Jelajahi Sumber

Retire unresolvable world rewards and load cached pass rows

Zach 3 minggu lalu
induk
melakukan
86d0f8004f
22 mengubah file dengan 562 tambahan dan 252 penghapusan
  1. 6 3
      Sunrise/src/client/content/items/packages/internal.h
  2. 14 13
      Sunrise/src/client/content/items/packages/package_item_build.cpp
  3. 12 8
      Sunrise/src/client/content/items/packages/package_item_rows.cpp
  4. 146 40
      Sunrise/src/client/content/items/packages/package_reward_build.cpp
  5. 11 4
      Sunrise/src/client/content/items/packages/package_reward_build.h
  6. 2 2
      Sunrise/src/client/content/items/packages/package_season_pass_build.cpp
  7. 0 4
      Sunrise/src/middleware/content/packages/tables/definition_index_table.h
  8. 91 34
      Sunrise/src/server/bap/bap_world_reward_queue.cpp
  9. 39 19
      Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp
  10. 12 3
      Sunrise/src/server/bap/internal.h
  11. 3 2
      Sunrise/src/server/web_service/web_service_record_actions.cpp
  12. 30 10
      Sunrise/src/state/build_data/cache/records/cache_investment_records.cpp
  13. 8 8
      Sunrise/src/state/build_data/rewards/definition.h
  14. 5 0
      Sunrise/src/state/build_data/rewards/reward_build_data_runtime.cpp
  15. 6 0
      Sunrise/src/state/build_data/runtime.h
  16. 5 0
      Sunrise/src/state/build_data/season_pass/season_pass_build_data_runtime.cpp
  17. 42 29
      Sunrise/src/state/rewards/reward_resolver.cpp
  18. 20 12
      Sunrise/src/state/runtime/runtime.h
  19. 69 47
      Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp
  20. 2 1
      Sunrise/src/state/runtime/state_account_runtime.cpp
  21. 37 12
      Sunrise/src/state/runtime/state_progression_runtime.cpp
  22. 2 1
      Sunrise/src/state/runtime/state_runtime.cpp

+ 6 - 3
Sunrise/src/client/content/items/packages/internal.h

