Procházet zdrojové kódy

Merge pull request #55 from HiddenInvariant/master

Inventory fixes: equip ordering, dismantle payouts, Year-1 masterworks
stan před 3 týdny
rodič
revize
38faacfdd3
29 změnil soubory, kde provedl 1119 přidání a 61 odebrání
  1. 2 0
      Sunrise/Sunrise.vcxproj
  2. 15 13
      Sunrise/resources/default_settings.json
  3. 5 1
      Sunrise/src/client/content/items/packages/package_item_rows.cpp
  4. 1 1
      Sunrise/src/core/settings/settings.h
  5. 5 2
      Sunrise/src/core/settings/settings_upgrade.cpp
  6. 72 2
      Sunrise/src/core/settings/state/account_rows_parser.cpp
  7. 62 0
      Sunrise/src/middleware/content/packages/tables/item_definition_reader.cpp
  8. 9 0
      Sunrise/src/middleware/content/packages/tables/items.h
  9. 2 1
      Sunrise/src/middleware/datagen/family4/inventory/layout.h
  10. 13 3
      Sunrise/src/state/account/account_state.cpp
  11. 32 3
      Sunrise/src/state/account/account_state.h
  12. 6 1
      Sunrise/src/state/account/inventory/inventory_state.h
  13. 10 7
      Sunrise/src/state/build_data/cache/records/cache_record_codec.cpp
  14. 7 4
      Sunrise/src/state/build_data/cache/records/format.h
  15. 33 0
      Sunrise/src/state/build_data/items/item_catalog.h
  16. 3 0
      Sunrise/src/state/build_data/items/socket_plugs/definition.h
  17. 14 0
      Sunrise/src/state/build_data/items/socket_plugs/socket_plug_build_data_runtime.cpp
  18. 31 0
      Sunrise/src/state/build_data/items/socket_plugs/socket_plug_catalog.cpp
  19. 9 0
      Sunrise/src/state/build_data/items/socket_plugs/socket_plug_catalog.h
  20. 12 0
      Sunrise/src/state/build_data/runtime.h
  21. 3 0
      Sunrise/src/state/runtime/runtime.h
  22. 287 11
      Sunrise/src/state/runtime/state_account_dismantle_staging.cpp
  23. 2 1
      Sunrise/src/state/runtime/state_account_equipment_runtime.cpp
  24. 8 4
      Sunrise/src/state/runtime/state_account_item_action_runtime.cpp
  25. 27 0
      Sunrise/src/state/runtime/state_account_runtime.cpp
  26. 81 6
      Sunrise/src/state/runtime/state_account_socket_runtime.cpp
  27. 6 1
      Sunrise/src/state/runtime/state_account_transaction_helpers.h
  28. 267 0
      Sunrise/src/state/runtime/state_rolled_socket_plugs.cpp
  29. 95 0
      Sunrise/src/state/runtime/state_rolled_socket_plugs.h

+ 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" />
@@ -996,6 +997,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" />

+ 15 - 13
Sunrise/resources/default_settings.json

