Selaa lähdekoodia

Extract runtime reward definitions and resolve server grants

Read installed reward pools, item wrappers, conditions, and Season Pass rows
alongside item definitions. Resolve existing server grants through State and
publish inventory and acquisition flags in the same transaction.

Replace authored engram and material reward tables and Season Pass armor
rolls. Share the extracted class-package path, preserve native claim indices,
and apply earned progression flags as seasonal experience changes.
Zach 1 kuukausi sitten
vanhempi
commit
9f9e4b9330
60 muutettua tiedostoa jossa 2975 lisäystä ja 1656 poistoa
  1. 9 16
      Sunrise/Sunrise.vcxproj
  2. 4 5
      Sunrise/src/client/content/items/packages/internal.h
  3. 11 5
      Sunrise/src/client/content/items/packages/package_item_build.cpp
  4. 11 1
      Sunrise/src/client/content/items/packages/package_item_rows.cpp
  5. 4 2
      Sunrise/src/client/content/items/packages/package_node_build.cpp
  6. 567 0
      Sunrise/src/client/content/items/packages/package_reward_build.cpp
  7. 79 0
      Sunrise/src/client/content/items/packages/package_reward_build.h
  8. 80 108
      Sunrise/src/client/content/items/packages/package_season_pass_build.cpp
  9. 12 2
      Sunrise/src/middleware/content/packages/tables/definition_index_table.cpp
  10. 63 6
      Sunrise/src/middleware/content/packages/tables/definition_index_table.h
  11. 26 0
      Sunrise/src/middleware/content/packages/tables/field_reader.h
  12. 2 19
      Sunrise/src/middleware/content/packages/tables/internal.h
  13. 1 4
      Sunrise/src/middleware/content/packages/tables/quest_initialization_reader.cpp
  14. 4 1
      Sunrise/src/middleware/content/packages/tables/unlock_expression.h
  15. 33 5
      Sunrise/src/server/bap/bap_world_reward_queue.cpp
  16. 1 4
      Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_incident_grants.cpp
  17. 6 53
      Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp
  18. 1 33
      Sunrise/src/server/bap/encrypted/internal.h
  19. 0 53
      Sunrise/src/server/bap/encrypted/push/queuez/queuez_select_character.cpp
  20. 5 188
      Sunrise/src/server/bap/encrypted/push/snapshot/family4_reward_updates.cpp
  21. 0 9
      Sunrise/src/server/bap/encrypted/push/snapshot/internal.h
  22. 69 0
      Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp
  23. 27 69
      Sunrise/src/server/bap/encrypted/queuez/queuez_reward_staging.cpp
  24. 8 8
      Sunrise/src/server/bap/encrypted/queuez/queuez_state_validation.h
  25. 19 43
      Sunrise/src/server/bap/encrypted/queuez/staging/queuez_character_staging.cpp
  26. 3 0
      Sunrise/src/server/bap/internal.h
  27. 5 310
      Sunrise/src/server/web_service/web_service_record_actions.cpp
  28. 4 0
      Sunrise/src/state/account/account_state.h
  29. 11 4
      Sunrise/src/state/build_data/build_data_runtime.cpp
  30. 13 3
      Sunrise/src/state/build_data/cache/read/cache_file_reader.cpp
  31. 39 8
      Sunrise/src/state/build_data/cache/read/cache_payload_reader.cpp
  32. 37 5
      Sunrise/src/state/build_data/cache/records/cache_domain_validation.cpp
  33. 30 20
      Sunrise/src/state/build_data/cache/records/cache_investment_records.cpp
  34. 123 0
      Sunrise/src/state/build_data/cache/records/cache_reward_records.cpp
  35. 17 5
      Sunrise/src/state/build_data/cache/records/codec.h
  36. 19 3
      Sunrise/src/state/build_data/cache/records/domains.h
  37. 93 15
      Sunrise/src/state/build_data/cache/records/format.h
  38. 7 2
      Sunrise/src/state/build_data/cache/write/cache_file_writer.cpp
  39. 14 4
      Sunrise/src/state/build_data/cache/write/cache_payload_writer.cpp
  40. 6 1
      Sunrise/src/state/build_data/cache/write/temporary/temporary_cache_file.cpp
  41. 2 0
      Sunrise/src/state/build_data/inventory/buckets/definition.h
  42. 114 0
      Sunrise/src/state/build_data/rewards/definition.h
  43. 231 0
      Sunrise/src/state/build_data/rewards/reward_catalog.cpp
  44. 30 0
      Sunrise/src/state/build_data/rewards/reward_catalog.h
  45. 5 13
      Sunrise/src/state/build_data/runtime.h
  46. 8 0
      Sunrise/src/state/build_data/runtime/build_data_catalog_runtime.cpp
  47. 35 11
      Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.cpp
  48. 7 1
      Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.h
  49. 10 16
      Sunrise/src/state/build_data/season_pass/definition.h
  50. 3 10
      Sunrise/src/state/build_data/season_pass/season_pass_build_data_runtime.cpp
  51. 25 54
      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. 4 184
      Sunrise/src/state/progression/season_pass_reward_catalog.h
  54. 418 0
      Sunrise/src/state/rewards/reward_resolver.cpp
  55. 44 0
      Sunrise/src/state/rewards/reward_resolver.h
  56. 27 32
      Sunrise/src/state/runtime/runtime.h
  57. 0 175
      Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp
  58. 436 77
      Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp
  59. 0 7
      Sunrise/src/state/runtime/state_account_transaction_helpers.h
  60. 105 9
      Sunrise/src/state/runtime/state_progression_runtime.cpp

+ 9 - 16
Sunrise/Sunrise.vcxproj

@@ -99,22 +99,10 @@
     <None Include="vendor\lua\UPSTREAM_README" />
   </ItemGroup>
   <ItemGroup>
-    <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" />
-    <ClCompile Include="src\client\diagnostics\studio\studio_pose_capture.cpp" />
-    <!-- Uncounted observer boundaries must not call compiler debug helpers. -->
-    <ClCompile Include="src\client\diagnostics\studio\studio_pose_link.cpp">
-      <SupportJustMyCode>false</SupportJustMyCode>
-      <BasicRuntimeChecks>Default</BasicRuntimeChecks>
-    </ClCompile>
-    <ClCompile Include="src\client\diagnostics\studio\studio_object_link.cpp" />
-    <ClCompile Include="src\client\diagnostics\studio\studio_cui_reader.cpp" />
-    <ClCompile Include="src\client\diagnostics\studio\studio_cui_link.cpp" />
-    <ClCompile Include="src\client\diagnostics\studio\studio_cui_observer.cpp">
-      <SupportJustMyCode>false</SupportJustMyCode>
-      <BasicRuntimeChecks>Default</BasicRuntimeChecks>
-    </ClCompile>
+    <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\gameplay\external\entity_object_types.cpp" />
     <ClCompile Include="src\state\investment\investment_account_store.cpp" />
     <ClCompile Include="src\state\investment\investment_database.cpp" />
@@ -1340,6 +1328,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" />

+ 4 - 5
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,7 @@ struct Storage {
     reader::Scratch scratch{};
     /** Read once per root. Every domain resolves its unlock slots through these. */
     SlotMaps slotMaps{};
+    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{};
@@ -157,11 +160,7 @@ struct Storage {
     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>

+ 11 - 5
Sunrise/src/client/content/items/packages/package_item_build.cpp

@@ -8,6 +8,7 @@
 #include "../../../../middleware/content/packages/reader/reader.h"
 #include "../../../../middleware/content/packages/tables/definition_index_table.h"
 #include "../../../../state/build_data/activities/activity_catalog.h"
+#include "../../../../state/build_data/rewards/reward_catalog.h"
 #include "../../../../state/build_data/runtime.h"
 #include "../../activity/activity_catalog_build.h"
 #include "../../activity/entity_position_profile_build.h"
@@ -17,6 +18,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 +36,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() && state::build_data::rewards::ready()
            && state::build_data::repeatable_bounties_ready()
            && state::build_data::record_definitions_ready()
            && state::build_data::node_definitions_ready()
@@ -126,7 +128,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() || !state::build_data::rewards::ready()
+                || !exotic_catalysts_settled()) {
                 reason = "unlock_maps";
                 if (!read_unlock_slot_maps(
                         source, storage, std::span<const std::byte>{storage.root})) {
@@ -184,11 +187,14 @@ bool build() noexcept {
                         std::span(storage.progressionSteps).first(storage.progressionStepCount));
                 }
             }
-            if (!state::build_data::season_pass_ready()
+            const bool rewardTables =
+                (!state::build_data::rewards::ready() || !state::build_data::season_pass_ready())
+                && storage.rewardBuild.load(
+                    source, storage.scratch, storage.root, storage.slotMaps);
+            if (rewardTables && !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));
+                    std::span(storage.seasonPassRewards).first(storage.seasonPassRewardCount));
             }
             // 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.

+ 11 - 1
Sunrise/src/client/content/items/packages/package_item_rows.cpp

@@ -4,6 +4,7 @@
 #include "../../../../middleware/content/packages/tables/quest_initialization_reader.h"
 #include "../../../../state/build_data/items/catalysts/exotic_catalyst_builder.h"
 #include "../../../../state/build_data/items/details/item_detail_catalog.h"
+#include "../../../../state/build_data/rewards/reward_catalog.h"
 #include "../../../../state/build_data/runtime.h"
 #include "internal.h"
 #include "package_socket_plug_build.h"