@@ -157,7 +157,7 @@ struct Storage {
                state::build_data::progressions::kStepCapacity>
         progressionSteps{};
     std::size_t progressionStepCount{};
-    /** Season pass reward rows and the wrapper items they grant. */
+    /** Season pass reward rows in native claim order. */
     std::array<state::build_data::season_pass::Reward,
                state::build_data::season_pass::kRewardCapacity>
         seasonPassRewards{};
@@ -206,6 +206,9 @@ struct Storage {
 /** @return True when the catalyst catalog is published or cannot exist on this executable. */
 [[nodiscard]] bool exotic_catalysts_settled() noexcept;
 
+/** @return True when the reward graph is published or can never be. */
+[[nodiscard]] bool reward_definitions_settled() noexcept;
+
 /** Adds one native definition index to the deduplicated request set. */
 void request(std::uint16_t definitionIndex, DetailRequests& requested) noexcept;
 
@@ -323,7 +326,7 @@ read_investment_constants(const reader::Source& source,
 /**
  * Reads one root's two unlock mapping tables into the pass slot maps.
  * @param source Package source.
- * @param storage Pass storage receiving the four maps.
+ * @param storage Pass storage receiving the five maps.
  * @param root Investment root bytes.
  * @return True when both account maps read. A character map may stay unmapped.
  */
@@ -378,7 +381,7 @@ read_investment_constants(const reader::Source& source,
                                       std::span<state::build_data::progressions::Step> steps,
                                       std::size_t& stepCount) noexcept;
 
-/** Reads the season pass reward list and the wrapper items it grants. */
+/** Reads the season pass reward list, keeping an unreadable row as an unavailable one. */
 [[nodiscard]] bool build_season_pass(const reader::Source& source,
                                      Storage& storage,
                                      std::span<const std::byte> root) noexcept;

+ 14 - 13
Sunrise/src/client/content/items/packages/package_item_build.cpp

@@ -35,8 +35,7 @@ namespace {
            && state::build_data::ability_buckets_ready()
            && state::build_data::socket_entry_buckets_ready()
            && state::build_data::progression_definitions_ready()
-           && state::build_data::season_pass_ready()
-           && state::build_data::reward_definitions_ready()
+           && state::build_data::season_pass_ready() && reward_definitions_settled()
            && state::build_data::repeatable_bounties_ready()
            && state::build_data::record_definitions_ready()
            && state::build_data::node_definitions_ready()
@@ -128,8 +127,8 @@ bool build() noexcept {
             if (!state::build_data::item_definitions_ready()
                 || !state::build_data::record_definitions_ready()
                 || !state::build_data::node_definitions_ready()
-                || !state::build_data::season_pass_ready()
-                || !state::build_data::reward_definitions_ready() || !exotic_catalysts_settled()) {
+                || !state::build_data::season_pass_ready() || !reward_definitions_settled()
+                || !exotic_catalysts_settled()) {
                 reason = "unlock_maps";
                 if (!read_unlock_slot_maps(
                         source, storage, std::span<const std::byte>{storage.root})) {
@@ -187,16 +186,18 @@ bool build() noexcept {
                         std::span(storage.progressionSteps).first(storage.progressionStepCount));
                 }
             }
-            // Pass rows bind their conditions through the reward condition tables.
-            const bool conditionTablesLoaded =
-                (!state::build_data::reward_definitions_ready()
-                 || !state::build_data::season_pass_ready())
-                && storage.rewardBuild.load(
+            // Pass rows bind through the reward condition tables, which load before the pools.
+            if (!reward_definitions_settled() || !state::build_data::season_pass_ready()) {
+                (void)storage.rewardBuild.load(
                     source, storage.scratch, storage.root, storage.slotMaps);
-            if (conditionTablesLoaded && !state::build_data::season_pass_ready()
-                && build_season_pass(source, storage, std::span<const std::byte>{storage.root})) {
-                (void)state::build_data::publish_season_pass(
-                    std::span(storage.seasonPassRewards).first(storage.seasonPassRewardCount));
+            }
+            if (!state::build_data::season_pass_ready() && storage.rewardBuild.conditions.loaded()
+                && build_season_pass(source, storage, std::span<const std::byte>{storage.root})
+                && !state::build_data::publish_season_pass(
+                    std::span(storage.seasonPassRewards).first(storage.seasonPassRewardCount))) {
+                core::log::write(core::log::Channel::client,
+                                 core::log::Level::warn,
+                                 "ev=pkg stage=season_pass result=fail reason=publish");
             }
             // Records are read before nodes: a node's lore-book flag and its parent bar come
             // from the records it owns, so the record rows must already be in pass storage.

+ 12 - 8
Sunrise/src/client/content/items/packages/package_item_rows.cpp

@@ -54,16 +54,16 @@ bool exotic_catalysts_settled() noexcept {
  * @param table Located item index array within storage.child.
  * @param rowCount Starts at zero; receives the rows retained even if publication fails.
  * @param reason Receives the last stage reached or its failure reason.
- * @return True when this pass's required item domains are ready; failure may retain prior results.
+ * @return True when required non-reward item domains are ready. Failure may retain prior results.
  */
 bool build_item_rows(const reader::Source& source,
                      Storage& storage,
                      const tables::Array& table,
                      std::size_t& rowCount,
                      const char*& reason) noexcept {
-    const bool needRewards = !state::build_data::reward_definitions_ready();
+    const bool needRewards = !reward_definitions_settled();
     const bool rewardStorageReady =
-        !needRewards || storage.rewardBuild.begin_items(static_cast<std::size_t>(table.count));
+        needRewards && storage.rewardBuild.begin_items(static_cast<std::size_t>(table.count));
     const bool needDefinitions = !state::build_data::item_definitions_ready();
     const bool needDetails = !state::build_data::configured_item_details_ready();
     const bool needSocketPlugs = !state::build_data::socket_plug_rules_ready();
@@ -74,7 +74,7 @@ bool build_item_rows(const reader::Source& source,
     const bool needDetailRows = needDetails || needSocketRows;
     // Bucket equipment slots are derived from this same complete item walk, so a partial retry
     // must still revisit the table even when definitions and detail domains already published.
-    const bool needRows = needDefinitions || needDetailRows || needBuckets || needRewards;
+    const bool needRows = needDefinitions || needDetailRows || needBuckets || rewardStorageReady;
     bool published = !needRows;
     if (retainDetails && storage.details.size() != kDetailCapacity) {
         storage.details.assign(kDetailCapacity, build_details::Definition{});
@@ -115,7 +115,7 @@ bool build_item_rows(const reader::Source& source,
                                                item)) {
             continue;
         }
-        if (needRewards && rewardStorageReady && itemClass == tables::kItemDefinitionClass) {
+        if (rewardStorageReady && itemClass == tables::kItemDefinitionClass) {
             storage.rewardBuild.item(item.definitionIndex, item.definitionHash, storage.definition);
         }
         const std::uint32_t plugCategoryHash =
@@ -166,9 +166,9 @@ bool build_item_rows(const reader::Source& source,
             append_initial_plugs(item, table.count, storage.detailRequests);
         }
     }
-    if (needRewards && rewardStorageReady) {
-        (void)storage.rewardBuild.publish();
-    }
+    // A reward refusal is reported by RewardBuild and does not block the other item domains.
+    const bool rewardsPublished =
+        !needRewards || (rewardStorageReady && storage.rewardBuild.publish());
     bool requestsFit = true;
     if (needRows) {
         // Every walked entry either published a row or was skipped, so the count of one
@@ -310,6 +310,10 @@ bool build_item_rows(const reader::Source& source,
             report_ability_count(abilityCount);
         }
     }
+    // Named last; later stages overwrite the reason as they start.
+    if (published && !rewardsPublished) {
+        reason = "rewards";
+    }
     return published && state::build_data::item_definitions_ready()
            && state::build_data::configured_item_details_ready()
            && state::build_data::socket_plug_rules_ready() && exotic_catalysts_settled()

+ 146 - 40
Sunrise/src/client/content/items/packages/package_reward_build.cpp

@@ -86,6 +86,33 @@ constexpr std::uint32_t kClassRow = 0x808074FAU;
 constexpr std::size_t kUseConditionPointer = 24;
 constexpr std::uint32_t kUseConditionClass = 0x80802980U;
 
+/** Set when the installed data refuses the reward tables or graph; no retry can change that. */
+bool g_rewardsUnsupported = false;
+
+/** Logs one refused reward load, sizing or publication. */
+void report_refusal(const char* reason) noexcept {
+    core::log::writef(core::log::Channel::client,
+                      core::log::Level::warn,
+                      "ev=pkg stage=rewards result=fail reason=%s",
+                      reason);
+}
+
+/** Logs a refusal the installed data decides and settles the domain once. */
+void settle_unsupported(const char* reason) noexcept {
+    report_refusal(reason);
+    if (!g_rewardsUnsupported) {
+        g_rewardsUnsupported = true;
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         "ev=pkg stage=rewards result=unsupported");
+    }
+}
+
+/** An unread item row keeps a zero hash. */
+[[nodiscard]] bool item_read(std::span<const domain::Item> items, std::uint16_t index) noexcept {
+    return index < items.size() && items[index].definitionHash != 0;
+}
+
 /** Appends within a bank's capacity; an allocation failure is refused rather than thrown. */
 template <typename T> bool append(std::vector<T>& bank, T value, std::size_t capacity) noexcept {
     if (bank.size() >= capacity) {
@@ -153,10 +180,20 @@ bool read_class_flag(std::span<const std::byte> blob, std::uint16_t& output) noe
 
 } // namespace
 
-bool RewardBuild::entry(std::span<const std::byte> blob, std::size_t at) noexcept {
+/** @return True when the reward graph is published or can never be. */
+bool reward_definitions_settled() noexcept {
+    return state::build_data::reward_definitions_ready() || g_rewardsUnsupported;
+}
+
+bool RewardBuild::entry(std::span<const std::byte> blob,
+                        std::size_t at,
+                        const char*& fullBank) noexcept {
     domain::Entry out{};
+    bool instructionsFull = false;
     if (!read_reward_entry(blob, at, out)
-        || !conditions.read(blob, at + kEntryConditionOffset, instructions_, out.condition)) {
+        || !conditions.read(
+            blob, at + kEntryConditionOffset, instructions_, out.condition, instructionsFull)) {
+        fullBank = instructionsFull ? "instruction_capacity" : nullptr;
         return false;
     }
     out.supplementalMissing = supplementalMissing_ && out.supplementalIndex != domain::kAbsent;
@@ -170,18 +207,28 @@ bool RewardBuild::entry(std::span<const std::byte> blob, std::size_t at) noexcep
     for (std::size_t i = 0; i < rows.count; ++i) {
         const auto offset = rows.dataOffset + i * kModifierStride;
         domain::Modifier modifier{};
-        if (!conditions.read(blob, offset, instructions_, modifier.condition)
+        if (!conditions.read(blob, offset, instructions_, modifier.condition, instructionsFull)
             || !tables::read(blob, offset + kModifierValueIndexOffset, modifier.valueIndex)
             || !tables::read(blob, offset + kModifierValueOffset, modifier.value)
-            || !std::isfinite(modifier.value)
-            || !append(modifiers_, modifier, domain::kModifierCapacity)) {
+            || !std::isfinite(modifier.value)) {
+            fullBank = instructionsFull ? "instruction_capacity" : nullptr;
+            return false;
+        }
+        if (modifiers_.size() >= domain::kModifierCapacity) {
+            fullBank = "modifier_capacity";
+            return false;
+        }
+        if (!append(modifiers_, modifier, domain::kModifierCapacity)) {
             return false;
         }
     }
     std::array<domain::SocketOverride, domain::kSocketsPerItem> overrides{};
     std::size_t count = 0;
-    if (!read_reward_sockets(blob, at + kEntrySocketsOffset, overrides, count)
-        || count > domain::kSocketOverrideCapacity - sockets_.size()) {
+    if (!read_reward_sockets(blob, at + kEntrySocketsOffset, overrides, count)) {
+        return false;
+    }
+    if (count > domain::kSocketOverrideCapacity - sockets_.size()) {
+        fullBank = "socket_capacity";
         return false;
     }
     out.sockets = {static_cast<std::uint32_t>(sockets_.size()), static_cast<std::uint32_t>(count)};
@@ -191,6 +238,10 @@ bool RewardBuild::entry(std::span<const std::byte> blob, std::size_t at) noexcep
             return false;
         }
     }
+    if (entries_.size() >= domain::kEntryCapacity) {
+        fullBank = "entry_capacity";
+        return false;
+    }
     return append(entries_, out, domain::kEntryCapacity);
 }
 
@@ -251,19 +302,24 @@ bool RewardConditions::load(const reader::Source& source,
     maps_ = &maps;
     load_class_flags(source, scratch, root);
     // Shared expressions are expanded before the runtime evaluates reward conditions.
-    return root_table(source, scratch, root, kFlagSlot, flags_, kFlagTableClass)
-           && tables::read_array(
-               flags_, tables::kTableArrayDescriptor, kFlagRowClass, kBindingStride, flagRows_)
-           && root_table(source, scratch, root, kValueSlot, values_, kValueTableClass)
-           && tables::read_array(
-               values_, tables::kTableArrayDescriptor, kValueRowClass, kBindingStride, valueRows_)
-           && root_table(
-               source, scratch, root, kExpressionSlot, expressions_, kExpressionTableClass)
-           && tables::read_array(expressions_,
-                                 tables::kTableArrayDescriptor,
-                                 kExpressionRowClass,
-                                 kExpressionRowStride,
-                                 expressionRows_);
+    loaded_ =
+        root_table(source, scratch, root, kFlagSlot, flags_, kFlagTableClass)
+        && tables::read_array(
+            flags_, tables::kTableArrayDescriptor, kFlagRowClass, kBindingStride, flagRows_)
+        && root_table(source, scratch, root, kValueSlot, values_, kValueTableClass)
+        && tables::read_array(
+            values_, tables::kTableArrayDescriptor, kValueRowClass, kBindingStride, valueRows_)
+        && root_table(source, scratch, root, kExpressionSlot, expressions_, kExpressionTableClass)
+        && tables::read_array(expressions_,
+                              tables::kTableArrayDescriptor,
+                              kExpressionRowClass,
+                              kExpressionRowStride,
+                              expressionRows_);
+    return loaded_;
+}
+
+bool RewardConditions::loaded() const noexcept {
+    return loaded_;
 }
 
 bool RewardConditions::bind(std::uint32_t native,
@@ -357,9 +413,12 @@ bool RewardConditions::append_expression(std::span<const std::byte> blob,
 bool RewardConditions::read(std::span<const std::byte> blob,
                             std::size_t at,
                             std::vector<domain::Instruction>& bank,
-                            domain::Range& range) const noexcept {
+                            domain::Range& range,
+                            bool& bankFull) const noexcept {
     const auto first = bank.size();
+    bankFull = false;
     if (!append_expression(blob, at, bank, 0)) {
+        bankFull = bank.size() >= domain::kInstructionCapacity;
         bank.resize(first);
         return false;
     }
@@ -377,12 +436,15 @@ bool RewardConditions::read_list(std::span<const std::byte> blob,
     if (!tables::read_array(blob, at, kConditionClass, tables::kUnlockExpressionFieldSize, rows)) {
         return false;
     }
+    // The row's condition capacity below bounds this bank.
+    bool bankFull = false;
     for (std::size_t i = 0; i < rows.count; ++i) {
         domain::Range expression{};
         if (!read(blob,
                   rows.dataOffset + i * tables::kUnlockExpressionFieldSize,
                   instructions,
-                  expression)
+                  expression,
+                  bankFull)
             || expression.count == 0) {
             return false;
         }
@@ -441,12 +503,17 @@ bool RewardBuild::load(const reader::Source& source,
     std::vector<std::byte> blob;
     tables::Array rows{};
     std::uint32_t supplementalTag = 0;
-    if (!tables::slot_tag(root, kSupplementalRewardSlot, supplementalTag)
-        || !conditions.load(source, scratch, root, maps)
+    // Conditions load first; the season pass binds through them even if the pools are refused.
+    if (!conditions.load(source, scratch, root, maps)
+        || !tables::slot_tag(root, kSupplementalRewardSlot, supplementalTag)
         || !root_table(source, scratch, root, kPoolSlot, blob, kPoolClass)
         || !tables::read_array(
-            blob, tables::kTableArrayDescriptor, kPoolRowClass, kPoolStride, rows)
-        || rows.count == 0 || rows.count > domain::kPoolCapacity) {
+            blob, tables::kTableArrayDescriptor, kPoolRowClass, kPoolStride, rows)) {
+        settle_unsupported("tables");
+        return false;
+    }
+    if (rows.count == 0 || rows.count > domain::kPoolCapacity) {
+        settle_unsupported("pool_count");
         return false;
     }
     supplementalMissing_ = supplementalTag == kAbsentTableTag;
@@ -463,7 +530,13 @@ bool RewardBuild::load(const reader::Source& source,
                 const auto beforeInstructions = instructions_.size();
                 const auto beforeModifiers = modifiers_.size();
                 const auto beforeSockets = sockets_.size();
-                if (!entry(blob, members.dataOffset + j * kEntryStride)) {
+                const char* fullBank = nullptr;
+                if (!entry(blob, members.dataOffset + j * kEntryStride, fullBank)) {
+                    // Skipping past a full bank would publish a pool missing some of its entries.
+                    if (fullBank != nullptr) {
+                        settle_unsupported(fullBank);
+                        return false;
+                    }
                     instructions_.resize(beforeInstructions);
                     modifiers_.resize(beforeModifiers);
                     sockets_.resize(beforeSockets);
@@ -476,14 +549,19 @@ bool RewardBuild::load(const reader::Source& source,
             ++skipped;
         }
         if (!append(pools_, pool, domain::kPoolCapacity)) {
+            report_refusal("pool_storage");
             return false;
         }
     }
     core::log::writef(core::log::Channel::client,
                       skipped == 0 ? core::log::Level::info : core::log::Level::warn,
-                      "ev=pkg stage=rewards pools=%zu entries=%zu skipped=%zu",
+                      "ev=pkg stage=rewards pools=%zu entries=%zu instructions=%zu modifiers=%zu "
+                      "sockets=%zu skipped=%zu",
                       pools_.size(),
                       entries_.size(),
+                      instructions_.size(),
+                      modifiers_.size(),
+                      sockets_.size(),
                       skipped);
     loaded_ = true;
     return true;
@@ -504,9 +582,9 @@ bool RewardBuild::read_item(std::uint32_t hash,
         domain::Instruction flag{};
         if (!conditions.bind(tables::kUnlockReadFlagOpcode, acquired, flag)
             || !unlocks::valid(flag)) {
-            return false;
-        }
-        if (flag.bank == unlocks::Bank::account) {
+            // The grant is kept; only its acquisition flag is unknown.
+            ++unboundAcquiredFlags_;
+        } else if (flag.bank == unlocks::Bank::account) {
             item.acquiredFlag = static_cast<std::uint16_t>(flag.operand);
         }
     }
@@ -537,13 +615,20 @@ bool RewardBuild::read_item(std::uint32_t hash,
 }
 
 bool RewardBuild::begin_items(std::size_t count) noexcept {
+    unboundAcquiredFlags_ = 0;
+    // A refused load has already reported itself.
+    if (!loaded_) {
+        return false;
+    }
     try {
-        if (!loaded_ || count == 0 || count > domain::kItemCapacity) {
+        if (count == 0 || count > domain::kItemCapacity) {
+            report_refusal("item_storage");
             return false;
         }
         items_.assign(count, {});
         return true;
     } catch (...) {
+        report_refusal("item_storage");
         return false;
     }
 }
@@ -561,6 +646,12 @@ bool RewardBuild::publish() noexcept {
     if (!loaded_) {
         return false;
     }
+    // Cleared first so the entry pass also drops draws of a cleared wrapper.
+    for (auto& item : items_) {
+        if (item.poolIndex != domain::kAbsent && item.poolIndex >= pools_.size()) {
+            item = {};
+        }
+    }
     std::size_t write = 0;
     std::size_t socketWrite = 0;
     for (auto& pool : pools_) {
@@ -568,11 +659,13 @@ bool RewardBuild::publish() noexcept {
         pool.entries.first = static_cast<std::uint32_t>(write);
         for (std::size_t i = range.first; i < range.first + range.count; ++i) {
             const auto& row = entries_[i];
-            bool valid = (row.itemIndex == domain::kAbsent || row.itemIndex < items_.size())
+            bool valid = (row.itemIndex == domain::kAbsent || item_read(items_, row.itemIndex))
                          && (row.poolIndex == domain::kAbsent || row.poolIndex < pools_.size());
             for (const auto& socket :
                  std::span(sockets_).subspan(row.sockets.first, row.sockets.count)) {
-                valid &= domain::valid_socket(socket, items_.size());
+                valid &=
+                    domain::valid_socket(socket, items_.size())
+                    && (socket.plugItem == domain::kAbsent || item_read(items_, socket.plugItem));
             }
             if (valid) {
                 auto retained = row;
@@ -586,15 +679,28 @@ bool RewardBuild::publish() noexcept {
         }
         pool.entries.count = static_cast<std::uint32_t>(write) - pool.entries.first;
     }
+    const std::size_t droppedEntries = entries_.size() - write;
     entries_.resize(write);
     sockets_.resize(socketWrite);
-    for (auto& item : items_) {
-        if (item.poolIndex != domain::kAbsent && item.poolIndex >= pools_.size()) {
-            item = {};
-        }
+    if (unboundAcquiredFlags_ != 0 || droppedEntries != 0) {
+        core::log::writef(core::log::Channel::client,
+                          core::log::Level::warn,
+                          "ev=pkg stage=rewards result=partial unbound_acquired_flags=%zu "
+                          "dropped_entries=%zu",
+                          unboundAcquiredFlags_,
+                          droppedEntries);
+    }
+    const domain::View graph{pools_, entries_, items_, instructions_, modifiers_, sockets_};
+    if (!state::build_data::valid_reward_definitions(graph)) {
+        settle_unsupported("graph");
+        return false;
     }
-    return state::build_data::publish_reward_definitions(
-        {pools_, entries_, items_, instructions_, modifiers_, sockets_});
+    // A cache write can still fail, so this stays retryable.
+    if (!state::build_data::publish_reward_definitions(graph)) {
+        report_refusal("publish");
+        return false;
+    }
+    return true;
 }
 
 } // namespace sunrise::client::content::items::packages

+ 11 - 4
Sunrise/src/client/content/items/packages/package_reward_build.h

@@ -23,15 +23,18 @@ public:
                             middleware::content::packages::reader::Scratch& scratch,
                             std::span<const std::byte> root,
                             const SlotMaps& maps) noexcept;
+    /** @return True when the last load() succeeded. */
+    [[nodiscard]] bool loaded() const noexcept;
     /** Binds one native instruction; flag and value slots resolve to their saved bank. */
     [[nodiscard]] bool bind(std::uint32_t native,
                             std::uint32_t operand,
                             state::build_data::rewards::Instruction& instruction) const noexcept;
-    /** Appends one expanded expression to the bank; the bank is unchanged on failure. */
+    /** Appends one expanded expression; the bank is unchanged on failure. */
     [[nodiscard]] bool read(std::span<const std::byte> blob,
                             std::size_t at,
                             std::vector<state::build_data::rewards::Instruction>& bank,
-                            state::build_data::rewards::Range& range) const noexcept;
+                            state::build_data::rewards::Range& range,
+                            bool& bankFull) const noexcept;
     /** Expands a list of expressions into one program where every expression must hold. */
     [[nodiscard]] bool read_list(std::span<const std::byte> blob,
                                  std::size_t at,
@@ -48,6 +51,7 @@ private:
                           std::span<const std::byte> root) noexcept;
     std::array<std::uint16_t, state::kCharacterClassCount> classFlags_{};
     const SlotMaps* maps_{};
+    bool loaded_{};
     std::vector<std::byte> flags_;
     std::vector<std::byte> values_;
     std::vector<std::byte> expressions_;
@@ -60,7 +64,7 @@ private:
 class RewardBuild {
 public:
     RewardConditions conditions;
-    /** Reads every reward pool; entries that fail to read are skipped and counted. */
+    /** Reads every reward pool; unreadable entries are skipped, a full bank refuses the load. */
     [[nodiscard]] bool load(const middleware::content::packages::reader::Source& source,
                             middleware::content::packages::reader::Scratch& scratch,
                             std::span<const std::byte> root,
@@ -73,12 +77,15 @@ public:
     [[nodiscard]] bool publish() noexcept;
 
 private:
-    [[nodiscard]] bool entry(std::span<const std::byte> blob, std::size_t at) noexcept;
+    /** Appends one entry; fullBank names the bank that refused it, if any. */
+    [[nodiscard]] bool
+    entry(std::span<const std::byte> blob, std::size_t at, const char*& fullBank) noexcept;
     [[nodiscard]] bool read_item(std::uint32_t hash,
                                  std::span<const std::byte> blob,
                                  state::build_data::rewards::Item& item) noexcept;
     bool loaded_{};
     bool supplementalMissing_{};
+    std::size_t unboundAcquiredFlags_{};
     std::vector<state::build_data::rewards::Pool> pools_;
     std::vector<state::build_data::rewards::Entry> entries_;
     std::vector<state::build_data::rewards::Item> items_;

+ 2 - 2
Sunrise/src/client/content/items/packages/package_season_pass_build.cpp

@@ -3,7 +3,6 @@
 
 #include "../../../../core/logging/log.h"
 #include "../../../../middleware/content/packages/tables/field_reader.h"
-#include "../../../../state/build_data/season_pass/season_pass_catalog.h"
 #include "../../../../state/progression/season_pass_reward_catalog.h"
 #include "internal.h"
 #include "package_reward_build.h"
@@ -71,7 +70,8 @@ bool read_season_reward(Storage& storage,
     }
     reward.socketCount = static_cast<std::uint8_t>(socketCount);
     reward.conditionCount = static_cast<std::uint8_t>(conditionCount);
-    return domain::valid(std::span(&reward, 1))
+    // Checked per row so one refused row stays unavailable.
+    return state::build_data::valid_season_pass_rewards(std::span(&reward, 1))
            && std::all_of(reward.sockets.begin(),
                           reward.sockets.begin() + reward.socketCount,
                           [&itemRows](const state::build_data::rewards::SocketOverride& socket) {

+ 0 - 4
Sunrise/src/middleware/content/packages/tables/definition_index_table.h

@@ -235,10 +235,6 @@ inline constexpr std::size_t kProgressionRewardClaimSlotOffset = 20;
 /** Eligibility expressions and socket overrides carried by one progression reward. */
 inline constexpr std::size_t kProgressionRewardConditionsOffset = 24;
 inline constexpr std::size_t kProgressionRewardSocketsOffset = 40;
-/** Items a wrapper item opens into, two bytes each as an item-definition index. */
-inline constexpr std::size_t kGearsetItemField = 392;
-inline constexpr std::uint32_t kGearsetItemRowClass = 0x808087DBU;
-inline constexpr std::size_t kGearsetItemStride = 2;
 
 /** Expression that makes one collectible acquired, which a purchase or a grant sets. */
 inline constexpr std::size_t kCollectibleAcquiredExpressionField = 112;

+ 91 - 34
Sunrise/src/server/bap/bap_world_reward_queue.cpp

@@ -1,3 +1,4 @@
+#include <limits>
 #include <memory>
 #include <new>
 #include <span>
@@ -11,38 +12,55 @@
 namespace sunrise::server::bap {
 namespace {
 
-/** Grants and retires one earned reward in the same database transaction. */
-bool commit_world_reward(const WorldRewardRequest& request) noexcept {
+/** Logged for a saved row whose hash is not in the item table. */
+constexpr std::uint16_t kUnresolvedItemIndex = (std::numeric_limits<std::uint16_t>::max)();
+
+/** Outcome of one settlement attempt. */
+enum class WorldRewardSettlement : std::uint8_t { granted, retired, kept };
+
+/** Grants and retires one earned reward in one transaction, or retires one State rules out. */
+WorldRewardSettlement commit_world_reward(const WorldRewardRequest& request) noexcept {
     state::investment::store::Transaction transaction;
     if (!transaction.ready()) {
-        return false;
+        return WorldRewardSettlement::kept;
     }
     bool committed = false;
+    bool unresolvable = false;
+    const char* reason = nullptr;
     if (request.kind == WorldRewardKind::item) {
         if (state::item_grant_route(request.itemDefinitionIndex) == state::ItemGrantRoute::quest) {
-            state::PendingItemAcquisition acquisition;
-            committed = request.quantity == 1
-                        && state::prepare_item_acquisition_for_item(request.itemDefinitionIndex,
-                                                                    acquisition)
-                        && state::commit_item_acquisition(acquisition);
+            if (request.quantity != 1) {
+                unresolvable = true;
+                reason = "instance_quantity";
+            } else {
+                state::PendingItemAcquisition acquisition;
+                committed = state::prepare_item_acquisition_for_item(request.itemDefinitionIndex,
+                                                                     acquisition)
+                            && state::commit_item_acquisition(acquisition);
+            }
         } else {
             const std::unique_ptr<state::PendingRecordRewardGrant> grant(
                 new (std::nothrow) state::PendingRecordRewardGrant);
             std::uint64_t seed = 0;
             const bool seeded =
                 middleware::crypto::random::fill(std::as_writable_bytes(std::span(&seed, 1)));
-            const char* reason = seeded ? "reward_allocation" : "random_source";
-            committed = seeded && grant && request.quantity > 0
-                        && state::prepare_item_reward(request.itemDefinitionIndex,
-                                                      static_cast<std::uint32_t>(request.quantity),
-                                                      seed,
-                                                      *grant,
-                                                      &reason);
+            reason = seeded ? "reward_allocation" : "random_source";
+            auto preparation = state::RewardPreparation::deferred;
+            if (seeded && grant) {
+                preparation =
+                    state::prepare_item_reward(request.itemDefinitionIndex,
+                                               static_cast<std::uint32_t>(request.quantity),
+                                               seed,
+                                               *grant,
+                                               &reason);
+            }
+            unresolvable = preparation == state::RewardPreparation::unresolvable;
+            committed = preparation == state::RewardPreparation::prepared;
             if (committed) {
                 reason = "inventory_commit";
                 committed = state::commit_record_reward(*grant);
             }
-            if (!committed) {
+            if (!committed && !unresolvable) {
                 report_reward_refusal("world_settle", request.itemDefinitionIndex, reason);
             }
         }
@@ -52,7 +70,14 @@ bool commit_world_reward(const WorldRewardRequest& request) noexcept {
                         request.itemDefinitionIndex, request.quantity, acquisition)
                     && state::commit_profile_item_acquisition(acquisition);
     }
-    return committed && complete_world_reward(request.id) && transaction.commit();
+    if (unresolvable) {
+        return retire_world_reward(transaction, request, reason) ? WorldRewardSettlement::retired
+                                                                 : WorldRewardSettlement::kept;
+    }
+    if (!committed || !complete_world_reward(request.id) || !transaction.commit()) {
+        return WorldRewardSettlement::kept;
+    }
+    return WorldRewardSettlement::granted;
 }
 
 /** Saves the reward under its stable definition hash and earning character. */
@@ -95,40 +120,72 @@ bool arm_world_profile_item_acquisition(std::uint16_t itemDefinitionIndex,
            && enqueue_world_reward(itemDefinitionIndex, quantity, WorldRewardKind::profileItem);
 }
 
-/** Restores the oldest reward belonging to the selected character. */
+/** Restores the selected character's oldest reward, retiring rows that can never be granted. */
 bool current_world_reward(WorldRewardRequest& request) noexcept {
-    request = {};
     state::investment::store::PendingReward saved;
-    state::build_data::items::Definition definition;
-    if (!state::investment::store::next_reward(saved)
-        || !state::build_data::find_item_definition_hash(saved.definitionHash, definition)) {
-        return false;
+    while (state::investment::store::next_reward(saved)) {
+        request = {};
+        request.id = saved.id;
+        request.quantity = saved.quantity;
+        request.kind = static_cast<WorldRewardKind>(saved.kind);
+        const bool itemsPublished = state::build_data::item_definitions_ready();
+        state::build_data::items::Definition definition;
+        const char* verdict = "reward_quantity";
+        if (state::build_data::find_item_definition_hash(saved.definitionHash, definition)) {
+            request.itemDefinitionIndex = definition.definitionIndex;
+            if (request.quantity > 0) {
+                return true;
+            }
+        } else if (!itemsPublished || !state::build_data::item_definitions_ready()) {
+            // An unpublished item table proves nothing; retry later.
+            break;
+        } else {
+            request.itemDefinitionIndex = kUnresolvedItemIndex;
+            verdict = "item_definition";
+        }
+        state::investment::store::Transaction transaction;
+        if (!transaction.ready() || !retire_world_reward(transaction, request, verdict)) {
+            break;
+        }
     }
-    request.id = saved.id;
-    request.quantity = saved.quantity;
-    request.itemDefinitionIndex = definition.definitionIndex;
-    request.kind = static_cast<WorldRewardKind>(saved.kind);
-    return true;
+    request = {};
+    return false;
 }
 
-/** Acknowledges exactly the reward whose inventory grant is being committed. */
+/** Acknowledges exactly the reward being granted or retired. */
 bool complete_world_reward(std::uint64_t id) noexcept {
     return state::investment::store::complete_reward(id);
 }
 
-/** A failed grant stays saved for a later attempt. */
+/** Deletes the row on the caller's open savepoint, commits, and logs why. */
+bool retire_world_reward(state::investment::store::Transaction& transaction,
+                         const WorldRewardRequest& request,
+                         const char* reason) noexcept {
+    const bool retired = complete_world_reward(request.id) && transaction.commit();
+    report_reward_refusal(
+        "world_retire", request.itemDefinitionIndex, retired ? reason : "queue_commit");
+    return retired;
+}
+
+/** A retired reward lets the next one take its turn; any other failed grant stays saved. */
 void settle_world_reward() noexcept {
     WorldRewardRequest request;
-    if (current_world_reward(request) && commit_world_reward(request)) {
-        arm_account_resync_everywhere();
+    while (current_world_reward(request)) {
+        const WorldRewardSettlement settlement = commit_world_reward(request);
+        if (settlement == WorldRewardSettlement::granted) {
+            arm_account_resync_everywhere();
+        }
+        if (settlement != WorldRewardSettlement::retired) {
+            return;
+        }
     }
 }
 
-/** Shutdown may grant ready rewards; rewards for other characters stay in the database. */
+/** Shutdown grants or retires rewards until one must wait; other characters' rewards stay saved. */
 void drain_world_rewards() noexcept {
     WorldRewardRequest request;
     while (current_world_reward(request)) {
-        if (!commit_world_reward(request)) {
+        if (commit_world_reward(request) == WorldRewardSettlement::kept) {
             break;
         }
     }

+ 39 - 19
Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp

@@ -75,12 +75,20 @@ selected_character(const state::AccountState& account) noexcept {
     }
     const char* reason = "transaction";
     state::investment::store::Transaction transaction;
-    if (!pending || !transaction.ready() || request.quantity <= 0
-        || !state::prepare_item_reward(request.itemDefinitionIndex,
-                                       static_cast<std::uint32_t>(request.quantity),
-                                       seed,
-                                       *pending,
-                                       &reason)) {
+    if (!pending || !transaction.ready()) {
+        return fail(reason);
+    }
+    const auto preparation =
+        state::prepare_item_reward(request.itemDefinitionIndex,
+                                   static_cast<std::uint32_t>(request.quantity),
+                                   seed,
+                                   *pending,
+                                   &reason);
+    if (preparation == state::RewardPreparation::unresolvable) {
+        (void)bap::retire_world_reward(transaction, request, reason);
+        return false;
+    }
+    if (preparation != state::RewardPreparation::prepared) {
         return fail(reason);
     }
     queuez::RecordRewardGrant update{};
@@ -99,18 +107,20 @@ selected_character(const state::AccountState& account) noexcept {
                                                    scratch.framed,
                                                    framedSize)
         && framedSize != 0;
+    // Only a piggyback slot can be short; the grant waits for a full poll.
+    if (encoded && framedSize > response.size()) {
+        return fail("response_capacity");
+    }
     if (!state::commit_record_reward(*pending)) {
         return fail("inventory_commit");
     }
     if (!bap::complete_world_reward(request.id) || !transaction.commit()) {
         return fail("queue_commit");
     }
-    // A failed presentation still commits the prepared draw and retires its queue row.
-    if (!encoded || framedSize > response.size()) {
+    // No buffer fixes a failed stage or encode, so the grant commits and the row retires.
+    if (!encoded) {
         bap::arm_account_resync_everywhere();
-        return fail(!staged    ? "inventory_stage"
-                    : !encoded ? "inventory_encode"
-                               : "response_capacity");
+        return fail(!staged ? "inventory_stage" : "inventory_encode");
     }
     std::copy_n(scratch.framed.begin(), framedSize, response.begin());
     written = framedSize;
@@ -165,6 +175,12 @@ selected_character(const state::AccountState& account) noexcept {
                                                       scratch.framed,
                                                       framedSize)
         && framedSize != 0;
+    // Only a piggyback slot can be short; the grant waits for a full poll.
+    if (encoded && framedSize > response.size()) {
+        report_reward_refusal(
+            "world_acquisition", request.itemDefinitionIndex, "response_capacity");
+        return false;
+    }
     if (!state::commit_item_acquisition(pending) || !bap::complete_world_reward(request.id)
         || !transaction.commit()) {
         core::log::write(core::log::Channel::server,
@@ -172,13 +188,12 @@ selected_character(const state::AccountState& account) noexcept {
                          "ev=queuez stage=world_acquisition result=fail reason=commit");
         return false;
     }
-    if (!encoded || framedSize > response.size()) {
+    // No buffer fixes a failed stage or encode, so the grant commits and the row retires.
+    if (!encoded) {
         bap::arm_account_resync_everywhere();
         report_reward_refusal("world_acquisition",
                               request.itemDefinitionIndex,
-                              !staged    ? "inventory_stage"
-                              : !encoded ? "inventory_encode"
-                                         : "response_capacity");
+                              !staged ? "inventory_stage" : "inventory_encode");
         return false;
     }
     std::copy_n(scratch.framed.begin(), framedSize, response.begin());
@@ -230,6 +245,12 @@ selected_character(const state::AccountState& account) noexcept {
                                                                                scratch.framed,
                                                                                framedSize)
                          && framedSize != 0;
+    // Only a piggyback slot can be short; the grant waits for a full poll.
+    if (encoded && framedSize > response.size()) {
+        report_reward_refusal(
+            "world_profile_acquisition", request.itemDefinitionIndex, "response_capacity");
+        return false;
+    }
     if (!state::commit_profile_item_acquisition(pending) || !bap::complete_world_reward(request.id)
         || !transaction.commit()) {
         core::log::write(core::log::Channel::server,
@@ -237,13 +258,12 @@ selected_character(const state::AccountState& account) noexcept {
                          "ev=queuez stage=world_profile_acquisition result=fail reason=commit");
         return false;
     }
-    if (!encoded || framedSize > response.size()) {
+    // No buffer fixes a failed stage or encode, so the grant commits and the row retires.
+    if (!encoded) {
         bap::arm_account_resync_everywhere();
         report_reward_refusal("world_profile_acquisition",
                               request.itemDefinitionIndex,
-                              !staged    ? "inventory_stage"
-                              : !encoded ? "inventory_encode"
-                                         : "response_capacity");
+                              !staged ? "inventory_stage" : "inventory_encode");
         return false;
     }
     std::copy_n(scratch.framed.begin(), framedSize, response.begin());

+ 12 - 3
Sunrise/src/server/bap/internal.h

@@ -25,6 +25,10 @@
 #include "encrypted/queuez/definition.h"
 #include "runtime.h"
 
+namespace sunrise::state::investment::store {
+class Transaction;
+}
+
 namespace sunrise::server::bap {
 
 /** One session per transport peer slot, so a connection id indexes this array directly. */
@@ -525,7 +529,7 @@ unique_activity_link_locked(const state::activity::SessionBinding& binding,
 session_scenario_layout(const Session& session,
                         state::build_data::scenarios::Definition& output) noexcept;
 
-/** Commits every queued world reward with no presentation and empties the queue. */
+/** Commits saved world rewards with no presentation until one must wait. */
 void drain_world_rewards() noexcept;
 
 /** Arms every other Family-4 peer after the origin publishes a complete account mutation. */
@@ -544,13 +548,18 @@ void arm_acquisition_presentation_hold(Session& session) noexcept;
 [[nodiscard]] bool arm_world_profile_item_acquisition(std::uint16_t itemDefinitionIndex,
                                                       std::int32_t quantity) noexcept;
 
-/** Reads the oldest queued world reward without removing it. */
+/** Reads the oldest saved world reward that can still be granted, retiring rows that cannot. */
 [[nodiscard]] bool current_world_reward(WorldRewardRequest& request) noexcept;
 
 /** Removes the world reward returned by current_world_reward. */
 [[nodiscard]] bool complete_world_reward(std::uint64_t id) noexcept;
 
-/** Commits the queued reward with no flyout once its presentation cannot be built. */
+/** Deletes an ungrantable reward on the caller's open savepoint, commits, and logs why. */
+[[nodiscard]] bool retire_world_reward(state::investment::store::Transaction& transaction,
+                                       const WorldRewardRequest& request,
+                                       const char* reason) noexcept;
+
+/** Grants the oldest saved reward with no flyout, retiring rewards that can never be granted. */
 void settle_world_reward() noexcept;
 
 /** Queues one transient XP item update so the native HUD presents a seasonal XP gain. */

+ 3 - 2
Sunrise/src/server/web_service/web_service_record_actions.cpp

@@ -99,8 +99,9 @@ prepare_record_reward(const middleware::web_service::Message& message,
             message, "storage", recordIndex, rewards[0].itemDefinitionIndex, rewards[0].quantity);
         return RecordRewardPreparation::failed;
     }
-    if (!state::prepare_record_reward_grant(
-            std::span(rewards).first(rewardCount), recordIndex, *grant)) {
+    if (state::prepare_record_reward_grant(
+            std::span(rewards).first(rewardCount), recordIndex, *grant)
+        != state::RewardPreparation::prepared) {
         clear_mutation(outcome);
         report_record_reward(
             message, "state", recordIndex, rewards[0].itemDefinitionIndex, rewards[0].quantity);

+ 30 - 10
Sunrise/src/state/build_data/cache/records/cache_investment_records.cpp

@@ -1,3 +1,5 @@
+#include <algorithm>
+
 #include "codec.h"
 
 namespace sunrise::state::build_data::cache::records {
@@ -6,6 +8,22 @@ namespace {
 /** Cache padding fields are always written as zero. */
 constexpr unsigned int kReservedFieldValue = 0;
 
+/** A condition slot past a pass row's count holds what a default instruction encodes to. */
+bool unused_condition(const RewardInstructionRecord& record) noexcept {
+    const rewards::Instruction unused{};
+    return record.opcode == static_cast<std::uint8_t>(unused.opcode)
+           && record.bank == static_cast<std::uint8_t>(unused.bank)
+           && record.reserved == kReservedFieldValue && record.operand == unused.operand;
+}
+
+/** A socket slot past a pass row's count holds what a default override encodes to. */
+bool unused_socket(const RewardSocketOverrideRecord& record) noexcept {
+    const rewards::SocketOverride unused{};
+    return record.socketType == unused.socketType && record.plugItem == unused.plugItem
+           && record.plugSet == unused.plugSet && record.rollSet == unused.rollSet
+           && record.selection == unused.selection;
+}
+
 } // namespace
 
 /** Flattens the 12 buckets into 3 parallel arrays with no per-bucket padding. */
@@ -117,10 +135,18 @@ bool encode(const season_pass::Reward& value, SeasonPassRewardRecord& record) no
     return true;
 }
 
-/** Decodes one season pass reward row after checking its padding. */
+/** Decodes one season pass reward row after checking its padding and unused slots. */
 bool decode(const SeasonPassRewardRecord& record, season_pass::Reward& value) noexcept {
     value = {};
-    if (record.reserved != decltype(record.reserved){}) {
+    if (record.reserved != decltype(record.reserved){}
+        || record.conditionCount > record.condition.size()
+        || record.socketCount > record.sockets.size()) {
+        return false;
+    }
+    const auto unusedConditions = std::span(record.condition).subspan(record.conditionCount);
+    const auto unusedSockets = std::span(record.sockets).subspan(record.socketCount);
+    if (!std::all_of(unusedConditions.begin(), unusedConditions.end(), unused_condition)
+        || !std::all_of(unusedSockets.begin(), unusedSockets.end(), unused_socket)) {
         return false;
     }
     value.itemHash = record.itemHash;
@@ -130,18 +156,12 @@ bool decode(const SeasonPassRewardRecord& record, season_pass::Reward& value) no
     value.requiredRank = record.requiredRank;
     value.socketCount = record.socketCount;
     value.conditionCount = record.conditionCount;
-    if (value.conditionCount > value.condition.size()) {
-        return false;
-    }
-    for (std::size_t i = 0; i < value.condition.size(); ++i) {
+    for (std::size_t i = 0; i < value.conditionCount; ++i) {
         if (!decode(record.condition[i], value.condition[i])) {
             return false;
         }
     }
-    if (value.socketCount > value.sockets.size()) {
-        return false;
-    }
-    for (std::size_t i = 0; i < value.sockets.size(); ++i) {
+    for (std::size_t i = 0; i < value.socketCount; ++i) {
         if (!decode(record.sockets[i], value.sockets[i])) {
             return false;
         }

+ 8 - 8
Sunrise/src/state/build_data/rewards/definition.h

@@ -17,14 +17,14 @@ inline constexpr std::uint16_t kAbsent = 0xFFFF;
 inline constexpr std::size_t kPoolCapacity = 4096;
 /** Item rows are dense over the installed item-definition table. */
 inline constexpr std::size_t kItemCapacity = items::kDefinitionCapacity;
-/**
- * The shipped build declares 10,549 entries, 14,185 condition instructions, 3,714 modifiers, and
- * 1,812 socket overrides. Each bank leaves room above that.
- */
-inline constexpr std::size_t kEntryCapacity = 32768;
-inline constexpr std::size_t kInstructionCapacity = 32768;
-inline constexpr std::size_t kModifierCapacity = 32768;
-inline constexpr std::size_t kSocketOverrideCapacity = 32768;
+/** The shipped build publishes 10,549 entries. The bank leaves room above that. */
+inline constexpr std::size_t kEntryCapacity = 16384;
+/** The shipped build publishes 14,185 expanded condition instructions, with room above that. */
+inline constexpr std::size_t kInstructionCapacity = 16384;
+/** The shipped build publishes 3,714 modifiers. The bank leaves room above that. */
+inline constexpr std::size_t kModifierCapacity = 4096;
+/** The shipped build publishes 1,812 socket overrides. The bank leaves room above that. */
+inline constexpr std::size_t kSocketOverrideCapacity = 2048;
 /** Reward overrides address the ordinary socket lanes an item's initial plugs fill. */
 inline constexpr std::size_t kSocketsPerItem = items::details::kInitialPlugCapacity;
 /** Category selections one wrapper declares. The most any shipped wrapper declares is four. */

+ 5 - 0
Sunrise/src/state/build_data/rewards/reward_build_data_runtime.cpp

@@ -16,6 +16,11 @@ bool publish_reward_definitions(rewards::View definitions) noexcept {
            && transaction.finish(rewards::replace(definitions), rewards::clear);
 }
 
+/** Checks a reward graph against the catalog's publication rules. */
+bool valid_reward_definitions(rewards::View definitions) noexcept {
+    return rewards::valid(definitions);
+}
+
 /** Reads one item's reward row. */
 bool find_reward_item(std::uint16_t itemIndex, rewards::Item& item) noexcept {
     return rewards::find_item(itemIndex, item);

+ 6 - 0
Sunrise/src/state/build_data/runtime.h

@@ -404,6 +404,9 @@ publish_progression_definitions(std::span<const progressions::Definition> defini
  */
 [[nodiscard]] bool publish_season_pass(std::span<const season_pass::Reward> rewards) noexcept;
 
+/** Checks rows against the rules publish_season_pass applies to the whole list. */
+[[nodiscard]] bool valid_season_pass_rewards(std::span<const season_pass::Reward> rewards) noexcept;
+
 /**
  * Reads one season pass reward row.
  * @param rewardIndex Native reward-array index the opcode-2400 claim names.
@@ -426,6 +429,9 @@ publish_progression_definitions(std::span<const progressions::Definition> defini
  */
 [[nodiscard]] bool publish_reward_definitions(rewards::View definitions) noexcept;
 
+/** Checks a reward graph against the rules publish_reward_definitions applies. */
+[[nodiscard]] bool valid_reward_definitions(rewards::View definitions) noexcept;
+
 /**
  * Reads one item's reward row.
  * @param itemIndex Native item-definition index.

+ 5 - 0
Sunrise/src/state/build_data/season_pass/season_pass_build_data_runtime.cpp

@@ -16,6 +16,11 @@ bool publish_season_pass(std::span<const season_pass::Reward> rewards) noexcept
            && transaction.finish(season_pass::replace(rewards), season_pass::clear);
 }
 
+/** Checks season pass rows against the catalog's publication rules. */
+bool valid_season_pass_rewards(std::span<const season_pass::Reward> rewards) noexcept {
+    return season_pass::valid(rewards);
+}
+
 /** Reads one season pass reward row. */
 bool find_season_pass_reward(std::uint16_t rewardIndex, season_pass::Reward& reward) noexcept {
     return season_pass::find(rewardIndex, reward);

+ 42 - 29
Sunrise/src/state/rewards/reward_resolver.cpp

@@ -141,7 +141,7 @@ struct Resolver {
         }
         double value = entry.weight;
         if (!definitions::fits(entry.modifiers, data.modifiers)) {
-            return false;
+            return refuse(context, "modifier_range");
         }
         for (const auto& modifier :
              data.modifiers.subspan(entry.modifiers.first, entry.modifiers.count)) {
@@ -160,7 +160,7 @@ struct Resolver {
             }
         }
         if (!std::isfinite(value) || value < 0) {
-            return false;
+            return refuse(context, "weight_shape");
         }
         if (value == 0) {
             return true;
@@ -175,7 +175,7 @@ struct Resolver {
             }
         } else if (entry.itemIndex != definitions::kAbsent) {
             if (entry.itemIndex >= data.items.size()) {
-                return false;
+                return refuse(context, "item_range");
             }
         } else if (entry.supplementalIndex == definitions::kAbsent) {
             return refuse(context, "reward_target");
@@ -189,12 +189,15 @@ struct Resolver {
                      std::size_t depth,
                      double& total) noexcept {
         total = 0;
-        if (depth >= definitions::kTraversalDepth || index >= data.pools.size()) {
-            return false;
+        if (depth >= definitions::kTraversalDepth) {
+            return refuse(context, "pool_depth");
+        }
+        if (index >= data.pools.size()) {
+            return refuse(context, "pool_range");
         }
         const auto range = data.pools[index].entries;
         if (!definitions::fits(range, data.entries)) {
-            return false;
+            return refuse(context, "entry_range");
         }
         for (const auto& entry : data.entries.subspan(range.first, range.count)) {
             double value = 0;
@@ -203,7 +206,7 @@ struct Resolver {
             }
             total += value;
         }
-        return std::isfinite(total);
+        return std::isfinite(total) || refuse(context, "weight_shape");
     }
 
     bool
@@ -235,8 +238,13 @@ struct Resolver {
         if (chosen->poolIndex != definitions::kAbsent) {
             // A pool reference selects one leaf; quantity belongs to that leaf.
             double childTotal = 0;
-            return pool_weight(chosen->poolIndex, category, depth + 1, childTotal) && childTotal > 0
-                   && draw(chosen->poolIndex, category, depth + 1, childTotal);
+            if (!pool_weight(chosen->poolIndex, category, depth + 1, childTotal)) {
+                return false;
+            }
+            if (childTotal == 0) {
+                return refuse(context, "empty_pool");
+            }
+            return draw(chosen->poolIndex, category, depth + 1, childTotal);
         }
         if (selectedCount == selectedEntries.size()) {
             return refuse(context, "reward_selection_capacity");
@@ -246,20 +254,29 @@ struct Resolver {
         if (chosen->itemIndex == definitions::kAbsent) {
             return true;
         }
-        if (chosen->quantity == 0 || chosen->quantity > kMaximumQuantity
-            || result.count == result.grants.size()
-            || !definitions::fits(chosen->sockets, data.sockets)) {
-            return false;
+        return append_grant(*chosen);
+    }
+
+    /** Appends the chosen leaf's item grant with the socket overrides its row carries. */
+    bool append_grant(const definitions::Entry& chosen) noexcept {
+        if (chosen.quantity == 0 || chosen.quantity > kMaximumQuantity) {
+            return refuse(context, "reward_quantity");
+        }
+        if (result.count == result.grants.size()) {
+            return refuse(context, "grant_capacity");
+        }
+        if (!definitions::fits(chosen.sockets, data.sockets)) {
+            return refuse(context, "socket_range");
         }
         auto& grant = result.grants[result.count++];
-        grant.itemIndex = chosen->itemIndex;
-        grant.quantity = static_cast<std::int32_t>(chosen->quantity);
-        if (chosen->sockets.count > grant.sockets.size()) {
-            return false;
+        grant.itemIndex = chosen.itemIndex;
+        grant.quantity = static_cast<std::int32_t>(chosen.quantity);
+        if (chosen.sockets.count > grant.sockets.size()) {
+            return refuse(context, "socket_capacity");
         }
-        grant.socketCount = chosen->sockets.count;
+        grant.socketCount = chosen.sockets.count;
         std::copy_n(
-            data.sockets.begin() + chosen->sockets.first, grant.socketCount, grant.sockets.begin());
+            data.sockets.begin() + chosen.sockets.first, grant.socketCount, grant.sockets.begin());
         return true;
     }
 };
@@ -270,11 +287,8 @@ bool resolve_item(definitions::View data,
                   std::uint32_t quantity,
                   Result& result) noexcept {
     result = {};
-    if (context.refusal != nullptr) {
-        *context.refusal = "reward_shape";
-    }
     if (itemIndex >= data.items.size() || quantity == 0 || quantity > kMaximumQuantity) {
-        return false;
+        return refuse(context, "reward_shape");
     }
     const auto& item = data.items[itemIndex];
     if (item.definitionHash == 0) {
@@ -288,16 +302,15 @@ bool resolve_item(definitions::View data,
         staged.grants[0].quantity = static_cast<std::int32_t>(quantity);
         staged.count = 1;
     } else {
-        if (quantity != 1 || item.selectionCount == 0
-            || item.selectionCount > item.selections.size()) {
-            return false;
+        if (quantity != 1) {
+            return refuse(context, "wrapper_quantity");
+        }
+        if (item.selectionCount == 0 || item.selectionCount > item.selections.size()) {
+            return refuse(context, "wrapper_selections");
         }
         Resolver resolver{data, context, staged, context.seed};
         for (std::size_t i = 0; i < item.selectionCount; ++i) {
             const auto& declared = item.selections[i];
-            if (declared.count > kDrawCapacity) {
-                return false;
-            }
             for (std::size_t j = 0; j < declared.count; ++j) {
                 double total = 0;
                 if (!resolver.pool_weight(item.poolIndex, declared.categoryHash, 0, total)) {

+ 20 - 12
Sunrise/src/state/runtime/runtime.h

@@ -265,6 +265,9 @@ struct PendingRecordRewardGrant {
     bool prepared{};
 };
 
+/** How one reward preparation ended. */
+enum class RewardPreparation : std::uint8_t { prepared, deferred, unresolvable };
+
 /** One uncommitted Season reward, the native row it will claim, and the seed of its draw. */
 struct PendingSeasonPassReward {
     PendingRecordRewardGrant grant{};
@@ -541,11 +544,11 @@ prepare_item_acquisition(std::uint16_t collectibleIndex,
  * @param seed Caller-drawn entropy for any wrapper draws.
  * @param refusal Receives a static reason on failure.
  */
-[[nodiscard]] bool prepare_item_reward(std::uint16_t itemIndex,
-                                       std::uint32_t quantity,
-                                       std::uint64_t seed,
-                                       PendingRecordRewardGrant& mutation,
-                                       const char** refusal = nullptr) noexcept;
+[[nodiscard]] RewardPreparation prepare_item_reward(std::uint16_t itemIndex,
+                                                    std::uint32_t quantity,
+                                                    std::uint64_t seed,
+                                                    PendingRecordRewardGrant& mutation,
+                                                    const char** refusal = nullptr) noexcept;
 /**
  * Prepares an eligible native pass row without writing its claim or acquisition flags.
  * @param seed Caller-drawn entropy; the commit replays the same draw from it.
@@ -567,12 +570,13 @@ prepare_item_acquisition(std::uint16_t collectibleIndex,
  * @param rewards Item rows the record grants.
  * @param claimedRecordIndex Record already claimed in the banks, or kUnclaimedRecordIndex.
  * @param mutation Receives the prepared grant.
- * @return True when every row fits the account after-image.
+ * @return Prepared when every row fits the account after-image.
  */
-[[nodiscard]] bool prepare_record_reward_grant(std::span<const DirectRecordReward> rewards,
-                                               std::uint16_t claimedRecordIndex,
-                                               PendingRecordRewardGrant& mutation,
-                                               const char** refusal = nullptr) noexcept;
+[[nodiscard]] RewardPreparation
+prepare_record_reward_grant(std::span<const DirectRecordReward> rewards,
+                            std::uint16_t claimedRecordIndex,
+                            PendingRecordRewardGrant& mutation,
+                            const char** refusal = nullptr) noexcept;
 
 /** Builds the full account after-image while a record reward remains current. */
 [[nodiscard]] bool preview_record_reward_grant(const PendingRecordRewardGrant& mutation,
@@ -776,8 +780,12 @@ inline constexpr std::uint16_t kArtifactUnlockProgressionIndex = 39;
 /** @return Seasonal XP published in the account progression bank. */
 [[nodiscard]] std::int32_t seasonal_experience() noexcept;
 
-/** Publishes every seasonal value the seeded XP and artifact ownership imply. */
-[[nodiscard]] bool seed_seasonal_progression() noexcept;
+/**
+ * Publishes every seasonal value the seeded XP and artifact ownership imply.
+ * @param accountObjectPublished Whether the caller's push carries the account object; earned-perk
+ *        flags reach the client only there, so otherwise the perk walk waits for an XP grant.
+ */
+[[nodiscard]] bool seed_seasonal_progression(bool accountObjectPublished) noexcept;
 
 /** @return One-based Season of Arrivals rank the published XP earns. */
 [[nodiscard]] std::uint16_t seasonal_rank() noexcept;

+ 69 - 47
Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp

@@ -105,12 +105,12 @@ apply_reward_sockets(const item_details::Definition& detail,
     return true;
 }
 
-[[nodiscard]] bool prepare_resolved_reward(const rewards::Result& resolved,
-                                           PendingRecordRewardGrant& mutation,
-                                           const char** refusal) noexcept {
+[[nodiscard]] RewardPreparation prepare_resolved_reward(const rewards::Result& resolved,
+                                                        PendingRecordRewardGrant& mutation,
+                                                        const char** refusal) noexcept {
     std::array<DirectRecordReward, kRecordRewardGrantCapacity> rows{};
     if (resolved.count > rows.size()) {
-        return false;
+        return RewardPreparation::unresolvable;
     }
     for (std::size_t i = 0; i < resolved.count; ++i) {
         const auto& grant = resolved.grants[i];
@@ -123,6 +123,15 @@ apply_reward_sockets(const item_details::Definition& detail,
         std::span(rows).first(resolved.count), kUnclaimedRecordIndex, mutation, refusal);
 }
 
+/** Classifies a catalog refusal: a verdict unless a catalog was cleared meanwhile. */
+[[nodiscard]] RewardPreparation catalog_refusal(const char*& reason) noexcept {
+    if (build_data::item_definitions_ready() && build_data::reward_definitions_ready()) {
+        return RewardPreparation::unresolvable;
+    }
+    reason = "not_ready";
+    return RewardPreparation::deferred;
+}
+
 /** Wrappers and direct account perks bypass quest initialization. */
 bool uses_reward_definition(const build_data::items::Definition& item) noexcept {
     build_data::rewards::Item reward{};
@@ -258,7 +267,7 @@ bool prepare_season_pass_reward(std::uint16_t rewardIndex,
         return false;
     }
     reason = "reward_placement";
-    if (!prepare_resolved_reward(resolved, mutation.grant, &reason)) {
+    if (prepare_resolved_reward(resolved, mutation.grant, &reason) != RewardPreparation::prepared) {
         return false;
     }
     reason = nullptr;
@@ -294,19 +303,24 @@ ItemGrantRoute item_grant_route(std::uint16_t itemIndex) noexcept {
                : ItemGrantRoute::unavailable;
 }
 
-bool prepare_item_reward(std::uint16_t itemIndex,
-                         std::uint32_t quantity,
-                         std::uint64_t seed,
-                         PendingRecordRewardGrant& mutation,
-                         const char** refusal) noexcept {
+RewardPreparation prepare_item_reward(std::uint16_t itemIndex,
+                                      std::uint32_t quantity,
+                                      std::uint64_t seed,
+                                      PendingRecordRewardGrant& mutation,
+                                      const char** refusal) noexcept {
     const char* unused = nullptr;
     auto& reason = refusal != nullptr ? *refusal : unused;
-    reason = "item_definition";
+    reason = "not_ready";
     mutation = {};
+    // Unpublished domains decide nothing; retry later.
+    if (!build_data::item_definitions_ready() || !build_data::reward_definitions_ready()) {
+        return RewardPreparation::deferred;
+    }
+    reason = "item_definition";
     const std::lock_guard lock(investment::store::g_mutex);
     build_data::items::Definition item{};
     if (!build_data::find_item_definition_index(itemIndex, item)) {
-        return false;
+        return catalog_refusal(reason);
     }
     reason = "selected_character";
     const AccountState account = account_snapshot();
@@ -314,21 +328,17 @@ bool prepare_item_reward(std::uint16_t itemIndex,
     unlocks::Table flags{};
     if (character >= account.characterCount
         || !investment::store::read_unlocks(flags, static_cast<int>(character))) {
-        return false;
+        return RewardPreparation::deferred;
     }
     rewards::Result resolved{};
     if (!rewards::resolve({flags, account.characters[character].characterClass, seed, &reason},
                           itemIndex,
                           quantity,
                           resolved)) {
-        return false;
+        return catalog_refusal(reason);
     }
     reason = "reward_placement";
-    if (!prepare_resolved_reward(resolved, mutation, &reason)) {
-        return false;
-    }
-    reason = nullptr;
-    return true;
+    return prepare_resolved_reward(resolved, mutation, &reason);
 }
 
 bool preview_reward_unlocks(const PendingRecordRewardGrant& mutation,
@@ -502,23 +512,30 @@ namespace {
 } // namespace
 
 /** Prepares every reward over one cumulative account view. */
-bool prepare_record_reward_grant(std::span<const DirectRecordReward> rewards,
-                                 std::uint16_t claimedRecordIndex,
-                                 PendingRecordRewardGrant& mutation,
-                                 const char** refusal) noexcept {
+RewardPreparation prepare_record_reward_grant(std::span<const DirectRecordReward> rewards,
+                                              std::uint16_t claimedRecordIndex,
+                                              PendingRecordRewardGrant& mutation,
+                                              const char** refusal) noexcept {
     const char* unused = nullptr;
     auto& reason = refusal != nullptr ? *refusal : unused;
     reason = "reward_count";
     mutation = {};
     if (rewards.empty() || rewards.size() > mutation.rewards.size()) {
-        return false;
+        return RewardPreparation::unresolvable;
+    }
+    reason = "not_ready";
+    // The loadout check needs these domains, so nothing is decided before they publish.
+    if (!build_data::item_definitions_ready() || !build_data::configured_item_details_ready()
+        || !build_data::inventory_bucket_descriptors_ready()
+        || !build_data::socket_entry_lists_ready()) {
+        return RewardPreparation::deferred;
     }
     reason = "account_state";
     const AccountState account = account_snapshot();
     const std::size_t characterIndex = selected_character_index(account);
     if (!account::valid(account) || !valid_profile_inventory(account)
         || characterIndex >= account.characterCount) {
-        return false;
+        return RewardPreparation::deferred;
     }
 
     AccountState working = account;
@@ -532,25 +549,27 @@ bool prepare_record_reward_grant(std::span<const DirectRecordReward> rewards,
             || !build_data::find_item_definition_index(requested.itemDefinitionIndex, item)
             || item.questInitialization.scope
                    != build_data::items::QuestInitialization::Scope::none) {
-            return false;
+            return RewardPreparation::unresolvable;
         }
         PreparedRecordReward prepared{};
         prepared.definitionHash = item.definitionHash;
         prepared.quantity = requested.quantity;
         if (requested.acquireUnlock) {
             reason = "acquisition_flag";
+            // Only a resolved reward asks for its flag, so the graph is published.
             build_data::rewards::Item source{};
             if (!build_data::find_reward_item(item.definitionIndex, source)
                 || source.definitionHash != item.definitionHash) {
-                return false;
+                return RewardPreparation::unresolvable;
             }
             prepared.acquiredFlag = source.acquiredFlag;
             if (source.acquiredFlag != build_data::rewards::kAbsent) {
                 std::int32_t before = 0;
+                // The saved flag refuses here, not the definition.
                 if (!investment::store::read_unlock(
                         investment::store::Bank::accountFlags, source.acquiredFlag, before)
                     || before < 0 || before > unlocks::kFlagSet) {
-                    return false;
+                    return RewardPreparation::deferred;
                 }
                 prepared.previousFlag = static_cast<std::uint8_t>(before);
             }
@@ -559,7 +578,7 @@ bool prepare_record_reward_grant(std::span<const DirectRecordReward> rewards,
             reason = "perk_acquisition";
             if (prepared.acquiredFlag == build_data::rewards::kAbsent || requested.quantity != 1
                 || !requested.sockets.empty()) {
-                return false;
+                return RewardPreparation::unresolvable;
             }
             prepared.kind = RecordRewardKind::accountUnlock;
             prepared.afterQuantity = 1;
@@ -571,13 +590,13 @@ bool prepare_record_reward_grant(std::span<const DirectRecordReward> rewards,
             || detail.definitionIndex != item.definitionIndex
             || detail.definitionHash != item.definitionHash || detail.bucketId != item.bucketId
             || !build_data::find_inventory_bucket_descriptor(item.bucketId, bucket)) {
-            return false;
+            return RewardPreparation::unresolvable;
         }
         reason = "duplicate_stack";
         if (detail.instancedDefinitionState == item_details::InstancedDefinitionState::stackable) {
             for (std::size_t prior = 0; prior < index; ++prior) {
                 if (mutation.rewards[prior].definitionHash == item.definitionHash) {
-                    return false;
+                    return RewardPreparation::unresolvable;
                 }
             }
         }
@@ -587,14 +606,14 @@ bool prepare_record_reward_grant(std::span<const DirectRecordReward> rewards,
             && (bucket.arraySelector != inventory_buckets::ArraySelector::character
                 || detail.instancedDefinitionState
                        != item_details::InstancedDefinitionState::instanced)) {
-            return false;
+            return RewardPreparation::unresolvable;
         }
         reason = "inventory_destination";
         if (bucket.arraySelector == inventory_buckets::ArraySelector::profile) {
             reason = "profile_capacity";
-            if (detail.instancedDefinitionState
-                != item_details::InstancedDefinitionState::stackable) {
-                return false;
+            if (detail.instancedDefinitionState != item_details::InstancedDefinitionState::stackable
+                || requested.quantity > detail.maxStackSize) {
+                return RewardPreparation::unresolvable;
             }
             PendingProfileItemAcquisition staged{};
             const bool actionSource =
@@ -607,7 +626,7 @@ bool prepare_record_reward_grant(std::span<const DirectRecordReward> rewards,
                                                    requested.quantity,
                                                    {.direct = true},
                                                    staged)) {
-                return false;
+                return RewardPreparation::deferred;
             }
             working.profileItems = staged.afterItems;
             working.profileItemCount = staged.afterItemCount;
@@ -622,20 +641,20 @@ bool prepare_record_reward_grant(std::span<const DirectRecordReward> rewards,
                           == item_details::InstancedDefinitionState::instanced) {
             reason = "instance_quantity";
             if (requested.quantity != 1) {
-                return false;
+                return RewardPreparation::unresolvable;
             }
             reason = "instance_capacity";
             PendingItemAcquisition staged{};
             if (!finalize_item_acquisition(
                     working, working, item.definitionHash, false, {.direct = true}, staged)) {
-                return false;
+                return RewardPreparation::deferred;
             }
             if (!apply_reward_sockets(
                     detail,
                     requested.sockets,
                     staged.afterCharacter.inventory.values[staged.inventoryIndex].sockets,
                     &reason)) {
-                return false;
+                return RewardPreparation::unresolvable;
             }
             working.characters[characterIndex] = staged.afterCharacter;
             prepared.instanceSoid = staged.acquiredInstanceSoid;
@@ -651,10 +670,12 @@ bool prepare_record_reward_grant(std::span<const DirectRecordReward> rewards,
                    && !detail.equipmentSlot.has_value()) {
             reason = "character_stack_capacity";
             CharacterState& character = working.characters[characterIndex];
-            if (requested.quantity > detail.maxStackSize
-                || character.nextInventorySerial
-                       >= static_cast<std::uint32_t>((std::numeric_limits<std::int32_t>::max)())) {
-                return false;
+            if (requested.quantity > detail.maxStackSize) {
+                return RewardPreparation::unresolvable;
+            }
+            if (character.nextInventorySerial
+                >= static_cast<std::uint32_t>((std::numeric_limits<std::int32_t>::max)())) {
+                return RewardPreparation::deferred;
             }
             std::size_t stackIndex = character.stacks.count;
             for (std::size_t candidate = 0; candidate < character.stacks.count; ++candidate) {
@@ -668,7 +689,7 @@ bool prepare_record_reward_grant(std::span<const DirectRecordReward> rewards,
                 || (!appended
                     && character.stacks.values[stackIndex].quantity
                            > detail.maxStackSize - requested.quantity)) {
-                return false;
+                return RewardPreparation::deferred;
             }
             auto& stack = character.stacks.values[stackIndex];
             if (appended) {
@@ -682,16 +703,17 @@ bool prepare_record_reward_grant(std::span<const DirectRecordReward> rewards,
             prepared.mutationSerial = stack.mutationSerial;
             prepared.kind = RecordRewardKind::characterStack;
         } else {
-            return false;
+            return RewardPreparation::unresolvable;
         }
         mutation.rewards[index] = prepared;
     }
 
     reason = "loadout";
+    // A full native bucket rejects the whole loadout; retry later.
     family4_loadout::ResolvedLoadout loadout{};
     if (!account::valid(working) || !valid_profile_inventory(working)
         || !family4_loadout::resolve(working, characterIndex, loadout)) {
-        return false;
+        return RewardPreparation::deferred;
     }
     mutation.beforeCharacter = account.characters[characterIndex];
     mutation.afterCharacter = working.characters[characterIndex];
@@ -706,7 +728,7 @@ bool prepare_record_reward_grant(std::span<const DirectRecordReward> rewards,
     mutation.rewardCount = rewards.size();
     reason = nullptr;
     mutation.prepared = true;
-    return true;
+    return RewardPreparation::prepared;
 }
 
 bool preview_record_reward_grant(const PendingRecordRewardGrant& mutation,

+ 2 - 1
Sunrise/src/state/runtime/state_account_runtime.cpp

@@ -241,7 +241,8 @@ bool set_selected_character(std::uint64_t characterSoid) noexcept {
         investment::store::g_session.selected[index] = candidate.characters[index].selected;
     }
     investment::store::g_mutex.unlock();
-    (void)seed_seasonal_progression();
+    // The selection patch carries no account object, so earned perks wait for the next XP grant.
+    (void)seed_seasonal_progression(false);
     return true;
 }
 

+ 37 - 12
Sunrise/src/state/runtime/state_progression_runtime.cpp

@@ -94,6 +94,13 @@ using SaleRows = std::array<build_data::ArtifactSaleRow, build_data::kArtifactSa
     return ladder_ranks(kArtifactUnlockProgressionIndex, experience);
 }
 
+/** @return One-based Season of Arrivals rank the given XP total earns. */
+[[nodiscard]] std::uint16_t seasonal_rank_for(std::int32_t experience) noexcept {
+    const std::int32_t earned = experience / kExperiencePerRank;
+    return static_cast<std::uint16_t>(
+        (std::min)(static_cast<std::int32_t>(kMaximumRank), earned + 1));
+}
+
 /**
  * Sets one global unlock value override, replacing any existing entry for the slot.
  * @return False when the slot is new and the override list is full.
@@ -256,10 +263,14 @@ void report_perk_refusal(std::size_t index, const char* reason) noexcept {
                       reason);
 }
 
+/** Set while a perk walk is owed to the next XP grant; only touched under g_mutex. */
+bool g_perkWalkOwed = true;
+
 /** Earned perks set both flags in the transaction that publishes their qualifying rank. */
-bool grant_progress_flags() noexcept {
+bool grant_progress_flags(bool& walked) noexcept {
     if (!build_data::season_pass_ready() || !build_data::reward_definitions_ready()
         || !build_data::item_definitions_ready()) {
+        g_perkWalkOwed = true;
         return true;
     }
     AccountState account;
@@ -318,6 +329,7 @@ bool grant_progress_flags() noexcept {
             flags.accountFlags[flag] = unlocks::kFlagSet;
         }
     }
+    walked = true;
     return true;
 }
 
@@ -357,7 +369,7 @@ std::int32_t seasonal_experience() noexcept {
 }
 
 /** Publishes every seasonal value the seeded XP and artifact ownership imply. */
-bool seed_seasonal_progression() noexcept {
+bool seed_seasonal_progression(bool accountObjectPublished) noexcept {
     const std::int32_t experience = seasonal_experience();
     SaleRows rows{};
     std::size_t count = 0;
@@ -365,6 +377,9 @@ bool seed_seasonal_progression() noexcept {
         return false;
     }
     investment::store::g_mutex.lock();
+    if (!accountObjectPublished) {
+        g_perkWalkOwed = true;
+    }
     investment::store::Transaction transaction;
     Family5State family;
     if (!transaction.ready() || !investment::store::read_family5(family)) {
@@ -394,19 +409,21 @@ bool seed_seasonal_progression() noexcept {
         }
     }
     const std::uint32_t mask = artifact_mask(rows, count);
-    const bool published = publish_experience_lanes(experience)
-                           && publish_artifact_locked(family, mask, experience)
-                           && investment::store::write_family5(family) && grant_progress_flags()
-                           && transaction.commit();
+    bool walked = false;
+    const bool published =
+        publish_experience_lanes(experience) && publish_artifact_locked(family, mask, experience)
+        && investment::store::write_family5(family)
+        && (!accountObjectPublished || grant_progress_flags(walked)) && transaction.commit();
+    if (published && walked) {
+        g_perkWalkOwed = false;
+    }
     investment::store::g_mutex.unlock();
     return published;
 }
 
 /** @return One-based Season of Arrivals rank the published XP earns. */
 std::uint16_t seasonal_rank() noexcept {
-    const std::int32_t earned = seasonal_experience() / kExperiencePerRank;
-    return static_cast<std::uint16_t>(
-        (std::min)(static_cast<std::int32_t>(kMaximumRank), earned + 1));
+    return seasonal_rank_for(seasonal_experience());
 }
 
 /** @return Account-wide Power bonus published by the seasonal artifact. */
@@ -439,6 +456,8 @@ bool grant_seasonal_experience(std::int32_t amount) noexcept {
         return false;
     }
     const auto total = previous + amount;
+    const bool walkPerks =
+        g_perkWalkOwed || seasonal_rank_for(previous) != seasonal_rank_for(total);
 
     Family5State family;
     if (!investment::store::read_family5(family)) {
@@ -447,9 +466,15 @@ bool grant_seasonal_experience(std::int32_t amount) noexcept {
     SaleRows rows{};
     std::size_t count = 0;
     const std::uint32_t mask = sale_rows(rows, count) ? artifact_mask(rows, count) : 0U;
-    return publish_experience_lanes(total) && publish_artifact_locked(family, mask, total)
-           && investment::store::write_family5(family) && grant_progress_flags()
-           && transaction.commit();
+    bool walked = false;
+    const bool granted = publish_experience_lanes(total)
+                         && publish_artifact_locked(family, mask, total)
+                         && investment::store::write_family5(family)
+                         && (!walkPerks || grant_progress_flags(walked)) && transaction.commit();
+    if (granted && walked) {
+        g_perkWalkOwed = false;
+    }
+    return granted;
 }
 
 /** @return True when this Season pass reward row is already claimed. */

+ 2 - 1
Sunrise/src/state/runtime/state_runtime.cpp

@@ -300,7 +300,8 @@ bool initialize(void* module,
     ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
     secure_reset(*initialized);
     // The seeded banks decide every derived bar, gate and seasonal counter, so both run last.
-    (void)seed_seasonal_progression();
+    // The login snapshot carries the account object, so the seed walks earned perks now.
+    (void)seed_seasonal_progression(true);
     unlocks::records::seed();
     return transaction.commit();
 }