فهرست منبع

Implement Season Pass reward grants

Millie 1 هفته پیش
والد
کامیت
6d2237b41d

+ 4 - 3
Sunrise/src/middleware/datagen/family4/character/character_encoder.cpp

@@ -171,8 +171,8 @@ constexpr std::int32_t kOccupiedRowWatermark = 1;
     for (std::size_t index = 0; index < resolvedLoadout.itemCount; ++index) {
         const loadout::ResolvedItem& item = resolvedLoadout.items[index];
         const instance::ResolvedInstance& itemInstance = item.instance;
-        if (item.inventoryRow >= layout::kInventoryCapacity
-            || item.equipmentSlot >= occupiedEquipmentSlots.size() || item.quantity <= 0
+        const bool validEquipmentSlot = item.equipmentSlot < occupiedEquipmentSlots.size();
+        if (item.inventoryRow >= layout::kInventoryCapacity || item.quantity <= 0
             || itemInstance.instanceSoid == 0 || itemInstance.bounds.itemDefinitionCount == 0
             || itemInstance.bounds.itemDefinitionCount > instance::layout::kDefinitionIndexCapacity
             || itemInstance.baseDefinitionIndex == kEmptyDefinitionIndex
@@ -180,7 +180,8 @@ constexpr std::int32_t kOccupiedRowWatermark = 1;
             || item.mutationSerial < 0
             || static_cast<std::uint32_t>(item.mutationSerial)
                    >= resolvedLoadout.nextInventorySerial
-            || (item.equipped && occupiedEquipmentSlots[item.equipmentSlot])
+            || (item.equipped
+                && (!validEquipmentSlot || occupiedEquipmentSlots[item.equipmentSlot]))
             || (index != 0 && resolvedLoadout.items[index - 1].inventoryRow >= item.inventoryRow)) {
             return false;
         }

+ 3 - 0
Sunrise/src/middleware/datagen/family4/loadout/definition.h

@@ -9,6 +9,9 @@
 
 namespace sunrise::middleware::datagen::family4::loadout {
 
+/** Character inventory items without an equipment slot retain an out-of-range native sentinel. */
+inline constexpr std::uint8_t kUnavailableEquipmentSlot = 0xFFU;
+
 /** Equipped plus unequipped authored items one character may publish. */
 inline constexpr std::size_t kItemCapacity = state::account::inventory::kEquipmentSlotCount
                                              + state::account::inventory::kCharacterItemCapacity;

+ 12 - 3
Sunrise/src/middleware/datagen/family4/loadout/loadout_item_resolver.cpp

@@ -139,8 +139,7 @@ bool resolve_item(const authored_inventory::Item& authored,
     if (!state::build_data::find_item_definition_hash(authored.definitionHash, itemDefinition)
         || !state::build_data::find_configured_item_detail(itemDefinition.definitionIndex,
                                                            itemDetail)
-        || itemDefinition.bucketId != itemDetail.bucketId || !itemDetail.equipmentSlot.has_value()
-        || *itemDetail.equipmentSlot < 0
+        || itemDefinition.bucketId != itemDetail.bucketId
         || !state::build_data::find_inventory_bucket_descriptor(itemDetail.bucketId, bucket)
         || bucket.arraySelector != build_buckets::ArraySelector::character
         || !state::build_data::find_socket_entry_list(itemDetail.socketEntryListIndex, socketList)
@@ -152,7 +151,17 @@ bool resolve_item(const authored_inventory::Item& authored,
 
     Candidate candidate{};
     candidate.bucket = bucket;
-    candidate.item.equipmentSlot = static_cast<std::uint8_t>(*itemDetail.equipmentSlot);
+    if (itemDetail.equipmentSlot.has_value()) {
+        if (*itemDetail.equipmentSlot < 0) {
+            return false;
+        }
+        candidate.item.equipmentSlot = static_cast<std::uint8_t>(*itemDetail.equipmentSlot);
+    } else {
+        if (bucket.equipmentSlot != build_buckets::kUnavailableEquipmentSlot) {
+            return false;
+        }
+        candidate.item.equipmentSlot = kUnavailableEquipmentSlot;
+    }
     candidate.item.mutationSerial = authored.mutationSerial;
     candidate.item.flags = authored.flags;
     if (!resolve_quantity(authored, itemDetail, candidate.item.quantity)

+ 7 - 2
Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp

@@ -228,8 +228,7 @@ bool process(const ServiceRoute& route,
         auto* seasonPassReward =
             web_service::mutation_if<state::PendingSeasonPassReward>(webOutcome);
         if (webOutcome.hasArtifactReset) {
-            if (queuezState.family4Version
-                == (std::numeric_limits<std::int32_t>::max)()) {
+            if (queuezState.family4Version == (std::numeric_limits<std::int32_t>::max)()) {
                 return refuse_web_action(message, output, written);
             }
             middleware::web_service::StatusResponse status{};
@@ -572,6 +571,12 @@ bool process(const ServiceRoute& route,
                                                           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;
             }
             if (!staged) {
                 core::log::write(core::log::Channel::server,

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

@@ -96,7 +96,11 @@ struct RecordRewardGrantTransaction {
 /** Season reward grant plus the exact Queuez after-image promised by opcode 2400. */
 struct SeasonPassRewardTransaction {
     std::unique_ptr<state::PendingSeasonPassReward> pending{};
-    std::variant<std::monostate, queuez::ItemAcquisition, queuez::ProfileItemAcquisition> update{};
+    std::variant<std::monostate,
+                 queuez::ItemAcquisition,
+                 queuez::ProfileItemAcquisition,
+                 queuez::RecordRewardGrant>
+        update{};
 };
 
 /** Optional side effect produced while decoding one authenticated service body. */
@@ -306,6 +310,7 @@ void append_queuez_notification(Scratch& scratch,
     const queuez::SessionState& before,
     const queuez::RecordRewardGrant& update,
     const state::PendingRecordRewardGrant& mutation,
+    std::optional<std::uint16_t> pendingSeasonReward,
     std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
     std::span<const std::byte, state::kAesKeySize> key,
     std::span<const std::byte, state::kBapNonceSize> nonce,

+ 8 - 2
Sunrise/src/server/bap/encrypted/push/queuez/queuez_select_character.cpp

@@ -461,14 +461,20 @@ bool append_record_reward_notification(
     const queuez::SessionState& before,
     const queuez::RecordRewardGrant& update,
     const state::PendingRecordRewardGrant& mutation,
+    std::optional<std::uint16_t> pendingSeasonReward,
     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) noexcept {
     snapshot::Prepared prepared{};
-    if (!snapshot::prepare_record_reward_grant(
-            scratch, before, update, mutation, acquisitionPresentationRows, prepared)
+    if (!snapshot::prepare_record_reward_grant(scratch,
+                                               before,
+                                               update,
+                                               mutation,
+                                               pendingSeasonReward,
+                                               acquisitionPresentationRows,
+                                               prepared)
         || prepared.family.type != queuez::kAccountFamilyType
         || prepared.family.rootSoid != before.family4RootSoid
         || prepared.family.version != before.family4Version + 1 || prepared.family.flags != 0

+ 8 - 2
Sunrise/src/server/bap/encrypted/push/snapshot/family4_inventory_updates.cpp

@@ -505,6 +505,7 @@ bool prepare_record_reward_grant(
     const queuez::SessionState& before,
     const queuez::RecordRewardGrant& update,
     const state::PendingRecordRewardGrant& mutation,
+    std::optional<std::uint16_t> pendingSeasonReward,
     std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
     Prepared& prepared) noexcept {
     namespace account_layout = family4_datagen::account::layout;
@@ -627,7 +628,8 @@ bool prepare_record_reward_grant(
                                             selected.loadout,
                                             selected.lightEvaluation,
                                             characterBytes,
-                                            &mutation.claim)) {
+                                            pendingSeasonReward.has_value() ? nullptr
+                                                                            : &mutation.claim)) {
         clear_after(scratch, reservation);
         return report_failure("record_reward_character_encode");
     }
@@ -699,7 +701,11 @@ bool prepare_record_reward_grant(
     }
 
     const auto accountBytes = rawStorage.first(account_layout::kObjectSize);
-    if (!family4_datagen::account::encode(account, accountBytes, std::nullopt, &mutation.claim)) {
+    if (!family4_datagen::account::encode(account,
+                                          accountBytes,
+                                          pendingSeasonReward,
+                                          pendingSeasonReward.has_value() ? nullptr
+                                                                          : &mutation.claim)) {
         clear_after(scratch, reservation);
         return report_failure("record_reward_account_encode");
     }

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

@@ -220,6 +220,7 @@ prepare_profile_item_acquisition(Scratch& scratch,
     const queuez::SessionState& before,
     const queuez::RecordRewardGrant& update,
     const state::PendingRecordRewardGrant& mutation,
+    std::optional<std::uint16_t> pendingSeasonReward,
     std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
     Prepared& prepared) noexcept;
 

+ 20 - 0
Sunrise/src/server/bap/encrypted/queuez/queuez_outcome_staging.cpp

@@ -575,6 +575,7 @@ bool stage_service_outcome(Scratch& scratch,
                                                      before,
                                                      recordRewardGrant->update,
                                                      *recordRewardGrant->pending,
+                                                     std::nullopt,
                                                      presentationRows,
                                                      key,
                                                      nonce,
@@ -638,6 +639,25 @@ bool stage_service_outcome(Scratch& scratch,
             if (staged) {
                 middleware::secure_channel::advance_nonce(nonce);
             }
+        } else if (const auto* resourceUpdate =
+                       std::get_if<RecordRewardGrant>(&seasonPassReward->update)) {
+            if (const auto* resources =
+                    std::get_if<state::PendingRecordRewardGrant>(&pending.grant)) {
+                staged = push::append_record_reward_notification(scratch,
+                                                                 before,
+                                                                 *resourceUpdate,
+                                                                 *resources,
+                                                                 pending.rewardIndex,
+                                                                 presentationRows,
+                                                                 key,
+                                                                 nonce,
+                                                                 response,
+                                                                 written);
+                if (staged) {
+                    middleware::secure_channel::advance_nonce(nonce);
+                    after = resourceUpdate->after;
+                }
+            }
         }
         if (!staged) {
             core::log::write(core::log::Channel::server,

+ 220 - 0
Sunrise/src/server/web_service/web_service_actions.cpp

@@ -4,9 +4,12 @@
 #include <array>
 #include <cstdio>
 #include <limits>
+#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/opcode1820.h"
 #include "../../middleware/web_service/messages/opcode1821.h"
@@ -60,6 +63,201 @@ prepare_premium_class_package(const state::progression::season_pass::PremiumClas
     return state::prepare_direct_item_bundle(package.hash, itemIndices, 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. */
+[[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;
+        switch (character->characterClass) {
+        case state::CharacterClass::hunter:
+            armour = pass::kLegendaryHunterArmour;
+            break;
+        case state::CharacterClass::warlock:
+            armour = pass::kLegendaryWarlockArmour;
+            break;
+        case state::CharacterClass::titan:
+        default:
+            armour = pass::kLegendaryTitanArmour;
+            break;
+        }
+    } else if (engramHash == pass::kExoticEngramHash) {
+        weapons = pass::kExoticEngramWeapons;
+        switch (character->characterClass) {
+        case state::CharacterClass::hunter:
+            armour = pass::kExoticHunterArmour;
+            break;
+        case state::CharacterClass::warlock:
+            armour = pass::kExoticWarlockArmour;
+            break;
+        case state::CharacterClass::titan:
+        default:
+            armour = pass::kExoticTitanArmour;
+            break;
+        }
+    } 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;
+}
+
+struct SeasonArmourRoll {
+    std::uint16_t rewardIndex{};
+    std::uint32_t itemHash{};
+    std::array<std::uint32_t, 4> statPlugs{};
+};
+
+/** Exact rolls shown by the free, early premium, and high-stat pass tiles. */
+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}},
+}};
+
+[[nodiscard]] constexpr bool season_armour_rolls_valid() noexcept {
+    for (const SeasonArmourRoll& roll : kSeasonArmourRolls) {
+        const auto* reward = state::progression::season_pass::find(roll.rewardIndex);
+        if (reward == nullptr || reward->itemHash != roll.itemHash) {
+            return false;
+        }
+    }
+    return true;
+}
+
+static_assert(season_armour_rolls_valid());
+
+/** 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 <= 9U
+        || 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[6U + index] = roll->statPlugs[index];
+    }
+    acquired.sockets = sockets;
+    return state::account::inventory::valid(acquired.sockets);
+}
+
 } // namespace
 
 /**
@@ -639,13 +837,35 @@ void claim_season_pass_reward(const middleware::web_service::Message& message,
             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, 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");
+        }
     }
+    grant->sourceDefinitionHash = reward->itemHash;
     grant->rewardIndex = request.rewardIndex;
     grant->prepared = true;
 }

+ 90 - 0
Sunrise/src/state/progression/season_pass_reward_catalog.h

@@ -14,6 +14,96 @@ inline constexpr std::uint16_t kHudProgressionDefinitionIndex = 41;
 /** Each rank-one premium wrapper opens into one class armour set plus the pass weapon. */
 inline constexpr std::size_t kPremiumPackageItemCount = 6;
 
+/** The pass package expands into every installed Season 11 planetary-material stack. */
+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
+};
+
+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. */
+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,
+};
+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,
+};
+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,
+};
+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,
+};
+
+inline constexpr std::array<std::uint32_t, 10> kExoticEngramWeapons{
+    4068264807U, 2130065553U, 3325463374U, 1541131350U, 814876685U,
+    2694576561U, 3766045777U, 1852863732U, 2044500762U, 3413860063U,
+};
+inline constexpr std::array<std::uint32_t, 10> kExoticTitanArmour{
+    1190497097U, 2326396534U, 2255796155U, 2240152949U, 2578771006U,
+    2423243921U, 1734844650U, 2808156426U, 106575079U, 1591207518U,
+};
+inline constexpr std::array<std::uint32_t, 10> kExoticHunterArmour{
+    2268523867U, 1219761634U, 2757274117U, 4165919945U, 1474735276U,
+    978537162U, 1688602431U, 1163283805U, 896224899U, 903984858U,
+};
+inline constexpr std::array<std::uint32_t, 10> kExoticWarlockArmour{
+    2822465023U, 235591051U, 3948284065U, 3084282676U, 4057299719U,
+    121305948U, 3288917178U, 3627185503U, 1725917554U, 1030017949U,
+};
+
+template <std::size_t Size>
+[[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;
+}
+
 struct PremiumClassPackage {
     std::uint32_t hash{};
     std::array<std::uint32_t, kPremiumPackageItemCount> items{};

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

@@ -160,16 +160,9 @@ struct PendingDirectItemBundle {
     bool prepared{};
 };
 
-/** One uncommitted Season reward and the exact native row it will claim. */
-struct PendingSeasonPassReward {
-    std::variant<PendingItemAcquisition, PendingProfileItemAcquisition, PendingDirectItemBundle>
-        grant{};
-    std::uint16_t rewardIndex{};
-    bool prepared{};
-};
-
-inline constexpr std::size_t kRecordRewardGrantCapacity =
-    build_data::records::rewards::kRewardPerRecordCapacity;
+/** Shared batch capacity covers both Triumph rewards and the nine-row Season package. */
+inline constexpr std::size_t kRecordRewardGrantCapacity = 9;
+static_assert(kRecordRewardGrantCapacity >= build_data::records::rewards::kRewardPerRecordCapacity);
 
 /** One direct item requested by a record reward policy. */
 struct DirectRecordReward {
@@ -215,6 +208,18 @@ struct PendingRecordRewardGrant {
     bool prepared{};
 };
 
+/** One uncommitted Season reward and the exact native row or bundle it will claim. */
+struct PendingSeasonPassReward {
+    std::variant<PendingItemAcquisition,
+                 PendingProfileItemAcquisition,
+                 PendingDirectItemBundle,
+                 PendingRecordRewardGrant>
+        grant{};
+    std::uint32_t sourceDefinitionHash{};
+    std::uint16_t rewardIndex{};
+    bool prepared{};
+};
+
 /** One profile material actually credited by a prepared dismantle. */
 struct DismantleReward {
     std::uint32_t definitionHash{};

+ 44 - 6
Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp

@@ -24,6 +24,10 @@ namespace family4_loadout = middleware::datagen::family4::loadout;
 
 namespace {
 
+[[nodiscard]] bool materialize_record_reward(const AccountState& current,
+                                             const PendingRecordRewardGrant& mutation,
+                                             AccountState& after) noexcept;
+
 struct GrantSource {
     std::uint32_t materialRequirementSetHash{};
     std::uint16_t collectibleIndex{};
@@ -423,20 +427,45 @@ namespace {
 
 [[nodiscard]] bool reward_matches(const progression::season_pass::Reward& reward,
                                   const PendingSeasonPassReward& mutation) noexcept {
-    if (!mutation.prepared) {
+    if (!mutation.prepared || mutation.sourceDefinitionHash != reward.itemHash) {
         return false;
     }
     if (const auto* item = std::get_if<PendingItemAcquisition>(&mutation.grant)) {
-        return reward.quantity == 1 && item->acquiredDefinitionHash == reward.itemHash;
+        if (reward.quantity != 1) {
+            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 == reward.quantity;
     }
-    const auto* bundle = std::get_if<PendingDirectItemBundle>(&mutation.grant);
-    return bundle != nullptr && reward.quantity == 1
-           && bundle->sourceDefinitionHash == reward.itemHash
-           && progression::season_pass::find_premium_class_package(reward.itemHash) != nullptr;
+    if (const auto* bundle = std::get_if<PendingDirectItemBundle>(&mutation.grant)) {
+        return reward.quantity == 1 && bundle->sourceDefinitionHash == reward.itemHash
+               && progression::season_pass::find_premium_class_package(reward.itemHash) != nullptr;
+    }
+    const auto* resources = std::get_if<PendingRecordRewardGrant>(&mutation.grant);
+    if (resources == nullptr || reward.quantity != 1
+        || reward.itemHash != progression::season_pass::kDestinationResourceBundleHash
+        || resources->rewardCount != progression::season_pass::kDestinationResourceHashes.size()) {
+        return false;
+    }
+    for (std::size_t index = 0; index < resources->rewardCount; ++index) {
+        if (resources->rewards[index].definitionHash
+                != progression::season_pass::kDestinationResourceHashes[index]
+            || resources->rewards[index].quantity
+                   != progression::season_pass::kDestinationResourceQuantity) {
+            return false;
+        }
+    }
+    return true;
 }
 
 void restore_reward_grant(AccountState& account, const PendingItemAcquisition& mutation) noexcept {
@@ -455,6 +484,13 @@ void restore_reward_grant(AccountState& account, const PendingDirectItemBundle&
     account.characters[mutation.characterIndex] = mutation.beforeCharacter;
 }
 
+void restore_reward_grant(AccountState& account,
+                          const PendingRecordRewardGrant& mutation) noexcept {
+    account.characters[mutation.characterIndex] = mutation.beforeCharacter;
+    account.profileItems = mutation.beforeProfileItems;
+    account.profileItemCount = mutation.beforeProfileItemCount;
+}
+
 } // namespace
 
 /** Produces the full account after-image while a prepared character pull remains current. */
@@ -503,6 +539,8 @@ bool commit_season_pass_reward(PendingSeasonPassReward& mutation) noexcept {
         ready = materialize_profile_acquisition(runtime::storage::g_state.account, *profile, after);
     } else if (const auto* bundle = std::get_if<PendingDirectItemBundle>(&mutation.grant)) {
         ready = materialize_direct_item_bundle(runtime::storage::g_state.account, *bundle, after);
+    } else if (const auto* resources = std::get_if<PendingRecordRewardGrant>(&mutation.grant)) {
+        ready = materialize_record_reward(runtime::storage::g_state.account, *resources, after);
     }
     if (ready) {
         runtime::storage::g_state.account = after;