@@ -61,6 +62,9 @@ bool build_item_rows(const reader::Source& source,
                      const tables::Array& table,
                      std::size_t& rowCount,
                      const char*& reason) noexcept {
+    const bool needRewards = !state::build_data::rewards::ready();
+    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 +75,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 || needRewards;
     bool published = !needRows;
     if (retainDetails && storage.details.size() != kDetailCapacity) {
         storage.details.assign(kDetailCapacity, build_details::Definition{});
@@ -112,6 +116,9 @@ bool build_item_rows(const reader::Source& source,
                                                item)) {
             continue;
         }
+        if (needRewards && 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{};
@@ -160,6 +167,9 @@ bool build_item_rows(const reader::Source& source,
             append_initial_plugs(item, table.count, storage.detailRequests);
         }
     }
+    if (needRewards && rewardStorageReady) {
+        (void)storage.rewardBuild.publish();
+    }
     bool requestsFit = true;
     if (needRows) {
         // Every walked entry either published a row or was skipped, so the count of one

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

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

@@ -0,0 +1,567 @@
+#include "package_reward_build.h"
+
+#include <algorithm>
+#include <cmath>
+#include <vector>
+
+#include "../../../../middleware/content/packages/tables/definition_index_table.h"
+#include "../../../../middleware/content/packages/tables/internal.h"
+#include "../../../../state/build_data/rewards/reward_catalog.h"
+#include "../../../../state/build_data/runtime.h"
+#include "core/logging/log.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;
+
+/** Investment-root slots identify reward pools and unlock-slot bindings. */
+constexpr std::size_t kPoolSlot = 88;
+constexpr std::size_t kExpressionSlot = 109;
+constexpr std::size_t kFlagSlot = 112;
+constexpr std::size_t kValueSlot = 114;
+/** 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, kFlagRowClass = 0x80807D4FU;
+constexpr std::uint32_t kValueTableClass = 0x80807C92U, kValueRowClass = 0x80807C96U;
+constexpr std::uint32_t kExpressionTableClass = 0x80807C49U, kExpressionRowClass = 0x80807C4FU;
+constexpr std::uint32_t kConditionClass = 0x80807D2FU;
+constexpr std::size_t kPoolStride = 24, kEntryStride = 80, kModifierStride = 24;
+constexpr std::size_t kBindingStride = 8, kExpressionRowStride = 24;
+constexpr std::size_t kSocketStride = 12, 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, kEntryPoolOffset = 8;
+constexpr std::size_t kEntryCategoryOffset = 20, kEntryWeightOffset = 24;
+constexpr std::size_t kEntryBucketOffset = 28, kEntryConditionOffset = 32;
+constexpr std::size_t kEntryModifiersOffset = 48, kEntrySocketsOffset = 64;
+constexpr std::size_t kModifierValueIndexOffset = 16, kModifierValueOffset = 20;
+constexpr std::size_t kPoolEntriesOffset = 8, kExpressionBodyOffset = 8;
+constexpr std::size_t kWrapperSelectionsOffset = 8, kWrapperFlagsOffset = 24;
+constexpr std::size_t kSelectionCountOffset = 4;
+constexpr std::size_t kSocketPlugOffset = 2, kSocketPlugSetOffset = 4;
+constexpr std::size_t kSocketRollSetOffset = 6, kSocketSelectionOffset = 8;
+/** Relative definition bodies are preceded by their four-byte schema class. */
+constexpr std::size_t kDefinitionClassPrefixSize = sizeof(std::uint32_t);
+
+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;
+    }
+}
+
+bool root_table(const reader::Source& source,
+                reader::Scratch& scratch,
+                std::span<const std::byte> root,
+                std::size_t slot,
+                std::vector<std::byte>& blob,
+                std::uint32_t expectedClass = 0) noexcept {
+    std::uint32_t tag = 0;
+    std::uint32_t cls = 0;
+    return tables::slot_tag(root, slot, tag) && tables::package_of(tag) != tables::kAbsentPackageId
+           && reader::read_tag(source, scratch, tag, blob, cls)
+           && (expectedClass == 0 || cls == expectedClass);
+}
+
+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 + kEntryCategoryOffset, entry.categoryHash)
+           && tables::read(blob, at + kEntryWeightOffset, entry.weight)
+           && tables::read(blob, at + kEntryBucketOffset, entry.bucketHash)
+           && std::isfinite(entry.weight) && entry.weight >= 0;
+}
+
+} // namespace
+
+bool RewardBuild::entry(std::span<const std::byte> blob, std::size_t at) noexcept {
+    domain::Entry out{};
+    if (!read_reward_entry(blob, at, out)
+        || !conditions.read(blob, at + kEntryConditionOffset, instructions, out.condition)) {
+        return false;
+    }
+    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)
+            || !tables::read(blob, offset + kModifierValueIndexOffset, modifier.valueIndex)
+            || !tables::read(blob, offset + kModifierValueOffset, modifier.value)
+            || !std::isfinite(modifier.value)
+            || !append(modifiers, modifier, domain::kModifierCapacity)) {
+            return false;
+        }
+    }
+    std::array<domain::SocketOverride, domain::kSocketsPerItem> overrides{};
+    std::size_t count = 0;
+    if (!read_reward_sockets(blob, at + kEntrySocketsOffset, overrides, count)
+        || count > domain::kSocketOverrideCapacity - sockets.size()) {
+        return false;
+    }
+    out.sockets = {static_cast<std::uint32_t>(sockets.size()), static_cast<std::uint32_t>(count)};
+    for (std::size_t i = 0; i < count; ++i) {
+        if (overrides[i].socketType == domain::kAbsent
+            || !append(sockets, overrides[i], domain::kSocketOverrideCapacity)) {
+            return false;
+        }
+    }
+    return append(entries, out, domain::kEntryCapacity);
+}
+
+void RewardConditions::load_class_flags(const reader::Source& source,
+                                        reader::Scratch& scratch,
+                                        std::span<const std::byte> root) noexcept {
+    classFlags_.fill(domain::kAbsent);
+    // Class rows name a default finisher whose use gate supplies the computed class flag.
+    constexpr std::size_t kClassSlot = 12, kClassStride = 32, kDefaultFinisherOffset = 24;
+    constexpr std::uint32_t kClassTable = 0x808075BEU, kClassRow = 0x808074FAU;
+    constexpr std::size_t kUseConditionPointer = 24;
+    constexpr std::uint32_t kUseConditionClass = 0x80802980U;
+    std::vector<std::byte> classes, index, blob;
+    tables::Array classRows{}, itemRows{};
+    if (!root_table(source, scratch, root, kClassSlot, classes, kClassTable)
+        || !tables::read_array(
+            classes, tables::kTableArrayDescriptor, kClassRow, kClassStride, classRows)
+        || classRows.count != classFlags_.size()
+        || !root_table(source, scratch, root, tables::kItemTableSlot, index)
+        || !tables::read_array(index,
+                               tables::kTableArrayDescriptor,
+                               tables::kItemIndexTableClass,
+                               tables::kItemIndexRowStride,
+                               itemRows)) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         "ev=pkg stage=class_flags result=skip reason=class_tables");
+        return;
+    }
+    for (std::size_t i = 0; i < classFlags_.size(); ++i) {
+        std::uint16_t itemIndex = domain::kAbsent;
+        tables::IndexRow item{};
+        tables::Array condition{};
+        std::uint32_t cls = 0, opcode = 0, flag = domain::kAbsent;
+        std::int64_t relative = 0;
+        if (!tables::read(std::span<const std::byte>{classes},
+                          classRows.dataOffset + i * kClassStride + kDefaultFinisherOffset,
+                          itemIndex)
+            || !tables::index_row(index, itemRows, itemIndex, item)
+            || !reader::read_tag(source, scratch, item.targetTag, blob, cls)
+            || cls != tables::kItemDefinitionClass
+            || !tables::read(std::span<const std::byte>{blob}, kUseConditionPointer, relative)
+            || relative <= 0 || static_cast<std::uint64_t>(relative) > blob.size()
+            || kUseConditionPointer > blob.size() - static_cast<std::size_t>(relative)) {
+            core::log::writef(
+                core::log::Channel::client,
+                core::log::Level::warn,
+                "ev=pkg stage=class_flags class=%zu result=skip reason=default_finisher",
+                i);
+            continue;
+        }
+        const auto at = kUseConditionPointer + static_cast<std::size_t>(relative);
+        if (tables::read(std::span<const std::byte>{blob}, at - kDefinitionClassPrefixSize, cls)
+            && cls == kUseConditionClass
+            && tables::read_array(
+                blob, at, kExpressionClass, tables::kUnlockInstructionStride, condition)
+            && condition.count == 1
+            && tables::read(std::span<const std::byte>{blob}, condition.dataOffset, opcode)
+            && opcode == static_cast<std::uint32_t>(tables::UnlockOpcode::flag)
+            && tables::read(std::span<const std::byte>{blob},
+                            condition.dataOffset + tables::kUnlockInstructionOperandOffset,
+                            flag)
+            && flag < domain::kAbsent) {
+            classFlags_[i] = static_cast<std::uint16_t>(flag);
+        } else {
+            core::log::writef(core::log::Channel::client,
+                              core::log::Level::warn,
+                              "ev=pkg stage=class_flags class=%zu result=skip reason=use_condition",
+                              i);
+        }
+    }
+}
+
+bool RewardConditions::load(const reader::Source& source,
+                            reader::Scratch& scratch,
+                            std::span<const std::byte> root,
+                            const SlotMaps& maps) noexcept {
+    maps_ = &maps;
+    load_class_flags(source, scratch, root);
+    // Shared expressions are expanded before the runtime evaluates reward conditions.
+    return root_table(source, scratch, root, kFlagSlot, flags_, kFlagTableClass)
+           && tables::read_array(
+               flags_, tables::kTableArrayDescriptor, kFlagRowClass, kBindingStride, flagRows_)
+           && root_table(source, scratch, root, kValueSlot, values_, kValueTableClass)
+           && tables::read_array(
+               values_, tables::kTableArrayDescriptor, kValueRowClass, kBindingStride, valueRows_)
+           && root_table(
+               source, scratch, root, kExpressionSlot, expressions_, kExpressionTableClass)
+           && tables::read_array(expressions_,
+                                 tables::kTableArrayDescriptor,
+                                 kExpressionRowClass,
+                                 kExpressionRowStride,
+                                 expressionRows_);
+}
+
+bool RewardConditions::bind(domain::Instruction& instruction) const noexcept {
+    const auto opcode =
+        static_cast<middleware::content::packages::tables::UnlockOpcode>(instruction.opcode);
+    const bool flag = opcode == middleware::content::packages::tables::UnlockOpcode::flag;
+    if (!flag && opcode != middleware::content::packages::tables::UnlockOpcode::loadValue) {
+        return true;
+    }
+    const auto& rows = flag ? flagRows_ : valueRows_;
+    const std::span<const std::byte> blob = flag ? flags_ : values_;
+    if (instruction.operand >= rows.count) {
+        return false;
+    }
+    const std::size_t at = rows.dataOffset + instruction.operand * kBindingStride;
+    std::uint32_t hash = 0;
+    if (!tables::read(blob, at, hash)) {
+        return false;
+    }
+    using B = domain::BankRead;
+    if (flag) {
+        for (std::size_t characterClass = 0; characterClass < classFlags_.size();
+             ++characterClass) {
+            if (classFlags_[characterClass] != domain::kAbsent
+                && instruction.operand == classFlags_[characterClass]) {
+                instruction = {static_cast<std::uint32_t>(B::characterClass),
+                               static_cast<std::uint32_t>(characterClass)};
+                return true;
+            }
+        }
+    }
+    const auto& account = flag ? maps_->accountFlag : maps_->accountValue;
+    const auto& character = flag ? maps_->characterFlag : maps_->characterValue;
+    const auto accountIndex = bank_index(account, static_cast<std::int32_t>(instruction.operand));
+    const auto characterIndex =
+        bank_index(character, static_cast<std::int32_t>(instruction.operand));
+    if (accountIndex != kUnmappedSlot) {
+        instruction = {static_cast<std::uint32_t>(flag ? B::accountFlag : B::accountValue),
+                       accountIndex};
+    } else if (flag
+               && bank_index(maps_->profileFlag, static_cast<std::int32_t>(instruction.operand))
+                      != kUnmappedSlot) {
+        instruction = {
+            static_cast<std::uint32_t>(B::profileFlag),
+            bank_index(maps_->profileFlag, static_cast<std::int32_t>(instruction.operand))};
+    } else if (characterIndex != kUnmappedSlot) {
+        instruction = {static_cast<std::uint32_t>(flag ? B::characterFlag : B::characterValue),
+                       characterIndex};
+    } else {
+        instruction = {static_cast<std::uint32_t>(flag ? B::externalFlag : B::externalValue), 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) 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) {
+        domain::Instruction instruction{};
+        if (!tables::read(
+                blob, rows.dataOffset + i * tables::kUnlockInstructionStride, instruction.opcode)
+            || !tables::read(blob,
+                             rows.dataOffset + i * tables::kUnlockInstructionStride
+                                 + tables::kUnlockInstructionOperandOffset,
+                             instruction.operand)) {
+            return false;
+        }
+        if (static_cast<middleware::content::packages::tables::UnlockOpcode>(instruction.opcode)
+            == middleware::content::packages::tables::UnlockOpcode::expression) {
+            const auto before = bank.size();
+            if (instruction.operand >= expressionRows_.count
+                || !append_expression(expressions_,
+                                      expressionRows_.dataOffset
+                                          + instruction.operand * kExpressionRowStride
+                                          + kExpressionBodyOffset,
+                                      bank,
+                                      depth + 1)
+                || bank.size() == before) {
+                return false;
+            }
+        } else if (!bind(instruction) || !domain::valid_instruction(instruction)
+                   || !append(bank, instruction, domain::kInstructionCapacity)) {
+            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) const noexcept {
+    const auto first = bank.size();
+    if (!append_expression(blob, at, bank, 0)) {
+        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) const noexcept {
+    count = 0;
+    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)
+            || expression.count == 0) {
+            return false;
+        }
+        // Every expression attached to a progression reward must hold.
+        if (i != 0
+            && !append(instructions,
+                       domain::Instruction{
+                           static_cast<std::uint32_t>(
+                               middleware::content::packages::tables::UnlockOpcode::logicalAnd),
+                           0},
+                       output.size())) {
+            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;
+    pools.clear();
+    entries.clear();
+    instructions.clear();
+    modifiers.clear();
+    sockets.clear();
+    std::vector<std::byte> blob;
+    tables::Array rows{};
+    if (!conditions.load(source, scratch, root, maps)
+        || !root_table(source, scratch, root, kPoolSlot, blob, kPoolClass)
+        || !tables::read_array(
+            blob, tables::kTableArrayDescriptor, kPoolRowClass, kPoolStride, rows)
+        || rows.count == 0 || rows.count > domain::kPoolCapacity) {
+        return false;
+    }
+    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(),
+                           beforeModifiers = modifiers.size(), beforeSockets = sockets.size();
+                if (!entry(blob, members.dataOffset + j * kEntryStride)) {
+                    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)) {
+            return false;
+        }
+    }
+    if (skipped != 0) {
+        core::log::writef(core::log::Channel::client,
+                          core::log::Level::warn,
+                          "ev=pkg stage=rewards skipped=%zu",
+                          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 relative = 0;
+    std::uint32_t cls = 0;
+    if (!tables::read(blob, kWrapperField, relative)
+        || !tables::read(blob, kAcquiredFlagField, acquired)) {
+        return false;
+    }
+    item.definitionHash = hash;
+    if (acquired != domain::kAbsent) {
+        domain::Instruction flag{
+            static_cast<std::uint32_t>(middleware::content::packages::tables::UnlockOpcode::flag),
+            acquired};
+        if (!conditions.bind(flag) || !domain::valid_instruction(flag)) {
+            return false;
+        }
+        if (flag.opcode == static_cast<std::uint32_t>(domain::BankRead::accountFlag)) {
+            item.acquiredFlag = static_cast<std::uint16_t>(flag.operand);
+        }
+    }
+    if (relative != 0) {
+        if (relative < 0 || static_cast<std::uint64_t>(relative) > blob.size()
+            || kWrapperField > blob.size() - static_cast<std::size_t>(relative)) {
+            return false;
+        }
+        const auto at = kWrapperField + static_cast<std::size_t>(relative);
+        tables::Array selections{};
+        if (!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 {
+    try {
+        if (!loaded_ || count == 0 || count > domain::kItemCapacity) {
+            return false;
+        }
+        items.assign(count, {});
+        return true;
+    } catch (...) {
+        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;
+    }
+}
+
+bool RewardBuild::publish() noexcept {
+    if (!loaded_) {
+        return false;
+    }
+    std::size_t write = 0, 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 || row.itemIndex < items.size())
+                         && (row.poolIndex == domain::kAbsent || row.poolIndex < pools.size());
+            for (const auto& socket :
+                 std::span(sockets).subspan(row.sockets.first, row.sockets.count)) {
+                valid &= socket.plugItem == domain::kAbsent || socket.plugItem < items.size();
+            }
+            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;
+    }
+    entries.resize(write);
+    sockets.resize(socketWrite);
+    for (auto& item : items) {
+        if (item.poolIndex != domain::kAbsent && item.poolIndex >= pools.size()) {
+            item = {};
+        }
+    }
+    return state::build_data::publish_reward_definitions(
+        {pools, entries, items, instructions, modifiers, sockets});
+}
+
+} // namespace sunrise::client::content::items::packages

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

@@ -0,0 +1,79 @@
+#pragma once
+
+#include "../../../../middleware/content/packages/reader/reader.h"
+#include "../../../../middleware/content/packages/tables/definition_index_table.h"
+#include "../../../../state/build_data/rewards/definition.h"
+#include "state/account/account_state.h"
+
+namespace sunrise::client::content::items::packages {
+
+struct SlotMaps;
+
+/** Resolves expression references and unlock slots once for a package pass. */
+class RewardConditions {
+public:
+    [[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;
+    [[nodiscard]] bool bind(state::build_data::rewards::Instruction& instruction) const noexcept;
+    [[nodiscard]] bool read(std::span<const std::byte> blob,
+                            std::size_t at,
+                            std::vector<state::build_data::rewards::Instruction>& bank,
+                            state::build_data::rewards::Range& range) const noexcept;
+    [[nodiscard]] bool read_list(std::span<const std::byte> blob,
+                                 std::size_t at,
+                                 std::span<state::build_data::rewards::Instruction> output,
+                                 std::size_t& count) const noexcept;
+
+private:
+    [[nodiscard]] bool append_expression(std::span<const std::byte> blob,
+                                         std::size_t at,
+                                         std::vector<state::build_data::rewards::Instruction>& bank,
+                                         std::size_t depth) const noexcept;
+    void load_class_flags(const middleware::content::packages::reader::Source& source,
+                          middleware::content::packages::reader::Scratch& scratch,
+                          std::span<const std::byte> root) noexcept;
+    std::array<std::uint16_t, state::kCharacterClassCount> classFlags_{};
+    const SlotMaps* maps_{};
+    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;
+    [[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;
+    [[nodiscard]] bool begin_items(std::size_t count) noexcept;
+    void item(std::uint16_t index, std::uint32_t hash, std::span<const std::byte> blob) noexcept;
+    [[nodiscard]] bool publish() noexcept;
+
+private:
+    [[nodiscard]] bool entry(std::span<const std::byte> blob, std::size_t at) noexcept;
+    [[nodiscard]] bool read_item(std::uint32_t hash,
+                                 std::span<const std::byte> blob,
+                                 state::build_data::rewards::Item& item) noexcept;
+    bool loaded_{};
+    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

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

@@ -1,81 +1,87 @@
 #include <algorithm>
-#include <cstring>
 #include <limits>
 
+#include "../../../../middleware/content/packages/tables/internal.h"
+#include "../../../../state/build_data/season_pass/season_pass_catalog.h"
+#include "../../../../state/progression/season_pass_reward_catalog.h"
+#include "core/logging/log.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) {
+bool read_season_reward(Storage& storage,
+                        std::span<const std::byte> progressionTable,
+                        const tables::Array& itemRows,
+                        std::size_t at,
+                        domain::Reward& reward) noexcept {
+    std::uint32_t rank = 0;
+    std::uint32_t itemIndex = 0;
+    std::uint32_t claimSlot = 0;
+    tables::IndexRow entry{};
+    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)
+        || reward.quantity == 0 || reward.quantity > INT32_MAX
+        || 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;
     }
-    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.
- */
-[[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)) {
+    reward.itemHash = entry.definitionHash;
+    reward.itemIndex = static_cast<std::uint16_t>(itemIndex);
+    reward.requiredRank = static_cast<std::uint8_t>(rank);
+    std::size_t socketCount = 0;
+    if (!read_reward_sockets(progressionTable,
+                             at + tables::kProgressionRewardSocketsOffset,
+                             reward.sockets,
+                             socketCount)) {
         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()) {
+    reward.socketCount = static_cast<std::uint8_t>(socketCount);
+    std::size_t conditionCount = 0;
+    if (!storage.rewardBuild.conditions.read_list(progressionTable,
+                                                  at + tables::kProgressionRewardConditionsOffset,
+                                                  reward.condition,
+                                                  conditionCount)) {
         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)) {
-            return false;
-        }
-        package.items[package.itemCount++] = entry.definitionHash;
+    reward.conditionCount = static_cast<std::uint8_t>(conditionCount);
+    // 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));
+    }
+
+    if (!domain::valid(std::span(&reward, 1))
+        || (claimSlot != 0 && reward.claimFlagIndex == domain::kUnavailableFlagIndex)
+        || !std::all_of(reward.sockets.begin(),
+                        reward.sockets.begin() + reward.socketCount,
+                        [&](const auto& socket) {
+                            return socket.socketType != state::build_data::rewards::kAbsent
+                                   && (socket.plugItem == state::build_data::rewards::kAbsent
+                                       || socket.plugItem < itemRows.count);
+                        })) {
+        return false;
     }
     return true;
 }
 
 } // 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.
- * @param source Installed package source.
- * @param storage Pass storage receiving the reward rows and the wrapper packages.
- * @param root Investment root bytes.
- * @return True when the reward list read and produced at least one row.
- */
+/** Preserves native reward order for opcode-2400 claim indices. */
 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 +93,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 +102,41 @@ 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;
-        }
-        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));
+        if (!read_season_reward(storage, progressionTable, itemRows, at, reward)) {
+            reward = {};
+            ++skipped;
         }
-
-        // 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;
 }
 

+ 12 - 2
Sunrise/src/middleware/content/packages/tables/definition_index_table.cpp

@@ -2,8 +2,7 @@
 
 #include <limits>
 
-#include "../reader/layout.h"
-#include "internal.h"
+#include "field_reader.h"
 
 namespace sunrise::middleware::content::packages::tables {
 namespace {
@@ -126,6 +125,17 @@ bool find_optional_array_at(std::span<const std::byte> blob,
     return resolve_descriptor(blob, descriptorOffset, true, output);
 }
 
+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,

+ 63 - 6
Sunrise/src/middleware/content/packages/tables/definition_index_table.h

@@ -28,6 +28,9 @@ inline constexpr std::uint16_t kAbsentPackageId = 0xFFFFU;
     return static_cast<std::uint16_t>((tag - kTagLowerBound) >> kTagPackageShift);
 }
 
+/** Hidden plugs, perks and quest roots use this bucket instead of held inventory. */
+inline constexpr std::uint8_t kNonInventoryBucketId = 37;
+
 /** Element class of the item index table inside the investment container. */
 inline constexpr std::uint32_t kItemIndexTableClass = 0x80807BE8U;
 /** Serialized item definition class, distinct from the item index table class. */
@@ -95,16 +98,58 @@ inline constexpr std::size_t kUnlockInstructionOperandOffset = 4;
 /** An expression field is a 64-bit count then a 64-bit self-relative offset. */
 inline constexpr std::size_t kUnlockExpressionFieldSize = 16;
 inline constexpr std::size_t kUnlockExpressionPointerOffset = 8;
-/** The evaluator's opcodes run to 27; anything wider means the field is not an expression. */
-inline constexpr std::uint32_t kUnlockOpcodeCeiling = 27;
+/** Native instruction values before unlock references are bound. */
+enum class UnlockOpcode : std::uint32_t {
+    flag = 1,
+    logicalNot = 2,
+    logicalOr = 3,
+    logicalAnd = 4,
+    logicalNor = 5,
+    notEqualAlternate = 6,
+    logicalNand = 7,
+    equal = 8,
+    notEqual = 9,
+    loadValue = 10,
+    constant = 11,
+    expression = 12,
+    greaterThan = 13,
+    greaterOrEqual = 14,
+    lessThan = 15,
+    lessOrEqual = 16,
+    add = 17,
+    subtract = 18,
+    multiply = 19,
+    divide = 20,
+    remainder = 21,
+    negate = 22,
+    hash = 23,
+    hashCombine = 24,
+    bitwiseAnd = 25,
+    bitwiseOr = 26,
+    bitwiseXor = 27,
+    bitwiseNot = 28,
+};
+
 /** The opcode that reads a value slot. */
-inline constexpr std::uint32_t kUnlockReadValueOpcode = 10;
+inline constexpr std::uint32_t kUnlockReadValueOpcode =
+    static_cast<std::uint32_t>(UnlockOpcode::loadValue);
 /** The opcode that tests a flag. */
-inline constexpr std::uint32_t kUnlockReadFlagOpcode = 1;
+inline constexpr std::uint32_t kUnlockReadFlagOpcode =
+    static_cast<std::uint32_t>(UnlockOpcode::flag);
 /** The opcode that pushes a literal. */
-inline constexpr std::uint32_t kUnlockLiteralOpcode = 11;
+inline constexpr std::uint32_t kUnlockLiteralOpcode =
+    static_cast<std::uint32_t>(UnlockOpcode::constant);
 /** The opcode that tests greater than or equal. */
-inline constexpr std::uint32_t kUnlockGreaterEqualOpcode = 14;
+inline constexpr std::uint32_t kUnlockGreaterEqualOpcode =
+    static_cast<std::uint32_t>(UnlockOpcode::greaterOrEqual);
+/** The opcode that inverts the value on top of the stack. Its operand is unused. */
+inline constexpr std::uint32_t kUnlockNotOpcode =
+    static_cast<std::uint32_t>(UnlockOpcode::logicalNot);
+/** The opcode that folds the top two values with logical and. */
+inline constexpr std::uint32_t kUnlockAndOpcode =
+    static_cast<std::uint32_t>(UnlockOpcode::logicalAnd);
+/** The opcode that tests the top two values for equality. */
+inline constexpr std::uint32_t kUnlockEqualOpcode = static_cast<std::uint32_t>(UnlockOpcode::equal);
 /** High half every installed array marker and element class carries. */
 inline constexpr std::uint32_t kDefinitionClassHigh = 0x8080U;
 /** Shift that leaves `kDefinitionClassHigh` from a marker or element class. */
@@ -123,6 +168,8 @@ 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 account object's profile unlock flag mapping table. */
+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,6 +263,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;
+/** Eligibility expressions and socket overrides carried by one progression reward. */
+inline constexpr std::size_t kProgressionRewardConditionsOffset = 24;
+inline constexpr std::size_t kProgressionRewardSocketsOffset = 40;
 /** Items a wrapper item opens into, two bytes each as an item-definition index. */
 inline constexpr std::size_t kGearsetItemField = 392;
 inline constexpr std::uint32_t kGearsetItemRowClass = 0x808087DBU;
@@ -277,6 +327,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.

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

@@ -0,0 +1,26 @@
+#pragma once
+
+#include <cstddef>
+#include <cstring>
+#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;
+}
+
+} // 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.

