Browse Source

charge vendor sale rows their own cost

Vendor purchases (opcode 901 and the 904 sale-row path) now spend the row's
price instead of granting for free. The original vendor change left the cost
uncharged because the row's price-override fields were still role-open; the
role is now settled: every entry names a stackable profile item and a
quantity, and Xur's rows match the documented Legendary Shard prices (weapon
29, armour 23, Fated Engram 97, Invitation of the Nine 9).

- build_data::vendors::SaleRow carries every price-override entry (up to
  four) as SaleCost {itemIndex, quantity} plus a count. The package extractor
  reads all of them and refuses a row whose override class mismatches or
  exceeds the capacity. build_data.bin format bumps 65 -> 66.
- state::vendors::Charge is the one place a row's cost is read; Purchase
  bundles what a sale asks of the account beyond the item.
- state::prepare_vendor_item_acquisition / prepare_vendor_profile_item_
  acquisition mirror the Collections grants but pay through apply_sale_charge,
  which reuses the material engine: a non-payable cost item refuses as
  cost_item, too few units as insufficient_currency, and nothing is granted.
- Recycle rows require exactly one cost entry, as an exchange charges one
  stack.

Observable: buying from any Tower vendor debits the profile stack and logs
ev=vendor stage=charge on refusal; a purchase that cannot pay grants nothing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Mozi 2 weeks ago
parent
commit
135cd0dfb1

+ 2 - 0
Sunrise/Sunrise.vcxproj

@@ -1959,6 +1959,8 @@
     <ClInclude Include="src\state\build_data\bounties\definition.h" />
     <ClInclude Include="src\state\build_data\bounties\bounty_catalog.h" />
     <ClInclude Include="src\state\build_data\vendors\repeatable_triggers.h" />
+    <ClInclude Include="src\state\vendors\charge.h" />
+    <ClInclude Include="src\state\vendors\purchase.h" />
     <ClInclude Include="src\state\build_data\progressions\definition.h" />
     <ClInclude Include="src\state\entitlements\definition.h" />
     <ClInclude Include="src\state\entitlements\entitlement_runtime.h" />

+ 21 - 9
Sunrise/src/client/content/vendors/package_vendor_build.cpp

