Procházet zdrojové kódy

Merge pull request #146 from throgsoft/runtime-item-definitions

Extract runtime reward definitions and replace hardcoded grants
stan před 4 dny
rodič
revize
6f71960dfc
65 změnil soubory, kde provedl 4279 přidání a 1943 odebrání
  1. 12 2
      Sunrise/Sunrise.vcxproj
  2. 11 8
      Sunrise/src/client/content/items/packages/internal.h
  3. 16 7
      Sunrise/src/client/content/items/packages/package_item_build.cpp
  4. 31 5
      Sunrise/src/client/content/items/packages/package_item_rows.cpp
  5. 4 2
      Sunrise/src/client/content/items/packages/package_node_build.cpp
  6. 808 0
      Sunrise/src/client/content/items/packages/package_reward_build.cpp
  7. 116 0
      Sunrise/src/client/content/items/packages/package_reward_build.h
  8. 96 102
      Sunrise/src/client/content/items/packages/package_season_pass_build.cpp
  9. 1 20
      Sunrise/src/middleware/content/packages/tables/activity_table.h
  10. 13 1
      Sunrise/src/middleware/content/packages/tables/definition_index_table.cpp
  11. 17 4
      Sunrise/src/middleware/content/packages/tables/definition_index_table.h
  12. 49 0
      Sunrise/src/middleware/content/packages/tables/field_reader.h
  13. 2 19
      Sunrise/src/middleware/content/packages/tables/internal.h
  14. 130 25
      Sunrise/src/server/bap/bap_world_reward_queue.cpp
  15. 1 4
      Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_incident_grants.cpp
  16. 6 53
      Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp
  17. 1 33
      Sunrise/src/server/bap/encrypted/internal.h
  18. 0 53
      Sunrise/src/server/bap/encrypted/push/queuez/queuez_select_character.cpp
  19. 11 191
      Sunrise/src/server/bap/encrypted/push/snapshot/family4_reward_updates.cpp
  20. 0 9
      Sunrise/src/server/bap/encrypted/push/snapshot/internal.h
  21. 145 53
      Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp
  22. 27 69
      Sunrise/src/server/bap/encrypted/queuez/queuez_reward_staging.cpp
  23. 9 8
      Sunrise/src/server/bap/encrypted/queuez/queuez_state_validation.h
  24. 19 43
      Sunrise/src/server/bap/encrypted/queuez/staging/queuez_character_staging.cpp
  25. 16 3
      Sunrise/src/server/bap/internal.h
  26. 13 311
      Sunrise/src/server/web_service/web_service_record_actions.cpp
  27. 4 0
      Sunrise/src/state/account/account_state.h
  28. 8 2
      Sunrise/src/state/build_data/build_data_runtime.cpp
  29. 13 3
      Sunrise/src/state/build_data/cache/read/cache_file_reader.cpp
  30. 39 8
      Sunrise/src/state/build_data/cache/read/cache_payload_reader.cpp
  31. 37 5
      Sunrise/src/state/build_data/cache/records/cache_domain_validation.cpp
  32. 54 24
      Sunrise/src/state/build_data/cache/records/cache_investment_records.cpp
  33. 163 0
      Sunrise/src/state/build_data/cache/records/cache_reward_records.cpp
  34. 17 5
      Sunrise/src/state/build_data/cache/records/codec.h
  35. 19 3
      Sunrise/src/state/build_data/cache/records/domains.h
  36. 99 16
      Sunrise/src/state/build_data/cache/records/format.h
  37. 7 2
      Sunrise/src/state/build_data/cache/write/cache_file_writer.cpp
  38. 14 4
      Sunrise/src/state/build_data/cache/write/cache_payload_writer.cpp
  39. 6 1
      Sunrise/src/state/build_data/cache/write/temporary/temporary_cache_file.cpp
  40. 5 0
      Sunrise/src/state/build_data/inventory/buckets/definition.h
  41. 121 0
      Sunrise/src/state/build_data/rewards/definition.h
  42. 35 0
      Sunrise/src/state/build_data/rewards/reward_build_data_runtime.cpp
  43. 221 0
      Sunrise/src/state/build_data/rewards/reward_catalog.cpp
  44. 28 0
      Sunrise/src/state/build_data/rewards/reward_catalog.h
  45. 36 10
      Sunrise/src/state/build_data/runtime.h
  46. 2 0
      Sunrise/src/state/build_data/runtime/build_data_catalog_runtime.cpp
  47. 34 11
      Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.cpp
  48. 6 1
      Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.h
  49. 12 16
      Sunrise/src/state/build_data/season_pass/definition.h
  50. 11 8
      Sunrise/src/state/build_data/season_pass/season_pass_build_data_runtime.cpp
  51. 38 50
      Sunrise/src/state/build_data/season_pass/season_pass_catalog.cpp
  52. 8 53
      Sunrise/src/state/build_data/season_pass/season_pass_catalog.h
  53. 0 119
      Sunrise/src/state/build_data/vendors/vendor_expression.cpp
  54. 0 58
      Sunrise/src/state/build_data/vendors/vendor_expression.h
  55. 2 0
      Sunrise/src/state/investment/store_internal.h
  56. 5 184
      Sunrise/src/state/progression/season_pass_reward_catalog.h
  57. 487 0
      Sunrise/src/state/rewards/reward_resolver.cpp
  58. 50 0
      Sunrise/src/state/rewards/reward_resolver.h
  59. 66 38
      Sunrise/src/state/runtime/runtime.h
  60. 0 175
      Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp
  61. 652 102
      Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp
  62. 0 7
      Sunrise/src/state/runtime/state_account_transaction_helpers.h
  63. 131 13
      Sunrise/src/state/runtime/state_progression_runtime.cpp
  64. 203 0
      Sunrise/src/state/unlocks/unlocks_expression.cpp
  65. 92 0
      Sunrise/src/state/unlocks/unlocks_expression.h

+ 12 - 2
Sunrise/Sunrise.vcxproj

@@ -99,6 +99,11 @@
     <None Include="vendor\lua\UPSTREAM_README" />
   </ItemGroup>
   <ItemGroup>
+    <ClCompile Include="src\state\build_data\cache\records\cache_reward_records.cpp" />
+    <ClCompile Include="src\client\content\items\packages\package_reward_build.cpp" />
+    <ClCompile Include="src\state\rewards\reward_resolver.cpp" />
+    <ClCompile Include="src\state\build_data\rewards\reward_catalog.cpp" />
+    <ClCompile Include="src\state\build_data\rewards\reward_build_data_runtime.cpp" />
     <ClCompile Include="src\client\diagnostics\studio\studio_link.cpp" />
     <ClCompile Include="src\client\diagnostics\studio\studio_world_reader.cpp" />
     <ClCompile Include="src\client\diagnostics\studio\studio_world_link.cpp" />
@@ -722,7 +727,7 @@
     <ClCompile Include="src\state\build_data\cache\records\cache_vendor_records.cpp" />
     <ClCompile Include="src\state\build_data\vendors\vendor_catalog.cpp" />
     <ClCompile Include="src\state\build_data\vendors\artifact_sale_view.cpp" />
-    <ClCompile Include="src\state\build_data\vendors\vendor_expression.cpp" />
+    <ClCompile Include="src\state\unlocks\unlocks_expression.cpp" />
     <ClCompile Include="src\client\content\vendors\package_vendor_build.cpp" />
     <ClCompile Include="src\state\build_data\cache\records\cache_hash_name_records.cpp" />
     <ClCompile Include="src\state\build_data\cache\records\cache_entity_metadata_records.cpp" />
@@ -1341,6 +1346,11 @@
     <ClCompile Remove="vendor\lua\lzio.c" />
   </ItemGroup>
   <ItemGroup>
+    <ClInclude Include="src\middleware\content\packages\tables\field_reader.h" />
+    <ClInclude Include="src\client\content\items\packages\package_reward_build.h" />
+    <ClInclude Include="src\state\rewards\reward_resolver.h" />
+    <ClInclude Include="src\state\build_data\rewards\reward_catalog.h" />
+    <ClInclude Include="src\state\build_data\rewards\definition.h" />
     <ClInclude Include="resources\resource.h" />
     <ClInclude Include="src\core\logging\log.h" />
     <ClInclude Include="src\core\logging\snapshot\snapshot.h" />
@@ -1629,7 +1639,7 @@
     <ClInclude Include="src\state\build_data\spawn_sets\definition.h" />
     <ClInclude Include="src\state\build_data\vendors\definition.h" />
     <ClInclude Include="src\state\build_data\vendors\vendor_catalog.h" />
-    <ClInclude Include="src\state\build_data\vendors\vendor_expression.h" />
+    <ClInclude Include="src\state\unlocks\unlocks_expression.h" />
     <ClInclude Include="src\client\content\vendors\layout.h" />
     <ClInclude Include="src\client\content\vendors\vendor_build.h" />
     <ClInclude Include="src\state\build_data\scenarios\definition.h" />

+ 11 - 8
Sunrise/src/client/content/items/packages/internal.h

@@ -24,6 +24,7 @@
 #include "../../../../state/build_data/runtime.h"
 #include "../../../../state/build_data/season_pass/definition.h"
 #include "../../../../state/build_data/sobjects/sobject_catalog.h"
+#include "package_reward_build.h"
 
 namespace sunrise::client::content::items::packages {
 
@@ -70,9 +71,10 @@ static_assert(kUnmappedSlot
 /** Bank index per unlock slot, indexed by slot. The first mapping row of a slot wins. */
 using SlotMap = std::array<std::uint16_t, kSlotSpace>;
 
-/** The four maps one root's two unlock mapping tables carry. 256 KiB together. */
+/** Saved-bank indices resolved from the root's flag and value mapping tables. */
 struct SlotMaps {
     SlotMap accountFlag{};
+    SlotMap profileFlag{};
     SlotMap characterFlag{};
     SlotMap accountValue{};
     SlotMap characterValue{};
@@ -83,6 +85,8 @@ struct Storage {
     reader::Scratch scratch{};
     /** Read once per root. Every domain resolves its unlock slots through these. */
     SlotMaps slotMaps{};
+    /** Reward pools, wrappers and condition tables read alongside the item walk. */
+    RewardBuild rewardBuild{};
     /** Node rows held until the value slot and owned records are resolved. */
     std::array<state::build_data::nodes::Definition, state::build_data::nodes::kDefinitionCapacity>
         nodeRows{};
@@ -153,15 +157,11 @@ 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{};
-    std::array<state::build_data::season_pass::Package,
-               state::build_data::season_pass::kPackageCapacity>
-        seasonPassPackages{};
     std::size_t seasonPassRewardCount{};
-    std::size_t seasonPassPackageCount{};
     /** Repeatable bounty rows, keyed by the item-type pair their pool shares. */
     std::array<state::build_data::bounties::Definition,
                state::build_data::bounties::kDefinitionCapacity>
@@ -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;

+ 16 - 7
Sunrise/src/client/content/items/packages/package_item_build.cpp

@@ -17,6 +17,7 @@
 #include "../../vendors/vendor_build.h"
 #include "build.h"
 #include "internal.h"
+#include "package_reward_build.h"
 #include "package_socket_plug_build.h"
 
 namespace sunrise::client::content::items::packages {
@@ -34,7 +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::season_pass_ready() && reward_definitions_settled()
            && state::build_data::repeatable_bounties_ready()
            && state::build_data::record_definitions_ready()
            && state::build_data::node_definitions_ready()
@@ -126,7 +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() || !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})) {
@@ -184,11 +186,18 @@ bool build() noexcept {
                         std::span(storage.progressionSteps).first(storage.progressionStepCount));
                 }
             }
-            if (!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),
-                    std::span(storage.seasonPassPackages).first(storage.seasonPassPackageCount));
+            // 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 (!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.

+ 31 - 5
Sunrise/src/client/content/items/packages/package_item_rows.cpp