+ 1 - 4
Sunrise/src/middleware/content/packages/tables/quest_initialization_reader.cpp

@@ -57,9 +57,6 @@ constexpr std::uint32_t kItemPresenceFlagClass = 0x80807D4BU;
 constexpr std::size_t kItemPresenceFlagStride = sizeof(std::uint16_t);
 /** Authored value/flag slots must fit the nonnegative range of a signed 16-bit mapping. */
 constexpr std::uint16_t kUnlockSlotLimit = 0x8000U;
-/** Policy: pursuits without presence flags need a bucket-37 root with no objective block. */
-constexpr std::uint8_t kSeparateQuestRootBucketId = 37;
-
 /** Map +40 has no supported save bank; a matching slot makes initialization unsafe. */
 constexpr std::size_t kThirdValueMapDescriptor = 40;
 /** Map +56 is also checked for duplicate slots but has no supported save bank. */
@@ -199,7 +196,7 @@ Quest read_quest_initialization(std::span<const std::byte> definition,
         std::uint8_t parentBucket = 0;
         std::int64_t parentObjective = 0;
         if (parentIndex == itemIndex || !read(parent, kBucketIdOffset, parentBucket)
-            || parentBucket != kSeparateQuestRootBucketId
+            || parentBucket != kNonInventoryBucketId
             || !read(parent, kItemObjectiveBlockOffset, parentObjective) || parentObjective != 0) {
             return {};
         }

+ 4 - 1
Sunrise/src/middleware/content/packages/tables/unlock_expression.h

@@ -9,6 +9,9 @@
 
 namespace sunrise::middleware::content::packages::tables {
 
+/** Record and node field detection rejects values above 20 as non-expression data. */
+inline constexpr std::uint32_t kExpressionDetectionOpcodeCeiling = 20;
+
 /**
  * Reads the first operand of a given opcode out of one expression field.
  * The same field holds a value read on one row and a flag test on another, so the opcode is asked
@@ -51,7 +54,7 @@ namespace sunrise::middleware::content::packages::tables {
         const std::size_t at = base + static_cast<std::size_t>(index) * kUnlockInstructionStride;
         std::memcpy(&instruction, table.data() + at, sizeof instruction);
         std::memcpy(&operand, table.data() + at + kUnlockInstructionOperandOffset, sizeof operand);
-        if (instruction > kUnlockOpcodeCeiling) {
+        if (instruction > kExpressionDetectionOpcodeCeiling) {
             return false;
         }
         if (instruction == opcode && operand <= static_cast<std::uint32_t>(INT16_MAX)) {

+ 33 - 5
Sunrise/src/server/bap/bap_world_reward_queue.cpp

@@ -1,6 +1,10 @@
-#include "../../core/logging/log.h"
+#include <memory>
+#include <mutex>
+#include <new>
+
 #include "../../state/build_data/runtime.h"
 #include "../../state/investment/store_internal.h"
+#include "core/logging/log.h"
 #include "internal.h"
 
 namespace sunrise::server::bap {
@@ -14,10 +18,25 @@ bool commit_world_reward(const WorldRewardRequest& request) noexcept {
     }
     bool committed = false;
     if (request.kind == WorldRewardKind::item) {
-        state::PendingItemAcquisition acquisition;
-        committed =
-            state::prepare_item_acquisition_for_item(request.itemDefinitionIndex, acquisition)
-            && state::commit_item_acquisition(acquisition);
+        state::build_data::items::Definition item{};
+        if (!state::build_data::find_item_definition_index(request.itemDefinitionIndex, item)) {
+            return false;
+        }
+        if (state::item_grant_route(request.itemDefinitionIndex) == state::ItemGrantRoute::quest) {
+            state::PendingItemAcquisition acquisition;
+            committed = request.quantity == 1
+                        && state::prepare_item_acquisition_for_item(request.itemDefinitionIndex,
+                                                                    acquisition)
+                        && state::commit_item_acquisition(acquisition);
+        } else {
+            const std::unique_ptr<state::PendingRecordRewardGrant> grant(
+                new (std::nothrow) state::PendingRecordRewardGrant);
+            committed = grant && request.quantity > 0
+                        && state::prepare_item_reward(request.itemDefinitionIndex,
+                                                      static_cast<std::uint32_t>(request.quantity),
+                                                      *grant)
+                        && state::commit_record_reward(*grant);
+        }
     } else {
         state::PendingProfileItemAcquisition acquisition;
         committed = state::prepare_profile_item_acquisition_for_item(
@@ -45,6 +64,15 @@ bool enqueue_world_reward(std::uint16_t definitionIndex,
 
 } // namespace
 
+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);

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

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

@@ -21,192 +21,6 @@ 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;
-}
-
 /** Builds all record rewards and the pending claim into one Family-4 revision. */
 bool prepare_record_reward_grant(
     Scratch& scratch,
@@ -346,7 +160,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 +217,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,

+ 69 - 0
Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp

@@ -2,15 +2,19 @@
 
 #include <algorithm>
 #include <limits>
+#include <memory>
+#include <new>
 
 #include "../../../../core/logging/log.h"
 #include "../../../../middleware/secure_channel/runtime.h"
 #include "../../../../state/account/account_state.h"
 #include "../../../../state/activity/destination/definition.h"
 #include "../../../../state/activity/runtime.h"
+#include "../../../../state/build_data/runtime.h"
 #include "../../../../state/runtime/runtime.h"
 #include "../internal.h"
 #include "../push/activity/activity_keepalive_push.h"
+#include "queuez_reward_staging.h"
 #include "queuez_state_validation.h"
 #include "state/investment/store_internal.h"
 
@@ -52,6 +56,63 @@ selected_character(const state::AccountState& account) noexcept {
     return nullptr;
 }
 
+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 std::unique_ptr<state::PendingRecordRewardGrant> pending(
+        new (std::nothrow) state::PendingRecordRewardGrant);
+    const auto fail = [&](const char* reason) noexcept {
+        report_reward_refusal("world_publish", request.itemDefinitionIndex, reason);
+        return false;
+    };
+    const char* reason = "transaction";
+    state::investment::store::Transaction transaction;
+    if (!pending || !transaction.ready() || request.quantity <= 0
+        || !state::prepare_item_reward(request.itemDefinitionIndex,
+                                       static_cast<std::uint32_t>(request.quantity),
+                                       *pending,
+                                       &reason)) {
+        return fail(reason);
+    }
+    queuez::RecordRewardGrant update{};
+    if (!queuez::stage_record_reward_grant(session.queuez, *pending, update)) {
+        return fail("inventory_stage");
+    }
+    touchesScratch = true;
+    std::size_t framedSize = 0;
+    if (!push::append_record_reward_notification(scratch,
+                                                 session.queuez,
+                                                 update,
+                                                 *pending,
+                                                 active_acquisition_presentation_rows(session),
+                                                 session.sessionKey,
+                                                 session.sendNonce,
+                                                 scratch.framed,
+                                                 framedSize)
+        || framedSize == 0) {
+        return fail("inventory_encode");
+    }
+    if (framedSize > response.size()) {
+        return false;
+    }
+    if (!state::commit_record_reward(*pending)) {
+        return fail("inventory_commit");
+    }
+    if (!bap::complete_world_reward(request.id) || !transaction.commit()) {
+        return fail("queue_commit");
+    }
+    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 +120,14 @@ selected_character(const state::AccountState& account) noexcept {
                                                   std::span<std::byte> response,
                                                   std::size_t& written,
                                                   bool& touchesScratch) noexcept {
+    state::build_data::items::Definition item{};
+    if (!state::build_data::find_item_definition_index(request.itemDefinitionIndex, item)) {
+        return false;
+    }
+    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;

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

+ 8 - 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,10 @@ namespace sunrise::server::bap::encrypted::queuez {
                                              std::span<const std::uint64_t> appendedResidents,
                                              RecordRewardGrant& grant) noexcept;
 
+[[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,

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

@@ -323,6 +323,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,

+ 5 - 310
Sunrise/src/server/web_service/web_service_record_actions.cpp

@@ -1,19 +1,14 @@
 /** Triumph, title and season-pass reward actions the web service prepares from one request. */
 
-#include <algorithm>
 #include <array>
 #include <cstdio>
 #include <span>
 #include <string_view>
 
 #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 +18,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 +61,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. */
@@ -427,50 +156,16 @@ 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");
-        }
+    const char* reason = nullptr;
+    if (!state::prepare_season_pass_reward(request.rewardIndex, *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;
 

+ 11 - 4
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"
@@ -117,8 +118,15 @@ bool initialize(void* module, std::uint64_t configuredEquipmentHash) noexcept {
         domains.constants.weaponPowerStatRow,
         domains.constants.characterStatRows,
     };
-    if (!constants::replace(cachedConstants) || !content::replace(domains.named) || !content::seal()
-        || !items::replace(domains.items) || !collectibles::replace(domains.collectibles)
+    if (status != cache::LoadStatus::loaded || !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 +138,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,
@@ -277,10 +288,31 @@ bool valid_domains(const BuildIdentity& build, Domains domains) noexcept {
         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.size() != domains.items.size()
+            || (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) {
+            const auto& socket = reward.sockets[i];
+            if (socket.socketType == rewards::kAbsent
+                || (socket.plugItem != rewards::kAbsent
+                    && socket.plugItem >= 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;

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

@@ -98,6 +98,22 @@ 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;
 }
 
@@ -112,30 +128,24 @@ 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()) {
+    value.socketCount = record.socketCount;
+    value.conditionCount = record.conditionCount;
+    if (value.conditionCount > value.condition.size()) {
         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()) {
+    for (std::size_t i = 0; i < value.condition.size(); ++i) {
+        if (!decode(record.condition[i], value.condition[i])) {
+            return false;
+        }
+    }
+    if (value.socketCount > value.sockets.size()) {
         return false;
     }
-    value.definitionHash = record.definitionHash;
-    value.items = record.items;
-    value.itemCount = record.itemCount;
+    for (std::size_t i = 0; i < value.sockets.size(); ++i) {
+        if (!decode(record.sockets[i], value.sockets[i])) {
+            return false;
+        }
+    }
     return true;
 }
 

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

@@ -0,0 +1,123 @@
+#include "codec.h"
+
+namespace sunrise::state::build_data::cache::records {
+
+bool encode(const rewards::Pool& value, RewardPoolRecord& record) noexcept {
+    record = {};
+    record.definitionHash = value.definitionHash;
+    record.entries = {value.entries.first, value.entries.count};
+    return true;
+}
+
+bool decode(const RewardPoolRecord& record, rewards::Pool& value) noexcept {
+    value = {};
+    value.definitionHash = record.definitionHash;
+    value.entries = {record.entries.first, record.entries.count};
+    return true;
+}
+
+bool encode(const rewards::Entry& value, RewardEntryRecord& record) noexcept {
+    record = {};
+    record.itemIndex = value.itemIndex;
+    record.poolIndex = value.poolIndex;
+    record.quantity = value.quantity;
+    record.categoryHash = value.categoryHash;
+    record.bucketHash = value.bucketHash;
+    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;
+}
+
+bool decode(const RewardEntryRecord& record, rewards::Entry& value) noexcept {
+    value = {};
+    value.itemIndex = record.itemIndex;
+    value.poolIndex = record.poolIndex;
+    value.quantity = record.quantity;
+    value.categoryHash = record.categoryHash;
+    value.bucketHash = record.bucketHash;
+    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;
+}
+
+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;
+}
+
+bool decode(const RewardItemRecord& record, rewards::Item& value) noexcept {
+    value = {};
+    value.definitionHash = record.definitionHash;
+    value.poolIndex = record.poolIndex;
+    value.acquiredFlag = record.acquiredFlag;
+    for (std::size_t i = 0; i < value.selections.size(); ++i) {
+        value.selections[i] = {record.selections[i].categoryHash, record.selections[i].count};
+    }
+    value.selectionCount = record.selectionCount;
+    value.flags = record.flags;
+    return true;
+}
+
+bool encode(const rewards::Instruction& value, RewardInstructionRecord& record) noexcept {
+    record = {};
+    record.opcode = value.opcode;
+    record.operand = value.operand;
+    return true;
+}
+
+bool decode(const RewardInstructionRecord& record, rewards::Instruction& value) noexcept {
+    value = {};
+    value.opcode = record.opcode;
+    value.operand = record.operand;
+    return true;
+}
+
+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;
+}
+
+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;
+}
+
+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;
+}
+
+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

+ 93 - 15
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"
@@ -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,21 @@ 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::uint32_t opcode{};
+    std::uint32_t operand{};
+};
+
 /** Disk form of one season pass reward row. */
 struct SeasonPassRewardRecord {
     std::uint32_t itemHash{};
@@ -310,19 +331,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 +600,76 @@ 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::uint32_t quantity{};
+    std::uint32_t categoryHash{};
+    std::uint32_t bucketHash{};
+    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)
+              == 2 * sizeof(std::uint16_t) + 3 * sizeof(std::uint32_t) + sizeof(float)
+                     + 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::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) + 2 * sizeof(std::uint64_t)
                      + sizeof(InvestmentConstants)
                      + sizeof(gameplay::entity_position_profiles::Fingerprint));
 static_assert(sizeof(SpawnPointRecord)
@@ -631,10 +710,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,

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

@@ -25,6 +25,8 @@ 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)();
+/** Character bucket containing unopened engrams. */
+inline constexpr std::uint8_t kEngramBucketId = 31;
 /** 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. */

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

@@ -0,0 +1,114 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+namespace sunrise::state::build_data::rewards {
+
+/** Native references use all bits set for an absent row. */
+inline constexpr std::uint16_t kAbsent = 0xFFFF;
+/** Bounds cover signed native indices and the flat banks they reference. */
+inline constexpr std::size_t kPoolCapacity = 4096;
+inline constexpr std::size_t kEntryCapacity = 32768;
+inline constexpr std::size_t kItemCapacity = 32768;
+inline constexpr std::size_t kInstructionCapacity = 131072;
+inline constexpr std::size_t kModifierCapacity = 32768;
+inline constexpr std::size_t kSocketOverrideCapacity = 32768;
+/** Native reward overrides address the 12 ordinary sockets on an item. */
+inline constexpr std::size_t kSocketsPerItem = 12;
+/** A wrapper can select independently from four reward categories. */
+inline constexpr std::size_t kSelectionCapacity = 4;
+/** A resolved grant fits one bounded inventory transaction. */
+inline constexpr std::size_t kGrantCapacity = 32;
+/** Nested pools and referenced conditions share a bounded traversal depth. */
+inline constexpr std::size_t kTraversalDepth = 32;
+
+struct Range {
+    std::uint32_t first{};
+    std::uint32_t count{};
+};
+
+struct Instruction {
+    std::uint32_t opcode{};
+    std::uint32_t operand{};
+};
+
+/** Resolved bank reads occupy a separate range from native expression operators. */
+enum class BankRead : std::uint32_t {
+    accountFlag = 256,
+    characterFlag,
+    accountValue,
+    characterValue,
+    externalFlag,
+    externalValue,
+    characterClass,
+    profileFlag,
+};
+
+/** 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::uint32_t quantity{};
+    std::uint32_t categoryHash{};
+    std::uint32_t bucketHash{};
+    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;
+}
+
+} // namespace sunrise::state::build_data::rewards

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

@@ -0,0 +1,231 @@
+#include "reward_catalog.h"
+
+#include <algorithm>
+#include <array>
+#include <cmath>
+#include <mutex>
+#include <shared_mutex>
+
+#include "../../account/account_state.h"
+#include "../../unlocks/definition.h"
+#include "../table.h"
+#include "core/threading/srw_lock.h"
+#include "middleware/content/packages/tables/definition_index_table.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;
+
+View view() noexcept {
+    return {g_pools.rows(),
+            g_entries.rows(),
+            g_items.rows(),
+            g_instructions.rows(),
+            g_modifiers.rows(),
+            g_sockets.rows()};
+}
+
+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)) {
+        if (entry.poolIndex == kAbsent) {
+            continue;
+        }
+        if (!valid_depth(data, entry.poolIndex, heights, depth + 1)) {
+            return false;
+        }
+        height = (std::max)(height, static_cast<std::uint8_t>(heights[entry.poolIndex] + 1));
+    }
+    heights[index] = height;
+    return true;
+}
+
+} // namespace
+
+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();
+}
+
+bool ready() noexcept {
+    const std::shared_lock guard(g_lock);
+    return g_pools.count() != 0 && g_items.count() != 0;
+}
+
+bool valid_instruction(const Instruction& instruction) noexcept {
+    const auto slot = instruction.operand;
+    switch (static_cast<BankRead>(instruction.opcode)) {
+    case BankRead::accountFlag:
+        return slot < unlocks::kAccountFlagCapacity;
+    case BankRead::profileFlag:
+        return slot < unlocks::kProfileFlagCapacity;
+    case BankRead::characterFlag:
+        return slot < unlocks::kCharacterObjectFlagCapacity;
+    case BankRead::accountValue:
+        return slot < unlocks::kObjectiveValueCapacity;
+    case BankRead::characterValue:
+        return slot < unlocks::kCharacterObjectValueCapacity;
+    case BankRead::characterClass:
+        return slot < kCharacterClassCount;
+    case BankRead::externalFlag:
+    case BankRead::externalValue:
+        return true;
+    default:
+        break;
+    }
+    using Op = middleware::content::packages::tables::UnlockOpcode;
+    switch (static_cast<Op>(instruction.opcode)) {
+    case Op::constant:
+    case Op::logicalNot:
+    case Op::logicalOr:
+    case Op::logicalAnd:
+    case Op::equal:
+    case Op::greaterThan:
+    case Op::greaterOrEqual:
+    case Op::lessThan:
+    case Op::lessOrEqual:
+    case Op::add:
+    case Op::negate:
+        return true;
+    default:
+        return false;
+    }
+}
+
+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 ((entry.itemIndex != kAbsent && entry.itemIndex >= data.items.size())
+            || (entry.poolIndex != kAbsent && entry.poolIndex >= data.pools.size())
+            || !fits(entry.condition, data.instructions) || !fits(entry.modifiers, data.modifiers)
+            || !fits(entry.sockets, data.sockets) || !std::isfinite(entry.weight)
+            || entry.weight < 0) {
+            return false;
+        }
+    }
+    for (const Item& item : data.items) {
+        if ((item.definitionHash == 0
+             && (item.poolIndex != kAbsent || item.acquiredFlag != kAbsent
+                 || item.selectionCount != 0))
+            || item.selectionCount > item.selections.size()
+            || (item.acquiredFlag != kAbsent && item.acquiredFlag >= unlocks::kAccountFlagCapacity)
+            || (item.poolIndex != kAbsent && item.poolIndex >= data.pools.size())) {
+            return false;
+        }
+    }
+    if (!std::all_of(data.instructions.begin(), data.instructions.end(), valid_instruction)) {
+        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 (socket.socketType == kAbsent
+            || (socket.plugItem != kAbsent && socket.plugItem >= 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;
+}
+
+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);
+}
+
+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());
+}
+
+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;
+}
+
+bool snapshot(std::span<Pool> output, std::size_t& count) noexcept {
+    const std::shared_lock guard(g_lock);
+    return g_pools.snapshot(output, count);
+}
+
+bool snapshot(std::span<Entry> output, std::size_t& count) noexcept {
+    const std::shared_lock guard(g_lock);
+    return g_entries.snapshot(output, count);
+}
+
+bool snapshot(std::span<Item> output, std::size_t& count) noexcept {
+    const std::shared_lock guard(g_lock);
+    return g_items.snapshot(output, count);
+}
+
+bool snapshot(std::span<Instruction> output, std::size_t& count) noexcept {
+    const std::shared_lock guard(g_lock);
+    return g_instructions.snapshot(output, count);
+}
+
+bool snapshot(std::span<Modifier> output, std::size_t& count) noexcept {
+    const std::shared_lock guard(g_lock);
+    return g_modifiers.snapshot(output, count);
+}
+
+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

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

@@ -0,0 +1,30 @@
+#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 a bound instruction against its saved bank and the native opcode range. */
+[[nodiscard]] bool valid_instruction(const Instruction& instruction) noexcept;
+/** Checks bank ranges, item references and bounded acyclic pool 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

+ 5 - 13
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,11 @@ 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;
 
 /**
  * Reads one season pass reward row.
@@ -414,15 +413,6 @@ publish_progression_definitions(std::span<const progressions::Definition> defini
 [[nodiscard]] bool find_season_pass_reward(std::uint16_t rewardIndex,
                                            season_pass::Reward& reward) 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.
- */
-[[nodiscard]] bool find_season_pass_package(std::uint32_t definitionHash,
-                                            season_pass::Package& package) noexcept;
-
 /** @return True when the repeatable bounty table is in State. */
 [[nodiscard]] bool repeatable_bounties_ready() noexcept;
 
@@ -760,4 +750,6 @@ publish_vendor_catalog(std::span<const vendors::IndexEntry> index,
  */
 [[nodiscard]] bool persist() noexcept;
 
+/** Publishes a complete, validated reward graph. */
+[[nodiscard]] bool publish_reward_definitions(rewards::View definitions) noexcept;
 } // namespace sunrise::state::build_data

+ 8 - 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();
@@ -385,4 +387,10 @@ void clear_catalogs() noexcept {
 
 } // namespace runtime
 
+bool publish_reward_definitions(rewards::View definitions) noexcept {
+    runtime::persistence::Transaction transaction;
+    return transaction.active()
+           && transaction.finish(rewards::replace(definitions), rewards::clear);
+}
+
 } // namespace sunrise::state::build_data

+ 35 - 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()
+           && rewards::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.rewardPoolsScratch),
+        ensure_scratch<rewards::Entry, rewards::kEntryCapacity>(state.rewardEntriesScratch),
+        ensure_scratch<rewards::Item, rewards::kItemCapacity>(state.rewardItemsScratch),
+        ensure_scratch<rewards::Instruction, rewards::kInstructionCapacity>(
+            state.rewardInstructionsScratch),
+        ensure_scratch<rewards::Modifier, rewards::kModifierCapacity>(state.rewardModifiersScratch),
+        ensure_scratch<rewards::SocketOverride, rewards::kSocketOverrideCapacity>(
+            state.rewardSocketsScratch),
     };
 }
 
@@ -299,8 +310,14 @@ 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.rewardPoolsScratch);
+    release_bank(state.rewardEntriesScratch);
+    release_bank(state.rewardItemsScratch);
+    release_bank(state.rewardInstructionsScratch);
+    release_bank(state.rewardModifiersScratch);
+    release_bank(state.rewardSocketsScratch);
+
     release_bank(state.recordScratch);
     release_bank(state.recordObjectiveScratch);
     release_bank(state.recordIntervalScratch);
@@ -402,9 +419,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.rewardPoolsScratch.data(), counts.rewardPools},
+        std::span<const rewards::Entry>{state.rewardEntriesScratch.data(), counts.rewardEntries},
+        std::span<const rewards::Item>{state.rewardItemsScratch.data(), counts.rewardItems},
+        std::span<const rewards::Instruction>{state.rewardInstructionsScratch.data(),
+                                              counts.rewardInstructions},
+        std::span<const rewards::Modifier>{state.rewardModifiersScratch.data(),
+                                           counts.rewardModifiers},
+        std::span<const rewards::SocketOverride>{state.rewardSocketsScratch.data(),
+                                                 counts.rewardSockets},
     };
 }
 

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

