Преглед изворни кода

Grant an item that no collectible owns, and prepare a vendor exchange on the profile stacks

A vendor sale row names an item, never a collectible, while the
acquisition state is keyed by collectible. An acquisition arriving with
kNoCollectibleIndex skips every collectible step - validation, the
material charge, the cost bookkeeping - and prepare, preview and commit
all apply the same gate, so the consistency guard holds rather than
being bypassed. Nothing is charged without a collectible.

prepare_vendor_exchange charges the stack a recycle row names and
credits its payouts. It rides the profile-stack mutation rather than
writing State directly, because the Client is only told about a
currency gain by the account object's change ring: every credited row
is announced under a fresh mutation serial, and that is what draws the
floating "+N" and lets repeats accumulate.
chnsw пре 6 дана
родитељ
комит
03fb559dd5

+ 130 - 43
Sunrise/src/server/bap/encrypted/push/snapshot/family4_inventory_updates.cpp

@@ -20,6 +20,122 @@ namespace sunrise::server::bap::encrypted::push::snapshot {
 
 namespace family4_datagen = middleware::datagen::family4;
 
+namespace {
+
+namespace account_layout = middleware::datagen::family4::account::layout;
+
+/**
+ * The change ring the native account observer reads.
+ *
+ * It compares profile quantities but only draws pickup feedback for a row whose mutation serial
+ * also appears here, so a gain the ring does not name lands silently. The bank is local to one
+ * incremental upsert - an ordinary snapshot encodes it empty - while the row's own rising serial
+ * stays persistent State.
+ */
+constexpr std::uint8_t kChangeKind = 1;
+/** Clear policy bits leave the record enabled; the observer skips any other pair. */
+constexpr std::uint16_t kChangeFlags = 0;
+
+/** @return True when the ring carries no record, which is how every snapshot encodes it. */
+[[nodiscard]] bool ring_is_empty(const account_layout::Object& accountObject) noexcept {
+    const auto recordIsZero = [](const account_layout::ProfileInventoryChangeRecord& record) {
+        return record.sequence == 0 && record.reserved == 0 && record.mutationSerial == 0
+               && record.kind == 0 && record.reservedKind == 0 && record.flags == 0;
+    };
+    return accountObject.profileInventoryChanges.writeSlot == 0
+           && accountObject.profileInventoryChanges.nextSequence == 0
+           && std::all_of(accountObject.profileInventoryChanges.records.cbegin(),
+                          accountObject.profileInventoryChanges.records.cend(),
+                          recordIsZero);
+}
+
+/** Points one ring record at one profile row. */
+void name_row(account_layout::ProfileInventoryChangeRecord& record,
+              std::size_t sequence,
+              std::int32_t mutationSerial) noexcept {
+    record.sequence = static_cast<std::uint16_t>(sequence);
+    record.mutationSerial = mutationSerial;
+    record.kind = kChangeKind;
+    record.flags = kChangeFlags;
+}
+
+/**
+ * Names every row an exchange credited, so each gain is drawn and repeats accumulate.
+ *
+ * @param accountObject Encoded account object being upserted.
+ * @param mutation Prepared exchange carrying the rows it credited.
+ * @return Null on success, or the reason the ring could not be written.
+ */
+[[nodiscard]] const char* write_exchange_changes(
+    account_layout::Object& accountObject,
+    const state::PendingProfileItemAcquisition& mutation) noexcept {
+    if (mutation.changeCount > accountObject.profileInventoryChanges.records.size()
+        || !ring_is_empty(accountObject)) {
+        return "exchange_inventory_change_state";
+    }
+    for (std::size_t change = 0; change < mutation.changeCount; ++change) {
+        const state::ProfileStackChange& announced = mutation.changes[change];
+        std::size_t matchedRows = 0;
+        for (const auto& row : accountObject.profileItems) {
+            if (row.mutationSerial != announced.mutationSerial) {
+                continue;
+            }
+            if (row.quantity != announced.afterQuantity) {
+                return "exchange_change_quantity";
+            }
+            ++matchedRows;
+        }
+        if (matchedRows != 1) {
+            return "exchange_change_row";
+        }
+        name_row(accountObject.profileInventoryChanges.records[change],
+                 change,
+                 announced.mutationSerial);
+    }
+    accountObject.profileInventoryChanges.writeSlot =
+        static_cast<std::uint16_t>(mutation.changeCount);
+    accountObject.profileInventoryChanges.nextSequence =
+        static_cast<std::uint16_t>(mutation.changeCount);
+    return nullptr;
+}
+
+/**
+ * Names the one row an ordinary acquisition added to or grew.
+ *
+ * @param accountObject Encoded account object being upserted.
+ * @param mutation Prepared acquisition naming its acquired row.
+ * @param acquiredRow Receives that row's position, for the checkpoint line.
+ * @return Null on success, or the reason the ring could not be written.
+ */
+[[nodiscard]] const char* write_acquisition_change(
+    account_layout::Object& accountObject,
+    const state::PendingProfileItemAcquisition& mutation,
+    std::size_t& acquiredRow) noexcept {
+    acquiredRow = accountObject.profileItems.size();
+    for (std::size_t row = 0; row < accountObject.profileItems.size(); ++row) {
+        if (accountObject.profileItems[row].mutationSerial != mutation.acquiredMutationSerial) {
+            continue;
+        }
+        if (acquiredRow != accountObject.profileItems.size()) {
+            return "profile_acquire_row_duplicate";
+        }
+        acquiredRow = row;
+    }
+    if (acquiredRow >= accountObject.profileItems.size()
+        || accountObject.profileItems[acquiredRow].quantity != mutation.acquiredQuantity
+        || !ring_is_empty(accountObject)) {
+        return "profile_acquire_inventory_change_state";
+    }
+    accountObject.profileInventoryChanges.writeSlot = 1;
+    accountObject.profileInventoryChanges.nextSequence = 1;
+    name_row(accountObject.profileInventoryChanges.records.front(),
+             0,
+             mutation.acquiredMutationSerial);
+    return nullptr;
+}
+
+} // namespace
+
 /** Builds a single full account-object upsert from an uncommitted profile-stack after-image. */
 bool prepare_profile_item_acquisition(Scratch& scratch,
                                       const queuez::ProfileItemAcquisition& acquisition,
@@ -55,51 +171,22 @@ bool prepare_profile_item_acquisition(Scratch& scratch,
         return report_failure("profile_acquire_account_encode");
     }
 
-    // The native account observer compares profile quantities but only emits pickup feedback when
-    // the changed row's mutation serial also appears in this transient 16-record bank at 0x6978.
-    // Keep the descriptor local to this one incremental upsert; ordinary snapshots encode an empty
-    // bank, while the row's rising mutation serial remains persistent State.
-    constexpr std::uint16_t kAcquisitionChangeSequence = 0;
-    constexpr std::uint16_t kAcquisitionChangeNextWriteSlot = 1;
-    constexpr std::uint16_t kAcquisitionChangeNextSequence = 1;
-    constexpr std::uint8_t kAcquisitionChangeKind = 1;
-    constexpr std::uint16_t kAcquisitionChangeFlags = 0;
     auto& accountObject =
         *reinterpret_cast<family4_datagen::account::layout::Object*>(accountBytes.data());
     std::size_t acquiredRow = accountObject.profileItems.size();
-    for (std::size_t row = 0; row < accountObject.profileItems.size(); ++row) {
-        const auto& inventoryRow = accountObject.profileItems[row];
-        if (inventoryRow.mutationSerial != mutation.acquiredMutationSerial) {
-            continue;
-        }
-        if (acquiredRow != accountObject.profileItems.size()) {
-            clear_after(scratch, reservation);
-            return report_failure("profile_acquire_row_duplicate");
-        }
-        acquiredRow = row;
-    }
-    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;
-        };
-    const bool recordsAreZero = std::all_of(accountObject.profileInventoryChanges.records.cbegin(),
-                                            accountObject.profileInventoryChanges.records.cend(),
-                                            recordIsZero);
-    if (acquiredRow >= accountObject.profileItems.size()
-        || accountObject.profileItems[acquiredRow].quantity != mutation.acquiredQuantity
-        || accountObject.profileInventoryChanges.writeSlot != 0
-        || accountObject.profileInventoryChanges.nextSequence != 0 || !recordsAreZero) {
+    // An exchange names every row it credited; an ordinary acquisition names the one row it added
+    // to or grew. Both write the same kind of record, which is what the observer draws.
+    const char* const ringFailure =
+        mutation.changeCount != 0
+            ? write_exchange_changes(accountObject, mutation)
+            : write_acquisition_change(accountObject, mutation, acquiredRow);
+    if (ringFailure != nullptr) {
         clear_after(scratch, reservation);
-        return report_failure("profile_acquire_inventory_change_state");
+        return report_failure(ringFailure);
     }
-    accountObject.profileInventoryChanges.writeSlot = kAcquisitionChangeNextWriteSlot;
-    accountObject.profileInventoryChanges.nextSequence = kAcquisitionChangeNextSequence;
-    auto& acquisitionChange = accountObject.profileInventoryChanges.records.front();
-    acquisitionChange.sequence = kAcquisitionChangeSequence;
-    acquisitionChange.mutationSerial = mutation.acquiredMutationSerial;
-    acquisitionChange.kind = kAcquisitionChangeKind;
-    acquisitionChange.flags = kAcquisitionChangeFlags;
+    const std::uint16_t reportedChangeSlot = accountObject.profileInventoryChanges.writeSlot;
+    const std::uint16_t reportedChangeSequence =
+        accountObject.profileInventoryChanges.nextSequence;
 
     Prepared staged{};
     staged.rawClearSize =
@@ -166,9 +253,9 @@ bool prepare_profile_item_acquisition(Scratch& scratch,
         mutation.acquiredQuantity,
         acquiredRow,
         mutation.acquiredMutationSerial,
-        static_cast<unsigned>(kAcquisitionChangeNextWriteSlot),
-        static_cast<unsigned>(kAcquisitionChangeNextSequence),
-        static_cast<unsigned>(kAcquisitionChangeKind),
+        static_cast<unsigned>(reportedChangeSlot),
+        static_cast<unsigned>(reportedChangeSequence),
+        static_cast<unsigned>(kChangeKind),
         prepared.family.objects[accountObjectIndex].payload.size(),
         objectCount,
         acquisition.appendedResident ? "item-account" : "account");

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

@@ -141,6 +141,15 @@ struct PendingItemAcquisition {
     bool prepared{};
 };
 
+/** One profile row an exchange changed, named the way the account's change ring names it. */
+struct ProfileStackChange {
+    std::int32_t mutationSerial{};
+    std::int32_t afterQuantity{};
+};
+
+/** Rows one exchange may announce. Shader recycling announces two: Glimmer and Legendary Shards. */
+inline constexpr std::size_t kProfileStackChangeCapacity = 4;
+
 /** Prepared account-profile stack insertion kept private until its reply and account upsert fit. */
 struct PendingProfileItemAcquisition {
     /** Exact profile inventory observed while preparing the mutation. */
@@ -164,6 +173,14 @@ struct PendingProfileItemAcquisition {
     std::uint16_t collectibleIndex{};
     std::uint8_t bucketId{};
     std::uint8_t materialRequirementCount{};
+    /**
+     * Rows this mutation announces to the account's change ring, which is what draws the floating
+     * "+5 Legendary Shards" the Client shows. Empty for an ordinary acquisition, which announces
+     * its one acquired row instead; non-empty marks this an exchange, whose quantities move by
+     * more than one and whose row count is not fixed at one.
+     */
+    std::array<ProfileStackChange, kProfileStackChangeCapacity> changes{};
+    std::size_t changeCount{};
     /** True only for installed profile mod/shader rows materialized as Family-4 residents. */
     bool actionSource{};
     bool appended{};
@@ -551,6 +568,33 @@ prepare_settings_update(const account::settings::SettingsDelta& delta,
  * @return True when the after-image was already current or was committed successfully.
  */
 [[nodiscard]] bool commit_settings_update(PendingSettingsUpdate& mutation) noexcept;
+/** One credited side of a vendor exchange: an authored profile stack and how much to add. */
+struct ProfileExchangePayout {
+    std::uint32_t definitionHash{};
+    std::int32_t quantity{};
+};
+
+/**
+ * Prepares one vendor recycle row: charges the stack it names and credits what it pays out.
+ *
+ * This rides the profile-stack mutation rather than writing State directly, because the Client is
+ * only told about a currency gain by the account object's change ring - a row named there is what
+ * draws the floating "+5 Legendary Shards"; a direct write with a resync moves the numbers and
+ * announces nothing. Every credited row is announced under a fresh mutation serial; the charged row
+ * is not. Only an already-held payout stack is credited, since the currencies a recycle pays into
+ * are authored from the start. `preview_profile_item_acquisition` and
+ * `commit_profile_item_acquisition` carry the result the rest of the way.
+ *
+ * @param costDefinitionHash Stack the row charges against.
+ * @param costQuantity Units of it the row consumes.
+ * @param payouts Stacks to credit, each clamped to its own native stack limit.
+ * @param mutation Gets the checked profile before/after images without changing account State.
+ * @return True only when the charge and every credit fit and the whole account stayed valid.
+ */
+[[nodiscard]] bool prepare_vendor_exchange(std::uint32_t costDefinitionHash,
+                                           std::int32_t costQuantity,
+                                           std::span<const ProfileExchangePayout> payouts,
+                                           PendingProfileItemAcquisition& mutation) noexcept;
 
 /** @return A copy of the active account state, read under the lock. */
 [[nodiscard]] AccountState account_snapshot() noexcept;

+ 46 - 20
Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp

@@ -28,21 +28,36 @@ bool prepare_item_acquisition(std::uint16_t collectibleIndex,
     const AccountState account = account_snapshot();
     build_data::collectibles::Definition collectible{};
     build_data::items::Definition grantedDefinition{};
+    // A vendor purchase names an item, not a collectible, so the collectible steps are skipped
+    // rather than faked. The item is still validated, just by its own hash.
+    const bool hasCollectible = collectibleIndex != build_data::collectibles::kNoCollectibleIndex;
     if (definitionHash == authored_inventory::kNoDefinitionHash || !account::valid(account)
-        || !valid_profile_inventory(account)
-        || !build_data::find_collectible_definition(collectibleIndex, collectible)
-        || collectible.itemDefinitionIndex
-               == build_data::collectibles::kUnavailableItemDefinitionIndex
-        || !build_data::find_item_definition_index(collectible.itemDefinitionIndex,
-                                                   grantedDefinition)
-        || grantedDefinition.definitionHash != definitionHash) {
+        || !valid_profile_inventory(account)) {
         report_acquisition("prepare", "fail", "input", definitionHash, 0, 0, 0, 0, 0, 0);
         return false;
     }
+    if (hasCollectible) {
+        if (!build_data::find_collectible_definition(collectibleIndex, collectible)
+            || collectible.itemDefinitionIndex
+                   == build_data::collectibles::kUnavailableItemDefinitionIndex
+            || !build_data::find_item_definition_index(collectible.itemDefinitionIndex,
+                                                       grantedDefinition)
+            || grantedDefinition.definitionHash != definitionHash) {
+            report_acquisition("prepare", "fail", "input", definitionHash, 0, 0, 0, 0, 0, 0);
+            return false;
+        }
+    } else if (!build_data::find_item_definition_hash(definitionHash, grantedDefinition)
+               || grantedDefinition.definitionHash != definitionHash) {
+        report_acquisition("prepare", "fail", "item", definitionHash, 0, 0, 0, 0, 0, 0);
+        return false;
+    }
 
-    AccountState chargedAccount{};
+    AccountState chargedAccount = account;
     bool profileChanged = false;
-    if (!apply_collection_materials(account, collectible, chargedAccount, profileChanged)) {
+    // Nothing is charged without a collectible: the cost lives on the collectible's material
+    // requirements, and a sale row's own cost fields are still role-open.
+    if (hasCollectible
+        && !apply_collection_materials(account, collectible, chargedAccount, profileChanged)) {
         report_acquisition("prepare", "fail", "materials", definitionHash, 0, 0, 0, 0, 0, 0);
         return false;
     }
@@ -223,13 +238,21 @@ bool commit_item_acquisition(PendingItemAcquisition& mutation) noexcept {
         return fail("mutation");
     }
 
-    build_data::collectibles::Definition collectible{};
-    if (!build_data::find_collectible_definition(prepared.collectibleIndex, collectible)
-        || collectible.itemDefinitionIndex
-               == build_data::collectibles::kUnavailableItemDefinitionIndex
-        || collectible.materialRequirementSetHash != prepared.materialRequirementSetHash
-        || collectible.materialRequirementCount != prepared.materialRequirementCount) {
-        return fail("collectible");
+    // The guard is that prepare and commit agree. Without a collectible they agree on there being
+    // none, which means both cost fields must still be clear.
+    if (prepared.collectibleIndex == build_data::collectibles::kNoCollectibleIndex) {
+        if (prepared.materialRequirementSetHash != 0 || prepared.materialRequirementCount != 0) {
+            return fail("collectible");
+        }
+    } else {
+        build_data::collectibles::Definition collectible{};
+        if (!build_data::find_collectible_definition(prepared.collectibleIndex, collectible)
+            || collectible.itemDefinitionIndex
+                   == build_data::collectibles::kUnavailableItemDefinitionIndex
+            || collectible.materialRequirementSetHash != prepared.materialRequirementSetHash
+            || collectible.materialRequirementCount != prepared.materialRequirementCount) {
+            return fail("collectible");
+        }
     }
 
     report_acquisition("commit_begin",
@@ -300,12 +323,15 @@ bool prepare_profile_item_acquisition(std::uint16_t collectibleIndex,
     inventory_buckets::Descriptor bucket{};
     if (definitionHash == authored_inventory::kNoDefinitionHash || !account::valid(account)
         || !valid_profile_inventory(account)
-        || !build_data::find_collectible_definition(collectibleIndex, collectible)
-        || collectible.itemDefinitionIndex
-               == build_data::collectibles::kUnavailableItemDefinitionIndex
         || !build_data::find_item_definition_hash(definitionHash, item)
         || item.definitionHash != definitionHash
-        || item.definitionIndex != collectible.itemDefinitionIndex
+        // The item resolves first, because the collectible cross-check reads it. With no
+        // collectible the item's own hash is the whole check.
+        || (collectibleIndex != build_data::collectibles::kNoCollectibleIndex
+            && (!build_data::find_collectible_definition(collectibleIndex, collectible)
+                || collectible.itemDefinitionIndex
+                       == build_data::collectibles::kUnavailableItemDefinitionIndex
+                || item.definitionIndex != collectible.itemDefinitionIndex))
         || !build_data::find_configured_item_detail(item.definitionIndex, detail)
         || detail.definitionIndex != item.definitionIndex || detail.definitionHash != definitionHash
         || detail.bucketId != item.bucketId

+ 203 - 8
Sunrise/src/state/runtime/state_account_profile_runtime.cpp

@@ -11,6 +11,7 @@
 #include <cstdint>
 #include <cstdio>
 #include <limits>
+#include <span>
 #include <string_view>
 #include <utility>
 
@@ -442,6 +443,64 @@ apply_action_materials(const AccountState& before,
 /** @return True when a pending profile acquisition carries canonical dense before/after images. */
 [[nodiscard]] bool
 valid_profile_mutation_shape(const PendingProfileItemAcquisition& mutation) noexcept {
+    // An exchange is the other shape this mutation carries. Its quantities move by more than one
+    // and it changes more than one row, so the single-increment rules below cannot describe it -
+    // they exist to pin the Collections pull, which is the only thing that should reach them.
+    if (mutation.changeCount != 0) {
+        if (!mutation.prepared || mutation.accountSoid == 0 || mutation.actionSource
+            || mutation.appended || mutation.acquiredInstanceSoid != 0
+            || mutation.acquiredDefinitionHash == authored_inventory::kNoDefinitionHash
+            || mutation.changeCount > mutation.changes.size()
+            || mutation.expectedItemCount > authored_inventory::kProfileItemCapacity
+            || mutation.afterItemCount > authored_inventory::kProfileItemCapacity
+            || mutation.afterItemCount == 0) {
+            return false;
+        }
+        for (std::size_t index = 0; index < mutation.beforeItems.size(); ++index) {
+            const authored_inventory::ProfileItem& before = mutation.beforeItems[index];
+            const authored_inventory::ProfileItem& after = mutation.afterItems[index];
+            if (index >= mutation.expectedItemCount
+                && (before.instanceSoid != 0 || before.definitionHash != 0 || before.quantity != 0
+                    || before.mutationSerial != 0)) {
+                return false;
+            }
+            if (index >= mutation.afterItemCount
+                && (after.instanceSoid != 0 || after.definitionHash != 0 || after.quantity != 0
+                    || after.mutationSerial != 0)) {
+                return false;
+            }
+        }
+        // Every announced row has to exist exactly once in the after-image, carrying the serial and
+        // quantity the change names. The account's change ring points at rows by serial, so a
+        // serial naming no row or two rows would announce a gain the Client cannot resolve.
+        for (std::size_t change = 0; change < mutation.changeCount; ++change) {
+            const ProfileStackChange& announced = mutation.changes[change];
+            if (announced.mutationSerial <= 0 || announced.afterQuantity <= 0) {
+                return false;
+            }
+            // Two changes naming one row would announce the same gain twice, and the ring has no
+            // way to say they meant different things.
+            for (std::size_t earlier = 0; earlier < change; ++earlier) {
+                if (mutation.changes[earlier].mutationSerial == announced.mutationSerial) {
+                    return false;
+                }
+            }
+            std::size_t matches = 0;
+            for (std::size_t index = 0; index < mutation.afterItemCount; ++index) {
+                if (mutation.afterItems[index].mutationSerial != announced.mutationSerial) {
+                    continue;
+                }
+                if (mutation.afterItems[index].quantity != announced.afterQuantity) {
+                    return false;
+                }
+                ++matches;
+            }
+            if (matches != 1) {
+                return false;
+            }
+        }
+        return true;
+    }
     if (!mutation.prepared || mutation.accountSoid == 0
         || mutation.actionSource != (mutation.acquiredInstanceSoid != 0)
         || mutation.acquiredDefinitionHash == authored_inventory::kNoDefinitionHash
@@ -506,18 +565,42 @@ valid_profile_mutation_shape(const PendingProfileItemAcquisition& mutation) noex
         || !same_profile_inventory(current, mutation.beforeItems, mutation.expectedItemCount)) {
         return false;
     }
+    // An exchange names no collectible, no bucket and no single acquired row, so none of the
+    // acquisition's definition checks apply to it. Its after-image was already checked whole when
+    // it was prepared, and the shape check above proved every announced row is in it.
+    if (mutation.changeCount != 0) {
+        after = current;
+        after.profileItems = mutation.afterItems;
+        after.profileItemCount = mutation.afterItemCount;
+        return account::valid(after) && valid_profile_inventory(after);
+    }
     item_details::Definition detail{};
     inventory_buckets::Descriptor bucket{};
     build_data::items::Definition item{};
     build_data::collectibles::Definition collectible{};
-    if (!build_data::find_collectible_definition(mutation.collectibleIndex, collectible)
-        || collectible.itemDefinitionIndex
-               == build_data::collectibles::kUnavailableItemDefinitionIndex
-        || collectible.materialRequirementSetHash != mutation.materialRequirementSetHash
-        || collectible.materialRequirementCount != mutation.materialRequirementCount
-        || !build_data::find_item_definition_hash(mutation.acquiredDefinitionHash, item)
-        || collectible.itemDefinitionIndex != item.definitionIndex
-        || !build_data::find_configured_item_detail(item.definitionIndex, detail)
+    // A vendor purchase names an item, never a collectible, and arrives with the sentinel. The
+    // collectible steps are skipped for it rather than faked, exactly as prepare already does -
+    // this is the same gate, and omitting it here is what took the connection down: prepare
+    // succeeded, the reply went out saying so, then preview and commit both refused and the
+    // Queuez frame was never staged.
+    if (!build_data::find_item_definition_hash(mutation.acquiredDefinitionHash, item)) {
+        return false;
+    }
+    if (mutation.collectibleIndex == build_data::collectibles::kNoCollectibleIndex) {
+        // With no collectible to hold them, both cost fields must still be clear. This is the
+        // rule `commit_item_acquisition` applies to the character path.
+        if (mutation.materialRequirementSetHash != 0 || mutation.materialRequirementCount != 0) {
+            return false;
+        }
+    } else if (!build_data::find_collectible_definition(mutation.collectibleIndex, collectible)
+               || collectible.itemDefinitionIndex
+                      == build_data::collectibles::kUnavailableItemDefinitionIndex
+               || collectible.materialRequirementSetHash != mutation.materialRequirementSetHash
+               || collectible.materialRequirementCount != mutation.materialRequirementCount
+               || collectible.itemDefinitionIndex != item.definitionIndex) {
+        return false;
+    }
+    if (!build_data::find_configured_item_detail(item.definitionIndex, detail)
         || detail.definitionHash != mutation.acquiredDefinitionHash
         || detail.definitionIndex != item.definitionIndex || detail.bucketId != item.bucketId
         || detail.bucketId != mutation.bucketId
@@ -536,4 +619,116 @@ valid_profile_mutation_shape(const PendingProfileItemAcquisition& mutation) noex
 }
 
 } // namespace runtime::detail
+
+/** Prepares one vendor recycle row: charges the stack it names and credits what it pays out. */
+bool prepare_vendor_exchange(std::uint32_t costDefinitionHash,
+                             std::int32_t costQuantity,
+                             std::span<const ProfileExchangePayout> payouts,
+                             PendingProfileItemAcquisition& mutation) noexcept {
+    namespace authored_inventory = account::inventory;
+    namespace item_details = build_data::items::details;
+    mutation = {};
+    if (costDefinitionHash == authored_inventory::kNoDefinitionHash || costDefinitionHash == 0
+        || costQuantity <= 0 || payouts.empty() || payouts.size() > kProfileStackChangeCapacity) {
+        return false;
+    }
+    const AccountState account = account_snapshot();
+    if (!account::valid(account) || account.primarySoid == 0) {
+        return false;
+    }
+    const auto stack_limit = [](std::uint32_t definitionHash, std::int32_t& limit) noexcept {
+        build_data::items::Definition definition{};
+        item_details::Definition detail{};
+        if (!build_data::find_item_definition_hash(definitionHash, definition)
+            || !build_data::find_configured_item_detail(definition.definitionIndex, detail)
+            || detail.maxStackSize <= 0) {
+            return false;
+        }
+        limit = detail.maxStackSize;
+        return true;
+    };
+    const auto find_stack = [](const AccountState& state, std::uint32_t definitionHash) noexcept {
+        std::size_t at = state.profileItemCount;
+        for (std::size_t index = 0; index < state.profileItemCount; ++index) {
+            if (state.profileItems[index].definitionHash == definitionHash) {
+                at = index;
+                break;
+            }
+        }
+        return at;
+    };
+
+    AccountState after = account;
+    const std::size_t costIndex = find_stack(after, costDefinitionHash);
+    if (costIndex >= after.profileItemCount
+        || after.profileItems[costIndex].quantity < costQuantity) {
+        return false;
+    }
+    // The charged row keeps its ordering token. Only a gain is announced, and the decrement is
+    // read straight off the republished account object, so bumping it would buy nothing and would
+    // move the charged stack to the front of its bucket for no reason the player asked for.
+    after.profileItems[costIndex].quantity -= costQuantity;
+
+    // Serials rise from the greatest already in the profile, so every announced row is unique and
+    // no existing row is displaced in the Client's ordering.
+    std::int32_t serial = 0;
+    for (std::size_t index = 0; index < after.profileItemCount; ++index) {
+        serial = (std::max)(serial, after.profileItems[index].mutationSerial);
+    }
+    if (serial > (std::numeric_limits<std::int32_t>::max)()
+                     - static_cast<std::int32_t>(payouts.size())) {
+        return false;
+    }
+    std::size_t changeCount = 0;
+    for (const ProfileExchangePayout& payout : payouts) {
+        std::int32_t limit = 0;
+        const std::size_t at = find_stack(after, payout.definitionHash);
+        // Paying back into the stack being charged is refused rather than netted out. It says
+        // nothing a recycle could mean, and it would leave the charged row's emptiness decided by
+        // payout order - the row is removed when the charge empties it, and a credit arriving
+        // afterwards would be crediting a row that is about to leave the array.
+        if (payout.quantity <= 0 || payout.definitionHash == costDefinitionHash
+            || !stack_limit(payout.definitionHash, limit) || at >= after.profileItemCount) {
+            return false;
+        }
+        // A currency already at its native cap takes nothing, which is the same outcome the Client
+        // reports as "your Glimmer is full" rather than a failed exchange.
+        const std::int32_t room = (std::max)(limit - after.profileItems[at].quantity, 0);
+        const std::int32_t credited = (std::min)(payout.quantity, room);
+        if (credited == 0) {
+            continue;
+        }
+        after.profileItems[at].quantity += credited;
+        after.profileItems[at].mutationSerial = ++serial;
+        mutation.changes[changeCount++] = {after.profileItems[at].mutationSerial,
+                                           after.profileItems[at].quantity};
+    }
+    // Nothing to announce means nothing was credited, and charging for that would be theft.
+    if (changeCount == 0) {
+        return false;
+    }
+    // A stack the charge emptied has to leave the array, because a zero-quantity row is not a valid
+    // profile row. It is removed last so the credited rows above were found at their real indices.
+    if (after.profileItems[costIndex].quantity == 0) {
+        for (std::size_t index = costIndex; index + 1U < after.profileItemCount; ++index) {
+            after.profileItems[index] = after.profileItems[index + 1U];
+        }
+        --after.profileItemCount;
+        after.profileItems[after.profileItemCount] = {};
+    }
+    if (!account::valid(after) || !runtime::detail::valid_profile_inventory(after)) {
+        return false;
+    }
+
+    mutation.beforeItems = account.profileItems;
+    mutation.afterItems = after.profileItems;
+    mutation.accountSoid = account.primarySoid;
+    mutation.acquiredDefinitionHash = costDefinitionHash;
+    mutation.expectedItemCount = account.profileItemCount;
+    mutation.afterItemCount = after.profileItemCount;
+    mutation.changeCount = changeCount;
+    mutation.prepared = true;
+    return true;
+}
+
 } // namespace sunrise::state