Pārlūkot izejas kodu

feat(inventory): return materials from gear dismantles

Thomas Shields 3 nedēļas atpakaļ
vecāks
revīzija
226436c1b9

+ 2 - 0
Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp

@@ -295,8 +295,10 @@ bool process(const ServiceRoute& route,
             // item-instance release descriptor; otherwise retain the generic sentinel reply and
             // publish no removal.
             if (!queuez::stage_item_dismantle(queuezState,
+                                              webOutcome.itemDismantle.accountSoid,
                                               webOutcome.itemDismantle.characterSoid,
                                               webOutcome.itemDismantle.dismantledInstanceSoid,
+                                              webOutcome.itemDismantle.profileChanged,
                                               outcome.itemDismantleUpdate)) {
                 core::log::write(core::log::Channel::server,
                                  core::log::Level::warn,

+ 9 - 2
Sunrise/src/server/bap/encrypted/push/queuez/queuez_select_character.cpp

@@ -255,7 +255,7 @@ bool append_profile_item_acquisition_notification(
     return true;
 }
 
-/** Appends one atomic character-upsert-and-instance-release Family-4 dismantle update. */
+/** Appends one atomic dismantle update, including any account-wide material payout. */
 bool append_item_dismantle_notification(Scratch& scratch,
                                         const queuez::ItemDismantle& dismantle,
                                         const state::PendingItemDismantle& mutation,
@@ -269,12 +269,19 @@ bool append_item_dismantle_notification(Scratch& scratch,
     }
     const std::size_t objectCount = prepared.family.objects.size();
     const std::size_t beforeBytes = written;
-    if (objectCount != 2 || prepared.family.objects[0].id != dismantle.characterDefinitionId
+    const std::size_t expectedObjectCount = dismantle.updatesAccount ? 3U : 2U;
+    if (objectCount != expectedObjectCount
+        || prepared.family.objects[0].id != dismantle.characterDefinitionId
         || prepared.family.objects[0].version != dismantle.characterSoid
         || prepared.family.objects[1].id != dismantle.itemInstanceDefinitionId
         || prepared.family.objects[1].version != dismantle.dismantledInstanceSoid
         || prepared.family.objects[1].encoding != middleware::queuez::Encoding::oodle
         || !prepared.family.objects[1].payload.empty()
+        || (dismantle.updatesAccount
+            && (prepared.family.objects[2].id != dismantle.accountDefinitionId
+                || prepared.family.objects[2].version != dismantle.accountSoid
+                || prepared.family.objects[2].encoding != middleware::queuez::Encoding::oodle
+                || prepared.family.objects[2].payload.empty()))
         || !queuez_frame::append(scratch,
                                  prepared.family,
                                  prepared.rawClearSize,

+ 96 - 6
Sunrise/src/server/bap/encrypted/push/snapshot/family4_selection_move.cpp

@@ -930,7 +930,7 @@ bool prepare_item_acquisition(Scratch& scratch,
     return true;
 }
 
-/** Builds a character upsert followed by one empty item-instance release descriptor. */
+/** Builds character removal, instance release, and any profile-reward pickup descriptors. */
 bool prepare_item_dismantle(Scratch& scratch,
                             const queuez::ItemDismantle& dismantle,
                             const state::PendingItemDismantle& mutation,
@@ -941,16 +941,23 @@ bool prepare_item_dismantle(Scratch& scratch,
         return report_failure("dismantle_reservation");
     }
 
-    state::AccountState account = state::account_snapshot();
+    state::AccountState account{};
     if (!mutation.prepared || mutation.characterSoid == 0 || mutation.dismantledInstanceSoid == 0
         || mutation.dismantledItem.instanceSoid != mutation.dismantledInstanceSoid
+        || mutation.accountSoid != dismantle.accountSoid
         || mutation.characterSoid != dismantle.characterSoid
         || mutation.dismantledInstanceSoid != dismantle.dismantledInstanceSoid
+        || mutation.profileChanged != dismantle.updatesAccount
+        || mutation.rewardCount > state::kDismantleRewardCapacity
+        || mutation.profileChanged != (mutation.rewardCount != 0)
+        || dismantle.accountDefinitionId == 0 || dismantle.characterDefinitionId == 0
+        || dismantle.itemInstanceDefinitionId == 0
+        || !state::preview_item_dismantle(mutation, account)
         || mutation.characterIndex >= account.characterCount
+        || account.primarySoid != dismantle.accountSoid
         || account.characters[mutation.characterIndex].soid != mutation.characterSoid) {
         return report_failure("dismantle_mutation");
     }
-    account.characters[mutation.characterIndex] = mutation.afterCharacter;
 
     Resolved selected{};
     const std::optional<std::size_t> selectedIndex = find_character_index(account);
@@ -1001,13 +1008,91 @@ bool prepare_item_dismantle(Scratch& scratch,
         middleware::queuez::Encoding::oodle,
         {},
     };
+
+    std::size_t objectCount = 2;
+    if (dismantle.updatesAccount) {
+        if (family4_datagen::account::layout::kObjectSize > rawStorage.size()) {
+            clear_after(scratch, reservation);
+            return report_failure("dismantle_account_storage");
+        }
+        const auto accountBytes = rawStorage.first(family4_datagen::account::layout::kObjectSize);
+        if (!family4_datagen::account::encode(account, accountBytes)) {
+            clear_after(scratch, reservation);
+            return report_failure("dismantle_account_encode");
+        }
+
+        auto& accountObject =
+            *reinterpret_cast<family4_datagen::account::layout::Object*>(accountBytes.data());
+        const auto recordIsZero =
+            [](const family4_datagen::account::layout::ProfileInventoryChangeRecord& record)
+            noexcept {
+                return record.sequence == 0 && record.reserved == 0
+                       && record.mutationSerial == 0 && record.kind == 0
+                       && record.reservedKind == 0 && record.flags == 0;
+            };
+        if (accountObject.profileInventoryChanges.writeSlot != 0
+            || accountObject.profileInventoryChanges.nextSequence != 0
+            || !std::all_of(accountObject.profileInventoryChanges.records.cbegin(),
+                            accountObject.profileInventoryChanges.records.cend(),
+                            recordIsZero)
+            || mutation.rewardCount == 0
+            || mutation.rewardCount
+                   > accountObject.profileInventoryChanges.records.size()) {
+            clear_after(scratch, reservation);
+            return report_failure("dismantle_account_change_state");
+        }
+
+        constexpr std::uint8_t kRewardChangeKind = 1;
+        constexpr std::uint16_t kRewardChangeFlags = 0;
+        for (std::size_t rewardIndex = 0; rewardIndex < mutation.rewardCount; ++rewardIndex) {
+            const state::DismantleReward& reward = mutation.rewards[rewardIndex];
+            std::size_t matchedRows = 0;
+            for (const auto& row : accountObject.profileItems) {
+                if (row.mutationSerial != reward.mutationSerial) {
+                    continue;
+                }
+                if (row.quantity != reward.afterQuantity) {
+                    clear_after(scratch, reservation);
+                    return report_failure("dismantle_reward_quantity");
+                }
+                ++matchedRows;
+            }
+            if (matchedRows != 1) {
+                clear_after(scratch, reservation);
+                return report_failure("dismantle_reward_row");
+            }
+            auto& change = accountObject.profileInventoryChanges.records[rewardIndex];
+            change.sequence = static_cast<std::uint16_t>(rewardIndex);
+            change.mutationSerial = reward.mutationSerial;
+            change.kind = kRewardChangeKind;
+            change.flags = kRewardChangeFlags;
+        }
+        accountObject.profileInventoryChanges.writeSlot =
+            static_cast<std::uint16_t>(mutation.rewardCount);
+        accountObject.profileInventoryChanges.nextSequence =
+            static_cast<std::uint16_t>(mutation.rewardCount);
+
+        if (!append_object(scratch,
+                           accountBytes,
+                           dismantle.accountDefinitionId,
+                           dismantle.accountSoid,
+                           staged.objects[2],
+                           compressedExtent)) {
+            clear_after(scratch, reservation);
+            return report_failure("dismantle_account_object");
+        }
+        staged.rawClearSize =
+            (std::max)(staged.rawClearSize,
+                       reservation.rawWriteOffset + family4_datagen::account::layout::kObjectSize);
+        objectCount = 3;
+    }
     staged.compressedClearSize = (std::max)(reservation.compressedClearSize, compressedExtent);
     staged.family = middleware::queuez::Family{
         kAccountFamilyType,
         dismantle.after.family4RootSoid,
         dismantle.after.family4Version,
         0,
-        std::span(staged.objects).first(2),
+        std::span(staged.objects).first(objectCount),
     };
     if (!commit(staged, prepared)) {
         clear_after(scratch, reservation);
@@ -1021,7 +1106,8 @@ bool prepare_item_dismantle(Scratch& scratch,
                       "ev=dismantle stage=family4_objects result=ok family_version=%d root=0x%llX "
                       "character=0x%llX character_definition=%u instance=0x%llX item_definition=%u "
                       "definition_hash=%u inventory_index=%zu inventory_row=%u equipment_slot=%u "
-                      "moved_items=%zu items_after=%zu next_serial=%u",
+                      "moved_items=%zu items_after=%zu next_serial=%u rewards=%zu objects=%zu "
+                      "order=%s",
                       dismantle.after.family4Version,
                       static_cast<unsigned long long>(dismantle.after.family4RootSoid),
                       static_cast<unsigned long long>(dismantle.characterSoid),
@@ -1034,7 +1120,11 @@ bool prepare_item_dismantle(Scratch& scratch,
                       static_cast<unsigned>(mutation.equipmentSlot),
                       mutation.movedInventoryItemCount,
                       mutation.afterCharacter.inventory.count,
-                      mutation.afterCharacter.nextInventorySerial);
+                      mutation.afterCharacter.nextInventorySerial,
+                      mutation.rewardCount,
+                      objectCount,
+                      dismantle.updatesAccount ? "character_release_account"
+                                               : "character_release");
     if (count > 0) {
         core::log::write(core::log::Channel::server,
                          core::log::Level::debug,

+ 4 - 0
Sunrise/src/server/bap/encrypted/queuez/definition.h

@@ -158,10 +158,14 @@ struct ProfileItemAcquisition {
 /** Validated item-dismantle after-image for one character upsert and one instance release. */
 struct ItemDismantle {
     SessionState after{};
+    std::uint32_t accountDefinitionId{};
     std::uint32_t characterDefinitionId{};
     std::uint32_t itemInstanceDefinitionId{};
+    std::uint64_t accountSoid{};
     std::uint64_t characterSoid{};
     std::uint64_t dismantledInstanceSoid{};
+    /** True when the same revision also publishes credited profile materials. */
+    bool updatesAccount{};
 };
 
 /** Queuez fields published after every staged frame is copied to caller output. */

+ 5 - 0
Sunrise/src/server/bap/encrypted/queuez/queuez_outcome_staging.cpp

@@ -323,8 +323,13 @@ bool stage_service_outcome(Scratch& scratch,
                             && afterIndex == dismantle.after.family4ResidentCount;
 
         if (!valid(dismantle.after) || !compactedManifest
+            || dismantle.accountSoid != outcome.itemDismantle.accountSoid
             || dismantle.characterSoid != outcome.itemDismantle.characterSoid
             || dismantle.dismantledInstanceSoid != outcome.itemDismantle.dismantledInstanceSoid
+            || dismantle.updatesAccount != outcome.itemDismantle.profileChanged
+            || dismantle.accountSoid != before.family4RootSoid
+            || before.family4ResidentCount == 0
+            || dismantle.accountDefinitionId != before.family4Residents.front().definitionId
             || dismantle.after.family4RootSoid != before.family4RootSoid
             || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
             || dismantle.after.family4Version != before.family4Version + 1

+ 2 - 0
Sunrise/src/server/bap/encrypted/queuez/queuez_state_validation.h

@@ -181,8 +181,10 @@ namespace sunrise::server::bap::encrypted::queuez {
  * when both schemas and both named residents exist exactly once.
  */
 [[nodiscard]] bool stage_item_dismantle(const SessionState& before,
+                                        std::uint64_t accountSoid,
                                         std::uint64_t characterSoid,
                                         std::uint64_t dismantledInstanceSoid,
+                                        bool updatesAccount,
                                         ItemDismantle& dismantle) noexcept;
 
 /** Clears state for the active root. Zero or another root leaves the state unchanged. */

+ 21 - 4
Sunrise/src/server/bap/encrypted/queuez/staging/queuez_character_staging.cpp

@@ -477,16 +477,22 @@ bool stage_profile_item_acquisition(const SessionState& before,
 
 /** Stages the character upsert and resident release required by one item dismantle. */
 bool stage_item_dismantle(const SessionState& before,
+                          std::uint64_t accountSoid,
                           std::uint64_t characterSoid,
                           std::uint64_t dismantledInstanceSoid,
+                          bool updatesAccount,
                           ItemDismantle& dismantle) noexcept {
     dismantle = {};
+    std::uint32_t accountDefinitionId = 0;
     std::uint32_t characterDefinitionId = 0;
     std::uint32_t itemInstanceDefinitionId = 0;
-    if (!valid(before) || !before.family4Active || before.family4RootSoid == 0 || characterSoid == 0
+    if (!valid(before) || !before.family4Active || before.family4RootSoid == 0 || accountSoid == 0
+        || accountSoid != before.family4RootSoid || characterSoid == 0
         || dismantledInstanceSoid == 0 || before.family4ResidentCount == 0
         || before.family4ResidentCount > before.family4Residents.size()
         || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
+        || !middleware::datagen::object_id(
+            kAccountFamilyType, middleware::datagen::kAccountSlot, accountDefinitionId)
         || !middleware::datagen::object_id(
             kAccountFamilyType, middleware::datagen::kCharacterSlot, characterDefinitionId)
         || !middleware::datagen::object_id(
@@ -494,10 +500,14 @@ bool stage_item_dismantle(const SessionState& before,
         return false;
     }
 
+    bool accountResident = false;
     bool characterResident = false;
     std::size_t dismantledResidentIndex = before.family4Residents.size();
     for (std::size_t index = 0; index < before.family4ResidentCount; ++index) {
         const ResidentObject& object = before.family4Residents[index];
+        accountResident =
+            accountResident
+            || (object.definitionId == accountDefinitionId && object.objectSoid == accountSoid);
         characterResident =
             characterResident
             || (object.definitionId == characterDefinitionId && object.objectSoid == characterSoid);
@@ -510,7 +520,8 @@ bool stage_item_dismantle(const SessionState& before,
         }
         dismantledResidentIndex = index;
     }
-    if (!characterResident || dismantledResidentIndex >= before.family4ResidentCount) {
+    if (!accountResident || !characterResident
+        || dismantledResidentIndex >= before.family4ResidentCount) {
         return false;
     }
 
@@ -522,10 +533,13 @@ bool stage_item_dismantle(const SessionState& before,
     }
     --dismantle.after.family4ResidentCount;
     dismantle.after.family4Residents[dismantle.after.family4ResidentCount] = {};
+    dismantle.accountDefinitionId = accountDefinitionId;
     dismantle.characterDefinitionId = characterDefinitionId;
     dismantle.itemInstanceDefinitionId = itemInstanceDefinitionId;
+    dismantle.accountSoid = accountSoid;
     dismantle.characterSoid = characterSoid;
     dismantle.dismantledInstanceSoid = dismantledInstanceSoid;
+    dismantle.updatesAccount = updatesAccount;
     const bool staged = valid(dismantle.after);
 
     std::array<char, core::log::kLineCapacity> line{};
@@ -534,7 +548,8 @@ bool stage_item_dismantle(const SessionState& before,
                       line.size(),
                       "ev=dismantle stage=queuez_version result=%s root=0x%llX before=%d after=%d "
                       "residents_before=%u residents_after=%u resident_index=%zu character=0x%llX "
-                      "instance=0x%llX character_definition=%u item_definition=%u",
+                      "instance=0x%llX account_definition=%u character_definition=%u "
+                      "item_definition=%u account_update=%u",
                       staged ? "ok" : "fail",
                       static_cast<unsigned long long>(before.family4RootSoid),
                       before.family4Version,
@@ -544,8 +559,10 @@ bool stage_item_dismantle(const SessionState& before,
                       dismantledResidentIndex,
                       static_cast<unsigned long long>(characterSoid),
                       static_cast<unsigned long long>(dismantledInstanceSoid),
+                      accountDefinitionId,
                       characterDefinitionId,
-                      itemInstanceDefinitionId);
+                      itemInstanceDefinitionId,
+                      static_cast<unsigned>(updatesAccount));
     if (count > 0) {
         core::log::write(core::log::Channel::server,
                          staged ? core::log::Level::debug : core::log::Level::warn,

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

@@ -98,21 +98,44 @@ struct PendingProfileItemAcquisition {
     bool prepared{};
 };
 
+/** One profile material actually credited by a prepared dismantle. */
+struct DismantleReward {
+    std::uint32_t definitionHash{};
+    std::size_t profileIndex{};
+    std::int32_t quantity{};
+    std::int32_t afterQuantity{};
+    std::int32_t mutationSerial{};
+};
+
+/** Dismantles currently publish the ordinary gear material pair in one native pickup bank. */
+inline constexpr std::size_t kDismantleRewardCapacity = 2;
+
 /** Prepared selected-character inventory removal kept private until its reply and push fit. */
 struct PendingItemDismantle {
     /** Exact prepare-time character view used as the commit staleness guard. */
     CharacterState beforeCharacter{};
     /** Canonical dense inventory after-image, including row-change mutation generations. */
     CharacterState afterCharacter{};
+    /** Exact profile material view observed before and after applying the dismantle payout. */
+    std::array<account::inventory::ProfileItem, account::inventory::kProfileItemCapacity>
+        beforeProfileItems{};
+    std::array<account::inventory::ProfileItem, account::inventory::kProfileItemCapacity>
+        afterProfileItems{};
+    std::array<DismantleReward, kDismantleRewardCapacity> rewards{};
     account::inventory::Item dismantledItem{};
+    std::uint64_t accountSoid{};
     std::uint64_t characterSoid{};
     std::uint64_t dismantledInstanceSoid{};
     std::size_t characterIndex{};
     std::size_t expectedInventoryCount{};
+    std::size_t expectedProfileItemCount{};
+    std::size_t afterProfileItemCount{};
     std::size_t inventoryIndex{};
     std::size_t movedInventoryItemCount{};
+    std::size_t rewardCount{};
     std::uint16_t inventoryRow{};
     std::uint8_t equipmentSlot{};
+    bool profileChanged{};
     bool prepared{};
 };
 
@@ -333,6 +356,10 @@ commit_profile_item_acquisition(PendingProfileItemAcquisition& mutation) noexcep
 [[nodiscard]] bool prepare_item_dismantle(std::uint64_t instanceSoid,
                                           PendingItemDismantle& mutation) noexcept;
 
+/** Builds the exact account after-image while a prepared dismantle remains current. */
+[[nodiscard]] bool preview_item_dismantle(const PendingItemDismantle& mutation,
+                                          AccountState& after) noexcept;
+
 /**
  * Commits a prepared inventory removal only while the complete prepare-time character view is
  * unchanged.

+ 322 - 49
Sunrise/src/state/runtime/state_account_runtime.cpp

@@ -27,6 +27,26 @@ namespace family4_loadout = middleware::datagen::family4::loadout;
 /** First SOID reserved for item instances created by this local runtime. */
 constexpr std::uint64_t kFirstGeneratedItemSoid = 0x4000000000000001ULL;
 
+/** Historical profile definitions returned by ordinary gear dismantles. */
+constexpr std::uint32_t kLegendaryShardsDefinitionHash = 0x3CF2E8E2U;
+constexpr std::uint32_t kGunsmithMaterialsDefinitionHash = 0x28D6AC07U;
+/** Base gear payout for the supported pre-Beyond-Light client. */
+constexpr std::int32_t kLegendaryShardDismantleQuantity = 4;
+constexpr std::int32_t kGunsmithMaterialDismantleQuantity = 3;
+/** Equipment slots 0-2 are weapons and 3-7 are class-specific armor. */
+constexpr std::uint8_t kGearEquipmentSlotCount = 8;
+
+struct DismantleRewardPolicy {
+    std::uint32_t definitionHash{};
+    std::int32_t quantity{};
+};
+
+constexpr std::array<DismantleRewardPolicy, kDismantleRewardCapacity>
+    kGearDismantleRewards{{
+        {kLegendaryShardsDefinitionHash, kLegendaryShardDismantleQuantity},
+        {kGunsmithMaterialsDefinitionHash, kGunsmithMaterialDismantleQuantity},
+    }};
+
 /** Writes one exhaustive equipment-transaction checkpoint to the persistent diagnostic log. */
 void report_equipment(std::string_view stage,
                       std::string_view result,
@@ -174,6 +194,25 @@ same_profile_inventory(const AccountState& account,
     return true;
 }
 
+/** @return True when two fixed profile views, including their empty tails, are identical. */
+[[nodiscard]] bool same_profile_views(
+    const std::array<authored_inventory::ProfileItem,
+                     authored_inventory::kProfileItemCapacity>& left,
+    std::size_t leftCount,
+    const std::array<authored_inventory::ProfileItem,
+                     authored_inventory::kProfileItemCapacity>& right,
+    std::size_t rightCount) noexcept {
+    if (leftCount != rightCount) {
+        return false;
+    }
+    for (std::size_t index = 0; index < left.size(); ++index) {
+        if (!same_profile_item(left[index], right[index])) {
+            return false;
+        }
+    }
+    return true;
+}
+
 /**
  * Checks every dense profile stack and mirrors the account encoder's bucket-row placement.
  * This keeps State rejection independent of whether a later push happens to have scratch space.
@@ -1493,6 +1532,108 @@ character_item_at(CharacterState& character, const CharacterItemLocation& locati
     return false;
 }
 
+/**
+ * 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.
+ */
+[[nodiscard]] bool apply_dismantle_rewards(
+    const AccountState& before,
+    std::uint8_t equipmentSlot,
+    AccountState& after,
+    std::array<DismantleReward, kDismantleRewardCapacity>& rewards,
+    std::size_t& rewardCount) noexcept {
+    after = before;
+    rewards = {};
+    rewardCount = 0;
+    if (!valid_profile_inventory(before)) {
+        return false;
+    }
+    if (equipmentSlot >= kGearEquipmentSlotCount) {
+        return true;
+    }
+
+    std::int32_t greatestMutationSerial = 0;
+    for (std::size_t index = 0; index < before.profileItemCount; ++index) {
+        greatestMutationSerial =
+            (std::max)(greatestMutationSerial, before.profileItems[index].mutationSerial);
+    }
+
+    for (const DismantleRewardPolicy& policy : kGearDismantleRewards) {
+        build_data::items::Definition definition{};
+        item_details::Definition detail{};
+        inventory_buckets::Descriptor bucket{};
+        if (policy.definitionHash == authored_inventory::kNoDefinitionHash || policy.quantity <= 0
+            || !build_data::find_item_definition_hash(policy.definitionHash, definition)
+            || definition.definitionHash != policy.definitionHash
+            || !build_data::find_configured_item_detail(definition.definitionIndex, detail)
+            || detail.definitionIndex != definition.definitionIndex
+            || detail.definitionHash != definition.definitionHash
+            || detail.bucketId != definition.bucketId
+            || detail.instancedDefinitionState != item_details::InstancedDefinitionState::stackable
+            || detail.maxStackSize <= 0
+            || !build_data::find_inventory_bucket_descriptor(detail.bucketId, bucket)
+            || bucket.arraySelector != inventory_buckets::ArraySelector::profile
+            || build_data::is_profile_action_source(definition.definitionIndex,
+                                                    definition.bucketId)) {
+            return false;
+        }
+
+        std::size_t profileIndex = after.profileItemCount;
+        for (std::size_t index = 0; index < after.profileItemCount; ++index) {
+            const authored_inventory::ProfileItem& item = after.profileItems[index];
+            if (item.definitionHash != policy.definitionHash) {
+                continue;
+            }
+            if (item.instanceSoid != 0 || item.quantity <= 0
+                || item.quantity > detail.maxStackSize) {
+                return false;
+            }
+            if (profileIndex == after.profileItemCount && item.quantity < detail.maxStackSize) {
+                profileIndex = index;
+            }
+        }
+
+        const bool appended = profileIndex == after.profileItemCount;
+        if ((appended && after.profileItemCount >= after.profileItems.size())
+            || greatestMutationSerial == (std::numeric_limits<std::int32_t>::max)()) {
+            continue;
+        }
+        const std::int32_t previousQuantity =
+            appended ? 0 : after.profileItems[profileIndex].quantity;
+        const std::int32_t available = detail.maxStackSize - previousQuantity;
+        const std::int32_t credited = (std::min)(policy.quantity, available);
+        if (credited <= 0) {
+            continue;
+        }
+
+        AccountState candidate = after;
+        const std::int32_t mutationSerial = greatestMutationSerial + 1;
+        const std::int32_t afterQuantity = previousQuantity + credited;
+        if (appended) {
+            candidate.profileItems[profileIndex] = {
+                0, policy.definitionHash, afterQuantity, mutationSerial};
+            ++candidate.profileItemCount;
+        } else {
+            candidate.profileItems[profileIndex].quantity = afterQuantity;
+            candidate.profileItems[profileIndex].mutationSerial = mutationSerial;
+        }
+        // A full native bucket drops this reward, but never blocks deletion of the source item.
+        if (!account::valid(candidate) || !valid_profile_inventory(candidate)) {
+            continue;
+        }
+        if (rewardCount >= rewards.size()) {
+            return false;
+        }
+        after = candidate;
+        greatestMutationSerial = mutationSerial;
+        rewards[rewardCount++] = {
+            policy.definitionHash, profileIndex, credited, afterQuantity, mutationSerial};
+    }
+    return account::valid(after) && valid_profile_inventory(after);
+}
+
 /**
  * Builds the one canonical dismantle transition for an exact account snapshot.
  *
@@ -1609,21 +1750,174 @@ character_item_at(CharacterState& character, const CharacterItemLocation& locati
         }
     }
 
+    build_data::items::Definition dismantledDefinition{};
+    item_details::Definition dismantledDetail{};
+    if (!build_data::find_item_definition_hash(dismantledItem.definitionHash,
+                                               dismantledDefinition)
+        || dismantledDefinition.definitionHash != dismantledItem.definitionHash
+        || !build_data::find_configured_item_detail(dismantledDefinition.definitionIndex,
+                                                    dismantledDetail)
+        || dismantledDetail.definitionIndex != dismantledDefinition.definitionIndex
+        || dismantledDetail.definitionHash != dismantledDefinition.definitionHash
+        || dismantledDetail.bucketId != dismantledDefinition.bucketId
+        || dismantledDetail.instancedDefinitionState
+               != item_details::InstancedDefinitionState::instanced
+        || !dismantledDetail.equipmentSlot.has_value()
+        || static_cast<std::uint8_t>(*dismantledDetail.equipmentSlot) != dismantledSlot) {
+        return false;
+    }
+
+    AccountState rewarded{};
+    std::array<DismantleReward, kDismantleRewardCapacity> rewards{};
+    std::size_t rewardCount = 0;
+    if (!apply_dismantle_rewards(candidate, dismantledSlot, rewarded, rewards, rewardCount)) {
+        return false;
+    }
+    candidate = rewarded;
+
     mutation.beforeCharacter = before;
     mutation.afterCharacter = after;
+    mutation.beforeProfileItems = account.profileItems;
+    mutation.afterProfileItems = candidate.profileItems;
+    mutation.rewards = rewards;
     mutation.dismantledItem = dismantledItem;
+    mutation.accountSoid = account.primarySoid;
     mutation.characterSoid = before.soid;
     mutation.dismantledInstanceSoid = instanceSoid;
     mutation.characterIndex = characterIndex;
     mutation.expectedInventoryCount = before.inventory.count;
+    mutation.expectedProfileItemCount = account.profileItemCount;
+    mutation.afterProfileItemCount = candidate.profileItemCount;
     mutation.inventoryIndex = inventoryIndex;
     mutation.movedInventoryItemCount = movedItemCount;
+    mutation.rewardCount = rewardCount;
     mutation.inventoryRow = dismantledRow;
     mutation.equipmentSlot = dismantledSlot;
+    mutation.profileChanged = rewardCount != 0;
     mutation.prepared = true;
     return true;
 }
 
+/** @return True when both descriptions name the same credited profile mutation. */
+[[nodiscard]] bool same_dismantle_reward(const DismantleReward& left,
+                                         const DismantleReward& right) noexcept {
+    return left.definitionHash == right.definitionHash && left.profileIndex == right.profileIndex
+           && left.quantity == right.quantity && left.afterQuantity == right.afterQuantity
+           && left.mutationSerial == right.mutationSerial;
+}
+
+/** @return True when two independently staged dismantles carry the exact same after-images. */
+[[nodiscard]] bool same_dismantle_transition(const PendingItemDismantle& left,
+                                             const PendingItemDismantle& right) noexcept {
+    if (left.prepared != right.prepared || left.accountSoid != right.accountSoid
+        || left.characterSoid != right.characterSoid
+        || left.dismantledInstanceSoid != right.dismantledInstanceSoid
+        || left.characterIndex != right.characterIndex
+        || left.expectedInventoryCount != right.expectedInventoryCount
+        || left.expectedProfileItemCount != right.expectedProfileItemCount
+        || left.afterProfileItemCount != right.afterProfileItemCount
+        || left.inventoryIndex != right.inventoryIndex
+        || left.movedInventoryItemCount != right.movedInventoryItemCount
+        || left.rewardCount != right.rewardCount || left.inventoryRow != right.inventoryRow
+        || left.equipmentSlot != right.equipmentSlot
+        || left.profileChanged != right.profileChanged
+        || !same_stationary_item(left.dismantledItem, right.dismantledItem)
+        || !same_character(left.beforeCharacter, right.beforeCharacter)
+        || !same_character(left.afterCharacter, right.afterCharacter)
+        || !same_profile_views(left.beforeProfileItems,
+                               left.expectedProfileItemCount,
+                               right.beforeProfileItems,
+                               right.expectedProfileItemCount)
+        || !same_profile_views(left.afterProfileItems,
+                               left.afterProfileItemCount,
+                               right.afterProfileItems,
+                               right.afterProfileItemCount)) {
+        return false;
+    }
+    for (std::size_t index = 0; index < left.rewards.size(); ++index) {
+        if (!same_dismantle_reward(left.rewards[index], right.rewards[index])) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** Applies a fully checked dismantle after-image over an exact current account view. */
+[[nodiscard]] bool materialize_item_dismantle(const AccountState& current,
+                                              const PendingItemDismantle& mutation,
+                                              AccountState& after) noexcept {
+    after = {};
+    if (!mutation.prepared || mutation.accountSoid == 0 || mutation.characterSoid == 0
+        || mutation.dismantledInstanceSoid == 0
+        || mutation.dismantledItem.instanceSoid != mutation.dismantledInstanceSoid
+        || mutation.dismantledItem.definitionHash == authored_inventory::kNoDefinitionHash
+        || mutation.characterIndex >= current.characterCount
+        || mutation.expectedInventoryCount == 0
+        || mutation.expectedInventoryCount > authored_inventory::kCharacterItemCapacity
+        || mutation.expectedProfileItemCount > authored_inventory::kProfileItemCapacity
+        || mutation.afterProfileItemCount > authored_inventory::kProfileItemCapacity
+        || mutation.inventoryIndex >= mutation.expectedInventoryCount
+        || mutation.rewardCount > mutation.rewards.size()
+        || mutation.profileChanged != (mutation.rewardCount != 0)
+        || mutation.beforeCharacter.soid != mutation.characterSoid
+        || mutation.afterCharacter.soid != mutation.characterSoid
+        || mutation.beforeCharacter.inventory.count != mutation.expectedInventoryCount
+        || mutation.afterCharacter.inventory.count + 1U != mutation.expectedInventoryCount
+        || !same_stationary_item(mutation.beforeCharacter.inventory.values[mutation.inventoryIndex],
+                                 mutation.dismantledItem)
+        || current.primarySoid != mutation.accountSoid
+        || !same_profile_inventory(
+            current, mutation.beforeProfileItems, mutation.expectedProfileItemCount)) {
+        return false;
+    }
+    const CharacterState& character = current.characters[mutation.characterIndex];
+    if (!character.selected || character.soid != mutation.characterSoid
+        || !same_character(character, mutation.beforeCharacter)) {
+        return false;
+    }
+    for (std::size_t index = 0; index < mutation.rewards.size(); ++index) {
+        const DismantleReward& reward = mutation.rewards[index];
+        if (index < mutation.rewardCount) {
+            if (reward.definitionHash == authored_inventory::kNoDefinitionHash
+                || reward.profileIndex >= mutation.afterProfileItemCount || reward.quantity <= 0
+                || reward.afterQuantity < reward.quantity || reward.mutationSerial <= 0) {
+                return false;
+            }
+            const authored_inventory::ProfileItem& row =
+                mutation.afterProfileItems[reward.profileIndex];
+            if (row.instanceSoid != 0 || row.definitionHash != reward.definitionHash
+                || row.quantity != reward.afterQuantity
+                || row.mutationSerial != reward.mutationSerial) {
+                return false;
+            }
+        } else if (reward.definitionHash != 0 || reward.profileIndex != 0 || reward.quantity != 0
+                   || reward.afterQuantity != 0 || reward.mutationSerial != 0) {
+            return false;
+        }
+    }
+    if (!mutation.profileChanged
+        && !same_profile_views(mutation.beforeProfileItems,
+                               mutation.expectedProfileItemCount,
+                               mutation.afterProfileItems,
+                               mutation.afterProfileItemCount)) {
+        return false;
+    }
+
+    PendingItemDismantle canonical{};
+    if (!stage_item_dismantle(
+            current, mutation.characterIndex, mutation.dismantledInstanceSoid, canonical)
+        || !same_dismantle_transition(canonical, mutation)) {
+        return false;
+    }
+
+    after = current;
+    after.characters[mutation.characterIndex] = mutation.afterCharacter;
+    after.profileItems = mutation.afterProfileItems;
+    after.profileItemCount = mutation.afterProfileItemCount;
+    return account::valid(after) && valid_profile_inventory(after)
+           && !identity_uses_soid(after, mutation.dismantledInstanceSoid);
+}
+
 } // namespace
 
 /** Stores the active account key without publishing an incomplete account. */
@@ -2587,7 +2881,26 @@ bool prepare_item_dismantle(std::uint64_t instanceSoid, PendingItemDismantle& mu
     return true;
 }
 
-/** Commits one prepared dismantle only while its complete prepare-time character is unchanged. */
+/** Produces the exact character-and-profile after-image while the captured views remain current. */
+bool preview_item_dismantle(const PendingItemDismantle& mutation,
+                            AccountState& after) noexcept {
+    const AccountState current = account_snapshot();
+    const bool ready = materialize_item_dismantle(current, mutation, after);
+    report_dismantle("preview",
+                     ready ? "ok" : "fail",
+                     ready ? "ready" : "stale_or_invalid",
+                     mutation.dismantledItem.definitionHash,
+                     mutation.characterSoid,
+                     mutation.dismantledInstanceSoid,
+                     mutation.inventoryIndex,
+                     mutation.inventoryRow,
+                     mutation.equipmentSlot,
+                     mutation.movedInventoryItemCount,
+                     mutation.afterCharacter.nextInventorySerial);
+    return ready;
+}
+
+/** Commits one prepared dismantle only while its complete account views are unchanged. */
 bool commit_item_dismantle(PendingItemDismantle& mutation) noexcept {
     const PendingItemDismantle prepared = mutation;
     mutation = {};
@@ -2606,21 +2919,6 @@ bool commit_item_dismantle(PendingItemDismantle& mutation) noexcept {
         return false;
     };
 
-    if (!prepared.prepared || prepared.characterSoid == 0 || prepared.dismantledInstanceSoid == 0
-        || prepared.dismantledItem.instanceSoid != prepared.dismantledInstanceSoid
-        || prepared.dismantledItem.definitionHash == authored_inventory::kNoDefinitionHash
-        || prepared.characterIndex >= kCharacterCapacity || prepared.expectedInventoryCount == 0
-        || prepared.expectedInventoryCount > authored_inventory::kCharacterItemCapacity
-        || prepared.inventoryIndex >= prepared.expectedInventoryCount
-        || prepared.beforeCharacter.soid != prepared.characterSoid
-        || prepared.afterCharacter.soid != prepared.characterSoid
-        || prepared.beforeCharacter.inventory.count != prepared.expectedInventoryCount
-        || prepared.afterCharacter.inventory.count + 1U != prepared.expectedInventoryCount
-        || !same_stationary_item(prepared.beforeCharacter.inventory.values[prepared.inventoryIndex],
-                                 prepared.dismantledItem)) {
-        return fail("mutation");
-    }
-
     report_dismantle("commit_begin",
                      "ok",
                      "ready",
@@ -2634,42 +2932,17 @@ bool commit_item_dismantle(PendingItemDismantle& mutation) noexcept {
                      prepared.afterCharacter.nextInventorySerial);
 
     AcquireSRWLockExclusive(&runtime::storage::g_stateLock);
-    AccountState candidate = runtime::storage::g_state.account;
-    if (prepared.characterIndex >= candidate.characterCount) {
-        ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
-        return fail("character");
-    }
-    const CharacterState& current = candidate.characters[prepared.characterIndex];
-    if (!current.selected || current.soid != prepared.characterSoid
-        || !same_character(current, prepared.beforeCharacter)) {
-        ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
-        return fail("stale");
-    }
-
-    PendingItemDismantle canonical{};
-    if (!stage_item_dismantle(
-            candidate, prepared.characterIndex, prepared.dismantledInstanceSoid, canonical)
-        || canonical.characterSoid != prepared.characterSoid
-        || canonical.characterIndex != prepared.characterIndex
-        || canonical.expectedInventoryCount != prepared.expectedInventoryCount
-        || canonical.inventoryIndex != prepared.inventoryIndex
-        || canonical.inventoryRow != prepared.inventoryRow
-        || canonical.equipmentSlot != prepared.equipmentSlot
-        || canonical.movedInventoryItemCount != prepared.movedInventoryItemCount
-        || !same_stationary_item(canonical.dismantledItem, prepared.dismantledItem)
-        || !same_character(canonical.beforeCharacter, prepared.beforeCharacter)
-        || !same_character(canonical.afterCharacter, prepared.afterCharacter)) {
-        ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
-        return fail("transition");
+    AccountState candidate{};
+    const bool ready =
+        materialize_item_dismantle(runtime::storage::g_state.account, prepared, candidate);
+    if (ready) {
+        runtime::storage::g_state.account = candidate;
     }
+    ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
 
-    candidate.characters[prepared.characterIndex] = prepared.afterCharacter;
-    if (!account::valid(candidate)) {
-        ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
-        return fail("account");
+    if (!ready) {
+        return fail("stale_or_invalid");
     }
-    runtime::storage::g_state.account = candidate;
-    ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
 
     report_dismantle("commit_end",
                      "ok",