소스 검색

Publish the vendor catalog for vendors named by hash, and survive a definition that does not fit

The vendor pass read definitions for the head of the index, which
assumed the Tower's vendors sit low in it. They do not - the Drifter is
row 195 - so nothing past the head ever resolved. vendor_catalog.txt
names the vendors to read by definition hash, which is stable where a
row position is not; the pass checks each name against the index it has
just read, logs one it does not carry, and fills the room left from the
head. It runs in one pass.

A definition that cannot be read or cannot fit the banks now costs that
vendor alone. It used to fail the whole pass, the empty catalog was
cached, and every later boot restored it. A skipped definition leaves
both row banks exactly as it found them, so the next one's offsets still
validate. A cached boot that carries no catalog retries the pass once.

The sale row's +100 is named categoryIndex, which correlating 3,304 rows
against the manifest settled it to be. collectibles::find_granting finds
the collectible that grants an item without copying the table, and
kNoCollectibleIndex names an item that has none. Cache format moves to
46: the cache stores the vendor domains and the pass now publishes
partial catalogs where it published all or nothing.
chnsw 6 일 전
부모
커밋
00dad87249

+ 12 - 0
Sunrise/src/client/content/investment/investment_refresh.cpp

@@ -63,6 +63,18 @@ bool refresh() noexcept {
         // The same lock as the extraction path. A cache write holds its own lock across file
         // calls, so a held thread stopped inside one would deadlock the freeze below.
         const std::lock_guard lock(g_refreshLock);
+        // The vendor catalog is deliberately not part of `ready()` - a boot without vendors is
+        // still a boot - but a restored cache can carry every mapping domain and no catalog,
+        // because the boot that wrote it lost the vendor pass. Every domain in `ready()` retries
+        // through the pass below until it publishes; this is the one domain that gate skips, so
+        // it gets one retry here. Once per session, because a pass that failed against these
+        // packages will keep failing against them, and its own log lines already say why.
+        static bool vendorRetryDone = false;
+        if (!vendorRetryDone && !state::build_data::vendor_catalog_ready()
+            && items::packages::readable()) {
+            vendorRetryDone = true;
+            (void)items::packages::build();
+        }
         const bool persisted = state::ensure_profile_item_identities()
                                && state::ensure_character_subclasses()
                                && emote_collection_settled()

+ 57 - 3
Sunrise/src/client/content/items/packages/package_item_build.cpp

@@ -1,9 +1,11 @@
 #include <Windows.h>
 
 #include <array>
+#include <span>
 
 #include "../../../../core/filesystem/path.h"
 #include "../../../../core/logging/log.h"
+#include "../../../../core/settings/rule_text.h"
 #include "../../../../middleware/content/packages/reader/reader.h"
 #include "../../../../middleware/content/packages/tables/definition_index_table.h"
 #include "../../../../middleware/content/packages/tables/items.h"
@@ -14,6 +16,7 @@
 #include "../../../../state/build_data/progressions/definition.h"
 #include "../../../../state/build_data/runtime.h"
 #include "../../../../state/build_data/socket_entry_lists/definition.h"
+#include "../../../../state/build_data/vendors/vendor_catalog.h"
 #include "../../../../state/content/content_catalog.h"
 #include "../../../../state/runtime/runtime.h"
 #include "../../../memory/current_process_memory.h"
@@ -21,6 +24,7 @@
 #include "../../hash_names/hash_name_build.h"
 #include "../../scenarios/scenario_build.h"
 #include "../../spawn_sets/spawn_set_build.h"
+#include "../../vendors/vendor_build.h"
 #include "build.h"
 #include "internal.h"
 #include "package_socket_plug_build.h"
@@ -28,6 +32,54 @@
 namespace sunrise::client::content::items::packages {
 namespace {
 
+/**
+ * Reads the vendors to publish definitions for, by definition hash, from `vendor_catalog.txt`.
+ *
+ * A row position is not a stable name for a vendor and the useful ones are not all at the head of
+ * the index, so the list is authored by hash. An absent or empty file leaves the caller with the
+ * leading window it used before.
+ *
+ * @param hashes Receives the requested definition hashes.
+ * @return How many were read.
+ */
+[[nodiscard]] std::size_t read_vendor_hashes(std::span<std::uint32_t> hashes) noexcept {
+    static std::array<char, core::rule_text::kRuleTextCapacity> text{};
+    if (!core::path::read_artifact_text(L"vendor_catalog.txt", text)) {
+        return 0;
+    }
+    std::size_t count = 0;
+    core::rule_text::Cursor rules{text.data()};
+    while (count < hashes.size() && rules.seek_field()) {
+        const std::uint32_t parsed = rules.read_hex();
+        if (parsed != 0) {
+            hashes[count++] = parsed;
+        }
+    }
+    return count;
+}
+
+/**
+ * Publishes the vendor catalog, index and definitions both.
+ *
+ * `vendors::build` reads the whole index and a definition for each vendor named by hash, filling
+ * any room left from the head of the index. The names come from `vendor_catalog.txt`: a row
+ * position is not a stable name for a vendor and the useful ones are not all at the head - the
+ * Drifter is row 195, so every request against him once failed to resolve a definition that had
+ * never been read.
+ *
+ * @param source Package directory and borrowed block keys.
+ * @param scratch Block storage shared with the other content passes.
+ */
+void build_vendor_catalog(const reader::Source& source, reader::Scratch& scratch) noexcept {
+    namespace vendor_domain = state::build_data::vendors;
+    if (state::build_data::vendor_catalog_ready()) {
+        return;
+    }
+    static std::array<std::uint32_t, vendor_domain::kDefinitionCapacity> named{};
+    const std::size_t namedCount = read_vendor_hashes(named);
+    (void)content::vendors::build(source, scratch, std::span(named).first(namedCount));
+}
+
 /** @return True when every domain owned by the package pass is published. */
 [[nodiscard]] bool package_domains_ready() noexcept {
     return state::build_data::item_definitions_ready()
@@ -66,9 +118,6 @@ namespace {
 
 /** Publishes the dense item table from the installed packages, once. */
 bool build() noexcept {
-    if (package_domains_ready()) {
-        return true;
-    }
     static Storage storage{};
     reader::BlockKeys keys{};
     core::path::Buffer directory{};
@@ -90,6 +139,11 @@ bool build() noexcept {
         (void)content::scenarios::build(packageSource, storage.scratch);
         (void)content::spawn_sets::build(packageSource, storage.scratch);
         (void)content::hash_names::build(packageSource, storage.scratch);
+        build_vendor_catalog(packageSource, storage.scratch);
+        if (package_domains_ready()) {
+            SecureZeroMemory(&keys, sizeof keys);
+            return true;
+        }
     }
     if (root_domains_ready()) {
         SecureZeroMemory(&keys, sizeof keys);

+ 2 - 2
Sunrise/src/client/content/vendors/layout.h

@@ -25,8 +25,8 @@ inline constexpr std::size_t kSaleExpression8Offset = 8;
 inline constexpr std::size_t kSaleNestedRecordOffset = 32;
 /** Sale row main item-definition index. */
 inline constexpr std::size_t kSaleItemIndexOffset = 70;
-/** Sale row installed/runtime table index. */
-inline constexpr std::size_t kSaleInstalledIndexOffset = 100;
+/** Sale row vendor category index. */
+inline constexpr std::size_t kSaleCategoryIndexOffset = 100;
 /** Sale row scalar with no closed consumer. */
 inline constexpr std::size_t kSaleRaw104Offset = 104;
 /** Sale row scalar with no closed consumer. */

+ 99 - 11
Sunrise/src/client/content/vendors/package_vendor_build.cpp

@@ -161,7 +161,7 @@ read_index(const reader::Source& source, reader::Scratch& scratch, Storage& stor
         value.rowIndex = static_cast<std::uint16_t>(row);
         if (!read(blob, at + kSaleItemIndexOffset, value.itemIndex)
             || !read(blob, at + kSaleSecondaryItemOffset, value.secondaryItemIndex)
-            || !read(blob, at + kSaleInstalledIndexOffset, value.installedIndex)
+            || !read(blob, at + kSaleCategoryIndexOffset, value.categoryIndex)
             || !read(blob, at + kSaleRaw104Offset, value.raw104)
             || !read(blob, at + kSaleRaw108Offset, value.raw108)
             || !read(blob, at + kSaleRaw172Offset, value.raw172)
@@ -251,10 +251,18 @@ read_index(const reader::Source& source, reader::Scratch& scratch, Storage& stor
     definition.thirdCount = third.count;
     definition.saleRowOffset = static_cast<std::uint32_t>(storage.saleRowCount);
     definition.installedRowOffset = static_cast<std::uint32_t>(storage.installedRowCount);
+    // Each row reader advances its bank before the other runs, so a definition whose sale rows fit
+    // but whose installed rows do not would leave orphan sale rows behind; the next definition's
+    // offset then carries the gap and `valid()` rejects the whole set. A skipped definition has to
+    // leave both banks exactly as it found them.
+    const std::size_t saleRowsBefore = storage.saleRowCount;
+    const std::size_t installedRowsBefore = storage.installedRowCount;
     if (!read(blob, kResetIntervalOffset, definition.resetIntervalRaw)
         || !read(blob, kResetPhaseOffset, definition.resetPhaseRaw)
         || !read_sale_rows(blob, definition, storage)
         || !read_installed_rows(blob, definition, storage)) {
+        storage.saleRowCount = saleRowsBefore;
+        storage.installedRowCount = installedRowsBefore;
         return false;
     }
     storage.definitions[storage.definitionCount] = definition;
@@ -262,6 +270,57 @@ read_index(const reader::Source& source, reader::Scratch& scratch, Storage& stor
     return true;
 }
 
+/**
+ * Chooses which definitions this pass reads.
+ *
+ * The named hashes come first, each checked against the index just read: a hash the index does
+ * not carry is a mistyped rule, and dropping it silently reads exactly like the vendor resolving
+ * - until a request against it fails with no line to say the catalog never held it. A hash named
+ * twice would spend two of the few definition slots on one vendor. Whatever room is left is
+ * filled from the head of the index.
+ *
+ * @param storage Pass storage holding the index.
+ * @param namedHashes Hashes named by the rule file, in priority order.
+ * @param hashes Receives the definitions to read.
+ * @return How many were chosen.
+ */
+[[nodiscard]] std::size_t select_definitions(const Storage& storage,
+                                             std::span<const std::uint32_t> namedHashes,
+                                             std::span<std::uint32_t> hashes) noexcept {
+    std::size_t wanted = 0;
+    for (std::size_t at = 0; at < namedHashes.size() && wanted < hashes.size(); ++at) {
+        bool present = false;
+        for (std::size_t held = 0; held < wanted && !present; ++held) {
+            present = hashes[held] == namedHashes[at];
+        }
+        if (present) {
+            continue;
+        }
+        bool installed = false;
+        for (std::size_t row = 0; row < storage.indexCount && !installed; ++row) {
+            installed = storage.index[row].definitionHash == namedHashes[at];
+        }
+        if (installed) {
+            hashes[wanted++] = namedHashes[at];
+            continue;
+        }
+        core::log::writef(core::log::Channel::state,
+                          core::log::Level::warn,
+                          "ev=vendor stage=catalog result=skip reason=unknown_hash hash=0x%08X",
+                          namedHashes[at]);
+    }
+    for (std::size_t row = 0; row < storage.indexCount && wanted < hashes.size(); ++row) {
+        bool present = false;
+        for (std::size_t at = 0; at < wanted && !present; ++at) {
+            present = hashes[at] == storage.index[row].definitionHash;
+        }
+        if (!present) {
+            hashes[wanted++] = storage.index[row].definitionHash;
+        }
+    }
+    return wanted;
+}
+
 /** @param hashes Requested hashes. @param hash Index row hash. @return True when requested. */
 [[nodiscard]] bool requested(std::span<const std::uint32_t> hashes, std::uint32_t hash) noexcept {
     for (const std::uint32_t value : hashes) {
@@ -275,22 +334,25 @@ read_index(const reader::Source& source, reader::Scratch& scratch, Storage& stor
 /**
  * Reports the pass so a boot with no vendor catalog says which step lost the rows.
  * @param storage Pass storage holding every count.
+ * @param skipped Requested definitions that could not be read or could not fit.
  * @param result Outcome text for the log line.
  */
-void report(const Storage& storage, const char* result) noexcept {
+void report(const Storage& storage, std::size_t skipped, const char* result) noexcept {
     std::array<char, core::log::kLineCapacity> line{};
     const int written = std::snprintf(line.data(),
                                       line.size(),
                                       "ev=build_data stage=vendors index=%zu definitions=%zu "
-                                      "sale=%zu installed=%zu result=%s",
+                                      "sale=%zu installed=%zu skipped=%zu result=%s",
                                       storage.indexCount,
                                       storage.definitionCount,
                                       storage.saleRowCount,
                                       storage.installedRowCount,
+                                      skipped,
                                       result);
     if (written > 0) {
         core::log::write(core::log::Channel::state,
-                         storage.indexCount != 0 ? core::log::Level::info : core::log::Level::warn,
+                         storage.indexCount != 0 && skipped == 0 ? core::log::Level::info
+                                                                 : core::log::Level::warn,
                          {line.data(), static_cast<std::size_t>(written)});
     }
 }
@@ -300,31 +362,57 @@ void report(const Storage& storage, const char* result) noexcept {
 /** Extracts and publishes the vendor catalog from the installed packages. */
 bool build(const reader::Source& source,
            reader::Scratch& scratch,
-           std::span<const std::uint32_t> definitionHashes) noexcept {
+           std::span<const std::uint32_t> namedHashes) noexcept {
     if (state::build_data::vendor_catalog_ready()) {
         return true;
     }
     static Storage storage{};
     storage = {};
     if (!read_index(source, scratch, storage)) {
-        report(storage, "index");
+        report(storage, 0, "index");
         return false;
     }
+    static std::array<std::uint32_t, domain::kDefinitionCapacity> chosen{};
+    const std::span<const std::uint32_t> definitionHashes =
+        std::span(chosen).first(select_definitions(storage, namedHashes, chosen));
     // Walking the index in order gives the ascending definition order the catalog requires.
+    //
+    // A definition that cannot be read - or cannot fit the definition or row banks - costs that
+    // vendor alone, not the pass. Failing whole here is what a full bank used to do, and it was
+    // the worst failure this domain had: the empty catalog was cached, every later boot restored
+    // it, and every vendor stayed unresolvable with one boot-time line to say why.
+    std::size_t skipped = 0;
     for (std::size_t row = 0; row < storage.indexCount; ++row) {
         const domain::IndexEntry entry = storage.index[row];
-        if (requested(definitionHashes, entry.definitionHash)
-            && !read_definition(source, scratch, entry, storage)) {
-            report(storage, "definition");
-            return false;
+        if (!requested(definitionHashes, entry.definitionHash)) {
+            continue;
+        }
+        if (read_definition(source, scratch, entry, storage)) {
+            continue;
         }
+        ++skipped;
+        core::log::writef(core::log::Channel::state,
+                          core::log::Level::warn,
+                          "ev=build_data stage=vendors result=skip hash=0x%08X row=%zu "
+                          "definitions=%zu sale=%zu",
+                          entry.definitionHash,
+                          row,
+                          storage.definitionCount,
+                          storage.saleRowCount);
     }
     const bool published = state::build_data::publish_vendor_catalog(
         std::span(storage.index).first(storage.indexCount),
         std::span(storage.definitions).first(storage.definitionCount),
         std::span(storage.saleRows).first(storage.saleRowCount),
         std::span(storage.installedRows).first(storage.installedRowCount));
-    report(storage, published ? "ok" : "publish");
+    report(storage, skipped, published ? "ok" : "publish");
+    core::log::writef(core::log::Channel::state,
+                      published ? core::log::Level::info : core::log::Level::warn,
+                      "ev=vendor stage=catalog result=%s named=%zu requested=%zu index_rows=%zu",
+                      published ? "ok" : "fail",
+                      namedHashes.size(),
+                      definitionHashes.size(),
+                      storage.indexCount);
     return published;
 }
 

+ 9 - 3
Sunrise/src/client/content/vendors/vendor_build.h

@@ -9,14 +9,20 @@ namespace sunrise::client::content::vendors {
 
 /**
  * Extracts the vendor catalog from the installed packages, once.
- * The whole index is read. A definition is read only when asked for, as each is over 100 KiB.
+ *
+ * The whole index is read. A definition is read only for a vendor asked for by hash, as each is
+ * over 100 KiB and the banks hold nowhere near all 511. The named hashes are checked against the
+ * index the pass has just read - one it does not carry is a mistyped rule and is logged - and
+ * whatever room is left is filled from the head of the index, so a short list still gets the
+ * vendors the old leading window would have covered.
+ *
  * @param source Package directory and borrowed block keys.
  * @param scratch Lock-owned block storage shared with the other content passes.
- * @param definitionHashes Vendor definition hashes to read definitions for.
+ * @param namedHashes Vendor definition hashes named by `vendor_catalog.txt`, in priority order.
  * @return True when State already holds the catalog or a full pass publishes it.
  */
 [[nodiscard]] bool build(const middleware::content::packages::reader::Source& source,
                          middleware::content::packages::reader::Scratch& scratch,
-                         std::span<const std::uint32_t> definitionHashes) noexcept;
+                         std::span<const std::uint32_t> namedHashes) noexcept;
 
 } // namespace sunrise::client::content::vendors

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

@@ -81,7 +81,7 @@ bool encode(const vendors::SaleRow& value, VendorSaleRowRecord& record) noexcept
     record.rowIndex = value.rowIndex;
     record.itemIndex = value.itemIndex;
     record.secondaryItemIndex = value.secondaryItemIndex;
-    record.installedIndex = value.installedIndex;
+    record.categoryIndex = value.categoryIndex;
     record.raw104 = value.raw104;
     record.raw108 = value.raw108;
     record.raw172 = value.raw172;
@@ -104,7 +104,7 @@ bool decode(const VendorSaleRowRecord& record, vendors::SaleRow& value) noexcept
     value.rowIndex = record.rowIndex;
     value.itemIndex = record.itemIndex;
     value.secondaryItemIndex = record.secondaryItemIndex;
-    value.installedIndex = record.installedIndex;
+    value.categoryIndex = record.categoryIndex;
     value.raw104 = record.raw104;
     value.raw108 = record.raw108;
     value.raw172 = record.raw172;

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

@@ -408,7 +408,7 @@ struct VendorSaleRowRecord {
     std::uint16_t rowIndex{};
     std::uint16_t itemIndex{};
     std::uint16_t secondaryItemIndex{};
-    std::int32_t installedIndex{};
+    std::int32_t categoryIndex{};
     std::uint32_t raw104{};
     std::uint32_t raw108{};
     std::int32_t raw172{};

+ 15 - 0
Sunrise/src/state/build_data/collectibles/collectible_catalog.cpp

@@ -106,6 +106,21 @@ bool grants_item(std::uint16_t itemDefinitionIndex) noexcept {
     return false;
 }
 
+/** Finds the collectible that grants one installed item row. */
+bool find_granting(std::uint16_t itemDefinitionIndex, std::uint16_t& collectibleIndex) noexcept {
+    if (itemDefinitionIndex == kUnavailableItemDefinitionIndex) {
+        return false;
+    }
+    const Lock::Shared guard(g_lock);
+    for (const Definition& definition : g_definitions.rows()) {
+        if (definition.itemDefinitionIndex == itemDefinitionIndex) {
+            collectibleIndex = definition.collectibleIndex;
+            return true;
+        }
+    }
+    return false;
+}
+
 /** Copies the dense rows without exposing catalog storage. */
 bool snapshot(std::span<Definition> output, std::size_t& count) noexcept {
     const std::shared_lock guard(g_lock);

+ 25 - 0
Sunrise/src/state/build_data/collectibles/collectible_catalog.h

@@ -11,6 +11,16 @@ namespace sunrise::state::build_data::collectibles {
 inline constexpr std::size_t kDefinitionCapacity = 1U << 15U;
 /** Some collectible rows deliberately do not resolve to an inventory item. */
 inline constexpr std::uint16_t kUnavailableItemDefinitionIndex = 0xFFFFU;
+
+/**
+ * Collectible row meaning "this item has no collectible".
+ *
+ * Vendor sale rows name an item, never a collectible, and bounties, quests and tokens have none at
+ * all. An acquisition carrying this index skips every collectible step - validation, the material
+ * charge, and the cost bookkeeping - and both prepare and commit must agree on it, so the
+ * consistency guard still holds rather than being bypassed.
+ */
+inline constexpr std::uint16_t kNoCollectibleIndex = 0xFFFEU;
 /** A collectible with no acquisition charge carries this native requirement-set sentinel. */
 inline constexpr std::uint16_t kUnavailableMaterialRequirementSetIndex = 0xFFFFU;
 /** Installed requirement sets contain at most six material rows. */
@@ -54,6 +64,21 @@ void clear() noexcept;
  */
 [[nodiscard]] bool grants_item(std::uint16_t itemDefinitionIndex) noexcept;
 
+/**
+ * Finds the collectible that grants one installed item row.
+ *
+ * The reverse of `find`. A vendor sale row names an item and never a collectible, while the
+ * acquisition state is keyed by collectible, so a purchase has to ask this. Walking the table under
+ * its own lock is what stops a caller copying all 32,768 rows to read one.
+ *
+ * @param itemDefinitionIndex Installed item-definition row.
+ * @param collectibleIndex Receives the first collectible naming that item, in native index order.
+ *        Left untouched when none does, so a caller's sentinel survives.
+ * @return True when a collectible grants that item.
+ */
+[[nodiscard]] bool find_granting(std::uint16_t itemDefinitionIndex,
+                                 std::uint16_t& collectibleIndex) noexcept;
+
 /** Copies every row in native collectible-index order. */
 [[nodiscard]] bool snapshot(std::span<Definition> output, std::size_t& count) noexcept;
 

+ 8 - 6
Sunrise/src/state/build_data/vendors/definition.h

@@ -9,9 +9,9 @@ namespace sunrise::state::build_data::vendors {
 /** Rows of the installed vendor index. The live table has 511. */
 inline constexpr std::size_t kIndexCapacity = 512;
 /** Vendor definitions this catalog holds rows for. A definition is read only when asked for. */
-inline constexpr std::size_t kDefinitionCapacity = 32;
+inline constexpr std::size_t kDefinitionCapacity = 48;
 /** Sale rows across every held definition. One installed definition declares 277. */
-inline constexpr std::size_t kSaleRowCapacity = 4096;
+inline constexpr std::size_t kSaleRowCapacity = 8192;
 /** Installed rows across every held definition. One installed definition declares 43. */
 inline constexpr std::size_t kInstalledRowCapacity = 2048;
 
@@ -37,8 +37,8 @@ inline constexpr std::uint32_t kSaleRowClass = 0x80807861U;
 
 /** Sale row +176 carries this when the row names no secondary item. */
 inline constexpr std::uint16_t kAbsentSecondaryItem = 0xFFFFU;
-/** Sale row +100 carries this when it selects no installed row. The client tests for it. */
-inline constexpr std::int32_t kAbsentInstalledIndex = -1;
+/** Sale row +100 carries this when the row belongs to no category. The client tests for it. */
+inline constexpr std::int32_t kAbsentCategoryIndex = -1;
 
 /** One row of the installed vendor index, which maps a vendor hash to its definition tag. */
 struct IndexEntry {
@@ -93,9 +93,11 @@ struct SaleRow {
     std::uint16_t itemIndex{};
     /** Row +176. `kAbsentSecondaryItem` when the row names none. */
     std::uint16_t secondaryItemIndex{};
-    /** Row +100. Row of the owning definition's installed array, and of a parallel runtime table.
+    /**
+     * Row +100. The row's vendor category, established by correlating 3,304 rows against the
+     * manifest. The catalog bounds it by the installed count, as it always has.
      */
-    std::int32_t installedIndex{};
+    std::int32_t categoryIndex{};
     /** Row +104, raw f32 bits. Role open. */
     std::uint32_t raw104{};
     /** Row +108. Role open. */

+ 35 - 3
Sunrise/src/state/build_data/vendors/vendor_catalog.cpp

@@ -90,10 +90,10 @@ Table<InstalledRow, kInstalledRowCapacity> g_installedRows;
                                        std::span<const SaleRow> saleRows) noexcept {
     for (std::size_t row = 0; row < definition.saleCount; ++row) {
         const SaleRow& value = saleRows[definition.saleRowOffset + row];
-        // Row +100 selects an installed row, so it is bounded before a reader strides with it.
+        // Row +100 is bounded by the installed count before any reader strides with it.
         const bool selects =
-            value.installedIndex == kAbsentInstalledIndex
-            || (value.installedIndex >= 0 && value.installedIndex < definition.installedCount);
+            value.categoryIndex == kAbsentCategoryIndex
+            || (value.categoryIndex >= 0 && value.categoryIndex < definition.installedCount);
         if (value.vendorIndex != definition.index || value.rowIndex != row || !selects) {
             return false;
         }
@@ -265,6 +265,22 @@ bool sale_rows(const Definition& definition,
         g_saleRows.rows(), definition.saleRowOffset, definition.saleCount, output, count);
 }
 
+/** Reads one sale row of one definition. */
+bool sale_row(const Definition& definition, std::size_t row, SaleRow& output) noexcept {
+    output = {};
+    if (row >= definition.saleCount) {
+        return false;
+    }
+    const Lock::Shared guard(g_lock);
+    const auto bank = g_saleRows.rows();
+    const std::size_t at = static_cast<std::size_t>(definition.saleRowOffset) + row;
+    if (at >= bank.size()) {
+        return false;
+    }
+    output = bank[at];
+    return true;
+}
+
 /** Copies the installed rows one definition owns. */
 bool installed_rows(const Definition& definition,
                     std::span<InstalledRow> output,
@@ -277,6 +293,22 @@ bool installed_rows(const Definition& definition,
                       count);
 }
 
+/** Reads one installed row of one definition. */
+bool installed_row(const Definition& definition, std::size_t row, InstalledRow& output) noexcept {
+    output = {};
+    if (row >= definition.installedCount) {
+        return false;
+    }
+    const Lock::Shared guard(g_lock);
+    const auto bank = g_installedRows.rows();
+    const std::size_t at = static_cast<std::size_t>(definition.installedRowOffset) + row;
+    if (at >= bank.size()) {
+        return false;
+    }
+    output = bank[at];
+    return true;
+}
+
 /** Copies every index row in ascending index order. */
 bool snapshot_index(std::span<IndexEntry> output, std::size_t& count) noexcept {
     const std::shared_lock guard(g_lock);

+ 24 - 0
Sunrise/src/state/build_data/vendors/vendor_catalog.h

@@ -72,6 +72,20 @@ void clear() noexcept;
 [[nodiscard]] bool
 sale_rows(const Definition& definition, std::span<SaleRow> output, std::size_t& count) noexcept;
 
+/**
+ * Reads one sale row of one definition.
+ *
+ * A purchase names a single row, and copying the definition's whole range to read it costs a
+ * bank-sized buffer per caller. This reads the one row under the catalog lock.
+ *
+ * @param definition Definition whose range is read.
+ * @param row Row ordinal inside that definition.
+ * @param output Receives the row, or a cleared row when the definition does not own it.
+ * @return True when the definition owns that row.
+ */
+[[nodiscard]] bool
+sale_row(const Definition& definition, std::size_t row, SaleRow& output) noexcept;
+
 /**
  * Copies the installed rows one definition owns, in row order.
  * @param definition Definition whose range is copied.
@@ -83,6 +97,16 @@ sale_rows(const Definition& definition, std::span<SaleRow> output, std::size_t&
                                   std::span<InstalledRow> output,
                                   std::size_t& count) noexcept;
 
+/**
+ * Reads one installed row of one definition, under the catalog lock.
+ * @param definition Definition whose range is read.
+ * @param row Row ordinal inside that definition.
+ * @param output Receives the row, or a cleared row when the definition does not own it.
+ * @return True when the definition owns that row.
+ */
+[[nodiscard]] bool
+installed_row(const Definition& definition, std::size_t row, InstalledRow& output) noexcept;
+
 /**
  * Copies every index row in ascending index order.
  * @param output Caller-owned fixed row storage.