Sfoglia il codice sorgente

feat(sockets): answer rolled socket actions with a result plug from the installed roll set

Some sockets offer only action plugs: an apply and a re-roll with no
effect of their own. The live service answered them by socketing one plug
out of a set it rolled from, and those result plugs are never in the
socket's own pool. Year-1 weapon masterworks (Vanguard or Crucible apply
and re-roll, answered with one stat plug carrying e.g. "10 Reload Speed"
and the orbs-on-multikill perk) and Year-1 armor masterworks (apply and
re-roll, answered with a plug that stands for one of the piece's own stat
perks, which the service swapped into the perk lane) are the two families
this build carries. Sunrise socketed the action plug literally, so the
masterwork showed nothing and re-roll "applied itself".

Everything is derived from the installed item table at call time. The
reader locates the item definition's plug block by its class marker rather
than a fixed offset (an optional record ahead of it shifts it, which also
made the fixed-offset category read wrong for those plugs) and takes the
plug category, the ordinal of the server roll set that grants the plug,
any linked plug the definition stands for, and the item's rarity tier; the
item catalog and cache carry them (cache format 36). A requested plug that
is stat-less, pooled for its lane and whose category has out-of-pool
rolled results is an action: keep it for the pool check and the material
charge, but land a rolled result in the lane. Results with stats are
eligible when every stat they carry is one the target declares; results
naming a linked perk are grouped as authored (three per class and slot)
and are eligible when the target's pools offer one of the group's perks,
in which case the linked perk is written into that lane too; delegate-less
results are the class-item masterworks, one per class. A re-roll never
returns the current plug. The mutation records the requested plug apart
from the granted one, and re-staging pins the earlier roll so preview and
commit reproduce the same after-image. The socket-plug catalog gains a
pool walk for the group lookup.

Verified in-game: weapon apply and re-roll show a stat masterwork; armor
apply and re-roll change the intrinsic perk and the stats; class items
take their class masterwork.

Reported by stanuwu.
Thomas Shields 3 settimane fa
parent
commit
028dd1cf0a

+ 2 - 0
Sunrise/Sunrise.vcxproj

@@ -144,6 +144,7 @@
     <ClCompile Include="src\state\runtime\state_account_identity_runtime.cpp" />
     <ClCompile Include="src\state\runtime\state_account_profile_runtime.cpp" />
     <ClCompile Include="src\state\runtime\state_account_socket_runtime.cpp" />
+    <ClCompile Include="src\state\runtime\state_rolled_socket_plugs.cpp" />
     <ClCompile Include="src\state\runtime\state_account_item_action_runtime.cpp" />
     <ClCompile Include="src\core\ui\busy\ui_busy_overlay.cpp" />
     <ClCompile Include="src\core\ui\busy\ui_busy_state.cpp" />
@@ -884,6 +885,7 @@
     <ClInclude Include="src\state\unlocks\unlocks_runtime.h" />
     <ClInclude Include="src\state\runtime\runtime.h" />
     <ClInclude Include="src\state\runtime\state_account_transaction_helpers.h" />
+    <ClInclude Include="src\state\runtime\state_rolled_socket_plugs.h" />
     <ClInclude Include="src\state\runtime\state.h" />
     <ClInclude Include="src\state\runtime\storage\internal.h" />
     <ClInclude Include="src\state\activity\definition.h" />

+ 5 - 1
Sunrise/src/client/content/items/packages/package_item_rows.cpp