@@ -79,8 +79,14 @@ 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> rewardPoolsScratch{};
+    std::vector<rewards::Entry> rewardEntriesScratch{};
+    std::vector<rewards::Item> rewardItemsScratch{};
+    std::vector<rewards::Instruction> rewardInstructionsScratch{};
+    std::vector<rewards::Modifier> rewardModifiersScratch{};
+    std::vector<rewards::SocketOverride> rewardSocketsScratch{};
+
     std::vector<records::Definition> recordScratch{};
     std::vector<records::Objective> recordObjectiveScratch{};
     std::vector<records::Interval> recordIntervalScratch{};

+ 10 - 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;
 
+/** Bound on the expanded eligibility expression of one pass row. */
+inline constexpr std::size_t kConditionCapacity = 64;
+
 /** 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,10 @@ 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{};
+    std::array<rewards::SocketOverride, rewards::kSocketsPerItem> sockets{};
+    std::uint8_t socketCount{};
+    std::array<rewards::Instruction, kConditionCapacity> condition{};
+    std::uint8_t conditionCount{};
 };
 
 } // namespace sunrise::state::build_data::season_pass

+ 3 - 10
Sunrise/src/state/build_data/season_pass/season_pass_build_data_runtime.cpp

@@ -9,12 +9,11 @@ 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);
 }
 
 /** Reads one season pass reward row. */
