Selaa lähdekoodia

Open nested reward wrappers, retry transient failures, and bound each change list

Zach 3 viikkoa sitten
vanhempi
commit
31509bbabe

+ 165 - 82
Sunrise/src/client/content/items/packages/package_reward_build.cpp

@@ -126,18 +126,42 @@ template <typename T> bool append(std::vector<T>& bank, T value, std::size_t cap
     }
 }
 
+/** Package reads can fail temporarily. Only decoded schema failures settle a domain. */
+enum class TableRead { loaded, readFailed, unsupported };
+
 /** Reads the table an investment-root slot names, checking its class when one is expected. */
-bool root_table(const reader::Source& source,
-                reader::Scratch& scratch,
-                std::span<const std::byte> root,
-                std::size_t slot,
-                std::vector<std::byte>& blob,
-                std::uint32_t expectedClass = 0) noexcept {
+TableRead root_table(const reader::Source& source,
+                     reader::Scratch& scratch,
+                     std::span<const std::byte> root,
+                     std::size_t slot,
+                     std::vector<std::byte>& blob,
+                     std::uint32_t expectedClass) noexcept {
     std::uint32_t tag = 0;
     std::uint32_t cls = 0;
-    return tables::slot_tag(root, slot, tag) && tables::package_of(tag) != tables::kAbsentPackageId
-           && reader::read_tag(source, scratch, tag, blob, cls)
-           && (expectedClass == 0 || cls == expectedClass);
+    if (!tables::slot_tag(root, slot, tag) || tables::package_of(tag) == tables::kAbsentPackageId) {
+        return TableRead::unsupported;
+    }
+    if (!reader::read_tag(source, scratch, tag, blob, cls)) {
+        return TableRead::readFailed;
+    }
+    return expectedClass == 0 || cls == expectedClass ? TableRead::loaded : TableRead::unsupported;
+}
+
+/** Required reward tables settle only after a decoded schema failure, leaving reads retryable. */
+bool load_reward_table(const reader::Source& source,
+                       reader::Scratch& scratch,
+                       std::span<const std::byte> root,
+                       std::size_t slot,
+                       std::vector<std::byte>& blob,
+                       std::uint32_t expectedClass,
+                       const char* reason) noexcept {
+    const auto result = root_table(source, scratch, root, slot, blob, expectedClass);
+    if (result == TableRead::unsupported) {
+        settle_unsupported(reason);
+    } else if (result == TableRead::readFailed) {
+        report_refusal(reason);
+    }
+    return result == TableRead::loaded;
 }
 
 /** Reads one entry's fixed fields; a negative or non-finite weight is refused. */
@@ -187,13 +211,14 @@ bool reward_definitions_settled() noexcept {
 
 bool RewardBuild::entry(std::span<const std::byte> blob,
                         std::size_t at,
-                        const char*& fullBank) noexcept {
+                        BankFailure& failure,
+                        const char*& reason) noexcept {
     domain::Entry out{};
-    bool instructionsFull = false;
+    failure = BankFailure::none;
     if (!read_reward_entry(blob, at, out)
         || !conditions.read(
-            blob, at + kEntryConditionOffset, instructions_, out.condition, instructionsFull)) {
-        fullBank = instructionsFull ? "instruction_capacity" : nullptr;
+            blob, at + kEntryConditionOffset, instructions_, out.condition, failure)) {
+        reason = failure == BankFailure::full ? "instruction_capacity" : "instruction_storage";
         return false;
     }
     out.supplementalMissing = supplementalMissing_ && out.supplementalIndex != domain::kAbsent;
@@ -207,18 +232,21 @@ bool RewardBuild::entry(std::span<const std::byte> blob,
     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, instructionsFull)
+        if (!conditions.read(blob, offset, instructions_, modifier.condition, failure)
             || !tables::read(blob, offset + kModifierValueIndexOffset, modifier.valueIndex)
             || !tables::read(blob, offset + kModifierValueOffset, modifier.value)
             || !std::isfinite(modifier.value)) {
-            fullBank = instructionsFull ? "instruction_capacity" : nullptr;
+            reason = failure == BankFailure::full ? "instruction_capacity" : "instruction_storage";
             return false;
         }
         if (modifiers_.size() >= domain::kModifierCapacity) {
-            fullBank = "modifier_capacity";
+            failure = BankFailure::full;
+            reason = "modifier_capacity";
             return false;
         }
         if (!append(modifiers_, modifier, domain::kModifierCapacity)) {
+            failure = BankFailure::storage;
+            reason = "modifier_storage";
             return false;
         }
     }
@@ -228,24 +256,35 @@ bool RewardBuild::entry(std::span<const std::byte> blob,
         return false;
     }
     if (count > domain::kSocketOverrideCapacity - sockets_.size()) {
-        fullBank = "socket_capacity";
+        failure = BankFailure::full;
+        reason = "socket_capacity";
         return false;
     }
     out.sockets = {static_cast<std::uint32_t>(sockets_.size()), static_cast<std::uint32_t>(count)};
     for (std::size_t i = 0; i < count; ++i) {
-        if (overrides[i].socketType == domain::kAbsent
-            || !append(sockets_, overrides[i], domain::kSocketOverrideCapacity)) {
+        if (overrides[i].socketType == domain::kAbsent) {
+            return false;
+        }
+        if (!append(sockets_, overrides[i], domain::kSocketOverrideCapacity)) {
+            failure = BankFailure::storage;
+            reason = "socket_storage";
             return false;
         }
     }
     if (entries_.size() >= domain::kEntryCapacity) {
-        fullBank = "entry_capacity";
+        failure = BankFailure::full;
+        reason = "entry_capacity";
+        return false;
+    }
+    if (!append(entries_, out, domain::kEntryCapacity)) {
+        failure = BankFailure::storage;
+        reason = "entry_storage";
         return false;
     }
-    return append(entries_, out, domain::kEntryCapacity);
+    return true;
 }
 
-void RewardConditions::load_class_flags(const reader::Source& source,
+bool RewardConditions::load_class_flags(const reader::Source& source,
                                         reader::Scratch& scratch,
                                         std::span<const std::byte> root) noexcept {
     classFlags_.fill(domain::kAbsent);
@@ -254,20 +293,21 @@ void RewardConditions::load_class_flags(const reader::Source& source,
     std::vector<std::byte> blob;
     tables::Array classRows{};
     tables::Array itemRows{};
-    if (!root_table(source, scratch, root, kClassSlot, classes, kClassTable)
-        || !tables::read_array(
+    if (!load_reward_table(source, scratch, root, kClassSlot, classes, kClassTable, "class_table")
+        || !load_reward_table(
+            source, scratch, root, tables::kItemTableSlot, index, 0, "class_item_table")) {
+        return false;
+    }
+    if (!tables::read_array(
             classes, tables::kTableArrayDescriptor, kClassRow, kClassStride, classRows)
         || classRows.count != classFlags_.size()
-        || !root_table(source, scratch, root, tables::kItemTableSlot, index)
         || !tables::read_array(index,
                                tables::kTableArrayDescriptor,
                                tables::kItemIndexTableClass,
                                tables::kItemIndexRowStride,
                                itemRows)) {
-        core::log::write(core::log::Channel::client,
-                         core::log::Level::warn,
-                         "ev=pkg stage=class_flags result=skip reason=class_tables");
-        return;
+        settle_unsupported("class_schema");
+        return false;
     }
     for (std::size_t i = 0; i < classFlags_.size(); ++i) {
         std::uint16_t itemIndex = domain::kAbsent;
@@ -276,46 +316,60 @@ void RewardConditions::load_class_flags(const reader::Source& source,
         if (!tables::read(std::span<const std::byte>{classes},
                           classRows.dataOffset + i * kClassStride + kDefaultFinisherOffset,
                           itemIndex)
-            || !tables::index_row(index, itemRows, itemIndex, item)
-            || !reader::read_tag(source, scratch, item.targetTag, blob, cls)
-            || cls != tables::kItemDefinitionClass) {
-            core::log::writef(
-                core::log::Channel::client,
-                core::log::Level::warn,
-                "ev=pkg stage=class_flags class=%zu result=skip reason=default_finisher",
-                i);
-            continue;
+            || !tables::index_row(index, itemRows, itemIndex, item)) {
+            settle_unsupported("class_finisher_index");
+            return false;
         }
-        if (!read_class_flag(blob, classFlags_[i])) {
+        if (!reader::read_tag(source, scratch, item.targetTag, blob, cls)) {
             core::log::writef(core::log::Channel::client,
                               core::log::Level::warn,
-                              "ev=pkg stage=class_flags class=%zu result=skip reason=use_condition",
+                              "ev=pkg stage=class_flags class=%zu result=fail reason=finisher_read",
                               i);
+            return false;
+        }
+        if (cls != tables::kItemDefinitionClass || !read_class_flag(blob, classFlags_[i])) {
+            settle_unsupported("class_finisher_schema");
+            return false;
         }
     }
+    return true;
 }
 
 bool RewardConditions::load(const reader::Source& source,
                             reader::Scratch& scratch,
                             std::span<const std::byte> root,
                             const SlotMaps& maps) noexcept {
+    loaded_ = false;
     maps_ = &maps;
-    load_class_flags(source, scratch, root);
+    if (!load_class_flags(source, scratch, root)
+        || !load_reward_table(
+            source, scratch, root, kFlagSlot, flags_, kFlagTableClass, "flag_table")
+        || !load_reward_table(
+            source, scratch, root, kValueSlot, values_, kValueTableClass, "value_table")
+        || !load_reward_table(source,
+                              scratch,
+                              root,
+                              kExpressionSlot,
+                              expressions_,
+                              kExpressionTableClass,
+                              "expression_table")) {
+        return false;
+    }
     // Shared expressions are expanded before the runtime evaluates reward conditions.
-    loaded_ =
-        root_table(source, scratch, root, kFlagSlot, flags_, kFlagTableClass)
-        && tables::read_array(
+    if (!tables::read_array(
             flags_, tables::kTableArrayDescriptor, kFlagRowClass, kBindingStride, flagRows_)
-        && root_table(source, scratch, root, kValueSlot, values_, kValueTableClass)
-        && tables::read_array(
+        || !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_;
+        || !tables::read_array(expressions_,
+                               tables::kTableArrayDescriptor,
+                               kExpressionRowClass,
+                               kExpressionRowStride,
+                               expressionRows_)) {
+        settle_unsupported("condition_schema");
+        return false;
+    }
+    loaded_ = true;
+    return true;
 }
 
 bool RewardConditions::loaded() const noexcept {
@@ -373,7 +427,8 @@ bool RewardConditions::bind(std::uint32_t native,
 bool RewardConditions::append_expression(std::span<const std::byte> blob,
                                          std::size_t at,
                                          std::vector<domain::Instruction>& bank,
-                                         std::size_t depth) const noexcept {
+                                         std::size_t depth,
+                                         BankFailure& failure) const noexcept {
     tables::Array rows{};
     if (depth >= domain::kTraversalDepth
         || !tables::read_array(
@@ -395,15 +450,23 @@ bool RewardConditions::append_expression(std::span<const std::byte> blob,
                                       expressionRows_.dataOffset + operand * kExpressionRowStride
                                           + kExpressionBodyOffset,
                                       bank,
-                                      depth + 1)
+                                      depth + 1,
+                                      failure)
                 || bank.size() == before) {
                 return false;
             }
             continue;
         }
         domain::Instruction instruction{};
-        if (!bind(native, operand, instruction) || !unlocks::valid(instruction)
-            || !append(bank, instruction, domain::kInstructionCapacity)) {
+        if (!bind(native, operand, instruction) || !unlocks::valid(instruction)) {
+            return false;
+        }
+        if (bank.size() >= domain::kInstructionCapacity) {
+            failure = BankFailure::full;
+            return false;
+        }
+        if (!append(bank, instruction, domain::kInstructionCapacity)) {
+            failure = BankFailure::storage;
             return false;
         }
     }
@@ -414,11 +477,10 @@ bool RewardConditions::read(std::span<const std::byte> blob,
                             std::size_t at,
                             std::vector<domain::Instruction>& bank,
                             domain::Range& range,
-                            bool& bankFull) const noexcept {
+                            BankFailure& failure) const noexcept {
     const auto first = bank.size();
-    bankFull = false;
-    if (!append_expression(blob, at, bank, 0)) {
-        bankFull = bank.size() >= domain::kInstructionCapacity;
+    failure = BankFailure::none;
+    if (!append_expression(blob, at, bank, 0, failure)) {
         bank.resize(first);
         return false;
     }
@@ -429,31 +491,36 @@ bool RewardConditions::read(std::span<const std::byte> blob,
 bool RewardConditions::read_list(std::span<const std::byte> blob,
                                  std::size_t at,
                                  std::span<domain::Instruction> output,
-                                 std::size_t& count) const noexcept {
+                                 std::size_t& count,
+                                 BankFailure& failure) const noexcept {
     count = 0;
+    failure = BankFailure::none;
     tables::Array rows{};
     std::vector<domain::Instruction> instructions;
     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,
-                  bankFull)
+                  failure)
             || expression.count == 0) {
             return false;
         }
         // Every expression attached to a progression reward must hold.
-        if (i != 0
-            && !append(instructions,
-                       domain::Instruction{unlocks::Opcode::logicalAnd, unlocks::Bank::none, 0},
-                       output.size())) {
-            return false;
+        if (i != 0) {
+            if (instructions.size() >= output.size()) {
+                return false;
+            }
+            if (!append(instructions,
+                        domain::Instruction{unlocks::Opcode::logicalAnd, unlocks::Bank::none, 0},
+                        output.size())) {
+                failure = BankFailure::storage;
+                return false;
+            }
         }
         if (instructions.size() > output.size()) {
             return false;
@@ -504,12 +571,19 @@ bool RewardBuild::load(const reader::Source& source,
     tables::Array rows{};
     std::uint32_t supplementalTag = 0;
     // 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(
+    if (!conditions.load(source, scratch, root, maps)) {
+        return false;
+    }
+    if (!tables::slot_tag(root, kSupplementalRewardSlot, supplementalTag)) {
+        settle_unsupported("supplemental_slot");
+        return false;
+    }
+    if (!load_reward_table(source, scratch, root, kPoolSlot, blob, kPoolClass, "pool_table")) {
+        return false;
+    }
+    if (!tables::read_array(
             blob, tables::kTableArrayDescriptor, kPoolRowClass, kPoolStride, rows)) {
-        settle_unsupported("tables");
+        settle_unsupported("pool_schema");
         return false;
     }
     if (rows.count == 0 || rows.count > domain::kPoolCapacity) {
@@ -530,11 +604,16 @@ bool RewardBuild::load(const reader::Source& source,
                 const auto beforeInstructions = instructions_.size();
                 const auto beforeModifiers = modifiers_.size();
                 const auto beforeSockets = sockets_.size();
-                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);
+                auto failure = BankFailure::none;
+                const char* reason = nullptr;
+                if (!entry(blob, members.dataOffset + j * kEntryStride, failure, reason)) {
+                    // Skipping a bank failure would publish a pool missing some of its entries.
+                    if (failure == BankFailure::full) {
+                        settle_unsupported(reason);
+                        return false;
+                    }
+                    if (failure == BankFailure::storage) {
+                        report_refusal(reason);
                         return false;
                     }
                     instructions_.resize(beforeInstructions);
@@ -586,6 +665,8 @@ bool RewardBuild::read_item(std::uint32_t hash,
             ++unboundAcquiredFlags_;
         } else if (flag.bank == unlocks::Bank::account) {
             item.acquiredFlag = static_cast<std::uint16_t>(flag.operand);
+        } else {
+            ++discardedAcquiredFlags_;
         }
     }
     // A zero delta means the item is not a wrapper.
@@ -616,6 +697,7 @@ bool RewardBuild::read_item(std::uint32_t hash,
 
 bool RewardBuild::begin_items(std::size_t count) noexcept {
     unboundAcquiredFlags_ = 0;
+    discardedAcquiredFlags_ = 0;
     // A refused load has already reported itself.
     if (!loaded_) {
         return false;
@@ -682,12 +764,13 @@ bool RewardBuild::publish() noexcept {
     const std::size_t droppedEntries = entries_.size() - write;
     entries_.resize(write);
     sockets_.resize(socketWrite);
-    if (unboundAcquiredFlags_ != 0 || droppedEntries != 0) {
+    if (unboundAcquiredFlags_ != 0 || discardedAcquiredFlags_ != 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",
+                          "discarded_acquired_flags=%zu dropped_entries=%zu",
                           unboundAcquiredFlags_,
+                          discardedAcquiredFlags_,
                           droppedEntries);
     }
     const domain::View graph{pools_, entries_, items_, instructions_, modifiers_, sockets_};

+ 18 - 9
Sunrise/src/client/content/items/packages/package_reward_build.h

@@ -15,6 +15,9 @@ namespace sunrise::client::content::items::packages {
 
 struct SlotMaps;
 
+/** A full reward bank settles the domain; failed storage is retried on a later pass. */
+enum class BankFailure : std::uint8_t { none, full, storage };
+
 /** Resolves expression references and unlock slots once for a package pass. */
 class RewardConditions {
 public:
@@ -34,21 +37,24 @@ public:
                             std::size_t at,
                             std::vector<state::build_data::rewards::Instruction>& bank,
                             state::build_data::rewards::Range& range,
-                            bool& bankFull) const noexcept;
+                            BankFailure& failure) 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,
                                  std::span<state::build_data::rewards::Instruction> output,
-                                 std::size_t& count) const noexcept;
+                                 std::size_t& count,
+                                 BankFailure& failure) const noexcept;
 
 private:
     [[nodiscard]] bool append_expression(std::span<const std::byte> blob,
                                          std::size_t at,
                                          std::vector<state::build_data::rewards::Instruction>& bank,
-                                         std::size_t depth) const noexcept;
-    void load_class_flags(const middleware::content::packages::reader::Source& source,
-                          middleware::content::packages::reader::Scratch& scratch,
-                          std::span<const std::byte> root) noexcept;
+                                         std::size_t depth,
+                                         BankFailure& failure) const noexcept;
+    /** Reads every class binding before any reward condition can be published. */
+    [[nodiscard]] bool load_class_flags(const middleware::content::packages::reader::Source& source,
+                                        middleware::content::packages::reader::Scratch& scratch,
+                                        std::span<const std::byte> root) noexcept;
     std::array<std::uint16_t, state::kCharacterClassCount> classFlags_{};
     const SlotMaps* maps_{};
     bool loaded_{};
@@ -77,15 +83,18 @@ public:
     [[nodiscard]] bool publish() noexcept;
 
 private:
-    /** 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;
+    /** Appends one entry; a bank failure also names the bank and what refused it. */
+    [[nodiscard]] bool entry(std::span<const std::byte> blob,
+                             std::size_t at,
+                             BankFailure& failure,
+                             const char*& reason) 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::size_t discardedAcquiredFlags_{};
     std::vector<state::build_data::rewards::Pool> pools_;
     std::vector<state::build_data::rewards::Entry> entries_;
     std::vector<state::build_data::rewards::Item> items_;

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

@@ -20,7 +20,8 @@ bool read_season_reward(Storage& storage,
                         std::span<const std::byte> progressionTable,
                         const tables::Array& itemRows,
                         std::size_t at,
-                        domain::Reward& reward) noexcept {
+                        domain::Reward& reward,
+                        BankFailure& failure) noexcept {
     std::uint32_t rank = 0;
     std::uint32_t itemIndex = 0;
     std::uint32_t claimSlot = 0;
@@ -65,7 +66,8 @@ bool read_season_reward(Storage& storage,
             progressionTable,
             at + tables::kProgressionRewardConditionsOffset,
             reward.condition,
-            conditionCount)) {
+            conditionCount,
+            failure)) {
         return false;
     }
     reward.socketCount = static_cast<std::uint8_t>(socketCount);
@@ -137,7 +139,15 @@ bool build_season_pass(const reader::Source& source,
             rewards.dataOffset + static_cast<std::size_t>(row) * tables::kProgressionRewardStride;
         auto& reward = storage.seasonPassRewards[row];
         reward = {};
-        if (!read_season_reward(storage, progressionTable, itemRows, at, reward)) {
+        auto failure = BankFailure::none;
+        if (!read_season_reward(storage, progressionTable, itemRows, at, reward, failure)) {
+            // A row is left unavailable only for its data, never for failed storage.
+            if (failure == BankFailure::storage) {
+                core::log::write(core::log::Channel::client,
+                                 core::log::Level::warn,
+                                 "ev=pkg stage=season_pass result=fail reason=condition_storage");
+                return false;
+            }
             reward = {};
             ++skipped;
         }

+ 12 - 2
Sunrise/src/server/bap/bap_world_reward_queue.cpp

@@ -126,6 +126,7 @@ bool current_world_reward(WorldRewardRequest& request) noexcept {
     while (state::investment::store::next_reward(saved)) {
         request = {};
         request.id = saved.id;
+        request.definitionHash = saved.definitionHash;
         request.quantity = saved.quantity;
         request.kind = static_cast<WorldRewardKind>(saved.kind);
         const bool itemsPublished = state::build_data::item_definitions_ready();
@@ -162,8 +163,17 @@ 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");
+    core::log::writef(core::log::Channel::server,
+                      core::log::Level::warn,
+                      "ev=reward stage=world_retire id=%llu hash=0x%08X index=%u quantity=%d "
+                      "kind=%u result=%s reason=%s",
+                      static_cast<unsigned long long>(request.id),
+                      static_cast<unsigned>(request.definitionHash),
+                      static_cast<unsigned>(request.itemDefinitionIndex),
+                      static_cast<int>(request.quantity),
+                      static_cast<unsigned>(request.kind),
+                      retired ? "retired" : "kept",
+                      reason != nullptr ? reason : "unknown");
     return retired;
 }
 

+ 3 - 5
Sunrise/src/server/bap/encrypted/push/snapshot/family4_reward_updates.cpp

@@ -1,12 +1,10 @@
-/** Family-4 reward updates: season pass packages and record rewards, one revision each. */
+/** Family-4 reward updates: record rewards, one revision each. */
 
 #include <algorithm>
 #include <cstddef>
 #include <limits>
-#include <optional>
 #include <span>
 
-#include "../../../../../middleware/datagen/definitions.h"
 #include "../../../../../middleware/datagen/family4/account/account_encoder.h"
 #include "../../../../../middleware/datagen/family4/account/layout.h"
 #include "../../../../../middleware/datagen/family4/character/character_encoder.h"
@@ -21,7 +19,7 @@ namespace sunrise::server::bap::encrypted::push::snapshot {
 
 namespace family4_datagen = middleware::datagen::family4;
 
-// Every row one reward grant publishes is named in the character or the profile change list.
+// The grant array holds both change lists in full; State bounds each list separately.
 static_assert(state::kRecordRewardGrantCapacity
               == family4_datagen::character::layout::kInventoryChangeRecordCapacity
                      + family4_datagen::account::layout::kProfileInventoryChangeRecordCapacity);
@@ -36,7 +34,7 @@ bool prepare_record_reward_grant(
     Prepared& prepared) noexcept {
     namespace account_layout = family4_datagen::account::layout;
     namespace character_layout = family4_datagen::character::layout;
-    if (!mutation.prepared || mutation.rewardCount == 0
+    if (!mutation.prepared || (mutation.rewardCount == 0 && mutation.wrapperFlags.empty())
         || mutation.rewardCount > mutation.rewards.size() || !queuez::valid(before)
         || !queuez::valid(update.after) || !before.family4Active || before.family4ResidentCount == 0
         || before.family4Version == (std::numeric_limits<std::int32_t>::max)()

+ 6 - 3
Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp

@@ -63,8 +63,6 @@ selected_character(const state::AccountState& account) noexcept {
                                                std::span<std::byte> response,
                                                std::size_t& written,
                                                bool& touchesScratch) noexcept {
-    const std::unique_ptr<state::PendingRecordRewardGrant> pending(
-        new (std::nothrow) state::PendingRecordRewardGrant);
     const auto fail = [&](const char* reason) noexcept {
         report_reward_refusal("world_publish", request.itemDefinitionIndex, reason);
         return false;
@@ -73,13 +71,18 @@ selected_character(const state::AccountState& account) noexcept {
     if (response.size() < scratch.framed.size()) {
         return false;
     }
+    const std::unique_ptr<state::PendingRecordRewardGrant> pending(
+        new (std::nothrow) state::PendingRecordRewardGrant);
+    if (!pending) {
+        return fail("reward_allocation");
+    }
     std::uint64_t seed = 0;
     if (!middleware::crypto::random::fill(std::as_writable_bytes(std::span(&seed, 1)))) {
         return fail("random_source");
     }
     const char* reason = "transaction";
     state::investment::store::Transaction transaction;
-    if (!pending || !transaction.ready()) {
+    if (!transaction.ready()) {
         return fail(reason);
     }
     const auto preparation =

+ 1 - 0
Sunrise/src/server/bap/internal.h

@@ -339,6 +339,7 @@ enum class WorldRewardKind : std::uint8_t {
 /** One reward earned in world, held until a Family-4 peer can publish it. */
 struct WorldRewardRequest {
     std::uint64_t id{};
+    std::uint32_t definitionHash{};
     std::int32_t quantity{};
     std::uint16_t itemDefinitionIndex{};
     WorldRewardKind kind{};

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

@@ -1,6 +1,17 @@
+#include <algorithm>
+
 #include "codec.h"
 
 namespace sunrise::state::build_data::cache::records {
+namespace {
+
+/** A selection slot past an item's count holds what a default selection encodes to. */
+bool unused_selection(const RewardSelectionRecord& record) noexcept {
+    const rewards::Selection unused{};
+    return record.categoryHash == unused.categoryHash && record.count == unused.count;
+}
+
+} // namespace
 
 /** Encodes one pool identity and its member range. */
 bool encode(const rewards::Pool& value, RewardPoolRecord& record) noexcept {
@@ -68,13 +79,20 @@ bool encode(const rewards::Item& value, RewardItemRecord& record) noexcept {
     return true;
 }
 
-/** Decodes one item's wrapper; the complete-domain validator checks its references. */
+/** Decodes one item's wrapper and checks its unused selections; references are checked later. */
 bool decode(const RewardItemRecord& record, rewards::Item& value) noexcept {
     value = {};
+    if (record.selectionCount > record.selections.size()) {
+        return false;
+    }
+    const auto unused = std::span(record.selections).subspan(record.selectionCount);
+    if (!std::all_of(unused.begin(), unused.end(), unused_selection)) {
+        return false;
+    }
     value.definitionHash = record.definitionHash;
     value.poolIndex = record.poolIndex;
     value.acquiredFlag = record.acquiredFlag;
-    for (std::size_t i = 0; i < value.selections.size(); ++i) {
+    for (std::size_t i = 0; i < record.selectionCount; ++i) {
         value.selections[i] = {record.selections[i].categoryHash, record.selections[i].count};
     }
     value.selectionCount = record.selectionCount;

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

@@ -32,7 +32,7 @@ namespace sunrise::state::build_data::cache::records {
 /** These 8 ASCII bytes mark a Sunrise build-data file. */
 inline constexpr std::array<char, 8> kCacheMagic{'S', 'U', 'N', 'R', 'I', 'S', 'E', 'B'};
 /** Bump when stored layouts or extracted values change; other versions are rebuilt. */
-inline constexpr std::uint32_t kCacheFormatVersion = 71;
+inline constexpr std::uint32_t kCacheFormatVersion = 72;
 /** Signed -1 on disk means there is no equipment slot. */
 inline constexpr std::int8_t kAbsentEquipmentSlot = -1;
 /** The standard 64-bit FNV-1a offset basis starts the payload checksum. */

+ 5 - 2
Sunrise/src/state/build_data/rewards/definition.h

@@ -29,9 +29,12 @@ inline constexpr std::size_t kSocketOverrideCapacity = 2048;
 inline constexpr std::size_t kSocketsPerItem = items::details::kInitialPlugCapacity;
 /** Category selections one wrapper declares. The most any shipped wrapper declares is four. */
 inline constexpr std::size_t kSelectionCapacity = 4;
-/** Rows one grant may publish: one push's 16-record character and profile change lists. */
+/** Grant rows one resolution may prepare: both change lists in full; perk rows use no list. */
 inline constexpr std::size_t kGrantCapacity = 32;
-/** Nested pools and referenced conditions share a bounded traversal depth. */
+/**
+ * Nested pools, automatic wrappers and referenced conditions share one traversal depth. The
+ * installed graph reaches 7.
+ */
 inline constexpr std::size_t kTraversalDepth = 32;
 
 struct Range {

+ 13 - 5
Sunrise/src/state/build_data/rewards/reward_catalog.cpp

@@ -31,7 +31,7 @@ View view() noexcept {
             g_sockets.rows()};
 }
 
-/** Rejects a pool cycle or a nested path deeper than kTraversalDepth, memoizing subtree heights. */
+/** Rejects cycles and bounds combined pool and automatic-wrapper paths, memoizing heights. */
 bool valid_depth(View data,
                  std::size_t index,
                  std::array<std::uint8_t, kPoolCapacity>& heights,
@@ -48,13 +48,21 @@ bool valid_depth(View data,
     std::uint8_t height = 1;
     const Range range = data.pools[index].entries;
     for (const Entry& entry : data.entries.subspan(range.first, range.count)) {
-        if (entry.poolIndex == kAbsent) {
+        auto child = entry.poolIndex;
+        // Pool references take precedence over an item named by the same entry.
+        if (child == kAbsent && entry.itemIndex != kAbsent) {
+            const Item& item = data.items[entry.itemIndex];
+            if ((item.flags & kOpenOnAcquisition) != 0) {
+                child = item.poolIndex;
+            }
+        }
+        if (child == kAbsent) {
             continue;
         }
-        if (!valid_depth(data, entry.poolIndex, heights, depth + 1)) {
+        if (!valid_depth(data, child, heights, depth + 1)) {
             return false;
         }
-        height = (std::max)(height, static_cast<std::uint8_t>(heights[entry.poolIndex] + 1));
+        height = (std::max)(height, static_cast<std::uint8_t>(heights[child] + 1));
     }
     heights[index] = height;
     return true;
@@ -99,7 +107,7 @@ bool ready() noexcept {
     return g_pools.count() != 0 && g_items.count() != 0;
 }
 
-/** Checks bank ranges, item references and bounded acyclic pool traversal. */
+/** Checks bank ranges, item references and bounded acyclic pool and wrapper traversal. */
 bool valid(View data) noexcept {
     if (data.pools.empty() || data.pools.size() > kPoolCapacity || data.entries.empty()
         || data.entries.size() > kEntryCapacity || data.items.empty()

+ 1 - 1
Sunrise/src/state/build_data/rewards/reward_catalog.h

@@ -8,7 +8,7 @@ namespace sunrise::state::build_data::rewards {
 void clear() noexcept;
 /** True once pool and item banks have been published. */
 [[nodiscard]] bool ready() noexcept;
-/** Checks bank ranges, item references and bounded acyclic pool traversal. */
+/** Checks bank ranges, item references and bounded acyclic pool and wrapper traversal. */
 [[nodiscard]] bool valid(View data) noexcept;
 /** Publishes validated banks together under the catalog lock. */
 [[nodiscard]] bool replace(View data) noexcept;

+ 2 - 0
Sunrise/src/state/investment/store_internal.h

@@ -21,6 +21,8 @@ struct Session {
     std::array<bool, kCharacterCapacity> selected{};
     std::array<std::uint16_t, kCharacterCapacity> activities{};
     std::uint64_t signInSeconds{};
+    /** Set while an earned-perk walk is owed; a rolled-back transaction restores it. */
+    bool perkWalkOwed{true};
 };
 extern Session g_session;
 

+ 144 - 47
Sunrise/src/state/rewards/reward_resolver.cpp

@@ -7,10 +7,18 @@
 #include "../build_data/runtime.h"
 #include "../unlocks/unlocks_expression.h"
 
-namespace sunrise::state::rewards {
+namespace sunrise::state::reward_resolution {
 namespace {
 
 namespace definitions = build_data::rewards;
+using AcquiredFlags = std::bitset<unlocks::kAccountFlagCapacity>;
+
+/** Completed acquisitions are visible to later draws without changing saved unlocks. */
+struct ConditionInputs {
+    const Context& context;
+    const AcquiredFlags* acquiredFlags{};
+};
+
 /** The empty category tag matches every wrapper selection. */
 constexpr std::uint32_t kEmptyTag = 0x811C9DC5U;
 /** Native wrappers retain up to 64 selections, including rows without an item grant. */
@@ -28,11 +36,13 @@ bool refuse(const Context& context, const char* reason) noexcept {
 
 /** Reads one validated flag from the caller's unlock banks or selected character. */
 bool read_flag(const void* raw, const unlocks::Instruction& instruction, bool& set) noexcept {
-    const auto& context = *static_cast<const Context*>(raw);
+    const auto& inputs = *static_cast<const ConditionInputs*>(raw);
+    const auto& context = inputs.context;
     const auto index = instruction.operand;
     switch (instruction.bank) {
     case unlocks::Bank::account:
-        set = context.unlocks.accountFlags[index] == unlocks::kFlagSet;
+        set = (inputs.acquiredFlags != nullptr && (*inputs.acquiredFlags)[index])
+              || context.unlocks.accountFlags[index] == unlocks::kFlagSet;
         return true;
     case unlocks::Bank::profile:
         set = context.unlocks.profileFlags[index] == unlocks::kFlagSet;
@@ -52,7 +62,7 @@ bool read_flag(const void* raw, const unlocks::Instruction& instruction, bool& s
 bool read_value(const void* raw,
                 const unlocks::Instruction& instruction,
                 std::int32_t& value) noexcept {
-    const auto& context = *static_cast<const Context*>(raw);
+    const auto& context = static_cast<const ConditionInputs*>(raw)->context;
     switch (instruction.bank) {
     case unlocks::Bank::account:
         value = context.unlocks.objectiveValues[instruction.operand];
@@ -68,7 +78,8 @@ bool read_value(const void* raw,
 /** An empty program is unconditional; external identities are not in the saved banks. */
 bool condition(std::span<const definitions::Instruction> program,
                const Context& context,
-               bool& result) noexcept {
+               bool& result,
+               const AcquiredFlags* acquiredFlags = nullptr) noexcept {
     result = program.empty();
     if (program.empty()) {
         return true;
@@ -83,20 +94,33 @@ bool condition(std::span<const definitions::Instruction> program,
                                                                       : "external_value");
         }
     }
-    const unlocks::Inputs inputs{read_flag, read_value, &context};
+    const ConditionInputs conditionInputs{context, acquiredFlags};
+    const unlocks::Inputs inputs{read_flag, read_value, &conditionInputs};
     return unlocks::evaluate(program, inputs, result) || refuse(context, "condition_shape");
 }
 
 bool condition(definitions::View data,
                definitions::Range expression,
                const Context& context,
-               bool& result) noexcept {
+               bool& result,
+               const AcquiredFlags& acquiredFlags) noexcept {
     if (!definitions::fits(expression, data.instructions)) {
         result = false;
         return refuse(context, "condition_shape");
     }
-    return condition(
-        data.instructions.subspan(expression.first, expression.count), context, result);
+    return condition(data.instructions.subspan(expression.first, expression.count),
+                     context,
+                     result,
+                     &acquiredFlags);
+}
+
+/** SplitMix64 supplies replayable wrapper seeds and wrapper-local draws. */
+std::uint64_t next_random(std::uint64_t& state) noexcept {
+    state += 0x9E3779B97F4A7C15ULL;
+    auto bits = state;
+    bits = (bits ^ (bits >> 30)) * 0xBF58476D1CE4E5B9ULL;
+    bits = (bits ^ (bits >> 27)) * 0x94D049BB133111EBULL;
+    return bits ^ (bits >> 31);
 }
 
 struct Resolver {
@@ -104,19 +128,17 @@ struct Resolver {
     const Context& context;
     Result& result;
     std::uint64_t random;
+    std::uint64_t& wrapperSeed;
+    AcquiredFlags& acquiredFlags;
+    bool& excludedByUnlocks;
+    bool& leafDrawn;
+    std::size_t wrapperDepth{};
     std::array<const definitions::Entry*, kDrawCapacity> selectedEntries{};
     std::array<std::uint32_t, kDrawCapacity> selectedCategories{};
     std::size_t selectedCount{};
-    bool excludedByUnlocks{};
 
     double fraction() noexcept {
-        // SplitMix64 makes a prepared seed replayable without shared random state.
-        random += 0x9E3779B97F4A7C15ULL;
-        auto bits = random;
-        bits = (bits ^ (bits >> 30)) * 0xBF58476D1CE4E5B9ULL;
-        bits = (bits ^ (bits >> 27)) * 0x94D049BB133111EBULL;
-        bits ^= bits >> 31;
-        return static_cast<double>(bits >> 11) * 0x1.0p-53;
+        return static_cast<double>(next_random(random) >> 11) * 0x1.0p-53;
     }
 
     bool weight(const definitions::Entry& entry,
@@ -134,7 +156,7 @@ struct Resolver {
             }
         }
         bool enabled = false;
-        if (!condition(data, entry.condition, context, enabled)) {
+        if (!condition(data, entry.condition, context, enabled, acquiredFlags)) {
             return false;
         }
         if (!enabled) {
@@ -147,7 +169,7 @@ struct Resolver {
         }
         for (const auto& modifier :
              data.modifiers.subspan(entry.modifiers.first, entry.modifiers.count)) {
-            if (!condition(data, modifier.condition, context, enabled)) {
+            if (!condition(data, modifier.condition, context, enabled, acquiredFlags)) {
                 return false;
             }
             if (enabled) {
@@ -256,22 +278,72 @@ struct Resolver {
         selectedEntries[selectedCount] = chosen;
         selectedCategories[selectedCount++] = category;
         if (chosen->itemIndex == definitions::kAbsent) {
+            leafDrawn = true;
+            return true;
+        }
+        return acquire_item(*chosen);
+    }
+
+    /** Completes one acquisition after its children have finished. */
+    bool acquire_flag(const definitions::Item& item, bool wrapper) noexcept {
+        if (item.acquiredFlag == definitions::kAbsent) {
             return true;
         }
-        return append_grant(*chosen);
+        if (item.acquiredFlag >= acquiredFlags.size()) {
+            return refuse(context, "acquisition_flag");
+        }
+        acquiredFlags.set(item.acquiredFlag);
+        if (wrapper) {
+            result.wrapperFlags.set(item.acquiredFlag);
+        }
+        return true;
     }
 
-    /** Appends the chosen leaf's item grant with the socket overrides its row carries. */
-    bool append_grant(const definitions::Entry& chosen) noexcept {
+    /** Acquires a selected item, opening automatic wrappers before the next parent draw. */
+    bool acquire_item(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");
         }
+        const auto& item = data.items[chosen.itemIndex];
+        if (item.definitionHash == 0) {
+            return refuse(context, "item_unavailable");
+        }
+        if (item.poolIndex != definitions::kAbsent
+            && (item.flags & definitions::kOpenOnAcquisition) != 0) {
+            if (chosen.quantity != 1) {
+                return refuse(context, "wrapper_quantity");
+            }
+            // These sockets belong to the consumed instance, not its descendant items.
+            if (chosen.sockets.count != 0) {
+                return refuse(context, "wrapper_socket_override");
+            }
+            bool childLeafDrawn = false;
+            Resolver child{data,
+                           context,
+                           result,
+                           next_random(wrapperSeed),
+                           wrapperSeed,
+                           acquiredFlags,
+                           excludedByUnlocks,
+                           childLeafDrawn,
+                           wrapperDepth + 1};
+            if (!child.open_wrapper(item)) {
+                return false;
+            }
+            // A child acquires its flag only when a draw inside it completed on a leaf row.
+            if (!childLeafDrawn) {
+                return true;
+            }
+            leafDrawn = true;
+            return acquire_flag(item, true);
+        }
+        if (result.count == result.grants.size()) {
+            return refuse(context, "grant_capacity");
+        }
+        leafDrawn = true;
         auto& grant = result.grants[result.count++];
         grant.itemIndex = chosen.itemIndex;
         grant.quantity = static_cast<std::int32_t>(chosen.quantity);
@@ -281,6 +353,33 @@ struct Resolver {
         grant.socketCount = chosen.sockets.count;
         std::copy_n(
             data.sockets.begin() + chosen.sockets.first, grant.socketCount, grant.sockets.begin());
+        return acquire_flag(item, false);
+    }
+
+    /** Each wrapper owns its selection history and random stream; pool references share them. */
+    bool open_wrapper(const definitions::Item& item) noexcept {
+        if (wrapperDepth >= definitions::kTraversalDepth) {
+            return refuse(context, "wrapper_depth");
+        }
+        if (item.selectionCount == 0 || item.selectionCount > item.selections.size()) {
+            return refuse(context, "wrapper_selections");
+        }
+        for (std::size_t i = 0; i < item.selectionCount; ++i) {
+            const auto& declared = item.selections[i];
+            for (std::size_t j = 0; j < declared.count; ++j) {
+                double total = 0;
+                if (!pool_weight(item.poolIndex, declared.categoryHash, 0, total)) {
+                    return false;
+                }
+                // Completed acquisitions can leave fewer eligible members than requested.
+                if (total == 0) {
+                    break;
+                }
+                if (!draw(item.poolIndex, declared.categoryHash, 0, total)) {
+                    return false;
+                }
+            }
+        }
         return true;
     }
 };
@@ -310,30 +409,28 @@ bool resolve_item(definitions::View data,
         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];
-            for (std::size_t j = 0; j < declared.count; ++j) {
-                double total = 0;
-                if (!resolver.pool_weight(item.poolIndex, declared.categoryHash, 0, total)) {
-                    return false;
-                }
-                // A fixed bundle can have fewer eligible members after an acquisition unlock.
-                if (total == 0) {
-                    break;
-                }
-                if (!resolver.draw(item.poolIndex, declared.categoryHash, 0, total)) {
-                    return false;
-                }
-            }
+        std::uint64_t wrapperSeed = context.seed;
+        AcquiredFlags acquiredFlags{};
+        bool excludedByUnlocks = false;
+        bool leafDrawn = false;
+        Resolver resolver{data,
+                          context,
+                          staged,
+                          context.seed,
+                          wrapperSeed,
+                          acquiredFlags,
+                          excludedByUnlocks,
+                          leafDrawn};
+        if (!resolver.open_wrapper(item)) {
+            return false;
         }
-        if (staged.count == 0) {
-            ineligible = resolver.excludedByUnlocks;
+        if (staged.count == 0 && staged.wrapperFlags.none()) {
+            ineligible = excludedByUnlocks && !leafDrawn;
             return refuse(context, "empty_reward");
         }
+        if (!resolver.acquire_flag(item, true)) {
+            return false;
+        }
     }
     result = staged;
     if (context.refusal != nullptr) {
@@ -380,4 +477,4 @@ Resolution resolve(const Context& context,
     return request.ineligible ? Resolution::ineligible : Resolution::refused;
 }
 
-} // namespace sunrise::state::rewards
+} // namespace sunrise::state::reward_resolution

+ 6 - 2
Sunrise/src/state/rewards/reward_resolver.h

@@ -1,10 +1,12 @@
 #pragma once
 
+#include <bitset>
+
 #include "../account/account_state.h"
 #include "../build_data/rewards/definition.h"
 #include "../unlocks/definition.h"
 
-namespace sunrise::state::rewards {
+namespace sunrise::state::reward_resolution {
 
 /** One item a resolved reward grants, with the socket overrides its row carries. */
 struct Grant {
@@ -18,6 +20,8 @@ struct Grant {
 struct Result {
     std::array<Grant, build_data::rewards::kGrantCapacity> grants{};
     std::size_t count{};
+    /** Consumed wrappers acquire flags without occupying inventory grant rows. */
+    std::bitset<unlocks::kAccountFlagCapacity> wrapperFlags{};
 };
 
 /** The seed belongs to the prepared server transaction, so validation repeats the same draw. */
@@ -43,4 +47,4 @@ enum class Resolution : std::uint8_t { resolved, ineligible, refused };
                                  std::uint32_t quantity,
                                  Result& result) noexcept;
 
-} // namespace sunrise::state::rewards
+} // namespace sunrise::state::reward_resolution

+ 15 - 2
Sunrise/src/state/runtime/runtime.h

@@ -5,6 +5,7 @@
 #include <cstdint>
 #include <optional>
 #include <span>
+#include <vector>
 
 #include "../build_data/items/quest_initialization.h"
 #include "../build_data/records/definition.h"
@@ -242,6 +243,12 @@ struct PreparedRecordReward {
     std::uint8_t previousFlag{};
 };
 
+/** One consumed wrapper's acquisition flag and the value its preparation observed. */
+struct PreparedRewardFlag {
+    std::uint16_t index{};
+    std::uint8_t previousValue{};
+};
+
 /** A reward grant that claims no record carries this instead of a record row. */
 inline constexpr std::uint16_t kUnclaimedRecordIndex = 0xFFFFU;
 
@@ -254,6 +261,8 @@ struct PendingRecordRewardGrant {
     std::array<account::inventory::ProfileItem, account::inventory::kProfileItemCapacity>
         afterProfileItems{};
     std::array<PreparedRecordReward, kRecordRewardGrantCapacity> rewards{};
+    /** Sorted acquisition flags from wrappers which opened without remaining in inventory. */
+    std::vector<PreparedRewardFlag> wrapperFlags;
     /** Record claimed with this grant, already written to the banks, or the unclaimed row. */
     std::uint16_t claimedRecordIndex{kUnclaimedRecordIndex};
     std::uint64_t accountSoid{};
@@ -780,7 +789,10 @@ 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. */
+/**
+ * Publishes every seasonal value the seeded XP and artifact ownership imply.
+ * An owed earned-perk walk runs here too.
+ */
 [[nodiscard]] bool seed_seasonal_progression() noexcept;
 
 /** @return One-based Season of Arrivals rank the published XP earns. */
@@ -790,7 +802,8 @@ inline constexpr std::uint16_t kArtifactUnlockProgressionIndex = 39;
 [[nodiscard]] std::uint16_t artifact_power_bonus() noexcept;
 
 /**
- * Adds base XP to the seasonal lanes and republishes every value derived from the total.
+ * Adds base XP to the seasonal lanes and republishes every value derived from the total,
+ * including earned-perk flags when a rank is reached or a walk is owed.
  * @param amount Positive XP to grant.
  * @return False when the amount is not positive or the total would overflow.
  */

+ 153 - 33
Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp

@@ -7,6 +7,8 @@
 #include <cstdint>
 #include <limits>
 
+#include "../../middleware/datagen/family4/account/layout.h"
+#include "../../middleware/datagen/family4/character/layout.h"
 #include "../../middleware/datagen/family4/loadout/loadout_resolver.h"
 #include "../build_data/runtime.h"
 #include "../investment/store_internal.h"
@@ -26,10 +28,47 @@ namespace family4_loadout = middleware::datagen::family4::loadout;
 
 namespace {
 
+/** Prepares inventory rows and consumed-wrapper flags against the same account view. */
+[[nodiscard]] RewardPreparation
+prepare_reward_grant(std::span<const DirectRecordReward> rewards,
+                     std::uint16_t claimedRecordIndex,
+                     const std::bitset<unlocks::kAccountFlagCapacity>* wrapperFlags,
+                     PendingRecordRewardGrant& mutation,
+                     const char** refusal) noexcept;
+
 [[nodiscard]] bool materialize_record_reward(const AccountState& current,
                                              const PendingRecordRewardGrant& mutation,
                                              AccountState& after) noexcept;
 
+/** Each native change list must fit independently; unlock flags consume neither list. */
+[[nodiscard]] const char*
+reward_change_refusal(std::span<const PreparedRecordReward> rewards) noexcept {
+    std::size_t characterChanges = 0;
+    std::size_t profileChanges = 0;
+    for (const auto& reward : rewards) {
+        switch (reward.kind) {
+        case RecordRewardKind::characterInstance:
+        case RecordRewardKind::characterStack:
+            if (++characterChanges
+                > middleware::datagen::family4::character::layout::kInventoryChangeRecordCapacity) {
+                return "character_change_capacity";
+            }
+            break;
+        case RecordRewardKind::profileStack:
+            if (++profileChanges > middleware::datagen::family4::account::layout::
+                    kProfileInventoryChangeRecordCapacity) {
+                return "profile_change_capacity";
+            }
+            break;
+        case RecordRewardKind::accountUnlock:
+            break;
+        default:
+            return "reward_kind";
+        }
+    }
+    return nullptr;
+}
+
 /** A reward overrides socket types, so adding an ordinary lane cannot shift a fixed roll. */
 [[nodiscard]] bool
 apply_reward_sockets(const item_details::Definition& detail,
@@ -105,7 +144,7 @@ apply_reward_sockets(const item_details::Definition& detail,
     return true;
 }
 
-[[nodiscard]] RewardPreparation prepare_resolved_reward(const rewards::Result& resolved,
+[[nodiscard]] RewardPreparation prepare_resolved_reward(const reward_resolution::Result& resolved,
                                                         PendingRecordRewardGrant& mutation,
                                                         const char** refusal) noexcept {
     std::array<DirectRecordReward, kRecordRewardGrantCapacity> rows{};
@@ -119,8 +158,11 @@ apply_reward_sockets(const item_details::Definition& detail,
                    std::span(grant.sockets).first(grant.socketCount),
                    true};
     }
-    return prepare_record_reward_grant(
-        std::span(rows).first(resolved.count), kUnclaimedRecordIndex, mutation, refusal);
+    return prepare_reward_grant(std::span(rows).first(resolved.count),
+                                kUnclaimedRecordIndex,
+                                &resolved.wrapperFlags,
+                                mutation,
+                                refusal);
 }
 
 /** Classifies a catalog refusal: a verdict unless a catalog was cleared meanwhile. */
@@ -148,7 +190,7 @@ enum class PassResolution { claim, replay };
 [[nodiscard]] bool resolve_pass(const build_data::season_pass::Reward& reward,
                                 const AccountState& account,
                                 std::uint64_t seed,
-                                rewards::Result& result,
+                                reward_resolution::Result& result,
                                 PassResolution resolution,
                                 const char** reason = nullptr) noexcept {
     const auto character = selected_character_index(account);
@@ -168,17 +210,19 @@ enum class PassResolution { claim, replay };
         flags.accountFlags[reward.claimFlagIndex] = unlocks::kFlagClear;
     }
     bool enabled = false;
-    if (!rewards::eligible(std::span(reward.condition).first(reward.conditionCount),
-                           {flags, account.characters[character].characterClass, seed, reason},
-                           enabled)
+    if (!reward_resolution::eligible(
+            std::span(reward.condition).first(reward.conditionCount),
+            {flags, account.characters[character].characterClass, seed, reason},
+            enabled)
         || !enabled) {
         return false;
     }
-    if (rewards::resolve({flags, account.characters[character].characterClass, seed, reason},
-                         reward.itemIndex,
-                         reward.quantity,
-                         result)
-        != rewards::Resolution::resolved) {
+    if (reward_resolution::resolve(
+            {flags, account.characters[character].characterClass, seed, reason},
+            reward.itemIndex,
+            reward.quantity,
+            result)
+        != reward_resolution::Resolution::resolved) {
         return false;
     }
     if (reward.socketCount != 0) {
@@ -197,13 +241,19 @@ enum class PassResolution { claim, replay };
 [[nodiscard]] bool reward_matches(const build_data::season_pass::Reward& reward,
                                   const PendingSeasonPassReward& mutation) noexcept {
     const auto* grant = &mutation.grant;
-    rewards::Result expected{};
+    reward_resolution::Result expected{};
     if (!mutation.prepared || mutation.sourceDefinitionHash != reward.itemHash
         || !resolve_pass(
             reward, account_snapshot(), mutation.seed, expected, PassResolution::replay)
-        || expected.count != grant->rewardCount) {
+        || expected.count != grant->rewardCount
+        || expected.wrapperFlags.count() != grant->wrapperFlags.size()) {
         return false;
     }
+    for (const auto& flag : grant->wrapperFlags) {
+        if (flag.index >= expected.wrapperFlags.size() || !expected.wrapperFlags[flag.index]) {
+            return false;
+        }
+    }
     for (std::size_t i = 0; i < expected.count; ++i) {
         const auto& planned = expected.grants[i];
         const auto& prepared = grant->rewards[i];
@@ -261,7 +311,7 @@ bool prepare_season_pass_reward(std::uint16_t rewardIndex,
         return false;
     }
     mutation.seed = seed;
-    rewards::Result resolved{};
+    reward_resolution::Result resolved{};
     reason = "reward_condition";
     if (!resolve_pass(
             reward, account_snapshot(), mutation.seed, resolved, PassResolution::claim, &reason)) {
@@ -331,17 +381,17 @@ RewardPreparation prepare_item_reward(std::uint16_t itemIndex,
         || !investment::store::read_unlocks(flags, static_cast<int>(character))) {
         return RewardPreparation::deferred;
     }
-    rewards::Result resolved{};
-    const auto resolution =
-        rewards::resolve({flags, account.characters[character].characterClass, seed, &reason},
-                         itemIndex,
-                         quantity,
-                         resolved);
+    reward_resolution::Result resolved{};
+    const auto resolution = reward_resolution::resolve(
+        {flags, account.characters[character].characterClass, seed, &reason},
+        itemIndex,
+        quantity,
+        resolved);
     // The saved unlocks can change, so an empty draw they caused is retried, not retired.
-    if (resolution == rewards::Resolution::ineligible) {
+    if (resolution == reward_resolution::Resolution::ineligible) {
         return RewardPreparation::deferred;
     }
-    if (resolution != rewards::Resolution::resolved) {
+    if (resolution != reward_resolution::Resolution::resolved) {
         return catalog_refusal(reason);
     }
     reason = "reward_placement";
@@ -364,6 +414,17 @@ bool preview_reward_unlocks(const PendingRecordRewardGrant& mutation,
             return false;
         }
     }
+    for (std::size_t i = 0; i < mutation.wrapperFlags.size(); ++i) {
+        const auto& flag = mutation.wrapperFlags[i];
+        if (flag.index >= after.accountFlags.size() || flag.previousValue > unlocks::kFlagSet
+            || (i != 0 && mutation.wrapperFlags[i - 1].index >= flag.index)
+            || after.accountFlags[flag.index] != flag.previousValue) {
+            return false;
+        }
+    }
+    for (const auto& flag : mutation.wrapperFlags) {
+        after.accountFlags[flag.index] = unlocks::kFlagSet;
+    }
     for (std::size_t i = 0; i < mutation.rewardCount; ++i) {
         const auto flag = mutation.rewards[i].acquiredFlag;
         if (flag != build_data::rewards::kAbsent) {
@@ -406,9 +467,11 @@ namespace {
 [[nodiscard]] bool materialize_record_reward(const AccountState& current,
                                              const PendingRecordRewardGrant& mutation,
                                              AccountState& after) noexcept {
-    if (!mutation.prepared || mutation.rewardCount == 0
-        || mutation.rewardCount > mutation.rewards.size() || mutation.accountSoid == 0
-        || mutation.characterSoid == 0 || mutation.characterIndex >= current.characterCount
+    if (!mutation.prepared || (mutation.rewardCount == 0 && mutation.wrapperFlags.empty())
+        || mutation.rewardCount > mutation.rewards.size()
+        || reward_change_refusal(std::span(mutation.rewards).first(mutation.rewardCount)) != nullptr
+        || mutation.accountSoid == 0 || mutation.characterSoid == 0
+        || mutation.characterIndex >= current.characterCount
         || current.primarySoid != mutation.accountSoid
         || !same_character(current.characters[mutation.characterIndex], mutation.beforeCharacter)
         || !same_profile_inventory(
@@ -516,18 +579,50 @@ namespace {
     return true;
 }
 
-} // namespace
+/** Captures only flags carried by consumed wrappers; storage failure remains retryable. */
+[[nodiscard]] bool prepare_wrapper_flags(const std::bitset<unlocks::kAccountFlagCapacity>* flags,
+                                         PendingRecordRewardGrant& mutation,
+                                         const char*& reason) noexcept {
+    if (flags == nullptr) {
+        return true;
+    }
+    try {
+        mutation.wrapperFlags.reserve(flags->count());
+        for (std::size_t index = 0; index < flags->size(); ++index) {
+            if (!(*flags)[index]) {
+                continue;
+            }
+            reason = "wrapper_acquisition_flag";
+            std::int32_t before = 0;
+            if (!investment::store::read_unlock(investment::store::Bank::accountFlags,
+                                                static_cast<std::uint16_t>(index),
+                                                before)
+                || before < 0 || before > unlocks::kFlagSet) {
+                return false;
+            }
+            mutation.wrapperFlags.push_back(
+                {static_cast<std::uint16_t>(index), static_cast<std::uint8_t>(before)});
+        }
+    } catch (...) {
+        reason = "wrapper_flag_storage";
+        return false;
+    }
+    return true;
+}
 
-/** Prepares every reward over one cumulative account view. */
-RewardPreparation prepare_record_reward_grant(std::span<const DirectRecordReward> rewards,
-                                              std::uint16_t claimedRecordIndex,
-                                              PendingRecordRewardGrant& mutation,
-                                              const char** refusal) noexcept {
+/** Prepares inventory rows and consumed-wrapper flags against the same account view. */
+RewardPreparation
+prepare_reward_grant(std::span<const DirectRecordReward> rewards,
+                     std::uint16_t claimedRecordIndex,
+                     const std::bitset<unlocks::kAccountFlagCapacity>* wrapperFlags,
+                     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()) {
+    if ((rewards.empty() && (wrapperFlags == nullptr || wrapperFlags->none()))
+        || rewards.size() > mutation.rewards.size()) {
         return RewardPreparation::unresolvable;
     }
     reason = "not_ready";
@@ -545,6 +640,9 @@ RewardPreparation prepare_record_reward_grant(std::span<const DirectRecordReward
         return RewardPreparation::deferred;
     }
 
+    if (!prepare_wrapper_flags(wrapperFlags, mutation, reason)) {
+        return RewardPreparation::deferred;
+    }
     AccountState working = account;
     for (std::size_t index = 0; index < rewards.size(); ++index) {
         reason = "item_identity";
@@ -715,6 +813,11 @@ RewardPreparation prepare_record_reward_grant(std::span<const DirectRecordReward
         mutation.rewards[index] = prepared;
     }
 
+    reason = reward_change_refusal(std::span(mutation.rewards).first(rewards.size()));
+    if (reason != nullptr) {
+        // Only a wrapper draw can exceed a list, and its next draw may fit.
+        return RewardPreparation::deferred;
+    }
     reason = "loadout";
     // A full native bucket rejects the whole loadout; retry later.
     family4_loadout::ResolvedLoadout loadout{};
@@ -738,6 +841,16 @@ RewardPreparation prepare_record_reward_grant(std::span<const DirectRecordReward
     return RewardPreparation::prepared;
 }
 
+} // namespace
+
+/** Prepares every direct reward over one cumulative account view. */
+RewardPreparation prepare_record_reward_grant(std::span<const DirectRecordReward> rewards,
+                                              std::uint16_t claimedRecordIndex,
+                                              PendingRecordRewardGrant& mutation,
+                                              const char** refusal) noexcept {
+    return prepare_reward_grant(rewards, claimedRecordIndex, nullptr, mutation, refusal);
+}
+
 bool preview_record_reward_grant(const PendingRecordRewardGrant& mutation,
                                  AccountState& after) noexcept {
     after = {};
@@ -766,6 +879,13 @@ bool commit_record_reward(PendingRecordRewardGrant& mutation) noexcept {
                     investment::store::Bank::accountFlags, flag, unlocks::kFlagSet);
             }
         }
+        for (const auto& flag : mutation.wrapperFlags) {
+            if (!ready) {
+                break;
+            }
+            ready = investment::store::write_unlock(
+                investment::store::Bank::accountFlags, flag.index, unlocks::kFlagSet);
+        }
         ready = ready && transaction.commit();
     }
     if (!ready && mutation.claimedRecordIndex != kUnclaimedRecordIndex) {

+ 15 - 13
Sunrise/src/state/runtime/state_progression_runtime.cpp

@@ -263,14 +263,14 @@ void report_perk_refusal(std::size_t index, const char* reason) noexcept {
                       reason);
 }
 
-/** Set while a perk walk is owed; only touched under g_mutex. */
-bool g_perkWalkOwed = true;
+// The walk skips an absent acquisition flag and an absent claim flag on one sentinel.
+static_assert(build_data::rewards::kAbsent == build_data::season_pass::kUnavailableFlagIndex);
 
-/** Earned perks set both flags in the transaction that publishes their qualifying rank. */
+/** Earned perks set both flags when their rank is reached or an owed walk runs. */
 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;
+        investment::store::g_session.perkWalkOwed = true;
         return true;
     }
     AccountState account;
@@ -309,9 +309,10 @@ bool grant_progress_flags(bool& walked) noexcept {
         }
         const char* reason = nullptr;
         bool eligible = false;
-        if (!rewards::eligible(std::span(reward.condition).first(reward.conditionCount),
-                               {flags, account.characters[character].characterClass, 0, &reason},
-                               eligible)) {
+        if (!reward_resolution::eligible(
+                std::span(reward.condition).first(reward.conditionCount),
+                {flags, account.characters[character].characterClass, 0, &reason},
+                eligible)) {
             report_perk_refusal(index, reason != nullptr ? reason : "perk_condition");
             continue;
         }
@@ -411,9 +412,10 @@ bool seed_seasonal_progression() noexcept {
     const bool published =
         publish_experience_lanes(experience) && publish_artifact_locked(family, mask, experience)
         && investment::store::write_family5(family)
-        && (!g_perkWalkOwed || grant_progress_flags(walked)) && transaction.commit();
+        && (!investment::store::g_session.perkWalkOwed || grant_progress_flags(walked))
+        && transaction.commit();
     if (published && walked) {
-        g_perkWalkOwed = false;
+        investment::store::g_session.perkWalkOwed = false;
     }
     investment::store::g_mutex.unlock();
     return published;
@@ -450,12 +452,12 @@ bool grant_seasonal_experience(std::int32_t amount) noexcept {
         return false;
     }
     const std::int32_t previous = seasonal_experience();
-    if (previous < 0 || amount > (std::numeric_limits<std::int32_t>::max)() - previous) {
+    if (previous > (std::numeric_limits<std::int32_t>::max)() - amount) {
         return false;
     }
     const auto total = previous + amount;
-    const bool walkPerks =
-        g_perkWalkOwed || seasonal_rank_for(previous) != seasonal_rank_for(total);
+    const bool walkPerks = investment::store::g_session.perkWalkOwed
+                           || seasonal_rank_for(previous) != seasonal_rank_for(total);
 
     Family5State family;
     if (!investment::store::read_family5(family)) {
@@ -470,7 +472,7 @@ bool grant_seasonal_experience(std::int32_t amount) noexcept {
                          && investment::store::write_family5(family)
                          && (!walkPerks || grant_progress_flags(walked)) && transaction.commit();
     if (granted && walked) {
-        g_perkWalkOwed = false;
+        investment::store::g_session.perkWalkOwed = false;
     }
     return granted;
 }

+ 2 - 2
Sunrise/src/state/unlocks/unlocks_expression.h

@@ -6,8 +6,8 @@
 
 namespace sunrise::state::unlocks {
 
-/** Stack slots one expression may use. The deepest installed reward condition needs nine. */
-inline constexpr std::size_t kExpressionStackCapacity = 16;
+/** Stack slots one expression may use. The deepest installed reward condition needs 20. */
+inline constexpr std::size_t kExpressionStackCapacity = 32;
 
 /** Opcodes the client's expression evaluator implements that the server evaluates. */
 enum class Opcode : std::uint8_t {