@@ -54,13 +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 = !reward_definitions_settled();
+    const bool rewardStorageReady =
+        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();
@@ -71,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;
+    const bool needRows = needDefinitions || needDetailRows || needBuckets || rewardStorageReady;
     bool published = !needRows;
     if (retainDetails && storage.details.size() != kDetailCapacity) {
         storage.details.assign(kDetailCapacity, build_details::Definition{});
@@ -107,11 +110,23 @@ bool build_item_rows(const reader::Source& source,
         item.definitionHash = row.definitionHash;
         item.definitionIndex = static_cast<std::uint16_t>(index);
         std::uint32_t itemClass = 0;
-        if (!reader::read_tag(source, storage.scratch, row.targetTag, storage.definition, itemClass)
-            || !tables::items::read_definition(std::span<const std::byte>{storage.definition},
-                                               item)) {
+        if (!reader::read_tag(
+                source, storage.scratch, row.targetTag, storage.definition, itemClass)) {
+            // The published catalog read this row before, so its reward waits for a retry; a row
+            // the catalog lacks stays pruned from both.
+            state::build_data::items::Definition cataloged{};
+            if (rewardStorageReady
+                && state::build_data::find_item_definition_index(item.definitionIndex, cataloged)) {
+                storage.rewardBuild.item_unread(item.definitionIndex);
+            }
+            continue;
+        }
+        if (!tables::items::read_definition(std::span<const std::byte>{storage.definition}, item)) {
             continue;
         }
+        if (rewardStorageReady && itemClass == tables::kItemDefinitionClass) {
+            storage.rewardBuild.item(item.definitionIndex, item.definitionHash, storage.definition);
+        }
         const std::uint32_t plugCategoryHash =
             corrected_plug_category(item.definitionHash, item.plugCategoryHash);
         build_items::QuestInitialization quest{};
@@ -175,6 +190,13 @@ bool build_item_rows(const reader::Source& source,
         published =
             state::build_data::publish_item_definitions(std::span(storage.rows).first(rowCount));
     }
+    // A reward refusal is reported by RewardBuild and does not block the other item domains.
+    // Rewards publish only beside the item catalog and at its row count, so both omit the same
+    // unread rows.
+    const bool rewardsPublished =
+        !needRewards
+        || (rewardStorageReady && state::build_data::item_definitions_ready()
+            && storage.rewardBuild.publish(state::build_data::item_definition_count()));
     if (!published) {
         reason = !detailStorageReady ? "detail_storage"
                  : !requestsFit      ? "detail_capacity"
@@ -301,6 +323,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()

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

@@ -30,9 +30,10 @@ void clear_slot_map(SlotMap& output) noexcept {
     output.fill(kUnmappedSlot);
 }
 
-/** Clears all four so an unread table cannot leave a prior pass's indexes addressable. */
+/** An unread table must not leave a prior pass's indices addressable. */
 void clear_slot_maps(SlotMaps& maps) noexcept {
     clear_slot_map(maps.accountFlag);
+    clear_slot_map(maps.profileFlag);
     clear_slot_map(maps.characterFlag);
     clear_slot_map(maps.accountValue);
     clear_slot_map(maps.characterValue);
@@ -113,7 +114,7 @@ read_slot_map(std::span<const std::byte> blob, std::size_t descriptor, SlotMap&
 /**
  * A gate names a slot; its saved bank index is the mapping row that names that slot.
  * @param source Borrowed package source.
- * @param storage Receives four slot maps and retained value-map bytes; may be partial on failure.
+ * @param storage Receives slot maps and retained value-map bytes; may be partial on failure.
  * @param root Investment root bytes naming the flag and value mapping tables.
  * @return True when both account maps read; a character map may remain unmapped.
  */
@@ -131,6 +132,7 @@ bool read_unlock_slot_maps(const reader::Source& source,
     const std::span<const std::byte> flagMap{storage.child};
     const bool flagMapRead =
         read_slot_map(flagMap, tables::kAccountFlagMapDescriptor, maps.accountFlag);
+    (void)read_slot_map(flagMap, tables::kProfileFlagMapDescriptor, maps.profileFlag);
     (void)read_slot_map(flagMap, tables::kCharacterFlagMapDescriptor, maps.characterFlag);
 
     std::uint32_t valueMapTag = 0;

+ 808 - 0
Sunrise/src/client/content/items/packages/package_reward_build.cpp

@@ -0,0 +1,808 @@
+#include "package_reward_build.h"
+
+#include <algorithm>
+#include <cmath>
+#include <vector>
+
+#include "../../../../core/logging/log.h"
+#include "../../../../middleware/content/packages/tables/definition_index_table.h"
+#include "../../../../middleware/content/packages/tables/field_reader.h"
+#include "../../../../state/build_data/runtime.h"
+#include "../../../../state/unlocks/unlocks_expression.h"
+#include "internal.h"
+
+namespace sunrise::client::content::items::packages {
+namespace {
+
+namespace reader = middleware::content::packages::reader;
+namespace tables = middleware::content::packages::tables;
+namespace domain = state::build_data::rewards;
+namespace unlocks = state::unlocks;
+
+/** Investment-root slots identify reward pools and unlock-slot bindings. */
+constexpr std::size_t kSupplementalRewardSlot = 84;
+constexpr std::size_t kPoolSlot = 88;
+constexpr std::size_t kExpressionSlot = 109;
+constexpr std::size_t kFlagSlot = 112;
+constexpr std::size_t kValueSlot = 114;
+/** Only the omitted-bank sentinel permits a no-op; other invalid tags leave effects unresolved. */
+constexpr std::uint32_t kAbsentTableTag = 0xFFFFFFFFU;
+/** Native reward schema classes and fixed row sizes. */
+constexpr std::uint32_t kPoolClass = 0x80807553U;
+constexpr std::uint32_t kPoolRowClass = 0x8080748CU;
+constexpr std::uint32_t kEntryClass = 0x8080748EU;
+constexpr std::uint32_t kExpressionClass = 0x80807D31U;
+constexpr std::uint32_t kModifierClass = 0x80807490U;
+constexpr std::uint32_t kSocketClass = 0x80803062U;
+constexpr std::uint32_t kWrapperClass = 0x808077CCU;
+constexpr std::uint32_t kSelectionClass = 0x808077CFU;
+constexpr std::uint32_t kFlagTableClass = 0x80807D49U;
+constexpr std::uint32_t kFlagRowClass = 0x80807D4FU;
+constexpr std::uint32_t kValueTableClass = 0x80807C92U;
+constexpr std::uint32_t kValueRowClass = 0x80807C96U;
+constexpr std::uint32_t kExpressionTableClass = 0x80807C49U;
+constexpr std::uint32_t kExpressionRowClass = 0x80807C4FU;
+constexpr std::uint32_t kConditionClass = 0x80807D2FU;
+constexpr std::size_t kPoolStride = 24;
+constexpr std::size_t kEntryStride = 80;
+constexpr std::size_t kModifierStride = 24;
+constexpr std::size_t kBindingStride = 8;
+constexpr std::size_t kExpressionRowStride = 24;
+constexpr std::size_t kSocketStride = 12;
+constexpr std::size_t kSelectionStride = 12;
+/** Item headers hold a relative wrapper pointer and an acquired-unlock slot. */
+constexpr std::size_t kWrapperField = 0x58;
+constexpr std::size_t kAcquiredFlagField = 0xDA;
+
+/** Byte offsets inside the serialized reward rows. */
+constexpr std::size_t kEntryQuantityOffset = 4;
+constexpr std::size_t kEntryPoolOffset = 8;
+constexpr std::size_t kEntrySupplementalOffset = 10;
+constexpr std::size_t kEntryCategoryOffset = 20;
+constexpr std::size_t kEntryWeightOffset = 24;
+constexpr std::size_t kEntryConditionOffset = 32;
+constexpr std::size_t kEntryModifiersOffset = 48;
+constexpr std::size_t kEntrySocketsOffset = 64;
+constexpr std::size_t kModifierValueIndexOffset = 16;
+constexpr std::size_t kModifierValueOffset = 20;
+constexpr std::size_t kPoolEntriesOffset = 8;
+constexpr std::size_t kExpressionBodyOffset = 8;
+constexpr std::size_t kWrapperSelectionsOffset = 8;
+constexpr std::size_t kWrapperFlagsOffset = 24;
+constexpr std::size_t kSelectionCountOffset = 4;
+constexpr std::size_t kSocketPlugOffset = 2;
+constexpr std::size_t kSocketPlugSetOffset = 4;
+constexpr std::size_t kSocketRollSetOffset = 6;
+constexpr std::size_t kSocketSelectionOffset = 8;
+/** Relative definition bodies are preceded by their four-byte schema class. */
+constexpr std::size_t kDefinitionClassPrefixSize = sizeof(std::uint32_t);
+
+/** Class rows name a default finisher whose use gate supplies the computed class flag. */
+constexpr std::size_t kClassSlot = 12;
+constexpr std::size_t kClassStride = 32;
+constexpr std::size_t kDefaultFinisherOffset = 24;
+constexpr std::uint32_t kClassTable = 0x808075BEU;
+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) {
+        return false;
+    }
+    try {
+        bank.push_back(value);
+        return true;
+    } catch (...) {
+        return false;
+    }
+}
+
+/** 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. */
+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;
+    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. */
+bool read_reward_entry(std::span<const std::byte> blob,
+                       std::size_t at,
+                       domain::Entry& entry) noexcept {
+    return tables::read(blob, at, entry.itemIndex)
+           && tables::read(blob, at + kEntryQuantityOffset, entry.quantity)
+           && tables::read(blob, at + kEntryPoolOffset, entry.poolIndex)
+           && tables::read(blob, at + kEntrySupplementalOffset, entry.supplementalIndex)
+           && tables::read(blob, at + kEntryCategoryOffset, entry.categoryHash)
+           && tables::read(blob, at + kEntryWeightOffset, entry.weight)
+           && std::isfinite(entry.weight) && entry.weight >= 0;
+}
+
+/** Reads the single flag that gates use of the class's default finisher. */
+bool read_class_flag(std::span<const std::byte> blob, std::uint16_t& output) noexcept {
+    std::size_t at = 0;
+    std::uint32_t cls = 0;
+    tables::Array expression{};
+    if (!tables::relative(blob, kUseConditionPointer, at) || at < kDefinitionClassPrefixSize
+        || !tables::read(blob, at - kDefinitionClassPrefixSize, cls) || cls != kUseConditionClass
+        || !tables::read_array(
+            blob, at, kExpressionClass, tables::kUnlockInstructionStride, expression)
+        || expression.count != 1) {
+        return false;
+    }
+    std::uint32_t opcode = 0;
+    std::uint32_t flag = domain::kAbsent;
+    if (!tables::read(blob, expression.dataOffset, opcode)
+        || opcode != tables::kUnlockReadFlagOpcode
+        || !tables::read(
+            blob, expression.dataOffset + tables::kUnlockInstructionOperandOffset, flag)
+        || flag >= domain::kAbsent) {
+        return false;
+    }
+    output = static_cast<std::uint16_t>(flag);
+    return true;
+}
+
+} // namespace
+
+/** @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,
+                        BankFailure& failure,
+                        const char*& reason) noexcept {
+    domain::Entry out{};
+    failure = BankFailure::none;
+    if (!read_reward_entry(blob, at, out)
+        || !conditions.read(
+            blob, at + kEntryConditionOffset, instructions_, out.condition, failure)) {
+        reason = failure == BankFailure::full ? "instruction_capacity" : "instruction_storage";
+        return false;
+    }
+    out.supplementalMissing = supplementalMissing_ && out.supplementalIndex != domain::kAbsent;
+    tables::Array rows{};
+    if (!tables::read_array(
+            blob, at + kEntryModifiersOffset, kModifierClass, kModifierStride, rows)) {
+        return false;
+    }
+    out.modifiers = {static_cast<std::uint32_t>(modifiers_.size()),
+                     static_cast<std::uint32_t>(rows.count)};
+    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, failure)
+            || !tables::read(blob, offset + kModifierValueIndexOffset, modifier.valueIndex)
+            || !tables::read(blob, offset + kModifierValueOffset, modifier.value)
+            || !std::isfinite(modifier.value)) {
+            reason = failure == BankFailure::full ? "instruction_capacity" : "instruction_storage";
+            return false;
+        }
+        if (modifiers_.size() >= domain::kModifierCapacity) {
+            failure = BankFailure::full;
+            reason = "modifier_capacity";
+            return false;
+        }
+        if (!append(modifiers_, modifier, domain::kModifierCapacity)) {
+            failure = BankFailure::storage;
+            reason = "modifier_storage";
+            return false;
+        }
+    }
+    std::array<domain::SocketOverride, domain::kSocketsPerItem> overrides{};
+    std::size_t count = 0;
+    if (!read_reward_sockets(blob, at + kEntrySocketsOffset, overrides, count)) {
+        return false;
+    }
+    if (count > domain::kSocketOverrideCapacity - sockets_.size()) {
+        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) {
+            return false;
+        }
+        if (!append(sockets_, overrides[i], domain::kSocketOverrideCapacity)) {
+            failure = BankFailure::storage;
+            reason = "socket_storage";
+            return false;
+        }
+    }
+    if (entries_.size() >= domain::kEntryCapacity) {
+        failure = BankFailure::full;
+        reason = "entry_capacity";
+        return false;
+    }
+    if (!append(entries_, out, domain::kEntryCapacity)) {
+        failure = BankFailure::storage;
+        reason = "entry_storage";
+        return false;
+    }
+    return true;
+}
+
+bool RewardConditions::load_class_flags(const reader::Source& source,
+                                        reader::Scratch& scratch,
+                                        std::span<const std::byte> root) noexcept {
+    classFlags_.fill(domain::kAbsent);
+    std::vector<std::byte> classes;
+    std::vector<std::byte> index;
+    std::vector<std::byte> blob;
+    tables::Array classRows{};
+    tables::Array itemRows{};
+    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()
+        || !tables::find_array_at(index, tables::kTableArrayDescriptor, itemRows)
+        || itemRows.elementClass != tables::kItemIndexTableClass) {
+        settle_unsupported("class_schema");
+        return false;
+    }
+    for (std::size_t i = 0; i < classFlags_.size(); ++i) {
+        std::uint16_t itemIndex = domain::kAbsent;
+        tables::IndexRow item{};
+        std::uint32_t cls = 0;
+        if (!tables::read(std::span<const std::byte>{classes},
+                          classRows.dataOffset + i * kClassStride + kDefaultFinisherOffset,
+                          itemIndex)
+            || !tables::index_row(index, itemRows, itemIndex, item)) {
+            settle_unsupported("class_finisher_index");
+            return false;
+        }
+        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=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;
+    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.
+    if (!tables::read_array(
+            flags_, tables::kTableArrayDescriptor, kFlagRowClass, kBindingStride, flagRows_)
+        || !tables::read_array(
+            values_, tables::kTableArrayDescriptor, kValueRowClass, kBindingStride, valueRows_)
+        || !tables::read_array(expressions_,
+                               tables::kTableArrayDescriptor,
+                               kExpressionRowClass,
+                               kExpressionRowStride,
+                               expressionRows_)) {
+        settle_unsupported("condition_schema");
+        return false;
+    }
+    loaded_ = true;
+    return true;
+}
+
+bool RewardConditions::loaded() const noexcept {
+    return loaded_;
+}
+
+bool RewardConditions::bind(std::uint32_t native,
+                            std::uint32_t operand,
+                            domain::Instruction& instruction) const noexcept {
+    const bool flag = native == tables::kUnlockReadFlagOpcode;
+    if (!flag && native != tables::kUnlockReadValueOpcode) {
+        instruction = {{}, unlocks::Bank::none, operand};
+        return unlocks::decode_opcode(native, instruction.opcode);
+    }
+    const auto& rows = flag ? flagRows_ : valueRows_;
+    const std::span<const std::byte> blob = flag ? flags_ : values_;
+    std::uint32_t hash = 0;
+    if (operand >= rows.count
+        || !tables::read(blob, rows.dataOffset + operand * kBindingStride, hash)) {
+        return false;
+    }
+    instruction.opcode = flag ? unlocks::Opcode::flag : unlocks::Opcode::loadValue;
+    if (flag) {
+        for (std::size_t characterClass = 0; characterClass < classFlags_.size();
+             ++characterClass) {
+            if (classFlags_[characterClass] != domain::kAbsent
+                && operand == classFlags_[characterClass]) {
+                instruction.bank = unlocks::Bank::characterClass;
+                instruction.operand = static_cast<std::uint32_t>(characterClass);
+                return true;
+            }
+        }
+    }
+    const auto slot = static_cast<std::int32_t>(operand);
+    const auto accountIndex = bank_index(flag ? maps_->accountFlag : maps_->accountValue, slot);
+    const auto profileIndex = flag ? bank_index(maps_->profileFlag, slot) : kUnmappedSlot;
+    const auto characterIndex =
+        bank_index(flag ? maps_->characterFlag : maps_->characterValue, slot);
+    if (accountIndex != kUnmappedSlot) {
+        instruction.bank = unlocks::Bank::account;
+        instruction.operand = accountIndex;
+    } else if (profileIndex != kUnmappedSlot) {
+        instruction.bank = unlocks::Bank::profile;
+        instruction.operand = profileIndex;
+    } else if (characterIndex != kUnmappedSlot) {
+        instruction.bank = unlocks::Bank::character;
+        instruction.operand = characterIndex;
+    } else {
+        instruction.bank = unlocks::Bank::external;
+        instruction.operand = hash;
+    }
+    return true;
+}
+
+bool RewardConditions::append_expression(std::span<const std::byte> blob,
+                                         std::size_t at,
+                                         std::vector<domain::Instruction>& bank,
+                                         std::size_t depth,
+                                         BankFailure& failure) const noexcept {
+    tables::Array rows{};
+    if (depth >= domain::kTraversalDepth
+        || !tables::read_array(
+            blob, at, kExpressionClass, tables::kUnlockInstructionStride, rows)) {
+        return false;
+    }
+    for (std::size_t i = 0; i < rows.count; ++i) {
+        const std::size_t row = rows.dataOffset + i * tables::kUnlockInstructionStride;
+        std::uint32_t native = 0;
+        std::uint32_t operand = 0;
+        if (!tables::read(blob, row, native)
+            || !tables::read(blob, row + tables::kUnlockInstructionOperandOffset, operand)) {
+            return false;
+        }
+        if (native == tables::kUnlockExpressionOpcode) {
+            const auto before = bank.size();
+            if (operand >= expressionRows_.count
+                || !append_expression(expressions_,
+                                      expressionRows_.dataOffset + operand * kExpressionRowStride
+                                          + kExpressionBodyOffset,
+                                      bank,
+                                      depth + 1,
+                                      failure)
+                || bank.size() == before) {
+                return false;
+            }
+            continue;
+        }
+        domain::Instruction instruction{};
+        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;
+        }
+    }
+    return true;
+}
+
+bool RewardConditions::read(std::span<const std::byte> blob,
+                            std::size_t at,
+                            std::vector<domain::Instruction>& bank,
+                            domain::Range& range,
+                            BankFailure& failure) const noexcept {
+    const auto first = bank.size();
+    failure = BankFailure::none;
+    if (!append_expression(blob, at, bank, 0, failure)) {
+        bank.resize(first);
+        return false;
+    }
+    range = {static_cast<std::uint32_t>(first), static_cast<std::uint32_t>(bank.size() - first)};
+    return true;
+}
+
+bool RewardConditions::read_list(std::span<const std::byte> blob,
+                                 std::size_t at,
+                                 std::span<domain::Instruction> output,
+                                 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;
+    }
+    for (std::size_t i = 0; i < rows.count; ++i) {
+        domain::Range expression{};
+        if (!read(blob,
+                  rows.dataOffset + i * tables::kUnlockExpressionFieldSize,
+                  instructions,
+                  expression,
+                  failure)
+            || expression.count == 0) {
+            return false;
+        }
+        // Every expression attached to a progression reward must hold.
+        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;
+        }
+    }
+    std::copy(instructions.begin(), instructions.end(), output.begin());
+    count = instructions.size();
+    return true;
+}
+
+bool read_reward_sockets(std::span<const std::byte> blob,
+                         std::size_t at,
+                         std::span<domain::SocketOverride> output,
+                         std::size_t& count) noexcept {
+    count = 0;
+    tables::Array rows{};
+    if (!tables::read_array(blob, at, kSocketClass, kSocketStride, rows)
+        || rows.count > output.size()) {
+        return false;
+    }
+    for (std::size_t i = 0; i < rows.count; ++i) {
+        const std::size_t p = rows.dataOffset + i * kSocketStride;
+        auto& socket = output[i];
+        if (!tables::read(blob, p, socket.socketType)
+            || !tables::read(blob, p + kSocketPlugOffset, socket.plugItem)
+            || !tables::read(blob, p + kSocketPlugSetOffset, socket.plugSet)
+            || !tables::read(blob, p + kSocketRollSetOffset, socket.rollSet)
+            || !tables::read(blob, p + kSocketSelectionOffset, socket.selection)) {
+            return false;
+        }
+    }
+    count = static_cast<std::size_t>(rows.count);
+    return true;
+}
+
+bool RewardBuild::load(const reader::Source& source,
+                       reader::Scratch& scratch,
+                       std::span<const std::byte> root,
+                       const SlotMaps& maps) noexcept {
+    loaded_ = false;
+    supplementalMissing_ = false;
+    pools_.clear();
+    entries_.clear();
+    instructions_.clear();
+    modifiers_.clear();
+    sockets_.clear();
+    std::vector<std::byte> blob;
+    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)) {
+        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("pool_schema");
+        return false;
+    }
+    if (rows.count == 0 || rows.count > domain::kPoolCapacity) {
+        settle_unsupported("pool_count");
+        return false;
+    }
+    supplementalMissing_ = supplementalTag == kAbsentTableTag;
+    std::size_t skipped = 0;
+    for (std::size_t i = 0; i < rows.count; ++i) {
+        const auto at = rows.dataOffset + i * kPoolStride;
+        domain::Pool pool{};
+        tables::Array members{};
+        if (tables::read(blob, at, pool.definitionHash) && pool.definitionHash != 0
+            && tables::read_array(
+                blob, at + kPoolEntriesOffset, kEntryClass, kEntryStride, members)) {
+            pool.entries.first = static_cast<std::uint32_t>(entries_.size());
+            for (std::size_t j = 0; j < members.count; ++j) {
+                const auto beforeInstructions = instructions_.size();
+                const auto beforeModifiers = modifiers_.size();
+                const auto beforeSockets = sockets_.size();
+                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);
+                    modifiers_.resize(beforeModifiers);
+                    sockets_.resize(beforeSockets);
+                    ++skipped;
+                }
+            }
+            pool.entries.count = static_cast<std::uint32_t>(entries_.size()) - pool.entries.first;
+        } else {
+            pool = {};
+            ++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 instructions=%zu modifiers=%zu "
+                      "sockets=%zu skipped=%zu",
+                      pools_.size(),
+                      entries_.size(),
+                      instructions_.size(),
+                      modifiers_.size(),
+                      sockets_.size(),
+                      skipped);
+    loaded_ = true;
+    return true;
+}
+
+bool RewardBuild::read_item(std::uint32_t hash,
+                            std::span<const std::byte> blob,
+                            domain::Item& item) noexcept {
+    std::uint16_t acquired = domain::kAbsent;
+    std::int64_t wrapper = 0;
+    std::uint32_t cls = 0;
+    if (!tables::read(blob, kWrapperField, wrapper)
+        || !tables::read(blob, kAcquiredFlagField, acquired)) {
+        return false;
+    }
+    item.definitionHash = hash;
+    if (acquired != domain::kAbsent) {
+        domain::Instruction flag{};
+        if (!conditions.bind(tables::kUnlockReadFlagOpcode, acquired, flag)
+            || !unlocks::valid(flag)) {
+            // 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);
+        } else {
+            ++discardedAcquiredFlags_;
+        }
+    }
+    // A zero delta means the item is not a wrapper.
+    if (wrapper != 0) {
+        std::size_t at = 0;
+        tables::Array selections{};
+        if (!tables::relative(blob, kWrapperField, at) || at < kDefinitionClassPrefixSize
+            || !tables::read(blob, at - kDefinitionClassPrefixSize, cls) || cls != kWrapperClass
+            || !tables::read(blob, at, item.poolIndex)
+            || !tables::read(blob, at + kWrapperFlagsOffset, item.flags)
+            || !tables::read_array(
+                blob, at + kWrapperSelectionsOffset, kSelectionClass, kSelectionStride, selections)
+            || selections.count > item.selections.size()) {
+            return false;
+        }
+        item.selectionCount = static_cast<std::uint8_t>(selections.count);
+        for (std::size_t j = 0; j < selections.count; ++j) {
+            auto& selection = item.selections[j];
+            const auto p = selections.dataOffset + j * kSelectionStride;
+            if (!tables::read(blob, p, selection.categoryHash)
+                || !tables::read(blob, p + kSelectionCountOffset, selection.count)) {
+                return false;
+            }
+        }
+    }
+    return true;
+}
+
+bool RewardBuild::begin_items(std::size_t count) noexcept {
+    unboundAcquiredFlags_ = 0;
+    discardedAcquiredFlags_ = 0;
+    unreadItems_ = 0;
+    // A refused load has already reported itself.
+    if (!loaded_) {
+        return false;
+    }
+    try {
+        if (count == 0 || count > domain::kItemCapacity) {
+            report_refusal("item_storage");
+            return false;
+        }
+        items_.assign(count, {});
+        return true;
+    } catch (...) {
+        report_refusal("item_storage");
+        return false;
+    }
+}
+
+void RewardBuild::item(std::uint16_t index,
+                       std::uint32_t hash,
+                       std::span<const std::byte> blob) noexcept {
+    domain::Item parsed{};
+    if (index < items_.size() && read_item(hash, blob, parsed)) {
+        items_[index] = parsed;
+    }
+}
+
+void RewardBuild::item_unread(std::uint16_t index) noexcept {
+    if (unreadItems_++ == 0) {
+        firstUnreadItem_ = index;
+    }
+}
+
+bool RewardBuild::publish(std::size_t catalogItemCount) noexcept {
+    if (!loaded_) {
+        return false;
+    }
+    // Publishing now would prune every entry that names an unread item.
+    if (unreadItems_ != 0) {
+        core::log::writef(core::log::Channel::client,
+                          core::log::Level::warn,
+                          "ev=pkg stage=rewards result=fail reason=item_read unread_items=%zu "
+                          "first_index=%u",
+                          unreadItems_,
+                          static_cast<unsigned>(firstUnreadItem_));
+        return false;
+    }
+    // A catalog that lost trailing rows is shorter than the table, and the cache pairs each of its
+    // items with one reward row.
+    if (catalogItemCount < items_.size()) {
+        items_.resize(catalogItemCount);
+    }
+    // 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_) {
+        const auto range = pool.entries;
+        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 || 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())
+                    && (socket.plugItem == domain::kAbsent || item_read(items_, socket.plugItem));
+            }
+            if (valid) {
+                auto retained = row;
+                retained.sockets.first = static_cast<std::uint32_t>(socketWrite);
+                for (const auto& socket :
+                     std::span(sockets_).subspan(row.sockets.first, row.sockets.count)) {
+                    sockets_[socketWrite++] = socket;
+                }
+                entries_[write++] = retained;
+            }
+        }
+        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);
+    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 "
+                          "discarded_acquired_flags=%zu dropped_entries=%zu",
+                          unboundAcquiredFlags_,
+                          discardedAcquiredFlags_,
+                          droppedEntries);
+    }
+    const domain::View graph{pools_, entries_, items_, instructions_, modifiers_, sockets_};
+    if (!state::build_data::valid_reward_definitions(graph)) {
+        settle_unsupported("graph");
+        return false;
+    }
+    // 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

+ 116 - 0
Sunrise/src/client/content/items/packages/package_reward_build.h

@@ -0,0 +1,116 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+#include <vector>
+
+#include "../../../../middleware/content/packages/reader/reader.h"
+#include "../../../../middleware/content/packages/tables/definition_index_table.h"
+#include "../../../../state/account/account_state.h"
+#include "../../../../state/build_data/rewards/definition.h"
+
+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:
+    /** Reads the root's flag, value, expression and class tables; false when one is missing. */
+    [[nodiscard]] bool load(const middleware::content::packages::reader::Source& source,
+                            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; 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,
+                            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,
+                                 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,
+                                         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_{};
+    std::vector<std::byte> flags_;
+    std::vector<std::byte> values_;
+    std::vector<std::byte> expressions_;
+    middleware::content::packages::tables::Array flagRows_{};
+    middleware::content::packages::tables::Array valueRows_{};
+    middleware::content::packages::tables::Array expressionRows_{};
+};
+
+/** Reward metadata collected alongside the existing item-definition walk. */
+class RewardBuild {
+public:
+    RewardConditions conditions;
+    /** 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,
+                            const SlotMaps& maps) noexcept;
+    /** Sizes the dense item rows before the item walk. */
+    [[nodiscard]] bool begin_items(std::size_t count) noexcept;
+    /** Records one item's wrapper and acquisition flag; an unreadable item stays unavailable. */
+    void item(std::uint16_t index, std::uint32_t hash, std::span<const std::byte> blob) noexcept;
+    /** Notes an item whose definition failed to read; publication waits for a clean walk. */
+    void item_unread(std::uint16_t index) noexcept;
+    /** Trims item rows to the catalog, drops rows naming unread items or pools, then publishes. */
+    [[nodiscard]] bool publish(std::size_t catalogItemCount) noexcept;
+
+private:
+    /** 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::size_t unreadItems_{};
+    std::uint16_t firstUnreadItem_{};
+    std::vector<state::build_data::rewards::Pool> pools_;
+    std::vector<state::build_data::rewards::Entry> entries_;
+    std::vector<state::build_data::rewards::Item> items_;
+    std::vector<state::build_data::rewards::Instruction> instructions_;
+    std::vector<state::build_data::rewards::Modifier> modifiers_;
+    std::vector<state::build_data::rewards::SocketOverride> sockets_;
+};
+
+/** Progression rewards and pool entries use the same socket-override layout. */
+[[nodiscard]] bool read_reward_sockets(std::span<const std::byte> blob,
+                                       std::size_t at,
+                                       std::span<state::build_data::rewards::SocketOverride> output,
+                                       std::size_t& count) noexcept;
+
+} // namespace sunrise::client::content::items::packages

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

@@ -1,73 +1,94 @@
 #include <algorithm>
-#include <cstring>
 #include <limits>
 
+#include "../../../../core/logging/log.h"
+#include "../../../../middleware/content/packages/tables/field_reader.h"
+#include "../../../../state/progression/season_pass_reward_catalog.h"
 #include "internal.h"
+#include "package_reward_build.h"
 
 namespace sunrise::client::content::items::packages {
 namespace {
 
 namespace domain = state::build_data::season_pass;
 
-/** Account progression the installed Season of Arrivals pass declares its rewards on. */
-constexpr std::uint16_t kPassProgressionIndex = 40;
-
-/** @param blob Source bytes. @param offset Field offset. @param value Receives the field. */
-template <typename Value>
-[[nodiscard]] bool
-read(std::span<const std::byte> blob, std::size_t offset, Value& value) noexcept {
-    if (offset > blob.size() || blob.size() - offset < sizeof value) {
-        return false;
-    }
-    std::memcpy(&value, blob.data() + offset, sizeof value);
-    return true;
-}
-
 /**
- * Reads the item set one reward's wrapper item opens into.
- * @param definition Whole item definition blob.
- * @param itemTable Item index table blob.
- * @param itemRows Located item index array.
- * @param package Receives the wrapper's items, or stays empty when the item opens into nothing.
- * @return True when the item declares no set, or declares one that reads back whole.
+ * Reads one pass row: item, rank, quantity and claim flag, then its sockets and conditions.
+ * @return False when any field is unreadable or out of range.
  */
-[[nodiscard]] bool read_package(std::span<const std::byte> definition,
-                                std::span<const std::byte> itemTable,
-                                const tables::Array& itemRows,
-                                domain::Package& package) noexcept {
-    tables::Array set{};
-    if (!tables::find_optional_array_at(definition, tables::kGearsetItemField, set)) {
+bool read_season_reward(Storage& storage,
+                        std::span<const std::byte> progressionTable,
+                        const tables::Array& itemRows,
+                        std::size_t at,
+                        domain::Reward& reward,
+                        BankFailure& failure) noexcept {
+    std::uint32_t rank = 0;
+    std::uint32_t itemIndex = 0;
+    std::uint32_t claimSlot = 0;
+    if (!tables::read(progressionTable, at + tables::kProgressionRewardRankOffset, rank)
+        || !tables::read(
+            progressionTable, at + tables::kProgressionRewardItemIndexOffset, itemIndex)
+        || !tables::read(
+            progressionTable, at + tables::kProgressionRewardQuantityOffset, reward.quantity)
+        || !tables::read(
+            progressionTable, at + tables::kProgressionRewardClaimSlotOffset, claimSlot)) {
         return false;
     }
-    if (set.count == 0) {
-        return true;
-    }
-    if (set.elementClass != tables::kGearsetItemRowClass || set.count > domain::kPackageItemCapacity
-        || set.dataOffset + static_cast<std::size_t>(set.count) * tables::kGearsetItemStride
-               > definition.size()) {
+
+    // Native fields are 32-bit, the stored row keeps the narrower native index widths.
+    tables::IndexRow entry{};
+    if (rank > (std::numeric_limits<std::uint8_t>::max)()
+        || itemIndex > (std::numeric_limits<std::uint16_t>::max)()
+        || claimSlot > (std::numeric_limits<std::uint16_t>::max)()
+        || !tables::index_row(storage.itemIndexTable, itemRows, itemIndex, entry)) {
         return false;
     }
-    for (std::uint64_t member = 0; member < set.count; ++member) {
-        std::uint16_t itemIndex = 0;
-        tables::IndexRow entry{};
-        if (!read(definition,
-                  set.dataOffset + static_cast<std::size_t>(member) * tables::kGearsetItemStride,
-                  itemIndex)
-            || !tables::index_row(itemTable, itemRows, itemIndex, entry)) {
+    reward.itemHash = entry.definitionHash;
+    reward.itemIndex = static_cast<std::uint16_t>(itemIndex);
+    reward.requiredRank = static_cast<std::uint8_t>(rank);
+
+    // A reward with no claim flag carries slot 0, any other slot must map to the account bank.
+    if (claimSlot != 0) {
+        reward.claimFlagIndex =
+            bank_index(storage.slotMaps.accountFlag, static_cast<std::int32_t>(claimSlot));
+        if (reward.claimFlagIndex == domain::kUnavailableFlagIndex) {
             return false;
         }
-        package.items[package.itemCount++] = entry.definitionHash;
     }
-    return true;
+
+    std::size_t socketCount = 0;
+    std::size_t conditionCount = 0;
+    if (!read_reward_sockets(progressionTable,
+                             at + tables::kProgressionRewardSocketsOffset,
+                             reward.sockets,
+                             socketCount)
+        || !storage.rewardBuild.conditions.read_list(
+            progressionTable,
+            at + tables::kProgressionRewardConditionsOffset,
+            reward.condition,
+            conditionCount,
+            failure)) {
+        return false;
+    }
+    reward.socketCount = static_cast<std::uint8_t>(socketCount);
+    reward.conditionCount = static_cast<std::uint8_t>(conditionCount);
+    // 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) {
+                              return state::build_data::rewards::valid_socket(socket,
+                                                                              itemRows.count);
+                          });
 }
 
 } // namespace
 
 /**
- * Reads the season pass reward list and the wrapper items it grants.
- * A reward names an item index and a claim flag slot; both become the values a grant needs.
+ * Reads the season pass reward list in native order, which the opcode-2400 claim indexes.
+ * A row that fails to read is kept as an unavailable row so later claim indices stay aligned.
  * @param source Installed package source.
- * @param storage Pass storage receiving the reward rows and the wrapper packages.
+ * @param storage Pass storage receiving the reward rows.
  * @param root Investment root bytes.
  * @return True when the reward list read and produced at least one row.
  */
@@ -75,7 +96,6 @@ bool build_season_pass(const reader::Source& source,
                        Storage& storage,
                        std::span<const std::byte> root) noexcept {
     storage.seasonPassRewardCount = 0;
-    storage.seasonPassPackageCount = 0;
 
     std::uint32_t itemTableTag = 0;
     tables::Array itemRows{};
@@ -87,7 +107,6 @@ bool build_season_pass(const reader::Source& source,
         || itemRows.elementClass != tables::kItemIndexTableClass) {
         return false;
     }
-    const std::span<const std::byte> itemTable{storage.itemIndexTable};
 
     std::uint32_t tableTag = 0;
     tables::Array progressions{};
@@ -97,74 +116,49 @@ bool build_season_pass(const reader::Source& source,
                                   tables::kTableArrayDescriptor,
                                   progressions)
         || progressions.elementClass != tables::kProgressionTableClass
-        || progressions.count <= kPassProgressionIndex) {
+        || progressions.count <= state::progression::season_pass::kProgressionDefinitionIndex) {
         return false;
     }
     const std::span<const std::byte> progressionTable{storage.progressionTable};
-    const std::size_t passAt =
-        progressions.dataOffset + kPassProgressionIndex * tables::kProgressionRowStride;
+    const std::size_t passAt = progressions.dataOffset
+                               + state::progression::season_pass::kProgressionDefinitionIndex
+                                     * tables::kProgressionRowStride;
     tables::Array rewards{};
-    if (!tables::find_optional_array_at(
-            progressionTable, passAt + tables::kProgressionRewardField, rewards)
-        || rewards.count == 0 || rewards.elementClass != tables::kProgressionRewardRowClass
-        || rewards.count > domain::kRewardCapacity
-        || rewards.dataOffset
-                   + static_cast<std::size_t>(rewards.count) * tables::kProgressionRewardStride
-               > progressionTable.size()) {
+    if (!tables::read_array(progressionTable,
+                            passAt + tables::kProgressionRewardField,
+                            tables::kProgressionRewardRowClass,
+                            tables::kProgressionRewardStride,
+                            rewards)
+        || rewards.count == 0 || rewards.count > domain::kRewardCapacity) {
         return false;
     }
 
+    std::size_t skipped = 0;
     for (std::uint64_t row = 0; row < rewards.count; ++row) {
         const std::size_t at =
             rewards.dataOffset + static_cast<std::size_t>(row) * tables::kProgressionRewardStride;
-        std::uint32_t rank = 0;
-        std::uint32_t itemIndex = 0;
-        std::uint32_t claimSlot = 0;
-        domain::Reward& reward = storage.seasonPassRewards[storage.seasonPassRewardCount];
+        auto& reward = storage.seasonPassRewards[row];
         reward = {};
-        tables::IndexRow entry{};
-        if (!read(progressionTable, at + tables::kProgressionRewardRankOffset, rank)
-            || !read(progressionTable, at + tables::kProgressionRewardItemIndexOffset, itemIndex)
-            || !read(
-                progressionTable, at + tables::kProgressionRewardQuantityOffset, reward.quantity)
-            || !read(progressionTable, at + tables::kProgressionRewardClaimSlotOffset, claimSlot)
-            || rank > (std::numeric_limits<std::uint8_t>::max)()
-            || itemIndex > (std::numeric_limits<std::uint16_t>::max)()
-            || claimSlot > (std::numeric_limits<std::uint16_t>::max)()
-            || !tables::index_row(itemTable, itemRows, itemIndex, entry)) {
-            storage.seasonPassRewardCount = 0;
-            return false;
+        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;
         }
-        reward.itemHash = entry.definitionHash;
-        reward.itemIndex = static_cast<std::uint16_t>(itemIndex);
-        reward.requiredRank = static_cast<std::uint8_t>(rank);
-        // A reward with no claim flag carries slot 0.
-        if (claimSlot != 0) {
-            reward.claimFlagIndex =
-                bank_index(storage.slotMaps.accountFlag, static_cast<std::int32_t>(claimSlot));
-        }
-
-        // A wrapper reward opens into a set; a plain reward declares none and keeps zero items.
-        // One wrapper can be granted at several ranks, so it is recorded once.
-        const std::span<const domain::Package> held =
-            std::span(storage.seasonPassPackages).first(storage.seasonPassPackageCount);
-        domain::Package package{};
-        package.definitionHash = entry.definitionHash;
-        if (storage.seasonPassPackageCount < domain::kPackageCapacity
-            && std::none_of(
-                held.begin(),
-                held.end(),
-                [&entry](const domain::Package& row) {
-                    return row.definitionHash == entry.definitionHash;
-                })
-            && reader::read_tag(source, storage.scratch, entry.targetTag, storage.definition)
-            && read_package(
-                std::span<const std::byte>{storage.definition}, itemTable, itemRows, package)
-            && package.itemCount != 0) {
-            storage.seasonPassPackages[storage.seasonPassPackageCount++] = package;
-        }
-        ++storage.seasonPassRewardCount;
     }
+    if (skipped != 0) {
+        core::log::writef(core::log::Channel::client,
+                          core::log::Level::warn,
+                          "ev=pkg stage=season_pass skipped=%zu",
+                          skipped);
+    }
+    storage.seasonPassRewardCount = static_cast<std::size_t>(rewards.count);
     return storage.seasonPassRewardCount != 0;
 }
 

+ 1 - 20
Sunrise/src/middleware/content/packages/tables/activity_table.h

@@ -4,6 +4,7 @@
 
 #include "../../../../state/build_data/activities/activity_catalog.h"
 #include "definition_index_table.h"
+#include "field_reader.h"
 
 namespace sunrise::middleware::content::packages::tables::activities {
 /** Public activity index, NOT the package-definition index in root slot 29. */
@@ -32,26 +33,6 @@ template <class T>
     std::memcpy(&value, bytes.data() + offset, sizeof(T));
     return true;
 }
-/**
- * Resolves a self-relative field.
- * @param field Offset of the 8-byte signed delta.
- * @param target Receives the resolved offset.
- * @return False when the delta is zero or the target falls outside the blob.
- */
-[[nodiscard]] inline bool
-relative(std::span<const std::byte> bytes, std::size_t field, std::size_t& target) noexcept {
-    std::int64_t delta{};
-    if (!read(bytes, field, delta) || delta == 0
-        || field > static_cast<std::size_t>((std::numeric_limits<std::int64_t>::max)())) {
-        return false;
-    }
-    const auto base = static_cast<std::int64_t>(field);
-    if (delta < -base || delta > (std::numeric_limits<std::int64_t>::max)() - base) {
-        return false;
-    }
-    target = static_cast<std::size_t>(base + delta);
-    return target < bytes.size();
-}
 /**
  * Parses the dense public activity table.
  * @param bytes The table blob.

+ 13 - 1
Sunrise/src/middleware/content/packages/tables/definition_index_table.cpp

@@ -3,7 +3,7 @@
 #include <limits>
 
 #include "../reader/layout.h"
-#include "internal.h"
+#include "field_reader.h"
 
 namespace sunrise::middleware::content::packages::tables {
 namespace {
@@ -126,6 +126,18 @@ bool find_optional_array_at(std::span<const std::byte> blob,
     return resolve_descriptor(blob, descriptorOffset, true, output);
 }
 
+/** Reads an optional typed array and checks every fixed-stride row lies in its blob. */
+bool read_array(std::span<const std::byte> blob,
+                std::size_t at,
+                std::uint32_t elementClass,
+                std::size_t stride,
+                Array& rows) noexcept {
+    return stride != 0 && find_optional_array_at(blob, at, rows)
+           && (rows.count == 0
+               || (rows.elementClass == elementClass && rows.dataOffset <= blob.size()
+                   && rows.count <= (blob.size() - rows.dataOffset) / stride));
+}
+
 /** Finds the first array whose header names one element class. */
 bool find_array(std::span<const std::byte> blob,
                 std::uint32_t elementClass,

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

@@ -103,6 +103,8 @@ inline constexpr std::uint32_t kUnlockReadValueOpcode = 10;
 inline constexpr std::uint32_t kUnlockReadFlagOpcode = 1;
 /** The opcode that pushes a literal. */
 inline constexpr std::uint32_t kUnlockLiteralOpcode = 11;
+/** The opcode that expands a shared expression row. Its operand is the row index. */
+inline constexpr std::uint32_t kUnlockExpressionOpcode = 12;
 /** The opcode that tests greater than or equal. */
 inline constexpr std::uint32_t kUnlockGreaterEqualOpcode = 14;
 /** High half every installed array marker and element class carries. */
@@ -123,6 +125,11 @@ inline constexpr std::size_t kNodeExpressionFieldAlternate = 48;
 /** Records a node owns, four bytes each as a row then a gate. */
 inline constexpr std::size_t kNodeChildRecordField = 136;
 inline constexpr std::size_t kNodeChildRecordStride = 4;
+/**
+ * Array descriptor of the profile flag mapping table, between the account (+8) and character (+40)
+ * maps. Profile flags are account-wide but saved in their own bank, not the account flag bank.
+ */
+inline constexpr std::size_t kProfileFlagMapDescriptor = 24;
 /** Array descriptor of the character object's flag mapping table, sized to that bank. */
 inline constexpr std::size_t kCharacterFlagMapDescriptor = 40;
 /** Array descriptor of the character object's value mapping table, sized to that bank. */
@@ -216,10 +223,9 @@ inline constexpr std::size_t kProgressionRewardItemIndexOffset = 4;
 inline constexpr std::size_t kProgressionRewardQuantityOffset = 8;
 /** Unlock flag slot a reward's claim sets. The slots run dense in reward order. */
 inline constexpr std::size_t kProgressionRewardClaimSlotOffset = 20;
-/** 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;
+/** Eligibility expressions and socket overrides carried by one progression reward. */
+inline constexpr std::size_t kProgressionRewardConditionsOffset = 24;
+inline constexpr std::size_t kProgressionRewardSocketsOffset = 40;
 
 /** Expression that makes one collectible acquired, which a purchase or a grant sets. */
 inline constexpr std::size_t kCollectibleAcquiredExpressionField = 112;
@@ -277,6 +283,13 @@ using RowVisitor = bool (*)(void* context, std::uint32_t index, const IndexRow&
                                           std::size_t descriptorOffset,
                                           Array& output) noexcept;
 
+/** Reads an optional typed array and checks every fixed-stride row lies in its blob. */
+[[nodiscard]] bool read_array(std::span<const std::byte> blob,
+                              std::size_t at,
+                              std::uint32_t elementClass,
+                              std::size_t stride,
+                              Array& rows) noexcept;
+
 /**
  * Finds the first array whose header names one element class.
  * @param blob Whole definition bytes.

+ 49 - 0
Sunrise/src/middleware/content/packages/tables/field_reader.h

@@ -0,0 +1,49 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <cstring>
+#include <limits>
+#include <span>
+
+namespace sunrise::middleware::content::packages::tables {
+/**
+ * Reads one little-endian field that must lie inside the blob.
+ * @param blob Whole definition bytes.
+ * @param offset Field offset.
+ * @param value Receives the field.
+ * @return True when the whole field is inside the blob.
+ */
+template <typename Value>
+[[nodiscard]] inline bool
+read(std::span<const std::byte> blob, std::size_t offset, Value& value) noexcept {
+    // Subtracting rather than adding keeps a large offset from wrapping past the size.
+    if (offset > blob.size() || blob.size() - offset < sizeof value) {
+        return false;
+    }
+    std::memcpy(&value, blob.data() + offset, sizeof value);
+    return true;
+}
+
+/**
+ * Resolves a self-relative field.
+ * @param field Offset of the 8-byte signed delta.
+ * @param target Receives the resolved offset.
+ * @return False when the delta is zero or the target falls outside the blob.
+ */
+[[nodiscard]] inline bool
+relative(std::span<const std::byte> bytes, std::size_t field, std::size_t& target) noexcept {
+    std::int64_t delta{};
+    if (!read(bytes, field, delta) || delta == 0
+        || field > static_cast<std::size_t>((std::numeric_limits<std::int64_t>::max)())) {
+        return false;
+    }
+    const auto base = static_cast<std::int64_t>(field);
+    if (delta < -base || delta > (std::numeric_limits<std::int64_t>::max)() - base) {
+        return false;
+    }
+    target = static_cast<std::size_t>(base + delta);
+    return target < bytes.size();
+}
+
+} // namespace sunrise::middleware::content::packages::tables

+ 2 - 19
Sunrise/src/middleware/content/packages/tables/internal.h

@@ -2,28 +2,11 @@
 
 #include <cstddef>
 #include <cstdint>
-#include <cstring>
 #include <span>
 
-namespace sunrise::middleware::content::packages::tables {
+#include "field_reader.h"
 
-/**
- * Reads one little-endian field that must lie inside the blob.
- * @param blob Whole definition bytes.
- * @param offset Field offset.
- * @param value Receives the field.
- * @return True when the whole field is inside the blob.
- */
-template <typename Value>
-[[nodiscard]] inline bool
-read(std::span<const std::byte> blob, std::size_t offset, Value& value) noexcept {
-    // Subtracting rather than adding keeps a large offset from wrapping past the size.
-    if (offset > blob.size() || blob.size() - offset < sizeof value) {
-        return false;
-    }
-    std::memcpy(&value, blob.data() + offset, sizeof value);
-    return true;
-}
+namespace sunrise::middleware::content::packages::tables {
 
 /**
  * Finds the offset of one fixed-stride array element.

+ 130 - 25
Sunrise/src/server/bap/bap_world_reward_queue.cpp

@@ -1,4 +1,10 @@
+#include <limits>
+#include <memory>
+#include <new>
+#include <span>
+
 #include "../../core/logging/log.h"
+#include "../../middleware/crypto/random_bytes.h"
 #include "../../state/build_data/runtime.h"
 #include "../../state/investment/store_internal.h"
 #include "internal.h"
@@ -6,25 +12,72 @@
 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) {
-        state::PendingItemAcquisition acquisition;
-        committed =
-            state::prepare_item_acquisition_for_item(request.itemDefinitionIndex, acquisition)
-            && state::commit_item_acquisition(acquisition);
+        if (state::item_grant_route(request.itemDefinitionIndex) == state::ItemGrantRoute::quest) {
+            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)));
+            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 && !unresolvable) {
+                report_reward_refusal("world_settle", request.itemDefinitionIndex, reason);
+            }
+        }
     } else {
         state::PendingProfileItemAcquisition acquisition;
         committed = state::prepare_profile_item_acquisition_for_item(
                         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. */
@@ -45,6 +98,16 @@ bool enqueue_world_reward(std::uint16_t definitionIndex,
 
 } // namespace
 
+/** Logs one refused reward stage with its native index and a specific reason. */
+void report_reward_refusal(const char* stage, std::uint16_t index, const char* reason) noexcept {
+    core::log::writef(core::log::Channel::server,
+                      core::log::Level::warn,
+                      "ev=reward stage=%s index=%u result=refused reason=%s",
+                      stage,
+                      static_cast<unsigned>(index),
+                      reason != nullptr ? reason : "unknown");
+}
+
 /** Saves one item reward before its pickup presentation is queued. */
 bool arm_world_item_acquisition(std::uint16_t itemDefinitionIndex) noexcept {
     return enqueue_world_reward(itemDefinitionIndex, 1, WorldRewardKind::item);
@@ -57,40 +120,82 @@ 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.definitionHash = saved.definitionHash;
+        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();
+    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;
+}
+
+/** 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;
         }
     }

+ 1 - 4
Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_incident_grants.cpp

@@ -380,10 +380,7 @@ void grant_random_moon_loot() noexcept {
         grant_lost_ghost_reward();
         // One found ghost pays this much seasonal experience.
         constexpr std::int32_t kBaseExperienceReward = 2500;
-        const bool queued = bap::arm_seasonal_experience_presentation(kBaseExperienceReward);
-        if (!queued) {
-            (void)state::grant_seasonal_experience(kBaseExperienceReward);
-        }
+        (void)bap::arm_seasonal_experience_presentation(kBaseExperienceReward);
     }
     return outcome;
 }

+ 6 - 53
Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp

@@ -530,22 +530,9 @@ bool process(const ServiceRoute& route,
         }
         if (recordRewardGrant != nullptr) {
             auto* transaction = emplace_transaction<RecordRewardGrantTransaction>(outcome);
-            std::array<std::uint64_t, state::kRecordRewardGrantCapacity> residents{};
-            std::size_t residentCount = 0;
-            for (std::size_t index = 0; index < recordRewardGrant->rewardCount; ++index) {
-                const auto& reward = recordRewardGrant->rewards[index];
-                if (reward.kind == state::RecordRewardKind::characterInstance
-                    || reward.appendedProfileResident) {
-                    residents[residentCount++] = reward.instanceSoid;
-                }
-            }
-            const bool staged =
-                transaction != nullptr
-                && queuez::stage_record_reward_grant(queuezState,
-                                                     recordRewardGrant->accountSoid,
-                                                     recordRewardGrant->characterSoid,
-                                                     std::span(residents).first(residentCount),
-                                                     transaction->update);
+            const bool staged = transaction != nullptr
+                                && queuez::stage_record_reward_grant(
+                                    queuezState, *recordRewardGrant, transaction->update);
             if (!staged) {
                 core::log::write(core::log::Channel::server,
                                  core::log::Level::warn,
@@ -578,43 +565,9 @@ bool process(const ServiceRoute& route,
                                  "ev=ws2400 stage=reward_preflight result=fail");
                 return refuse_web_action(message, output, written);
             }
-            bool staged = false;
-            std::int32_t stagedVersion = 0;
-            if (const auto* itemGrant =
-                    std::get_if<state::PendingItemAcquisition>(&seasonPassReward->grant)) {
-                auto& update = transaction->update.emplace<queuez::ItemAcquisition>();
-                staged = queuez::stage_item_acquisition(queuezState,
-                                                        itemGrant->accountSoid,
-                                                        itemGrant->characterSoid,
-                                                        itemGrant->acquiredInstanceSoid,
-                                                        true,
-                                                        update);
-                stagedVersion = update.after.family4Version;
-            } else if (const auto* profileGrant = std::get_if<state::PendingProfileItemAcquisition>(
-                           &seasonPassReward->grant)) {
-                auto& update = transaction->update.emplace<queuez::ProfileItemAcquisition>();
-                staged = queuez::stage_profile_item_acquisition(queuezState,
-                                                                profileGrant->accountSoid,
-                                                                profileGrant->acquiredInstanceSoid,
-                                                                profileGrant->actionSource,
-                                                                profileGrant->appended,
-                                                                update);
-                stagedVersion = update.after.family4Version;
-            } else if (const auto* bundle =
-                           std::get_if<state::PendingDirectItemBundle>(&seasonPassReward->grant)) {
-                staged = queuez::stage_direct_item_bundle(queuezState,
-                                                          bundle->accountSoid,
-                                                          bundle->characterSoid,
-                                                          bundle->firstInstanceSoid,
-                                                          bundle->itemCount,
-                                                          stagedVersion);
-            } else if (const auto* resources =
-                           std::get_if<state::PendingRecordRewardGrant>(&seasonPassReward->grant)) {
-                auto& update = transaction->update.emplace<queuez::RecordRewardGrant>();
-                staged = queuez::stage_record_reward_grant(
-                    queuezState, resources->accountSoid, resources->characterSoid, {}, update);
-                stagedVersion = update.after.family4Version;
-            }
+            const bool staged = queuez::stage_record_reward_grant(
+                queuezState, seasonPassReward->grant, transaction->update);
+            const auto stagedVersion = transaction->update.after.family4Version;
             if (!staged) {
                 core::log::write(core::log::Channel::server,
                                  core::log::Level::warn,

+ 1 - 33
Sunrise/src/server/bap/encrypted/internal.h

@@ -141,15 +141,7 @@ struct SeasonPassRewardTransaction {
 
     std::unique_ptr<state::PendingSeasonPassReward> pending{};
 
-    std::variant<std::monostate,
-
-                 queuez::ItemAcquisition,
-
-                 queuez::ProfileItemAcquisition,
-
-                 queuez::RecordRewardGrant>
-
-        update{};
+    queuez::RecordRewardGrant update{};
 };
 
 /** Optional side effect produced while decoding one authenticated service body. */
@@ -480,30 +472,6 @@ void append_queuez_notification(Scratch& scratch,
 
     queuez::SessionState& after) noexcept;
 
-/** Publishes one Season package as new residents plus their acquisition descriptors. */
-
-[[nodiscard]] bool append_season_pass_package_notification(
-
-    Scratch& scratch,
-
-    const queuez::SessionState& before,
-
-    const state::PendingDirectItemBundle& mutation,
-
-    std::uint16_t rewardIndex,
-
-    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
-
-    std::span<const std::byte, state::kAesKeySize> key,
-
-    std::span<const std::byte, state::kBapNonceSize> nonce,
-
-    std::span<std::byte> response,
-
-    std::size_t& written,
-
-    queuez::SessionState& after) noexcept;
-
 /** Publishes every item and the claimed record in one Family-4 revision. */
 
 [[nodiscard]] bool append_record_reward_notification(

+ 0 - 53
Sunrise/src/server/bap/encrypted/push/queuez/queuez_select_character.cpp

@@ -178,59 +178,6 @@ bool append_seasonal_experience_notification(
     return true;
 }
 
-/** Publishes every item in one rank-one class package as an ordinary acquisition. */
-bool append_season_pass_package_notification(
-    Scratch& scratch,
-    const queuez::SessionState& before,
-    const state::PendingDirectItemBundle& mutation,
-    std::uint16_t rewardIndex,
-    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
-    std::span<const std::byte, state::kAesKeySize> key,
-    std::span<const std::byte, state::kBapNonceSize> nonce,
-    std::span<std::byte> response,
-    std::size_t& written,
-    queuez::SessionState& after) noexcept {
-    after = before;
-    snapshot::Prepared prepared{};
-    const std::size_t itemCount = mutation.itemCount;
-    // The item objects are indexed below, and every new resident must fit the peer's manifest.
-    if (!snapshot::prepare_season_pass_package(
-            scratch, before, mutation, rewardIndex, acquisitionPresentationRows, prepared)
-        || itemCount == 0 || prepared.family.objects.size() != itemCount + 2U
-        || before.family4ResidentCount + itemCount > before.family4Residents.size()) {
-        return false;
-    }
-    for (std::size_t index = 0; index < itemCount; ++index) {
-        const middleware::queuez::Object& object = prepared.family.objects[index];
-        for (std::size_t residentIndex = 0; residentIndex < before.family4ResidentCount;
-             ++residentIndex) {
-            if (before.family4Residents[residentIndex].objectSoid == object.version) {
-                return false;
-            }
-        }
-        after.family4Residents[after.family4ResidentCount++] =
-            queuez::ResidentObject{object.version, object.id};
-    }
-    const middleware::queuez::Object& characterObject = prepared.family.objects[itemCount];
-    std::size_t characterMatches = 0;
-    for (std::size_t index = 0; index < before.family4ResidentCount; ++index) {
-        const queuez::ResidentObject& resident = before.family4Residents[index];
-        characterMatches +=
-            static_cast<std::size_t>(resident.objectSoid == characterObject.version
-                                     && resident.definitionId == characterObject.id);
-    }
-    if (characterMatches != 1) {
-        return false;
-    }
-    after.family4Version = prepared.family.version;
-    if (!queuez::valid(after)
-        || !queuez_frame::append_prepared(scratch, prepared, key, nonce, response, written)) {
-        after = before;
-        return false;
-    }
-    return true;
-}
-
 /** Publishes one prepared record-reward batch. */
 bool append_record_reward_notification(
     Scratch& scratch,

+ 11 - 191
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,191 +19,10 @@ namespace sunrise::server::bap::encrypted::push::snapshot {
 
 namespace family4_datagen = middleware::datagen::family4;
 
-/** Builds one atomic Season package update with one acquisition record per granted item. */
-bool prepare_season_pass_package(
-    Scratch& scratch,
-    const queuez::SessionState& before,
-    const state::PendingDirectItemBundle& mutation,
-    std::uint16_t rewardIndex,
-    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
-    Prepared& prepared) noexcept {
-    namespace character_layout = family4_datagen::character::layout;
-    const std::size_t itemCount = mutation.itemCount;
-    state::build_data::season_pass::Reward reward{};
-    state::build_data::season_pass::Package package{};
-    if (itemCount == 0 || itemCount > character_layout::kInventoryChangeRecordCapacity
-        || !mutation.prepared || !state::build_data::find_season_pass_reward(rewardIndex, reward)
-        || reward.quantity != 1 || reward.itemHash != mutation.sourceDefinitionHash
-        || !state::build_data::find_season_pass_package(reward.itemHash, package)
-        || !queuez::valid(before) || !before.family4Active || before.family4ResidentCount == 0
-        || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
-        || before.family4ResidentCount + itemCount > before.family4Residents.size()) {
-        return report_failure("season_package_session");
-    }
-
-    state::AccountState account{};
-    if (!state::preview_direct_item_bundle(mutation, account)) {
-        return report_failure("season_package_preview");
-    }
-    const std::optional<std::size_t> selectedIndex = find_character_index(account);
-    Resolved selected{};
-    if (!state::account::valid(account) || account.primarySoid != before.family4RootSoid
-        || account.primarySoid != mutation.accountSoid || !selectedIndex.has_value()
-        || *selectedIndex != mutation.characterIndex
-        || !resolve(account, *selectedIndex, selected)) {
-        return report_failure("season_package_account");
-    }
-    const state::CharacterState& character = account.characters[*selectedIndex];
-    if (character.soid != mutation.characterSoid
-        || mutation.expectedInventoryCount > character.inventory.count
-        || character.inventory.count != mutation.expectedInventoryCount + itemCount) {
-        return report_failure("season_package_count");
-    }
-
-    std::uint32_t itemInstanceObjectId = 0;
-    if (!middleware::datagen::object_id(queuez::kAccountFamilyType,
-                                        middleware::datagen::kItemInstanceSlot,
-                                        itemInstanceObjectId)
-        || before.family4Residents.front().objectSoid != account.primarySoid
-        || before.family4Residents.front().definitionId == 0) {
-        return report_failure("season_package_definitions");
-    }
-    std::size_t characterResidentMatches = 0;
-    for (std::size_t index = 0; index < before.family4ResidentCount; ++index) {
-        const queuez::ResidentObject& resident = before.family4Residents[index];
-        characterResidentMatches +=
-            static_cast<std::size_t>(resident.objectSoid == character.soid
-                                     && resident.definitionId == selected.characterObjectId);
-    }
-    if (characterResidentMatches != 1) {
-        return report_failure("season_package_character_resident");
-    }
-
-    family4_datagen::loadout::ResolvedInstances acquired{};
-    const std::size_t firstGranted = mutation.expectedInventoryCount;
-    for (std::size_t grantIndex = 0; grantIndex < itemCount; ++grantIndex) {
-        const auto& granted = character.inventory.values[firstGranted + grantIndex];
-        if (granted.instanceSoid != mutation.firstInstanceSoid + grantIndex
-            || granted.mutationSerial < 0) {
-            return report_failure("season_package_item_identity");
-        }
-        for (std::size_t residentIndex = 0; residentIndex < before.family4ResidentCount;
-             ++residentIndex) {
-            if (before.family4Residents[residentIndex].objectSoid == granted.instanceSoid) {
-                return report_failure("season_package_item_resident");
-            }
-        }
-        std::size_t matches = 0;
-        for (std::size_t loadoutIndex = 0; loadoutIndex < selected.loadout.itemCount;
-             ++loadoutIndex) {
-            const auto& resolvedItem = selected.loadout.items[loadoutIndex];
-            if (resolvedItem.instance.instanceSoid != granted.instanceSoid) {
-                continue;
-            }
-            acquired.items[grantIndex].equipmentSlot = resolvedItem.equipmentSlot;
-            acquired.items[grantIndex].instance = resolvedItem.instance;
-            if (resolvedItem.mutationSerial != granted.mutationSerial) {
-                return report_failure("season_package_item_serial");
-            }
-            ++matches;
-        }
-        if (matches != 1) {
-            return report_failure("season_package_item_loadout");
-        }
-    }
-    acquired.itemCount = itemCount;
-
-    const Reservation reservation = reserve_prior(scratch, prepared);
-    const auto rawStorage = std::span(scratch.plaintext).subspan(reservation.rawWriteOffset);
-    if (character_layout::kObjectSize > rawStorage.size()
-        || family4_datagen::account::layout::kObjectSize > rawStorage.size()) {
-        return report_failure("season_package_storage");
-    }
-
-    Prepared staged{};
-    std::size_t compressedExtent = reservation.compressedWriteOffset;
-    std::size_t itemCursor = 0;
-    if (!append_items(scratch,
-                      rawStorage,
-                      itemInstanceObjectId,
-                      acquired,
-                      0,
-                      staged,
-                      itemCursor,
-                      compressedExtent)
-        || itemCursor != itemCount) {
-        clear_after(scratch, reservation);
-        return report_failure("season_package_item_objects");
-    }
-
-    const auto characterBytes = rawStorage.first(character_layout::kObjectSize);
-    if (!family4_datagen::character::encode(
-            character, selected.loadout, selected.lightEvaluation, characterBytes)) {
-        clear_after(scratch, reservation);
-        return report_failure("season_package_character_encode");
-    }
-    auto& characterObject = *reinterpret_cast<character_layout::Object*>(characterBytes.data());
-    if (characterObject.inventoryChanges.writeSlot != 0
-        || characterObject.inventoryChanges.nextSequence != 0
-        || !std::all_of(characterObject.inventoryChanges.records.cbegin(),
-                        characterObject.inventoryChanges.records.cend(),
-                        kChangeRecordIsZero)) {
-        clear_after(scratch, reservation);
-        return report_failure("season_package_change_state");
-    }
-    for (std::size_t index = 0; index < itemCount; ++index) {
-        auto& change = characterObject.inventoryChanges.records[index];
-        change.sequence = static_cast<std::uint16_t>(index);
-        change.mutationSerial = character.inventory.values[firstGranted + index].mutationSerial;
-        change.kind = kChangeKind;
-        change.flags = kChangeFlags;
-    }
-    characterObject.inventoryChanges.writeSlot = static_cast<std::uint16_t>(itemCount);
-    characterObject.inventoryChanges.nextSequence = static_cast<std::uint16_t>(itemCount);
-    if (!apply_acquisition_presentation(
-            characterBytes, selected.loadout, acquisitionPresentationRows)) {
-        clear_after(scratch, reservation);
-        return report_failure("season_package_presentation");
-    }
-    if (!append_object(scratch,
-                       characterBytes,
-                       selected.characterObjectId,
-                       character.soid,
-                       staged.objects[itemCount],
-                       compressedExtent)) {
-        clear_after(scratch, reservation);
-        return report_failure("season_package_character_object");
-    }
-
-    const auto accountBytes = rawStorage.first(family4_datagen::account::layout::kObjectSize);
-    if (!family4_datagen::account::encode(account, accountBytes)
-        || !append_object(scratch,
-                          accountBytes,
-                          before.family4Residents.front().definitionId,
-                          account.primarySoid,
-                          staged.objects[itemCount + 1U],
-                          compressedExtent)) {
-        clear_after(scratch, reservation);
-        return report_failure("season_package_account_object");
-    }
-
-    staged.rawClearSize =
-        (std::max)(reservation.rawClearSize,
-                   reservation.rawWriteOffset
-                       + (std::max)(character_layout::kObjectSize,
-                                    family4_datagen::account::layout::kObjectSize));
-    staged.compressedClearSize = (std::max)(reservation.compressedClearSize, compressedExtent);
-    staged.family = middleware::queuez::Family{queuez::kAccountFamilyType,
-                                               account.primarySoid,
-                                               before.family4Version + 1,
-                                               0,
-                                               std::span(staged.objects).first(itemCount + 2U)};
-    if (!commit(staged, prepared)) {
-        clear_after(scratch, reservation);
-        return report_failure("season_package_commit");
-    }
-    return true;
-}
+// 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);
 
 /** Builds all record rewards and the pending claim into one Family-4 revision. */
 bool prepare_record_reward_grant(
@@ -217,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)()
@@ -346,7 +163,8 @@ bool prepare_record_reward_grant(
     std::size_t characterChanges = 0;
     for (std::size_t rewardIndex = 0; rewardIndex < mutation.rewardCount; ++rewardIndex) {
         const state::PreparedRecordReward& reward = mutation.rewards[rewardIndex];
-        if (reward.kind == state::RecordRewardKind::profileStack) {
+        if (reward.kind == state::RecordRewardKind::profileStack
+            || reward.kind == state::RecordRewardKind::accountUnlock) {
             continue;
         }
         state::build_data::items::Definition definition{};
@@ -402,7 +220,9 @@ bool prepare_record_reward_grant(
     }
 
     const auto accountBytes = rawStorage.first(account_layout::kObjectSize);
-    if (!family4_datagen::account::encode(account, accountBytes)) {
+    state::unlocks::Table unlocks{};
+    if (!state::preview_reward_unlocks(mutation, unlocks)
+        || !family4_datagen::account::encode(account, accountBytes, unlocks)) {
         clear_after(scratch, reservation);
         return report_failure("record_reward_account_encode");
     }

+ 0 - 9
Sunrise/src/server/bap/encrypted/push/snapshot/internal.h

@@ -236,15 +236,6 @@ prepare_profile_item_acquisition(Scratch& scratch,
     std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
     Prepared& prepared) noexcept;
 
-/** Builds one package increment containing its new instances, character, and account objects. */
-[[nodiscard]] bool prepare_season_pass_package(
-    Scratch& scratch,
-    const queuez::SessionState& before,
-    const state::PendingDirectItemBundle& mutation,
-    std::uint16_t rewardIndex,
-    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
-    Prepared& prepared) noexcept;
-
 /** Builds one atomic record-reward batch in resident, character, account order. */
 [[nodiscard]] bool prepare_record_reward_grant(
     Scratch& scratch,

+ 145 - 53
Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp

@@ -2,8 +2,12 @@
 
 #include <algorithm>
 #include <limits>
+#include <memory>
+#include <new>
+#include <span>
 
 #include "../../../../core/logging/log.h"
+#include "../../../../middleware/crypto/random_bytes.h"
 #include "../../../../middleware/secure_channel/runtime.h"
 #include "../../../../state/account/account_state.h"
 #include "../../../../state/activity/destination/definition.h"
@@ -52,6 +56,84 @@ selected_character(const state::AccountState& account) noexcept {
     return nullptr;
 }
 
+/** Publishes and commits one world reward resolved through its installed reward definition. */
+[[nodiscard]] bool consume_world_record_reward(const WorldRewardRequest& request,
+                                               Session& session,
+                                               Scratch& scratch,
+                                               std::span<std::byte> response,
+                                               std::size_t& written,
+                                               bool& touchesScratch) noexcept {
+    const auto fail = [&](const char* reason) noexcept {
+        report_reward_refusal("world_publish", request.itemDefinitionIndex, reason);
+        return false;
+    };
+    // A draw is not retained, so it waits for a buffer its whole frame is sure to fit.
+    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 (!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{};
+    touchesScratch = true;
+    std::size_t framedSize = 0;
+    const bool staged = queuez::stage_record_reward_grant(session.queuez, *pending, update);
+    const bool encoded =
+        staged
+        && push::append_record_reward_notification(scratch,
+                                                   session.queuez,
+                                                   update,
+                                                   *pending,
+                                                   active_acquisition_presentation_rows(session),
+                                                   session.sessionKey,
+                                                   session.sendNonce,
+                                                   scratch.framed,
+                                                   framedSize)
+        && framedSize != 0;
+    if (!state::commit_record_reward(*pending)) {
+        return fail("inventory_commit");
+    }
+    if (!bap::complete_world_reward(request.id) || !transaction.commit()) {
+        return fail("queue_commit");
+    }
+    // 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" : "inventory_encode");
+    }
+    std::copy_n(scratch.framed.begin(), framedSize, response.begin());
+    written = framedSize;
+    middleware::secure_channel::advance_nonce(session.sendNonce);
+    session.queuez = update.after;
+    bap::arm_account_resync_elsewhere(session);
+    bap::arm_acquisition_presentation_hold(session);
+    return true;
+}
+
 /** Publishes and commits one character-inventory world reward. */
 [[nodiscard]] bool consume_world_item_acquisition(const WorldRewardRequest& request,
                                                   Session& session,
@@ -59,6 +141,10 @@ selected_character(const state::AccountState& account) noexcept {
                                                   std::span<std::byte> response,
                                                   std::size_t& written,
                                                   bool& touchesScratch) noexcept {
+    if (state::item_grant_route(request.itemDefinitionIndex) != state::ItemGrantRoute::quest) {
+        return consume_world_record_reward(
+            request, session, scratch, response, written, touchesScratch);
+    }
     state::investment::store::Transaction transaction;
     if (!transaction.ready()) {
         return false;
@@ -69,38 +155,33 @@ selected_character(const state::AccountState& account) noexcept {
         core::log::write(core::log::Channel::server,
                          core::log::Level::warn,
                          "ev=queuez stage=world_acquisition result=fail reason=prepare");
-        bap::settle_world_reward();
         return false;
     }
     touchesScratch = true;
     queuez::ItemAcquisition acquisition{};
-    if (!queuez::stage_item_acquisition(session.queuez,
-                                        pending.accountSoid,
-                                        pending.characterSoid,
-                                        pending.acquiredInstanceSoid,
-                                        pending.updates_account(),
-                                        acquisition)) {
-        core::log::write(core::log::Channel::server,
-                         core::log::Level::warn,
-                         "ev=queuez stage=world_acquisition result=fail reason=stage");
-        bap::settle_world_reward();
-        return false;
-    }
+    const bool staged = queuez::stage_item_acquisition(session.queuez,
+                                                       pending.accountSoid,
+                                                       pending.characterSoid,
+                                                       pending.acquiredInstanceSoid,
+                                                       pending.updates_account(),
+                                                       acquisition);
     auto nextSendNonce = session.sendNonce;
     std::size_t framedSize = 0;
-    if (!push::append_item_acquisition_notification(scratch,
-                                                    acquisition,
-                                                    pending,
-                                                    active_acquisition_presentation_rows(session),
-                                                    session.sessionKey,
-                                                    nextSendNonce,
-                                                    scratch.framed,
-                                                    framedSize)
-        || framedSize == 0 || framedSize > response.size()) {
-        core::log::write(core::log::Channel::server,
-                         core::log::Level::warn,
-                         "ev=queuez stage=world_acquisition result=fail reason=encode");
-        bap::settle_world_reward();
+    const bool encoded =
+        staged
+        && push::append_item_acquisition_notification(scratch,
+                                                      acquisition,
+                                                      pending,
+                                                      active_acquisition_presentation_rows(session),
+                                                      session.sessionKey,
+                                                      nextSendNonce,
+                                                      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)
@@ -108,7 +189,14 @@ selected_character(const state::AccountState& account) noexcept {
         core::log::write(core::log::Channel::server,
                          core::log::Level::warn,
                          "ev=queuez stage=world_acquisition result=fail reason=commit");
-        bap::settle_world_reward();
+        return false;
+    }
+    // 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" : "inventory_encode");
         return false;
     }
     std::copy_n(scratch.framed.begin(), framedSize, response.begin());
@@ -139,37 +227,31 @@ selected_character(const state::AccountState& account) noexcept {
         core::log::write(core::log::Channel::server,
                          core::log::Level::warn,
                          "ev=queuez stage=world_profile_acquisition result=fail reason=prepare");
-        bap::settle_world_reward();
         return false;
     }
     touchesScratch = true;
     queuez::ProfileItemAcquisition acquisition{};
-    if (!queuez::stage_profile_item_acquisition(session.queuez,
-                                                pending.accountSoid,
-                                                pending.acquiredInstanceSoid,
-                                                pending.actionSource,
-                                                pending.appended,
-                                                acquisition)) {
-        core::log::write(core::log::Channel::server,
-                         core::log::Level::warn,
-                         "ev=queuez stage=world_profile_acquisition result=fail reason=stage");
-        bap::settle_world_reward();
-        return false;
-    }
+    const bool staged = queuez::stage_profile_item_acquisition(session.queuez,
+                                                               pending.accountSoid,
+                                                               pending.acquiredInstanceSoid,
+                                                               pending.actionSource,
+                                                               pending.appended,
+                                                               acquisition);
     auto nextSendNonce = session.sendNonce;
     std::size_t framedSize = 0;
-    if (!push::append_profile_item_acquisition_notification(scratch,
-                                                            acquisition,
-                                                            pending,
-                                                            session.sessionKey,
-                                                            nextSendNonce,
-                                                            scratch.framed,
-                                                            framedSize)
-        || framedSize == 0 || framedSize > response.size()) {
-        core::log::write(core::log::Channel::server,
-                         core::log::Level::warn,
-                         "ev=queuez stage=world_profile_acquisition result=fail reason=encode");
-        bap::settle_world_reward();
+    const bool encoded = staged
+                         && push::append_profile_item_acquisition_notification(scratch,
+                                                                               acquisition,
+                                                                               pending,
+                                                                               session.sessionKey,
+                                                                               nextSendNonce,
+                                                                               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)
@@ -177,7 +259,14 @@ selected_character(const state::AccountState& account) noexcept {
         core::log::write(core::log::Channel::server,
                          core::log::Level::warn,
                          "ev=queuez stage=world_profile_acquisition result=fail reason=commit");
-        bap::settle_world_reward();
+        return false;
+    }
+    // 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" : "inventory_encode");
         return false;
     }
     std::copy_n(scratch.framed.begin(), framedSize, response.begin());
@@ -690,6 +779,9 @@ bool consume_deferred(Session& session,
         if (published) {
             return true;
         }
+        if (session.accountResyncArmed) {
+            return false;
+        }
     }
     if (consume_seasonal_experience_presentation(
             session, scratch, response, written, touchesScratch)) {

+ 27 - 69
Sunrise/src/server/bap/encrypted/queuez/queuez_reward_staging.cpp

@@ -2,9 +2,6 @@
 
 #include "queuez_reward_staging.h"
 
-#include <optional>
-#include <variant>
-
 #include "../../../../core/logging/log.h"
 #include "../../../../middleware/secure_channel/runtime.h"
 
@@ -56,70 +53,24 @@ stage_profile_item_acquisition_push(Scratch& scratch,
     return true;
 }
 
-/** Stages the one grant a season pass rank owes, whichever of the four shapes it carries. */
+/** Publishes a reward batch before advancing the nonce and peer revision. */
 [[nodiscard]] bool
-stage_season_pass_reward(Scratch& scratch,
+stage_record_reward_push(Scratch& scratch,
                          const SessionState& before,
-                         const SeasonPassRewardTransaction& reward,
+                         const RecordRewardGrant& update,
+                         const state::PendingRecordRewardGrant& pending,
                          std::span<const AcquisitionPresentationRow> presentationRows,
                          std::span<const std::byte, state::kAesKeySize> key,
                          std::array<std::byte, state::kBapNonceSize>& nonce,
                          std::span<std::byte> response,
                          std::size_t& written,
                          SessionState& after) noexcept {
-    const auto& pending = *reward.pending;
-    if (const auto* itemUpdate = std::get_if<ItemAcquisition>(&reward.update)) {
-        const auto* itemPending = std::get_if<state::PendingItemAcquisition>(&pending.grant);
-        return itemPending != nullptr
-               && stage_item_acquisition_push(scratch,
-                                              *itemUpdate,
-                                              *itemPending,
-                                              presentationRows,
-                                              key,
-                                              nonce,
-                                              response,
-                                              written,
-                                              after);
-    }
-    if (const auto* profileUpdate = std::get_if<ProfileItemAcquisition>(&reward.update)) {
-        const auto* profilePending =
-            std::get_if<state::PendingProfileItemAcquisition>(&pending.grant);
-        return profilePending != nullptr
-               && stage_profile_item_acquisition_push(
-                   scratch, *profileUpdate, *profilePending, key, nonce, response, written, after);
-    }
-    if (const auto* bundle = std::get_if<state::PendingDirectItemBundle>(&pending.grant)) {
-        if (!push::append_season_pass_package_notification(scratch,
-                                                           before,
-                                                           *bundle,
-                                                           pending.rewardIndex,
-                                                           presentationRows,
-                                                           key,
-                                                           nonce,
-                                                           response,
-                                                           written,
-                                                           after)) {
-            return false;
-        }
-        middleware::secure_channel::advance_nonce(nonce);
-        return true;
-    }
-    const auto* resourceUpdate = std::get_if<RecordRewardGrant>(&reward.update);
-    const auto* resources = std::get_if<state::PendingRecordRewardGrant>(&pending.grant);
-    if (resourceUpdate == nullptr || resources == nullptr
-        || !push::append_record_reward_notification(scratch,
-                                                    before,
-                                                    *resourceUpdate,
-                                                    *resources,
-                                                    presentationRows,
-                                                    key,
-                                                    nonce,
-                                                    response,
-                                                    written)) {
+    if (!push::append_record_reward_notification(
+            scratch, before, update, pending, presentationRows, key, nonce, response, written)) {
         return false;
     }
     middleware::secure_channel::advance_nonce(nonce);
-    after = resourceUpdate->after;
+    after = update.after;
     return true;
 }
 
@@ -184,28 +135,35 @@ bool stage_reward_outcome(Scratch& scratch,
     }
     if (const auto* record = transaction_if<RecordRewardGrantTransaction>(outcome)) {
         if (record->pending == nullptr
-            || !push::append_record_reward_notification(scratch,
-                                                        before,
-                                                        record->update,
-                                                        *record->pending,
-                                                        presentationRows,
-                                                        key,
-                                                        nonce,
-                                                        response,
-                                                        written)) {
+            || !stage_record_reward_push(scratch,
+                                         before,
+                                         record->update,
+                                         *record->pending,
+                                         presentationRows,
+                                         key,
+                                         nonce,
+                                         response,
+                                         written,
+                                         after)) {
             core::log::write(core::log::Channel::server,
                              core::log::Level::warn,
                              "ev=queuez stage=record_reward result=fail");
             return false;
         }
-        middleware::secure_channel::advance_nonce(nonce);
-        after = record->update.after;
         return true;
     }
     const auto* seasonPass = transaction_if<SeasonPassRewardTransaction>(outcome);
     if (seasonPass == nullptr || seasonPass->pending == nullptr
-        || !stage_season_pass_reward(
-            scratch, before, *seasonPass, presentationRows, key, nonce, response, written, after)) {
+        || !stage_record_reward_push(scratch,
+                                     before,
+                                     seasonPass->update,
+                                     seasonPass->pending->grant,
+                                     presentationRows,
+                                     key,
+                                     nonce,
+                                     response,
+                                     written,
+                                     after)) {
         core::log::write(core::log::Channel::server,
                          core::log::Level::warn,
                          "ev=ws2400 stage=queuez_reward result=fail");

+ 9 - 8
Sunrise/src/server/bap/encrypted/queuez/queuez_state_validation.h

@@ -7,6 +7,10 @@
 #include "../../../../middleware/queuez/subscription.h"
 #include "definition.h"
 
+namespace sunrise::state {
+struct PendingRecordRewardGrant;
+}
+
 namespace sunrise::server::bap::encrypted::queuez {
 
 /** @return True when one peer queuez state is canonical for the implemented versions. */
@@ -158,14 +162,6 @@ namespace sunrise::server::bap::encrypted::queuez {
                                           bool updatesAccount,
                                           ItemAcquisition& acquisition) noexcept;
 
-/** Validates one same-version bundle append and returns the revision its response may promise. */
-[[nodiscard]] bool stage_direct_item_bundle(const SessionState& before,
-                                            std::uint64_t accountSoid,
-                                            std::uint64_t characterSoid,
-                                            std::uint64_t firstInstanceSoid,
-                                            std::size_t itemCount,
-                                            std::int32_t& family4Version) noexcept;
-
 /**
  * Stages one Family-4 version increment for a full resident account-object upsert.
  * A profile row with a nonzero action-source SOID must already be resident when its stack grows,
@@ -192,6 +188,11 @@ namespace sunrise::server::bap::encrypted::queuez {
                                              std::span<const std::uint64_t> appendedResidents,
                                              RecordRewardGrant& grant) noexcept;
 
+/** Stages a prepared record-reward grant, appending the residents its new instances need. */
+[[nodiscard]] bool stage_record_reward_grant(const SessionState& before,
+                                             const state::PendingRecordRewardGrant& pending,
+                                             RecordRewardGrant& grant) noexcept;
+
 /**
  * Stages one Family-4 increment that removes an item resident and updates its character.
  *

+ 19 - 43
Sunrise/src/server/bap/encrypted/queuez/staging/queuez_character_staging.cpp

@@ -7,6 +7,7 @@
 
 #include "../../../../../core/logging/log.h"
 #include "../../../../../middleware/datagen/definitions.h"
+#include "../../../../../state/runtime/runtime.h"
 #include "../queuez_state_validation.h"
 
 namespace sunrise::server::bap::encrypted::queuez {
@@ -472,55 +473,30 @@ bool stage_item_acquisition(const SessionState& before,
     return staged;
 }
 
-/** Checks that a fixed bundle can append all residents in one Family-4 increment. */
-bool stage_direct_item_bundle(const SessionState& before,
-                              std::uint64_t accountSoid,
-                              std::uint64_t characterSoid,
-                              std::uint64_t firstInstanceSoid,
-                              std::size_t itemCount,
-                              std::int32_t& family4Version) noexcept {
-    family4Version = 0;
-    std::uint32_t accountDefinitionId = 0;
-    std::uint32_t characterDefinitionId = 0;
-    std::uint32_t itemDefinitionId = 0;
-    if (!valid(before) || !before.family4Active || accountSoid == 0 || characterSoid == 0
-        || firstInstanceSoid == 0 || itemCount == 0 || accountSoid != before.family4RootSoid
-        || before.family4ResidentCount == 0
-        || itemCount > before.family4Residents.size() - before.family4ResidentCount
-        || itemCount - 1U > (std::numeric_limits<std::uint64_t>::max)() - firstInstanceSoid
-        || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
-        || !middleware::datagen::object_id(
-            kAccountFamilyType, middleware::datagen::kAccountSlot, accountDefinitionId)
-        || !middleware::datagen::object_id(
-            kAccountFamilyType, middleware::datagen::kCharacterSlot, characterDefinitionId)
-        || !middleware::datagen::object_id(
-            kAccountFamilyType, middleware::datagen::kItemInstanceSlot, itemDefinitionId)) {
+/** Stages one atomic record-reward manifest update. */
+bool stage_record_reward_grant(const SessionState& before,
+                               const state::PendingRecordRewardGrant& pending,
+                               RecordRewardGrant& grant) noexcept {
+    grant = {};
+    if (!pending.prepared || pending.rewardCount > pending.rewards.size()) {
         return false;
     }
-
-    std::size_t accountMatches = 0;
-    std::size_t characterMatches = 0;
-    for (std::size_t residentIndex = 0; residentIndex < before.family4ResidentCount;
-         ++residentIndex) {
-        const ResidentObject& resident = before.family4Residents[residentIndex];
-        accountMatches += static_cast<std::size_t>(resident.objectSoid == accountSoid
-                                                   && resident.definitionId == accountDefinitionId);
-        characterMatches += static_cast<std::size_t>(
-            resident.objectSoid == characterSoid && resident.definitionId == characterDefinitionId);
-        for (std::size_t itemIndex = 0; itemIndex < itemCount; ++itemIndex) {
-            if (resident.objectSoid == firstInstanceSoid + itemIndex) {
-                return false;
-            }
+    std::array<std::uint64_t, state::kRecordRewardGrantCapacity> residents{};
+    std::size_t count = 0;
+    for (std::size_t i = 0; i < pending.rewardCount; ++i) {
+        const auto& reward = pending.rewards[i];
+        if (reward.kind == state::RecordRewardKind::characterInstance
+            || reward.appendedProfileResident) {
+            residents[count++] = reward.instanceSoid;
         }
     }
-    if (accountMatches != 1 || characterMatches != 1) {
-        return false;
-    }
-    family4Version = before.family4Version + 1;
-    return true;
+    return stage_record_reward_grant(before,
+                                     pending.accountSoid,
+                                     pending.characterSoid,
+                                     std::span(residents).first(count),
+                                     grant);
 }
 
-/** Stages one atomic record-reward manifest update. */
 bool stage_record_reward_grant(const SessionState& before,
                                std::uint64_t accountSoid,
                                std::uint64_t characterSoid,

+ 16 - 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. */
@@ -323,6 +327,9 @@ struct ReplicationEpochPublication {
     bool staged{};
 };
 
+/** Logs one refused reward stage with its native index and a specific reason. */
+void report_reward_refusal(const char* stage, std::uint16_t index, const char* reason) noexcept;
+
 /** Which inventory one world reward lands in. */
 enum class WorldRewardKind : std::uint8_t {
     item,
@@ -332,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{};
@@ -522,7 +530,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. */
@@ -541,13 +549,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. */

+ 13 - 311
Sunrise/src/server/web_service/web_service_record_actions.cpp

@@ -1,6 +1,5 @@
 /** Triumph, title and season-pass reward actions the web service prepares from one request. */
 
-#include <algorithm>
 #include <array>
 #include <cstdio>
 #include <span>
@@ -8,12 +7,9 @@
 
 #include "../../core/logging/log.h"
 #include "../../middleware/crypto/random_bytes.h"
-#include "../../middleware/encoding/byte_order.h"
 #include "../../middleware/web_service/messages/opcode1801.h"
 #include "../../middleware/web_service/messages/opcode1821.h"
 #include "../../middleware/web_service/messages/opcode2400.h"
-#include "../../state/account/account_state.h"
-#include "../../state/build_data/items/item_catalog.h"
 #include "../../state/build_data/runtime.h"
 #include "../../state/progression/season_pass_reward_catalog.h"
 #include "../../state/runtime/runtime.h"
@@ -23,226 +19,6 @@
 
 namespace sunrise::server::web_service {
 
-namespace {
-
-/** Prepares one rank-one class package without changing account State. */
-[[nodiscard]] bool
-prepare_premium_class_package(const state::build_data::season_pass::Package& package,
-                              state::PendingDirectItemBundle& mutation) noexcept {
-    std::array<std::uint16_t, state::build_data::season_pass::kPackageItemCapacity> itemIndices{};
-    if (package.itemCount == 0 || package.itemCount > itemIndices.size()) {
-        return false;
-    }
-    for (std::size_t index = 0; index < package.itemCount; ++index) {
-        state::build_data::items::Definition definition{};
-        if (!state::build_data::find_item_definition_hash(package.items[index], definition)) {
-            return false;
-        }
-        itemIndices[index] = definition.definitionIndex;
-    }
-    return state::prepare_direct_item_bundle(
-        package.definitionHash, std::span(itemIndices).first(package.itemCount), mutation);
-}
-
-/** Expands the manifest-authored destination package into all nine material stacks. */
-[[nodiscard]] bool
-prepare_destination_resource_bundle(state::PendingSeasonPassReward& grant) noexcept {
-    namespace pass = state::progression::season_pass;
-    std::array<state::DirectRecordReward, pass::kDestinationResourceHashes.size()> rewards{};
-    for (std::size_t index = 0; index < rewards.size(); ++index) {
-        state::build_data::items::Definition definition{};
-        if (!state::build_data::find_item_definition_hash(pass::kDestinationResourceHashes[index],
-                                                          definition)) {
-            return false;
-        }
-        rewards[index] = {definition.definitionIndex, pass::kDestinationResourceQuantity};
-    }
-    auto& mutation = grant.grant.emplace<state::PendingRecordRewardGrant>();
-    return state::prepare_record_reward_grant(rewards, {}, mutation);
-}
-
-/** Chooses one installed weapon or selected-class armour item from an auto-decrypting engram. */
-template <std::size_t Size>
-[[nodiscard]] std::span<const std::uint32_t>
-class_armour_pool(state::CharacterClass characterClass,
-                  const std::array<std::uint32_t, Size>& titan,
-                  const std::array<std::uint32_t, Size>& hunter,
-                  const std::array<std::uint32_t, Size>& warlock) noexcept {
-    switch (characterClass) {
-    case state::CharacterClass::hunter:
-        return hunter;
-    case state::CharacterClass::warlock:
-        return warlock;
-    case state::CharacterClass::titan:
-    default:
-        return titan;
-    }
-}
-
-/**
- * Picks one random installed item from the engram's weapon and selected-class armour pools.
- * @param itemIndex Receives the picked item definition index; unchanged on failure.
- * @return False when no character is selected, the engram is unknown, or no pool item is installed.
- */
-[[nodiscard]] bool choose_engram_reward(std::uint32_t engramHash,
-                                        std::uint16_t& itemIndex) noexcept {
-    namespace pass = state::progression::season_pass;
-    std::span<const std::uint32_t> weapons;
-    std::span<const std::uint32_t> armour;
-    const state::AccountState account = state::account_snapshot();
-    const state::CharacterState* character = nullptr;
-    for (std::size_t index = 0; index < account.characterCount; ++index) {
-        if (account.characters[index].selected) {
-            character = &account.characters[index];
-            break;
-        }
-    }
-    if (character == nullptr) {
-        return false;
-    }
-
-    if (engramHash == pass::kLegendaryEngramHash) {
-        weapons = pass::kLegendaryEngramWeapons;
-        armour = class_armour_pool(character->characterClass,
-                                   pass::kLegendaryTitanArmour,
-                                   pass::kLegendaryHunterArmour,
-                                   pass::kLegendaryWarlockArmour);
-    } else if (engramHash == pass::kExoticEngramHash) {
-        weapons = pass::kExoticEngramWeapons;
-        armour = class_armour_pool(character->characterClass,
-                                   pass::kExoticTitanArmour,
-                                   pass::kExoticHunterArmour,
-                                   pass::kExoticWarlockArmour);
-    } else {
-        return false;
-    }
-
-    std::array<std::byte, sizeof(std::uint32_t)> randomBytes{};
-    if (!middleware::crypto::random::fill(randomBytes)) {
-        return false;
-    }
-    const std::size_t count = weapons.size() + armour.size();
-    const std::size_t first = middleware::encoding::read_u32_le(randomBytes) % count;
-    for (std::size_t offset = 0; offset < count; ++offset) {
-        const std::size_t selected = (first + offset) % count;
-        const std::uint32_t hash =
-            selected < weapons.size() ? weapons[selected] : armour[selected - weapons.size()];
-        state::build_data::items::Definition definition{};
-        if (state::build_data::find_item_definition_hash(hash, definition)) {
-            itemIndex = definition.definitionIndex;
-            return true;
-        }
-    }
-    return false;
-}
-
-/** Stat plugs occupy the four ordinary socket lanes after the six the armour piece itself uses. */
-constexpr std::size_t kSeasonArmourStatLaneBase = 6;
-/** One roll names one lane of each of the four armour stats. */
-constexpr std::size_t kSeasonArmourStatLaneCount = 4;
-
-struct SeasonArmourRoll {
-    std::uint16_t rewardIndex{};
-    std::uint32_t itemHash{};
-    std::array<std::uint32_t, kSeasonArmourStatLaneCount> statPlugs{};
-};
-
-/**
- * Exact rolls shown by the free, early premium, and high-stat pass tiles.
- * TODO: extract; the pass reward row names the item, and no package field names its stat plugs.
- */
-constexpr std::array<SeasonArmourRoll, 36> kSeasonArmourRolls{{
-    // Hunter
-    {29, 3097544525U, {1232924472U, 3898228147U, 1177742666U, 1322519294U}},
-    {106, 3097544525U, {2191854732U, 3198072315U, 1322519294U, 597281635U}},
-    {139, 3097544525U, {3072193746U, 772656086U, 2692705068U, 1300719726U}},
-    {3, 3750210364U, {1232924472U, 3898228147U, 1177742666U, 757147114U}},
-    {78, 3750210364U, {2120002858U, 1526511067U, 1277668557U, 3014984195U}},
-    {111, 3750210364U, {2600679334U, 3955940376U, 3026009912U, 115819390U}},
-    {24, 2930001572U, {1232924472U, 2518640481U, 1177742666U, 4237052128U}},
-    {97, 2930001572U, {2191854732U, 2440285137U, 1322519294U, 597281635U}},
-    {134, 2930001572U, {4286256569U, 2148570570U, 594234536U, 2664898188U}},
-    {10, 3136019014U, {2191854732U, 2236091344U, 1177742666U, 2596709069U}},
-    {83, 3136019014U, {3407231789U, 1798836563U, 1322519294U, 3203727595U}},
-    {120, 3136019014U, {3346214146U, 2399358832U, 594234536U, 2701881372U}},
-    // Titan
-    {30, 1214477175U, {1232924472U, 3898228147U, 1177742666U, 1322519294U}},
-    {107, 1214477175U, {479726201U, 176934377U, 1322519294U, 597281635U}},
-    {140, 1214477175U, {2868325782U, 2387165386U, 2692705068U, 1300719726U}},
-    {4, 287888126U, {1232924472U, 3898228147U, 1177742666U, 757147114U}},
-    {79, 287888126U, {2120002858U, 1369119565U, 1277668557U, 3014984195U}},
-    {112, 287888126U, {2148570570U, 2148570570U, 3026009912U, 115819390U}},
-    {25, 1585947570U, {1232924472U, 2518640481U, 1177742666U, 4237052128U}},
-    {98, 1585947570U, {1232924472U, 1533918171U, 1322519294U, 597281635U}},
-    {135, 1585947570U, {2868325782U, 1087628722U, 594234536U, 2664898188U}},
-    {11, 1131831128U, {2191854732U, 2236091344U, 1177742666U, 2596709069U}},
-    {84, 1131831128U, {1232924472U, 4002613733U, 1322519294U, 3203727595U}},
-    {121, 1131831128U, {2387165386U, 2148570570U, 594234536U, 2701881372U}},
-    // Warlock
-    {31, 1173249516U, {1232924472U, 3898228147U, 1177742666U, 1322519294U}},
-    {108, 1173249516U, {2191854732U, 2433764085U, 1322519294U, 597281635U}},
-    {141, 1173249516U, {2868325782U, 3072193746U, 2692705068U, 1300719726U}},
-    {5, 327547301U, {1232924472U, 3898228147U, 1177742666U, 757147114U}},
-    {80, 327547301U, {1232924472U, 3554741641U, 1277668557U, 3014984195U}},
-    {113, 327547301U, {4248662490U, 1281324436U, 3026009912U, 115819390U}},
-    {26, 119457531U, {1232924472U, 2518640481U, 1177742666U, 4237052128U}},
-    {99, 119457531U, {1232924472U, 1858911761U, 1322519294U, 597281635U}},
-    {136, 119457531U, {3198618292U, 643846500U, 594234536U, 2664898188U}},
-    {12, 674876967U, {2191854732U, 2236091344U, 1177742666U, 2596709069U}},
-    {85, 674876967U, {2576224072U, 1534361879U, 1322519294U, 3203727595U}},
-    {122, 674876967U, {643846500U, 4248662490U, 594234536U, 2701881372U}},
-}};
-
-/** Replaces native stat plugs with the fixed roll represented by one known pass tile. */
-[[nodiscard]] bool apply_season_armour_roll(std::uint16_t rewardIndex,
-                                            state::PendingItemAcquisition& mutation) noexcept {
-    const auto roll = std::find_if(kSeasonArmourRolls.begin(),
-                                   kSeasonArmourRolls.end(),
-                                   [rewardIndex](const SeasonArmourRoll& candidate) {
-                                       return candidate.rewardIndex == rewardIndex;
-                                   });
-    if (roll == kSeasonArmourRolls.end()) {
-        return true;
-    }
-    if (mutation.inventoryIndex >= mutation.afterCharacter.inventory.count) {
-        return false;
-    }
-    auto& acquired = mutation.afterCharacter.inventory.values[mutation.inventoryIndex];
-    state::build_data::items::Definition item{};
-    state::build_data::items::details::Definition detail{};
-    if (acquired.definitionHash != mutation.acquiredDefinitionHash
-        || acquired.definitionHash != roll->itemHash
-        || !state::build_data::find_item_definition_hash(acquired.definitionHash, item)
-        || !state::build_data::find_configured_item_detail(item.definitionIndex, detail)
-        || detail.definitionIndex != item.definitionIndex
-        || detail.ordinarySocketCount < kSeasonArmourStatLaneBase + kSeasonArmourStatLaneCount
-        || detail.ordinarySocketCount > acquired.sockets.plugs.size()) {
-        return false;
-    }
-
-    state::account::inventory::Sockets sockets{};
-    sockets.policy = state::account::inventory::SocketPolicy::authored;
-    sockets.plugCount = detail.ordinarySocketCount;
-    for (std::size_t lane = 0; lane < sockets.plugCount; ++lane) {
-        const std::uint16_t plugIndex = detail.initialPlugIndices[lane];
-        if (plugIndex == state::build_data::items::details::kUnavailableItemIndex) {
-            continue;
-        }
-        state::build_data::items::Definition plug{};
-        if (!state::build_data::find_item_definition_index(plugIndex, plug)) {
-            return false;
-        }
-        sockets.plugs[lane] = plug.definitionHash;
-    }
-    for (std::size_t index = 0; index < roll->statPlugs.size(); ++index) {
-        sockets.plugs[kSeasonArmourStatLaneBase + index] = roll->statPlugs[index];
-    }
-    acquired.sockets = sockets;
-    return state::account::inventory::valid(acquired.sockets);
-}
-
-} // namespace
-
 /** Reports an opcode-1801 claim failure. */
 void report_record_claim(const middleware::web_service::Message& message,
                          std::string_view reason,
@@ -286,52 +62,6 @@ void report_record_reward(const middleware::web_service::Message& message,
     write_warning(line, count);
 }
 
-/** Prepares one direct Season-pass item, returning a failure reason or null. */
-[[nodiscard]] const char* prepare_direct_reward(std::uint16_t itemIndex,
-                                                std::int32_t quantity,
-                                                state::PendingSeasonPassReward& grant) noexcept {
-    if (quantity <= 0) {
-        return "quantity";
-    }
-    state::build_data::items::Definition definition{};
-    if (!state::build_data::find_item_definition_index(itemIndex, definition)) {
-        return "item_definition";
-    }
-
-    state::build_data::items::details::Definition detail{};
-    state::build_data::inventory::buckets::Descriptor bucket{};
-    if (!state::build_data::find_configured_item_detail(itemIndex, detail)
-        || detail.definitionIndex != itemIndex || detail.definitionHash != definition.definitionHash
-        || detail.bucketId != definition.bucketId
-        || !state::build_data::find_inventory_bucket_descriptor(detail.bucketId, bucket)) {
-        return "item_detail_or_bucket";
-    }
-
-    namespace bucket_domain = state::build_data::inventory::buckets;
-    namespace detail_domain = state::build_data::items::details;
-    if (bucket.arraySelector == bucket_domain::ArraySelector::profile) {
-        if (detail.instancedDefinitionState != detail_domain::InstancedDefinitionState::stackable) {
-            return "profile_item_instanced";
-        }
-        auto& mutation = grant.grant.emplace<state::PendingProfileItemAcquisition>();
-        return state::prepare_profile_item_acquisition_for_item(itemIndex, quantity, mutation)
-                   ? nullptr
-                   : "profile_state";
-    }
-    if (bucket.arraySelector == bucket_domain::ArraySelector::character) {
-        if (quantity != 1) {
-            return "character_item_quantity";
-        }
-        auto& mutation = grant.grant.emplace<state::PendingItemAcquisition>();
-        if (!state::prepare_item_acquisition_for_item(itemIndex, mutation)) {
-            return "state";
-        }
-        mutation.profileChanged = false;
-        return nullptr;
-    }
-    return "unsupported_inventory_array";
-}
-
 /** Whether a claimed record owes a reward, and whether that reward could be prepared. */
 enum class RecordRewardPreparation : std::uint8_t {
     /** The record grants nothing, so only the claim is written. */
@@ -369,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);
@@ -427,50 +158,21 @@ void claim_season_pass_reward(const middleware::web_service::Message& message,
     if (grant == nullptr) {
         return fail("storage");
     }
-    state::build_data::season_pass::Package package{};
-    if (state::build_data::find_season_pass_package(reward.itemHash, package)) {
-        auto& bundle = grant->grant.emplace<state::PendingDirectItemBundle>();
-        if (!prepare_premium_class_package(package, bundle)) {
-            clear_mutation(outcome);
-            return fail("package_grant");
-        }
-    } else if (reward.itemHash == pass::kDestinationResourceBundleHash) {
-        if (!prepare_destination_resource_bundle(*grant)) {
-            clear_mutation(outcome);
-            return fail("resource_bundle");
-        }
-    } else if (reward.itemHash == pass::kLegendaryEngramHash
-               || reward.itemHash == pass::kExoticEngramHash) {
-        std::uint16_t decryptedItemIndex = 0;
-        if (!choose_engram_reward(reward.itemHash, decryptedItemIndex)) {
-            clear_mutation(outcome);
-            return fail("engram_pool");
-        }
-        if (const char* reason = prepare_direct_reward(decryptedItemIndex, 1, *grant)) {
-            clear_mutation(outcome);
-            return fail(reason);
-        }
-    } else {
-        if (const char* reason = prepare_direct_reward(
-                item.definitionIndex, static_cast<std::int32_t>(reward.quantity), *grant)) {
-            clear_mutation(outcome);
-            return fail(reason);
-        }
-        if (auto* armour = std::get_if<state::PendingItemAcquisition>(&grant->grant);
-            armour != nullptr && !apply_season_armour_roll(request.rewardIndex, *armour)) {
-            clear_mutation(outcome);
-            return fail("armour_roll");
-        }
+    std::uint64_t seed = 0;
+    if (!middleware::crypto::random::fill(std::as_writable_bytes(std::span(&seed, 1)))) {
+        clear_mutation(outcome);
+        return fail("random_source");
+    }
+    const char* reason = nullptr;
+    if (!state::prepare_season_pass_reward(request.rewardIndex, seed, *grant, &reason)) {
+        clear_mutation(outcome);
+        return fail(reason != nullptr ? reason : "reward_preparation");
     }
-    // The claim is world state the moment the grant is accepted, so it is written before the
-    // reply and the push are staged from the banks.
+    // Stage the claim before encoding the reply and account push from the transaction's banks.
     if (!state::claim_season_pass_reward(request.rewardIndex)) {
         clear_mutation(outcome);
         return fail("claim_rejected");
     }
-    grant->sourceDefinitionHash = reward.itemHash;
-    grant->rewardIndex = request.rewardIndex;
-    grant->prepared = true;
 }
 
 /** Decodes one opcode-1801 Triumphs claim and reports the record it names. */

+ 4 - 0
Sunrise/src/state/account/account_state.h

@@ -79,6 +79,10 @@ enum class CharacterClass : std::uint8_t {
     warlock = 2,
 };
 
+/** Number of character classes represented on the wire. */
+inline constexpr std::size_t kCharacterClassCount =
+    static_cast<std::size_t>(CharacterClass::warlock) + 1;
+
 /** Default movement entry. Each subclass offers 3, as entries 4, 5 and 6 of its group. */
 inline constexpr std::uint8_t kDefaultMovementAbilityEntry = 4;
 

+ 8 - 2
Sunrise/src/state/build_data/build_data_runtime.cpp

@@ -21,6 +21,7 @@
 #include "nodes/node_catalog.h"
 #include "progressions/progression_catalog.h"
 #include "records/record_catalog.h"
+#include "rewards/reward_catalog.h"
 #include "runtime.h"
 #include "runtime/build_data_catalog_runtime.h"
 #include "runtime/domain_markers.h"
@@ -119,6 +120,12 @@ bool initialize(void* module, std::uint64_t configuredEquipmentHash) noexcept {
     };
     if (!constants::replace(cachedConstants) || !content::replace(domains.named) || !content::seal()
         || !items::replace(domains.items) || !collectibles::replace(domains.collectibles)
+        || !rewards::replace({domains.rewardPools,
+                              domains.rewardEntries,
+                              domains.rewardItems,
+                              domains.rewardInstructions,
+                              domains.rewardModifiers,
+                              domains.rewardSockets})
         || !material_requirements::replace(domains.materialRequirementSets)
         || !inventory::buckets::replace(domains.inventoryBuckets)
         || !socket_entry_lists::replace(domains.socketEntryLists)
@@ -130,8 +137,7 @@ bool initialize(void* module, std::uint64_t configuredEquipmentHash) noexcept {
         || !catalystsReplaced || !abilities::replace(domains.abilityBuckets)
         || !progressions::replace(domains.progressions, domains.progressionSteps)
         // An empty catalog is complete: a build with no installed pass declares no reward.
-        || (!domains.seasonPassRewards.empty()
-            && !season_pass::replace(domains.seasonPassRewards, domains.seasonPassPackages))
+        || (!domains.seasonPassRewards.empty() && !season_pass::replace(domains.seasonPassRewards))
         || !bounties::replace(domains.bounties)
         || !records::replace(domains.records,
                              domains.recordObjectives,

+ 13 - 3
Sunrise/src/state/build_data/cache/read/cache_file_reader.cpp

@@ -53,8 +53,13 @@ namespace {
            && counts.recordRewards <= output.recordRewards.size()
            && counts.progressionSteps <= output.progressionSteps.size()
            && counts.seasonPassRewards <= output.seasonPassRewards.size()
-           && counts.seasonPassPackages <= output.seasonPassPackages.size()
-           && counts.bounties <= output.bounties.size();
+           && counts.bounties <= output.bounties.size()
+           && counts.rewardPools <= output.rewardPools.size()
+           && counts.rewardEntries <= output.rewardEntries.size()
+           && counts.rewardItems <= output.rewardItems.size()
+           && counts.rewardInstructions <= output.rewardInstructions.size()
+           && counts.rewardModifiers <= output.rewardModifiers.size()
+           && counts.rewardSockets <= output.rewardSockets.size();
 }
 
 /** @return The header's row counts, as platform sizes. */
@@ -94,8 +99,13 @@ namespace {
         header.recordRewardCount,
         header.progressionStepCount,
         header.seasonPassRewardCount,
-        header.seasonPassPackageCount,
         header.bountyCount,
+        header.rewardPoolCount,
+        header.rewardEntryCount,
+        header.rewardItemCount,
+        header.rewardInstructionCount,
+        header.rewardModifierCount,
+        header.rewardSocketCount,
     };
 }
 

+ 39 - 8
Sunrise/src/state/build_data/cache/read/cache_payload_reader.cpp

@@ -123,9 +123,14 @@ void clear(records::MutableDomains output) noexcept {
     std::fill(output.progressionSteps.begin(), output.progressionSteps.end(), progressions::Step{});
     std::fill(
         output.seasonPassRewards.begin(), output.seasonPassRewards.end(), season_pass::Reward{});
-    std::fill(
-        output.seasonPassPackages.begin(), output.seasonPassPackages.end(), season_pass::Package{});
     std::fill(output.bounties.begin(), output.bounties.end(), bounties::Definition{});
+    std::fill(output.rewardPools.begin(), output.rewardPools.end(), rewards::Pool{});
+    std::fill(output.rewardEntries.begin(), output.rewardEntries.end(), rewards::Entry{});
+    std::fill(output.rewardItems.begin(), output.rewardItems.end(), rewards::Item{});
+    std::fill(
+        output.rewardInstructions.begin(), output.rewardInstructions.end(), rewards::Instruction{});
+    std::fill(output.rewardModifiers.begin(), output.rewardModifiers.end(), rewards::Modifier{});
+    std::fill(output.rewardSockets.begin(), output.rewardSockets.end(), rewards::SocketOverride{});
 }
 
 /** Computes the exact file size for every record array. */
@@ -167,8 +172,13 @@ bool expected_size(const records::DomainCounts& counts, std::uint64_t& size) noe
            && add_records(counts.recordRewards, sizeof(records::RecordRewardRecord), size)
            && add_records(counts.progressionSteps, sizeof(records::ProgressionStepRecord), size)
            && add_records(counts.seasonPassRewards, sizeof(records::SeasonPassRewardRecord), size)
-           && add_records(counts.seasonPassPackages, sizeof(records::SeasonPassPackageRecord), size)
-           && add_records(counts.bounties, sizeof(records::BountyRecord), size);
+           && add_records(counts.bounties, sizeof(records::BountyRecord), size)
+           && add_records(counts.rewardPools, sizeof(records::RewardPoolRecord), size)
+           && add_records(counts.rewardEntries, sizeof(records::RewardEntryRecord), size)
+           && add_records(counts.rewardItems, sizeof(records::RewardItemRecord), size)
+           && add_records(counts.rewardInstructions, sizeof(records::RewardInstructionRecord), size)
+           && add_records(counts.rewardModifiers, sizeof(records::RewardModifierRecord), size)
+           && add_records(counts.rewardSockets, sizeof(records::RewardSocketOverrideRecord), size);
 }
 
 /** Reads every payload array and checks the decoded domains as one transaction. */
@@ -282,12 +292,28 @@ bool read_payload(HANDLE file,
     valid = valid
             && read_domain<records::SeasonPassRewardRecord>(
                 file, output.seasonPassRewards.first(counts.seasonPassRewards), checksum);
-    valid = valid
-            && read_domain<records::SeasonPassPackageRecord>(
-                file, output.seasonPassPackages.first(counts.seasonPassPackages), checksum);
     valid = valid
             && read_domain<records::BountyRecord>(
                 file, output.bounties.first(counts.bounties), checksum);
+    valid = valid
+            && read_domain<records::RewardPoolRecord>(
+                file, output.rewardPools.first(counts.rewardPools), checksum);
+    valid = valid
+            && read_domain<records::RewardEntryRecord>(
+                file, output.rewardEntries.first(counts.rewardEntries), checksum);
+    valid = valid
+            && read_domain<records::RewardItemRecord>(
+                file, output.rewardItems.first(counts.rewardItems), checksum);
+    valid = valid
+            && read_domain<records::RewardInstructionRecord>(
+                file, output.rewardInstructions.first(counts.rewardInstructions), checksum);
+    valid = valid
+            && read_domain<records::RewardModifierRecord>(
+                file, output.rewardModifiers.first(counts.rewardModifiers), checksum);
+    valid = valid
+            && read_domain<records::RewardSocketOverrideRecord>(
+                file, output.rewardSockets.first(counts.rewardSockets), checksum);
+
     if (!valid) {
         return false;
     }
@@ -330,8 +356,13 @@ bool read_payload(HANDLE file,
             output.recordRewards.first(counts.recordRewards),
             output.progressionSteps.first(counts.progressionSteps),
             output.seasonPassRewards.first(counts.seasonPassRewards),
-            output.seasonPassPackages.first(counts.seasonPassPackages),
             output.bounties.first(counts.bounties),
+            output.rewardPools.first(counts.rewardPools),
+            output.rewardEntries.first(counts.rewardEntries),
+            output.rewardItems.first(counts.rewardItems),
+            output.rewardInstructions.first(counts.rewardInstructions),
+            output.rewardModifiers.first(counts.rewardModifiers),
+            output.rewardSockets.first(counts.rewardSockets),
         });
 }
 

+ 37 - 5
Sunrise/src/state/build_data/cache/records/cache_domain_validation.cpp

@@ -13,6 +13,7 @@
 #include "../../nodes/node_catalog.h"
 #include "../../progressions/progression_catalog.h"
 #include "../../records/record_catalog.h"
+#include "../../rewards/reward_catalog.h"
 #include "../../scenarios/scenario_catalog.h"
 #include "../../season_pass/season_pass_catalog.h"
 #include "../../sobjects/sobject_catalog.h"
@@ -169,8 +170,13 @@ template <typename Value, typename Less>
            && counts.recordRewards <= domains.recordRewards.size()
            && counts.progressionSteps <= domains.progressionSteps.size()
            && counts.seasonPassRewards <= domains.seasonPassRewards.size()
-           && counts.seasonPassPackages <= domains.seasonPassPackages.size()
-           && counts.bounties <= domains.bounties.size();
+           && counts.bounties <= domains.bounties.size()
+           && counts.rewardPools <= domains.rewardPools.size()
+           && counts.rewardEntries <= domains.rewardEntries.size()
+           && counts.rewardItems <= domains.rewardItems.size()
+           && counts.rewardInstructions <= domains.rewardInstructions.size()
+           && counts.rewardModifiers <= domains.rewardModifiers.size()
+           && counts.rewardSockets <= domains.rewardSockets.size();
 }
 
 } // namespace
@@ -236,6 +242,12 @@ bool valid_domains(const BuildIdentity& build, Domains domains) noexcept {
         || !strictly_ordered(domains.named, named_less) || !items::valid(domains.items)
         || !collectibles::valid(domains.collectibles)
         || !strictly_ordered(domains.collectibles, collectible_less)
+        || !rewards::valid({domains.rewardPools,
+                            domains.rewardEntries,
+                            domains.rewardItems,
+                            domains.rewardInstructions,
+                            domains.rewardModifiers,
+                            domains.rewardSockets})
         || !material_requirements::valid(domains.materialRequirementSets)
         || !strictly_ordered(domains.materialRequirementSets, material_requirement_less)
         || !inventory::buckets::valid(domains.inventoryBuckets)
@@ -249,8 +261,7 @@ bool valid_domains(const BuildIdentity& build, Domains domains) noexcept {
         || !strictly_ordered(domains.abilityBuckets, ability_less)
         || !progressions::valid(domains.progressions, domains.progressionSteps)
         // An empty catalog is complete: a build with no installed pass declares no reward.
-        || (!domains.seasonPassRewards.empty()
-            && !season_pass::valid(domains.seasonPassRewards, domains.seasonPassPackages))
+        || (!domains.seasonPassRewards.empty() && !season_pass::valid(domains.seasonPassRewards))
         || !bounties::valid(domains.bounties)
         || !build_data::records::valid(domains.records,
                                        domains.recordObjectives,
@@ -276,11 +287,32 @@ bool valid_domains(const BuildIdentity& build, Domains domains) noexcept {
         || !hash_names::valid(domains.hashNames)) {
         return false;
     }
+    // Reward rows are dense by item index, so each must name its item or be unavailable.
+    if (domains.rewardItems.size() != domains.items.size()) {
+        return false;
+    }
     for (std::size_t index = 0; index < domains.items.size(); ++index) {
-        if (domains.items[index].definitionIndex != index) {
+        if (domains.items[index].definitionIndex != index
+            || (domains.rewardItems[index].definitionHash != 0
+                && domains.rewardItems[index].definitionHash
+                       != domains.items[index].definitionHash)) {
             return false;
         }
     }
+    for (const auto& reward : domains.seasonPassRewards) {
+        if (reward.itemHash == 0) {
+            continue;
+        }
+        if (reward.itemIndex >= domains.items.size()
+            || domains.items[reward.itemIndex].definitionHash != reward.itemHash) {
+            return false;
+        }
+        for (std::size_t i = 0; i < reward.socketCount; ++i) {
+            if (!rewards::valid_socket(reward.sockets[i], domains.items.size())) {
+                return false;
+            }
+        }
+    }
     for (const material_requirements::Definition& definition : domains.materialRequirementSets) {
         for (std::size_t index = 0; index < definition.requirementCount; ++index) {
             const std::uint16_t itemIndex = definition.requirements[index].itemDefinitionIndex;

+ 54 - 24
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. */
@@ -98,13 +116,37 @@ bool encode(const season_pass::Reward& value, SeasonPassRewardRecord& record) no
     record.itemIndex = value.itemIndex;
     record.claimFlagIndex = value.claimFlagIndex;
     record.requiredRank = value.requiredRank;
+    record.socketCount = value.socketCount;
+    record.conditionCount = value.conditionCount;
+    if (record.conditionCount > record.condition.size()
+        || record.socketCount > record.sockets.size()) {
+        return false;
+    }
+    for (std::size_t i = 0; i < record.condition.size(); ++i) {
+        if (!encode(value.condition[i], record.condition[i])) {
+            return false;
+        }
+    }
+    for (std::size_t i = 0; i < record.sockets.size(); ++i) {
+        if (!encode(value.sockets[i], record.sockets[i])) {
+            return false;
+        }
+    }
     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;
@@ -112,30 +154,18 @@ bool decode(const SeasonPassRewardRecord& record, season_pass::Reward& value) no
     value.itemIndex = record.itemIndex;
     value.claimFlagIndex = record.claimFlagIndex;
     value.requiredRank = record.requiredRank;
-    return true;
-}
-
-/** Encodes one season pass wrapper with its unused item slots zeroed. */
-bool encode(const season_pass::Package& value, SeasonPassPackageRecord& record) noexcept {
-    if (value.itemCount > value.items.size()) {
-        return false;
+    value.socketCount = record.socketCount;
+    value.conditionCount = record.conditionCount;
+    for (std::size_t i = 0; i < value.conditionCount; ++i) {
+        if (!decode(record.condition[i], value.condition[i])) {
+            return false;
+        }
     }
-    record = {};
-    record.definitionHash = value.definitionHash;
-    record.items = value.items;
-    record.itemCount = value.itemCount;
-    return true;
-}
-
-/** Decodes one season pass wrapper after checking its padding and item count. */
-bool decode(const SeasonPassPackageRecord& record, season_pass::Package& value) noexcept {
-    value = {};
-    if (record.reserved != decltype(record.reserved){} || record.itemCount > record.items.size()) {
-        return false;
+    for (std::size_t i = 0; i < value.socketCount; ++i) {
+        if (!decode(record.sockets[i], value.sockets[i])) {
+            return false;
+        }
     }
-    value.definitionHash = record.definitionHash;
-    value.items = record.items;
-    value.itemCount = record.itemCount;
     return true;
 }
 

+ 163 - 0
Sunrise/src/state/build_data/cache/records/cache_reward_records.cpp

@@ -0,0 +1,163 @@
+#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 {
+    record = {};
+    record.definitionHash = value.definitionHash;
+    record.entries = {value.entries.first, value.entries.count};
+    return true;
+}
+
+/** Decodes one pool; the complete-domain validator checks its member range. */
+bool decode(const RewardPoolRecord& record, rewards::Pool& value) noexcept {
+    value = {};
+    value.definitionHash = record.definitionHash;
+    value.entries = {record.entries.first, record.entries.count};
+    return true;
+}
+
+/** Encodes one pool entry with its dependent bank ranges. */
+bool encode(const rewards::Entry& value, RewardEntryRecord& record) noexcept {
+    record = {};
+    record.itemIndex = value.itemIndex;
+    record.poolIndex = value.poolIndex;
+    record.supplementalIndex = value.supplementalIndex;
+    record.supplementalMissing = static_cast<std::uint8_t>(value.supplementalMissing);
+    record.quantity = value.quantity;
+    record.categoryHash = value.categoryHash;
+    record.weight = value.weight;
+    record.condition = {value.condition.first, value.condition.count};
+    record.modifiers = {value.modifiers.first, value.modifiers.count};
+    record.sockets = {value.sockets.first, value.sockets.count};
+    return true;
+}
+
+/** Decodes one pool entry, refusing an absent-bank marker without a supplemental reference. */
+bool decode(const RewardEntryRecord& record, rewards::Entry& value) noexcept {
+    value = {};
+    if (record.supplementalMissing > 1
+        || (record.supplementalMissing != 0 && record.supplementalIndex == rewards::kAbsent)) {
+        return false;
+    }
+    value.itemIndex = record.itemIndex;
+    value.poolIndex = record.poolIndex;
+    value.supplementalIndex = record.supplementalIndex;
+    value.supplementalMissing = record.supplementalMissing != 0;
+    value.quantity = record.quantity;
+    value.categoryHash = record.categoryHash;
+    value.weight = record.weight;
+    value.condition = {record.condition.first, record.condition.count};
+    value.modifiers = {record.modifiers.first, record.modifiers.count};
+    value.sockets = {record.sockets.first, record.sockets.count};
+    return true;
+}
+
+/** Encodes one item's wrapper and acquisition flag. */
+bool encode(const rewards::Item& value, RewardItemRecord& record) noexcept {
+    record = {};
+    record.definitionHash = value.definitionHash;
+    record.poolIndex = value.poolIndex;
+    record.acquiredFlag = value.acquiredFlag;
+    for (std::size_t i = 0; i < record.selections.size(); ++i) {
+        record.selections[i] = {value.selections[i].categoryHash, value.selections[i].count};
+    }
+    record.selectionCount = value.selectionCount;
+    record.flags = value.flags;
+    return true;
+}
+
+/** 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 < record.selectionCount; ++i) {
+        value.selections[i] = {record.selections[i].categoryHash, record.selections[i].count};
+    }
+    value.selectionCount = record.selectionCount;
+    value.flags = record.flags;
+    return true;
+}
+
+/** Encodes one bound condition instruction. */
+bool encode(const rewards::Instruction& value, RewardInstructionRecord& record) noexcept {
+    record = {};
+    record.opcode = static_cast<std::uint8_t>(value.opcode);
+    record.bank = static_cast<std::uint8_t>(value.bank);
+    record.operand = value.operand;
+    return true;
+}
+
+/** Decodes one bound condition instruction, refusing an unknown opcode or bank. */
+bool decode(const RewardInstructionRecord& record, rewards::Instruction& value) noexcept {
+    value = {};
+    if (record.reserved != 0 || !unlocks::decode_opcode(record.opcode, value.opcode)) {
+        return false;
+    }
+    value.bank = static_cast<unlocks::Bank>(record.bank);
+    value.operand = record.operand;
+    return unlocks::valid(value);
+}
+
+/** Encodes one conditional weight modifier. */
+bool encode(const rewards::Modifier& value, RewardModifierRecord& record) noexcept {
+    record = {};
+    record.condition = {value.condition.first, value.condition.count};
+    record.valueIndex = value.valueIndex;
+    record.value = value.value;
+    return true;
+}
+
+/** Decodes one weight modifier; the complete-domain validator checks its condition range. */
+bool decode(const RewardModifierRecord& record, rewards::Modifier& value) noexcept {
+    value = {};
+    value.condition = {record.condition.first, record.condition.count};
+    value.valueIndex = record.valueIndex;
+    value.value = record.value;
+    return true;
+}
+
+/** Encodes one socket override. */
+bool encode(const rewards::SocketOverride& value, RewardSocketOverrideRecord& record) noexcept {
+    record = {};
+    record.socketType = value.socketType;
+    record.plugItem = value.plugItem;
+    record.plugSet = value.plugSet;
+    record.rollSet = value.rollSet;
+    record.selection = value.selection;
+    return true;
+}
+
+/** Decodes one socket override; the complete-domain validator checks its plug reference. */
+bool decode(const RewardSocketOverrideRecord& record, rewards::SocketOverride& value) noexcept {
+    value = {};
+    value.socketType = record.socketType;
+    value.plugItem = record.plugItem;
+    value.plugSet = record.plugSet;
+    value.rollSet = record.rollSet;
+    value.selection = record.selection;
+    return true;
+}
+
+} // namespace sunrise::state::build_data::cache::records

+ 17 - 5
Sunrise/src/state/build_data/cache/records/codec.h

@@ -95,11 +95,6 @@ namespace sunrise::state::build_data::cache::records {
 [[nodiscard]] bool decode(const SeasonPassRewardRecord& record,
                           season_pass::Reward& value) noexcept;
 
-[[nodiscard]] bool encode(const season_pass::Package& value,
-                          SeasonPassPackageRecord& record) noexcept;
-[[nodiscard]] bool decode(const SeasonPassPackageRecord& record,
-                          season_pass::Package& value) noexcept;
-
 [[nodiscard]] bool encode(const bounties::Definition& value, BountyRecord& record) noexcept;
 [[nodiscard]] bool decode(const BountyRecord& record, bounties::Definition& value) noexcept;
 
@@ -165,4 +160,21 @@ namespace sunrise::state::build_data::cache::records {
 [[nodiscard]] bool decode(const VendorInstalledRowRecord& record,
                           vendors::InstalledRow& value) noexcept;
 
+[[nodiscard]] bool encode(const rewards::Pool& value, RewardPoolRecord& record) noexcept;
+[[nodiscard]] bool decode(const RewardPoolRecord& record, rewards::Pool& value) noexcept;
+[[nodiscard]] bool encode(const rewards::Entry& value, RewardEntryRecord& record) noexcept;
+[[nodiscard]] bool decode(const RewardEntryRecord& record, rewards::Entry& value) noexcept;
+[[nodiscard]] bool encode(const rewards::Item& value, RewardItemRecord& record) noexcept;
+[[nodiscard]] bool decode(const RewardItemRecord& record, rewards::Item& value) noexcept;
+[[nodiscard]] bool encode(const rewards::Instruction& value,
+                          RewardInstructionRecord& record) noexcept;
+[[nodiscard]] bool decode(const RewardInstructionRecord& record,
+                          rewards::Instruction& value) noexcept;
+[[nodiscard]] bool encode(const rewards::Modifier& value, RewardModifierRecord& record) noexcept;
+[[nodiscard]] bool decode(const RewardModifierRecord& record, rewards::Modifier& value) noexcept;
+[[nodiscard]] bool encode(const rewards::SocketOverride& value,
+                          RewardSocketOverrideRecord& record) noexcept;
+[[nodiscard]] bool decode(const RewardSocketOverrideRecord& record,
+                          rewards::SocketOverride& value) noexcept;
+
 } // namespace sunrise::state::build_data::cache::records

+ 19 - 3
Sunrise/src/state/build_data/cache/records/domains.h

@@ -19,6 +19,7 @@
 #include "../../nodes/definition.h"
 #include "../../progressions/definition.h"
 #include "../../records/definition.h"
+#include "../../rewards/definition.h"
 #include "../../scenarios/definition.h"
 #include "../../season_pass/definition.h"
 #include "../../sobjects/sobject_catalog.h"
@@ -65,8 +66,13 @@ struct DomainCounts {
     std::size_t recordRewards{};
     std::size_t progressionSteps{};
     std::size_t seasonPassRewards{};
-    std::size_t seasonPassPackages{};
     std::size_t bounties{};
+    std::size_t rewardPools{};
+    std::size_t rewardEntries{};
+    std::size_t rewardItems{};
+    std::size_t rewardInstructions{};
+    std::size_t rewardModifiers{};
+    std::size_t rewardSockets{};
 };
 
 /** Fixed caller storage used while decoding the cache domains. */
@@ -108,8 +114,13 @@ struct MutableDomains {
     std::span<build_data::records::Reward> recordRewards;
     std::span<progressions::Step> progressionSteps;
     std::span<season_pass::Reward> seasonPassRewards;
-    std::span<season_pass::Package> seasonPassPackages;
     std::span<bounties::Definition> bounties;
+    std::span<rewards::Pool> rewardPools;
+    std::span<rewards::Entry> rewardEntries;
+    std::span<rewards::Item> rewardItems;
+    std::span<rewards::Instruction> rewardInstructions;
+    std::span<rewards::Modifier> rewardModifiers;
+    std::span<rewards::SocketOverride> rewardSockets;
 };
 
 /** Read-only complete views used for the checks and for cache encoding. */
@@ -150,8 +161,13 @@ struct Domains {
     std::span<const build_data::records::Reward> recordRewards;
     std::span<const progressions::Step> progressionSteps;
     std::span<const season_pass::Reward> seasonPassRewards;
-    std::span<const season_pass::Package> seasonPassPackages;
     std::span<const bounties::Definition> bounties;
+    std::span<const rewards::Pool> rewardPools;
+    std::span<const rewards::Entry> rewardEntries;
+    std::span<const rewards::Item> rewardItems;
+    std::span<const rewards::Instruction> rewardInstructions;
+    std::span<const rewards::Modifier> rewardModifiers;
+    std::span<const rewards::SocketOverride> rewardSockets;
 };
 
 } // namespace sunrise::state::build_data::cache::records

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

@@ -20,6 +20,7 @@
 #include "../../nodes/definition.h"
 #include "../../progressions/definition.h"
 #include "../../records/definition.h"
+#include "../../rewards/definition.h"
 #include "../../scenarios/definition.h"
 #include "../../season_pass/definition.h"
 #include "../../sobjects/sobject_catalog.h"
@@ -31,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 = 67;
+inline constexpr std::uint32_t kCacheFormatVersion = 73;
 /** 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. */
@@ -103,8 +104,13 @@ struct Header {
     std::uint32_t recordRewardCount{};
     std::uint32_t progressionStepCount{};
     std::uint32_t seasonPassRewardCount{};
-    std::uint32_t seasonPassPackageCount{};
     std::uint32_t bountyCount{};
+    std::uint32_t rewardPoolCount{};
+    std::uint32_t rewardEntryCount{};
+    std::uint32_t rewardItemCount{};
+    std::uint32_t rewardInstructionCount{};
+    std::uint32_t rewardModifierCount{};
+    std::uint32_t rewardSocketCount{};
     gameplay::entity_position_profiles::Fingerprint positionFingerprint{};
     InvestmentConstants constants{};
     std::uint64_t payloadChecksum{};
@@ -303,6 +309,24 @@ struct ProgressionStepRecord {
     std::int32_t cost{};
 };
 
+/** Disk form of one reward socket override. */
+struct RewardSocketOverrideRecord {
+    std::uint16_t socketType{};
+    std::uint16_t plugItem{};
+    std::uint16_t plugSet{};
+    std::uint16_t rollSet{};
+    std::uint32_t selection{};
+};
+
+/** One normalized reward-condition instruction. */
+struct RewardInstructionRecord {
+    std::uint8_t opcode{};
+    std::uint8_t bank{};
+    /** Must be zero, so the packed instruction always matches. */
+    std::uint16_t reserved{};
+    std::uint32_t operand{};
+};
+
 /** Disk form of one season pass reward row. */
 struct SeasonPassRewardRecord {
     std::uint32_t itemHash{};
@@ -310,19 +334,14 @@ struct SeasonPassRewardRecord {
     std::uint16_t itemIndex{};
     std::uint16_t claimFlagIndex{season_pass::kUnavailableFlagIndex};
     std::uint8_t requiredRank{};
+    std::array<RewardSocketOverrideRecord, rewards::kSocketsPerItem> sockets{};
+    std::uint8_t socketCount{};
+    std::array<RewardInstructionRecord, season_pass::kConditionCapacity> condition{};
+    std::uint8_t conditionCount{};
     /** Must be zero, so the packed reward row always matches. */
     std::array<std::uint8_t, 3> reserved{};
 };
 
-/** Disk form of one season pass wrapper item and the set it opens into. */
-struct SeasonPassPackageRecord {
-    std::uint32_t definitionHash{};
-    std::array<std::uint32_t, season_pass::kPackageItemCapacity> items{};
-    std::uint8_t itemCount{};
-    /** Must be zero, so the packed wrapper row always matches. */
-    std::array<std::uint8_t, 3> reserved{};
-};
-
 /** Disk form of one repeatable bounty and the item-type its pool is keyed by. */
 struct BountyRecord {
     std::uint32_t itemTypeBank{};
@@ -584,13 +603,78 @@ struct RosterGroupRecord {
     std::array<std::uint16_t, scenarios::kRosterSlotCapacity> slotIndices{};
 };
 
+/** One declared category selection in a reward wrapper. */
+struct RewardSelectionRecord {
+    std::uint32_t categoryHash{};
+    std::uint32_t count{};
+};
+
+/** Offset and count into one reward bank. */
+struct RewardRangeRecord {
+    std::uint32_t first{};
+    std::uint32_t count{};
+};
+
+/** Pool identity and its contiguous member range. */
+struct RewardPoolRecord {
+    std::uint32_t definitionHash{};
+    RewardRangeRecord entries{};
+};
+
+/** Reward target, quantity, weight and its dependent bank ranges. */
+struct RewardEntryRecord {
+    std::uint16_t itemIndex{};
+    std::uint16_t poolIndex{};
+    std::uint16_t supplementalIndex{};
+    std::uint8_t supplementalMissing{};
+    std::uint32_t quantity{};
+    std::uint32_t categoryHash{};
+    float weight{};
+    RewardRangeRecord condition{};
+    RewardRangeRecord modifiers{};
+    RewardRangeRecord sockets{};
+};
+
+/** Native item-to-pool relation and acquisition flag. */
+struct RewardItemRecord {
+    std::uint32_t definitionHash{};
+    std::uint16_t poolIndex{};
+    std::uint16_t acquiredFlag{};
+    std::array<RewardSelectionRecord, rewards::kSelectionCapacity> selections{};
+    std::uint8_t selectionCount{};
+    std::uint32_t flags{};
+};
+
+/** Conditional replacement weight or indexed value reference. */
+struct RewardModifierRecord {
+    RewardRangeRecord condition{};
+    std::uint16_t valueIndex{};
+    float value{};
+};
+
 #pragma pack(pop)
 
+static_assert(sizeof(RewardSelectionRecord) == 2 * sizeof(std::uint32_t));
+static_assert(sizeof(RewardRangeRecord) == 2 * sizeof(std::uint32_t));
+static_assert(sizeof(RewardPoolRecord) == sizeof(std::uint32_t) + sizeof(RewardRangeRecord));
+static_assert(sizeof(RewardEntryRecord)
+              == 3 * sizeof(std::uint16_t) + 2 * sizeof(std::uint32_t) + sizeof(float)
+                     + sizeof(std::uint8_t) + 3 * sizeof(RewardRangeRecord));
+static_assert(sizeof(RewardItemRecord)
+              == 2 * sizeof(std::uint32_t) + 2 * sizeof(std::uint16_t) + sizeof(std::uint8_t)
+                     + rewards::kSelectionCapacity * sizeof(RewardSelectionRecord));
+static_assert(sizeof(RewardModifierRecord)
+              == sizeof(RewardRangeRecord) + sizeof(std::uint16_t) + sizeof(float));
+static_assert(sizeof(RewardSocketOverrideRecord)
+              == 4 * sizeof(std::uint16_t) + sizeof(std::uint32_t));
+static_assert(sizeof(RewardInstructionRecord)
+              == 2 * sizeof(std::uint8_t) + sizeof(std::uint16_t) + sizeof(std::uint32_t));
+
 static_assert(sizeof(Prefix) == kCacheMagic.size() + sizeof(std::uint32_t));
 static_assert(sizeof(InvestmentConstants)
               == constants::kCharacterStatRowCount + 3 * sizeof(std::uint8_t));
 static_assert(sizeof(Header)
-              == kCacheMagic.size() + 39 * sizeof(std::uint32_t) + 3 * sizeof(std::uint64_t)
+              == kCacheMagic.size() + 44 * sizeof(std::uint32_t) + 3 * sizeof(std::uint64_t)
                      + sizeof(InvestmentConstants)
                      + sizeof(gameplay::entity_position_profiles::Fingerprint));
 static_assert(sizeof(SpawnPointRecord)
@@ -631,10 +715,9 @@ static_assert(sizeof(RosterGroupRecord)
 static_assert(sizeof(ProgressionRecord) == 2 * sizeof(std::uint16_t) + 2 * sizeof(std::uint8_t));
 static_assert(sizeof(ProgressionStepRecord) == sizeof(std::int32_t));
 static_assert(sizeof(SeasonPassRewardRecord)
-              == 2 * sizeof(std::uint32_t) + 2 * sizeof(std::uint16_t) + 4 * sizeof(std::uint8_t));
-static_assert(sizeof(SeasonPassPackageRecord)
-              == (1 + season_pass::kPackageItemCapacity) * sizeof(std::uint32_t)
-                     + 4 * sizeof(std::uint8_t));
+              == 2 * sizeof(std::uint32_t) + 2 * sizeof(std::uint16_t) + 6 * sizeof(std::uint8_t)
+                     + rewards::kSocketsPerItem * sizeof(RewardSocketOverrideRecord)
+                     + season_pass::kConditionCapacity * sizeof(RewardInstructionRecord));
 static_assert(sizeof(BountyRecord) == 2 * sizeof(std::uint32_t) + 2 * sizeof(std::uint16_t));
 static_assert(sizeof(RecordDefinitionRecord)
               == sizeof(std::uint32_t) + 10 * sizeof(std::uint16_t) + 5 * sizeof(std::uint8_t));

+ 7 - 2
Sunrise/src/state/build_data/cache/write/cache_file_writer.cpp

@@ -48,8 +48,13 @@ namespace {
            && domains.recordRewards.size() <= kMaximumCount
            && domains.progressionSteps.size() <= kMaximumCount
            && domains.seasonPassRewards.size() <= kMaximumCount
-           && domains.seasonPassPackages.size() <= kMaximumCount
-           && domains.bounties.size() <= kMaximumCount;
+           && domains.bounties.size() <= kMaximumCount
+           && domains.rewardPools.size() <= kMaximumCount
+           && domains.rewardEntries.size() <= kMaximumCount
+           && domains.rewardItems.size() <= kMaximumCount
+           && domains.rewardInstructions.size() <= kMaximumCount
+           && domains.rewardModifiers.size() <= kMaximumCount
+           && domains.rewardSockets.size() <= kMaximumCount;
 }
 
 /** @return True when the requested final-name rule is one of the declared values. */

+ 14 - 4
Sunrise/src/state/build_data/cache/write/cache_payload_writer.cpp

@@ -101,9 +101,14 @@ bool payload_checksum(records::Domains domains, std::uint64_t& checksum) noexcep
            && checksum_domain<records::RecordRewardRecord>(domains.recordRewards, checksum)
            && checksum_domain<records::ProgressionStepRecord>(domains.progressionSteps, checksum)
            && checksum_domain<records::SeasonPassRewardRecord>(domains.seasonPassRewards, checksum)
-           && checksum_domain<records::SeasonPassPackageRecord>(domains.seasonPassPackages,
+           && checksum_domain<records::BountyRecord>(domains.bounties, checksum)
+           && checksum_domain<records::RewardPoolRecord>(domains.rewardPools, checksum)
+           && checksum_domain<records::RewardEntryRecord>(domains.rewardEntries, checksum)
+           && checksum_domain<records::RewardItemRecord>(domains.rewardItems, checksum)
+           && checksum_domain<records::RewardInstructionRecord>(domains.rewardInstructions,
                                                                 checksum)
-           && checksum_domain<records::BountyRecord>(domains.bounties, checksum);
+           && checksum_domain<records::RewardModifierRecord>(domains.rewardModifiers, checksum)
+           && checksum_domain<records::RewardSocketOverrideRecord>(domains.rewardSockets, checksum);
 }
 
 /** Writes every array in the same order used by the payload checksum. */
@@ -143,8 +148,13 @@ bool write_payload(HANDLE file, records::Domains domains) noexcept {
            && write_domain<records::RecordRewardRecord>(file, domains.recordRewards)
            && write_domain<records::ProgressionStepRecord>(file, domains.progressionSteps)
            && write_domain<records::SeasonPassRewardRecord>(file, domains.seasonPassRewards)
-           && write_domain<records::SeasonPassPackageRecord>(file, domains.seasonPassPackages)
-           && write_domain<records::BountyRecord>(file, domains.bounties);
+           && write_domain<records::BountyRecord>(file, domains.bounties)
+           && write_domain<records::RewardPoolRecord>(file, domains.rewardPools)
+           && write_domain<records::RewardEntryRecord>(file, domains.rewardEntries)
+           && write_domain<records::RewardItemRecord>(file, domains.rewardItems)
+           && write_domain<records::RewardInstructionRecord>(file, domains.rewardInstructions)
+           && write_domain<records::RewardModifierRecord>(file, domains.rewardModifiers)
+           && write_domain<records::RewardSocketOverrideRecord>(file, domains.rewardSockets);
 }
 
 } // namespace sunrise::state::build_data::cache::writer

+ 6 - 1
Sunrise/src/state/build_data/cache/write/temporary/temporary_cache_file.cpp

@@ -141,8 +141,13 @@ enum class WriteStatus {
         static_cast<std::uint32_t>(domains.recordRewards.size()),
         static_cast<std::uint32_t>(domains.progressionSteps.size()),
         static_cast<std::uint32_t>(domains.seasonPassRewards.size()),
-        static_cast<std::uint32_t>(domains.seasonPassPackages.size()),
         static_cast<std::uint32_t>(domains.bounties.size()),
+        static_cast<std::uint32_t>(domains.rewardPools.size()),
+        static_cast<std::uint32_t>(domains.rewardEntries.size()),
+        static_cast<std::uint32_t>(domains.rewardItems.size()),
+        static_cast<std::uint32_t>(domains.rewardInstructions.size()),
+        static_cast<std::uint32_t>(domains.rewardModifiers.size()),
+        static_cast<std::uint32_t>(domains.rewardSockets.size()),
         domains.positionFingerprint,
         domains.constants,
         checksum,

+ 5 - 0
Sunrise/src/state/build_data/inventory/buckets/definition.h

@@ -25,6 +25,11 @@ inline constexpr std::uint32_t kProfileSlotCapacity = 701;
 inline constexpr std::uint32_t kSmallProfileSlotCapacity = 6;
 /** An all-one bucket id means no runtime bucket is available. */
 inline constexpr std::uint8_t kUnavailableBucketId = (std::numeric_limits<std::uint8_t>::max)();
+/**
+ * Season Pass perks share this 5-slot character bucket with quest roots. A reward item here that
+ * carries an acquisition flag is granted by setting that flag rather than as an inventory row.
+ */
+inline constexpr std::uint8_t kPerkBucketId = 37;
 /** Leaving out the unavailable id leaves at most 255 unique bucket records. */
 inline constexpr std::size_t kDescriptorCapacity = kUnavailableBucketId;
 /** Signed -1 marks a bucket that has no equipment slot. */

+ 121 - 0
Sunrise/src/state/build_data/rewards/definition.h

@@ -0,0 +1,121 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+#include "../../unlocks/unlocks_expression.h"
+#include "../items/details/definition.h"
+#include "../items/item_catalog.h"
+
+namespace sunrise::state::build_data::rewards {
+
+/** Native references use all bits set for an absent row. */
+inline constexpr std::uint16_t kAbsent = 0xFFFF;
+/** The shipped build declares 979 pools. The domain leaves room above that. */
+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 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. */
+inline constexpr std::size_t kSelectionCapacity = 4;
+/** 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, automatic wrappers and referenced conditions share one traversal depth. The
+ * installed graph reaches 7.
+ */
+inline constexpr std::size_t kTraversalDepth = 32;
+
+struct Range {
+    std::uint32_t first{};
+    std::uint32_t count{};
+};
+
+/** Reward and pass conditions are unlock expressions bound to their saved banks. */
+using Instruction = unlocks::Instruction;
+
+/** A fixed override names its plug directly rather than selecting from a set. */
+inline constexpr std::uint32_t kFixedPlugSelection = 0xFFFFFFFFU;
+/** Wrappers without this bit remain inventory items until explicitly opened. */
+inline constexpr std::uint32_t kOpenOnAcquisition = 1;
+
+struct SocketOverride {
+    std::uint16_t socketType{kAbsent};
+    std::uint16_t plugItem{kAbsent};
+    std::uint16_t plugSet{kAbsent};
+    std::uint16_t rollSet{kAbsent};
+    std::uint32_t selection{kFixedPlugSelection};
+};
+
+struct Modifier {
+    Range condition{};
+    std::uint16_t valueIndex{kAbsent};
+    float value{};
+};
+
+struct Entry {
+    std::uint16_t itemIndex{kAbsent};
+    std::uint16_t poolIndex{kAbsent};
+    std::uint16_t supplementalIndex{kAbsent};
+    /** True only when the source bank is explicitly absent from the investment root. */
+    bool supplementalMissing{};
+    std::uint32_t quantity{};
+    std::uint32_t categoryHash{};
+    float weight{};
+    Range condition{};
+    Range modifiers{};
+    Range sockets{};
+};
+
+struct Pool {
+    std::uint32_t definitionHash{};
+    Range entries{};
+};
+
+struct Selection {
+    std::uint32_t categoryHash{};
+    std::uint32_t count{};
+};
+
+/** Dense item rows bind inventory definitions to their reward pools and acquisition flags. */
+struct Item {
+    std::uint32_t definitionHash{};
+    std::uint16_t poolIndex{kAbsent};
+    std::uint16_t acquiredFlag{kAbsent};
+    std::array<Selection, kSelectionCapacity> selections{};
+    std::uint8_t selectionCount{};
+    std::uint32_t flags{};
+};
+
+struct View {
+    std::span<const Pool> pools;
+    std::span<const Entry> entries;
+    std::span<const Item> items;
+    std::span<const Instruction> instructions;
+    std::span<const Modifier> modifiers;
+    std::span<const SocketOverride> sockets;
+};
+
+template <typename T> [[nodiscard]] constexpr bool fits(Range range, std::span<T> bank) noexcept {
+    return range.first <= bank.size() && range.count <= bank.size() - range.first;
+}
+
+/** An override names a socket type and, when it fixes a plug, an item that was read. */
+[[nodiscard]] constexpr bool valid_socket(const SocketOverride& socket,
+                                          std::size_t itemCount) noexcept {
+    return socket.socketType != kAbsent
+           && (socket.plugItem == kAbsent || socket.plugItem < itemCount);
+}
+
+} // namespace sunrise::state::build_data::rewards

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

@@ -0,0 +1,35 @@
+#include "../runtime.h"
+#include "../runtime/persistence/publication_transaction.h"
+#include "reward_catalog.h"
+
+namespace sunrise::state::build_data {
+
+/** @return True when the reward pools and item wrappers are published. */
+bool reward_definitions_ready() noexcept {
+    return rewards::ready();
+}
+
+/** Publishes the complete reward graph in one step. */
+bool publish_reward_definitions(rewards::View definitions) noexcept {
+    runtime::persistence::Transaction transaction;
+    return transaction.active()
+           && 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);
+}
+
+/** Lends the complete reward graph to one callback under the catalog's read lock. */
+bool read_reward_definitions(void* context,
+                             bool (*consume)(void*, rewards::View) noexcept) noexcept {
+    return rewards::read(context, consume);
+}
+
+} // namespace sunrise::state::build_data

+ 221 - 0
Sunrise/src/state/build_data/rewards/reward_catalog.cpp

@@ -0,0 +1,221 @@
+#include "reward_catalog.h"
+
+#include <algorithm>
+#include <array>
+#include <cmath>
+#include <mutex>
+#include <shared_mutex>
+
+#include "../../unlocks/definition.h"
+#include "../table.h"
+#include "core/threading/srw_lock.h"
+
+namespace sunrise::state::build_data::rewards {
+namespace {
+
+core::threading::SrwLock g_lock;
+Table<Pool, kPoolCapacity> g_pools;
+Table<Entry, kEntryCapacity> g_entries;
+Table<Item, kItemCapacity> g_items;
+Table<Instruction, kInstructionCapacity> g_instructions;
+Table<Modifier, kModifierCapacity> g_modifiers;
+Table<SocketOverride, kSocketOverrideCapacity> g_sockets;
+
+/** Borrows every published bank; the caller holds the catalog lock. */
+View view() noexcept {
+    return {g_pools.rows(),
+            g_entries.rows(),
+            g_items.rows(),
+            g_instructions.rows(),
+            g_modifiers.rows(),
+            g_sockets.rows()};
+}
+
+/** 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,
+                 std::size_t depth = 0) noexcept {
+    constexpr std::uint8_t kVisiting = 0xFF;
+    if (depth >= kTraversalDepth) {
+        return false;
+    }
+    if (heights[index] != 0) {
+        // Reused subtrees must also fit when reached through a deeper path.
+        return heights[index] != kVisiting && heights[index] <= kTraversalDepth - depth;
+    }
+    heights[index] = kVisiting;
+    std::uint8_t height = 1;
+    const Range range = data.pools[index].entries;
+    for (const Entry& entry : data.entries.subspan(range.first, range.count)) {
+        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, child, heights, depth + 1)) {
+            return false;
+        }
+        height = (std::max)(height, static_cast<std::uint8_t>(heights[child] + 1));
+    }
+    heights[index] = height;
+    return true;
+}
+
+/** Checks one entry's references, dependent ranges and weight. */
+bool valid_entry(View data, const Entry& entry) noexcept {
+    return (entry.itemIndex == kAbsent || entry.itemIndex < data.items.size())
+           && (entry.poolIndex == kAbsent || entry.poolIndex < data.pools.size())
+           && (!entry.supplementalMissing || entry.supplementalIndex != kAbsent)
+           && fits(entry.condition, data.instructions) && fits(entry.modifiers, data.modifiers)
+           && fits(entry.sockets, data.sockets) && std::isfinite(entry.weight) && entry.weight >= 0;
+}
+
+/** An unavailable item row carries nothing; an available one names valid banks. */
+bool valid_item(View data, const Item& item) noexcept {
+    if (item.definitionHash == 0) {
+        return item.poolIndex == kAbsent && item.acquiredFlag == kAbsent
+               && item.selectionCount == 0;
+    }
+    return item.selectionCount <= item.selections.size()
+           && (item.acquiredFlag == kAbsent || item.acquiredFlag < unlocks::kAccountFlagCapacity)
+           && (item.poolIndex == kAbsent || item.poolIndex < data.pools.size());
+}
+
+} // namespace
+
+/** Discards the reward graph and every borrowed bank. */
+void clear() noexcept {
+    const std::lock_guard guard(g_lock);
+    g_pools.clear();
+    g_entries.clear();
+    g_items.clear();
+    g_instructions.clear();
+    g_modifiers.clear();
+    g_sockets.clear();
+}
+
+/** @return True once pool and item banks have been published. */
+bool ready() noexcept {
+    const std::shared_lock guard(g_lock);
+    return g_pools.count() != 0 && g_items.count() != 0;
+}
+
+/** 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()
+        || data.items.size() > kItemCapacity || data.instructions.size() > kInstructionCapacity
+        || data.modifiers.size() > kModifierCapacity
+        || data.sockets.size() > kSocketOverrideCapacity) {
+        return false;
+    }
+    for (const Pool& pool : data.pools) {
+        if ((pool.definitionHash == 0 && pool.entries.count != 0)
+            || !fits(pool.entries, data.entries)) {
+            return false;
+        }
+    }
+    for (const Entry& entry : data.entries) {
+        if (!valid_entry(data, entry)) {
+            return false;
+        }
+    }
+    for (const Item& item : data.items) {
+        if (!valid_item(data, item)) {
+            return false;
+        }
+    }
+    if (!std::all_of(data.instructions.begin(), data.instructions.end(), unlocks::valid)) {
+        return false;
+    }
+    for (const Modifier& modifier : data.modifiers) {
+        if (!fits(modifier.condition, data.instructions) || !std::isfinite(modifier.value)) {
+            return false;
+        }
+    }
+    for (const SocketOverride& socket : data.sockets) {
+        if (!valid_socket(socket, data.items.size())) {
+            return false;
+        }
+    }
+    std::array<std::uint8_t, kPoolCapacity> heights{};
+    for (std::size_t index = 0; index < data.pools.size(); ++index) {
+        if (!valid_depth(data, index, heights)) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** Publishes validated banks together under the catalog lock. */
+bool replace(View data) noexcept {
+    if (!valid(data)) {
+        return false;
+    }
+    const std::lock_guard guard(g_lock);
+    return g_pools.replace(data.pools) && g_entries.replace(data.entries)
+           && g_items.replace(data.items) && g_instructions.replace(data.instructions)
+           && g_modifiers.replace(data.modifiers) && g_sockets.replace(data.sockets);
+}
+
+/** Lends every bank to one callback under the read lock. */
+bool read(void* context, bool (*consume)(void*, View) noexcept) noexcept {
+    const std::shared_lock guard(g_lock);
+    return consume != nullptr && g_pools.count() != 0 && consume(context, view());
+}
+
+/** Copies one extracted item row; unavailable rows are refused. */
+bool find_item(std::uint16_t itemIndex, Item& item) noexcept {
+    item = {};
+    const std::shared_lock guard(g_lock);
+    if (itemIndex >= g_items.count() || g_items.rows()[itemIndex].definitionHash == 0) {
+        return false;
+    }
+    item = g_items.rows()[itemIndex];
+    return true;
+}
+
+/** Copies every pool; count is zero when the output is too small. */
+bool snapshot(std::span<Pool> output, std::size_t& count) noexcept {
+    const std::shared_lock guard(g_lock);
+    return g_pools.snapshot(output, count);
+}
+
+/** Copies every entry; count is zero when the output is too small. */
+bool snapshot(std::span<Entry> output, std::size_t& count) noexcept {
+    const std::shared_lock guard(g_lock);
+    return g_entries.snapshot(output, count);
+}
+
+/** Copies every item row; count is zero when the output is too small. */
+bool snapshot(std::span<Item> output, std::size_t& count) noexcept {
+    const std::shared_lock guard(g_lock);
+    return g_items.snapshot(output, count);
+}
+
+/** Copies every condition instruction; count is zero when the output is too small. */
+bool snapshot(std::span<Instruction> output, std::size_t& count) noexcept {
+    const std::shared_lock guard(g_lock);
+    return g_instructions.snapshot(output, count);
+}
+
+/** Copies every modifier; count is zero when the output is too small. */
+bool snapshot(std::span<Modifier> output, std::size_t& count) noexcept {
+    const std::shared_lock guard(g_lock);
+    return g_modifiers.snapshot(output, count);
+}
+
+/** Copies every socket override; count is zero when the output is too small. */
+bool snapshot(std::span<SocketOverride> output, std::size_t& count) noexcept {
+    const std::shared_lock guard(g_lock);
+    return g_sockets.snapshot(output, count);
+}
+
+} // namespace sunrise::state::build_data::rewards

+ 28 - 0
Sunrise/src/state/build_data/rewards/reward_catalog.h

@@ -0,0 +1,28 @@
+#pragma once
+
+#include "definition.h"
+
+namespace sunrise::state::build_data::rewards {
+
+/** Discards the reward graph and every borrowed bank. */
+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 and wrapper traversal. */
+[[nodiscard]] bool valid(View data) noexcept;
+/** Publishes validated banks together under the catalog lock. */
+[[nodiscard]] bool replace(View data) noexcept;
+/** The borrowed view remains valid only during the callback. */
+[[nodiscard]] bool read(void* context, bool (*consume)(void*, View) noexcept) noexcept;
+/** Copies a native item row, refusing unavailable extraction entries. */
+[[nodiscard]] bool find_item(std::uint16_t itemIndex, Item& item) noexcept;
+
+/** Copies one complete bank; count is zero if the output is too small. */
+[[nodiscard]] bool snapshot(std::span<Pool> output, std::size_t& count) noexcept;
+[[nodiscard]] bool snapshot(std::span<Entry> output, std::size_t& count) noexcept;
+[[nodiscard]] bool snapshot(std::span<Item> output, std::size_t& count) noexcept;
+[[nodiscard]] bool snapshot(std::span<Instruction> output, std::size_t& count) noexcept;
+[[nodiscard]] bool snapshot(std::span<Modifier> output, std::size_t& count) noexcept;
+[[nodiscard]] bool snapshot(std::span<SocketOverride> output, std::size_t& count) noexcept;
+
+} // namespace sunrise::state::build_data::rewards

+ 36 - 10
Sunrise/src/state/build_data/runtime.h

@@ -23,6 +23,7 @@
 #include "nodes/definition.h"
 #include "progressions/definition.h"
 #include "records/definition.h"
+#include "rewards/definition.h"
 #include "scenarios/definition.h"
 #include "season_pass/definition.h"
 #include "sobjects/sobject_catalog.h"
@@ -397,13 +398,14 @@ publish_progression_definitions(std::span<const progressions::Definition> defini
 [[nodiscard]] bool season_pass_ready() noexcept;
 
 /**
- * Publishes the season pass reward list and the wrapper items it grants, in one step.
+ * Publishes the season pass reward list with its fixed socket overrides.
  * @param rewards Complete reward rows in native reward order.
- * @param packages Complete wrapper packages.
  * @return True when the rows pass the checks and any needed cache write succeeds.
  */
-[[nodiscard]] bool publish_season_pass(std::span<const season_pass::Reward> rewards,
-                                       std::span<const season_pass::Package> packages) noexcept;
+[[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.
@@ -414,14 +416,38 @@ publish_progression_definitions(std::span<const progressions::Definition> defini
 [[nodiscard]] bool find_season_pass_reward(std::uint16_t rewardIndex,
                                            season_pass::Reward& reward) noexcept;
 
+/** @return Season pass reward rows in State, including unavailable rows. */
+[[nodiscard]] std::size_t season_pass_reward_count() noexcept;
+
+/** @return True when the reward pools and item wrappers are in State. */
+[[nodiscard]] bool reward_definitions_ready() noexcept;
+
+/**
+ * Publishes the complete reward graph in one step.
+ * @param definitions Pools, entries, item rows, conditions, modifiers and socket overrides.
+ * @return True when the graph passes the checks and any needed cache write succeeds.
+ */
+[[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.
+ * @param item Receives the row.
+ * @return True when the catalog holds an extracted row for that item.
+ */
+[[nodiscard]] bool find_reward_item(std::uint16_t itemIndex, rewards::Item& item) noexcept;
+
 /**
- * Finds the item set one season pass wrapper opens into.
- * @param definitionHash Authored wrapper item hash.
- * @param package Receives the wrapper and its items.
- * @return True when the catalog is ready and a wrapper carries that hash.
+ * Lends the complete reward graph to one callback under the catalog's read lock.
+ * @param context Caller state passed through unchanged.
+ * @param consume Receives the graph, which stays valid only during the call.
+ * @return The callback's result, or false when no graph is published.
  */
-[[nodiscard]] bool find_season_pass_package(std::uint32_t definitionHash,
-                                            season_pass::Package& package) noexcept;
+[[nodiscard]] bool read_reward_definitions(void* context,
+                                           bool (*consume)(void*, rewards::View) noexcept) noexcept;
 
 /** @return True when the repeatable bounty table is in State. */
 [[nodiscard]] bool repeatable_bounties_ready() noexcept;

+ 2 - 0
Sunrise/src/state/build_data/runtime/build_data_catalog_runtime.cpp

@@ -14,6 +14,7 @@
 #include "../nodes/node_catalog.h"
 #include "../progressions/progression_catalog.h"
 #include "../records/record_catalog.h"
+#include "../rewards/reward_catalog.h"
 #include "../runtime.h"
 #include "../scenarios/scenario_catalog.h"
 #include "../season_pass/season_pass_catalog.h"
@@ -372,6 +373,7 @@ void clear_catalogs() noexcept {
     rollback_ability_publication();
     progressions::clear();
     season_pass::clear();
+    rewards::clear();
     bounties::clear();
     records::clear();
     nodes::clear();

+ 34 - 11
Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.cpp

@@ -21,6 +21,7 @@
 #include "../../nodes/node_catalog.h"
 #include "../../progressions/progression_catalog.h"
 #include "../../records/record_catalog.h"
+#include "../../rewards/reward_catalog.h"
 #include "../../runtime.h"
 #include "../../scenarios/scenario_catalog.h"
 #include "../../season_pass/season_pass_catalog.h"
@@ -90,8 +91,13 @@ to_record(const constants::InvestmentConstants& value) noexcept {
            && progressions::snapshot(scratch.progressions, counts.progressions)
            && progressions::snapshot_steps(scratch.progressionSteps, counts.progressionSteps)
            && season_pass::snapshot(scratch.seasonPassRewards, counts.seasonPassRewards)
-           && season_pass::snapshot_packages(scratch.seasonPassPackages, counts.seasonPassPackages)
            && bounties::snapshot(scratch.bounties, counts.bounties)
+           && rewards::snapshot(scratch.rewardPools, counts.rewardPools)
+           && rewards::snapshot(scratch.rewardEntries, counts.rewardEntries)
+           && rewards::snapshot(scratch.rewardItems, counts.rewardItems)
+           && rewards::snapshot(scratch.rewardInstructions, counts.rewardInstructions)
+           && rewards::snapshot(scratch.rewardModifiers, counts.rewardModifiers)
+           && rewards::snapshot(scratch.rewardSockets, counts.rewardSockets)
            && records::snapshot(scratch.records, counts.records)
            && records::snapshot_objectives(scratch.recordObjectives, counts.recordObjectives)
            && records::snapshot_intervals(scratch.recordIntervals, counts.recordIntervals)
@@ -125,9 +131,10 @@ to_record(const constants::InvestmentConstants& value) noexcept {
            && material_requirement_sets_ready() && inventory_bucket_descriptors_ready()
            && socket_entry_lists_ready() && ability_buckets_ready()
            && progression_definitions_ready() && season_pass_ready() && repeatable_bounties_ready()
-           && record_definitions_ready() && node_definitions_ready() && sobject_definitions_ready()
-           && scenario_layouts_ready() && spawn_sets_ready() && hash_names_ready()
-           && vendor_catalog_ready() && gameplay::entity_position_profiles::available()
+           && reward_definitions_ready() && record_definitions_ready() && node_definitions_ready()
+           && sobject_definitions_ready() && scenario_layouts_ready() && spawn_sets_ready()
+           && hash_names_ready() && vendor_catalog_ready()
+           && gameplay::entity_position_profiles::available()
            && gameplay::entity_object_types::available() && constants::find(published);
 }
 
@@ -222,9 +229,6 @@ cache::records::MutableDomains scratch_domains(Context& state) noexcept {
     const auto seasonPassRewards =
         ensure_scratch<season_pass::Reward, season_pass::kRewardCapacity>(
             state.seasonPassRewardScratch);
-    const auto seasonPassPackages =
-        ensure_scratch<season_pass::Package, season_pass::kPackageCapacity>(
-            state.seasonPassPackageScratch);
     const auto bountyRows =
         ensure_scratch<bounties::Definition, bounties::kDefinitionCapacity>(state.bountyScratch);
     const auto positionProfiles = ensure_scratch<gameplay::entity_position_profiles::Row,
@@ -268,8 +272,15 @@ cache::records::MutableDomains scratch_domains(Context& state) noexcept {
         recordRewards,
         progressionSteps,
         seasonPassRewards,
-        seasonPassPackages,
         bountyRows,
+        ensure_scratch<rewards::Pool, rewards::kPoolCapacity>(state.rewardPoolScratch),
+        ensure_scratch<rewards::Entry, rewards::kEntryCapacity>(state.rewardEntryScratch),
+        ensure_scratch<rewards::Item, rewards::kItemCapacity>(state.rewardItemScratch),
+        ensure_scratch<rewards::Instruction, rewards::kInstructionCapacity>(
+            state.rewardInstructionScratch),
+        ensure_scratch<rewards::Modifier, rewards::kModifierCapacity>(state.rewardModifierScratch),
+        ensure_scratch<rewards::SocketOverride, rewards::kSocketOverrideCapacity>(
+            state.rewardSocketScratch),
     };
 }
 
@@ -299,8 +310,13 @@ void release_scratch_locked(Context& state) noexcept {
     release_bank(state.progressionScratch);
     release_bank(state.progressionStepScratch);
     release_bank(state.seasonPassRewardScratch);
-    release_bank(state.seasonPassPackageScratch);
     release_bank(state.bountyScratch);
+    release_bank(state.rewardPoolScratch);
+    release_bank(state.rewardEntryScratch);
+    release_bank(state.rewardItemScratch);
+    release_bank(state.rewardInstructionScratch);
+    release_bank(state.rewardModifierScratch);
+    release_bank(state.rewardSocketScratch);
     release_bank(state.recordScratch);
     release_bank(state.recordObjectiveScratch);
     release_bank(state.recordIntervalScratch);
@@ -402,9 +418,16 @@ cache::records::Domains occupied_domains(Context& state,
                                             counts.progressionSteps},
         std::span<const season_pass::Reward>{state.seasonPassRewardScratch.data(),
                                              counts.seasonPassRewards},
-        std::span<const season_pass::Package>{state.seasonPassPackageScratch.data(),
-                                              counts.seasonPassPackages},
         std::span<const bounties::Definition>{state.bountyScratch.data(), counts.bounties},
+        std::span<const rewards::Pool>{state.rewardPoolScratch.data(), counts.rewardPools},
+        std::span<const rewards::Entry>{state.rewardEntryScratch.data(), counts.rewardEntries},
+        std::span<const rewards::Item>{state.rewardItemScratch.data(), counts.rewardItems},
+        std::span<const rewards::Instruction>{state.rewardInstructionScratch.data(),
+                                              counts.rewardInstructions},
+        std::span<const rewards::Modifier>{state.rewardModifierScratch.data(),
+                                           counts.rewardModifiers},
+        std::span<const rewards::SocketOverride>{state.rewardSocketScratch.data(),
+                                                 counts.rewardSockets},
     };
 }
 

+ 6 - 1
Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.h

@@ -79,8 +79,13 @@ struct Context {
     std::vector<progressions::Definition> progressionScratch{};
     std::vector<progressions::Step> progressionStepScratch{};
     std::vector<season_pass::Reward> seasonPassRewardScratch{};
-    std::vector<season_pass::Package> seasonPassPackageScratch{};
     std::vector<bounties::Definition> bountyScratch{};
+    std::vector<rewards::Pool> rewardPoolScratch{};
+    std::vector<rewards::Entry> rewardEntryScratch{};
+    std::vector<rewards::Item> rewardItemScratch{};
+    std::vector<rewards::Instruction> rewardInstructionScratch{};
+    std::vector<rewards::Modifier> rewardModifierScratch{};
+    std::vector<rewards::SocketOverride> rewardSocketScratch{};
     std::vector<records::Definition> recordScratch{};
     std::vector<records::Objective> recordObjectiveScratch{};
     std::vector<records::Interval> recordIntervalScratch{};

+ 12 - 16
Sunrise/src/state/build_data/season_pass/definition.h

@@ -4,23 +4,22 @@
 #include <cstddef>
 #include <cstdint>
 
+#include "../rewards/definition.h"
+
 namespace sunrise::state::build_data::season_pass {
 
 /** Reward rows the installed pass declares. The shipped build carries 196. */
 inline constexpr std::size_t kRewardCapacity = 256;
 
-/** Wrapper rewards that open into a set. The shipped pass carries one per character class. */
-inline constexpr std::size_t kPackageCapacity = 8;
-
-/** Items one wrapper opens into. The shipped wrappers each carry six. */
-inline constexpr std::size_t kPackageItemCapacity = 8;
-
 /** A reward whose claim flag no mapping table addresses carries this instead of an index. */
 inline constexpr std::uint16_t kUnavailableFlagIndex = 0xFFFFU;
 
+/** Instructions in one row's expanded eligibility condition. The longest shipped row needs 5. */
+inline constexpr std::size_t kConditionCapacity = 16;
+
 /** One reward row of the pass, in the native order the opcode-2400 claim names by index. */
 struct Reward {
-    /** Authored definition hash of the granted item. */
+    /** Installed definition hash of the granted item. */
     std::uint32_t itemHash{};
     /** Units granted. */
     std::uint32_t quantity{};
@@ -30,15 +29,12 @@ struct Reward {
     std::uint16_t claimFlagIndex{kUnavailableFlagIndex};
     /** Rank the account needs before the row may be claimed. */
     std::uint8_t requiredRank{};
-};
-
-/** One wrapper reward and the items it opens into. */
-struct Package {
-    /** Authored definition hash of the wrapper item. */
-    std::uint32_t definitionHash{};
-    /** Authored definition hashes the wrapper opens into, in declared order. */
-    std::array<std::uint32_t, kPackageItemCapacity> items{};
-    std::uint8_t itemCount{};
+    /** Fixed plugs this row places on the granted item, in native order. */
+    std::array<rewards::SocketOverride, rewards::kSocketsPerItem> sockets{};
+    std::uint8_t socketCount{};
+    /** Bound eligibility condition; every native expression on the row must hold. */
+    std::array<rewards::Instruction, kConditionCapacity> condition{};
+    std::uint8_t conditionCount{};
 };
 
 } // namespace sunrise::state::build_data::season_pass

+ 11 - 8
Sunrise/src/state/build_data/season_pass/season_pass_build_data_runtime.cpp

@@ -9,12 +9,16 @@ bool season_pass_ready() noexcept {
     return season_pass::count() != 0;
 }
 
-/** Publishes the season pass reward list and the wrapper items it grants, in one step. */
-bool publish_season_pass(std::span<const season_pass::Reward> rewards,
-                         std::span<const season_pass::Package> packages) noexcept {
+/** Publishes the season pass reward list with its fixed socket overrides. */
+bool publish_season_pass(std::span<const season_pass::Reward> rewards) noexcept {
     runtime::persistence::Transaction transaction;
     return transaction.active()
-           && transaction.finish(season_pass::replace(rewards, packages), season_pass::clear);
+           && 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. */
@@ -22,10 +26,9 @@ bool find_season_pass_reward(std::uint16_t rewardIndex, season_pass::Reward& rew
     return season_pass::find(rewardIndex, reward);
 }
 
-/** Finds the item set one season pass wrapper opens into. */
-bool find_season_pass_package(std::uint32_t definitionHash,
-                              season_pass::Package& package) noexcept {
-    return season_pass::find_package(definitionHash, package);
+/** @return Season pass reward rows in State. */
+std::size_t season_pass_reward_count() noexcept {
+    return season_pass::count();
 }
 
 } // namespace sunrise::state::build_data

+ 38 - 50
Sunrise/src/state/build_data/season_pass/season_pass_catalog.cpp

@@ -1,96 +1,84 @@
 #include "season_pass_catalog.h"
 
 #include <algorithm>
+#include <limits>
+#include <mutex>
 #include <shared_mutex>
 
+#include "../../unlocks/definition.h"
+#include "../../unlocks/unlocks_expression.h"
 #include "../table.h"
 #include "core/threading/srw_lock.h"
 
 namespace sunrise::state::build_data::season_pass {
 namespace {
-
-// One lock covers both tables. A reward names a wrapper by hash, so a reader must never see one
-// table replaced and the other not.
 core::threading::SrwLock g_lock;
 Table<Reward, kRewardCapacity> g_rewards;
-Table<Package, kPackageCapacity> g_packages;
-
 } // namespace
 
-/** Clears the reward rows and the wrapper packages under the catalog lock. */
+/** Clears the reward rows under the catalog lock. */
 void clear() noexcept {
     const std::lock_guard guard(g_lock);
     g_rewards.clear();
-    g_packages.clear();
 }
 
-/** Checks one complete pass catalog. */
-bool valid(std::span<const Reward> rewards, std::span<const Package> packages) noexcept {
-    if (rewards.empty() || rewards.size() > kRewardCapacity || packages.size() > kPackageCapacity) {
+/** Checks one complete pass catalog; unavailable rows keep their native positions. */
+bool valid(std::span<const Reward> rewards) noexcept {
+    if (rewards.empty() || rewards.size() > kRewardCapacity) {
         return false;
     }
-    // A row granting nothing would claim its flag and hand the account no item.
-    const bool granting = std::all_of(rewards.begin(), rewards.end(), [](const Reward& reward) {
-        return reward.itemHash != 0 && reward.quantity != 0;
-    });
-    return granting && std::all_of(packages.begin(), packages.end(), [](const Package& package) {
-               return package.definitionHash != 0 && package.itemCount != 0
-                      && package.itemCount <= kPackageItemCapacity;
-           });
+    constexpr auto kMaximumQuantity =
+        static_cast<std::uint32_t>((std::numeric_limits<std::int32_t>::max)());
+    for (const Reward& reward : rewards) {
+        // An unavailable row keeps its native claim index and grants nothing.
+        if (reward.itemHash == 0) {
+            if (reward.quantity != 0 || reward.conditionCount != 0 || reward.socketCount != 0
+                || reward.claimFlagIndex != kUnavailableFlagIndex) {
+                return false;
+            }
+            continue;
+        }
+        if (reward.quantity == 0 || reward.quantity > kMaximumQuantity
+            || (reward.claimFlagIndex != kUnavailableFlagIndex
+                && reward.claimFlagIndex >= unlocks::kAccountFlagCapacity)
+            || reward.socketCount > reward.sockets.size()
+            || reward.conditionCount > reward.condition.size()
+            || !std::all_of(reward.condition.begin(),
+                            reward.condition.begin() + reward.conditionCount,
+                            unlocks::valid)) {
+            return false;
+        }
+    }
+    return true;
 }
 
 /** Replaces the complete pass catalog in one step. */
-bool replace(std::span<const Reward> rewards, std::span<const Package> packages) noexcept {
-    if (!valid(rewards, packages)) {
+bool replace(std::span<const Reward> rewards) noexcept {
+    if (!valid(rewards)) {
         return false;
     }
     const std::lock_guard guard(g_lock);
-    const bool replaced = g_rewards.replace(rewards) && g_packages.replace(packages);
-    if (!replaced) {
-        g_rewards.clear();
-        g_packages.clear();
-    }
-    return replaced;
+    return g_rewards.replace(rewards);
 }
 
-/** Reads one reward row by the index an opcode-2400 claim names. */
+/** Reads one available reward row by the index an opcode-2400 claim names. */
 bool find(std::uint16_t rewardIndex, Reward& reward) noexcept {
     reward = {};
     const std::shared_lock guard(g_lock);
-    const std::span<const Reward> rows = g_rewards.rows();
-    if (rewardIndex >= rows.size()) {
+    if (rewardIndex >= g_rewards.count() || g_rewards.rows()[rewardIndex].itemHash == 0) {
         return false;
     }
-    reward = rows[rewardIndex];
+    reward = g_rewards.rows()[rewardIndex];
     return true;
 }
 
-/** Finds the wrapper package one item hash opens into. */
-bool find_package(std::uint32_t definitionHash, Package& package) noexcept {
-    package = {};
-    const std::shared_lock guard(g_lock);
-    for (const Package& row : g_packages.rows()) {
-        if (row.definitionHash == definitionHash) {
-            package = row;
-            return true;
-        }
-    }
-    return false;
-}
-
 /** Copies every reward row in native reward order. */
 bool snapshot(std::span<Reward> output, std::size_t& count) noexcept {
     const std::shared_lock guard(g_lock);
     return g_rewards.snapshot(output, count);
 }
 
-/** Copies every wrapper package in declared order. */
-bool snapshot_packages(std::span<Package> output, std::size_t& count) noexcept {
-    const std::shared_lock guard(g_lock);
-    return g_packages.snapshot(output, count);
-}
-
-/** @return The reward row count, read under the lock. */
+/** @return The reward row count, including unavailable rows, read under the lock. */
 std::size_t count() noexcept {
     const std::shared_lock guard(g_lock);
     return g_rewards.count();

+ 8 - 53
Sunrise/src/state/build_data/season_pass/season_pass_catalog.h

@@ -1,67 +1,22 @@
 #pragma once
 
-#include <cstddef>
-#include <cstdint>
 #include <span>
 
 #include "definition.h"
 
 namespace sunrise::state::build_data::season_pass {
 
-/** Clears the reward rows and the wrapper packages. */
+/** Discards the installed pass, including its native claim indices. */
 void clear() noexcept;
-
-/**
- * Checks one complete pass catalog.
- * @param rewards Candidate reward rows in native reward order.
- * @param packages Candidate wrapper packages, which may be empty.
- * @return True when every count and item range holds.
- */
-[[nodiscard]] bool valid(std::span<const Reward> rewards,
-                         std::span<const Package> packages) noexcept;
-
-/**
- * Replaces the complete pass catalog in one step.
- * @param rewards Complete reward rows in native reward order.
- * @param packages Complete wrapper packages.
- * @return True when the catalog passes validation and fits fixed State storage.
- */
-[[nodiscard]] bool replace(std::span<const Reward> rewards,
-                           std::span<const Package> packages) noexcept;
-
-/**
- * Reads one reward row by the index an opcode-2400 claim names.
- * @param rewardIndex Native reward-array index.
- * @param reward Receives the row.
- * @return True when the catalog holds that row.
- */
+/** Checks row bounds; unavailable rows retain their native positions. */
+[[nodiscard]] bool valid(std::span<const Reward> rewards) noexcept;
+/** Publishes a validated pass without reordering its claim indices. */
+[[nodiscard]] bool replace(std::span<const Reward> rewards) noexcept;
+/** Copies one native claim row; clears the output when absent or unavailable. */
 [[nodiscard]] bool find(std::uint16_t rewardIndex, Reward& reward) noexcept;
-
-/**
- * Finds the wrapper package one item hash opens into.
- * @param definitionHash Authored wrapper item hash.
- * @param package Receives the matching wrapper.
- * @return True when a wrapper carries that hash.
- */
-[[nodiscard]] bool find_package(std::uint32_t definitionHash, Package& package) noexcept;
-
-/**
- * Copies every reward row in native reward order.
- * @param output Caller-owned fixed row storage.
- * @param count Receives the copied row count, or zero when output is too small.
- * @return True when output can hold every row.
- */
+/** Copies the complete pass; count is zero when the destination is too small. */
 [[nodiscard]] bool snapshot(std::span<Reward> output, std::size_t& count) noexcept;
-
-/**
- * Copies every wrapper package in declared order.
- * @param output Caller-owned fixed row storage.
- * @param count Receives the copied row count, or zero when output is too small.
- * @return True when output can hold every row.
- */
-[[nodiscard]] bool snapshot_packages(std::span<Package> output, std::size_t& count) noexcept;
-
-/** @return The reward row count, read under the lock. */
+/** Number of native positions, including unavailable rows. */
 [[nodiscard]] std::size_t count() noexcept;
 
 } // namespace sunrise::state::build_data::season_pass

+ 0 - 119
Sunrise/src/state/build_data/vendors/vendor_expression.cpp

@@ -1,119 +0,0 @@
-#include "vendor_expression.h"
-
-#include <array>
-
-namespace sunrise::state::build_data::vendors {
-namespace {
-
-/** What one stack slot holds. The two kinds never substitute for each other. */
-enum class Kind : std::uint8_t {
-    boolean,
-    number,
-};
-
-/** One typed stack slot. */
-struct Slot {
-    Kind kind{};
-    bool boolean{};
-    std::int32_t number{};
-};
-
-/** Fixed evaluation stack, which refuses rather than growing. */
-class Stack final {
-public:
-    /** @param slot Value to push. @return True when there was room. */
-    [[nodiscard]] bool push(const Slot& slot) noexcept {
-        if (depth_ == slots_.size()) {
-            return false;
-        }
-        slots_[depth_++] = slot;
-        return true;
-    }
-
-    /** @param kind Required kind. @param slot Receives the value. @return True when it matched. */
-    [[nodiscard]] bool pop(Kind kind, Slot& slot) noexcept {
-        if (depth_ == 0 || slots_[depth_ - 1].kind != kind) {
-            return false;
-        }
-        slot = slots_[--depth_];
-        return true;
-    }
-
-    /** @return Slots currently held. */
-    [[nodiscard]] std::size_t depth() const noexcept {
-        return depth_;
-    }
-
-private:
-    std::array<Slot, kExpressionStackCapacity> slots_{};
-    std::size_t depth_{};
-};
-
-/** @param value Boolean result. @return The stack slot holding it. */
-[[nodiscard]] Slot boolean_slot(bool value) noexcept {
-    return {Kind::boolean, value, 0};
-}
-
-/** @param value Numeric result. @return The stack slot holding it. */
-[[nodiscard]] Slot number_slot(std::int32_t value) noexcept {
-    return {Kind::number, false, value};
-}
-
-/**
- * Runs one instruction against the stack.
- * @param instruction Instruction to run.
- * @param inputs Flag and value readers.
- * @param stack Evaluation stack.
- * @return True when the opcode is known and its operands were there in the right kind.
- */
-[[nodiscard]] bool
-step(const Instruction& instruction, const Inputs& inputs, Stack& stack) noexcept {
-    Slot left{};
-    Slot right{};
-    switch (instruction.opcode) {
-    case Opcode::flag: {
-        std::uint8_t logical = 0;
-        return inputs.flag(inputs.context, instruction.operand, logical)
-               && stack.push(boolean_slot(logical == kFlagActive));
-    }
-    case Opcode::logicalNot:
-        return stack.pop(Kind::boolean, left) && stack.push(boolean_slot(!left.boolean));
-    case Opcode::loadValue: {
-        std::int32_t value = 0;
-        return inputs.value(inputs.context, instruction.operand, value)
-               && stack.push(number_slot(value));
-    }
-    case Opcode::constant:
-        return stack.push(number_slot(instruction.operand));
-    case Opcode::lessThan:
-        // The right operand was pushed last, so it comes off first.
-        return stack.pop(Kind::number, right) && stack.pop(Kind::number, left)
-               && stack.push(boolean_slot(left.number < right.number));
-    default:
-        return false;
-    }
-}
-
-} // namespace
-
-/** Evaluates one expression program. */
-bool evaluate(std::span<const Instruction> program, const Inputs& inputs, bool& result) noexcept {
-    result = false;
-    if (program.empty() || inputs.flag == nullptr || inputs.value == nullptr) {
-        return false;
-    }
-    Stack stack;
-    for (const Instruction& instruction : program) {
-        if (!step(instruction, inputs, stack)) {
-            return false;
-        }
-    }
-    Slot final{};
-    if (stack.depth() != 1 || !stack.pop(Kind::boolean, final)) {
-        return false;
-    }
-    result = final.boolean;
-    return true;
-}
-
-} // namespace sunrise::state::build_data::vendors

+ 0 - 58
Sunrise/src/state/build_data/vendors/vendor_expression.h

@@ -1,58 +0,0 @@
-#pragma once
-
-#include <cstddef>
-#include <cstdint>
-#include <span>
-
-namespace sunrise::state::build_data::vendors {
-
-/** Stack slots one expression may use. Known gates need two, so this leaves headroom. */
-inline constexpr std::size_t kExpressionStackCapacity = 16;
-
-/** Opcodes the client's expression evaluator implements. */
-enum class Opcode : std::uint8_t {
-    /** Pushes true when the flag's logical byte is `kFlagActive`. Operand is the flag slot. */
-    flag = 1,
-    /** Pops one boolean and pushes its negation. */
-    logicalNot = 2,
-    /** Pushes one progression value. Operand is the value slot. */
-    loadValue = 10,
-    /** Pushes the operand as a constant. */
-    constant = 11,
-    /** Pops right then left and pushes left less than right. */
-    lessThan = 15,
-};
-
-/** A flag is active only for this logical byte. Logical 0 and 1 are both inactive. */
-inline constexpr std::uint8_t kFlagActive = 2;
-
-/** One expression instruction. Opcodes with no operand leave it unread. */
-struct Instruction {
-    Opcode opcode{};
-    std::uint16_t operand{};
-};
-
-/** Reads one flag's logical byte. Returns false when the slot has no value. */
-using FlagReader = bool (*)(void* context, std::uint16_t slot, std::uint8_t& logical) noexcept;
-/** Reads one progression value. Returns false when the slot has no value. */
-using ValueReader = bool (*)(void* context, std::uint16_t slot, std::int32_t& value) noexcept;
-
-/** The two readers one evaluation needs, and the caller state they share. */
-struct Inputs {
-    FlagReader flag{};
-    ValueReader value{};
-    void* context{};
-};
-
-/**
- * Evaluates one expression program.
- * Every failure is refused, never defaulted: bad opcode, bad stack, bad kind, unreadable slot.
- * @param program Instructions in evaluation order.
- * @param inputs Flag and value readers.
- * @param result Receives the expression result, and is cleared on any refusal.
- * @return True only when the whole program evaluated to one boolean.
- */
-[[nodiscard]] bool
-evaluate(std::span<const Instruction> program, const Inputs& inputs, bool& result) noexcept;
-
-} // namespace sunrise::state::build_data::vendors

+ 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;
 

+ 5 - 184
Sunrise/src/state/progression/season_pass_reward_catalog.h

@@ -1,9 +1,9 @@
 #pragma once
 
-#include <array>
-#include <cstddef>
 #include <cstdint>
 
+#include "../build_data/season_pass/definition.h"
+
 namespace sunrise::state::progression::season_pass {
 
 /** Account progression used by the installed Season of Arrivals pass. */
@@ -11,187 +11,8 @@ inline constexpr std::uint16_t kProgressionDefinitionIndex = 40;
 /** Repeating HUD bar paired with the Arrivals progression. */
 inline constexpr std::uint16_t kHudProgressionDefinitionIndex = 41;
 
-/**
- * The pass package expands into every installed Season 11 planetary-material stack.
- * TODO: extract; the package names a sack reward set, not an item list, and no reader
- * resolves a reward set to its members yet.
- */
-inline constexpr std::uint32_t kDestinationResourceBundleHash = 3104539653U;
-inline constexpr std::uint16_t kDestinationResourceQuantity = 50;
-inline constexpr std::array<std::uint32_t, 9> kDestinationResourceHashes{
-    3592324052U, // Helium Filaments
-    31293053U,   // Seraphite
-    49145143U,   // Simulation Seed
-    2014411539U, // Alkane Dust
-    1305274547U, // Phaseglass Needle
-    950899352U,  // Dusklight Shard
-    3487922223U, // Microphasic Datalattice
-    592227263U,  // Baryon Bough
-    1177810185U, // Etheric Spiral
-};
-
-/** The two auto-decrypting engram item definitions. */
-inline constexpr std::uint32_t kLegendaryEngramHash = 2223145359U;
-inline constexpr std::uint32_t kExoticEngramHash = 3875551374U;
-
-/**
- * Exact non-class rewards advertised by the two auto-decrypting engram definitions.
- * TODO: extract; an engram names a sack reward set rather than items, and no reader resolves
- * a reward set to its members yet.
- */
-inline constexpr std::array<std::uint32_t, 30> kLegendaryEngramWeapons{
-    991314988U,  720351795U,  4230993599U, 253196586U,  4146702548U, 2957367743U,
-    2009277538U, 3745990145U, 3356526253U, 821154603U,  3504336176U, 2199171672U,
-    188882152U,  3863882743U, 4106983932U, 3569802112U, 1529450902U, 1807343361U,
-    3622137132U, 3055192515U, 2257180473U, 2742838701U, 2742838700U, 1162247618U,
-    1723380073U, 2807687156U, 1786797708U, 2857348871U, 1946491241U, 1835747805U,
-};
-/** Legendary engram Titan armour rewards. */
-inline constexpr std::array<std::uint32_t, 18> kLegendaryTitanArmour{
-    3852389988U,
-    3250360146U,
-    362404956U,
-    3299386902U,
-    1648238545U,
-    2629014079U,
-    881579413U,
-    2112821379U,
-    1063507982U,
-    3651598572U,
-    1076538456U,
-    1560040304U,
-    445618861U,
-    238618945U,
-    3406670226U,
-    3238424670U,
-    1566911695U,
-    1407026808U,
-};
-/** Legendary engram Hunter armour rewards. */
-inline constexpr std::array<std::uint32_t, 18> kLegendaryHunterArmour{
-    3239215026U,
-    974507844U,
-    1399263478U,
-    2808379196U,
-    1153347999U,
-    265279665U,
-    2567710435U,
-    2298664693U,
-    4064910796U,
-    2905153902U,
-    3750877150U,
-    344824594U,
-    940065571U,
-    382498903U,
-    299852984U,
-    3554672786U,
-    902989307U,
-    2944336620U,
-};
-/** Legendary engram Warlock armour rewards. */
-inline constexpr std::array<std::uint32_t, 18> kLegendaryWarlockArmour{
-    1920259123U,
-    4239920089U,
-    695071581U,
-    2364041279U,
-    2581516944U,
-    3611199822U,
-    2339155434U,
-    597618504U,
-    1655109893U,
-    2837138379U,
-    785967407U,
-    3931361417U,
-    1416697412U,
-    1557571326U,
-    936010065U,
-    2640935765U,
-    4245441464U,
-    3523809305U,
-};
-
-/** Exotic engram non-class rewards. */
-inline constexpr std::array<std::uint32_t, 10> kExoticEngramWeapons{
-    4068264807U,
-    2130065553U,
-    3325463374U,
-    1541131350U,
-    814876685U,
-    2694576561U,
-    3766045777U,
-    1852863732U,
-    2044500762U,
-    3413860063U,
-};
-/** Exotic engram Titan armour rewards. */
-inline constexpr std::array<std::uint32_t, 10> kExoticTitanArmour{
-    1190497097U,
-    2326396534U,
-    2255796155U,
-    2240152949U,
-    2578771006U,
-    2423243921U,
-    1734844650U,
-    2808156426U,
-    106575079U,
-    1591207518U,
-};
-/** Exotic engram Hunter armour rewards. */
-inline constexpr std::array<std::uint32_t, 10> kExoticHunterArmour{
-    2268523867U,
-    1219761634U,
-    2757274117U,
-    4165919945U,
-    1474735276U,
-    978537162U,
-    1688602431U,
-    1163283805U,
-    896224899U,
-    903984858U,
-};
-/** Exotic engram Warlock armour rewards. */
-inline constexpr std::array<std::uint32_t, 10> kExoticWarlockArmour{
-    2822465023U,
-    235591051U,
-    3948284065U,
-    3084282676U,
-    4057299719U,
-    121305948U,
-    3288917178U,
-    3627185503U,
-    1725917554U,
-    1030017949U,
-};
-
-template <std::size_t Size>
-/** @return True when the reward array holds that item hash. */
-[[nodiscard]] constexpr bool contains(const std::array<std::uint32_t, Size>& hashes,
-                                      std::uint32_t hash) noexcept {
-    for (const std::uint32_t candidate : hashes) {
-        if (candidate == hash) {
-            return true;
-        }
-    }
-    return false;
-}
-
-/** Checks one decrypted result against its wrapper's weapon and active-class pools. */
-[[nodiscard]] constexpr bool contains_engram_reward(std::uint32_t engramHash,
-                                                    std::uint32_t rewardHash,
-                                                    std::uint8_t characterClass) noexcept {
-    if (engramHash == kLegendaryEngramHash) {
-        return contains(kLegendaryEngramWeapons, rewardHash)
-               || (characterClass == 0 && contains(kLegendaryTitanArmour, rewardHash))
-               || (characterClass == 1 && contains(kLegendaryHunterArmour, rewardHash))
-               || (characterClass == 2 && contains(kLegendaryWarlockArmour, rewardHash));
-    }
-    if (engramHash == kExoticEngramHash) {
-        return contains(kExoticEngramWeapons, rewardHash)
-               || (characterClass == 0 && contains(kExoticTitanArmour, rewardHash))
-               || (characterClass == 1 && contains(kExoticHunterArmour, rewardHash))
-               || (characterClass == 2 && contains(kExoticWarlockArmour, rewardHash));
-    }
-    return false;
-}
+/** Acquisition flag of a direct perk reward, or build_data::rewards::kAbsent for an item or
+ * package. */
+[[nodiscard]] std::uint16_t progress_flag(const build_data::season_pass::Reward& reward) noexcept;
 
 } // namespace sunrise::state::progression::season_pass

+ 487 - 0
Sunrise/src/state/rewards/reward_resolver.cpp

@@ -0,0 +1,487 @@
+#include "reward_resolver.h"
+
+#include <algorithm>
+#include <cmath>
+#include <limits>
+
+#include "../build_data/runtime.h"
+#include "../unlocks/unlocks_expression.h"
+
+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. */
+constexpr std::size_t kDrawCapacity = 64;
+/** A grant quantity is published as a signed 32-bit stack size. */
+constexpr auto kMaximumQuantity =
+    static_cast<std::uint32_t>((std::numeric_limits<std::int32_t>::max)());
+
+bool refuse(const Context& context, const char* reason) noexcept {
+    if (context.refusal != nullptr) {
+        *context.refusal = reason;
+    }
+    return false;
+}
+
+/** 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& inputs = *static_cast<const ConditionInputs*>(raw);
+    const auto& context = inputs.context;
+    const auto index = instruction.operand;
+    switch (instruction.bank) {
+    case unlocks::Bank::account:
+        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;
+        return true;
+    case unlocks::Bank::character:
+        set = context.unlocks.characterObjectFlags[index] == unlocks::kFlagSet;
+        return true;
+    case unlocks::Bank::characterClass:
+        set = index == static_cast<std::uint32_t>(context.characterClass);
+        return true;
+    default:
+        return false;
+    }
+}
+
+/** Reads one validated value from the caller's unlock banks. */
+bool read_value(const void* raw,
+                const unlocks::Instruction& instruction,
+                std::int32_t& value) noexcept {
+    const auto& context = static_cast<const ConditionInputs*>(raw)->context;
+    switch (instruction.bank) {
+    case unlocks::Bank::account:
+        value = context.unlocks.objectiveValues[instruction.operand];
+        return true;
+    case unlocks::Bank::character:
+        value = context.unlocks.characterObjectValues[instruction.operand];
+        return true;
+    default:
+        return false;
+    }
+}
+
+/** 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,
+               const AcquiredFlags* acquiredFlags = nullptr) noexcept {
+    result = program.empty();
+    if (program.empty()) {
+        return true;
+    }
+    for (const auto& instruction : program) {
+        if (!unlocks::valid(instruction)) {
+            return refuse(context, "condition_shape");
+        }
+        if (instruction.bank == unlocks::Bank::external) {
+            return refuse(context,
+                          instruction.opcode == unlocks::Opcode::flag ? "external_flag"
+                                                                      : "external_value");
+        }
+    }
+    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,
+               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,
+                     &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 {
+    definitions::View data;
+    const Context& context;
+    Result& result;
+    std::uint64_t random;
+    std::uint64_t& wrapperSeed;
+    AcquiredFlags& acquiredFlags;
+    bool& excludedByUnlocks;
+    bool& leafDrawn;
+    bool& leafFlagAcquired;
+    Resolution& failure;
+    std::size_t wrapperDepth{};
+    std::array<const definitions::Entry*, kDrawCapacity> selectedEntries{};
+    std::array<std::uint32_t, kDrawCapacity> selectedCategories{};
+    std::size_t selectedCount{};
+
+    double fraction() noexcept {
+        return static_cast<double>(next_random(random) >> 11) * 0x1.0p-53;
+    }
+
+    bool weight(const definitions::Entry& entry,
+                std::uint32_t category,
+                std::size_t depth,
+                double& output) noexcept {
+        output = 0;
+        if (entry.categoryHash != kEmptyTag && entry.categoryHash != category) {
+            return true;
+        }
+        // Draws exclude the selected pool row within its category, not every copy of its item.
+        for (std::size_t i = 0; i < selectedCount; ++i) {
+            if (selectedEntries[i] == &entry && selectedCategories[i] == category) {
+                return true;
+            }
+        }
+        bool enabled = false;
+        if (!condition(data, entry.condition, context, enabled, acquiredFlags)) {
+            return false;
+        }
+        if (!enabled) {
+            excludedByUnlocks = true;
+            return true;
+        }
+        double value = entry.weight;
+        if (!definitions::fits(entry.modifiers, data.modifiers)) {
+            return refuse(context, "modifier_range");
+        }
+        for (const auto& modifier :
+             data.modifiers.subspan(entry.modifiers.first, entry.modifiers.count)) {
+            if (!condition(data, modifier.condition, context, enabled, acquiredFlags)) {
+                return false;
+            }
+            if (enabled) {
+                if (modifier.valueIndex != definitions::kAbsent) {
+                    return refuse(context, "indexed_weight");
+                }
+                // The first matching modifier replaces the weight; a negative value keeps it.
+                if (modifier.value >= 0) {
+                    value = modifier.value;
+                }
+                break;
+            }
+        }
+        if (!std::isfinite(value) || value < 0) {
+            return refuse(context, "weight_shape");
+        }
+        if (value == 0) {
+            // A modifier can change this weight when the unlocks change.
+            excludedByUnlocks |= entry.modifiers.count != 0;
+            return true;
+        }
+        if (entry.poolIndex != definitions::kAbsent) {
+            double total = 0;
+            if (!pool_weight(entry.poolIndex, category, depth + 1, total)) {
+                return false;
+            }
+            if (total == 0) {
+                return true;
+            }
+        } else if (entry.itemIndex != definitions::kAbsent) {
+            if (entry.itemIndex >= data.items.size()) {
+                return refuse(context, "item_range");
+            }
+        } else if (entry.supplementalIndex == definitions::kAbsent) {
+            return refuse(context, "reward_target");
+        }
+        output = value;
+        return true;
+    }
+
+    bool pool_weight(std::uint16_t index,
+                     std::uint32_t category,
+                     std::size_t depth,
+                     double& total) noexcept {
+        total = 0;
+        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 refuse(context, "entry_range");
+        }
+        for (const auto& entry : data.entries.subspan(range.first, range.count)) {
+            double value = 0;
+            if (!weight(entry, category, depth, value)) {
+                return false;
+            }
+            total += value;
+        }
+        return std::isfinite(total) || refuse(context, "weight_shape");
+    }
+
+    bool
+    draw(std::uint16_t index, std::uint32_t category, std::size_t depth, double total) noexcept {
+        const auto range = data.pools[index].entries;
+        double remaining = fraction() * total;
+        const definitions::Entry* chosen = nullptr;
+        for (const auto& entry : data.entries.subspan(range.first, range.count)) {
+            double value = 0;
+            if (!weight(entry, category, depth, value)) {
+                return false;
+            }
+            if (value == 0) {
+                continue;
+            }
+            chosen = &entry;
+            remaining -= value;
+            if (remaining < 0) {
+                break;
+            }
+        }
+        if (chosen == nullptr) {
+            return refuse(context, "empty_pool");
+        }
+        if (chosen->supplementalIndex != definitions::kAbsent && !chosen->supplementalMissing) {
+            return refuse(context, "supplemental_reward");
+        }
+        // A missing supplemental definition does not cancel the selected item grant.
+        if (chosen->poolIndex != definitions::kAbsent) {
+            // A pool reference selects one leaf; quantity belongs to that leaf.
+            double childTotal = 0;
+            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()) {
+            failure = Resolution::exceeded;
+            return refuse(context, "reward_selection_capacity");
+        }
+        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;
+        }
+        if (item.acquiredFlag >= acquiredFlags.size()) {
+            return refuse(context, "acquisition_flag");
+        }
+        acquiredFlags.set(item.acquiredFlag);
+        if (wrapper) {
+            result.wrapperFlags.set(item.acquiredFlag);
+        }
+        return true;
+    }
+
+    /** 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 (!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,
+                           leafFlagAcquired,
+                           failure,
+                           wrapperDepth + 1};
+            if (!child.open_wrapper(item)) {
+                return false;
+            }
+            // A child's flag is set after opening whatever it drew, but only a child that drew
+            // a leaf row makes the resolution non-empty.
+            if (childLeafDrawn) {
+                leafDrawn = true;
+                leafFlagAcquired |= item.acquiredFlag != definitions::kAbsent;
+            }
+            return acquire_flag(item, true);
+        }
+        if (result.count == result.grants.size()) {
+            failure = Resolution::exceeded;
+            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);
+        if (chosen.sockets.count > grant.sockets.size()) {
+            return refuse(context, "socket_capacity");
+        }
+        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;
+    }
+};
+
+bool resolve_item(definitions::View data,
+                  const Context& context,
+                  std::uint16_t itemIndex,
+                  std::uint32_t quantity,
+                  Result& result,
+                  Resolution& failure) noexcept {
+    result = {};
+    if (itemIndex >= data.items.size() || quantity == 0 || quantity > kMaximumQuantity) {
+        return refuse(context, "reward_shape");
+    }
+    const auto& item = data.items[itemIndex];
+    if (item.definitionHash == 0) {
+        return refuse(context, "item_unavailable");
+    }
+    Result staged{};
+    // Stored engrams retain their wrapper until an opening transaction.
+    if (item.poolIndex == definitions::kAbsent
+        || (item.flags & definitions::kOpenOnAcquisition) == 0) {
+        staged.grants[0].itemIndex = itemIndex;
+        staged.grants[0].quantity = static_cast<std::int32_t>(quantity);
+        staged.count = 1;
+    } else {
+        if (quantity != 1) {
+            return refuse(context, "wrapper_quantity");
+        }
+        std::uint64_t wrapperSeed = context.seed;
+        AcquiredFlags acquiredFlags{};
+        bool excludedByUnlocks = false;
+        bool leafDrawn = false;
+        bool leafFlagAcquired = false;
+        Resolver resolver{data,
+                          context,
+                          staged,
+                          context.seed,
+                          wrapperSeed,
+                          acquiredFlags,
+                          excludedByUnlocks,
+                          leafDrawn,
+                          leafFlagAcquired,
+                          failure};
+        if (!resolver.open_wrapper(item)) {
+            return false;
+        }
+        if (staged.count == 0 && !leafFlagAcquired) {
+            failure =
+                excludedByUnlocks && !leafDrawn ? Resolution::ineligible : Resolution::refused;
+            return refuse(context, "empty_reward");
+        }
+        if (!resolver.acquire_flag(item, true)) {
+            return false;
+        }
+    }
+    result = staged;
+    if (context.refusal != nullptr) {
+        *context.refusal = nullptr;
+    }
+    return true;
+}
+
+} // namespace
+
+bool eligible(std::span<const definitions::Instruction> instructions,
+              const Context& context,
+              bool& result) noexcept {
+    return condition(instructions, context, result);
+}
+
+Resolution resolve(const Context& context,
+                   std::uint16_t itemIndex,
+                   std::uint32_t quantity,
+                   Result& result) noexcept {
+    struct Request {
+        const Context& context;
+        std::uint16_t item;
+        std::uint32_t quantity;
+        Result& result;
+        Resolution failure;
+    } request{context, itemIndex, quantity, result, Resolution::refused};
+    result = {};
+    if (context.refusal != nullptr) {
+        *context.refusal = "reward_item";
+    }
+    if (build_data::read_reward_definitions(
+            &request, [](void* raw, definitions::View data) noexcept {
+                auto& value = *static_cast<Request*>(raw);
+                return resolve_item(
+                    data, value.context, value.item, value.quantity, value.result, value.failure);
+            })) {
+        return Resolution::resolved;
+    }
+    return request.failure;
+}
+
+} // namespace sunrise::state::reward_resolution

+ 50 - 0
Sunrise/src/state/rewards/reward_resolver.h

@@ -0,0 +1,50 @@
+#pragma once
+
+#include <bitset>
+
+#include "../account/account_state.h"
+#include "../build_data/rewards/definition.h"
+#include "../unlocks/definition.h"
+
+namespace sunrise::state::reward_resolution {
+
+/** One item a resolved reward grants, with the socket overrides its row carries. */
+struct Grant {
+    std::uint16_t itemIndex{build_data::rewards::kAbsent};
+    std::int32_t quantity{};
+    std::array<build_data::rewards::SocketOverride, build_data::rewards::kSocketsPerItem> sockets{};
+    std::size_t socketCount{};
+};
+
+/** Every grant one resolution produces, in draw order. */
+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. */
+struct Context {
+    const unlocks::Table& unlocks;
+    CharacterClass characterClass{};
+    std::uint64_t seed{};
+    /** Optional refusal reason; points to a static diagnostic label. */
+    const char** refusal{};
+};
+
+/** Evaluates one bound condition; an empty condition is always eligible. */
+[[nodiscard]] bool eligible(std::span<const build_data::rewards::Instruction> instructions,
+                            const Context& context,
+                            bool& result) noexcept;
+
+/** Ineligible: the saved unlocks left nothing to draw. Exceeded: this draw outgrew a bound. */
+enum class Resolution : std::uint8_t { resolved, ineligible, exceeded, refused };
+
+/** Plans an acquisition without changing State; retains wrappers opened separately. */
+[[nodiscard]] Resolution resolve(const Context& context,
+                                 std::uint16_t itemIndex,
+                                 std::uint32_t quantity,
+                                 Result& result) noexcept;
+
+} // namespace sunrise::state::reward_resolution

+ 66 - 38
Sunrise/src/state/runtime/runtime.h

@@ -5,13 +5,19 @@
 #include <cstdint>
 #include <optional>
 #include <span>
-#include <variant>
+#include <vector>
 
 #include "../build_data/items/quest_initialization.h"
 #include "../build_data/records/definition.h"
+#include "../build_data/rewards/definition.h"
 #include "../build_data/vendors/definition.h"
+#include "../unlocks/definition.h"
 #include "state.h"
 
+namespace sunrise::state::unlocks {
+struct Table;
+}
+
 namespace sunrise::state::account::settings {
 
 struct SettingsDelta;
@@ -20,6 +26,10 @@ struct SettingsDelta;
 
 namespace sunrise::state {
 
+/** Which prepare path grants one installed item. */
+enum class ItemGrantRoute : std::uint8_t { unavailable, quest, profile, reward };
+/** Chooses the prepare path from the item's wrapper, quest setup and bucket. */
+[[nodiscard]] ItemGrantRoute item_grant_route(std::uint16_t itemIndex) noexcept;
 /**
  * Assigns runtime SOIDs only to installed profile mod/shader rows which are socket action sources.
  * Currency, material, and consumable profile rows remain canonically non-instanced.
@@ -199,34 +209,23 @@ struct PendingProfileItemAcquisition {
     bool prepared{};
 };
 
-/** Prepared fixed package expansion kept private until every object and response byte fits. */
-struct PendingDirectItemBundle {
-    CharacterState beforeCharacter{};
-    CharacterState afterCharacter{};
-    std::uint64_t accountSoid{};
-    std::uint64_t characterSoid{};
-    std::uint64_t firstInstanceSoid{};
-    std::uint32_t sourceDefinitionHash{};
-    std::size_t characterIndex{};
-    std::size_t expectedInventoryCount{};
-    std::size_t itemCount{};
-    bool prepared{};
-};
-
-/** Shared batch capacity covers both Triumph rewards and the nine-row Season package. */
-inline constexpr std::size_t kRecordRewardGrantCapacity = 9;
+/** Shared batch capacity bounds one reward transaction. */
+inline constexpr std::size_t kRecordRewardGrantCapacity = build_data::rewards::kGrantCapacity;
 static_assert(kRecordRewardGrantCapacity >= build_data::records::kRewardPerRecordCapacity);
 
 /** One direct item requested by a record reward policy. */
 struct DirectRecordReward {
     std::uint16_t itemDefinitionIndex{};
     std::int32_t quantity{};
+    std::span<const build_data::rewards::SocketOverride> sockets{};
+    bool acquireUnlock{};
 };
 
 enum class RecordRewardKind : std::uint8_t {
     characterInstance,
     characterStack,
     profileStack,
+    accountUnlock,
 };
 
 /** Native row identity of one item inside a prepared record-reward batch. */
@@ -240,6 +239,14 @@ struct PreparedRecordReward {
     std::uint16_t inventoryRow{};
     RecordRewardKind kind{};
     bool appendedProfileResident{};
+    std::uint16_t acquiredFlag{build_data::rewards::kAbsent};
+    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. */
@@ -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{};
@@ -265,14 +274,14 @@ struct PendingRecordRewardGrant {
     bool prepared{};
 };
 
-/** One uncommitted Season reward and the exact native row or bundle it will claim. */
+/** 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 {
-    std::variant<PendingItemAcquisition,
-                 PendingProfileItemAcquisition,
-                 PendingDirectItemBundle,
-                 PendingRecordRewardGrant>
-        grant{};
+    PendingRecordRewardGrant grant{};
     std::uint32_t sourceDefinitionHash{};
+    std::uint64_t seed{};
     std::uint16_t rewardIndex{};
     bool prepared{};
 };
@@ -537,17 +546,30 @@ prepare_item_acquisition(std::uint16_t collectibleIndex,
 [[nodiscard]] bool prepare_item_acquisition_for_item(std::uint16_t itemDefinitionIndex,
                                                      PendingItemAcquisition& mutation) noexcept;
 
-/** Prepares one fixed wrapper expansion without changing account State. */
-[[nodiscard]] bool prepare_direct_item_bundle(std::uint32_t sourceDefinitionHash,
-                                              std::span<const std::uint16_t> itemDefinitionIndices,
-                                              PendingDirectItemBundle& mutation) noexcept;
-
-/** Builds the full account after-image while a prepared bundle remains current. */
-[[nodiscard]] bool preview_direct_item_bundle(const PendingDirectItemBundle& mutation,
-                                              AccountState& after) noexcept;
-
 /** Atomically commits one prepared reward grant and its durable Season claim. */
 [[nodiscard]] bool commit_season_pass_reward(PendingSeasonPassReward& mutation) noexcept;
+/**
+ * Resolves an installed item or wrapper for the selected character.
+ * @param seed Caller-drawn entropy for any wrapper draws.
+ * @param refusal Receives a static reason on failure.
+ */
+[[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.
+ * @param refusal Receives a static reason on failure.
+ */
+[[nodiscard]] bool prepare_season_pass_reward(std::uint16_t rewardIndex,
+                                              std::uint64_t seed,
+                                              PendingSeasonPassReward& mutation,
+                                              const char** refusal = nullptr) noexcept;
+/** Applies a prepared grant's acquisition flags to the saved banks; false if one moved. */
+[[nodiscard]] bool preview_reward_unlocks(const PendingRecordRewardGrant& mutation,
+                                          unlocks::Table& after) noexcept;
 
 /** Atomically commits one prepared Triumph reward and its durable record claim. */
 [[nodiscard]] bool commit_record_reward(PendingRecordRewardGrant& mutation) noexcept;
@@ -557,11 +579,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) 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,
@@ -765,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. */
@@ -775,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.
  */

+ 0 - 175
Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp

@@ -261,100 +261,6 @@ bool prepare_item_acquisition_for_item(std::uint16_t itemDefinitionIndex,
         account, account, grantedDefinition.definitionHash, false, {.direct = true}, mutation);
 }
 
-/** Prepares a fixed Season wrapper expansion without exposing a partial package. */
-bool prepare_direct_item_bundle(std::uint32_t sourceDefinitionHash,
-                                std::span<const std::uint16_t> itemDefinitionIndices,
-                                PendingDirectItemBundle& mutation) noexcept {
-    mutation = {};
-    build_data::season_pass::Package package{};
-    if (!build_data::find_season_pass_package(sourceDefinitionHash, package)
-        || itemDefinitionIndices.size() != package.itemCount) {
-        return false;
-    }
-
-    std::array<std::uint32_t, build_data::season_pass::kPackageItemCapacity> hashes{};
-    for (std::size_t index = 0; index < itemDefinitionIndices.size(); ++index) {
-        const std::uint16_t definitionIndex = itemDefinitionIndices[index];
-        build_data::items::Definition definition{};
-        item_details::Definition detail{};
-        inventory_buckets::Descriptor bucket{};
-        if (!build_data::find_item_definition_index(definitionIndex, definition)
-            || definition.definitionHash != package.items[index]
-            || !build_data::find_configured_item_detail(definitionIndex, detail)
-            || detail.definitionIndex != definition.definitionIndex
-            || detail.definitionHash != definition.definitionHash
-            || detail.bucketId != definition.bucketId
-            || detail.instancedDefinitionState != item_details::InstancedDefinitionState::instanced
-            || !detail.equipmentSlot.has_value()
-            || !build_data::find_inventory_bucket_descriptor(detail.bucketId, bucket)
-            || bucket.arraySelector != inventory_buckets::ArraySelector::character) {
-            return false;
-        }
-        hashes[index] = definition.definitionHash;
-    }
-
-    const AccountState account = account_snapshot();
-    const std::size_t characterIndex = selected_character_index(account);
-    if (!account::valid(account) || characterIndex >= account.characterCount) {
-        return false;
-    }
-    const CharacterState& before = account.characters[characterIndex];
-    if (before.nextInventorySerial
-            > static_cast<std::uint32_t>((std::numeric_limits<std::int32_t>::max)())
-        || itemDefinitionIndices.size() > before.inventory.values.size() - before.inventory.count
-        || itemDefinitionIndices.size()
-               > static_cast<std::size_t>((std::numeric_limits<std::int32_t>::max)())
-                     - before.nextInventorySerial) {
-        return false;
-    }
-
-    std::uint64_t firstSoid = 0;
-    if (!next_item_instance_soid(account, firstSoid)
-        || itemDefinitionIndices.size() - 1U
-               > (std::numeric_limits<std::uint64_t>::max)() - firstSoid) {
-        return false;
-    }
-
-    CharacterState after = before;
-    const std::int32_t level = acquisition_level(before);
-    for (std::size_t index = 0; index < itemDefinitionIndices.size(); ++index) {
-        authored_inventory::Item granted{};
-        granted.instanceSoid = firstSoid + index;
-        granted.definitionHash = hashes[index];
-        granted.level = level;
-        granted.quantity = 1;
-        granted.mutationSerial = static_cast<std::int32_t>(after.nextInventorySerial++);
-        after.inventory.values[after.inventory.count++] = granted;
-    }
-
-    AccountState candidate = account;
-    candidate.characters[characterIndex] = after;
-    family4_loadout::ResolvedLoadout resolved{};
-    if (!account::valid(candidate)
-        || !family4_loadout::resolve(candidate, characterIndex, resolved)) {
-        return false;
-    }
-    for (std::size_t index = 0; index < itemDefinitionIndices.size(); ++index) {
-        std::uint16_t row = 0;
-        std::uint8_t slot = 0;
-        if (!find_unequipped_row(resolved, firstSoid + index, row, slot)) {
-            return false;
-        }
-    }
-
-    mutation.beforeCharacter = before;
-    mutation.afterCharacter = after;
-    mutation.accountSoid = account.primarySoid;
-    mutation.characterSoid = before.soid;
-    mutation.firstInstanceSoid = firstSoid;
-    mutation.sourceDefinitionHash = sourceDefinitionHash;
-    mutation.characterIndex = characterIndex;
-    mutation.expectedInventoryCount = before.inventory.count;
-    mutation.itemCount = itemDefinitionIndices.size();
-    mutation.prepared = true;
-    return true;
-}
-
 /**
  * Takes the selected character's next inventory mutation serial under the State lock.
  * @param mutationSerial Receives the reserved serial; zero when nothing was reserved.
@@ -475,80 +381,6 @@ valid_item_acquisition_source(const PendingItemAcquisition& mutation) noexcept {
            && row == mutation.inventoryRow && slot == mutation.equipmentSlot;
 }
 
-/** Rebuilds one package from installed policy and rejects any altered after-image. */
-[[nodiscard]] bool materialize_direct_item_bundle(const AccountState& current,
-                                                  const PendingDirectItemBundle& mutation,
-                                                  AccountState& after) noexcept {
-    build_data::season_pass::Package package{};
-    std::uint64_t firstSoid = 0;
-    if (!mutation.prepared
-        || !build_data::find_season_pass_package(mutation.sourceDefinitionHash, package)
-        || mutation.itemCount != package.itemCount || mutation.accountSoid == 0
-        || mutation.characterSoid == 0 || mutation.firstInstanceSoid == 0
-        || mutation.characterIndex >= current.characterCount
-        || mutation.expectedInventoryCount >= authored_inventory::kCharacterItemCapacity
-        || current.primarySoid != mutation.accountSoid
-        || !same_character(current.characters[mutation.characterIndex], mutation.beforeCharacter)
-        || !current.characters[mutation.characterIndex].selected
-        || current.characters[mutation.characterIndex].soid != mutation.characterSoid
-        || mutation.beforeCharacter.inventory.count != mutation.expectedInventoryCount
-        || mutation.itemCount
-               > mutation.beforeCharacter.inventory.values.size() - mutation.expectedInventoryCount
-        || mutation.beforeCharacter.nextInventorySerial
-               > static_cast<std::uint32_t>((std::numeric_limits<std::int32_t>::max)())
-        || mutation.itemCount > static_cast<std::size_t>((std::numeric_limits<std::int32_t>::max)())
-                                    - mutation.beforeCharacter.nextInventorySerial
-        || !next_item_instance_soid(current, firstSoid) || firstSoid != mutation.firstInstanceSoid
-        || mutation.itemCount - 1U > (std::numeric_limits<std::uint64_t>::max)() - firstSoid) {
-        return false;
-    }
-
-    CharacterState canonical = mutation.beforeCharacter;
-    const std::int32_t level = acquisition_level(canonical);
-    for (std::size_t index = 0; index < mutation.itemCount; ++index) {
-        build_data::items::Definition definition{};
-        item_details::Definition detail{};
-        inventory_buckets::Descriptor bucket{};
-        if (!build_data::find_item_definition_hash(package.items[index], definition)
-            || !build_data::find_configured_item_detail(definition.definitionIndex, detail)
-            || detail.definitionHash != definition.definitionHash
-            || detail.definitionIndex != definition.definitionIndex
-            || detail.bucketId != definition.bucketId
-            || detail.instancedDefinitionState != item_details::InstancedDefinitionState::instanced
-            || !detail.equipmentSlot.has_value()
-            || !build_data::find_inventory_bucket_descriptor(detail.bucketId, bucket)
-            || bucket.arraySelector != inventory_buckets::ArraySelector::character) {
-            return false;
-        }
-        authored_inventory::Item granted{};
-        granted.instanceSoid = firstSoid + index;
-        granted.definitionHash = package.items[index];
-        granted.level = level;
-        granted.quantity = 1;
-        granted.mutationSerial = static_cast<std::int32_t>(canonical.nextInventorySerial++);
-        canonical.inventory.values[canonical.inventory.count++] = granted;
-    }
-    if (!same_character(canonical, mutation.afterCharacter)) {
-        return false;
-    }
-
-    after = current;
-    after.characters[mutation.characterIndex] = canonical;
-    family4_loadout::ResolvedLoadout resolved{};
-    if (!account::valid(after)
-        || !family4_loadout::resolve(after, mutation.characterIndex, resolved)) {
-        return false;
-    }
-    for (std::size_t index = 0; index < mutation.itemCount; ++index) {
-        std::uint16_t row = 0;
-        std::uint8_t slot = 0;
-        if (!find_unequipped_row(resolved, firstSoid + index, row, slot)) {
-            return false;
-        }
-    }
-    return true;
-}
-
 } // namespace runtime::detail
 
 /**
@@ -579,13 +411,6 @@ bool preview_item_acquisition(const PendingItemAcquisition& mutation,
     return true;
 }
 
-/** Produces the full account after-image while a prepared package remains current. */
-bool preview_direct_item_bundle(const PendingDirectItemBundle& mutation,
-                                AccountState& after) noexcept {
-    after = {};
-    return materialize_direct_item_bundle(account_snapshot(), mutation, after);
-}
-
 /**
  * Inventory and first-step state share one transaction; failure rolls both back.
  * @param mutation Prepared grant consumed on either success or failure.

+ 652 - 102
Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp

@@ -2,17 +2,17 @@
  * Grants that no purchase pays for: season pass rewards, record rewards, and the
  * default emote collection.
  */
-#include <Windows.h>
-
 #include <array>
 #include <cstddef>
 #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"
-#include "../progression/season_pass_reward_catalog.h"
+#include "../rewards/reward_resolver.h"
 #include "../unlocks/unlocks_records.h"
 #include "runtime.h"
 #include "state_account_transaction_helpers.h"
@@ -28,90 +28,480 @@ 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;
 
-/**
- * Checks that a staged grant is the one the authored reward row describes.
- * @param reward Authored season pass reward row.
- * @return False when the grant's item, quantity or bundle does not match the row.
- */
+/** 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,
+                     std::span<const build_data::rewards::SocketOverride> overrides,
+                     authored_inventory::Sockets& sockets,
+                     const char** refusal = nullptr) noexcept {
+    const auto fail = [&](const char* reason) noexcept {
+        if (refusal != nullptr) {
+            *refusal = reason;
+        }
+        return false;
+    };
+    if (overrides.empty()) {
+        return true;
+    }
+    if (detail.ordinarySocketCount > sockets.plugs.size()) {
+        return fail("socket_layout");
+    }
+    authored_inventory::Sockets staged{};
+    staged.policy = authored_inventory::SocketPolicy::authored;
+    staged.plugCount = detail.ordinarySocketCount;
+    for (std::size_t lane = 0; lane < staged.plugCount; ++lane) {
+        const auto index = detail.initialPlugIndices[lane];
+        if (index == item_details::kUnavailableItemIndex) {
+            continue;
+        }
+        build_data::items::Definition plug{};
+        if (!build_data::find_item_definition_index(index, plug)) {
+            return fail("socket_layout");
+        }
+        staged.plugs[lane] = plug.definitionHash;
+    }
+    std::array<bool, item_details::kInitialPlugCapacity> replaced{};
+    for (const auto& override : overrides) {
+        if (override.plugSet != build_data::rewards::kAbsent) {
+            return fail("socket_plug_set");
+        }
+        if (override.rollSet != build_data::rewards::kAbsent) {
+            return fail("socket_roll_set");
+        }
+        // Zero selection preserves the initial plug in every lane of the named type.
+        const bool preserve =
+            override.plugItem == build_data::rewards::kAbsent && override.selection == 0;
+        if (!preserve && override.selection != build_data::rewards::kFixedPlugSelection) {
+            return fail("socket_selection");
+        }
+        build_data::items::Definition plug{};
+        if (!preserve && !build_data::find_item_definition_index(override.plugItem, plug)) {
+            return fail("socket_layout");
+        }
+        bool found = false;
+        for (std::size_t lane = 0; lane < staged.plugCount; ++lane) {
+            if (detail.socketTypes[lane] != override.socketType) {
+                continue;
+            }
+            if (replaced[lane] || (!preserve && found)) {
+                return fail("socket_layout");
+            }
+            replaced[lane] = true;
+            found = true;
+            if (!preserve) {
+                staged.plugs[lane] = plug.definitionHash;
+            }
+        }
+        if (!found) {
+            return fail("socket_layout");
+        }
+    }
+    if (!authored_inventory::valid(staged)) {
+        return fail("socket_layout");
+    }
+    sockets = staged;
+    return true;
+}
+
+[[nodiscard]] RewardPreparation prepare_resolved_reward(const reward_resolution::Result& resolved,
+                                                        PendingRecordRewardGrant& mutation,
+                                                        const char** refusal) noexcept {
+    std::array<DirectRecordReward, kRecordRewardGrantCapacity> rows{};
+    if (resolved.count > rows.size()) {
+        return RewardPreparation::unresolvable;
+    }
+    for (std::size_t i = 0; i < resolved.count; ++i) {
+        const auto& grant = resolved.grants[i];
+        rows[i] = {grant.itemIndex,
+                   grant.quantity,
+                   std::span(grant.sockets).first(grant.socketCount),
+                   true};
+    }
+    return prepare_reward_grant(std::span(rows).first(resolved.count),
+                                kUnclaimedRecordIndex,
+                                &resolved.wrapperFlags,
+                                mutation,
+                                refusal);
+}
+
+/** Finds the kept unsocketed grant of this item, or null. */
+[[nodiscard]] reward_resolution::Grant* kept_stack(reward_resolution::Result& resolved,
+                                                   std::size_t kept,
+                                                   std::uint16_t itemIndex) noexcept {
+    for (std::size_t prior = 0; prior < kept; ++prior) {
+        auto& grant = resolved.grants[prior];
+        if (grant.itemIndex == itemIndex && grant.socketCount == 0) {
+            return &grant;
+        }
+    }
+    return nullptr;
+}
+
+/** Folds repeat draws of a stackable item into its first draw; false when one stack overflows. */
+[[nodiscard]] bool consolidate_stacks(reward_resolution::Result& resolved) noexcept {
+    std::size_t kept = 0;
+    for (std::size_t index = 0; index < resolved.count; ++index) {
+        const reward_resolution::Grant grant = resolved.grants[index];
+        build_data::items::Definition item{};
+        item_details::Definition detail{};
+        // A perk grants its account flag, never an inventory stack.
+        const bool inventoryStack =
+            grant.socketCount == 0 && build_data::find_item_definition_index(grant.itemIndex, item)
+            && item.bucketId != inventory_buckets::kPerkBucketId
+            && build_data::find_configured_item_detail(grant.itemIndex, detail)
+            && detail.instancedDefinitionState == item_details::InstancedDefinitionState::stackable;
+        auto* const merged = inventoryStack ? kept_stack(resolved, kept, grant.itemIndex) : nullptr;
+        if (merged == nullptr) {
+            resolved.grants[kept++] = grant;
+            continue;
+        }
+        if (static_cast<std::int64_t>(merged->quantity) + grant.quantity > detail.maxStackSize) {
+            return false;
+        }
+        merged->quantity += grant.quantity;
+    }
+    resolved.count = kept;
+    return true;
+}
+
+/** 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{};
+    if (!build_data::find_reward_item(item.definitionIndex, reward)) {
+        return false;
+    }
+    return reward.poolIndex != build_data::rewards::kAbsent
+           || (item.bucketId == inventory_buckets::kPerkBucketId
+               && reward.acquiredFlag != build_data::rewards::kAbsent);
+}
+
+enum class PassResolution { claim, replay };
+
+[[nodiscard]] bool resolve_pass(const build_data::season_pass::Reward& reward,
+                                const AccountState& account,
+                                std::uint64_t seed,
+                                reward_resolution::Result& result,
+                                PassResolution resolution,
+                                const char** reason = nullptr) noexcept {
+    const auto character = selected_character_index(account);
+    unlocks::Table flags{};
+    build_data::rewards::Item source{};
+    if (character >= account.characterCount
+        || !investment::store::read_unlocks(flags, static_cast<int>(character))
+        || !build_data::find_reward_item(reward.itemIndex, source)
+        || source.definitionHash != reward.itemHash || reward.socketCount > reward.sockets.size()
+        || reward.conditionCount > reward.condition.size()) {
+        return false;
+    }
+    if (reason != nullptr) {
+        *reason = "reward_condition";
+    }
+    if (resolution == PassResolution::replay && reward.claimFlagIndex < flags.accountFlags.size()) {
+        flags.accountFlags[reward.claimFlagIndex] = unlocks::kFlagClear;
+    }
+    bool enabled = false;
+    if (!reward_resolution::eligible(
+            std::span(reward.condition).first(reward.conditionCount),
+            {flags, account.characters[character].characterClass, seed, reason},
+            enabled)
+        || !enabled) {
+        return false;
+    }
+    if (reward_resolution::resolve(
+            {flags, account.characters[character].characterClass, seed, reason},
+            reward.itemIndex,
+            reward.quantity,
+            result)
+        != reward_resolution::Resolution::resolved) {
+        return false;
+    }
+    if (!consolidate_stacks(result)) {
+        if (reason != nullptr) {
+            *reason = "stack_capacity";
+        }
+        return false;
+    }
+    if (reward.socketCount != 0) {
+        if (result.count != 1 || result.grants[0].itemIndex != reward.itemIndex) {
+            if (reason != nullptr) {
+                *reason = "socket_owner";
+            }
+            return false;
+        }
+        result.grants[0].sockets = reward.sockets;
+        result.grants[0].socketCount = reward.socketCount;
+    }
+    return true;
+}
+
 [[nodiscard]] bool reward_matches(const build_data::season_pass::Reward& reward,
                                   const PendingSeasonPassReward& mutation) noexcept {
-    if (!mutation.prepared || mutation.sourceDefinitionHash != reward.itemHash) {
+    const auto* grant = &mutation.grant;
+    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.wrapperFlags.count() != grant->wrapperFlags.size()) {
         return false;
     }
-    if (const auto* item = std::get_if<PendingItemAcquisition>(&mutation.grant)) {
-        if (reward.quantity != 1) {
+    for (const auto& flag : grant->wrapperFlags) {
+        if (flag.index >= expected.wrapperFlags.size() || !expected.wrapperFlags[flag.index]) {
             return false;
         }
-        if (reward.itemHash != progression::season_pass::kLegendaryEngramHash
-            && reward.itemHash != progression::season_pass::kExoticEngramHash) {
-            return item->acquiredDefinitionHash == reward.itemHash;
-        }
-        return progression::season_pass::contains_engram_reward(
-            reward.itemHash,
-            item->acquiredDefinitionHash,
-            static_cast<std::uint8_t>(item->afterCharacter.characterClass));
-    }
-    if (const auto* profile = std::get_if<PendingProfileItemAcquisition>(&mutation.grant)) {
-        return profile->acquiredDefinitionHash == reward.itemHash
-               && profile->acquiredQuantity - profile->previousQuantity
-                      == static_cast<std::int32_t>(reward.quantity);
-    }
-    if (const auto* bundle = std::get_if<PendingDirectItemBundle>(&mutation.grant)) {
-        build_data::season_pass::Package package{};
-        return reward.quantity == 1 && bundle->sourceDefinitionHash == reward.itemHash
-               && build_data::find_season_pass_package(reward.itemHash, package);
-    }
-    const auto* resources = std::get_if<PendingRecordRewardGrant>(&mutation.grant);
-    if (resources == nullptr || reward.quantity != 1
-        || reward.itemHash != progression::season_pass::kDestinationResourceBundleHash
-        || resources->rewardCount != progression::season_pass::kDestinationResourceHashes.size()) {
-        return false;
     }
-    for (std::size_t index = 0; index < resources->rewardCount; ++index) {
-        if (resources->rewards[index].definitionHash
-                != progression::season_pass::kDestinationResourceHashes[index]
-            || resources->rewards[index].quantity
-                   != progression::season_pass::kDestinationResourceQuantity) {
+    for (std::size_t i = 0; i < expected.count; ++i) {
+        const auto& planned = expected.grants[i];
+        const auto& prepared = grant->rewards[i];
+        build_data::items::Definition definition{};
+        build_data::rewards::Item source{};
+        if (!build_data::find_item_definition_index(planned.itemIndex, definition)
+            || !build_data::find_reward_item(planned.itemIndex, source)
+            || prepared.definitionHash != definition.definitionHash
+            || prepared.quantity != planned.quantity
+            || prepared.acquiredFlag != source.acquiredFlag) {
             return false;
         }
+        if (planned.socketCount != 0) {
+            item_details::Definition detail{};
+            authored_inventory::Sockets sockets{};
+            if (prepared.kind != RecordRewardKind::characterInstance
+                || prepared.stateIndex >= grant->afterCharacter.inventory.count
+                || !build_data::find_configured_item_detail(planned.itemIndex, detail)
+                || !apply_reward_sockets(
+                    detail, std::span(planned.sockets).first(planned.socketCount), sockets)) {
+                return false;
+            }
+            const auto& installed =
+                grant->afterCharacter.inventory.values[prepared.stateIndex].sockets;
+            if (installed.policy != sockets.policy || installed.plugCount != sockets.plugCount
+                || installed.plugs != sockets.plugs) {
+                return false;
+            }
+        }
     }
     return true;
 }
 
 } // namespace
 
-/** Commits a reward and its claim together after every outbound byte has been staged. */
-bool commit_season_pass_reward(PendingSeasonPassReward& mutation) noexcept {
-    const PendingConsumption consume{mutation};
+bool prepare_season_pass_reward(std::uint16_t rewardIndex,
+                                std::uint64_t seed,
+                                PendingSeasonPassReward& mutation,
+                                const char** refusal) noexcept {
+    const char* unused = nullptr;
+    auto& reason = refusal != nullptr ? *refusal : unused;
+    reason = "reward_index";
+    mutation = {};
+    const std::lock_guard lock(investment::store::g_mutex);
     build_data::season_pass::Reward reward{};
-    if (!build_data::find_season_pass_reward(mutation.rewardIndex, reward)
-        || !reward_matches(reward, mutation)) {
-        revoke_season_pass_reward(mutation.rewardIndex);
+    if (!build_data::find_season_pass_reward(rewardIndex, reward)) {
+        return false;
+    }
+    reason = "rank";
+    if (reward.requiredRank > seasonal_rank()) {
+        return false;
+    }
+    reason = "already_claimed";
+    if (season_pass_reward_claimed(rewardIndex)) {
+        return false;
+    }
+    mutation.seed = seed;
+    reward_resolution::Result resolved{};
+    reason = "reward_condition";
+    if (!resolve_pass(
+            reward, account_snapshot(), mutation.seed, resolved, PassResolution::claim, &reason)) {
+        return false;
+    }
+    reason = "reward_placement";
+    if (prepare_resolved_reward(resolved, mutation.grant, &reason) != RewardPreparation::prepared) {
         return false;
     }
+    reason = nullptr;
+    mutation.sourceDefinitionHash = reward.itemHash;
+    mutation.rewardIndex = rewardIndex;
+    mutation.prepared = true;
+    return true;
+}
 
-    investment::store::g_mutex.lock();
-    AccountState after{};
+ItemGrantRoute item_grant_route(std::uint16_t itemIndex) noexcept {
+    build_data::items::Definition item{};
+    if (!build_data::find_item_definition_index(itemIndex, item)) {
+        return ItemGrantRoute::unavailable;
+    }
+    if (uses_reward_definition(item)) {
+        return ItemGrantRoute::reward;
+    }
+    if (item.questInitialization.scope != build_data::items::QuestInitialization::Scope::none) {
+        return ItemGrantRoute::quest;
+    }
+    item_details::Definition detail{};
+    inventory_buckets::Descriptor bucket{};
+    if (!build_data::find_configured_item_detail(itemIndex, detail)
+        || !build_data::find_inventory_bucket_descriptor(item.bucketId, bucket)) {
+        return ItemGrantRoute::unavailable;
+    }
+    if (bucket.arraySelector == inventory_buckets::ArraySelector::profile
+        && detail.instancedDefinitionState == item_details::InstancedDefinitionState::stackable) {
+        return ItemGrantRoute::profile;
+    }
+    return bucket.arraySelector == inventory_buckets::ArraySelector::character
+               ? ItemGrantRoute::reward
+               : ItemGrantRoute::unavailable;
+}
+
+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 = "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 catalog_refusal(reason);
+    }
+    reason = "selected_character";
+    const AccountState account = account_snapshot();
+    const auto character = selected_character_index(account);
+    unlocks::Table flags{};
+    if (character >= account.characterCount
+        || !investment::store::read_unlocks(flags, static_cast<int>(character))) {
+        return RewardPreparation::deferred;
+    }
+    reward_resolution::Result resolved{};
+    const auto resolution = reward_resolution::resolve(
+        {flags, account.characters[character].characterClass, seed, &reason},
+        itemIndex,
+        quantity,
+        resolved);
+    // Unlocks can change and another draw can fit, so these are retried, not retired.
+    if (resolution == reward_resolution::Resolution::ineligible
+        || resolution == reward_resolution::Resolution::exceeded) {
+        return RewardPreparation::deferred;
+    }
+    if (resolution != reward_resolution::Resolution::resolved) {
+        return catalog_refusal(reason);
+    }
+    reason = "stack_capacity";
+    if (!consolidate_stacks(resolved)) {
+        return RewardPreparation::deferred;
+    }
+    reason = "reward_placement";
+    return prepare_resolved_reward(resolved, mutation, &reason);
+}
+
+bool preview_reward_unlocks(const PendingRecordRewardGrant& mutation,
+                            unlocks::Table& after) noexcept {
+    if (!mutation.prepared || mutation.rewardCount > mutation.rewards.size()
+        || !investment::store::read_unlocks(after, static_cast<int>(mutation.characterIndex))) {
+        return false;
+    }
+    for (std::size_t i = 0; i < mutation.rewardCount; ++i) {
+        const auto& reward = mutation.rewards[i];
+        if (reward.acquiredFlag == build_data::rewards::kAbsent) {
+            continue;
+        }
+        if (reward.acquiredFlag >= after.accountFlags.size()
+            || after.accountFlags[reward.acquiredFlag] != reward.previousFlag) {
+            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) {
+            after.accountFlags[flag] = unlocks::kFlagSet;
+        }
+    }
+    return true;
+}
+
+/** Revalidates the prepared reward under the lock that commits its inventory and flags. */
+bool commit_season_pass_reward(PendingSeasonPassReward& mutation) noexcept {
+    if (!mutation.prepared) {
+        return false;
+    }
+    const PendingConsumption consume{mutation};
     bool ready = false;
-    if (const auto* item = std::get_if<PendingItemAcquisition>(&mutation.grant)) {
-        ready = materialize_item_acquisition(investment::store::account(), *item, after);
-    } else if (const auto* profile = std::get_if<PendingProfileItemAcquisition>(&mutation.grant)) {
-        ready = materialize_profile_acquisition(investment::store::account(), *profile, after);
-    } else if (const auto* bundle = std::get_if<PendingDirectItemBundle>(&mutation.grant)) {
-        ready = materialize_direct_item_bundle(investment::store::account(), *bundle, after);
-    } else if (const auto* resources = std::get_if<PendingRecordRewardGrant>(&mutation.grant)) {
-        ready = materialize_record_reward(investment::store::account(), *resources, after);
-    }
-    if (ready) {
-        ready = investment::store::write_account(after);
+    {
+        investment::store::Transaction transaction;
+        build_data::season_pass::Reward reward{};
+        ready = transaction.ready()
+                && build_data::find_season_pass_reward(mutation.rewardIndex, reward)
+                && season_pass_reward_claimed(mutation.rewardIndex)
+                && reward.requiredRank <= seasonal_rank() && reward_matches(reward, mutation)
+                && commit_record_reward(mutation.grant) && transaction.commit();
     }
-    investment::store::g_mutex.unlock();
     if (!ready) {
-        // The claim was written when the reward was prepared, so a refused install undoes it.
         revoke_season_pass_reward(mutation.rewardIndex);
     }
     return ready;
@@ -128,9 +518,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(
@@ -157,8 +549,21 @@ namespace {
         inventory_buckets::Descriptor bucket{};
         if (reward.definitionHash == authored_inventory::kNoDefinitionHash || reward.quantity <= 0
             || reward.afterQuantity < reward.quantity || reward.mutationSerial < 0
-            || !build_data::find_item_definition_hash(reward.definitionHash, item)
-            || !build_data::find_configured_item_detail(item.definitionIndex, detail)
+            || !build_data::find_item_definition_hash(reward.definitionHash, item)) {
+            return false;
+        }
+        if (reward.kind == RecordRewardKind::accountUnlock) {
+            build_data::rewards::Item source{};
+            if (reward.quantity != 1 || reward.afterQuantity != 1 || reward.instanceSoid != 0
+                || item.bucketId != build_data::inventory::buckets::kPerkBucketId
+                || !build_data::find_reward_item(item.definitionIndex, source)
+                || source.acquiredFlag == build_data::rewards::kAbsent
+                || source.acquiredFlag != reward.acquiredFlag) {
+                return false;
+            }
+            continue;
+        }
+        if (!build_data::find_configured_item_detail(item.definitionIndex, detail)
             || detail.definitionIndex != item.definitionIndex
             || detail.definitionHash != item.definitionHash || detail.bucketId != item.bucketId
             || !build_data::find_inventory_bucket_descriptor(item.bucketId, bucket)) {
@@ -166,7 +571,7 @@ namespace {
         }
         if (reward.kind == RecordRewardKind::characterInstance) {
             if (reward.quantity != 1 || reward.afterQuantity != 1 || reward.instanceSoid == 0
-                || reward.appendedProfileResident || !detail.equipmentSlot.has_value()
+                || reward.appendedProfileResident
                 || detail.instancedDefinitionState
                        != item_details::InstancedDefinitionState::instanced
                 || bucket.arraySelector != inventory_buckets::ArraySelector::character
@@ -225,52 +630,146 @@ 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. */
-bool prepare_record_reward_grant(std::span<const DirectRecordReward> rewards,
-                                 std::uint16_t claimedRecordIndex,
-                                 PendingRecordRewardGrant& mutation) 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()) {
-        return false;
+    if ((rewards.empty() && (wrapperFlags == nullptr || wrapperFlags->none()))
+        || rewards.size() > mutation.rewards.size()) {
+        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;
     }
 
+    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";
         const DirectRecordReward& requested = rewards[index];
         build_data::items::Definition item{};
         item_details::Definition detail{};
         inventory_buckets::Descriptor bucket{};
         if (requested.quantity <= 0
             || !build_data::find_item_definition_index(requested.itemDefinitionIndex, item)
-            || !build_data::find_configured_item_detail(requested.itemDefinitionIndex, detail)
+            || item.questInitialization.scope
+                   != build_data::items::QuestInitialization::Scope::none) {
+            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 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 RewardPreparation::deferred;
+                }
+                prepared.previousFlag = static_cast<std::uint8_t>(before);
+            }
+        }
+        if (item.bucketId == build_data::inventory::buckets::kPerkBucketId) {
+            reason = "perk_acquisition";
+            if (prepared.acquiredFlag == build_data::rewards::kAbsent || requested.quantity != 1
+                || !requested.sockets.empty()) {
+                return RewardPreparation::unresolvable;
+            }
+            prepared.kind = RecordRewardKind::accountUnlock;
+            prepared.afterQuantity = 1;
+            mutation.rewards[index] = prepared;
+            continue;
+        }
+        reason = "item_detail";
+        if (!build_data::find_configured_item_detail(requested.itemDefinitionIndex, detail)
             || 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;
                 }
             }
         }
 
-        PreparedRecordReward prepared{};
-        prepared.definitionHash = item.definitionHash;
-        prepared.quantity = requested.quantity;
+        reason = "socket_owner";
+        if (!requested.sockets.empty()
+            && (bucket.arraySelector != inventory_buckets::ArraySelector::character
+                || detail.instancedDefinitionState
+                       != item_details::InstancedDefinitionState::instanced)) {
+            return RewardPreparation::unresolvable;
+        }
+        reason = "inventory_destination";
         if (bucket.arraySelector == inventory_buckets::ArraySelector::profile) {
-            if (detail.instancedDefinitionState
-                != item_details::InstancedDefinitionState::stackable) {
-                return false;
+            reason = "profile_capacity";
+            if (detail.instancedDefinitionState != item_details::InstancedDefinitionState::stackable
+                || requested.quantity > detail.maxStackSize) {
+                return RewardPreparation::unresolvable;
             }
             PendingProfileItemAcquisition staged{};
             const bool actionSource =
@@ -283,7 +782,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;
@@ -296,13 +795,22 @@ bool prepare_record_reward_grant(std::span<const DirectRecordReward> rewards,
         } else if (bucket.arraySelector == inventory_buckets::ArraySelector::character
                    && detail.instancedDefinitionState
                           == item_details::InstancedDefinitionState::instanced) {
-            if (requested.quantity != 1 || !detail.equipmentSlot.has_value()) {
-                return false;
+            reason = "instance_quantity";
+            if (requested.quantity != 1) {
+                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 RewardPreparation::unresolvable;
             }
             working.characters[characterIndex] = staged.afterCharacter;
             prepared.instanceSoid = staged.acquiredInstanceSoid;
@@ -316,11 +824,14 @@ bool prepare_record_reward_grant(std::span<const DirectRecordReward> rewards,
                    && detail.instancedDefinitionState
                           == item_details::InstancedDefinitionState::stackable
                    && !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) {
@@ -334,7 +845,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) {
@@ -348,15 +859,22 @@ 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 = 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{};
     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];
@@ -369,8 +887,19 @@ bool prepare_record_reward_grant(std::span<const DirectRecordReward> rewards,
     mutation.beforeProfileItemCount = account.profileItemCount;
     mutation.afterProfileItemCount = working.profileItemCount;
     mutation.rewardCount = rewards.size();
+    reason = nullptr;
     mutation.prepared = true;
-    return true;
+    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,
@@ -381,14 +910,35 @@ bool preview_record_reward_grant(const PendingRecordRewardGrant& mutation,
 
 /** Commits the shared reward after-image and claim together. */
 bool commit_record_reward(PendingRecordRewardGrant& mutation) noexcept {
+    if (!mutation.prepared) {
+        return false;
+    }
     const PendingConsumption consume{mutation};
-    investment::store::g_mutex.lock();
-    AccountState after{};
-    bool ready = materialize_record_reward(investment::store::account(), mutation, after);
-    if (ready) {
-        ready = investment::store::write_account(after);
+    bool ready = false;
+    {
+        investment::store::Transaction transaction;
+        AccountState after{};
+        unlocks::Table flags{};
+        ready = transaction.ready()
+                && materialize_record_reward(investment::store::account(), mutation, after)
+                && preview_reward_unlocks(mutation, flags)
+                && investment::store::write_account(after);
+        for (std::size_t i = 0; ready && i < mutation.rewardCount; ++i) {
+            const auto flag = mutation.rewards[i].acquiredFlag;
+            if (flag != build_data::rewards::kAbsent) {
+                ready = investment::store::write_unlock(
+                    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();
     }
-    investment::store::g_mutex.unlock();
     if (!ready && mutation.claimedRecordIndex != kUnclaimedRecordIndex) {
         // The claim was written when the reward was prepared, so a refused install undoes it.
         unlocks::records::revoke(mutation.claimedRecordIndex);

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

@@ -166,13 +166,6 @@ finalize_profile_item_acquisition(const AccountState& account,
 [[nodiscard]] bool materialize_item_acquisition(const AccountState& current,
                                                 const PendingItemAcquisition& mutation,
                                                 AccountState& after) noexcept;
-/**
- * Rebuilds one package from installed policy and rejects any altered after-image.
- * @return False when the account moved or the rebuilt character differs from the mutation.
- */
-[[nodiscard]] bool materialize_direct_item_bundle(const AccountState& current,
-                                                  const PendingDirectItemBundle& mutation,
-                                                  AccountState& after) noexcept;
 [[nodiscard]] bool native_equipment_slot(const account::inventory::Item& item,
                                          std::uint8_t& slot) noexcept;
 [[nodiscard]] bool semantic_equipment_slot(std::uint8_t nativeSlot,

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

@@ -8,14 +8,17 @@
 #include <limits>
 #include <span>
 
+#include "../../core/logging/log.h"
 #include "../build_data/runtime.h"
 #include "../investment/investment.h"
 #include "../investment/store_internal.h"
 #include "../progression/season_pass_reward_catalog.h"
+#include "../rewards/reward_resolver.h"
 #include "../unlocks/definition.h"
 #include "../unlocks/unlocks_runtime.h"
 #include "runtime.h"
 #include "state.h"
+#include "state_account_transaction_helpers.h"
 #include "storage/internal.h"
 
 namespace sunrise::state {
@@ -91,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.
@@ -244,6 +254,86 @@ bool publish_artifact_character_banks(CharacterArtifactWrite& write) noexcept {
     return publish_artifact_character_banks(write);
 }
 
+/** Logs one earned perk whose flags were not granted, and why. */
+void report_perk_refusal(std::size_t index, const char* reason) noexcept {
+    core::log::writef(core::log::Channel::state,
+                      core::log::Level::warn,
+                      "ev=season_xp stage=automatic_perk index=%zu result=skip reason=%s",
+                      index,
+                      reason);
+}
+
+// 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 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()) {
+        investment::store::g_session.perkWalkOwed = true;
+        return true;
+    }
+    AccountState account;
+    if (!investment::store::read_account(account)) {
+        return false;
+    }
+    const auto character = runtime::detail::selected_character_index(account);
+    if (character >= account.characterCount) {
+        return true;
+    }
+    unlocks::Table flags{};
+    if (!investment::store::read_unlocks(flags, static_cast<int>(character))) {
+        return false;
+    }
+    const auto rank = seasonal_rank();
+    const auto count = build_data::season_pass_reward_count();
+    for (std::size_t index = 0; index < count; ++index) {
+        build_data::season_pass::Reward reward{};
+        if (!build_data::find_season_pass_reward(static_cast<std::uint16_t>(index), reward)) {
+            continue;
+        }
+        if (reward.requiredRank > rank) {
+            continue;
+        }
+        const auto acquired = pass::progress_flag(reward);
+        if (acquired == build_data::rewards::kAbsent) {
+            continue;
+        }
+        const auto claim = reward.claimFlagIndex;
+        if (acquired >= flags.accountFlags.size() || reward.quantity != 1 || reward.socketCount != 0
+            || reward.conditionCount > reward.condition.size()
+            || (claim != build_data::season_pass::kUnavailableFlagIndex
+                && claim >= flags.accountFlags.size())) {
+            report_perk_refusal(index, "perk_shape");
+            continue;
+        }
+        const char* reason = nullptr;
+        bool eligible = false;
+        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;
+        }
+        if (!eligible) {
+            continue;
+        }
+        for (const auto flag : {acquired, claim}) {
+            if (flag == build_data::rewards::kAbsent
+                || flags.accountFlags[flag] == unlocks::kFlagSet) {
+                continue;
+            }
+            if (!unlocks::set_account_flag(flag, unlocks::kFlagSet)) {
+                return false;
+            }
+            flags.accountFlags[flag] = unlocks::kFlagSet;
+        }
+    }
+    walked = true;
+    return true;
+}
+
 /** Publishes the four seasonal progression lanes the current XP total implies. */
 bool publish_experience_lanes(std::int32_t experience) noexcept {
     return unlocks::set_account_progression(kArtifactPowerProgressionIndex, experience)
@@ -258,6 +348,22 @@ bool publish_experience_lanes(std::int32_t experience) noexcept {
 
 } // namespace
 
+/** Packages share the perk bucket; only rewards without a pool grant the flag directly. */
+std::uint16_t
+progression::season_pass::progress_flag(const build_data::season_pass::Reward& reward) noexcept {
+    build_data::items::Definition item{};
+    build_data::rewards::Item acquisition{};
+    if (build_data::find_item_definition_index(reward.itemIndex, item)
+        && item.definitionHash == reward.itemHash
+        && item.bucketId == build_data::inventory::buckets::kPerkBucketId
+        && build_data::find_reward_item(reward.itemIndex, acquisition)
+        && acquisition.definitionHash == reward.itemHash
+        && acquisition.poolIndex == build_data::rewards::kAbsent) {
+        return acquisition.acquiredFlag;
+    }
+    return build_data::rewards::kAbsent;
+}
+
 /** @return Seasonal XP published in the account progression bank. */
 std::int32_t seasonal_experience() noexcept {
     return unlocks::account_progression(kArtifactPowerProgressionIndex);
@@ -301,18 +407,23 @@ 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) && transaction.commit();
+    bool walked = false;
+    // A later character pick publishes only a selection patch, so only an owed walk runs here.
+    const bool published =
+        publish_experience_lanes(experience) && publish_artifact_locked(family, mask, experience)
+        && investment::store::write_family5(family)
+        && (!investment::store::g_session.perkWalkOwed || grant_progress_flags(walked))
+        && transaction.commit();
+    if (published && walked) {
+        investment::store::g_session.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. */
@@ -331,7 +442,7 @@ std::uint16_t artifact_power_bonus() noexcept {
     return bonus > 0 ? static_cast<std::uint16_t>(bonus) : 0U;
 }
 
-/** Adds base XP to the seasonal lanes and republishes every value derived from the total. */
+/** Publishes seasonal XP, artifact progress and newly earned acquisition flags atomically. */
 bool grant_seasonal_experience(std::int32_t amount) noexcept {
     if (amount <= 0) {
         return false;
@@ -344,19 +455,26 @@ bool grant_seasonal_experience(std::int32_t amount) noexcept {
     if (previous > (std::numeric_limits<std::int32_t>::max)() - amount) {
         return false;
     }
-    const std::int32_t total = previous + amount;
+    const auto total = previous + amount;
+    const bool walkPerks = investment::store::g_session.perkWalkOwed
+                           || seasonal_rank_for(previous) != seasonal_rank_for(total);
 
     Family5State family;
-    if (!transaction.ready() || !investment::store::read_family5(family)) {
+    if (!investment::store::read_family5(family)) {
         return false;
     }
     SaleRows rows{};
     std::size_t count = 0;
     const std::uint32_t mask = sale_rows(rows, count) ? artifact_mask(rows, count) : 0U;
-    const bool saved = publish_experience_lanes(total)
-                       && publish_artifact_locked(family, mask, total)
-                       && investment::store::write_family5(family) && transaction.commit();
-    return saved;
+    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) {
+        investment::store::g_session.perkWalkOwed = false;
+    }
+    return granted;
 }
 
 /** @return True when this Season pass reward row is already claimed. */

+ 203 - 0
Sunrise/src/state/unlocks/unlocks_expression.cpp

@@ -0,0 +1,203 @@
+#include "unlocks_expression.h"
+
+#include <array>
+#include <limits>
+
+#include "../account/account_state.h"
+#include "definition.h"
+
+namespace sunrise::state::unlocks {
+namespace {
+
+/** Fixed evaluation stack, which refuses rather than growing. */
+class Stack final {
+public:
+    /** @param value Value to push. @return True when there was room. */
+    [[nodiscard]] bool push(std::int32_t value) noexcept {
+        if (depth_ == values_.size()) {
+            return false;
+        }
+        values_[depth_++] = value;
+        return true;
+    }
+
+    /** @param value Receives the top value. @return True when there was one. */
+    [[nodiscard]] bool pop(std::int32_t& value) noexcept {
+        if (depth_ == 0) {
+            return false;
+        }
+        value = values_[--depth_];
+        return true;
+    }
+
+    /** @return Values currently held. */
+    [[nodiscard]] std::size_t depth() const noexcept {
+        return depth_;
+    }
+
+private:
+    std::array<std::int32_t, kExpressionStackCapacity> values_{};
+    std::size_t depth_{};
+};
+
+/** Signed arithmetic wraps rather than overflowing. */
+[[nodiscard]] std::int32_t wrapping_add(std::int32_t left, std::int32_t right) noexcept {
+    return static_cast<std::int32_t>(static_cast<std::uint32_t>(left)
+                                     + static_cast<std::uint32_t>(right));
+}
+
+/**
+ * Applies one binary operator.
+ * @return False when the opcode is not a binary operator.
+ */
+[[nodiscard]] bool
+binary_result(Opcode opcode, std::int32_t left, std::int32_t right, std::int32_t& value) noexcept {
+    switch (opcode) {
+    case Opcode::logicalOr:
+        value = left != 0 || right != 0;
+        return true;
+    case Opcode::logicalAnd:
+        value = left != 0 && right != 0;
+        return true;
+    case Opcode::equal:
+        value = left == right;
+        return true;
+    case Opcode::greaterThan:
+        value = left > right;
+        return true;
+    case Opcode::greaterOrEqual:
+        value = left >= right;
+        return true;
+    case Opcode::lessThan:
+        value = left < right;
+        return true;
+    case Opcode::lessOrEqual:
+        value = left <= right;
+        return true;
+    case Opcode::add:
+        value = wrapping_add(left, right);
+        return true;
+    default:
+        return false;
+    }
+}
+
+/**
+ * Runs one instruction against the stack.
+ * @param instruction Instruction to run.
+ * @param inputs Flag and value readers.
+ * @param stack Evaluation stack.
+ * @return True when the opcode is known and its operands were there.
+ */
+[[nodiscard]] bool
+step(const Instruction& instruction, const Inputs& inputs, Stack& stack) noexcept {
+    std::int32_t left = 0;
+    std::int32_t right = 0;
+    std::int32_t value = 0;
+    switch (instruction.opcode) {
+    case Opcode::flag: {
+        bool set = false;
+        return inputs.flag(inputs.context, instruction, set) && stack.push(set ? 1 : 0);
+    }
+    case Opcode::loadValue:
+        return inputs.value(inputs.context, instruction, value) && stack.push(value);
+    case Opcode::constant:
+        return stack.push(static_cast<std::int32_t>(instruction.operand));
+    case Opcode::logicalNot:
+        return stack.pop(left) && stack.push(left == 0 ? 1 : 0);
+    case Opcode::negate:
+        return stack.pop(left) && stack.push(wrapping_add(~left, 1));
+    default:
+        // The right operand was pushed last, so it comes off first.
+        return stack.pop(right) && stack.pop(left)
+               && binary_result(instruction.opcode, left, right, value) && stack.push(value);
+    }
+}
+
+} // namespace
+
+/** Converts one native opcode word to an evaluated opcode. */
+bool decode_opcode(std::uint32_t native, Opcode& opcode) noexcept {
+    if (native > (std::numeric_limits<std::uint8_t>::max)()) {
+        return false;
+    }
+    switch (static_cast<Opcode>(native)) {
+    case Opcode::flag:
+    case Opcode::logicalNot:
+    case Opcode::logicalOr:
+    case Opcode::logicalAnd:
+    case Opcode::equal:
+    case Opcode::loadValue:
+    case Opcode::constant:
+    case Opcode::greaterThan:
+    case Opcode::greaterOrEqual:
+    case Opcode::lessThan:
+    case Opcode::lessOrEqual:
+    case Opcode::add:
+    case Opcode::negate:
+        opcode = static_cast<Opcode>(native);
+        return true;
+    default:
+        return false;
+    }
+}
+
+/** Checks one instruction's bank and its operand against that bank's capacity. */
+bool valid(const Instruction& instruction) noexcept {
+    Opcode decoded{};
+    if (!decode_opcode(static_cast<std::uint32_t>(instruction.opcode), decoded)) {
+        return false;
+    }
+    const std::uint32_t index = instruction.operand;
+    if (instruction.opcode == Opcode::flag) {
+        switch (instruction.bank) {
+        case Bank::account:
+            return index < kAccountFlagCapacity;
+        case Bank::profile:
+            return index < kProfileFlagCapacity;
+        case Bank::character:
+            return index < kCharacterObjectFlagCapacity;
+        case Bank::characterClass:
+            return index < kCharacterClassCount;
+        case Bank::external:
+            return true;
+        default:
+            return false;
+        }
+    }
+    if (instruction.opcode == Opcode::loadValue) {
+        switch (instruction.bank) {
+        case Bank::account:
+            return index < kObjectiveValueCapacity;
+        case Bank::character:
+            return index < kCharacterObjectValueCapacity;
+        case Bank::external:
+            return true;
+        default:
+            return false;
+        }
+    }
+    return instruction.bank == Bank::none;
+}
+
+/** Evaluates one expression program. */
+bool evaluate(std::span<const Instruction> program, const Inputs& inputs, bool& result) noexcept {
+    result = false;
+    if (program.empty() || inputs.flag == nullptr || inputs.value == nullptr) {
+        return false;
+    }
+    Stack stack;
+    for (const Instruction& instruction : program) {
+        if (!step(instruction, inputs, stack)) {
+            return false;
+        }
+    }
+    std::int32_t final = 0;
+    if (stack.depth() != 1 || !stack.pop(final)) {
+        return false;
+    }
+    result = final != 0;
+    return true;
+}
+
+} // namespace sunrise::state::unlocks

+ 92 - 0
Sunrise/src/state/unlocks/unlocks_expression.h

@@ -0,0 +1,92 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+namespace sunrise::state::unlocks {
+
+/** 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 {
+    /** Pushes 1 when the bound flag is set, otherwise 0. */
+    flag = 1,
+    /** Pops one value and pushes 1 when it was zero. */
+    logicalNot = 2,
+    /** Binary operators pop right then left and push the result. */
+    logicalOr = 3,
+    logicalAnd = 4,
+    equal = 8,
+    /** Pushes one bound value. */
+    loadValue = 10,
+    /** Pushes the operand as a signed constant. */
+    constant = 11,
+    greaterThan = 13,
+    greaterOrEqual = 14,
+    lessThan = 15,
+    lessOrEqual = 16,
+    add = 17,
+    /** Pops one value and pushes its two's-complement negation. */
+    negate = 22,
+};
+
+/** Saved bank a flag or value instruction reads once its authored unlock slot is bound. */
+enum class Bank : std::uint8_t {
+    /** Operators and constants read no bank. */
+    none,
+    account,
+    profile,
+    character,
+    /** A flag that is set when the operand names the selected character's class. */
+    characterClass,
+    /** Resolved outside the saved banks. The operand is its identity hash. */
+    external,
+};
+
+/** One bound expression instruction. */
+struct Instruction {
+    Opcode opcode{};
+    Bank bank{Bank::none};
+    /** Bank index, class index, external identity hash, or signed constant. */
+    std::uint32_t operand{};
+};
+
+/** Reads one bound flag. Returns false when the flag cannot be read. */
+using FlagReader = bool (*)(const void* context,
+                            const Instruction& instruction,
+                            bool& set) noexcept;
+/** Reads one bound value. Returns false when the value cannot be read. */
+using ValueReader = bool (*)(const void* context,
+                             const Instruction& instruction,
+                             std::int32_t& value) noexcept;
+
+/** The two readers one evaluation needs, and the caller state they share. */
+struct Inputs {
+    FlagReader flag{};
+    ValueReader value{};
+    const void* context{};
+};
+
+/**
+ * Converts one native opcode word to an evaluated opcode.
+ * @return False for an opcode the server does not evaluate.
+ */
+[[nodiscard]] bool decode_opcode(std::uint32_t native, Opcode& opcode) noexcept;
+
+/** Checks one instruction's bank and its operand against that bank's capacity. */
+[[nodiscard]] bool valid(const Instruction& instruction) noexcept;
+
+/**
+ * Evaluates one expression program.
+ * Every failure is refused, never defaulted: bad opcode, bad stack, unreadable flag or value.
+ * @param program Instructions in evaluation order.
+ * @param inputs Flag and value readers.
+ * @param result Receives whether the final value is nonzero, and is cleared on any refusal.
+ * @return True only when the whole program evaluated to one value.
+ */
+[[nodiscard]] bool
+evaluate(std::span<const Instruction> program, const Inputs& inputs, bool& result) noexcept;
+
+} // namespace sunrise::state::unlocks