@@ -22,10 +21,4 @@ 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);
-}
-
 } // namespace sunrise::state::build_data

+ 25 - 54
Sunrise/src/state/build_data/season_pass/season_pass_catalog.cpp

@@ -1,96 +1,67 @@
 #include "season_pass_catalog.h"
 
 #include <algorithm>
+#include <mutex>
 #include <shared_mutex>
 
+#include "../../unlocks/definition.h"
+#include "../rewards/reward_catalog.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. */
 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) {
-        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;
-           });
+bool valid(std::span<const Reward> rewards) noexcept {
+    return !rewards.empty() && rewards.size() <= kRewardCapacity
+           && std::all_of(rewards.begin(), rewards.end(), [](const Reward& reward) {
+                  if (reward.itemHash == 0) {
+                      return reward.quantity == 0 && reward.conditionCount == 0
+                             && reward.socketCount == 0
+                             && reward.claimFlagIndex == kUnavailableFlagIndex;
+                  }
+                  return reward.quantity != 0 && reward.quantity <= INT32_MAX
+                         && (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,
+                                        rewards::valid_instruction);
+              });
 }
 
-/** 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. */
 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. */
 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

+ 4 - 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,7 @@ 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 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

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

@@ -0,0 +1,418 @@
+#include "reward_resolver.h"
+
+#include <algorithm>
+#include <cmath>
+
+#include "../build_data/rewards/reward_catalog.h"
+#include "../build_data/runtime.h"
+#include "middleware/content/packages/tables/definition_index_table.h"
+
+namespace sunrise::state::rewards {
+namespace {
+
+namespace definitions = build_data::rewards;
+/** The empty bucket tag inherits the enclosing pool's bucket constraint. */
+constexpr std::uint32_t kEmptyTag = 0x811C9DC5U;
+/** Native postfix expressions use at most 256 signed 32-bit stack values. */
+constexpr std::size_t kExpressionCapacity = 256;
+
+bool refuse(const Context& context, const char* reason) noexcept {
+    if (context.refusal != nullptr) {
+        *context.refusal = reason;
+    }
+    return false;
+}
+
+bool apply_condition_operator(const definitions::Instruction& instruction,
+                              std::span<std::int32_t> stack,
+                              std::size_t& size,
+                              const Context& context) noexcept {
+    if (!definitions::valid_instruction(instruction)) {
+        return refuse(context, "condition_opcode");
+    }
+    using Op = middleware::content::packages::tables::UnlockOpcode;
+    const auto opcode = static_cast<Op>(instruction.opcode);
+    const bool unary = opcode == Op::logicalNot || opcode == Op::negate;
+    if (size < (unary ? 1U : 2U)) {
+        return refuse(context, "condition_shape");
+    }
+    const auto right = unary ? 0 : stack[--size];
+    auto& left = stack[size - 1];
+    switch (opcode) {
+    case Op::logicalNot:
+        left = left == 0;
+        break;
+    case Op::negate:
+        left = static_cast<std::int32_t>(0U - static_cast<std::uint32_t>(left));
+        break;
+    case Op::logicalOr:
+        left = left != 0 || right != 0;
+        break;
+    case Op::logicalAnd:
+        left = left != 0 && right != 0;
+        break;
+    case Op::equal:
+        left = left == right;
+        break;
+    case Op::greaterThan:
+        left = left > right;
+        break;
+    case Op::greaterOrEqual:
+        left = left >= right;
+        break;
+    case Op::lessOrEqual:
+        left = left <= right;
+        break;
+    case Op::add:
+        left = static_cast<std::int32_t>(static_cast<std::uint32_t>(left)
+                                         + static_cast<std::uint32_t>(right));
+        break;
+    case Op::lessThan:
+        left = left < right;
+        break;
+    default:
+        return refuse(context, "condition_opcode");
+    }
+    return true;
+}
+
+bool condition(definitions::View data,
+               definitions::Range expression,
+               const Context& context,
+               bool& result) noexcept {
+    result = expression.count == 0;
+    if (!definitions::fits(expression, data.instructions)) {
+        return refuse(context, "condition_shape");
+    }
+    std::array<std::int32_t, kExpressionCapacity> stack{};
+    std::size_t size = 0;
+    for (const auto& instruction : data.instructions.subspan(expression.first, expression.count)) {
+        const auto operand = instruction.operand;
+        std::int32_t value = 0;
+        using Read = definitions::BankRead;
+        using Op = middleware::content::packages::tables::UnlockOpcode;
+        switch (instruction.opcode) {
+        case static_cast<std::uint32_t>(Read::accountFlag):
+            if (operand >= context.unlocks.accountFlags.size()) {
+                return refuse(context, "condition_shape");
+            }
+            value = context.unlocks.accountFlags[operand] == unlocks::kFlagSet;
+            break;
+        case static_cast<std::uint32_t>(Read::profileFlag):
+            if (operand >= context.unlocks.profileFlags.size()) {
+                return refuse(context, "condition_shape");
+            }
+            value = context.unlocks.profileFlags[operand] == unlocks::kFlagSet;
+            break;
+        case static_cast<std::uint32_t>(Read::characterFlag):
+            if (operand >= context.unlocks.characterObjectFlags.size()) {
+                return refuse(context, "condition_shape");
+            }
+            value = context.unlocks.characterObjectFlags[operand] == unlocks::kFlagSet;
+            break;
+        case static_cast<std::uint32_t>(Read::accountValue):
+            if (operand >= context.unlocks.objectiveValues.size()) {
+                return refuse(context, "condition_shape");
+            }
+            value = context.unlocks.objectiveValues[operand];
+            break;
+        case static_cast<std::uint32_t>(Read::characterValue):
+            if (operand >= context.unlocks.characterObjectValues.size()) {
+                return refuse(context, "condition_shape");
+            }
+            value = context.unlocks.characterObjectValues[operand];
+            break;
+        case static_cast<std::uint32_t>(Read::characterClass):
+            value = operand == static_cast<std::uint32_t>(context.characterClass);
+            break;
+        case static_cast<std::uint32_t>(Op::constant):
+            value = static_cast<std::int32_t>(operand);
+            break;
+        case static_cast<std::uint32_t>(Read::externalFlag):
+            return refuse(context, "external_flag");
+        case static_cast<std::uint32_t>(Read::externalValue):
+            return refuse(context, "external_value");
+        default:
+            if (!apply_condition_operator(instruction, stack, size, context)) {
+                return false;
+            }
+            continue;
+        }
+        if (size == stack.size()) {
+            return refuse(context, "condition_shape");
+        }
+        stack[size++] = value;
+    }
+    if (expression.count != 0) {
+        if (size != 1) {
+            return refuse(context, "condition_shape");
+        }
+        result = stack[0] != 0;
+    }
+    return true;
+}
+
+struct Resolver {
+    definitions::View data;
+    const Context& context;
+    Result& result;
+    std::uint64_t random;
+    Selection selection;
+
+    double fraction() noexcept {
+        // SplitMix64 makes a prepared seed replayable without shared random state.
+        random += 0x9E3779B97F4A7C15ULL;
+        auto bits = random;
+        bits = (bits ^ (bits >> 30)) * 0xBF58476D1CE4E5B9ULL;
+        bits = (bits ^ (bits >> 27)) * 0x94D049BB133111EBULL;
+        bits ^= bits >> 31;
+        return static_cast<double>(bits >> 11) * 0x1.0p-53;
+    }
+
+    bool weight(const definitions::Entry& entry,
+                std::uint32_t category,
+                std::uint32_t bucket,
+                std::size_t depth,
+                double& output) noexcept {
+        output = 0;
+        if (entry.categoryHash != category
+            || (bucket != kEmptyTag && entry.bucketHash != kEmptyTag
+                && entry.bucketHash != bucket)) {
+            return true;
+        }
+        if (selection == Selection::equipment && entry.poolIndex == definitions::kAbsent) {
+            build_data::items::details::Definition item{};
+            if (!build_data::find_configured_item_detail(entry.itemIndex, item)
+                || !item.equipmentSlot.has_value()) {
+                return true;
+            }
+        }
+        bool enabled = false;
+        if (!condition(data, entry.condition, context, enabled)) {
+            return false;
+        }
+        if (!enabled) {
+            return true;
+        }
+        double value = entry.weight;
+        if (!definitions::fits(entry.modifiers, data.modifiers)) {
+            return false;
+        }
+        for (const auto& modifier :
+             data.modifiers.subspan(entry.modifiers.first, entry.modifiers.count)) {
+            if (!condition(data, modifier.condition, context, enabled)) {
+                return false;
+            }
+            if (enabled) {
+                if (modifier.valueIndex != definitions::kAbsent) {
+                    return refuse(context, "indexed_weight");
+                }
+                value = modifier.value;
+            }
+        }
+        if (!std::isfinite(value) || value < 0) {
+            return false;
+        }
+        if (value == 0) {
+            return true;
+        }
+        if (entry.poolIndex != definitions::kAbsent) {
+            double total = 0;
+            if (!pool_weight(entry.poolIndex,
+                             category,
+                             entry.bucketHash == kEmptyTag ? bucket : entry.bucketHash,
+                             depth + 1,
+                             total)) {
+                return false;
+            }
+            if (total == 0) {
+                return true;
+            }
+        } else if (entry.itemIndex != definitions::kAbsent) {
+            if (entry.itemIndex >= data.items.size()) {
+                return false;
+            }
+            for (std::size_t i = 0; i < result.count; ++i) {
+                if (result.grants[i].itemIndex == entry.itemIndex) {
+                    return true;
+                }
+            }
+        } else {
+            return refuse(context, "reward_mapping");
+        }
+        output = value;
+        return true;
+    }
+
+    bool pool_weight(std::uint16_t index,
+                     std::uint32_t category,
+                     std::uint32_t bucket,
+                     std::size_t depth,
+                     double& total) noexcept {
+        total = 0;
+        if (depth >= definitions::kTraversalDepth || index >= data.pools.size()) {
+            return false;
+        }
+        const auto range = data.pools[index].entries;
+        if (!definitions::fits(range, data.entries)) {
+            return false;
+        }
+        for (const auto& entry : data.entries.subspan(range.first, range.count)) {
+            double value = 0;
+            if (!weight(entry, category, bucket, depth, value)) {
+                return false;
+            }
+            total += value;
+        }
+        return std::isfinite(total);
+    }
+
+    bool draw(std::uint16_t index,
+              std::uint32_t category,
+              std::uint32_t bucket,
+              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, bucket, depth, value)) {
+                return false;
+            }
+            if (value == 0) {
+                continue;
+            }
+            chosen = &entry;
+            remaining -= value;
+            if (remaining < 0) {
+                break;
+            }
+        }
+        if (chosen == nullptr
+            || (chosen->quantity != 1 && chosen->poolIndex != definitions::kAbsent)) {
+            return refuse(context, "nested_quantity");
+        }
+        if (chosen->poolIndex != definitions::kAbsent) {
+            const auto childBucket = chosen->bucketHash == kEmptyTag ? bucket : chosen->bucketHash;
+            double childTotal = 0;
+            return pool_weight(chosen->poolIndex, category, childBucket, depth + 1, childTotal)
+                   && childTotal > 0
+                   && draw(chosen->poolIndex, category, childBucket, depth + 1, childTotal);
+        }
+        if (chosen->quantity == 0 || chosen->quantity > INT32_MAX
+            || result.count == result.grants.size()
+            || !definitions::fits(chosen->sockets, data.sockets)) {
+            return false;
+        }
+        auto& grant = result.grants[result.count++];
+        grant.itemIndex = chosen->itemIndex;
+        grant.quantity = static_cast<std::int32_t>(chosen->quantity);
+        if (chosen->sockets.count > grant.sockets.size()) {
+            return false;
+        }
+        grant.socketCount = chosen->sockets.count;
+        std::copy_n(
+            data.sockets.begin() + chosen->sockets.first, grant.socketCount, grant.sockets.begin());
+        return true;
+    }
+};
+
+bool resolve_item(definitions::View data,
+                  const Context& context,
+                  std::uint16_t itemIndex,
+                  std::uint32_t quantity,
+                  Selection selection,
+                  Result& result) noexcept {
+    result = {};
+    if (context.refusal != nullptr) {
+        *context.refusal = "reward_shape";
+    }
+    if (itemIndex >= data.items.size() || quantity == 0 || quantity > INT32_MAX) {
+        return false;
+    }
+    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 || item.selectionCount == 0
+            || item.selectionCount > item.selections.size()) {
+            return false;
+        }
+        Resolver resolver{data, context, staged, context.seed, selection};
+        for (std::size_t i = 0; i < item.selectionCount; ++i) {
+            const auto& declared = item.selections[i];
+            if (declared.count > staged.grants.size()) {
+                return false;
+            }
+            for (std::size_t j = 0; j < declared.count; ++j) {
+                double total = 0;
+                if (!resolver.pool_weight(
+                        item.poolIndex, declared.categoryHash, kEmptyTag, 0, total)) {
+                    return false;
+                }
+                // A fixed bundle can have fewer eligible members after an acquisition unlock.
+                if (total == 0) {
+                    break;
+                }
+                if (!resolver.draw(item.poolIndex, declared.categoryHash, kEmptyTag, 0, total)) {
+                    return false;
+                }
+            }
+        }
+        if (staged.count == 0) {
+            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 {
+    definitions::View view{};
+    view.instructions = instructions;
+    if (instructions.size() > UINT32_MAX) {
+        return false;
+    }
+    return condition(view, {0, static_cast<std::uint32_t>(instructions.size())}, context, result);
+}
+
+bool resolve(const Context& context,
+             std::uint16_t itemIndex,
+             std::uint32_t quantity,
+             Selection selection,
+             Result& result) noexcept {
+    struct Request {
+        const Context& context;
+        std::uint16_t item;
+        std::uint32_t quantity;
+        Selection selection;
+        Result& result;
+    } request{context, itemIndex, quantity, selection, result};
+    result = {};
+    if (context.refusal != nullptr) {
+        *context.refusal = "reward_item";
+    }
+    return definitions::read(&request, [](void* raw, definitions::View data) noexcept {
+        auto& value = *static_cast<Request*>(raw);
+        return resolve_item(
+            data, value.context, value.item, value.quantity, value.selection, value.result);
+    });
+}
+
+} // namespace sunrise::state::rewards

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