@@ -1,5 +1,5 @@
 {
-  "version": 6,
+  "version": 8,
   "core": {
     "logging": {
       "debugger_sink": true,
@@ -147,27 +147,29 @@
     "account": {
       "primary_soid": "0x9EAA300100100100",
       "dismantle_rewards": [
-        {
-          "definition_hash": "0x3CF2E8E2",
-          "quantity": 4
-        },
-        {
-          "definition_hash": "0x28D6AC07",
-          "quantity": 3
-        }
+        { "definition_hash": "0xBC53E66E", "quantity": 25, "rarity": "common" },
+        { "definition_hash": "0xBC53E66E", "quantity": 50, "rarity": "uncommon" },
+        { "definition_hash": "0xBC53E66E", "quantity": 100, "rarity": "rare" },
+        { "definition_hash": "0xBC53E66E", "quantity": 250, "rarity": "legendary" },
+        { "definition_hash": "0xBC53E66E", "quantity": 500, "rarity": "exotic" },
+        { "definition_hash": "0x3CF2E8E2", "quantity": 3, "rarity": "legendary" },
+        { "definition_hash": "0x3CF2E8E2", "quantity": 5, "rarity": "exotic" },
+        { "definition_hash": "0x28D6AC07", "quantity": 1, "rarity": "rare", "class": "weapon" },
+        { "definition_hash": "0x28D6AC07", "quantity": 3, "rarity": ["legendary", "exotic"], "class": "weapon" },
+        { "definition_hash": "0xE5B38AD2", "quantity": 3, "rarity": ["legendary", "exotic"], "masterworked": true }
       ],
       "profile_items": [
         {
           "definition_hash": "0xBC53E66E",
-          "quantity": 250000
+          "quantity": 100000
         },
         {
           "definition_hash": "0x3CF2E8E2",
-          "quantity": 999999
+          "quantity": 50000
         },
         {
           "definition_hash": "0xA7EE4765",
-          "quantity": 999999
+          "quantity": 50000
         },
         {
           "definition_hash": "0xB19439E5",
@@ -175,7 +177,7 @@
         },
         {
           "definition_hash": "0x28D6AC07",
-          "quantity": 9999
+          "quantity": 500
         },
         {
           "definition_hash": "0xE5B38AD2",

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

+ 1 - 1
Sunrise/src/core/settings/settings.h

@@ -18,7 +18,7 @@ namespace sunrise::core::settings {
  * Raise it when a key is renamed, removed, changes meaning, or must take a new default.
  * Adding a key needs no raise, because a missing key already takes its default.
  */
-inline constexpr std::uint32_t kSettingsVersion = 6;
+inline constexpr std::uint32_t kSettingsVersion = 8;
 
 /** Parsed read-only process settings. */
 struct Settings {

+ 5 - 2
Sunrise/src/core/settings/settings_upgrade.cpp

@@ -30,12 +30,15 @@ struct ReplacedMember {
  * Members replaced with the bundled default, each with the version that changed it.
  * A member is listed because its value form changed, or because its default changed.
  */
-constexpr std::array<ReplacedMember, 5> kReplacedMembers{{
+constexpr std::array<ReplacedMember, 6> kReplacedMembers{{
     {"\"key_bindings\"", 3},
     {"\"region_private\"", 5},
     {"\"topology\"", 5},
     {"\"characters\"", 5},
-    {"\"profile_items\"", 6},
+    {"\"profile_items\"", 7},
+    // Version 8 turned the flat payout list into rows filtered by rarity, gear class and
+    // masterwork state.
+    {"\"dismantle_rewards\"", 8},
 }};
 /** One splice per replaced member, plus the version member itself. */
 constexpr std::size_t kSpliceCapacity = kReplacedMembers.size() + 1;

+ 72 - 2
Sunrise/src/core/settings/state/account_rows_parser.cpp

@@ -1,5 +1,6 @@
 #include <limits>
 
+#include "../../../state/build_data/items/item_catalog.h"
 #include "../parser.h"
 
 namespace sunrise::core::settings::parser {
@@ -12,7 +13,37 @@ constexpr std::uint64_t kMaximumDestinationHash = (std::numeric_limits<std::uint
 
 } // namespace
 
-/** Parses the definition hashes and quantities credited by ordinary gear dismantles. */
+namespace {
+
+/** Sets the tier bit one rarity name stands for. */
+[[nodiscard]] bool dismantle_tier_bit(std::string_view name, std::uint8_t& mask) noexcept {
+    using Tier = state::build_data::items::Tier;
+    Tier tier = Tier::none;
+    if (name == "common") {
+        tier = Tier::common;
+    } else if (name == "uncommon") {
+        tier = Tier::uncommon;
+    } else if (name == "rare") {
+        tier = Tier::rare;
+    } else if (name == "legendary") {
+        tier = Tier::legendary;
+    } else if (name == "exotic") {
+        tier = Tier::exotic;
+    } else {
+        return false;
+    }
+    const std::uint8_t bit = static_cast<std::uint8_t>(1U << static_cast<unsigned>(tier));
+    if ((mask & bit) != 0) {
+        return false;
+    }
+    mask |= bit;
+    return true;
+}
+
+} // namespace
+
+/** Parses the materials credited by ordinary gear dismantles, with optional rarity/class filters.
+ */
 bool Parser::dismantle_rewards(state::AccountState& output) noexcept {
     output.dismantleRewards = {};
     output.dismantleRewardCount = 0;
@@ -49,6 +80,45 @@ bool Parser::dismantle_rewards(state::AccountState& output) noexcept {
                 }
                 reward.quantity = static_cast<std::int32_t>(value);
                 hasQuantity = true;
+            } else if (key == "rarity") {
+                // One name or an array of names; each sets its tier bit.
+                if (reward.tierMask != 0) {
+                    return false;
+                }
+                const bool list = consume('[');
+                for (;;) {
+                    std::string_view name;
+                    if (!string(name) || !dismantle_tier_bit(name, reward.tierMask)) {
+                        return false;
+                    }
+                    if (!list || consume(']')) {
+                        break;
+                    }
+                    if (!consume(',')) {
+                        return false;
+                    }
+                }
+            } else if (key == "class") {
+                std::string_view name;
+                if (reward.classMask != 0 || !string(name)) {
+                    return false;
+                }
+                if (name == "weapon") {
+                    reward.classMask = static_cast<std::uint8_t>(state::DismantleGearClass::weapon);
+                } else if (name == "armor") {
+                    reward.classMask = static_cast<std::uint8_t>(state::DismantleGearClass::armor);
+                } else {
+                    return false;
+                }
+            } else if (key == "masterworked") {
+                bool masterworked = false;
+                if (reward.masterwork != state::DismantleMasterworkFilter::any
+                    || !boolean(masterworked)) {
+                    return false;
+                }
+                reward.masterwork = masterworked
+                                        ? state::DismantleMasterworkFilter::masterworked
+                                        : state::DismantleMasterworkFilter::notMasterworked;
             } else if (!skip_value(0)) {
                 return false;
             }
@@ -60,7 +130,7 @@ bool Parser::dismantle_rewards(state::AccountState& output) noexcept {
             }
         }
         for (std::size_t index = 0; index < output.dismantleRewardCount; ++index) {
-            if (output.dismantleRewards[index].definitionHash == reward.definitionHash) {
+            if (state::same_dismantle_policy_key(output.dismantleRewards[index], reward)) {
                 return false;
             }
         }

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

+ 2 - 1
Sunrise/src/middleware/datagen/family4/inventory/layout.h

@@ -35,7 +35,8 @@ struct Entry {
     std::uint64_t instanceSoid{};
     /** Current stack quantity, set from the item definition for non-instanced rows. */
     std::int32_t quantity{};
-    /** Rising serial that keeps inventory mutation and eviction order. */
+    /** Rising serial that keeps inventory mutation and eviction order; the Client also orders a
+     *  bucket's grid cells by it. */
     std::int32_t mutationSerial{};
     /** Native item-state bits accumulated by inventory mutations. */
     std::uint32_t flags{};

+ 13 - 3
Sunrise/src/state/account/account_state.cpp

@@ -18,9 +18,16 @@ inline constexpr std::size_t kIdentityCapacity =
            && item.mutationSerial == 0;
 }
 
+/** Tier bits 1-5 are the native rarity ladder; bit 0 (no tier) is never a payout target. */
+constexpr std::uint8_t kDismantleTierMaskBits = 0b0011'1110U;
+constexpr std::uint8_t kDismantleClassMaskBits =
+    static_cast<std::uint8_t>(DismantleGearClass::weapon)
+    | static_cast<std::uint8_t>(DismantleGearClass::armor);
+
 /** @return True when one unused dismantle policy row is canonical zero. */
 [[nodiscard]] bool empty_dismantle_reward(const DismantleRewardPolicy& reward) noexcept {
-    return reward.definitionHash == 0 && reward.quantity == 0;
+    return reward.definitionHash == 0 && reward.quantity == 0 && reward.tierMask == 0
+           && reward.classMask == 0 && reward.masterwork == DismantleMasterworkFilter::any;
 }
 
 /** Checks filled policy rows, uniqueness, and the zero tail. */
@@ -36,11 +43,14 @@ inline constexpr std::size_t kIdentityCapacity =
             }
             continue;
         }
-        if (reward.definitionHash == inventory::kNoDefinitionHash || reward.quantity <= 0) {
+        if (reward.definitionHash == inventory::kNoDefinitionHash || reward.quantity <= 0
+            || (reward.tierMask & ~kDismantleTierMaskBits) != 0
+            || (reward.classMask & ~kDismantleClassMaskBits) != 0
+            || reward.masterwork > DismantleMasterworkFilter::notMasterworked) {
             return false;
         }
         for (std::size_t prior = 0; prior < index; ++prior) {
-            if (state.dismantleRewards[prior].definitionHash == reward.definitionHash) {
+            if (same_dismantle_policy_key(state.dismantleRewards[prior], reward)) {
                 return false;
             }
         }

+ 32 - 3
Sunrise/src/state/account/account_state.h

@@ -11,15 +11,44 @@ namespace sunrise::state {
 
 /** One account can own at most the 3 playable character slots. */
 inline constexpr std::size_t kCharacterCapacity = 3;
-/** A server-authored dismantle policy stays small but leaves room for build variants. */
-inline constexpr std::size_t kDismantleRewardPolicyCapacity = 8;
+/** A server-authored dismantle policy: a few rows per rarity and gear class. */
+inline constexpr std::size_t kDismantleRewardPolicyCapacity = 32;
 
-/** One profile material credited when ordinary character gear is dismantled. */
+/** Gear classes a dismantle payout row can be limited to. */
+enum class DismantleGearClass : std::uint8_t {
+    weapon = 1U << 0U,
+    armor = 1U << 1U,
+};
+
+/** Whether a payout row wants the dismantled item masterworked. */
+enum class DismantleMasterworkFilter : std::uint8_t {
+    any = 0,
+    masterworked = 1,
+    notMasterworked = 2,
+};
+
+/**
+ * One profile material credited when ordinary character gear is dismantled. Every filter left at
+ * its "any" value matches every item; the payout is the sum of the matching rows.
+ */
 struct DismantleRewardPolicy {
     std::uint32_t definitionHash{};
     std::int32_t quantity{};
+    /** Bit (1 << tier) per native tier 1-5 the row pays for; 0 pays for every tier. */
+    std::uint8_t tierMask{};
+    /** DismantleGearClass bits the row pays for; 0 pays for both. */
+    std::uint8_t classMask{};
+    DismantleMasterworkFilter masterwork{DismantleMasterworkFilter::any};
 };
 
+/** @return True when both rows are the same row: same material under the same filters. */
+[[nodiscard]] constexpr bool
+same_dismantle_policy_key(const DismantleRewardPolicy& left,
+                          const DismantleRewardPolicy& right) noexcept {
+    return left.definitionHash == right.definitionHash && left.tierMask == right.tierMask
+           && left.classMask == right.classMask && left.masterwork == right.masterwork;
+}
+
 /** Stable character race values authored independently of package definition mappings. */
 enum class CharacterRace : std::uint8_t {
     /** Wire value 0 is a Human character. */

+ 6 - 1
Sunrise/src/state/account/inventory/inventory_state.h

@@ -83,7 +83,12 @@ struct Item {
     std::uint32_t definitionHash{};
     std::int32_t level{};
     std::int32_t quantity{};
-    /** Rising per-character generation assigned whenever this item changes inventory rows. */
+    /**
+     * Rising per-character generation assigned whenever this item changes inventory rows. The
+     * Client also uses it as the stable ordering token inside a bucket's grid, so an item's cell
+     * follows its serial rather than its row index. Equip swaps therefore hand the displaced item
+     * the clicked item's prior serial so it takes the clicked cell.
+     */
     std::int32_t mutationSerial{};
     /** Native accumulated item-state bits such as the finisher favorite marker. */
     std::uint32_t flags{};

+ 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

@@ -29,7 +29,7 @@ inline constexpr std::array<char, 8> kCacheMagic{'S', 'U', 'N', 'R', 'I', 'S', '
  * Bump it when a stored shape changes, and when the extraction filling it changes what it writes.
  * A cached row survives a code change, so a corrected walk keeps publishing the old rows.
  */
-inline constexpr std::uint32_t kCacheFormatVersion = 43;
+inline constexpr std::uint32_t kCacheFormatVersion = 44;
 /** 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. */
@@ -107,12 +107,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. */
@@ -466,7 +469,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{};

+ 287 - 11
Sunrise/src/state/runtime/state_account_dismantle_staging.cpp

@@ -4,10 +4,12 @@
 
 #include <algorithm>
 #include <array>
+#include <bit>
 #include <cstddef>
 #include <cstdint>
 #include <cstdio>
 #include <limits>
+#include <optional>
 #include <string_view>
 #include <utility>
 
@@ -17,6 +19,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 {
@@ -27,9 +30,6 @@ namespace item_details = build_data::items::details;
 namespace inventory_buckets = build_data::inventory::buckets;
 namespace family4_loadout = middleware::datagen::family4::loadout;
 
-/** Equipment slots 0-2 are weapons and 3-7 are class-specific armor. */
-constexpr std::uint8_t kGearEquipmentSlotCount = 8;
-
 /** Writes one exhaustive item-dismantle transaction checkpoint. */
 void report_dismantle(std::string_view stage,
                       std::string_view result,
@@ -70,16 +70,234 @@ void report_dismantle(std::string_view stage,
     }
 }
 
+/** Records how the dismantled item was classified and how many payout materials matched. */
+void report_dismantle_reward_match(std::uint32_t definitionHash,
+                                   std::uint8_t tier,
+                                   std::uint8_t gearClass,
+                                   bool isMasterworked,
+                                   std::size_t materialCount) noexcept {
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count = std::snprintf(line.data(),
+                                    line.size(),
+                                    "ev=dismantle stage=reward result=ok reason=matched "
+                                    "definition_hash=0x%08X tier=%u gear_class=%u masterworked=%u "
+                                    "materials=%zu",
+                                    definitionHash,
+                                    static_cast<unsigned>(tier),
+                                    static_cast<unsigned>(gearClass),
+                                    isMasterworked ? 1U : 0U,
+                                    materialCount);
+    if (count > 0) {
+        core::log::write(core::log::Channel::state,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+}
+
+/** Records one payout row that could not be credited, so a silent zero payout is visible. */
+void report_dismantle_reward_dropped(std::string_view reason,
+                                     std::uint32_t definitionHash,
+                                     std::int32_t policyQuantity,
+                                     std::int32_t previousQuantity,
+                                     std::int32_t maxStackSize) noexcept {
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count = std::snprintf(line.data(),
+                                    line.size(),
+                                    "ev=dismantle stage=reward result=dropped reason=%.*s "
+                                    "definition_hash=0x%08X policy_quantity=%d held=%d "
+                                    "max_stack=%d",
+                                    static_cast<int>(reason.size()),
+                                    reason.data(),
+                                    definitionHash,
+                                    policyQuantity,
+                                    previousQuantity,
+                                    maxStackSize);
+    if (count > 0) {
+        core::log::write(core::log::Channel::state,
+                         core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+}
+
+/**
+ * @return The gear class one native equipment slot belongs to, or 0 outside the gear the
+ *         supported client pays materials for. Native slot numbers are not the semantic enum
+ *         order (kinetic is 7, energy 8, heavy 9), so the check goes through the semantic map.
+ */
+[[nodiscard]] std::uint8_t gear_class_of(std::uint8_t nativeSlot) noexcept {
+    using EquipmentSlot = authored_inventory::EquipmentSlot;
+    std::size_t semanticIndex = authored_inventory::kEquipmentSlotCount;
+    if (!semantic_equipment_slot(nativeSlot, semanticIndex)) {
+        return 0;
+    }
+    switch (static_cast<EquipmentSlot>(semanticIndex)) {
+    case EquipmentSlot::kinetic:
+    case EquipmentSlot::energy:
+    case EquipmentSlot::heavy:
+        return static_cast<std::uint8_t>(DismantleGearClass::weapon);
+    case EquipmentSlot::helmet:
+    case EquipmentSlot::gauntlets:
+    case EquipmentSlot::chest:
+    case EquipmentSlot::legs:
+    case EquipmentSlot::classItem:
+        return static_cast<std::uint8_t>(DismantleGearClass::armor);
+    default:
+        return 0;
+    }
+}
+
+/** Per-stat-row tally of one lane's pool, enough to recognise a masterwork tier ladder. */
+struct LadderTally {
+    static constexpr std::size_t kRowCount = 256;
+    static constexpr std::size_t kValueBits = 64;
+    std::array<std::uint16_t, kRowCount> members{};
+    std::array<std::int32_t, kRowCount> greatest{};
+    /** Bit per distinct value below kValueBits; a larger value marks the row unusable. */
+    std::array<std::uint64_t, kRowCount> seen{};
+    std::array<bool, kRowCount> overflow{};
+};
+
+/** Folds one pool member's stats into the tally. */
+bool tally_member(void* context, std::uint16_t plugIndex) noexcept {
+    auto& tally = *static_cast<LadderTally*>(context);
+    item_details::Definition detail{};
+    if (!build_data::find_configured_item_detail(plugIndex, detail)
+        || detail.statCount > detail.stats.size()) {
+        return true;
+    }
+    for (std::size_t index = 0; index < detail.statCount; ++index) {
+        const item_details::Stat& stat = detail.stats[index];
+        ++tally.members[stat.row];
+        tally.greatest[stat.row] = (std::max)(tally.greatest[stat.row], stat.value);
+        if (stat.value < 0 || stat.value >= static_cast<std::int32_t>(LadderTally::kValueBits)) {
+            tally.overflow[stat.row] = true;
+        } else {
+            tally.seen[stat.row] |= 1ULL << static_cast<unsigned>(stat.value);
+        }
+    }
+    return true;
+}
+
+/**
+ * @return True when the plug in one lane sits high enough on that lane's masterwork ladder.
+ *
+ * A masterwork lane's pool is a ladder: its tier plugs all carry the same stat row, each with
+ * a different value, one per tier. Any lane whose pool has such a row for the plug's stats is a
+ * ladder; a mod pool is not, because many mods repeat the same value. A weapon counts only at
+ * the top of its ladder; armor counts from halfway up, which is where the service started
+ * refunding materials.
+ */
+[[nodiscard]] bool on_masterwork_ladder(const item_details::Definition& target,
+                                        std::uint8_t lane,
+                                        const item_details::Definition& plug,
+                                        bool weapon) noexcept {
+    constexpr std::uint16_t kMinimumLadderRungs = 3;
+    if (plug.statCount == 0 || plug.statCount > plug.stats.size()) {
+        return false;
+    }
+    LadderTally tally{};
+    if (!build_data::visit_socket_plug_pool(target.definitionIndex, lane, &tally_member, &tally)) {
+        return false;
+    }
+    // The ladder row is the plug's stat row most of the pool shares.
+    std::size_t ladderRow = LadderTally::kRowCount;
+    for (std::size_t index = 0; index < plug.statCount; ++index) {
+        const std::uint8_t row = plug.stats[index].row;
+        if (ladderRow == LadderTally::kRowCount || tally.members[row] > tally.members[ladderRow]) {
+            ladderRow = row;
+        }
+    }
+    if (ladderRow >= LadderTally::kRowCount || tally.overflow[ladderRow]
+        || tally.members[ladderRow] < kMinimumLadderRungs
+        || std::popcount(tally.seen[ladderRow]) != tally.members[ladderRow]) {
+        return false;
+    }
+    std::int32_t value = 0;
+    for (std::size_t index = 0; index < plug.statCount; ++index) {
+        if (plug.stats[index].row == ladderRow) {
+            value = plug.stats[index].value;
+        }
+    }
+    const std::int32_t top = tally.greatest[ladderRow];
+    return weapon ? value == top : value * 2 >= top;
+}
+
+/**
+ * @return True when the item is masterworked for the payout: a lane holds a rolled result plug
+ *         (a Year-1 masterwork), or a plug high enough on its lane's tier ladder. An item still
+ *         on its native defaults is read through the definition's initial plugs, since
+ *         default-equipped gear can ship masterworked.
+ */
+[[nodiscard]] bool masterworked(const authored_inventory::Item& item,
+                                const item_details::Definition& detail,
+                                bool weapon) noexcept {
+    const bool authored = item.sockets.policy == authored_inventory::SocketPolicy::authored;
+    for (std::size_t lane = 0;
+         lane < detail.ordinarySocketCount && lane < authored_inventory::kPlugCapacity;
+         ++lane) {
+        build_data::items::Definition plug{};
+        if (authored) {
+            const std::optional<std::uint32_t>& hash = item.sockets.plugs[lane];
+            if (!hash.has_value() || !build_data::find_item_definition_hash(*hash, plug)
+                || plug.definitionHash != *hash) {
+                continue;
+            }
+        } else {
+            const std::uint16_t plugIndex = detail.initialPlugIndices[lane];
+            if (plugIndex == item_details::kUnavailableItemIndex
+                || !build_data::find_item_definition_index(plugIndex, plug)
+                || plug.definitionIndex != plugIndex) {
+                continue;
+            }
+        }
+        if (is_rolled_result(plug.definitionHash)) {
+            return true;
+        }
+        item_details::Definition plugDetail{};
+        if (build_data::find_configured_item_detail(plug.definitionIndex, plugDetail)
+            && plugDetail.definitionHash == plug.definitionHash
+            && on_masterwork_ladder(detail, static_cast<std::uint8_t>(lane), plugDetail, weapon)) {
+            return true;
+        }
+    }
+    return false;
+}
+
+/** @return True when one policy row pays for the dismantled item. */
+[[nodiscard]] bool policy_matches(const DismantleRewardPolicy& policy,
+                                  std::uint8_t tier,
+                                  std::uint8_t gearClass,
+                                  bool isMasterworked) noexcept {
+    if (policy.tierMask != 0 && (policy.tierMask & (1U << tier)) == 0) {
+        return false;
+    }
+    if (policy.classMask != 0 && (policy.classMask & gearClass) == 0) {
+        return false;
+    }
+    switch (policy.masterwork) {
+    case DismantleMasterworkFilter::masterworked:
+        return isMasterworked;
+    case DismantleMasterworkFilter::notMasterworked:
+        return !isMasterworked;
+    default:
+        return true;
+    }
+}
+
 /**
  * Credits the supported client's ordinary weapon/armor dismantle payout.
  *
- * Capped stacks
- * lose only the overflowing part, matching normal profile-inventory behavior.
- * Every credited row
- * receives a new mutation serial so the account observer can display it.
+ * Every policy row whose rarity, gear-class and masterwork filters match the dismantled item is
+ * summed per material first, so one material lands as one credited row. Capped stacks lose only
+ * the overflowing part, matching normal profile-inventory behavior; a stack already at its
+ * native cap drops that row's payout and says so in the log. Every credited row receives a new
+ * mutation serial so the account observer can display it.
  */
 [[nodiscard]] bool
 apply_dismantle_rewards(const AccountState& before,
+                        const authored_inventory::Item& dismantledItem,
+                        const build_data::items::Definition& dismantledDefinition,
+                        const item_details::Definition& dismantledDetail,
                         std::uint8_t equipmentSlot,
                         AccountState& after,
                         std::array<DismantleReward, kDismantleRewardCapacity>& rewards,
@@ -90,9 +308,44 @@ apply_dismantle_rewards(const AccountState& before,
     if (!valid_profile_inventory(before)) {
         return false;
     }
-    if (equipmentSlot >= kGearEquipmentSlotCount) {
+    const std::uint8_t gearClass = gear_class_of(equipmentSlot);
+    if (gearClass == 0) {
         return true;
     }
+    const std::uint8_t tier = dismantledDefinition.tier;
+    const bool isMasterworked =
+        masterworked(dismantledItem,
+                     dismantledDetail,
+                     gearClass == static_cast<std::uint8_t>(DismantleGearClass::weapon));
+
+    // Sum the matching rows per material before crediting anything.
+    std::array<DismantleRewardPolicy, kDismantleRewardPolicyCapacity> payout{};
+    std::size_t payoutCount = 0;
+    for (std::size_t policyIndex = 0; policyIndex < before.dismantleRewardCount; ++policyIndex) {
+        const DismantleRewardPolicy& policy = before.dismantleRewards[policyIndex];
+        if (!policy_matches(policy, tier, gearClass, isMasterworked)) {
+            continue;
+        }
+        std::size_t slot = payoutCount;
+        for (std::size_t index = 0; index < payoutCount; ++index) {
+            if (payout[index].definitionHash == policy.definitionHash) {
+                slot = index;
+                break;
+            }
+        }
+        if (slot == payoutCount) {
+            if (payoutCount >= payout.size()) {
+                return false;
+            }
+            payout[payoutCount++] = {policy.definitionHash, 0};
+        }
+        if (policy.quantity > (std::numeric_limits<std::int32_t>::max)() - payout[slot].quantity) {
+            return false;
+        }
+        payout[slot].quantity += policy.quantity;
+    }
+    report_dismantle_reward_match(
+        dismantledDefinition.definitionHash, tier, gearClass, isMasterworked, payoutCount);
 
     std::int32_t greatestMutationSerial = 0;
     for (std::size_t index = 0; index < before.profileItemCount; ++index) {
@@ -100,8 +353,8 @@ apply_dismantle_rewards(const AccountState& before,
             (std::max)(greatestMutationSerial, before.profileItems[index].mutationSerial);
     }
 
-    for (std::size_t policyIndex = 0; policyIndex < before.dismantleRewardCount; ++policyIndex) {
-        const DismantleRewardPolicy& policy = before.dismantleRewards[policyIndex];
+    for (std::size_t policyIndex = 0; policyIndex < payoutCount; ++policyIndex) {
+        const DismantleRewardPolicy& policy = payout[policyIndex];
         build_data::items::Definition definition{};
         item_details::Definition detail{};
         inventory_buckets::Descriptor bucket{};
@@ -139,6 +392,11 @@ apply_dismantle_rewards(const AccountState& before,
         const bool appended = profileIndex == after.profileItemCount;
         if ((appended && after.profileItemCount >= after.profileItems.size())
             || greatestMutationSerial == (std::numeric_limits<std::int32_t>::max)()) {
+            report_dismantle_reward_dropped(appended ? "profile_full" : "serial_exhausted",
+                                            policy.definitionHash,
+                                            policy.quantity,
+                                            0,
+                                            detail.maxStackSize);
             continue;
         }
         const std::int32_t previousQuantity =
@@ -146,6 +404,12 @@ apply_dismantle_rewards(const AccountState& before,
         const std::int32_t available = detail.maxStackSize - previousQuantity;
         const std::int32_t credited = (std::min)(policy.quantity, available);
         if (credited <= 0) {
+            // Every stack of this currency is already at its native cap.
+            report_dismantle_reward_dropped("stack_capped",
+                                            policy.definitionHash,
+                                            policy.quantity,
+                                            previousQuantity,
+                                            detail.maxStackSize);
             continue;
         }
 
@@ -162,6 +426,11 @@ apply_dismantle_rewards(const AccountState& before,
         }
         // A full native bucket drops this reward, but never blocks deletion of the source item.
         if (!account::valid(candidate) || !valid_profile_inventory(candidate)) {
+            report_dismantle_reward_dropped("bucket_full",
+                                            policy.definitionHash,
+                                            policy.quantity,
+                                            previousQuantity,
+                                            detail.maxStackSize);
             continue;
         }
         if (rewardCount >= rewards.size()) {
@@ -313,7 +582,14 @@ apply_dismantle_rewards(const AccountState& before,
     AccountState rewarded{};
     std::array<DismantleReward, kDismantleRewardCapacity> rewards{};
     std::size_t rewardCount = 0;
-    if (!apply_dismantle_rewards(candidate, dismantledSlot, rewarded, rewards, rewardCount)) {
+    if (!apply_dismantle_rewards(candidate,
+                                 dismantledItem,
+                                 dismantledDefinition,
+                                 dismantledDetail,
+                                 dismantledSlot,
+                                 rewarded,
+                                 rewards,
+                                 rewardCount)) {
         return false;
     }
     candidate = rewarded;

+ 2 - 1
Sunrise/src/state/runtime/state_account_equipment_runtime.cpp

@@ -161,7 +161,8 @@ namespace family4_loadout = middleware::datagen::family4::loadout;
  *
  * Every surviving instance must preserve its native bucket. A generation advances exactly when
  * its published native row or equipped marker changes, and a second resolution proves that the
- * stamped after-image retained the staged placement.
+ * stamped after-image retained the staged placement. Callers that need a moved item to keep an
+ * older grid cell (equip swaps) rewrite that item's serial afterwards and re-validate.
  */
 [[nodiscard]] bool
 finalize_equipment_transition(const AccountState& account,

+ 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

+ 27 - 0
Sunrise/src/state/runtime/state_account_runtime.cpp

@@ -256,6 +256,33 @@ bool prepare_equipment_swap(std::uint64_t requestedInstanceSoid,
         return false;
     }
 
+    if (previousInstanceSoid != 0) {
+        // The serial on an unequipped row is also the Client's stable ordering token for that
+        // bucket.  Giving the displaced item a fresh, greatest serial makes the Client rebuild it
+        // in the first grid cell even though the character object places it in the selected row.
+        // Transfer the selected row's prior token along with the row instead: the newly equipped
+        // item still has a fresh generation, while the displaced item occupies the grid cell the
+        // player clicked.
+        authored_inventory::Item& displaced = after.inventory.values[inventoryIndex];
+        if (displaced.instanceSoid != previousInstanceSoid) {
+            return false;
+        }
+        displaced.mutationSerial = requestedPosition.mutationSerial;
+
+        AccountState checkedAccount = account;
+        checkedAccount.characters[characterIndex] = after;
+        family4_loadout::ResolvedLoadout checkedLoadout{};
+        ResolvedPosition displacedPosition{};
+        if (!account::valid(checkedAccount)
+            || !family4_loadout::resolve(checkedAccount, characterIndex, checkedLoadout)
+            || !find_resolved_position(checkedLoadout, previousInstanceSoid, displacedPosition)
+            || displacedPosition.equipped || displacedPosition.equipmentSlot != requestedNativeSlot
+            || displacedPosition.inventoryRow != requestedPosition.inventoryRow
+            || displacedPosition.mutationSerial != requestedPosition.mutationSerial) {
+            return false;
+        }
+    }
+
     mutation.beforeCharacter = before;
     mutation.afterCharacter = after;
     mutation.characterSoid = before.soid;

+ 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