@@ -89,17 +89,19 @@ read(std::span<const std::byte> blob, std::size_t offset, Value& value) noexcept
 }
 
 /**
- * Reads what one sale row charges, from the first row of its price-override array.
- * A row charging nothing declares no override, which is data rather than a malformed row.
+ * Reads what one sale row charges: every row of its price-override array, in order.
+ * A row charging nothing declares no override, which is data rather than a malformed row. A row
+ * declaring more overrides than the catalog can hold is refused, so a charge is never a subset of
+ * what the package asks for.
  * @param blob Whole definition blob.
  * @param at Sale row offset inside the blob.
- * @param value Receives the cost item and quantity, or the absent cost.
- * @return True when the array is absent, or resolves and ends inside the blob.
+ * @param value Receives the cost rows, or none.
+ * @return True when the array is absent, or resolves, fits, and ends inside the blob.
  */
 [[nodiscard]] bool
 read_sale_cost(std::span<const std::byte> blob, std::size_t at, domain::SaleRow& value) noexcept {
-    value.costItemIndex = domain::kAbsentCostItem;
-    value.costQuantity = 0;
+    value.costs = {};
+    value.costCount = 0;
     ArrayView cost{};
     if (!read_array(blob, at + kSaleCostArrayDescriptor, domain::kSaleCostRowStride, cost)) {
         return false;
@@ -107,9 +109,19 @@ read_sale_cost(std::span<const std::byte> blob, std::size_t at, domain::SaleRow&
     if (cost.count == 0) {
         return true;
     }
-    return cost.classId == domain::kSaleCostRowClass
-           && read(blob, cost.base + kSaleCostItemIndexOffset, value.costItemIndex)
-           && read(blob, cost.base + kSaleCostQuantityOffset, value.costQuantity);
+    if (cost.classId != domain::kSaleCostRowClass || cost.count > value.costs.size()) {
+        return false;
+    }
+    for (std::size_t row = 0; row < cost.count; ++row) {
+        const std::size_t rowAt = cost.base + (row * domain::kSaleCostRowStride);
+        domain::SaleCost& entry = value.costs[row];
+        if (!read(blob, rowAt + kSaleCostItemIndexOffset, entry.itemIndex)
+            || !read(blob, rowAt + kSaleCostQuantityOffset, entry.quantity)) {
+            return false;
+        }
+    }
+    value.costCount = static_cast<std::uint8_t>(cost.count);
+    return true;
 }
 
 /**

+ 115 - 58
Sunrise/src/server/web_service/web_service_vendor_actions.cpp

@@ -5,6 +5,7 @@
 #include <cstdio>
 #include <cstring>
 #include <limits>
+#include <optional>
 #include <span>
 #include <string_view>
 
@@ -20,6 +21,7 @@
 #include "../../state/build_data/vendors/repeatable_triggers.h"
 #include "../../state/build_data/vendors/vendor_catalog.h"
 #include "../../state/runtime/runtime.h"
+#include "../../state/vendors/purchase.h"
 #include "internal_actions.h"
 #include "web_service_actions.h"
 
@@ -124,6 +126,16 @@ void report_purchase(std::uint16_t opcode,
            && vendor_domain::find(entry.definitionHash, definition);
 }
 
+/** One vendor sale row as a purchase names it, resolved from the request's two indices. */
+struct ResolvedSale {
+    /** Item the row sells. */
+    std::uint16_t itemDefinitionIndex{kUnavailableDefinitionIndex};
+    /** Row +100, or `kAbsentCategoryIndex` when the request named no sale row. */
+    std::int32_t categoryIndex{state::build_data::vendors::kAbsentCategoryIndex};
+    /** The row's own cost. */
+    state::vendors::Charge charge{};
+};
+
 /** What the substitution table said about one sale row's item. */
 enum class Substitution : std::uint8_t {
     /** The table does not name this item; the row grants what it names. */
@@ -298,26 +310,30 @@ constexpr std::array<LegacyQuestStep, 3> kLegacyQuestSteps{{
     // A sale row holds its cost as u32; the mutation charges an i32, so a wider row is refused.
     constexpr auto kQuantityLimit =
         static_cast<std::uint32_t>((std::numeric_limits<std::int32_t>::max)());
+    const state::vendors::Charge charge = state::vendors::Charge::of(row);
+    // An exchange charges exactly one stack, so a row declaring several currencies is not one.
+    const state::build_data::vendors::SaleCost single =
+        charge.count == 1 ? charge.costs[0] : state::build_data::vendors::SaleCost{};
     state::build_data::items::Definition cost{};
     // A recycle row owns its purchase from here, refused or not: falling through would grant the
     // placeholder, which is the failure this path exists to avoid.
-    if (row.costItemIndex == vendor_domain::kAbsentCostItem || row.costQuantity == 0
-        || row.costQuantity > kQuantityLimit
-        || !state::build_data::find_item_definition_index(row.costItemIndex, cost)) {
+    if (charge.count != 1 || charge.is_free() || single.quantity > kQuantityLimit
+        || !state::build_data::find_item_definition_index(single.itemIndex, cost)) {
         core::log::writef(core::log::Channel::server,
                           core::log::Level::warn,
                           "ev=vendor_exchange stage=apply result=fail reason=cost vendor=%d "
-                          "hash=0x%08X row=%d cost_item=%u quantity=%u",
+                          "hash=0x%08X row=%d costs=%u cost_item=%u quantity=%u",
                           vendorIndex,
                           entry.definitionHash,
                           rowIndex,
-                          static_cast<unsigned>(row.costItemIndex),
-                          static_cast<unsigned>(row.costQuantity));
+                          static_cast<unsigned>(charge.count),
+                          static_cast<unsigned>(single.itemIndex),
+                          static_cast<unsigned>(single.quantity));
         return true;
     }
     const bool applied = state::prepare_vendor_exchange(
         cost.definitionHash,
-        static_cast<std::int32_t>(row.costQuantity),
+        static_cast<std::int32_t>(single.quantity),
         std::span<const state::ProfileExchangePayout>{payouts.data(), payoutCount},
         mutation);
     core::log::writef(core::log::Channel::server,
@@ -329,23 +345,52 @@ constexpr std::array<LegacyQuestStep, 3> kLegacyQuestSteps{{
                       entry.definitionHash,
                       rowIndex,
                       cost.definitionHash,
-                      static_cast<unsigned>(row.costQuantity),
+                      static_cast<unsigned>(single.quantity),
                       payoutCount);
     return true;
 }
 
+/**
+ * Names a refused charge for the purchase line, and writes what the row asked for.
+ * @return The refusal reason, or the caller's own reason when the charge did not refuse.
+ */
+[[nodiscard]] const char* charge_refusal_reason(state::vendors::ChargeRefusal refusal,
+                                                const state::vendors::Charge& charge,
+                                                const char* grantReason) noexcept {
+    if (refusal == state::vendors::ChargeRefusal::none) {
+        return grantReason;
+    }
+    const state::build_data::vendors::SaleCost first =
+        charge.count != 0 ? charge.costs[0] : state::build_data::vendors::SaleCost{};
+    const char* const reason = refusal == state::vendors::ChargeRefusal::insufficient
+                                   ? "insufficient_currency"
+                                   : "cost_item";
+    core::log::writef(core::log::Channel::server,
+                      core::log::Level::warn,
+                      "ev=vendor stage=charge result=fail reason=%s costs=%u cost_item=%u "
+                      "quantity=%u",
+                      reason,
+                      static_cast<unsigned>(charge.count),
+                      static_cast<unsigned>(first.itemIndex),
+                      static_cast<unsigned>(first.quantity));
+    return reason;
+}
+
 /**
  * Grants one item, given the collectible that owns it and its definition index.
  * Acquisition state is keyed by collectible, so the caller resolves one first.
  * @param message Request being answered, for the log line.
  * @param collectibleIndex Collectible that owns the item.
  * @param itemDefinitionIndex Item to grant.
+ * @param purchase A vendor row's cost and weekly claim, charged instead of the collectible's
+ *        material set; absent for a Collections pull, which pays with the collectible's materials.
  * @param outcome Receives the prepared mutation on success.
  * @return True when a mutation is prepared. A pursuit already held prepares none.
  */
 bool grant_item_definition(const middleware::web_service::Message& message,
                            std::uint16_t collectibleIndex,
                            std::uint16_t itemDefinitionIndex,
+                           const std::optional<state::vendors::Purchase>& purchase,
                            Outcome& outcome) noexcept {
     state::build_data::items::Definition definition{};
     if (!state::build_data::find_item_definition_index(itemDefinitionIndex, definition)) {
@@ -403,15 +448,23 @@ bool grant_item_definition(const middleware::web_service::Message& message,
                                     0);
             return false;
         }
-        if (!state::prepare_profile_item_acquisition(
-                collectibleIndex, definition.definitionHash, *mutation)) {
+        state::vendors::ChargeRefusal refusal = state::vendors::ChargeRefusal::none;
+        const bool prepared =
+            purchase.has_value()
+                ? state::prepare_vendor_profile_item_acquisition(
+                      collectibleIndex, definition.definitionHash, *purchase, *mutation, refusal)
+                : state::prepare_profile_item_acquisition(
+                      collectibleIndex, definition.definitionHash, *mutation);
+        if (!prepared) {
             clear_mutation(outcome);
-            report_item_acquisition(message,
-                                    "profile_state",
-                                    collectibleIndex,
-                                    itemDefinitionIndex,
-                                    definition.definitionHash,
-                                    0);
+            report_item_acquisition(
+                message,
+                charge_refusal_reason(
+                    refusal, purchase.value_or(state::vendors::Purchase{}).charge, "profile_state"),
+                collectibleIndex,
+                itemDefinitionIndex,
+                definition.definitionHash,
+                0);
             return false;
         }
         return true;
@@ -436,10 +489,23 @@ bool grant_item_definition(const middleware::web_service::Message& message,
                                 0);
         return false;
     }
-    if (!state::prepare_item_acquisition(collectibleIndex, definition.definitionHash, *mutation)) {
+    state::vendors::ChargeRefusal refusal = state::vendors::ChargeRefusal::none;
+    const bool prepared =
+        purchase.has_value()
+            ? state::prepare_vendor_item_acquisition(
+                  collectibleIndex, definition.definitionHash, *purchase, *mutation, refusal)
+            : state::prepare_item_acquisition(
+                  collectibleIndex, definition.definitionHash, *mutation);
+    if (!prepared) {
         clear_mutation(outcome);
         report_item_acquisition(
-            message, "state", collectibleIndex, itemDefinitionIndex, definition.definitionHash, 0);
+            message,
+            charge_refusal_reason(
+                refusal, purchase.value_or(state::vendors::Purchase{}).charge, "state"),
+            collectibleIndex,
+            itemDefinitionIndex,
+            definition.definitionHash,
+            0);
         return false;
     }
     return true;
@@ -478,24 +544,24 @@ void acquire_item(const middleware::web_service::Message& message, Outcome& outc
             message, "collectible_definition", collectibleIndex, kUnavailableDefinitionIndex, 0, 0);
         return;
     }
-    (void)grant_item_definition(message, collectibleIndex, itemDefinitionIndex, outcome);
+    (void)grant_item_definition(
+        message, collectibleIndex, itemDefinitionIndex, std::nullopt, outcome);
 }
 
 /**
  * Resolves one vendor row to the item it sells. Shared by 901 and 904, which name a row alike.
  * @param vendorIndex Vendor table row.
  * @param rowIndex Sale row within that vendor.
- * @param itemDefinitionIndex Receives the item the row sells.
- * @param categoryIndex Receives the row's category, which is its sale row plus 100.
+ * @param sale Receives the row: its vendor, item, category and cost.
  * @param reason Receives the step that failed, when one does.
  * @return True when the row resolved.
  */
 [[nodiscard]] bool resolve_vendor_row(std::int32_t vendorIndex,
                                       std::int32_t rowIndex,
-                                      std::uint16_t& itemDefinitionIndex,
-                                      std::int32_t& categoryIndex,
+                                      ResolvedSale& sale,
                                       const char*& reason) noexcept {
     namespace vendor_domain = state::build_data::vendors;
+    sale = {};
     if (vendorIndex < 0 || rowIndex < 0) {
         reason = "negative_index";
         return false;
@@ -511,8 +577,9 @@ void acquire_item(const middleware::web_service::Message& message, Outcome& outc
         reason = "sale_row";
         return false;
     }
-    itemDefinitionIndex = row.itemIndex;
-    categoryIndex = row.categoryIndex;
+    sale.itemDefinitionIndex = row.itemIndex;
+    sale.categoryIndex = row.categoryIndex;
+    sale.charge = state::vendors::Charge::of(row);
     return true;
 }
 
@@ -634,17 +701,17 @@ constexpr std::uint32_t kAbsentNameHash = 0x811C9DC5U;
  * @param opcode Opcode to report under.
  * @param vendorIndex Vendor the request names.
  * @param rowIndex Sale row the request names.
- * @param categoryIndex Category of that row, from sale row +100.
- * @param itemDefinitionIndex Item the row names.
+ * @param sale The resolved row; its cost is spent only by a plain grant.
  * @param outcome Receives whatever mutation the row prepared.
  */
 void settle_vendor_row(const middleware::web_service::Message& message,
                        std::uint16_t opcode,
                        std::int32_t vendorIndex,
                        std::int32_t rowIndex,
-                       std::int32_t categoryIndex,
-                       std::uint16_t itemDefinitionIndex,
+                       const ResolvedSale& sale,
                        Outcome& outcome) noexcept {
+    const std::int32_t categoryIndex = sale.categoryIndex;
+    const std::uint16_t itemDefinitionIndex = sale.itemDefinitionIndex;
     std::uint16_t rolledBounty = kUnavailableDefinitionIndex;
     if (roll_vendor_bounty(vendorIndex, categoryIndex, rolledBounty)) {
         report_purchase(opcode,
@@ -657,7 +724,9 @@ void settle_vendor_row(const middleware::web_service::Message& message,
         if (rolledBounty != kUnavailableDefinitionIndex) {
             std::uint16_t rolledCollectible = state::build_data::collectibles::kNoCollectibleIndex;
             (void)find_collectible_for_item(rolledBounty, rolledCollectible);
-            (void)grant_item_definition(message, rolledCollectible, rolledBounty, outcome);
+            // A rolled bounty is free: the row's cost fields belong to its placeholder item.
+            (void)grant_item_definition(
+                message, rolledCollectible, rolledBounty, state::vendors::Purchase{}, outcome);
         }
         return;
     }
@@ -690,13 +759,14 @@ void settle_vendor_row(const middleware::web_service::Message& message,
     }
     std::uint16_t collectibleIndex = state::build_data::collectibles::kNoCollectibleIndex;
     const bool collected = find_collectible_for_item(granted, collectibleIndex);
+    const state::vendors::Purchase purchase{.charge = sale.charge};
     report_purchase(opcode,
                     "ok",
                     collected ? "resolved" : "resolved_no_collectible",
                     vendorIndex,
                     rowIndex,
                     granted);
-    (void)grant_item_definition(message, collectibleIndex, granted, outcome);
+    (void)grant_item_definition(message, collectibleIndex, granted, purchase, outcome);
 }
 
 /**
@@ -722,17 +792,17 @@ void acquire_quest(const middleware::web_service::Message& message, Outcome& out
         return;
     }
     const std::int32_t row = request.saleIndex;
-    std::uint16_t itemDefinitionIndex = 0;
     const char* reason = "unknown";
     // A row of -1 says the tile is not a sale row at all, so the installed array answers it.
     // Reading the slot as a sale row here would grant whatever sits at that row.
     const bool rowless = row < 0;
-    // A rowless 904 is an interaction reply, so its slot names the interaction, not a sale row.
-    std::int32_t questCategoryIndex = -1;
-    const bool located =
-        rowless ? resolve_rowless_quest(request.vendorIndex, request.slotIndex, itemDefinitionIndex)
-                : resolve_vendor_row(
-                      request.vendorIndex, row, itemDefinitionIndex, questCategoryIndex, reason);
+    // A rowless 904 is an interaction reply, so its slot names the interaction, not a sale row:
+    // it has no category and no cost.
+    ResolvedSale sale{};
+    const bool located = rowless ? resolve_rowless_quest(request.vendorIndex,
+                                                         request.slotIndex,
+                                                         sale.itemDefinitionIndex)
+                                 : resolve_vendor_row(request.vendorIndex, row, sale, reason);
     if (!located) {
         report_purchase(quest::kOpcode,
                         "fail",
@@ -746,19 +816,13 @@ void acquire_quest(const middleware::web_service::Message& message, Outcome& out
         }
         return;
     }
-    settle_vendor_row(message,
-                      quest::kOpcode,
-                      request.vendorIndex,
-                      row,
-                      questCategoryIndex,
-                      itemDefinitionIndex,
-                      outcome);
+    settle_vendor_row(message, quest::kOpcode, request.vendorIndex, row, sale, outcome);
 }
 
 /**
  * Prepares one opcode-901 vendor purchase, for any Tower vendor.
- * The sale row names an item-definition index, so this hands over to the Collections grant.
- * Only a recycle row charges: an ordinary row's cost is read but not yet spent.
+ * The sale row names an item-definition index, so this hands over to the Collections grant, and
+ * the row's own cost is charged with it: a purchase that cannot pay grants nothing.
  */
 void purchase_item(const middleware::web_service::Message& message, Outcome& outcome) noexcept {
     namespace purchase = middleware::web_service::messages::opcode901;
@@ -767,11 +831,9 @@ void purchase_item(const middleware::web_service::Message& message, Outcome& out
         report_purchase(purchase::kOpcode, "fail", "payload", -1, -1, kUnavailableDefinitionIndex);
         return;
     }
-    std::uint16_t itemDefinitionIndex = 0;
     const char* reason = "unknown";
-    std::int32_t categoryIndex = -1;
-    if (!resolve_vendor_row(
-            request.vendorIndex, request.saleIndex, itemDefinitionIndex, categoryIndex, reason)) {
+    ResolvedSale sale{};
+    if (!resolve_vendor_row(request.vendorIndex, request.saleIndex, sale, reason)) {
         report_purchase(purchase::kOpcode,
                         "fail",
                         reason,
@@ -780,13 +842,8 @@ void purchase_item(const middleware::web_service::Message& message, Outcome& out
                         kUnavailableDefinitionIndex);
         return;
     }
-    settle_vendor_row(message,
-                      purchase::kOpcode,
-                      request.vendorIndex,
-                      request.saleIndex,
-                      categoryIndex,
-                      itemDefinitionIndex,
-                      outcome);
+    settle_vendor_row(
+        message, purchase::kOpcode, request.vendorIndex, request.saleIndex, sale, outcome);
 }
 
 } // namespace sunrise::server::web_service

+ 24 - 6
Sunrise/src/state/build_data/cache/records/cache_vendor_records.cpp

@@ -74,28 +74,46 @@ bool decode(const VendorDefinitionRecord& record, vendors::Definition& value) no
     return true;
 }
 
-/** Encodes one vendor sale row. */
+/** Encodes one vendor sale row. Unused cost rows stay zero so the packed row always matches. */
 bool encode(const vendors::SaleRow& value, VendorSaleRowRecord& record) noexcept {
     record = {};
+    if (value.costCount > value.costs.size()) {
+        return false;
+    }
     record.itemIndex = value.itemIndex;
     record.secondaryItemIndex = value.secondaryItemIndex;
     record.categoryIndex = value.categoryIndex;
-    record.costQuantity = value.costQuantity;
-    record.costItemIndex = value.costItemIndex;
+    record.costCount = value.costCount;
+    for (std::size_t cost = 0; cost < value.costCount; ++cost) {
+        record.costs[cost].itemIndex = value.costs[cost].itemIndex;
+        record.costs[cost].quantity = value.costs[cost].quantity;
+    }
     return true;
 }
 
 /** Decodes one vendor sale row. */
 bool decode(const VendorSaleRowRecord& record, vendors::SaleRow& value) noexcept {
     value = {};
-    if (record.reserved != decltype(record.reserved){}) {
+    if (record.reserved != decltype(record.reserved){} || record.costCount > record.costs.size()) {
         return false;
     }
+    for (std::size_t cost = 0; cost < record.costs.size(); ++cost) {
+        const VendorSaleCostRecord& stored = record.costs[cost];
+        if (stored.reserved != 0) {
+            return false;
+        }
+        if (cost >= record.costCount) {
+            if (stored.itemIndex != 0 || stored.quantity != 0) {
+                return false;
+            }
+            continue;
+        }
+        value.costs[cost] = {stored.itemIndex, stored.quantity};
+    }
     value.itemIndex = record.itemIndex;
     value.secondaryItemIndex = record.secondaryItemIndex;
     value.categoryIndex = record.categoryIndex;
-    value.costQuantity = record.costQuantity;
-    value.costItemIndex = record.costItemIndex;
+    value.costCount = record.costCount;
     return true;
 }
 

+ 16 - 5
Sunrise/src/state/build_data/cache/records/format.h

@@ -31,7 +31,7 @@ namespace sunrise::state::build_data::cache::records {
 /** These 8 ASCII bytes mark a Sunrise build-data file. */
 inline constexpr std::array<char, 8> kCacheMagic{'S', 'U', 'N', 'R', 'I', 'S', 'E', 'B'};
 /** Bump when stored layouts or extracted values change; other versions are rebuilt. */
-inline constexpr std::uint32_t kCacheFormatVersion = 65;
+inline constexpr std::uint32_t kCacheFormatVersion = 66;
 /** Signed -1 on disk means there is no equipment slot. */
 inline constexpr std::int8_t kAbsentEquipmentSlot = -1;
 /** The standard 64-bit FNV-1a offset basis starts the payload checksum. */
@@ -546,15 +546,23 @@ struct VendorDefinitionRecord {
     std::uint16_t thirdCount{};
 };
 
+/** Disk form of one price-override row of a vendor sale row. */
+struct VendorSaleCostRecord {
+    std::uint32_t quantity{};
+    std::uint16_t itemIndex{};
+    /** Must be zero, so the packed cost row always matches. */
+    std::uint16_t reserved{};
+};
+
 /** Disk form of one vendor sale row. */
 struct VendorSaleRowRecord {
     std::int32_t categoryIndex{};
-    std::uint32_t costQuantity{};
+    std::array<VendorSaleCostRecord, vendors::kSaleCostCapacity> costs{};
     std::uint16_t itemIndex{};
     std::uint16_t secondaryItemIndex{};
-    std::uint16_t costItemIndex{};
+    std::uint8_t costCount{};
     /** Must be zero, so the packed sale row always matches. */
-    std::uint16_t reserved{};
+    std::array<std::uint8_t, 3> reserved{};
 };
 
 /** Disk form of one vendor category row. */
@@ -588,7 +596,10 @@ static_assert(sizeof(SpawnPointRecord)
 static_assert(sizeof(VendorIndexRecord) == 2 * sizeof(std::uint32_t) + 2 * sizeof(std::uint16_t));
 static_assert(sizeof(VendorDefinitionRecord)
               == 14 * sizeof(std::uint32_t) + 4 * sizeof(std::uint16_t));
-static_assert(sizeof(VendorSaleRowRecord) == 4 * sizeof(std::uint16_t) + 2 * sizeof(std::uint32_t));
+static_assert(sizeof(VendorSaleCostRecord) == sizeof(std::uint32_t) + 2 * sizeof(std::uint16_t));
+static_assert(sizeof(VendorSaleRowRecord)
+              == sizeof(std::int32_t) + vendors::kSaleCostCapacity * sizeof(VendorSaleCostRecord)
+                     + 2 * sizeof(std::uint16_t) + 4 * sizeof(std::uint8_t));
 static_assert(sizeof(VendorInstalledRowRecord) == sizeof(std::uint32_t));
 static_assert(sizeof(HashNameRecord)
               == hash_names::kNameLength + sizeof(std::uint32_t) + 4 * sizeof(std::uint8_t));

+ 20 - 5
Sunrise/src/state/build_data/vendors/definition.h

@@ -1,5 +1,6 @@
 #pragma once
 
+#include <array>
 #include <cstddef>
 #include <cstdint>
 
@@ -83,21 +84,35 @@ struct Definition {
     std::uint16_t thirdCount{};
 };
 
-/** A sale row charging nothing carries this instead of a cost item. */
+/** A price-override row naming no item carries this. */
 inline constexpr std::uint16_t kAbsentCostItem = 0xFFFFU;
+/** Price-override rows one sale row may declare. A row declaring more is refused, not truncated. */
+inline constexpr std::size_t kSaleCostCapacity = 4;
+
+/**
+ * One price-override row (sale row +32 array, `kSaleCostRowClass`): what the sale charges.
+ * Observed on Xûr's definition: item 128 with 29, 23, 97 and 9 units, which are exactly the
+ * Legendary Shard prices of his weapon, armour, Fated Engram and Invitation of the Nine rows.
+ */
+struct SaleCost {
+    /** Override row +0. Cost item-definition index. */
+    std::uint16_t itemIndex{kAbsentCostItem};
+    /** Override row +4. Units charged. */
+    std::uint32_t quantity{};
+};
 
 /** One sale row of one vendor definition. */
 struct SaleRow {
     /** Row +100. The row's vendor category. The catalog bounds it by the category count. */
     std::int32_t categoryIndex{};
-    /** First price-override row's charged units. Zero when the row charges nothing. */
-    std::uint32_t costQuantity{};
     /** Row +70. Main sale item-definition index. */
     std::uint16_t itemIndex{};
     /** Row +176. `kAbsentSecondaryItem` when the row names none. */
     std::uint16_t secondaryItemIndex{};
-    /** First price-override row's item, or `kAbsentCostItem` when the row charges nothing. */
-    std::uint16_t costItemIndex{kAbsentCostItem};
+    /** Every price-override row, in declared order. The row charges all of them together. */
+    std::array<SaleCost, kSaleCostCapacity> costs{};
+    /** Rows of `costs` in use. Zero when the row charges nothing. */
+    std::uint8_t costCount{};
 };
 
 /** One category row, reduced to the definition hash a rowless request resolves through. */

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

@@ -8,6 +8,7 @@
 
 #include "../build_data/items/quest_initialization.h"
 #include "../build_data/records/definition.h"
+#include "../vendors/purchase.h"
 #include "state.h"
 
 namespace sunrise::state::account::settings {
@@ -533,6 +534,22 @@ set_selected_title(std::uint16_t recordIndex, std::uint64_t& characterSoid, bool
 [[nodiscard]] bool prepare_item_acquisition_for_item(std::uint16_t itemDefinitionIndex,
                                                      PendingItemAcquisition& mutation) noexcept;
 
+/**
+ * Prepares one vendor sale row's character-item grant, charging the row's own cost instead of a
+ * collectible's material set.
+ * @param collectibleIndex Collections row that owns the item, or kNoCollectibleIndex.
+ * @param definitionHash Installed item definition the row sells.
+ * @param purchase The row's cost.
+ * @param mutation Gets a checked after-image without changing account State.
+ * @param refusal Receives why the cost refused, or none when the grant itself refused.
+ * @return True when the charge and the grant both fit the account.
+ */
+[[nodiscard]] bool prepare_vendor_item_acquisition(std::uint16_t collectibleIndex,
+                                                   std::uint32_t definitionHash,
+                                                   const vendors::Purchase& purchase,
+                                                   PendingItemAcquisition& mutation,
+                                                   vendors::ChargeRefusal& refusal) noexcept;
+
 /** Prepares one fixed wrapper expansion without changing account State. */
 [[nodiscard]] bool prepare_direct_item_bundle(std::uint32_t sourceDefinitionHash,
                                               std::span<const std::uint16_t> itemDefinitionIndices,
@@ -607,6 +624,23 @@ prepare_profile_item_acquisition_for_item(std::uint16_t itemDefinitionIndex,
                                           std::int32_t quantity,
                                           PendingProfileItemAcquisition& mutation) noexcept;
 
+/**
+ * Prepares one vendor sale row's profile-stack grant, charging the row's own cost instead of a
+ * collectible's material set.
+ * @param collectibleIndex Collections row that owns the item, or kNoCollectibleIndex.
+ * @param definitionHash Installed stackable definition the row sells.
+ * @param purchase The row's cost.
+ * @param mutation Gets the checked profile before/after images without changing account State.
+ * @param refusal Receives why the cost refused, or none when the grant itself refused.
+ * @return True when the charge and one unit of the item both fit the profile.
+ */
+[[nodiscard]] bool
+prepare_vendor_profile_item_acquisition(std::uint16_t collectibleIndex,
+                                        std::uint32_t definitionHash,
+                                        const vendors::Purchase& purchase,
+                                        PendingProfileItemAcquisition& mutation,
+                                        vendors::ChargeRefusal& refusal) noexcept;
+
 /**
  * Materializes a prepared profile acquisition over the current account only while its complete
  * profile-inventory view is unchanged. This is the account object encoded before commit.

+ 112 - 5
Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp

@@ -199,8 +199,8 @@ bool prepare_item_acquisition(std::uint16_t collectibleIndex,
 
     AccountState chargedAccount = account;
     bool profileChanged = false;
-    // 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.
+    // Nothing is charged without a collectible: a Collections pull pays with the collectible's
+    // material requirements. A vendor row's own cost is spent by the prepare_vendor_* siblings.
     if (hasCollectible
         && !apply_collection_materials(account, collectible, chargedAccount, profileChanged)) {
         return false;
@@ -217,6 +217,65 @@ bool prepare_item_acquisition(std::uint16_t collectibleIndex,
         mutation);
 }
 
+/**
+ * A sale row's cost replaces the collectible's material set; the collectible, when the row has
+ * one, still names the grant so commit can re-check it.
+ * @param collectibleIndex Collections row that owns the item, or kNoCollectibleIndex.
+ * @param definitionHash Item definition the row sells.
+ * @param purchase The row's cost.
+ * @param mutation Receives a pending grant; prepared is set only on success.
+ * @param refusal Receives why the cost refused, or none when the grant itself refused.
+ * @return False when identity, cost, capacity, or saved state prevent the grant.
+ */
+bool prepare_vendor_item_acquisition(std::uint16_t collectibleIndex,
+                                     std::uint32_t definitionHash,
+                                     const vendors::Purchase& purchase,
+                                     PendingItemAcquisition& mutation,
+                                     vendors::ChargeRefusal& refusal) noexcept {
+    const std::lock_guard lock(investment::store::g_mutex);
+    mutation = {};
+    refusal = vendors::ChargeRefusal::none;
+    const AccountState account = account_snapshot();
+    build_data::collectibles::Definition collectible{};
+    build_data::items::Definition grantedDefinition{};
+    const bool hasCollectible = collectibleIndex != build_data::collectibles::kNoCollectibleIndex;
+    if (definitionHash == authored_inventory::kNoDefinitionHash || !account::valid(account)
+        || !valid_profile_inventory(account)) {
+        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) {
+            return false;
+        }
+    } else if (!build_data::find_item_definition_hash(definitionHash, grantedDefinition)
+               || grantedDefinition.definitionHash != definitionHash) {
+        return false;
+    }
+
+    AccountState chargedAccount = account;
+    bool profileChanged = false;
+    if (!apply_sale_charge(account, purchase.charge, chargedAccount, profileChanged, refusal)) {
+        return false;
+    }
+
+    // Commit re-checks the collectible's own cost fields, so the mutation carries those and the
+    // sale charge is proven only by its before/after profile images.
+    return finalize_item_acquisition(
+        account,
+        chargedAccount,
+        definitionHash,
+        profileChanged,
+        {.materialRequirementSetHash = collectible.materialRequirementSetHash,
+         .collectibleIndex = collectibleIndex,
+         .materialRequirementCount = collectible.materialRequirementCount},
+        mutation);
+}
+
 /**
  * Direct grants share quest-state checks but do not charge Collections materials.
  * @param itemDefinitionIndex Item-table row to grant to the selected character.
@@ -736,8 +795,8 @@ bool prepare_profile_item_acquisition(std::uint16_t collectibleIndex,
     }
     AccountState chargedAccount = account;
     bool materialsChanged = false;
-    // 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.
+    // Nothing is charged without a collectible: a Collections pull pays with the collectible's
+    // material requirements. A vendor row's own cost is spent by the prepare_vendor_* siblings.
     if (collectibleIndex != build_data::collectibles::kNoCollectibleIndex
         && !apply_collection_materials(account, collectible, chargedAccount, materialsChanged)) {
         return false;
@@ -759,6 +818,53 @@ bool prepare_profile_item_acquisition(std::uint16_t collectibleIndex,
         mutation);
 }
 
+/** Prepares one vendor sale row's profile-stack grant, charging the row's own cost. */
+bool prepare_vendor_profile_item_acquisition(std::uint16_t collectibleIndex,
+                                             std::uint32_t definitionHash,
+                                             const vendors::Purchase& purchase,
+                                             PendingProfileItemAcquisition& mutation,
+                                             vendors::ChargeRefusal& refusal) noexcept {
+    mutation = {};
+    refusal = vendors::ChargeRefusal::none;
+    const AccountState account = account_snapshot();
+    build_data::collectibles::Definition collectible{};
+    build_data::items::Definition item{};
+    item_details::Definition detail{};
+    if (definitionHash == authored_inventory::kNoDefinitionHash || !account::valid(account)
+        || !valid_profile_inventory(account)
+        || !build_data::find_item_definition_hash(definitionHash, item)
+        || (collectibleIndex != build_data::collectibles::kNoCollectibleIndex
+            && (!build_data::find_collectible_definition(collectibleIndex, collectible)
+                || collectible.itemDefinitionIndex
+                       == build_data::collectibles::kUnavailableItemDefinitionIndex
+                || item.definitionIndex != collectible.itemDefinitionIndex))
+        || !resolve_profile_item(item.definitionIndex, item, detail)
+        || item.definitionHash != definitionHash) {
+        return false;
+    }
+    AccountState chargedAccount = account;
+    bool profileChanged = false;
+    if (!apply_sale_charge(account, purchase.charge, chargedAccount, profileChanged, refusal)) {
+        return false;
+    }
+    const bool actionSource =
+        build_data::is_profile_action_source(item.definitionIndex, item.bucketId);
+
+    // Commit re-checks the collectible's own cost fields, so the mutation carries those and the
+    // sale charge is proven only by its before/after profile images.
+    return finalize_profile_item_acquisition(
+        account,
+        chargedAccount,
+        definitionHash,
+        detail,
+        actionSource,
+        1,
+        {.materialRequirementSetHash = collectible.materialRequirementSetHash,
+         .collectibleIndex = collectibleIndex,
+         .materialRequirementCount = collectible.materialRequirementCount},
+        mutation);
+}
+
 /** Prepares one direct profile-stack grant, with no Collections row or material charge. */
 bool prepare_profile_item_acquisition_for_item(std::uint16_t itemDefinitionIndex,
                                                std::int32_t quantity,
@@ -792,7 +898,8 @@ bool preview_profile_item_acquisition(const PendingProfileItemAcquisition& mutat
     return materialize_profile_acquisition(current, mutation, after);
 }
 
-/** Commits one profile-stack after-image only while its exact prepare-time view remains current. */
+/** Commits one profile-stack after-image only while its exact prepare-time view remains
+ * current. */
 bool commit_profile_item_acquisition(PendingProfileItemAcquisition& mutation) noexcept {
     const PendingProfileItemAcquisition& prepared = mutation;
     const PendingConsumption consume{mutation};

+ 64 - 0
Sunrise/src/state/runtime/state_account_profile_runtime.cpp

@@ -294,6 +294,70 @@ apply_collection_materials(const AccountState& before,
         changed);
 }
 
+/** One vendor price-override row, shaped so the material engine consumes it unchanged. */
+struct SaleRequirement {
+    std::uint16_t itemDefinitionIndex{};
+    std::uint32_t quantity{};
+    bool deleteOnAction{true};
+};
+
+/** @return True when the engine could debit this item: a stackable profile item, not a source. */
+[[nodiscard]] static bool payable_cost_item(std::uint16_t itemDefinitionIndex) noexcept {
+    build_data::items::Definition definition{};
+    item_details::Definition detail{};
+    inventory_buckets::Descriptor bucket{};
+    return itemDefinitionIndex != build_data::vendors::kAbsentCostItem
+           && build_data::find_item_definition_index(itemDefinitionIndex, definition)
+           && definition.definitionIndex == itemDefinitionIndex
+           && build_data::find_configured_item_detail(itemDefinitionIndex, detail)
+           && detail.definitionIndex == itemDefinitionIndex
+           && detail.definitionHash == definition.definitionHash
+           && detail.bucketId == definition.bucketId
+           && detail.instancedDefinitionState == item_details::InstancedDefinitionState::stackable
+           && build_data::find_inventory_bucket_descriptor(definition.bucketId, bucket)
+           && bucket.arraySelector == inventory_buckets::ArraySelector::profile
+           && !build_data::is_profile_action_source(definition.definitionIndex,
+                                                    definition.bucketId);
+}
+
+[[nodiscard]] bool apply_sale_charge(const AccountState& before,
+                                     const vendors::Charge& charge,
+                                     AccountState& after,
+                                     bool& changed,
+                                     vendors::ChargeRefusal& refusal) noexcept {
+    after = before;
+    changed = false;
+    refusal = vendors::ChargeRefusal::none;
+    if (charge.is_free()) {
+        return true;
+    }
+    std::array<SaleRequirement, build_data::vendors::kSaleCostCapacity> requirements{};
+    std::size_t requirementCount = 0;
+    for (const build_data::vendors::SaleCost& cost : charge.entries()) {
+        if (cost.quantity == 0) {
+            continue;
+        }
+        // A cost row the engine cannot debit is a row this build does not understand, which is
+        // refused as its own thing rather than reported as an empty wallet.
+        if (!payable_cost_item(cost.itemIndex)) {
+            refusal = vendors::ChargeRefusal::malformedCost;
+            return false;
+        }
+        requirements[requirementCount++] = {cost.itemIndex, cost.quantity, true};
+    }
+    if (apply_material_requirements<SaleRequirement>(
+            before,
+            std::span<const SaleRequirement>{requirements.data(), requirementCount},
+            after,
+            changed)) {
+        return true;
+    }
+    after = before;
+    changed = false;
+    refusal = vendors::ChargeRefusal::insufficient;
+    return false;
+}
+
 /** @return True when the account holds the requested socket-action source. */
 [[nodiscard]] bool holds_plug_source(const AccountState& account,
                                      std::uint32_t definitionHash) noexcept {

+ 10 - 0
Sunrise/src/state/runtime/state_account_transaction_helpers.h

@@ -99,6 +99,16 @@ apply_collection_materials(const AccountState& before,
                            const build_data::collectibles::Definition& collectible,
                            AccountState& after,
                            bool& changed) noexcept;
+/**
+ * Applies one sale row's cost through the same validation Collections materials use.
+ * @param refusal Receives why the charge was refused, or none.
+ * @return True when every cost row was payable and paid, or the row was free.
+ */
+[[nodiscard]] bool apply_sale_charge(const AccountState& before,
+                                     const vendors::Charge& charge,
+                                     AccountState& after,
+                                     bool& changed,
+                                     vendors::ChargeRefusal& refusal) noexcept;
 [[nodiscard]] bool
 valid_profile_mutation_shape(const PendingProfileItemAcquisition& mutation) noexcept;
 [[nodiscard]] bool materialize_profile_acquisition(const AccountState& current,

+ 52 - 0
Sunrise/src/state/vendors/charge.h

@@ -0,0 +1,52 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+#include "../build_data/vendors/definition.h"
+
+namespace sunrise::state::vendors {
+
+/**
+ * What one vendor sale row charges: every price-override row it declares, spent together.
+ * Every path that spends a row's cost builds one of these from the row, so the cost fields are
+ * read in exactly one place and a free row is recognised the same way everywhere.
+ */
+struct Charge {
+    std::array<build_data::vendors::SaleCost, build_data::vendors::kSaleCostCapacity> costs{};
+    std::uint8_t count{};
+
+    /** @return The cost one sale row carries. */
+    [[nodiscard]] static constexpr Charge of(const build_data::vendors::SaleRow& row) noexcept {
+        return {row.costs, row.costCount};
+    }
+
+    /** @return The declared cost rows, in order. */
+    [[nodiscard]] std::span<const build_data::vendors::SaleCost> entries() const noexcept {
+        return {costs.data(), count > costs.size() ? std::size_t{0} : std::size_t{count}};
+    }
+
+    /** @return True when nothing is charged: no cost row names an item with a nonzero quantity. */
+    [[nodiscard]] constexpr bool is_free() const noexcept {
+        for (std::size_t index = 0; index < count && index < costs.size(); ++index) {
+            if (costs[index].itemIndex != build_data::vendors::kAbsentCostItem
+                && costs[index].quantity != 0) {
+                return false;
+            }
+        }
+        return true;
+    }
+};
+
+/** Why one sale charge was refused. */
+enum class ChargeRefusal : std::uint8_t {
+    none,
+    /** A cost row names something the account cannot pay with: not a stackable profile item. */
+    malformedCost,
+    /** Every cost row is payable, but the account holds too little of one. */
+    insufficient,
+};
+
+} // namespace sunrise::state::vendors

+ 13 - 0
Sunrise/src/state/vendors/purchase.h

@@ -0,0 +1,13 @@
+#pragma once
+
+#include "charge.h"
+
+namespace sunrise::state::vendors {
+
+/** Everything one vendor sale row asks of the account beyond the item it grants. */
+struct Purchase {
+    /** The row's cost, spent with the grant. */
+    Charge charge{};
+};
+
+} // namespace sunrise::state::vendors