@@ -0,0 +1,44 @@
+#pragma once
+
+#include "../account/account_state.h"
+#include "../build_data/rewards/definition.h"
+#include "../unlocks/definition.h"
+
+namespace sunrise::state::rewards {
+
+/** Pass engrams select equipment; ordinary acquisitions expand every declared lane. */
+enum class Selection : std::uint8_t { all, equipment };
+
+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{};
+};
+
+struct Result {
+    std::array<Grant, build_data::rewards::kGrantCapacity> grants{};
+    std::size_t count{};
+};
+
+/** 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{};
+};
+
+[[nodiscard]] bool eligible(std::span<const build_data::rewards::Instruction> instructions,
+                            const Context& context,
+                            bool& result) noexcept;
+
+/** Plans an acquisition without changing State; retains wrappers opened separately. */
+[[nodiscard]] bool resolve(const Context& context,
+                           std::uint16_t itemIndex,
+                           std::uint32_t quantity,
+                           Selection selection,
+                           Result& result) noexcept;
+
+} // namespace sunrise::state::rewards

+ 27 - 32
Sunrise/src/state/runtime/runtime.h

@@ -5,11 +5,12 @@
 #include <cstdint>
 #include <optional>
 #include <span>
-#include <variant>
 
 #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::account::settings {