@@ -84,7 +84,11 @@ bool build_item_rows(const reader::Source& source,
                                                  item.definitionIndex,
                                                  item.bucketId,
                                                  item.insertionMaterialRequirementSetIndex,
-                                                 item.enabledMaterialRequirementSetIndex};
+                                                 item.enabledMaterialRequirementSetIndex,
+                                                 item.tier,
+                                                 item.plugCategoryHash,
+                                                 item.rollSetIndex,
+                                                 item.linkedPlugIndex};
         if (needSocketPlugs) {
             storage.specialPlugCategories[item.definitionIndex] =
                 special_plug_category(item.plugCategoryHash);

+ 62 - 0
Sunrise/src/middleware/content/packages/tables/item_definition_reader.cpp

@@ -11,6 +11,12 @@ namespace {
 constexpr std::size_t kMaxStackSizeOffset = 180;
 /** A nonzero predicate byte marks an instanced definition. */
 constexpr std::size_t kInstancedOffset = 187;
+/**
+ * Definition byte 186 is the item tier: 1 common, 2 uncommon, 3 rare, 4 legendary, 5 exotic, and
+ * 0 for rows outside the rarity ladder such as currencies. Confirmed against every installed
+ * legendary weapon and armor row and the currency rows.
+ */
+constexpr std::size_t kTierOffset = 186;
 /** The equipment block is self-relative from this offset, zero when absent. */
 constexpr std::size_t kEquipmentBlockOffset = 16;
 /** The equipment block stores its signed slot id here. */
@@ -28,6 +34,25 @@ constexpr std::size_t kSocketPlugOffset = 2;
 constexpr std::size_t kFixedFieldEnd = kInstancedOffset + 1;
 /** Optional plug category used to expand three native reusable plug families. */
 constexpr std::size_t kPlugCategoryOffset = 392;
+/**
+ * The plug block is an embedded record whose class marker precedes its fields. It usually starts
+ * at byte 388, so the category sits at kPlugCategoryOffset, but an optional record ahead of it
+ * moves it, so the block is located by its marker inside this window rather than assumed.
+ */
+constexpr std::uint32_t kPlugBlockClass = 0x808077E3U;
+constexpr std::size_t kPlugBlockSearchStart = 0x100;
+constexpr std::size_t kPlugBlockSearchEnd = 0x300;
+/** Category hash and server-roll set ordinal, relative to the plug block marker. */
+constexpr std::size_t kPlugBlockCategoryOffset = 4;
+constexpr std::size_t kPlugBlockRollSetOffset = 0x26;
+/**
+ * A plug that grants another plug's effect (a Year-1 armor masterwork result naming the stat
+ * perk it stands for) carries one linked-plug record; its item index sits at byte 12.
+ */
+constexpr std::uint32_t kLinkedPlugClass = 0x80803036U;
+constexpr std::size_t kLinkedPlugIndexOffset = 12;
+/** A roll-set ordinal outside the ladder: none, or the marker for a plug rolled by no set. */
+constexpr std::uint16_t kNoRollSet = 0;
 /** Embedded reusable-list array descriptor inside one 80-byte ordinary socket entry. */
 constexpr std::size_t kEmbeddedPlugListOffset = 64;
 /** Reusable and randomized shared plug-set row indices inside one socket entry. */
@@ -220,6 +245,41 @@ constexpr std::size_t kStatEntryValue = 20;
  * @param definition Whole item definition bytes.
  * @param row Receives the declared stat rows and values.
  */
+/** @return The offset of the first record of one class inside a window, or the blob size. */
+[[nodiscard]] std::size_t find_record(std::span<const std::byte> definition,
+                                      std::uint32_t recordClass,
+                                      std::size_t start,
+                                      std::size_t end) noexcept {
+    const std::size_t limit = (std::min)(end, definition.size());
+    for (std::size_t offset = start; offset + sizeof(std::uint32_t) <= limit;
+         offset += sizeof(std::uint32_t)) {
+        std::uint32_t marker = 0;
+        if (read(definition, offset, marker) && marker == recordClass) {
+            return offset;
+        }
+    }
+    return definition.size();
+}
+
+/**
+ * Reads the located plug block: its category (which corrects the fixed-offset read for a shifted
+ * block), the ordinal of the server roll set that grants the plug, and any linked plug.
+ */
+void read_plug_block(std::span<const std::byte> definition, Row& row) noexcept {
+    row.rollSetIndex = kNoRollSet;
+    row.linkedPlugIndex = kUnavailablePlug;
+    const std::size_t block =
+        find_record(definition, kPlugBlockClass, kPlugBlockSearchStart, kPlugBlockSearchEnd);
+    if (block < definition.size()) {
+        (void)read(definition, block + kPlugBlockCategoryOffset, row.plugCategoryHash);
+        (void)read(definition, block + kPlugBlockRollSetOffset, row.rollSetIndex);
+    }
+    const std::size_t linked = find_record(definition, kLinkedPlugClass, 0, definition.size());
+    if (linked < definition.size()) {
+        (void)read(definition, linked + kLinkedPlugIndexOffset, row.linkedPlugIndex);
+    }
+}
+
 void read_stats(std::span<const std::byte> definition, Row& row) noexcept {
     row.statCount = 0;
     std::int64_t blockRelative = 0;
@@ -273,12 +333,14 @@ bool read_definition(std::span<const std::byte> definition, Row& row) noexcept {
     std::uint8_t instanced = 0;
     if (!read(definition, kBucketIdOffset, row.bucketId)
         || !read(definition, kMaxStackSizeOffset, row.maxStackSize)
+        || !read(definition, kTierOffset, row.tier)
         || !read(definition, kInstancedOffset, instanced)) {
         return false;
     }
     row.instanced = instanced != 0;
     // Short legacy definitions simply do not declare a plug category.
     (void)read(definition, kPlugCategoryOffset, row.plugCategoryHash);
+    read_plug_block(definition, row);
     (void)read(definition,
                kInsertionMaterialRequirementSetIndexOffset,
                row.insertionMaterialRequirementSetIndex);

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

@@ -41,6 +41,8 @@ struct Row {
     std::uint32_t definitionHash{};
     std::uint16_t definitionIndex{};
     std::uint8_t bucketId{};
+    /** Native rarity ladder: 1 common through 5 exotic; 0 outside the ladder. */
+    std::uint8_t tier{};
     std::int32_t maxStackSize{};
     bool instanced{};
     std::optional<std::int8_t> equipmentSlot{};
@@ -53,6 +55,13 @@ struct Row {
     std::uint16_t socketTypes[kSocketCapacity]{};
     /** Plug category used by a few native sockets to expand a seed into its whole safe family. */
     std::uint32_t plugCategoryHash{};
+    /**
+     * Ordinal of the server roll set that grants this plug in place of a socket's action plug;
+     * 0 when the plug is socketed directly, 0xFFFF for a plug the service granted by other means.
+     */
+    std::uint16_t rollSetIndex{};
+    /** Item index of the plug this one stands for, or kUnavailablePlug when it stands alone. */
+    std::uint16_t linkedPlugIndex{kUnavailablePlug};
     /** Native material sets used when this definition is inserted or enabled as a plug. */
     std::uint16_t insertionMaterialRequirementSetIndex{kUnavailableMaterialRequirementSetIndex};
     std::uint16_t enabledMaterialRequirementSetIndex{kUnavailableMaterialRequirementSetIndex};

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

@@ -59,24 +59,27 @@ bool encode(const items::Definition& value, ItemRecord& record) noexcept {
         value.definitionHash,
         value.definitionIndex,
         value.bucketId,
-        kReservedFieldValue,
+        value.tier,
         value.insertionMaterialRequirementSetIndex,
         value.enabledMaterialRequirementSetIndex,
+        value.plugCategoryHash,
+        value.rollSetIndex,
+        value.linkedPlugIndex,
     };
     return true;
 }
 
-/** Decodes one installed-build item mapping after checking its padding. */
+/** Decodes one installed-build item mapping. */
 bool decode(const ItemRecord& record, items::Definition& value) noexcept {
-    value = {};
-    if (record.reserved != kReservedFieldValue) {
-        return false;
-    }
     value = {record.definitionHash,
              record.definitionIndex,
              record.bucketId,
              record.insertionMaterialRequirementSetIndex,
-             record.enabledMaterialRequirementSetIndex};
+             record.enabledMaterialRequirementSetIndex,
+             record.tier,
+             record.plugCategoryHash,
+             record.rollSetIndex,
+             record.linkedPlugIndex};
     return true;
 }
 

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

@@ -28,7 +28,7 @@ inline constexpr std::array<char, 8> kCacheMagic{'S', 'U', 'N', 'R', 'I', 'S', '
  * Current build-data cache format. An older cache is rebuilt rather than read, so a bump needs
  * no other edit. Bump it whenever a domain's stored shape changes.
  */
-inline constexpr std::uint32_t kCacheFormatVersion = 35;
+inline constexpr std::uint32_t kCacheFormatVersion = 36;
 /** Signed -1 on disk means there is no equipment slot. */
 inline constexpr std::int8_t kAbsentEquipmentSlot = -1;
 /** The standard 64-bit FNV-1a offset basis starts the payload checksum. */
@@ -106,12 +106,15 @@ struct ItemRecord {
     std::uint32_t definitionHash{};
     std::uint16_t definitionIndex{};
     std::uint8_t bucketId{items::kUnresolvedBucketId};
-    /** Must be zero, so the packed item row matches across compilers. */
-    std::uint8_t reserved{};
+    /** Native rarity ladder byte; 0 outside the ladder. */
+    std::uint8_t tier{};
     std::uint16_t insertionMaterialRequirementSetIndex{
         items::kUnavailableMaterialRequirementSetIndex};
     std::uint16_t enabledMaterialRequirementSetIndex{
         items::kUnavailableMaterialRequirementSetIndex};
+    std::uint32_t plugCategoryHash{};
+    std::uint16_t rollSetIndex{};
+    std::uint16_t linkedPlugIndex{items::kUnavailableLinkedPlugIndex};
 };
 
 /** Disk form of one material charged by a native Collections acquisition. */
@@ -449,7 +452,7 @@ static_assert(sizeof(NamedRecord)
               == content::kDefinitionNameCapacity + 2 * sizeof(std::uint16_t)
                      + 2 * sizeof(std::uint32_t));
 static_assert(sizeof(ItemRecord)
-              == sizeof(std::uint32_t) + 3 * 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(MaterialRequirementRecord)
               == sizeof(std::uint32_t) + 2 * sizeof(std::uint16_t) + 2 * sizeof(std::uint8_t));
 static_assert(sizeof(CollectibleRecord)

+ 33 - 0
Sunrise/src/state/build_data/items/item_catalog.h

@@ -12,6 +12,8 @@ inline constexpr std::size_t kDefinitionCapacity = 32768;
 inline constexpr std::uint8_t kUnresolvedBucketId = 0xFF;
 /** A plug with no authored insertion/enabled price carries all set-index bits. */
 inline constexpr std::uint16_t kUnavailableMaterialRequirementSetIndex = 0xFFFFU;
+/** An item-index field naming no row carries all bits set. */
+inline constexpr std::uint16_t kUnavailableLinkedPlugIndex = 0xFFFFU;
 
 /** One installed-build item identity, used to look up authored definition hashes. */
 struct Definition {
@@ -20,6 +22,37 @@ struct Definition {
     std::uint8_t bucketId{kUnresolvedBucketId};
     std::uint16_t insertionMaterialRequirementSetIndex{kUnavailableMaterialRequirementSetIndex};
     std::uint16_t enabledMaterialRequirementSetIndex{kUnavailableMaterialRequirementSetIndex};
+    /** Native rarity ladder: 1 common through 5 exotic; 0 outside the ladder. */
+    std::uint8_t tier{};
+    /** Plug category the definition declares, or 0 when it declares none. */
+    std::uint32_t plugCategoryHash{};
+    /**
+     * Ordinal of the server roll set that grants this plug in place of a socket's action plug;
+     * kNoRollSet when the plug is socketed directly, kForeignRollSet when the service granted it
+     * by other means.
+     */
+    std::uint16_t rollSetIndex{};
+    /** Item index of the plug this one stands for, or kUnavailableLinkedPlugIndex when it stands
+     * alone. */
+    std::uint16_t linkedPlugIndex{kUnavailableLinkedPlugIndex};
+};
+
+/** Roll-set ordinals outside the rolled ladder. */
+inline constexpr std::uint16_t kNoRollSet = 0;
+inline constexpr std::uint16_t kForeignRollSet = 0xFFFFU;
+/** @return True when a definition is a plug the service rolled from a socket action. */
+[[nodiscard]] constexpr bool rolled_result(const Definition& definition) noexcept {
+    return definition.rollSetIndex != kNoRollSet && definition.rollSetIndex != kForeignRollSet;
+}
+
+/** Native item tiers, as the definition's rarity byte encodes them. */
+enum class Tier : std::uint8_t {
+    none = 0,
+    common = 1,
+    uncommon = 2,
+    rare = 3,
+    legendary = 4,
+    exotic = 5,
 };
 
 /** Clears every generated item mapping. */

+ 3 - 0
Sunrise/src/state/build_data/items/socket_plugs/definition.h

@@ -36,4 +36,7 @@ struct Pool {
 /** Native item-definition index of one allowed plug. */
 using Member = std::uint16_t;
 
+/** Called once per pool member; returning false stops the walk. */
+using MemberVisitor = bool (*)(void* context, Member plugDefinitionIndex) noexcept;
+
 } // namespace sunrise::state::build_data::items::socket_plugs

+ 14 - 0
Sunrise/src/state/build_data/items/socket_plugs/socket_plug_build_data_runtime.cpp

@@ -65,6 +65,20 @@ bool is_socket_plug_allowed(std::uint16_t itemDefinitionIndex,
            && items::socket_plugs::allowed(itemDefinitionIndex, lane, plugDefinitionIndex);
 }
 
+/** Walks one lane's pool once the whole relation is in State. */
+bool visit_socket_plug_pool(std::uint16_t itemDefinitionIndex,
+                            std::uint8_t lane,
+                            items::socket_plugs::MemberVisitor visitor,
+                            void* context) noexcept {
+    return socket_plug_rules_ready()
+           && items::socket_plugs::visit_pool(itemDefinitionIndex, lane, visitor, context);
+}
+
+/** Answers pool membership anywhere in the installed relation. */
+bool is_socket_plug_pooled(std::uint16_t plugDefinitionIndex) noexcept {
+    return socket_plug_rules_ready() && items::socket_plugs::contains(plugDefinitionIndex);
+}
+
 /**
  * Answers whether applying one plug spends a stack the account has to hold.
  *

+ 31 - 0
Sunrise/src/state/build_data/items/socket_plugs/socket_plug_catalog.cpp

@@ -112,6 +112,37 @@ bool allowed(std::uint16_t itemDefinitionIndex,
     return std::binary_search(range.begin(), range.end(), plugDefinitionIndex);
 }
 
+/** Walks the members of one lane's pool under a shared hold. */
+bool visit_pool(std::uint16_t itemDefinitionIndex,
+                std::uint8_t lane,
+                MemberVisitor visitor,
+                void* context) noexcept {
+    if (lane >= kLaneCapacity || visitor == nullptr) {
+        return false;
+    }
+    const Lock::Shared guard(g_lock);
+    const auto rules = g_rules.rows();
+    const auto pools = g_pools.rows();
+    const auto members = g_members.rows();
+    const Rule key{itemDefinitionIndex, lane, 0, 0};
+    const auto found = std::lower_bound(rules.begin(), rules.end(), key, rule_less);
+    if (found == rules.end() || found->itemDefinitionIndex != itemDefinitionIndex
+        || found->lane != lane || found->poolIndex >= pools.size()) {
+        return false;
+    }
+    const Pool& pool = pools[found->poolIndex];
+    if (pool.memberOffset > members.size()
+        || pool.memberCount > members.size() - pool.memberOffset) {
+        return false;
+    }
+    for (const Member member : members.subspan(pool.memberOffset, pool.memberCount)) {
+        if (!visitor(context, member)) {
+            return false;
+        }
+    }
+    return true;
+}
+
 /** Answers whether one definition occurs in any installed ordinary-socket plug pool. */
 bool contains(Member plugDefinitionIndex) noexcept {
     const Lock::Shared guard(g_lock);

+ 9 - 0
Sunrise/src/state/build_data/items/socket_plugs/socket_plug_catalog.h

@@ -41,6 +41,15 @@ void clear() noexcept;
  */
 [[nodiscard]] bool contains(Member plugDefinitionIndex) noexcept;
 
+/**
+ * Walks every plug one exact ordinary socket lane accepts.
+ * @return True when the lane has a pool and the visitor saw every member.
+ */
+[[nodiscard]] bool visit_pool(std::uint16_t itemDefinitionIndex,
+                              std::uint8_t lane,
+                              MemberVisitor visitor,
+                              void* context) noexcept;
+
 /** Copies the complete relation while holding its single shared lock. */
 [[nodiscard]] bool snapshot(std::span<Rule> rules,
                             std::size_t& ruleCount,

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

@@ -179,6 +179,18 @@ publish_socket_plug_rules(std::span<const items::socket_plugs::Rule> rules,
                                           std::uint8_t lane,
                                           std::uint16_t plugDefinitionIndex) noexcept;
 
+/**
+ * Walks every plug one exact ordinary socket lane accepts. Missing relations fail closed.
+ * @return True when the lane has a pool and the visitor saw every member.
+ */
+[[nodiscard]] bool visit_socket_plug_pool(std::uint16_t itemDefinitionIndex,
+                                          std::uint8_t lane,
+                                          items::socket_plugs::MemberVisitor visitor,
+                                          void* context) noexcept;
+
+/** @return True when one plug definition occurs in any installed ordinary-socket plug pool. */
+[[nodiscard]] bool is_socket_plug_pooled(std::uint16_t plugDefinitionIndex) noexcept;
+
 /**
  * Answers whether one profile definition needs an item-instance resident so the native socket
  * action route can materialize it. Only stackable installed socket plugs in the supported mod and

+ 3 - 0
Sunrise/src/state/runtime/runtime.h

@@ -163,7 +163,10 @@ struct PendingSocketPlug {
     /** Equipment semantic index or dense inventory index, selected by `targetEquipped`. */
     std::size_t itemIndex{};
     std::uint16_t targetDefinitionIndex{};
+    /** Plug that lands in the lane. Differs from the request only for a rolled socket. */
     std::uint16_t plugDefinitionIndex{};
+    /** Plug the Client asked for, which decides the pool check and the material charge. */
+    std::uint16_t requestedPlugDefinitionIndex{};
     std::uint16_t materialRequirementSetIndex{0xFFFFU};
     std::uint8_t socketLane{};
     std::uint8_t targetBucketId{};

+ 8 - 4
Sunrise/src/state/runtime/state_account_item_action_runtime.cpp

@@ -223,8 +223,9 @@ bool preview_socket_plug(const PendingSocketPlug& mutation, AccountState& after)
                            mutation.characterIndex,
                            mutation.targetInstanceSoid,
                            mutation.socketLane,
-                           mutation.plugDefinitionIndex,
-                           canonical)
+                           mutation.requestedPlugDefinitionIndex,
+                           canonical,
+                           mutation.plugDefinitionHash)
         || canonical.accountSoid != mutation.accountSoid
         || canonical.characterSoid != mutation.characterSoid
         || canonical.targetDefinitionHash != mutation.targetDefinitionHash
@@ -236,6 +237,7 @@ bool preview_socket_plug(const PendingSocketPlug& mutation, AccountState& after)
         || canonical.itemIndex != mutation.itemIndex
         || canonical.targetDefinitionIndex != mutation.targetDefinitionIndex
         || canonical.plugDefinitionIndex != mutation.plugDefinitionIndex
+        || canonical.requestedPlugDefinitionIndex != mutation.requestedPlugDefinitionIndex
         || canonical.materialRequirementSetIndex != mutation.materialRequirementSetIndex
         || canonical.socketLane != mutation.socketLane
         || canonical.targetBucketId != mutation.targetBucketId
@@ -327,8 +329,9 @@ bool commit_socket_plug(PendingSocketPlug& mutation) noexcept {
                            prepared.characterIndex,
                            prepared.targetInstanceSoid,
                            prepared.socketLane,
-                           prepared.plugDefinitionIndex,
-                           canonical)
+                           prepared.requestedPlugDefinitionIndex,
+                           canonical,
+                           prepared.plugDefinitionHash)
         || canonical.characterSoid != prepared.characterSoid
         || canonical.accountSoid != prepared.accountSoid
         || canonical.targetDefinitionHash != prepared.targetDefinitionHash
@@ -340,6 +343,7 @@ bool commit_socket_plug(PendingSocketPlug& mutation) noexcept {
         || canonical.itemIndex != prepared.itemIndex
         || canonical.targetDefinitionIndex != prepared.targetDefinitionIndex
         || canonical.plugDefinitionIndex != prepared.plugDefinitionIndex
+        || canonical.requestedPlugDefinitionIndex != prepared.requestedPlugDefinitionIndex
         || canonical.materialRequirementSetIndex != prepared.materialRequirementSetIndex
         || canonical.socketLane != prepared.socketLane
         || canonical.targetBucketId != prepared.targetBucketId

+ 81 - 6
Sunrise/src/state/runtime/state_account_socket_runtime.cpp

@@ -17,6 +17,7 @@
 #include "runtime.h"
 #include "state.h"
 #include "state_account_transaction_helpers.h"
+#include "state_rolled_socket_plugs.h"
 #include "storage/internal.h"
 
 namespace sunrise::state {
@@ -101,7 +102,8 @@ void report_socket_plug(std::string_view stage,
                                      std::uint64_t targetInstanceSoid,
                                      std::uint8_t socketLane,
                                      std::uint16_t plugDefinitionIndex,
-                                     PendingSocketPlug& mutation) noexcept {
+                                     PendingSocketPlug& mutation,
+                                     std::uint32_t pinnedPlugHash) noexcept {
     mutation = {};
     CharacterItemLocation location{};
     build_data::items::Definition targetDefinition{};
@@ -212,7 +214,78 @@ void report_socket_plug(std::string_view stage,
         && *authoredSockets.plugs[socketLane] == plugDefinition.definitionHash) {
         return fail("already_applied");
     }
-    authoredSockets.plugs[socketLane] = plugDefinition.definitionHash;
+
+    // A rolled socket's apply or re-roll plug is an action, not a plug: the service answered it
+    // by socketing a result plug from the socket's roll set. The requested plug still decides the
+    // pool check and the material charge above; only the plug that lands in the lane changes.
+    build_data::items::Definition grantedDefinition = plugDefinition;
+    if (classify_rolled_plug(plugDefinition, targetDefinition, socketLane)
+        == RolledPlugAction::roll) {
+        const std::uint32_t currentPlugHash = authoredSockets.plugs[socketLane].value_or(0);
+        const bool reroll = is_rolled_result(currentPlugHash);
+        // A re-staging must land on the plug the first staging rolled, so the pinned roll is
+        // taken as long as it is still one the fresh roll could have produced.
+        RolledPlug rolled{};
+        if (pinnedPlugHash != 0) {
+            if (pinnedPlugHash == currentPlugHash
+                || !pin_rolled_plug(pinnedPlugHash, detail, rolled)) {
+                return fail("rolled_plug_pin");
+            }
+        } else {
+            const std::uint64_t seed = targetInstanceSoid
+                                       ^ (static_cast<std::uint64_t>(GetTickCount64()) << 8U)
+                                       ^ static_cast<std::uint64_t>(before.nextInventorySerial);
+            if (!roll_socket_plug(plugDefinition,
+                                  detail,
+                                  static_cast<std::uint8_t>(before.characterClass),
+                                  currentPlugHash,
+                                  seed,
+                                  rolled)) {
+                return fail("rolled_plug_roll");
+            }
+        }
+        if (!build_data::find_item_definition_hash(rolled.plugHash, grantedDefinition)
+            || grantedDefinition.definitionHash != rolled.plugHash) {
+            return fail("rolled_plug_roll");
+        }
+        // A result that stands for another plug re-rolls that plug's lane as well: the service
+        // swapped the piece's stat perk to the one the result names, which is what moves the
+        // stats.
+        if (rolled.linkedPerkHash != 0) {
+            build_data::items::Definition linkedDefinition{};
+            if (rolled.linkedLane == socketLane || rolled.linkedLane >= detail.ordinarySocketCount
+                || !build_data::find_item_definition_hash(rolled.linkedPerkHash, linkedDefinition)
+                || linkedDefinition.definitionHash != rolled.linkedPerkHash) {
+                return fail("rolled_plug_link");
+            }
+            authoredSockets.plugs[rolled.linkedLane] = rolled.linkedPerkHash;
+            report_socket_plug("rolled_plug_link",
+                               "ok",
+                               reroll ? "reroll" : "apply",
+                               before.soid,
+                               targetInstanceSoid,
+                               targetDefinition.definitionIndex,
+                               rolled.linkedLane,
+                               linkedDefinition.definitionIndex,
+                               targetDefinition.bucketId,
+                               linkedDefinition.bucketId,
+                               location.equipped,
+                               location.index);
+        }
+        report_socket_plug("rolled_plug",
+                           "ok",
+                           reroll ? "reroll" : "apply",
+                           before.soid,
+                           targetInstanceSoid,
+                           targetDefinition.definitionIndex,
+                           socketLane,
+                           grantedDefinition.definitionIndex,
+                           targetDefinition.bucketId,
+                           grantedDefinition.bucketId,
+                           location.equipped,
+                           location.index);
+    }
+    authoredSockets.plugs[socketLane] = grantedDefinition.definitionHash;
 
     CharacterState after = before;
     authored_inventory::Item* changed = character_item_at(after, location);
@@ -259,7 +332,8 @@ void report_socket_plug(std::string_view stage,
         || resolvedTarget->instance.ordinarySockets.state
                != middleware::datagen::family4::instance::OrdinarySocketBlockState::present
         || !resolvedTarget->instance.ordinarySockets.plugs[socketLane].has_value()
-        || *resolvedTarget->instance.ordinarySockets.plugs[socketLane] != plugDefinitionIndex) {
+        || *resolvedTarget->instance.ordinarySockets.plugs[socketLane]
+               != grantedDefinition.definitionIndex) {
         return fail("after_socket");
     }
 
@@ -271,18 +345,19 @@ void report_socket_plug(std::string_view stage,
     mutation.characterSoid = before.soid;
     mutation.targetInstanceSoid = targetInstanceSoid;
     mutation.targetDefinitionHash = targetDefinition.definitionHash;
-    mutation.plugDefinitionHash = plugDefinition.definitionHash;
+    mutation.plugDefinitionHash = grantedDefinition.definitionHash;
     mutation.materialRequirementSetHash = materialSet.requirementSetHash;
     mutation.characterIndex = characterIndex;
     mutation.expectedProfileItemCount = snapshot.profileItemCount;
     mutation.afterProfileItemCount = chargedAccount.profileItemCount;
     mutation.itemIndex = location.index;
     mutation.targetDefinitionIndex = targetDefinition.definitionIndex;
-    mutation.plugDefinitionIndex = plugDefinitionIndex;
+    mutation.plugDefinitionIndex = grantedDefinition.definitionIndex;
+    mutation.requestedPlugDefinitionIndex = plugDefinitionIndex;
     mutation.materialRequirementSetIndex = materialSetIndex;
     mutation.socketLane = socketLane;
     mutation.targetBucketId = targetDefinition.bucketId;
-    mutation.plugBucketId = plugDefinition.bucketId;
+    mutation.plugBucketId = grantedDefinition.bucketId;
     mutation.materialRequirementCount = materialSet.requirementCount;
     mutation.profileChanged = profileChanged;
     mutation.targetEquipped = location.equipped;

+ 6 - 1
Sunrise/src/state/runtime/state_account_transaction_helpers.h

@@ -133,12 +133,17 @@ find_resolved_position(const middleware::datagen::family4::loadout::ResolvedLoad
     CharacterState& after,
     std::size_t& movedItemCount) noexcept;
 [[nodiscard]] bool same_character(const CharacterState& left, const CharacterState& right) noexcept;
+/**
+ * @param pinnedPlugHash For a rolled socket's apply or re-roll, the result plug an earlier
+ *        staging rolled, so a re-staging reproduces the same after-image; 0 rolls afresh.
+ */
 [[nodiscard]] bool stage_socket_plug(const AccountState& snapshot,
                                      std::size_t characterIndex,
                                      std::uint64_t targetInstanceSoid,
                                      std::uint8_t socketLane,
                                      std::uint16_t plugDefinitionIndex,
-                                     PendingSocketPlug& mutation) noexcept;
+                                     PendingSocketPlug& mutation,
+                                     std::uint32_t pinnedPlugHash = 0) noexcept;
 [[nodiscard]] bool stage_item_state(const AccountState& snapshot,
                                     std::size_t characterIndex,
                                     std::uint64_t targetInstanceSoid,

+ 267 - 0
Sunrise/src/state/runtime/state_rolled_socket_plugs.cpp

@@ -0,0 +1,267 @@
+/** Rolled socket plugs: answers a socket's action plug with a result plug from its roll set. */
+
+#include "state_rolled_socket_plugs.h"
+
+#include <algorithm>
+#include <array>
+
+#include "../build_data/runtime.h"
+
+namespace sunrise::state::runtime::detail {
+namespace {
+
+namespace item_details = build_data::items::details;
+namespace items = build_data::items;
+
+/** Result plugs of one category, in installed-table order. */
+struct ResultSet {
+    static constexpr std::size_t kCapacity = 64;
+    std::array<items::Definition, kCapacity> results{};
+    std::size_t count{};
+    /** True when at least one result stands for another plug. */
+    bool linking{};
+};
+
+/** One splitmix64 step, enough spread for a cosmetic roll. */
+[[nodiscard]] std::uint64_t mix(std::uint64_t value) noexcept {
+    value += 0x9E3779B97F4A7C15ULL;
+    value = (value ^ (value >> 30U)) * 0xBF58476D1CE4E5B9ULL;
+    value = (value ^ (value >> 27U)) * 0x94D049BB133111EBULL;
+    return value ^ (value >> 31U);
+}
+
+/** @return The installed detail of one definition, or false when it is not configured. */
+[[nodiscard]] bool detail_of(const items::Definition& definition,
+                             item_details::Definition& detail) noexcept {
+    return build_data::find_configured_item_detail(definition.definitionIndex, detail)
+           && detail.definitionHash == definition.definitionHash;
+}
+
+/**
+ * Collects every rolled result plug sharing one category. Results are never in a socket pool;
+ * one that is would be an ordinary plug and is left out.
+ */
+[[nodiscard]] bool collect_results(std::uint32_t plugCategoryHash, ResultSet& set) noexcept {
+    set = {};
+    if (plugCategoryHash == 0) {
+        return false;
+    }
+    const std::size_t count = build_data::item_definition_count();
+    for (std::size_t index = 0; index < count && index <= 0xFFFFU; ++index) {
+        items::Definition definition{};
+        if (!build_data::find_item_definition_index(static_cast<std::uint16_t>(index), definition)
+            || definition.plugCategoryHash != plugCategoryHash || !items::rolled_result(definition)
+            || build_data::is_socket_plug_pooled(definition.definitionIndex)) {
+            continue;
+        }
+        if (set.count >= set.results.size()) {
+            return false;
+        }
+        if (definition.linkedPlugIndex != items::kUnavailableLinkedPlugIndex) {
+            set.linking = true;
+        }
+        set.results[set.count++] = definition;
+    }
+    return set.count != 0;
+}
+
+/** @return True when every stat the plug contributes is one the target declares. */
+[[nodiscard]] bool plug_fits_target(const item_details::Definition& plug,
+                                    const item_details::Definition& target) noexcept {
+    if (plug.statCount == 0 || plug.statCount > plug.stats.size()
+        || target.statCount > target.stats.size()) {
+        return false;
+    }
+    for (std::size_t index = 0; index < plug.statCount; ++index) {
+        const std::uint8_t row = plug.stats[index].row;
+        const bool declared =
+            std::any_of(target.stats.begin(),
+                        target.stats.begin() + static_cast<std::ptrdiff_t>(target.statCount),
+                        [row](const item_details::Stat& stat) { return stat.row == row; });
+        if (!declared) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** @return True when one of the target's socket pools offers this plug, naming the lane. */
+[[nodiscard]] bool target_offers(const item_details::Definition& target,
+                                 std::uint16_t plugIndex,
+                                 std::uint8_t& lane) noexcept {
+    for (std::uint8_t candidate = 0; candidate < target.ordinarySocketCount; ++candidate) {
+        if (build_data::is_socket_plug_allowed(target.definitionIndex, candidate, plugIndex)) {
+            lane = candidate;
+            return true;
+        }
+    }
+    return false;
+}
+
+/**
+ * Linked results are authored in groups of three consecutive rows, one group per class and
+ * slot, each row standing for a different stat focus. Delegate-less rows sit between groups
+ * and are skipped, so the group of a linked result is its ordinal among linked results / 3.
+ */
+constexpr std::size_t kLinkedGroupSize = 3;
+
+/** @return The ordinal of one result among the linked results of its set, or the count. */
+[[nodiscard]] std::size_t linked_ordinal(const ResultSet& set, std::size_t resultIndex) noexcept {
+    std::size_t ordinal = 0;
+    for (std::size_t index = 0; index < set.count; ++index) {
+        if (set.results[index].linkedPlugIndex == items::kUnavailableLinkedPlugIndex) {
+            continue;
+        }
+        if (index == resultIndex) {
+            return ordinal;
+        }
+        ++ordinal;
+    }
+    return set.count;
+}
+
+/** @return The lane on the target offering any linked perk of one result's group, if any. */
+[[nodiscard]] bool group_lane(const ResultSet& set,
+                              std::size_t resultIndex,
+                              const item_details::Definition& target,
+                              std::uint8_t& lane) noexcept {
+    const std::size_t ordinal = linked_ordinal(set, resultIndex);
+    if (ordinal >= set.count) {
+        return false;
+    }
+    const std::size_t group = ordinal / kLinkedGroupSize;
+    for (std::size_t index = 0; index < set.count; ++index) {
+        const items::Definition& other = set.results[index];
+        if (other.linkedPlugIndex == items::kUnavailableLinkedPlugIndex
+            || linked_ordinal(set, index) / kLinkedGroupSize != group) {
+            continue;
+        }
+        if (target_offers(target, other.linkedPlugIndex, lane)) {
+            return true;
+        }
+    }
+    return false;
+}
+
+/** Fills the outcome's linked perk from one result, when the result stands for one. */
+[[nodiscard]] bool
+resolve_link(const items::Definition& result, std::uint8_t lane, RolledPlug& rolled) noexcept {
+    rolled.linkedLane = lane;
+    rolled.linkedPerkHash = 0;
+    if (result.linkedPlugIndex == items::kUnavailableLinkedPlugIndex) {
+        return true;
+    }
+    items::Definition perk{};
+    if (!build_data::find_item_definition_index(result.linkedPlugIndex, perk)
+        || perk.definitionIndex != result.linkedPlugIndex) {
+        return false;
+    }
+    rolled.linkedPerkHash = perk.definitionHash;
+    return true;
+}
+
+} // namespace
+
+RolledPlugAction classify_rolled_plug(const items::Definition& requested,
+                                      const items::Definition& target,
+                                      std::uint8_t lane) noexcept {
+    item_details::Definition detail{};
+    if (requested.plugCategoryHash == 0 || items::rolled_result(requested)
+        || !detail_of(requested, detail) || detail.statCount != 0
+        || !build_data::is_socket_plug_allowed(
+            target.definitionIndex, lane, requested.definitionIndex)) {
+        return RolledPlugAction::none;
+    }
+    ResultSet set{};
+    return collect_results(requested.plugCategoryHash, set) ? RolledPlugAction::roll
+                                                            : RolledPlugAction::none;
+}
+
+bool is_rolled_result(std::uint32_t plugHash) noexcept {
+    items::Definition definition{};
+    return plugHash != 0 && build_data::find_item_definition_hash(plugHash, definition)
+           && definition.definitionHash == plugHash && items::rolled_result(definition);
+}
+
+bool roll_socket_plug(const items::Definition& requested,
+                      const item_details::Definition& target,
+                      std::uint8_t characterClass,
+                      std::uint32_t currentPlugHash,
+                      std::uint64_t seed,
+                      RolledPlug& rolled) noexcept {
+    rolled = {};
+    ResultSet set{};
+    if (!collect_results(requested.plugCategoryHash, set)) {
+        return false;
+    }
+
+    std::array<std::size_t, ResultSet::kCapacity> pick{};
+    std::array<std::uint8_t, ResultSet::kCapacity> pickLanes{};
+    std::size_t pickCount = 0;
+    // A delegate-less, stat-less result is a class-item masterwork; the k-th one in table order
+    // belongs to the k-th class. It only counts when no linked group fits the piece.
+    std::size_t classItemResult = set.count;
+    std::size_t delegateless = 0;
+    for (std::size_t index = 0; index < set.count; ++index) {
+        const items::Definition& result = set.results[index];
+        std::uint8_t lane = 0;
+        if (result.linkedPlugIndex != items::kUnavailableLinkedPlugIndex) {
+            if (result.definitionHash != currentPlugHash && group_lane(set, index, target, lane)) {
+                pick[pickCount] = index;
+                pickLanes[pickCount++] = lane;
+            }
+            continue;
+        }
+        item_details::Definition detail{};
+        if (detail_of(result, detail) && detail.statCount != 0) {
+            if (result.definitionHash != currentPlugHash && plug_fits_target(detail, target)) {
+                pick[pickCount] = index;
+                pickLanes[pickCount++] = 0;
+            }
+            continue;
+        }
+        if (set.linking && delegateless++ == characterClass
+            && result.definitionHash != currentPlugHash) {
+            classItemResult = index;
+        }
+    }
+    if (pickCount == 0 && classItemResult < set.count) {
+        pick[pickCount] = classItemResult;
+        pickLanes[pickCount++] = 0;
+    }
+    if (pickCount == 0) {
+        return false;
+    }
+    const std::size_t chosen = static_cast<std::size_t>(mix(seed) % pickCount);
+    const items::Definition& result = set.results[pick[chosen]];
+    rolled.plugHash = result.definitionHash;
+    return resolve_link(result, pickLanes[chosen], rolled);
+}
+
+bool pin_rolled_plug(std::uint32_t plugHash,
+                     const item_details::Definition& target,
+                     RolledPlug& rolled) noexcept {
+    rolled = {};
+    items::Definition result{};
+    if (!build_data::find_item_definition_hash(plugHash, result)
+        || result.definitionHash != plugHash || !items::rolled_result(result)) {
+        return false;
+    }
+    rolled.plugHash = plugHash;
+    if (result.linkedPlugIndex == items::kUnavailableLinkedPlugIndex) {
+        return true;
+    }
+    ResultSet set{};
+    if (!collect_results(result.plugCategoryHash, set)) {
+        return false;
+    }
+    std::uint8_t lane = 0;
+    for (std::size_t index = 0; index < set.count; ++index) {
+        if (set.results[index].definitionHash == plugHash) {
+            return group_lane(set, index, target, lane) && resolve_link(result, lane, rolled);
+        }
+    }
+    return false;
+}
+
+} // namespace sunrise::state::runtime::detail

+ 95 - 0
Sunrise/src/state/runtime/state_rolled_socket_plugs.h

@@ -0,0 +1,95 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+
+#include "../build_data/items/details/definition.h"
+#include "../build_data/items/item_catalog.h"
+
+namespace sunrise::state::runtime::detail {
+
+/**
+ * Some sockets offer only action plugs: an "apply" and a "re-roll" that carry no effect of their
+ * own. The live service answered them by socketing one plug out of a set it rolled from, and
+ * those result plugs are never in the socket's own pool. The installed data still marks them:
+ * every result plug names the roll set that grants it, and shares its plug category with the
+ * action plugs of the same socket. Year-1 weapon masterworks (one stat bonus each) and Year-1
+ * armor masterworks (each standing for one of the piece's own stat perks) are the two families
+ * this build carries.
+ *
+ * Everything below is derived from the installed item table at call time; nothing names a
+ * particular plug.
+ */
+
+/** How one requested plug should be socketed. */
+enum class RolledPlugAction : std::uint8_t {
+    /** An ordinary plug; socket it as asked. */
+    none,
+    /** An action plug of a rolled socket; roll a result plug and socket that instead. */
+    roll,
+};
+
+/**
+ * Classifies one requested plug for one target lane.
+ * @param requested Installed definition of the plug the Client asked to socket.
+ * @param target Installed definition of the item being socketed.
+ * @param lane Ordinary socket lane the request names.
+ * @return roll when the plug is stat-less, pooled for that lane, and its category has result
+ *         plugs the pool does not offer.
+ */
+[[nodiscard]] RolledPlugAction classify_rolled_plug(const build_data::items::Definition& requested,
+                                                    const build_data::items::Definition& target,
+                                                    std::uint8_t lane) noexcept;
+
+/**
+ * @return True when a plug already in a lane is a rolled result of any set, which is what a
+ *         re-roll replaces and what marks the item as masterworked.
+ */
+[[nodiscard]] bool is_rolled_result(std::uint32_t plugHash) noexcept;
+
+/** One roll outcome: the result plug and, for a delegating result, the perk it stands for. */
+struct RolledPlug {
+    std::uint32_t plugHash{};
+    /** Lane whose pool offers the linked perk; only meaningful when linkedPerkHash is nonzero. */
+    std::uint8_t linkedLane{};
+    /** Stat perk the result stands for, which the service swapped into linkedLane; 0 for none. */
+    std::uint32_t linkedPerkHash{};
+};
+
+/**
+ * Rolls one result plug for a target item.
+ *
+ * Results that carry stats (weapon masterworks) are eligible when every stat they contribute
+ * is one the target declares. Results that stand for another plug (armor masterworks) are
+ * eligible when the target's socket pools offer one of the perks of their group, a group being
+ * the results authored together for one class and slot; a target offering none takes the
+ * delegate-less result for the character's class. The current plug is never rolled again, so a
+ * re-roll changes something whenever more than one result is eligible.
+ * @param requested The action plug, which names the roll set through its category.
+ * @param target Installed detail of the item being socketed.
+ * @param characterClass Wire class of the owning character.
+ * @param currentPlugHash Plug already in the lane, or 0.
+ * @param seed Any per-request entropy.
+ * @param rolled Receives the outcome.
+ * @return True when at least one eligible result existed.
+ */
+[[nodiscard]] bool roll_socket_plug(const build_data::items::Definition& requested,
+                                    const build_data::items::details::Definition& target,
+                                    std::uint8_t characterClass,
+                                    std::uint32_t currentPlugHash,
+                                    std::uint64_t seed,
+                                    RolledPlug& rolled) noexcept;
+
+/**
+ * Re-derives the linked perk for a result an earlier staging rolled, so a re-staging lands on
+ * the same after-image.
+ * @param plugHash Result plug the earlier staging chose.
+ * @param target Installed detail of the item being socketed.
+ * @param rolled Receives the plug and its linked perk, if it has one.
+ * @return True when the plug is a rolled result the target could have rolled.
+ */
+[[nodiscard]] bool pin_rolled_plug(std::uint32_t plugHash,
+                                   const build_data::items::details::Definition& target,
+                                   RolledPlug& rolled) noexcept;
+
+} // namespace sunrise::state::runtime::detail