Quellcode durchsuchen

feat(dismantle): pay by rarity, gear class and masterwork state

Turn the flat dismantle_rewards list into rows with optional filters:
rarity (one name or a list), class (weapon or armor) and masterworked.
Rows without a filter still pay for everything; matching rows are summed
per material before crediting so one material lands as one credited row.
Rarity comes from the item tier the catalog now carries.

An item counts as masterworked when a lane holds a rolled result plug (a
Year-1 masterwork), or a plug high enough on its lane's masterwork ladder:
a lane is a ladder when its pool shares one stat row with one plug per
value, and the item counts at the top of the ladder for a weapon and from
halfway up for armor, which is where the service started refunding
materials. Mod pools repeat values and are not ladders. Default-equipped
gear is read through its definition's initial plugs, since it can ship
masterworked. Ship a Shadowkeep-era table in the defaults and bump the
settings layout to 8 so existing files take it. Log the classification per
dismantle.

Verified in-game across Red War, Forsaken and Shadowkeep gear.
Thomas Shields vor 3 Wochen
Ursprung
Commit
5dced037b1

+ 11 - 13
Sunrise/resources/default_settings.json

@@ -1,5 +1,5 @@
 {
-  "version": 7,
+  "version": 8,
   "core": {
     "logging": {
       "debugger_sink": true,
@@ -140,18 +140,16 @@
     "account": {
       "primary_soid": "0x9EAA300100100100",
       "dismantle_rewards": [
-        {
-          "definition_hash": "0xBC53E66E",
-          "quantity": 250
-        },
-        {
-          "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": [
         {

+ 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 = 7;
+inline constexpr std::uint32_t kSettingsVersion = 8;
 
 /** Parsed read-only process settings. */
 struct Settings {

+ 3 - 1
Sunrise/src/core/settings/settings_upgrade.cpp

@@ -36,7 +36,9 @@ constexpr std::array<ReplacedMember, 6> kReplacedMembers{{
     {"\"topology\"", 5},
     {"\"characters\"", 5},
     {"\"profile_items\"", 7},
-    {"\"dismantle_rewards\"", 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;
             }
         }

+ 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. */

+ 251 - 36
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,32 +30,6 @@ namespace item_details = build_data::items::details;
 namespace inventory_buckets = build_data::inventory::buckets;
 namespace family4_loadout = middleware::datagen::family4::loadout;
 
-/**
- * @return True when a native equipment slot holds weapons or class-specific armor, 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]] bool gear_equipment_slot(std::uint8_t nativeSlot) noexcept {
-    using EquipmentSlot = authored_inventory::EquipmentSlot;
-    std::size_t semanticIndex = authored_inventory::kEquipmentSlotCount;
-    if (!semantic_equipment_slot(nativeSlot, semanticIndex)) {
-        return false;
-    }
-    switch (static_cast<EquipmentSlot>(semanticIndex)) {
-    case EquipmentSlot::kinetic:
-    case EquipmentSlot::energy:
-    case EquipmentSlot::heavy:
-    case EquipmentSlot::helmet:
-    case EquipmentSlot::gauntlets:
-    case EquipmentSlot::chest:
-    case EquipmentSlot::legs:
-    case EquipmentSlot::classItem:
-        return true;
-    default:
-        return false;
-    }
-}
-
 /** Writes one exhaustive item-dismantle transaction checkpoint. */
 void report_dismantle(std::string_view stage,
                       std::string_view result,
@@ -93,6 +70,30 @@ 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,
@@ -118,15 +119,185 @@ void report_dismantle_reward_dropped(std::string_view reason,
     }
 }
 
+/**
+ * @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; 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.
+ * 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,
@@ -137,9 +308,44 @@ apply_dismantle_rewards(const AccountState& before,
     if (!valid_profile_inventory(before)) {
         return false;
     }
-    if (!gear_equipment_slot(equipmentSlot)) {
+    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) {
@@ -147,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{};
@@ -186,9 +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);
+            report_dismantle_reward_dropped(appended ? "profile_full" : "serial_exhausted",
+                                            policy.definitionHash,
+                                            policy.quantity,
+                                            0,
+                                            detail.maxStackSize);
             continue;
         }
         const std::int32_t previousQuantity =
@@ -374,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;