@@ -20,6 +21,9 @@ struct SettingsDelta;
 
 namespace sunrise::state {
 
+/** Acquisition planner selected from installed wrapper, quest and bucket definitions. */
+enum class ItemGrantRoute : std::uint8_t { unavailable, quest, profile, reward };
+[[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 +203,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 +233,8 @@ struct PreparedRecordReward {
     std::uint16_t inventoryRow{};
     RecordRewardKind kind{};
     bool appendedProfileResident{};
+    std::uint16_t acquiredFlag{build_data::rewards::kAbsent};
+    std::uint8_t previousFlag{};
 };
 
 /** A reward grant that claims no record carries this instead of a record row. */
@@ -267,12 +262,9 @@ struct PendingRecordRewardGrant {
 
 /** One uncommitted Season reward and the exact native row or bundle it will claim. */
 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 +529,19 @@ 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; refusal receives a static reason on failure. */
+[[nodiscard]] bool prepare_item_reward(std::uint16_t itemIndex,
+                                       std::uint32_t quantity,
+                                       PendingRecordRewardGrant& mutation,
+                                       const char** refusal = nullptr) noexcept;
+/** Prepares an eligible native pass row without writing its claim or acquisition flags. */
+[[nodiscard]] bool prepare_season_pass_reward(std::uint16_t rewardIndex,
+                                              PendingSeasonPassReward& mutation,
+                                              const char** refusal = nullptr) noexcept;
+[[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;
@@ -561,7 +555,8 @@ prepare_item_acquisition(std::uint16_t collectibleIndex,
  */
 [[nodiscard]] bool prepare_record_reward_grant(std::span<const DirectRecordReward> rewards,
                                                std::uint16_t claimedRecordIndex,
-                                               PendingRecordRewardGrant& mutation) noexcept;
+                                               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,

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

+ 436 - 77
Sunrise/src/state/runtime/state_account_reward_grant_runtime.cpp

@@ -2,18 +2,21 @@
  * 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 <cstring>
 #include <limits>
 
+#include "../../middleware/crypto/random_bytes.h"
 #include "../../middleware/datagen/family4/loadout/loadout_resolver.h"
+#include "../build_data/rewards/reward_catalog.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 "../unlocks/unlocks_runtime.h"
+#include "middleware/content/packages/tables/definition_index_table.h"
 #include "runtime.h"
 #include "state_account_transaction_helpers.h"
 #include "storage/internal.h"
@@ -32,86 +35,353 @@ namespace {
                                              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.
- */
-[[nodiscard]] bool reward_matches(const build_data::season_pass::Reward& reward,
-                                  const PendingSeasonPassReward& mutation) noexcept {
-    if (!mutation.prepared || mutation.sourceDefinitionHash != reward.itemHash) {
+/** 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]] bool prepare_resolved_reward(const rewards::Result& resolved,
+                                           PendingRecordRewardGrant& mutation,
+                                           const char** refusal) noexcept {
+    std::array<DirectRecordReward, kRecordRewardGrantCapacity> rows{};
+    if (resolved.count > rows.size()) {
+        return false;
+    }
+    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_record_reward_grant(
+        std::span(rows).first(resolved.count), kUnclaimedRecordIndex, mutation, refusal);
+}
+
+[[nodiscard]] bool resolve_pass(const build_data::season_pass::Reward& reward,
+                                const AccountState& account,
+                                std::uint64_t seed,
+                                rewards::Result& result,
+                                bool replay = false,
+                                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::rewards::find_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 (replay && reward.claimFlagIndex < flags.accountFlags.size()) {
+        flags.accountFlags[reward.claimFlagIndex] = unlocks::kFlagClear;
+    }
+    bool enabled = false;
+    if (!rewards::eligible(std::span(reward.condition).first(reward.conditionCount),
+                           {flags, account.characters[character].characterClass, seed, reason},
+                           enabled)
+        || !enabled) {
         return false;
     }
-    if (const auto* item = std::get_if<PendingItemAcquisition>(&mutation.grant)) {
-        if (reward.quantity != 1) {
+    build_data::items::Definition item{};
+    const bool engram = build_data::find_item_definition_index(reward.itemIndex, item)
+                        && item.bucketId == inventory_buckets::kEngramBucketId;
+    if (!rewards::resolve({flags, account.characters[character].characterClass, seed, reason},
+                          reward.itemIndex,
+                          reward.quantity,
+                          engram ? rewards::Selection::equipment : rewards::Selection::all,
+                          result)) {
+        return false;
+    }
+    if (reward.socketCount != 0) {
+        if (result.count != 1 || result.grants[0].itemIndex != reward.itemIndex) {
+            if (reason != nullptr) {
+                *reason = "socket_owner";
+            }
             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()) {
+        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 {
+    const auto* grant = &mutation.grant;
+    rewards::Result expected{};
+    if (!mutation.prepared || mutation.sourceDefinitionHash != reward.itemHash
+        || !resolve_pass(reward, account_snapshot(), mutation.seed, expected, true)
+        || expected.count != grant->rewardCount) {
         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::rewards::find_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,
+                                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;
+    }
+    reason = "random_source";
+    std::array<std::byte, sizeof mutation.seed> random{};
+    if (!middleware::crypto::random::fill(random)) {
+        return false;
+    }
+    std::memcpy(&mutation.seed, random.data(), random.size());
+    rewards::Result resolved{};
+    reason = "reward_condition";
+    if (!resolve_pass(reward, account_snapshot(), mutation.seed, resolved, false, &reason)) {
+        return false;
+    }
+    reason = "reward_placement";
+    if (!prepare_resolved_reward(resolved, mutation.grant, &reason)) {
+        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{};
+    build_data::rewards::Item reward{};
+    if (!build_data::find_item_definition_index(itemIndex, item)) {
+        return ItemGrantRoute::unavailable;
+    }
+    if (build_data::rewards::find_item(itemIndex, reward)
+        && (reward.poolIndex != build_data::rewards::kAbsent
+            || (item.bucketId == middleware::content::packages::tables::kNonInventoryBucketId
+                && reward.acquiredFlag != build_data::rewards::kAbsent))) {
+        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;
+}
+
+bool prepare_item_reward(std::uint16_t itemIndex,
+                         std::uint32_t quantity,
+                         PendingRecordRewardGrant& mutation,
+                         const char** refusal) noexcept {
+    const char* unused = nullptr;
+    auto& reason = refusal != nullptr ? *refusal : unused;
+    reason = "item_definition";
+    mutation = {};
+    const std::lock_guard lock(investment::store::g_mutex);
+    build_data::items::Definition item{};
+    std::array<std::byte, sizeof(std::uint64_t)> random{};
+    if (!build_data::find_item_definition_index(itemIndex, item)) {
+        return false;
+    }
+    reason = "random_source";
+    if (!middleware::crypto::random::fill(random)) {
+        return false;
+    }
+    std::uint64_t seed = 0;
+    std::memcpy(&seed, random.data(), random.size());
+    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 false;
+    }
+    rewards::Result resolved{};
+    if (!rewards::resolve({flags, account.characters[character].characterClass, seed, &reason},
+                          itemIndex,
+                          quantity,
+                          rewards::Selection::all,
+                          resolved)) {
+        return false;
+    }
+    reason = "reward_placement";
+    if (!prepare_resolved_reward(resolved, mutation, &reason)) {
+        return false;
+    }
+    reason = nullptr;
+    return true;
+}
+
+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.rewardCount; ++i) {
+        const auto flag = mutation.rewards[i].acquiredFlag;
+        if (flag != build_data::rewards::kAbsent) {
+            after.accountFlags[flag] = unlocks::kFlagSet;
+        }
+    }
+    return true;
+}
+
+/** Revalidates the native draw under the same 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;
@@ -157,8 +427,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 != middleware::content::packages::tables::kNonInventoryBucketId
+                || !build_data::rewards::find_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 +449,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
@@ -230,11 +513,16 @@ namespace {
 /** Prepares every reward over one cumulative account view. */
 bool prepare_record_reward_grant(std::span<const DirectRecordReward> rewards,
                                  std::uint16_t claimedRecordIndex,
-                                 PendingRecordRewardGrant& mutation) noexcept {
+                                 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;
     }
+    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)
@@ -244,18 +532,57 @@ bool prepare_record_reward_grant(std::span<const DirectRecordReward> rewards,
 
     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 false;
+        }
+        PreparedRecordReward prepared{};
+        prepared.definitionHash = item.definitionHash;
+        prepared.quantity = requested.quantity;
+        if (requested.acquireUnlock) {
+            reason = "acquisition_flag";
+            build_data::rewards::Item source{};
+            if (!build_data::rewards::find_item(item.definitionIndex, source)
+                || source.definitionHash != item.definitionHash) {
+                return false;
+            }
+            prepared.acquiredFlag = source.acquiredFlag;
+            if (source.acquiredFlag != build_data::rewards::kAbsent) {
+                std::int32_t before = 0;
+                if (!investment::store::read_unlock(
+                        investment::store::Bank::accountFlags, source.acquiredFlag, before)
+                    || before < 0 || before > unlocks::kFlagSet) {
+                    return false;
+                }
+                prepared.previousFlag = static_cast<std::uint8_t>(before);
+            }
+        }
+        if (item.bucketId == middleware::content::packages::tables::kNonInventoryBucketId) {
+            reason = "perk_acquisition";
+            if (prepared.acquiredFlag == build_data::rewards::kAbsent || requested.quantity != 1
+                || !requested.sockets.empty()) {
+                return false;
+            }
+            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;
         }
+        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) {
@@ -264,10 +591,16 @@ bool prepare_record_reward_grant(std::span<const DirectRecordReward> rewards,
             }
         }
 
-        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 false;
+        }
+        reason = "inventory_destination";
         if (bucket.arraySelector == inventory_buckets::ArraySelector::profile) {
+            reason = "profile_capacity";
             if (detail.instancedDefinitionState
                 != item_details::InstancedDefinitionState::stackable) {
                 return false;
@@ -296,14 +629,23 @@ 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()) {
+            reason = "instance_quantity";
+            if (requested.quantity != 1) {
                 return false;
             }
+            reason = "instance_capacity";
             PendingItemAcquisition staged{};
             if (!finalize_item_acquisition(
                     working, working, item.definitionHash, false, {.direct = true}, staged)) {
                 return false;
             }
+            if (!apply_reward_sockets(
+                    detail,
+                    requested.sockets,
+                    staged.afterCharacter.inventory.values[staged.inventoryIndex].sockets,
+                    &reason)) {
+                return false;
+            }
             working.characters[characterIndex] = staged.afterCharacter;
             prepared.instanceSoid = staged.acquiredInstanceSoid;
             prepared.stateIndex = staged.inventoryIndex;
@@ -316,6 +658,7 @@ 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
@@ -353,6 +696,7 @@ bool prepare_record_reward_grant(std::span<const DirectRecordReward> rewards,
         mutation.rewards[index] = prepared;
     }
 
+    reason = "loadout";
     family4_loadout::ResolvedLoadout loadout{};
     if (!account::valid(working) || !valid_profile_inventory(working)
         || !family4_loadout::resolve(working, characterIndex, loadout)) {
@@ -369,6 +713,7 @@ 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;
 }
@@ -381,14 +726,28 @@ 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);
+            }
+        }
+        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,

+ 105 - 9
Sunrise/src/state/runtime/state_progression_runtime.cpp

@@ -8,14 +8,20 @@
 #include <limits>
 #include <span>
 
+#include "../build_data/rewards/reward_catalog.h"
 #include "../build_data/runtime.h"
+#include "../build_data/season_pass/season_pass_catalog.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 "core/logging/log.h"
+#include "middleware/content/packages/tables/definition_index_table.h"
 #include "runtime.h"
 #include "state.h"
+#include "state_account_transaction_helpers.h"
 #include "storage/internal.h"
 
 namespace sunrise::state {
@@ -244,6 +250,79 @@ bool publish_artifact_character_banks(CharacterArtifactWrite& write) noexcept {
     return publish_artifact_character_banks(write);
 }
 
+void report_perk_refusal(std::size_t index, const char* reason) noexcept {
+    core::log::writef(core::log::Channel::server,
+                      core::log::Level::warn,
+                      "ev=season_xp stage=automatic_perk index=%zu result=skip reason=%s",
+                      index,
+                      reason);
+}
+
+/** Earned perks set both flags in the transaction that publishes their qualifying rank. */
+bool grant_progress_flags() noexcept {
+    if (!build_data::season_pass_ready() || !build_data::rewards::ready()
+        || !build_data::item_definitions_ready()) {
+        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::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 (!rewards::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;
+        }
+    }
+    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 +337,23 @@ 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 {
+    namespace rewards = build_data::rewards;
+    build_data::items::Definition item{};
+    rewards::Item acquisition{};
+    if (build_data::find_item_definition_index(reward.itemIndex, item)
+        && item.definitionHash == reward.itemHash
+        && item.bucketId == middleware::content::packages::tables::kNonInventoryBucketId
+        && rewards::find_item(reward.itemIndex, acquisition)
+        && acquisition.definitionHash == reward.itemHash
+        && acquisition.poolIndex == rewards::kAbsent) {
+        return acquisition.acquiredFlag;
+    }
+    return rewards::kAbsent;
+}
+
 /** @return Seasonal XP published in the account progression bank. */
 std::int32_t seasonal_experience() noexcept {
     return unlocks::account_progression(kArtifactPowerProgressionIndex);
@@ -303,7 +399,8 @@ 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();
+                           && investment::store::write_family5(family) && grant_progress_flags()
+                           && transaction.commit();
     investment::store::g_mutex.unlock();
     return published;
 }
@@ -331,7 +428,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;
@@ -341,22 +438,21 @@ bool grant_seasonal_experience(std::int32_t amount) noexcept {
         return false;
     }
     const std::int32_t previous = seasonal_experience();
-    if (previous > (std::numeric_limits<std::int32_t>::max)() - amount) {
+    if (previous < 0 || amount > (std::numeric_limits<std::int32_t>::max)() - previous) {
         return false;
     }
-    const std::int32_t total = previous + amount;
+    const auto total = previous + amount;
 
     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;
+    return publish_experience_lanes(total) && publish_artifact_locked(family, mask, total)
+           && investment::store::write_family5(family) && grant_progress_flags()
+           && transaction.commit();
 }
 
 /** @return True when this Season pass reward row is already claimed. */