فهرست منبع

Grant record rewards on triumph claim

Claiming a triumph now grants the rewards authored for it in
record_rewards.bin (beside settings.json, generated from the era-correct
manifest 86657.20.08.23.1800-9, which joins all 2242 shipped records by
hash at +0x28). Extraction reads the record hash; a claim resolves its
record's reward rows, stages the grant through the same acquisition path
as opcode 1820, and promises the matching Family-4 version in the reply,
so the client pops the item immediately. Settings entries override the
file. Verified in game against build 86657.
millie 2 هفته پیش
والد
کامیت
136c7a86fd
27فایلهای تغییر یافته به همراه1335 افزوده شده و 186 حذف شده
  1. 6 0
      Sunrise/Sunrise.vcxproj
  2. 2 0
      Sunrise/src/client/content/items/packages/package_record_build.cpp
  3. 2 0
      Sunrise/src/core/settings/parser.h
  4. 83 0
      Sunrise/src/core/settings/state/account_rows_parser.cpp
  5. 6 0
      Sunrise/src/core/settings/state_settings.cpp
  6. 6 0
      Sunrise/src/middleware/content/packages/tables/definition_index_table.h
  7. 60 2
      Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp
  8. 22 1
      Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp
  9. 12 1
      Sunrise/src/server/bap/encrypted/internal.h
  10. 154 77
      Sunrise/src/server/bap/encrypted/queuez/queuez_outcome_staging.cpp
  11. 17 0
      Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp
  12. 141 3
      Sunrise/src/server/web_service/web_service_actions.cpp
  13. 2 1
      Sunrise/src/server/web_service/web_service_runtime.h
  14. 57 4
      Sunrise/src/state/account/account_state.cpp
  15. 38 0
      Sunrise/src/state/account/account_state.h
  16. 8 0
      Sunrise/src/state/build_data/records/definition.h
  17. 35 0
      Sunrise/src/state/build_data/records/rewards/definition.h
  18. 47 0
      Sunrise/src/state/build_data/records/rewards/reward_build_data_runtime.cpp
  19. 52 0
      Sunrise/src/state/build_data/records/rewards/reward_catalog.cpp
  20. 43 0
      Sunrise/src/state/build_data/records/rewards/reward_catalog.h
  21. 154 0
      Sunrise/src/state/build_data/records/rewards/reward_persistence.cpp
  22. 31 0
      Sunrise/src/state/build_data/records/rewards/reward_persistence.h
  23. 17 0
      Sunrise/src/state/build_data/runtime.h
  24. 46 0
      Sunrise/src/state/runtime/runtime.h
  25. 268 87
      Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp
  26. 22 10
      Sunrise/src/state/runtime/state_account_profile_runtime.cpp
  27. 4 0
      Sunrise/src/state/runtime/state_runtime.cpp

+ 6 - 0
Sunrise/Sunrise.vcxproj

@@ -830,6 +830,9 @@
     <ClCompile Include="src\state\build_data\nodes\node_persistence.cpp" />
     <ClCompile Include="src\state\build_data\records\record_build_data_runtime.cpp" />
     <ClCompile Include="src\state\build_data\records\record_catalog.cpp" />
+    <ClCompile Include="src\state\build_data\records\rewards\reward_build_data_runtime.cpp" />
+    <ClCompile Include="src\state\build_data\records\rewards\reward_catalog.cpp" />
+    <ClCompile Include="src\state\build_data\records\rewards\reward_persistence.cpp" />
     <ClCompile Include="src\state\record_claims\record_claims.cpp" />
     <ClCompile Include="src\state\activity\current_activity.cpp" />
     <ClCompile Include="src\state\build_data\records\record_persistence.cpp" />
@@ -1446,6 +1449,9 @@
     <ClInclude Include="src\state\build_data\nodes\node_persistence.h" />
     <ClInclude Include="src\state\build_data\records\definition.h" />
     <ClInclude Include="src\state\build_data\records\record_catalog.h" />
+    <ClInclude Include="src\state\build_data\records\rewards\definition.h" />
+    <ClInclude Include="src\state\build_data\records\rewards\reward_catalog.h" />
+    <ClInclude Include="src\state\build_data\records\rewards\reward_persistence.h" />
     <ClInclude Include="src\state\record_claims\record_claims.h" />
     <ClInclude Include="src\state\activity\current_activity.h" />
     <ClInclude Include="src\state\build_data\records\record_persistence.h" />

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

@@ -145,6 +145,8 @@ bool build_records(const reader::Source& source,
         domain::Definition& definition = output[static_cast<std::size_t>(row)];
         definition = {};
         definition.definitionIndex = static_cast<std::uint16_t>(row);
+        std::memcpy(&definition.definitionHash, blob.data() + at + tables::kRecordHashOffset,
+                    sizeof definition.definitionHash);
         // The lore row this record displays, or 0xFFFF for a book's parent triumph.
         std::memcpy(&definition.loreRow, blob.data() + at + tables::kLoreRowOffset,
                     sizeof definition.loreRow);

+ 2 - 0
Sunrise/src/core/settings/parser.h

@@ -161,6 +161,8 @@ private:
     [[nodiscard]] bool profile_items(state::AccountState& output) noexcept;
     /** Parses the server-authored, definition-driven ordinary-gear dismantle payout. */
     [[nodiscard]] bool dismantle_rewards(state::AccountState& output) noexcept;
+    /** Parses the server-authored record-claim reward table, keyed by record index. */
+    [[nodiscard]] bool record_rewards(state::AccountState& output) noexcept;
     /**
      * Parses one authored character identity.
      * @param output Receives one complete authored character row.

+ 83 - 0
Sunrise/src/core/settings/state/account_rows_parser.cpp

@@ -147,6 +147,89 @@ bool Parser::dismantle_rewards(state::AccountState& output) noexcept {
     }
 }
 
+/**
+ * Parses the record-claim reward table: one item, optionally with a quantity, per record index.
+ * Empty by default, so behavior is unchanged unless the operator populates it.
+ */
+bool Parser::record_rewards(state::AccountState& output) noexcept {
+    output.recordRewards = {};
+    output.recordRewardCount = 0;
+    if (!consume('[')) {
+        return false;
+    }
+    if (consume(']')) {
+        return true;
+    }
+    for (;;) {
+        if (output.recordRewardCount >= output.recordRewards.size() || !consume('{')) {
+            return false;
+        }
+        state::RecordRewardPolicy reward{};
+        bool hasRecordIndex = false;
+        bool hasItemIndex = false;
+        bool hasQuantity = false;
+        for (;;) {
+            std::string_view key;
+            if (!string(key) || !consume(':')) {
+                return false;
+            }
+            std::uint64_t value = 0;
+            if (key == "record_index") {
+                if (hasRecordIndex || !unsigned_integer(value)
+                    || value > (std::numeric_limits<std::uint16_t>::max)()) {
+                    return false;
+                }
+                reward.recordIndex = static_cast<std::uint16_t>(value);
+                hasRecordIndex = true;
+            } else if (key == "item_index") {
+                if (hasItemIndex || !unsigned_integer(value)
+                    || value > (std::numeric_limits<std::uint16_t>::max)()) {
+                    return false;
+                }
+                reward.itemIndex = static_cast<std::uint16_t>(value);
+                hasItemIndex = true;
+            } else if (key == "quantity") {
+                // Halved so a grant added to a stack already at its maximum cannot overflow
+                // before maxStackSize is checked. The real ceiling is the item's own stack size,
+                // enforced where the grant is applied; this only keeps a mis-authored table from
+                // reaching signed overflow.
+                if (hasQuantity || !unsigned_integer(value) || value == 0
+                    || value > (std::numeric_limits<std::int32_t>::max)() / 2) {
+                    return false;
+                }
+                reward.quantity = static_cast<std::int32_t>(value);
+                hasQuantity = true;
+            } else if (!skip_value(0)) {
+                return false;
+            }
+            if (consume('}')) {
+                break;
+            }
+            if (!consume(',')) {
+                return false;
+            }
+        }
+        if (!hasQuantity) {
+            reward.quantity = 1;
+        }
+        if (!hasRecordIndex || !hasItemIndex) {
+            return false;
+        }
+        for (std::size_t index = 0; index < output.recordRewardCount; ++index) {
+            if (state::same_record_reward_key(output.recordRewards[index], reward)) {
+                return false;
+            }
+        }
+        output.recordRewards[output.recordRewardCount++] = reward;
+        if (consume(']')) {
+            return true;
+        }
+        if (!consume(',')) {
+            return false;
+        }
+    }
+}
+
 /** Parses the authored account-wide item array. */
 bool Parser::profile_items(state::AccountState& output) noexcept {
     namespace inventory = state::account::inventory;

+ 6 - 0
Sunrise/src/core/settings/state_settings.cpp

@@ -263,6 +263,7 @@ bool Parser::account(state::AccountState& output) noexcept {
     bool hasPrimarySoid = false;
     bool hasSettings = false;
     bool hasDismantleRewards = false;
+    bool hasRecordRewards = false;
     if (consume('}')) {
         return false;
     }
@@ -290,6 +291,11 @@ bool Parser::account(state::AccountState& output) noexcept {
                 return false;
             }
             hasDismantleRewards = true;
+        } else if (key == "record_rewards") {
+            if (hasRecordRewards || !record_rewards(output)) {
+                return false;
+            }
+            hasRecordRewards = true;
         } else if (!skip_value(0)) {
             return false;
         }

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

@@ -39,6 +39,12 @@ inline constexpr std::size_t kRecordTableSlot = 72;
 inline constexpr std::size_t kRecordRowStride = 216;
 /** Unlock slot of the record's completion flag, or a non-positive value when it has none. */
 inline constexpr std::size_t kRecordCompletionFlagOffset = 100;
+/**
+ * Authored DestinyRecordDefinition hash inside one record row.
+ * Measured, not divided out: offset +0 holds degenerate values, and +0x28 was confirmed by joining
+ * 48 rows to Bungie's manifest by name.
+ */
+inline constexpr std::size_t kRecordHashOffset = 0x28;
 /**
  * Lore row a record names, or 0xFFFF when it names none.
  *

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

@@ -212,6 +212,8 @@ bool process(const ServiceRoute& route,
             web_service::mutation_if<state::PendingProfileItemAcquisition>(webOutcome);
         const auto* itemDismantle =
             web_service::mutation_if<state::PendingItemDismantle>(webOutcome);
+        const auto* recordRewardGrant =
+            web_service::mutation_if<state::PendingRecordRewardGrant>(webOutcome);
         if (equipmentSwap != nullptr) {
             // Equip is an optimistic Character-screen action. Its status-pair value is the exact
             // Family-4 revision whose following Queuez frame makes it authoritative. Stage that
@@ -450,12 +452,68 @@ bool process(const ServiceRoute& route,
                 transaction.pending = *itemDismantle;
             }
         }
+        if (recordRewardGrant != nullptr) {
+            // A record-claim reward names an item directly rather than a Collections row, so
+            // which acquisition kind actually lands it depends on the granted item's own
+            // inventory bucket. Stage whichever alternative resolved, exactly like the
+            // corresponding opcode-1820 branch above, so the reply below promises the same
+            // version the staged Family-4 update actually carries.
+            auto& transaction = outcome.transaction.emplace<RecordRewardGrantTransaction>();
+            bool staged = false;
+            std::int32_t stagedVersion = 0;
+            if (const auto* itemGrant =
+                    std::get_if<state::PendingItemAcquisition>(&recordRewardGrant->grant)) {
+                auto& update = transaction.update.emplace<queuez::ItemAcquisition>();
+                staged = queuez::stage_item_acquisition(queuezState,
+                                                        itemGrant->accountSoid,
+                                                        itemGrant->characterSoid,
+                                                        itemGrant->acquiredInstanceSoid,
+                                                        itemGrant->profileChanged,
+                                                        update);
+                stagedVersion = update.after.family4Version;
+            } else if (const auto* profileGrant = std::get_if<state::PendingProfileItemAcquisition>(
+                           &recordRewardGrant->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;
+            }
+            if (!staged) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=ws1801 stage=reward_preflight result=fail");
+                // Keep the plain claim reply below: the flag-bank claim already committed, so a
+                // reward that cannot be staged falls back to the ordinary account resync instead
+                // of failing the whole request.
+                outcome.transaction = std::monostate{};
+            } else {
+                middleware::web_service::StatusResponse status{};
+                status.value = stagedVersion;
+                if (!middleware::web_service::encode_response(
+                        message,
+                        middleware::web_service::ResponseShape::statusPair,
+                        status,
+                        output,
+                        written)) {
+                    core::log::write(core::log::Channel::server,
+                                     core::log::Level::warn,
+                                     "ev=ws1801 stage=reward_response result=fail");
+                    return false;
+                }
+                transaction.pending = *recordRewardGrant;
+            }
+        }
         // A pick that names the resident character moves nothing, so staging refuses it and the
         // reply still stands on its own.
-        if (webOutcome.hasRecordClaim) {
+        if (webOutcome.hasRecordClaim && std::holds_alternative<std::monostate>(outcome.transaction)) {
             // Every other client action promises the exact Family-4 revision that makes it
             // authoritative, and the claim was answering with the sentinel instead. The account
-            // resync staged later carries the next version, so promise that one here.
+            // resync staged later carries the next version, so promise that one here. A staged
+            // reward above already encoded its own correlated reply and is skipped here.
             middleware::web_service::StatusResponse status{};
             status.value = queuezState.family4Version + 1;
             if (!middleware::web_service::encode_response(

+ 22 - 1
Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp

@@ -172,7 +172,8 @@ bool consume(Session& session,
         || transaction_if<ItemStateTransaction>(outcome) != nullptr
         || transaction_if<ItemAcquisitionTransaction>(outcome) != nullptr
         || transaction_if<ProfileItemAcquisitionTransaction>(outcome) != nullptr
-        || transaction_if<ItemDismantleTransaction>(outcome) != nullptr;
+        || transaction_if<ItemDismantleTransaction>(outcome) != nullptr
+        || transaction_if<RecordRewardGrantTransaction>(outcome) != nullptr;
     // State commits consume and clear their pending payloads. Retain only the small diagnostic
     // fields needed after publication; QueueZ after-images stay owned by the transaction variant.
     const auto* stagedSocket = transaction_if<SocketPlugTransaction>(outcome);
@@ -347,6 +348,26 @@ bool consume(Session& session,
                                      {line.data(), static_cast<std::size_t>(count)});
                 }
             }
+            if (const auto* transaction = transaction_if<RecordRewardGrantTransaction>(outcome)) {
+                const bool profileGrant =
+                    std::holds_alternative<queuez::ProfileItemAcquisition>(transaction->update);
+                std::array<char, core::log::kLineCapacity> line{};
+                const int count = std::snprintf(
+                    line.data(),
+                    line.size(),
+                    "ev=record_reward stage=output_publish result=ok framed_bytes=%zu "
+                    "queuez_published=%u family_version=%d residents=%u profile_grant=%u",
+                    framedSize,
+                    static_cast<unsigned>(publishesQueuez),
+                    session.queuez.family4Version,
+                    static_cast<unsigned>(session.queuez.family4ResidentCount),
+                    static_cast<unsigned>(profileGrant));
+                if (count > 0) {
+                    core::log::write(core::log::Channel::server,
+                                     core::log::Level::debug,
+                                     {line.data(), static_cast<std::size_t>(count)});
+                }
+            }
         }
     }
     if (!handled) {

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

@@ -76,6 +76,16 @@ struct ItemDismantleTransaction {
     queuez::ItemDismantle update{};
 };
 
+/**
+ * Record-claim reward grant and its exact QueueZ after-image.
+ * A record reward lands through whichever acquisition kind its item's bucket resolved to, so the
+ * update carries the matching alternative of the same pair the pending grant itself wraps.
+ */
+struct RecordRewardGrantTransaction {
+    state::PendingRecordRewardGrant pending{};
+    std::variant<queuez::ItemAcquisition, queuez::ProfileItemAcquisition> update{};
+};
+
 /** Optional side effect produced while decoding one authenticated service body. */
 struct ServiceOutcome {
     bool hasSubscription{};
@@ -99,7 +109,8 @@ struct ServiceOutcome {
                                      ItemStateTransaction,
                                      ItemAcquisitionTransaction,
                                      ProfileItemAcquisitionTransaction,
-                                     ItemDismantleTransaction>;
+                                     ItemDismantleTransaction,
+                                     RecordRewardGrantTransaction>;
     Transaction transaction{};
 };
 

+ 154 - 77
Sunrise/src/server/bap/encrypted/queuez/queuez_outcome_staging.cpp

@@ -7,6 +7,124 @@
 #include "queuez_state_validation.h"
 
 namespace sunrise::server::bap::encrypted::queuez {
+namespace {
+
+/**
+ * Stages the character upsert and appended resident an item-acquisition transaction promised.
+ * Shared by the ordinary opcode-1820 transaction and a record-claim reward that resolved to a
+ * character-bucket grant: both stage the exact same way once an `ItemAcquisitionTransaction` is
+ * in hand.
+ */
+[[nodiscard]] bool stage_item_acquisition_push(Scratch& scratch,
+                                               const SessionState& before,
+                                               const ItemAcquisitionTransaction& itemAcquisition,
+                                               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 ItemAcquisition& acquisition = itemAcquisition.update;
+    const std::size_t appendedIndex = before.family4ResidentCount;
+    bool preservedManifest = acquisition.after.family4ResidentCount == appendedIndex + 1U;
+    for (std::size_t index = 0; preservedManifest && index < appendedIndex; ++index) {
+        preservedManifest = acquisition.after.family4Residents[index].objectSoid
+                                == before.family4Residents[index].objectSoid
+                            && acquisition.after.family4Residents[index].definitionId
+                                   == before.family4Residents[index].definitionId;
+    }
+    if (!valid(acquisition.after) || !preservedManifest
+        || acquisition.accountSoid != itemAcquisition.pending.accountSoid
+        || acquisition.characterSoid != itemAcquisition.pending.characterSoid
+        || acquisition.acquiredInstanceSoid != itemAcquisition.pending.acquiredInstanceSoid
+        || acquisition.updatesAccount != itemAcquisition.pending.profileChanged
+        || acquisition.accountSoid != before.family4RootSoid
+        || acquisition.after.family4RootSoid != before.family4RootSoid
+        || before.family4ResidentCount >= before.family4Residents.size()
+        || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
+        || acquisition.after.family4Version != before.family4Version + 1
+        || acquisition.after.family4Residents[appendedIndex].objectSoid
+               != acquisition.acquiredInstanceSoid
+        || acquisition.after.family4Residents[appendedIndex].definitionId
+               != acquisition.itemInstanceDefinitionId
+        || !push::append_item_acquisition_notification(
+            scratch, acquisition, itemAcquisition.pending, key, nonce, response, written)) {
+        return false;
+    }
+    middleware::secure_channel::advance_nonce(nonce);
+    after = acquisition.after;
+    return true;
+}
+
+/**
+ * Stages the account upsert (and possible manifest append) a profile-acquisition transaction
+ * promised. Shared by the ordinary opcode-1820 transaction and a record-claim reward that
+ * resolved to a profile-bucket grant.
+ */
+[[nodiscard]] bool
+stage_profile_item_acquisition_push(Scratch& scratch,
+                                    const SessionState& before,
+                                    const ProfileItemAcquisitionTransaction& profileAcquisition,
+                                    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 ProfileItemAcquisition& acquisition = profileAcquisition.update;
+    const std::size_t priorResidentCount = before.family4ResidentCount;
+    const std::size_t expectedResidentCount =
+        priorResidentCount + static_cast<std::size_t>(acquisition.appendedResident);
+    bool validManifest = expectedResidentCount <= acquisition.after.family4Residents.size()
+                         && acquisition.after.family4ResidentCount == expectedResidentCount;
+    for (std::size_t index = 0; validManifest && index < priorResidentCount; ++index) {
+        validManifest = acquisition.after.family4Residents[index].objectSoid
+                            == before.family4Residents[index].objectSoid
+                        && acquisition.after.family4Residents[index].definitionId
+                               == before.family4Residents[index].definitionId;
+    }
+    std::size_t priorProfileResidentMatches = 0;
+    for (std::size_t index = 0; index < priorResidentCount; ++index) {
+        const ResidentObject& resident = before.family4Residents[index];
+        priorProfileResidentMatches += static_cast<std::size_t>(
+            acquisition.acquiredInstanceSoid != 0
+            && resident.objectSoid == acquisition.acquiredInstanceSoid
+            && resident.definitionId == acquisition.itemInstanceDefinitionId);
+    }
+    const bool appendedResidentValid =
+        !acquisition.appendedResident
+        || (priorResidentCount < acquisition.after.family4Residents.size()
+            && acquisition.after.family4Residents[priorResidentCount].objectSoid
+                   == acquisition.acquiredInstanceSoid
+            && acquisition.after.family4Residents[priorResidentCount].definitionId
+                   == acquisition.itemInstanceDefinitionId
+            && priorProfileResidentMatches == 0);
+    const bool sourceIdentityValid =
+        acquisition.actionSource == (acquisition.acquiredInstanceSoid != 0)
+        && (acquisition.actionSource
+                ? acquisition.itemInstanceDefinitionId != 0 && appendedResidentValid
+                      && (acquisition.appendedResident || priorProfileResidentMatches == 1)
+                : acquisition.itemInstanceDefinitionId == 0 && !acquisition.appendedResident
+                      && priorProfileResidentMatches == 0);
+    if (!valid(acquisition.after) || !validManifest || !sourceIdentityValid
+        || acquisition.accountSoid != profileAcquisition.pending.accountSoid
+        || acquisition.acquiredInstanceSoid != profileAcquisition.pending.acquiredInstanceSoid
+        || acquisition.actionSource != profileAcquisition.pending.actionSource
+        || acquisition.appendedResident
+               != (profileAcquisition.pending.appended && profileAcquisition.pending.actionSource)
+        || acquisition.accountSoid != before.family4RootSoid || before.family4ResidentCount == 0
+        || acquisition.accountDefinitionId != before.family4Residents.front().definitionId
+        || acquisition.after.family4RootSoid != before.family4RootSoid
+        || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
+        || acquisition.after.family4Version != before.family4Version + 1
+        || !push::append_profile_item_acquisition_notification(
+            scratch, acquisition, profileAcquisition.pending, key, nonce, response, written)) {
+        return false;
+    }
+    middleware::secure_channel::advance_nonce(nonce);
+    after = acquisition.after;
+    return true;
+}
+
+} // namespace
 
 /** Stages queuez subscription, unsubscription, or character-move output for one peer. */
 bool stage_service_outcome(Scratch& scratch,
@@ -30,6 +148,7 @@ bool stage_service_outcome(Scratch& scratch,
     const auto* itemAcquisition = transaction_if<ItemAcquisitionTransaction>(outcome);
     const auto* profileAcquisition = transaction_if<ProfileItemAcquisitionTransaction>(outcome);
     const auto* itemDismantle = transaction_if<ItemDismantleTransaction>(outcome);
+    const auto* recordRewardGrant = transaction_if<RecordRewardGrantTransaction>(outcome);
     if (outcome.hasSubscription) {
         push::append_queuez_notification(scratch,
                                          before,
@@ -278,97 +397,55 @@ bool stage_service_outcome(Scratch& scratch,
     } else if (itemAcquisition != nullptr) {
         // Body processing staged this exact manifest append before encoding the response version.
         // The character and new item objects must both fit or the State insertion is not committed.
-        const ItemAcquisition& acquisition = itemAcquisition->update;
-        const std::size_t appendedIndex = before.family4ResidentCount;
-        bool preservedManifest = acquisition.after.family4ResidentCount == appendedIndex + 1U;
-        for (std::size_t index = 0; preservedManifest && index < appendedIndex; ++index) {
-            preservedManifest = acquisition.after.family4Residents[index].objectSoid
-                                    == before.family4Residents[index].objectSoid
-                                && acquisition.after.family4Residents[index].definitionId
-                                       == before.family4Residents[index].definitionId;
-        }
-        if (!valid(acquisition.after) || !preservedManifest
-            || acquisition.accountSoid != itemAcquisition->pending.accountSoid
-            || acquisition.characterSoid != itemAcquisition->pending.characterSoid
-            || acquisition.acquiredInstanceSoid != itemAcquisition->pending.acquiredInstanceSoid
-            || acquisition.updatesAccount != itemAcquisition->pending.profileChanged
-            || acquisition.accountSoid != before.family4RootSoid
-            || acquisition.after.family4RootSoid != before.family4RootSoid
-            || before.family4ResidentCount >= before.family4Residents.size()
-            || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
-            || acquisition.after.family4Version != before.family4Version + 1
-            || acquisition.after.family4Residents[appendedIndex].objectSoid
-                   != acquisition.acquiredInstanceSoid
-            || acquisition.after.family4Residents[appendedIndex].definitionId
-                   != acquisition.itemInstanceDefinitionId
-            || !push::append_item_acquisition_notification(
-                scratch, acquisition, itemAcquisition->pending, key, nonce, response, written)) {
+        if (!stage_item_acquisition_push(
+                scratch, before, *itemAcquisition, key, nonce, response, written, after)) {
             core::log::write(core::log::Channel::server,
                              core::log::Level::warn,
                              "ev=queuez stage=acquire result=fail");
             return false;
         }
-        middleware::secure_channel::advance_nonce(nonce);
-        after = acquisition.after;
     } else if (profileAcquisition != nullptr) {
         // A source-backed profile append creates one dependency before the account starts naming
         // it. Existing stacks and non-actionable currency rows preserve the complete manifest.
-        const ProfileItemAcquisition& acquisition = profileAcquisition->update;
-        const std::size_t priorResidentCount = before.family4ResidentCount;
-        const std::size_t expectedResidentCount =
-            priorResidentCount + static_cast<std::size_t>(acquisition.appendedResident);
-        bool validManifest = expectedResidentCount <= acquisition.after.family4Residents.size()
-                             && acquisition.after.family4ResidentCount == expectedResidentCount;
-        for (std::size_t index = 0; validManifest && index < priorResidentCount; ++index) {
-            validManifest = acquisition.after.family4Residents[index].objectSoid
-                                == before.family4Residents[index].objectSoid
-                            && acquisition.after.family4Residents[index].definitionId
-                                   == before.family4Residents[index].definitionId;
+        if (!stage_profile_item_acquisition_push(
+                scratch, before, *profileAcquisition, key, nonce, response, written, after)) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             "ev=queuez stage=profile_acquire result=fail");
+            return false;
         }
-        std::size_t priorProfileResidentMatches = 0;
-        for (std::size_t index = 0; index < priorResidentCount; ++index) {
-            const ResidentObject& resident = before.family4Residents[index];
-            priorProfileResidentMatches += static_cast<std::size_t>(
-                acquisition.acquiredInstanceSoid != 0
-                && resident.objectSoid == acquisition.acquiredInstanceSoid
-                && resident.definitionId == acquisition.itemInstanceDefinitionId);
+    } else if (recordRewardGrant != nullptr) {
+        // A record-claim reward names an item directly rather than a Collections row; whichever
+        // acquisition kind its bucket resolved to stages exactly the same way its opcode-1820
+        // counterpart does above, so this takes priority over the plain resync fallback below.
+        bool staged = false;
+        if (const auto* itemUpdate =
+                std::get_if<ItemAcquisition>(&recordRewardGrant->update)) {
+            ItemAcquisitionTransaction wrapped{};
+            if (const auto* itemPending = std::get_if<state::PendingItemAcquisition>(
+                    &recordRewardGrant->pending.grant)) {
+                wrapped.pending = *itemPending;
+            }
+            wrapped.update = *itemUpdate;
+            staged = stage_item_acquisition_push(
+                scratch, before, wrapped, key, nonce, response, written, after);
+        } else if (const auto* profileUpdate =
+                       std::get_if<ProfileItemAcquisition>(&recordRewardGrant->update)) {
+            ProfileItemAcquisitionTransaction wrapped{};
+            if (const auto* profilePending = std::get_if<state::PendingProfileItemAcquisition>(
+                    &recordRewardGrant->pending.grant)) {
+                wrapped.pending = *profilePending;
+            }
+            wrapped.update = *profileUpdate;
+            staged = stage_profile_item_acquisition_push(
+                scratch, before, wrapped, key, nonce, response, written, after);
         }
-        const bool appendedResidentValid =
-            !acquisition.appendedResident
-            || (priorResidentCount < acquisition.after.family4Residents.size()
-                && acquisition.after.family4Residents[priorResidentCount].objectSoid
-                       == acquisition.acquiredInstanceSoid
-                && acquisition.after.family4Residents[priorResidentCount].definitionId
-                       == acquisition.itemInstanceDefinitionId
-                && priorProfileResidentMatches == 0);
-        const bool sourceIdentityValid =
-            acquisition.actionSource == (acquisition.acquiredInstanceSoid != 0)
-            && (acquisition.actionSource
-                    ? acquisition.itemInstanceDefinitionId != 0 && appendedResidentValid
-                          && (acquisition.appendedResident || priorProfileResidentMatches == 1)
-                    : acquisition.itemInstanceDefinitionId == 0 && !acquisition.appendedResident
-                          && priorProfileResidentMatches == 0);
-        if (!valid(acquisition.after) || !validManifest || !sourceIdentityValid
-            || acquisition.accountSoid != profileAcquisition->pending.accountSoid
-            || acquisition.acquiredInstanceSoid != profileAcquisition->pending.acquiredInstanceSoid
-            || acquisition.actionSource != profileAcquisition->pending.actionSource
-            || acquisition.appendedResident
-                   != (profileAcquisition->pending.appended
-                       && profileAcquisition->pending.actionSource)
-            || acquisition.accountSoid != before.family4RootSoid || before.family4ResidentCount == 0
-            || acquisition.accountDefinitionId != before.family4Residents.front().definitionId
-            || acquisition.after.family4RootSoid != before.family4RootSoid
-            || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
-            || acquisition.after.family4Version != before.family4Version + 1
-            || !push::append_profile_item_acquisition_notification(
-                scratch, acquisition, profileAcquisition->pending, key, nonce, response, written)) {
+        if (!staged) {
             core::log::write(core::log::Channel::server,
                              core::log::Level::warn,
-                             "ev=queuez stage=profile_acquire result=fail");
+                             "ev=queuez stage=record_reward result=fail");
             return false;
         }
-        middleware::secure_channel::advance_nonce(nonce);
-        after = acquisition.after;
     } else if (outcome.hasRecordClaim) {
         // A claim rewrites one byte of the account flag bank and leaves the manifest alone, so a
         // full account snapshot at the next version carries it with no other staging.

+ 17 - 0
Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp

@@ -252,6 +252,23 @@ bool commit(ServiceOutcome& outcome, Publication& publication) noexcept {
                                    : "ev=dismantle stage=transaction_commit result=fail");
         return committed;
     }
+    if (auto* transaction = transaction_if<RecordRewardGrantTransaction>(outcome)) {
+        // Whichever acquisition kind the reward resolved to commits through the exact same path
+        // its opcode-1820 counterpart uses.
+        bool committed = false;
+        if (auto* itemPending =
+                std::get_if<state::PendingItemAcquisition>(&transaction->pending.grant)) {
+            committed = state::commit_item_acquisition(*itemPending);
+        } else if (auto* profilePending = std::get_if<state::PendingProfileItemAcquisition>(
+                       &transaction->pending.grant)) {
+            committed = state::commit_profile_item_acquisition(*profilePending);
+        }
+        core::log::write(core::log::Channel::server,
+                         committed ? core::log::Level::debug : core::log::Level::warn,
+                         committed ? "ev=record_reward stage=transaction_commit result=ok"
+                                   : "ev=record_reward stage=transaction_commit result=fail");
+        return committed;
+    }
     return true;
 }
 

+ 141 - 3
Sunrise/src/server/web_service/web_service_actions.cpp

@@ -1,6 +1,7 @@
 #include "web_service_actions.h"
 
 #include <array>
+#include <atomic>
 #include <cstdio>
 #include <limits>
 #include <string_view>
@@ -17,6 +18,7 @@
 #include "../../middleware/web_service/messages/opcode801.h"
 #include "../../middleware/web_service/messages/opcode903.h"
 #include "../../state/account/account_state.h"
+#include "../../state/build_data/records/rewards/reward_persistence.h"
 #include "../../state/build_data/runtime.h"
 #include "../../state/runtime/runtime.h"
 
@@ -848,11 +850,144 @@ void acquire_item(const middleware::web_service::Message& message, Outcome& outc
                             mutation.acquiredInstanceSoid);
 }
 
+/** Logs one record-claim reward resolution and its item/quantity destination. */
+void report_record_reward(const middleware::web_service::Message& message,
+                          std::string_view result,
+                          std::string_view reason,
+                          std::uint32_t recordIndex,
+                          std::uint32_t itemIndex,
+                          std::int32_t quantity) noexcept {
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count =
+        std::snprintf(line.data(),
+                      line.size(),
+                      "ev=ws1801 stage=reward result=%.*s reason=%.*s transaction=%u "
+                      "record_index=%u item_index=%u quantity=%d",
+                      static_cast<int>(result.size()),
+                      result.data(),
+                      static_cast<int>(reason.size()),
+                      reason.data(),
+                      static_cast<unsigned>(message.transactionId),
+                      recordIndex,
+                      itemIndex,
+                      quantity);
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         result == "ok" ? core::log::Level::debug : core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+}
+
+/**
+ * Resolves and prepares a claimed record's reward, if any.
+ *
+ * Two sources feed this, in order. The settings-authored `record_rewards` table (see
+ * `state::account::RecordRewardPolicy`) is the operator's manual override and wins whenever it
+ * names this record. Only when it does not is the shipped, manifest-generated table consulted (see
+ * `state::build_data::records::rewards`), which sources the reward Bungie's manifest actually
+ * assigns the Triumph. Neither is required: a record naming no reward in either table, or one whose
+ * item cannot be resolved or no longer fits, leaves the outcome exactly as an unmapped claim
+ * already behaves -- no mutation, so the caller falls back to the plain account resync. The claim
+ * itself already committed by the time this runs, so a reward that cannot be granted never turns a
+ * successful claim into a failed one.
+ * @param message Parsed opcode-1801 request, used only for correlated logging.
+ * @param recordIndex Native record row that was just claimed.
+ * @param recordHash Manifest definition hash of the same record, used only to consult the
+ * generated table when the settings table names nothing for this record.
+ * @param outcome Gets the prepared grant mutation only when one resolves cleanly.
+ */
+void grant_record_reward(const middleware::web_service::Message& message,
+                         std::uint16_t recordIndex,
+                         std::uint32_t recordHash,
+                         Outcome& outcome) noexcept {
+    state::RecordRewardPolicy reward{};
+    if (!state::account::find_record_reward(state::account_snapshot(), recordIndex, reward)) {
+        // No manual override for this record. The shipped table loads lazily, on this first need,
+        // and only ever attempts its one file read once per process: a deployment that ships none
+        // of it, or none at all, must not reopen a missing file on every later claim.
+        static std::atomic<bool> generatedTableAttempted{false};
+        if (!generatedTableAttempted.exchange(true, std::memory_order_relaxed)) {
+            (void)state::build_data::records::rewards::load_and_publish();
+        }
+        std::uint16_t itemIndex = 0;
+        std::int32_t quantity = 0;
+        if (!state::build_data::find_generated_record_reward(recordHash, itemIndex, quantity)) {
+            return;
+        }
+        reward.recordIndex = recordIndex;
+        reward.itemIndex = itemIndex;
+        reward.quantity = quantity;
+    }
+
+    state::build_data::items::Definition definition{};
+    if (!state::build_data::find_item_definition_index(reward.itemIndex, definition)) {
+        report_record_reward(
+            message, "fail", "item_definition", recordIndex, reward.itemIndex, reward.quantity);
+        return;
+    }
+
+    state::build_data::items::details::Definition detail{};
+    state::build_data::inventory::buckets::Descriptor bucket{};
+    if (!state::build_data::find_configured_item_detail(reward.itemIndex, detail)
+        || detail.definitionIndex != reward.itemIndex
+        || detail.definitionHash != definition.definitionHash
+        || detail.bucketId != definition.bucketId
+        || !state::build_data::find_inventory_bucket_descriptor(detail.bucketId, bucket)) {
+        report_record_reward(message,
+                             "fail",
+                             "item_detail_or_bucket",
+                             recordIndex,
+                             reward.itemIndex,
+                             reward.quantity);
+        return;
+    }
+
+    namespace bucket_domain = state::build_data::inventory::buckets;
+    namespace detail_domain = state::build_data::items::details;
+    state::PendingRecordRewardGrant grant{};
+    if (bucket.arraySelector == bucket_domain::ArraySelector::profile) {
+        if (detail.instancedDefinitionState != detail_domain::InstancedDefinitionState::stackable) {
+            report_record_reward(message,
+                                 "fail",
+                                 "profile_item_instanced",
+                                 recordIndex,
+                                 reward.itemIndex,
+                                 reward.quantity);
+            return;
+        }
+        auto& mutation = grant.grant.emplace<state::PendingProfileItemAcquisition>();
+        if (!state::prepare_profile_item_acquisition_for_item(
+                reward.itemIndex, reward.quantity, mutation)) {
+            report_record_reward(
+                message, "fail", "profile_state", recordIndex, reward.itemIndex, reward.quantity);
+            return;
+        }
+    } else if (bucket.arraySelector == bucket_domain::ArraySelector::character) {
+        auto& mutation = grant.grant.emplace<state::PendingItemAcquisition>();
+        if (!state::prepare_item_acquisition_for_item(reward.itemIndex, mutation)) {
+            report_record_reward(
+                message, "fail", "state", recordIndex, reward.itemIndex, reward.quantity);
+            return;
+        }
+    } else {
+        report_record_reward(message,
+                             "fail",
+                             "unsupported_inventory_array",
+                             recordIndex,
+                             reward.itemIndex,
+                             reward.quantity);
+        return;
+    }
+    outcome.mutation = grant;
+    report_record_reward(message, "ok", "ready", recordIndex, reward.itemIndex, reward.quantity);
+}
+
 /** Decodes one opcode-1801 Triumphs claim and reports the record it names. */
 void claim_record(const middleware::web_service::Message& message, Outcome& outcome) noexcept {
-    // Deliberately no mutation. The shared reply path answers an action that prepares nothing with
-    // the refusal status, so preparing a placeholder here would turn a silently-accepted claim into
-    // an explicitly rejected one. Attach the transition here once claimed state is identified.
+    // No mutation is prepared below the claim itself: the shared reply path answers an action
+    // that prepares nothing with the refusal status, so preparing a placeholder here would turn a
+    // silently-accepted claim into an explicitly rejected one. A configured reward is the one
+    // exception, attached only once the claim itself has already landed.
     namespace records = state::build_data::records;
     middleware::web_service::messages::opcode1801::Request request{};
     if (!middleware::web_service::messages::opcode1801::parse_request(message, request)) {
@@ -896,6 +1031,9 @@ void claim_record(const middleware::web_service::Message& message, Outcome& outc
                         request.recordIndex,
                         definition.completionFlagIndex,
                         definition.scoreValue);
+    // Attached only after the claim above has already landed: a reward that fails to resolve or
+    // fit must never turn this already-successful claim into a failure.
+    grant_record_reward(message, request.recordIndex, definition.definitionHash, outcome);
 }
 
 } // namespace sunrise::server::web_service

+ 2 - 1
Sunrise/src/server/web_service/web_service_runtime.h

@@ -27,7 +27,8 @@ struct Outcome {
                                   state::PendingProfileItemAcquisition,
                                   state::PendingItemDismantle,
                                   state::PendingSocketPlug,
-                                  state::PendingItemState>;
+                                  state::PendingItemState,
+                                  state::PendingRecordRewardGrant>;
     Mutation mutation{};
 };
 

+ 57 - 4
Sunrise/src/state/account/account_state.cpp

@@ -58,6 +58,36 @@ constexpr std::uint8_t kDismantleClassMaskBits =
     return true;
 }
 
+/** @return True when one unused record-reward policy row is canonical zero. */
+[[nodiscard]] bool empty_record_reward(const RecordRewardPolicy& reward) noexcept {
+    return reward.recordIndex == 0 && reward.itemIndex == 0 && reward.quantity == 0;
+}
+
+/** Checks filled record-reward rows, uniqueness by record, and the zero tail. */
+[[nodiscard]] bool valid_record_rewards(const AccountState& state) noexcept {
+    if (state.recordRewardCount > state.recordRewards.size()) {
+        return false;
+    }
+    for (std::size_t index = 0; index < state.recordRewards.size(); ++index) {
+        const RecordRewardPolicy& reward = state.recordRewards[index];
+        if (index >= state.recordRewardCount) {
+            if (!empty_record_reward(reward)) {
+                return false;
+            }
+            continue;
+        }
+        if (reward.quantity <= 0) {
+            return false;
+        }
+        for (std::size_t prior = 0; prior < index; ++prior) {
+            if (same_record_reward_key(state.recordRewards[prior], reward)) {
+                return false;
+            }
+        }
+    }
+    return true;
+}
+
 /** Adds one nonzero globally unique key to a bounded identity set. */
 [[nodiscard]] bool append_identity(std::array<std::uint64_t, kIdentityCapacity>& identities,
                                    std::size_t& count,
@@ -81,17 +111,20 @@ constexpr std::uint8_t kDismantleClassMaskBits =
     }
     if (state.primarySoid == 0) {
         if (state.profileItemCount != 0 || state.characterCount != 0
-            || state.dismantleRewardCount != 0 || state.settings.configured
-            || state.settings.keyBindings.configured) {
+            || state.dismantleRewardCount != 0 || state.recordRewardCount != 0
+            || state.settings.configured || state.settings.keyBindings.configured) {
             return false;
         }
         return std::all_of(
                    state.profileItems.cbegin(), state.profileItems.cend(), empty_profile_item)
                && std::all_of(state.dismantleRewards.cbegin(),
                               state.dismantleRewards.cend(),
-                              empty_dismantle_reward);
+                              empty_dismantle_reward)
+               && std::all_of(
+                   state.recordRewards.cbegin(), state.recordRewards.cend(), empty_record_reward);
     }
-    if (!settings::valid(state.settings) || !valid_dismantle_rewards(state)) {
+    if (!settings::valid(state.settings) || !valid_dismantle_rewards(state)
+        || !valid_record_rewards(state)) {
         return false;
     }
 
@@ -190,4 +223,24 @@ std::uint64_t banner_character_soid(const AccountState& state) noexcept {
     return state.characterCount == 0 ? 0 : state.characters[0].soid;
 }
 
+/**
+ * Finds the configured reward for one claimed record, if any.
+ * @param state Account snapshot carrying the authored reward table.
+ * @param recordIndex Native record row the claim named.
+ * @param reward Receives the matching row only on success.
+ * @return True when the table carries a row for this record.
+ */
+bool find_record_reward(const AccountState& state,
+                        std::uint16_t recordIndex,
+                        RecordRewardPolicy& reward) noexcept {
+    const std::size_t count = (std::min)(state.recordRewardCount, state.recordRewards.size());
+    for (std::size_t index = 0; index < count; ++index) {
+        if (state.recordRewards[index].recordIndex == recordIndex) {
+            reward = state.recordRewards[index];
+            return true;
+        }
+    }
+    return false;
+}
+
 } // namespace sunrise::state::account

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

@@ -13,6 +13,8 @@ namespace sunrise::state {
 inline constexpr std::size_t kCharacterCapacity = 3;
 /** A server-authored dismantle policy: a few rows per rarity and gear class. */
 inline constexpr std::size_t kDismantleRewardPolicyCapacity = 32;
+/** A server-authored record-reward policy: one row per rewarded Triumph. */
+inline constexpr std::size_t kRecordRewardPolicyCapacity = 256;
 
 /** Gear classes a dismantle payout row can be limited to. */
 enum class DismantleGearClass : std::uint8_t {
@@ -49,6 +51,28 @@ same_dismantle_policy_key(const DismantleRewardPolicy& left,
            && left.classMask == right.classMask && left.masterwork == right.masterwork;
 }
 
+/**
+ * One item granted alongside a record's completion flag when its claim lands.
+ * The map source is deliberately settings-authored for now, kept pluggable behind
+ * `account::find_record_reward` so a later task can source it from extracted build data instead.
+ */
+struct RecordRewardPolicy {
+    /** Native record row an opcode-1801 claim names, matching records::Definition::definitionIndex.
+     */
+    std::uint16_t recordIndex{};
+    /** Native item-definition row the claim grants, resolved the same way Collections resolves one.
+     */
+    std::uint16_t itemIndex{};
+    /** Units granted. Ignored beyond 1 for a non-stackable (character-bucket) item. */
+    std::int32_t quantity{};
+};
+
+/** @return True when both rows reward the same record. */
+[[nodiscard]] constexpr bool
+same_record_reward_key(const RecordRewardPolicy& left, const RecordRewardPolicy& right) noexcept {
+    return left.recordIndex == right.recordIndex;
+}
+
 /** Stable character race values authored independently of package definition mappings. */
 enum class CharacterRace : std::uint8_t {
     /** Wire value 0 is a Human character. */
@@ -164,6 +188,9 @@ struct AccountState {
     /** Economy policy comes from configuration, never from item-specific runtime constants. */
     std::array<DismantleRewardPolicy, kDismantleRewardPolicyCapacity> dismantleRewards{};
     std::size_t dismantleRewardCount{};
+    /** Server-authored record-claim reward table, empty by default. */
+    std::array<RecordRewardPolicy, kRecordRewardPolicyCapacity> recordRewards{};
+    std::size_t recordRewardCount{};
     /** Account-wide currencies and materials, placed by bucket rather than by authored slot. */
     std::array<account::inventory::ProfileItem, account::inventory::kProfileItemCapacity>
         profileItems{};
@@ -191,6 +218,17 @@ namespace account {
  */
 [[nodiscard]] std::uint64_t banner_character_soid(const AccountState& state) noexcept;
 
+/**
+ * Finds the configured reward for one claimed record, if any.
+ * @param state Account snapshot carrying the authored reward table.
+ * @param recordIndex Native record row the claim named.
+ * @param reward Receives the matching row only on success.
+ * @return True when the table carries a row for this record.
+ */
+[[nodiscard]] bool find_record_reward(const AccountState& state,
+                                      std::uint16_t recordIndex,
+                                      RecordRewardPolicy& reward) noexcept;
+
 } // namespace account
 
 } // namespace sunrise::state

+ 8 - 0
Sunrise/src/state/build_data/records/definition.h

@@ -36,6 +36,14 @@ inline constexpr std::uint16_t kUnavailableValueIndex = 0xFFFFU;
 struct Definition {
     /** Native record row, which is what an opcode-1801 claim names. */
     std::uint16_t definitionIndex{};
+    /**
+     * Authored DestinyRecordDefinition hash, joining this row to Bungie's manifest.
+     *
+     * Read from row offset +0x28, verified by joining 48 rows to the manifest by name; offset +0
+     * holds degenerate values and is not the hash. Used to look up this record's manifest-sourced
+     * reward items, kept apart from the settings-authored override table.
+     */
+    std::uint32_t definitionHash{};
     /** Account flag bank mapping row, or kUnavailableFlagIndex when the slot is unaddressable. */
     std::uint16_t completionFlagIndex{kUnavailableFlagIndex};
     /**

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

@@ -0,0 +1,35 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+
+namespace sunrise::state::build_data::records::rewards {
+
+/**
+ * Most reward rows the shipped table can hold. A handful of Triumphs carry a reward at all, so
+ * this is ample headroom rather than a measured ceiling.
+ */
+inline constexpr std::size_t kRewardCapacity = 4096;
+
+/**
+ * One manifest-sourced record reward: claiming the named Triumph grants the named item.
+ *
+ * A record with several authored rewards contributes several rows sharing the same recordHash.
+ * This is generated data joined out of Bungie's manifest by an external tool, not extracted from
+ * the installed client, so the rows carry authored hashes rather than this build's native indices;
+ * `itemHash` is resolved against this build's item table at lookup time, and a hash this build
+ * never installed is skipped rather than failing the row.
+ */
+struct RewardRow {
+    /** records::Definition::definitionHash of the Triumph that grants this item. */
+    std::uint32_t recordHash{};
+    /** Authored DestinyInventoryItemDefinition hash of the granted item. */
+    std::uint32_t itemHash{};
+    /** Units granted. */
+    std::uint32_t quantity{};
+};
+
+/** Called once per reward row naming one record, in table order; returning false ends the walk. */
+using RowVisitor = bool (*)(void* context, const RewardRow& row) noexcept;
+
+} // namespace sunrise::state::build_data::records::rewards

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

@@ -0,0 +1,47 @@
+#include "../../runtime.h"
+#include "reward_catalog.h"
+
+namespace sunrise::state::build_data {
+namespace {
+
+/** Carries one resolution attempt through the raw RowVisitor callback. */
+struct ResolveContext {
+    std::uint16_t itemDefinitionIndex{};
+    std::int32_t quantity{};
+    bool resolved{false};
+};
+
+/** Accepts the first visited row whose item hash resolves in this build's item table. */
+bool resolve_first_installed(void* context, const records::rewards::RewardRow& row) noexcept {
+    auto& resolution = *static_cast<ResolveContext*>(context);
+    items::Definition item{};
+    if (!find_item_definition_hash(row.itemHash, item)) {
+        // Vaulted or later-era item this build never installed. Keep looking rather than failing
+        // the whole record: the aggregate count of these is computed once when the table loads.
+        return true;
+    }
+    resolution.itemDefinitionIndex = item.definitionIndex;
+    resolution.quantity = static_cast<std::int32_t>(row.quantity);
+    resolution.resolved = true;
+    return false;
+}
+
+} // namespace
+
+/** Finds one manifest-sourced reward for a claimed record, from the shipped generated table. */
+bool find_generated_record_reward(std::uint32_t recordHash,
+                                  std::uint16_t& itemDefinitionIndex,
+                                  std::int32_t& quantity) noexcept {
+    itemDefinitionIndex = 0;
+    quantity = 0;
+    ResolveContext resolution;
+    records::rewards::visit_for_record(recordHash, resolve_first_installed, &resolution);
+    if (!resolution.resolved) {
+        return false;
+    }
+    itemDefinitionIndex = resolution.itemDefinitionIndex;
+    quantity = resolution.quantity;
+    return true;
+}
+
+} // namespace sunrise::state::build_data

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

@@ -0,0 +1,52 @@
+#include "reward_catalog.h"
+
+#include "../../table.h"
+
+namespace sunrise::state::build_data::records::rewards {
+namespace {
+
+Lock g_lock;
+Table<RewardRow, kRewardCapacity> g_rows;
+
+} // namespace
+
+/** Clears every generated record-reward row under the catalog lock. */
+void clear() noexcept {
+    const Lock::Exclusive guard(g_lock);
+    g_rows.clear();
+}
+
+/** Checks that the rows fit fixed storage. Zero rows is valid. */
+bool valid(std::span<const RewardRow> rows) noexcept {
+    return rows.size() <= kRewardCapacity;
+}
+
+/** Replaces the generated record-reward table in one step. */
+bool replace(std::span<const RewardRow> rows) noexcept {
+    if (!valid(rows)) {
+        return false;
+    }
+    const Lock::Exclusive guard(g_lock);
+    return g_rows.replace(rows);
+}
+
+/** Visits every reward row naming one record, holding the catalog lock for the whole walk. */
+void visit_for_record(std::uint32_t recordHash, RowVisitor visitor, void* context) noexcept {
+    const Lock::Shared guard(g_lock);
+    for (const RewardRow& row : g_rows.rows()) {
+        if (row.recordHash != recordHash) {
+            continue;
+        }
+        if (!visitor(context, row)) {
+            return;
+        }
+    }
+}
+
+/** @return Number of generated reward rows, read under the lock. */
+std::size_t count() noexcept {
+    const Lock::Shared guard(g_lock);
+    return g_rows.count();
+}
+
+} // namespace sunrise::state::build_data::records::rewards

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

@@ -0,0 +1,43 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+#include "definition.h"
+
+namespace sunrise::state::build_data::records::rewards {
+
+/** Clears every generated record-reward row. */
+void clear() noexcept;
+
+/**
+ * Checks that the rows fit fixed storage. Zero rows is valid: it is what an absent or empty
+ * shipped table loads as, and is a normal deployment with no generated rewards, not a malformed
+ * one.
+ * @param rows Candidate rows.
+ * @return True when the rows fit storage.
+ */
+[[nodiscard]] bool valid(std::span<const RewardRow> rows) noexcept;
+
+/**
+ * Replaces the generated record-reward table in one step.
+ * @param rows Complete replacement rows, in any order.
+ * @return True when the rows pass the checks and fit fixed State storage.
+ */
+[[nodiscard]] bool replace(std::span<const RewardRow> rows) noexcept;
+
+/**
+ * Visits every reward row naming one record, holding the catalog lock for the whole walk.
+ * Resolving an item hash against this build's item table is the caller's concern, not the
+ * catalog's: this domain only owns the authored rows extracted from the manifest join.
+ * @param recordHash records::Definition::definitionHash to match.
+ * @param visitor Called once per matching row, in table order. Returning false ends the walk early.
+ * @param context Opaque pointer handed back to the visitor.
+ */
+void visit_for_record(std::uint32_t recordHash, RowVisitor visitor, void* context) noexcept;
+
+/** @return Number of generated reward rows, read under the lock. */
+[[nodiscard]] std::size_t count() noexcept;
+
+} // namespace sunrise::state::build_data::records::rewards

+ 154 - 0
Sunrise/src/state/build_data/records/rewards/reward_persistence.cpp

@@ -0,0 +1,154 @@
+#include "reward_persistence.h"
+
+#include <algorithm>
+#include <array>
+#include <cstdio>
+#include <cstring>
+#include <string_view>
+#include <vector>
+
+#include <windows.h>
+
+#include "../../../../core/logging/log.h"
+#include "../../../../core/filesystem/path.h"
+#include "../../runtime.h"
+#include "reward_catalog.h"
+
+namespace sunrise::state::build_data::records::rewards {
+namespace {
+
+/**
+ * Shipped beside settings.json in the artifact directory, not under `\cache\`: the record and node
+ * tables there are extraction output the running client rebuilds on a cold cache, while this file
+ * is generated ahead of time by an external tool and is never written by this process.
+ */
+constexpr std::wstring_view kRewardFileSuffix = L"\\record_rewards.bin";
+/** Identifies the file on sight, so an unrelated file of the right length cannot be read as one. */
+constexpr std::array<char, 8> kMagic{'S', 'N', 'R', 'S', 'R', 'W', 'D', '1'};
+
+core::path::Buffer g_path{};
+bool g_pathReady{};
+
+/** Reports the one line this domain logs per load attempt, success or not. */
+void report(const char* result,
+           std::size_t rows,
+           std::size_t records,
+           std::size_t unresolvedItems) noexcept {
+    std::array<char, 160> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=recrewards stage=load result=%s rows=%zu records=%zu "
+                                      "unresolved_items=%zu",
+                                      result,
+                                      rows,
+                                      records,
+                                      unresolvedItems);
+    if (written > 0) {
+        core::log::write(core::log::Channel::state,
+                         std::string_view{result} == "ok" ? core::log::Level::info
+                                                          : core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Header carried ahead of the rows, in the same style as the record and node caches: the row
+ * width is written out and checked on the way back in, so a shape change on either side rejects a
+ * stale layout instead of misreading it.
+ */
+struct Header {
+    std::array<char, 8> magic{};
+    std::uint32_t rows{};
+    std::uint32_t rowWidth{};
+};
+
+/** @return Distinct record hashes named across every row. */
+[[nodiscard]] std::size_t distinct_record_count(std::vector<std::uint32_t> hashes) noexcept {
+    std::sort(hashes.begin(), hashes.end());
+    return static_cast<std::size_t>(std::unique(hashes.begin(), hashes.end()) - hashes.begin());
+}
+
+} // namespace
+
+/** Derives the reward file path. It lives beside settings.json, not the extraction cache. */
+bool initialize(void* module) noexcept {
+    g_pathReady = false;
+    if (!core::path::artifact_directory(module, g_path)
+        || !core::path::append(g_path, kRewardFileSuffix)) {
+        report("path_fail", 0, 0, 0);
+        return false;
+    }
+    g_pathReady = true;
+    return true;
+}
+
+/**
+ * Loads the shipped reward table and publishes it. A missing or empty file is a clean no-op: the
+ * feature stays inert and the settings-authored table remains the operator's only reward source.
+ */
+bool load_and_publish() noexcept {
+    if (!g_pathReady) {
+        return false;
+    }
+    const HANDLE file = CreateFileW(
+        g_path.chars.data(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, 0, nullptr);
+    if (file == INVALID_HANDLE_VALUE) {
+        // Not shipped for this deployment. Reported as an ordinary zero-row load, not a fault.
+        report("ok", 0, 0, 0);
+        return true;
+    }
+
+    Header header{};
+    DWORD read = 0;
+    const BOOL headerCall = ReadFile(file, &header, sizeof header, &read, nullptr);
+    if (headerCall != FALSE && read == 0) {
+        // A zero-byte file is an explicit "no generated rewards" deployment, not corruption.
+        CloseHandle(file);
+        report("ok", 0, 0, 0);
+        return true;
+    }
+    const bool headerRead = headerCall != FALSE && read == sizeof header;
+    if (!headerRead || std::memcmp(header.magic.data(), kMagic.data(), kMagic.size()) != 0
+        || header.rowWidth != sizeof(RewardRow) || header.rows > kRewardCapacity) {
+        CloseHandle(file);
+        report(headerRead ? "rejected" : "header_fail", header.rows, 0, 0);
+        return false;
+    }
+    if (header.rows == 0) {
+        CloseHandle(file);
+        report("ok", 0, 0, 0);
+        return true;
+    }
+
+    std::vector<RewardRow> rows(header.rows);
+    const auto expected = static_cast<DWORD>(rows.size() * sizeof(RewardRow));
+    const bool rowsRead =
+        ReadFile(file, rows.data(), expected, &read, nullptr) != FALSE && read == expected;
+    CloseHandle(file);
+    if (!rowsRead) {
+        report("read_fail", rows.size(), 0, 0);
+        return false;
+    }
+    if (!valid(std::span<const RewardRow>{rows}) || !replace(std::span<const RewardRow>{rows})) {
+        report("publish_fail", rows.size(), 0, 0);
+        return false;
+    }
+
+    // The three numbers this domain reports are computed once, here, rather than on every claim:
+    // an item hash this build never installed (vaulted, or authored in a later era) is expected,
+    // and a claim skipping it silently is cheaper than re-deriving the same count each time.
+    std::vector<std::uint32_t> recordHashes;
+    recordHashes.reserve(rows.size());
+    std::size_t unresolved = 0;
+    for (const RewardRow& row : rows) {
+        recordHashes.push_back(row.recordHash);
+        state::build_data::items::Definition item{};
+        if (!state::build_data::find_item_definition_hash(row.itemHash, item)) {
+            ++unresolved;
+        }
+    }
+    report("ok", rows.size(), distinct_record_count(std::move(recordHashes)), unresolved);
+    return true;
+}
+
+} // namespace sunrise::state::build_data::records::rewards

+ 31 - 0
Sunrise/src/state/build_data/records/rewards/reward_persistence.h

@@ -0,0 +1,31 @@
+#pragma once
+
+namespace sunrise::state::build_data::records::rewards {
+
+/**
+ * Keeps the manifest-sourced record-reward table in its own file, beside settings.json.
+ *
+ * This is shipped, generated data -- an external tool joins Bungie's manifest by record hash and
+ * writes the file -- not extraction-cache output, so it does not live under the cache directory
+ * the record and node tables use, and nothing here ever writes it back out. The settings-authored
+ * `record_rewards` table remains the operator's override and is tried first; see
+ * `state::account::find_record_reward` and `find_generated_record_reward` for the precedence.
+ */
+
+/**
+ * Derives the reward file path next to the executable's other shipped data.
+ * @param module Loaded DLL, used to find the artifact directory.
+ * @return True when the path resolves. Loading is best effort and reported separately.
+ */
+[[nodiscard]] bool initialize(void* module) noexcept;
+
+/**
+ * Loads the shipped reward table and publishes it, if one is installed.
+ * A missing or empty file is a clean no-op: the feature simply stays inert. Logs one informational
+ * line reporting rows loaded, distinct records covered, and item hashes that failed to resolve
+ * against this build's item table.
+ * @return True when the file was absent, empty, or loaded and published without error.
+ */
+[[nodiscard]] bool load_and_publish() noexcept;
+
+} // namespace sunrise::state::build_data::records::rewards

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

@@ -133,6 +133,23 @@ publish_record_definitions(std::span<const records::Definition> definitions) noe
 [[nodiscard]] bool find_record_definition(std::uint16_t definitionIndex,
                                           records::Definition& definition) noexcept;
 
+/**
+ * Finds one manifest-sourced reward for a claimed record, from the shipped generated table.
+ *
+ * This is the fallback source behind `state::account::find_record_reward`: the settings-authored
+ * `record_rewards` table is the operator's override and is tried first, and this is consulted only
+ * when it names nothing for the claimed record. See `state::build_data::records::rewards` for the
+ * shipped table itself. A row naming an item this build never installed (vaulted, or authored in a
+ * later era than this build) is skipped rather than failing the lookup.
+ * @param recordHash records::Definition::definitionHash of the claimed Triumph.
+ * @param itemDefinitionIndex Receives the resolved dense item index only on success.
+ * @param quantity Receives the row's authored quantity only on success.
+ * @return True when the shipped table holds a resolvable reward for this record.
+ */
+[[nodiscard]] bool find_generated_record_reward(std::uint32_t recordHash,
+                                                std::uint16_t& itemDefinitionIndex,
+                                                std::int32_t& quantity) noexcept;
+
 [[nodiscard]] bool collectible_definitions_ready() noexcept;
 
 /**

+ 46 - 0
Sunrise/src/state/runtime/runtime.h

@@ -4,6 +4,7 @@
 #include <cstddef>
 #include <cstdint>
 #include <span>
+#include <variant>
 
 #include "state.h"
 
@@ -108,6 +109,8 @@ struct PendingItemAcquisition {
     std::uint8_t equipmentSlot{};
     std::uint8_t materialRequirementCount{};
     bool profileChanged{};
+    /** True when the item was granted directly, with no Collections row and no material cost. */
+    bool freeGrant{};
     bool prepared{};
 };
 
@@ -137,9 +140,21 @@ struct PendingProfileItemAcquisition {
     /** True only for installed profile mod/shader rows materialized as Family-4 residents. */
     bool actionSource{};
     bool appended{};
+    /** True when the stack was granted directly, with no Collections row and no material cost. */
+    bool freeGrant{};
     bool prepared{};
 };
 
+/**
+ * Record-claim reward grant prepared for staging.
+ * A record reward names an item directly rather than a Collections row, so the installed item's
+ * own inventory bucket decides which acquisition kind actually lands it; exactly one alternative
+ * of this pair is ever prepared.
+ */
+struct PendingRecordRewardGrant {
+    std::variant<PendingItemAcquisition, PendingProfileItemAcquisition> grant{};
+};
+
 /** One profile material actually credited by a prepared dismantle. */
 struct DismantleReward {
     std::uint32_t definitionHash{};
@@ -330,6 +345,20 @@ void shutdown() noexcept;
                                             std::uint32_t definitionHash,
                                             PendingItemAcquisition& mutation) noexcept;
 
+/**
+ * Prepares one installed equippable definition as a new selected-character inventory instance,
+ * granted directly rather than pulled through a Collections row.
+ *
+ * Used by record-claim reward grants: no material requirement set is charged, and the mutation
+ * carries no collectible to re-verify at commit.
+ *
+ * @param itemDefinitionIndex Native item-definition row the reward names.
+ * @param mutation Gets a checked after-image without changing account State.
+ * @return True when the item and every existing loadout row resolve with one free native row.
+ */
+[[nodiscard]] bool prepare_item_acquisition_for_item(std::uint16_t itemDefinitionIndex,
+                                                     PendingItemAcquisition& mutation) noexcept;
+
 /** Builds the exact full-account after-image while a prepared item pull remains current. */
 [[nodiscard]] bool preview_item_acquisition(const PendingItemAcquisition& mutation,
                                             AccountState& after) noexcept;
@@ -359,6 +388,23 @@ prepare_profile_item_acquisition(std::uint16_t collectibleIndex,
                                  std::uint32_t definitionHash,
                                  PendingProfileItemAcquisition& mutation) noexcept;
 
+/**
+ * Prepares one installed profile-owned stackable definition for a direct grant, with no Collections
+ * row and no material cost.
+ *
+ * Used by record-claim reward grants. An existing non-full stack is incremented by `quantity`;
+ * otherwise a new dense State entry is appended, still capped at the definition's max stack size.
+ *
+ * @param itemDefinitionIndex Native item-definition row the reward names.
+ * @param quantity Units to grant. Must be positive and must fit the definition's max stack size.
+ * @param mutation Gets the checked profile before/after images without changing account State.
+ * @return True when the definition belongs to the main profile array and the units fit.
+ */
+[[nodiscard]] bool
+prepare_profile_item_acquisition_for_item(std::uint16_t itemDefinitionIndex,
+                                          std::int32_t quantity,
+                                          PendingProfileItemAcquisition& mutation) noexcept;
+
 /**
  * Materializes a prepared profile acquisition over the current account only while its complete
  * profile-inventory view is unchanged. This is the account object encoded before commit.

+ 268 - 87
Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp

@@ -20,33 +20,33 @@ namespace item_details = build_data::items::details;
 namespace inventory_buckets = build_data::inventory::buckets;
 namespace family4_loadout = middleware::datagen::family4::loadout;
 
-/** Prepares one native-row-checked selected-character inventory insertion. */
-bool prepare_item_acquisition(std::uint16_t collectibleIndex,
-                              std::uint32_t definitionHash,
-                              PendingItemAcquisition& mutation) noexcept {
-    mutation = {};
-    const AccountState account = account_snapshot();
-    build_data::collectibles::Definition collectible{};
-    build_data::items::Definition grantedDefinition{};
-    if (definitionHash == authored_inventory::kNoDefinitionHash || !account::valid(account)
-        || !valid_profile_inventory(account)
-        || !build_data::find_collectible_definition(collectibleIndex, collectible)
-        || collectible.itemDefinitionIndex
-               == build_data::collectibles::kUnavailableItemDefinitionIndex
-        || !build_data::find_item_definition_index(collectible.itemDefinitionIndex,
-                                                   grantedDefinition)
-        || grantedDefinition.definitionHash != definitionHash) {
-        report_acquisition("prepare", "fail", "input", definitionHash, 0, 0, 0, 0, 0, 0);
-        return false;
-    }
-
-    AccountState chargedAccount{};
-    bool profileChanged = false;
-    if (!apply_collection_materials(account, collectible, chargedAccount, profileChanged)) {
-        report_acquisition("prepare", "fail", "materials", definitionHash, 0, 0, 0, 0, 0, 0);
-        return false;
-    }
+namespace {
 
+/**
+ * Shared tail of both `prepare_item_acquisition` and `prepare_item_acquisition_for_item`.
+ * Everything from the acquiring character's selection onward is identical whether the granted
+ * item and its (possible) material charge came from a Collections row or a direct reward; only
+ * how `definitionHash` and the charged account were produced differs between the two callers.
+ * @param account Uncharged snapshot the caller resolved the grant against.
+ * @param chargedAccount Account after any material requirement set was applied; equal to
+ * `account` for a free grant.
+ * @param profileChanged Whether `chargedAccount` differs from `account`.
+ * @param definitionHash Installed item definition hash being granted.
+ * @param collectibleIndex Collections row the grant re-verifies at commit, or 0 for a free grant.
+ * @param materialRequirementSetHash Charged requirement set, or 0 for a free grant.
+ * @param materialRequirementCount Charged requirement row count, or 0 for a free grant.
+ * @param freeGrant True when commit must skip the collectible re-verification entirely.
+ * @param mutation Gets a checked after-image without changing account State.
+ */
+[[nodiscard]] bool finalize_item_acquisition(const AccountState& account,
+                                             const AccountState& chargedAccount,
+                                             bool profileChanged,
+                                             std::uint32_t definitionHash,
+                                             std::uint16_t collectibleIndex,
+                                             std::uint32_t materialRequirementSetHash,
+                                             std::uint8_t materialRequirementCount,
+                                             bool freeGrant,
+                                             PendingItemAcquisition& mutation) noexcept {
     std::size_t characterIndex = account.characterCount;
     for (std::size_t index = 0; index < account.characterCount; ++index) {
         if (account.characters[index].selected) {
@@ -132,7 +132,7 @@ bool prepare_item_acquisition(std::uint16_t collectibleIndex,
     mutation.characterSoid = before.soid;
     mutation.acquiredInstanceSoid = instanceSoid;
     mutation.acquiredDefinitionHash = definitionHash;
-    mutation.materialRequirementSetHash = collectible.materialRequirementSetHash;
+    mutation.materialRequirementSetHash = materialRequirementSetHash;
     mutation.expectedNextInventorySerial = before.nextInventorySerial;
     mutation.characterIndex = characterIndex;
     mutation.expectedInventoryCount = before.inventory.count;
@@ -142,8 +142,9 @@ bool prepare_item_acquisition(std::uint16_t collectibleIndex,
     mutation.collectibleIndex = collectibleIndex;
     mutation.inventoryRow = inventoryRow;
     mutation.equipmentSlot = equipmentSlot;
-    mutation.materialRequirementCount = collectible.materialRequirementCount;
+    mutation.materialRequirementCount = materialRequirementCount;
     mutation.profileChanged = profileChanged;
+    mutation.freeGrant = freeGrant;
     mutation.prepared = true;
     report_acquisition("prepare",
                        "ok",
@@ -158,6 +159,63 @@ bool prepare_item_acquisition(std::uint16_t collectibleIndex,
     return true;
 }
 
+} // namespace
+
+/** Prepares one native-row-checked selected-character inventory insertion. */
+bool prepare_item_acquisition(std::uint16_t collectibleIndex,
+                              std::uint32_t definitionHash,
+                              PendingItemAcquisition& mutation) noexcept {
+    mutation = {};
+    const AccountState account = account_snapshot();
+    build_data::collectibles::Definition collectible{};
+    build_data::items::Definition grantedDefinition{};
+    if (definitionHash == authored_inventory::kNoDefinitionHash || !account::valid(account)
+        || !valid_profile_inventory(account)
+        || !build_data::find_collectible_definition(collectibleIndex, collectible)
+        || collectible.itemDefinitionIndex
+               == build_data::collectibles::kUnavailableItemDefinitionIndex
+        || !build_data::find_item_definition_index(collectible.itemDefinitionIndex,
+                                                   grantedDefinition)
+        || grantedDefinition.definitionHash != definitionHash) {
+        report_acquisition("prepare", "fail", "input", definitionHash, 0, 0, 0, 0, 0, 0);
+        return false;
+    }
+
+    AccountState chargedAccount{};
+    bool profileChanged = false;
+    if (!apply_collection_materials(account, collectible, chargedAccount, profileChanged)) {
+        report_acquisition("prepare", "fail", "materials", definitionHash, 0, 0, 0, 0, 0, 0);
+        return false;
+    }
+
+    return finalize_item_acquisition(account,
+                                     chargedAccount,
+                                     profileChanged,
+                                     definitionHash,
+                                     collectibleIndex,
+                                     collectible.materialRequirementSetHash,
+                                     collectible.materialRequirementCount,
+                                     false,
+                                     mutation);
+}
+
+/** Prepares one direct selected-character inventory grant, with no Collections row or charge. */
+bool prepare_item_acquisition_for_item(std::uint16_t itemDefinitionIndex,
+                                       PendingItemAcquisition& mutation) noexcept {
+    mutation = {};
+    const AccountState account = account_snapshot();
+    build_data::items::Definition grantedDefinition{};
+    if (!account::valid(account) || !valid_profile_inventory(account)
+        || !build_data::find_item_definition_index(itemDefinitionIndex, grantedDefinition)
+        || grantedDefinition.definitionHash == authored_inventory::kNoDefinitionHash) {
+        report_acquisition("prepare", "fail", "input", 0, 0, 0, 0, 0, 0, 0);
+        return false;
+    }
+
+    return finalize_item_acquisition(
+        account, account, false, grantedDefinition.definitionHash, 0, 0, 0, true, mutation);
+}
+
 /** Produces the full account after-image while a prepared character pull remains current. */
 bool preview_item_acquisition(const PendingItemAcquisition& mutation,
                               AccountState& after) noexcept {
@@ -223,13 +281,23 @@ bool commit_item_acquisition(PendingItemAcquisition& mutation) noexcept {
         return fail("mutation");
     }
 
-    build_data::collectibles::Definition collectible{};
-    if (!build_data::find_collectible_definition(prepared.collectibleIndex, collectible)
-        || collectible.itemDefinitionIndex
-               == build_data::collectibles::kUnavailableItemDefinitionIndex
-        || collectible.materialRequirementSetHash != prepared.materialRequirementSetHash
-        || collectible.materialRequirementCount != prepared.materialRequirementCount) {
-        return fail("collectible");
+    if (prepared.freeGrant) {
+        // A record-reward grant charges nothing and names no Collections row, so there is no
+        // collectible to re-verify; only that the granted definition still resolves at all.
+        build_data::items::Definition grantedDefinition{};
+        if (!build_data::find_item_definition_hash(prepared.acquiredDefinitionHash,
+                                                    grantedDefinition)) {
+            return fail("item_definition");
+        }
+    } else {
+        build_data::collectibles::Definition collectible{};
+        if (!build_data::find_collectible_definition(prepared.collectibleIndex, collectible)
+            || collectible.itemDefinitionIndex
+                   == build_data::collectibles::kUnavailableItemDefinitionIndex
+            || collectible.materialRequirementSetHash != prepared.materialRequirementSetHash
+            || collectible.materialRequirementCount != prepared.materialRequirementCount) {
+            return fail("collectible");
+        }
     }
 
     report_acquisition("commit_begin",
@@ -288,51 +356,43 @@ bool commit_item_acquisition(PendingItemAcquisition& mutation) noexcept {
     return true;
 }
 
-/** Prepares one checked profile-stack increment or append for a Collections pull. */
-bool prepare_profile_item_acquisition(std::uint16_t collectibleIndex,
-                                      std::uint32_t definitionHash,
-                                      PendingProfileItemAcquisition& mutation) noexcept {
-    mutation = {};
-    const AccountState account = account_snapshot();
-    build_data::collectibles::Definition collectible{};
-    build_data::items::Definition item{};
-    item_details::Definition detail{};
-    inventory_buckets::Descriptor bucket{};
-    if (definitionHash == authored_inventory::kNoDefinitionHash || !account::valid(account)
-        || !valid_profile_inventory(account)
-        || !build_data::find_collectible_definition(collectibleIndex, collectible)
-        || collectible.itemDefinitionIndex
-               == build_data::collectibles::kUnavailableItemDefinitionIndex
-        || !build_data::find_item_definition_hash(definitionHash, item)
-        || item.definitionHash != definitionHash
-        || item.definitionIndex != collectible.itemDefinitionIndex
-        || !build_data::find_configured_item_detail(item.definitionIndex, detail)
-        || detail.definitionIndex != item.definitionIndex || detail.definitionHash != definitionHash
-        || detail.bucketId != item.bucketId
-        || detail.instancedDefinitionState != item_details::InstancedDefinitionState::stackable
-        || detail.maxStackSize <= 0
-        || !build_data::find_inventory_bucket_descriptor(detail.bucketId, bucket)
-        || bucket.arraySelector != inventory_buckets::ArraySelector::profile) {
-        report_profile_acquisition("prepare",
-                                   "fail",
-                                   "definition_or_profile",
-                                   definitionHash,
-                                   account.primarySoid,
-                                   0,
-                                   0,
-                                   0,
-                                   account.profileItemCount,
-                                   0,
-                                   0,
-                                   false);
-        return false;
-    }
-    AccountState chargedAccount{};
-    bool materialsChanged = false;
-    if (!apply_collection_materials(account, collectible, chargedAccount, materialsChanged)) {
+namespace {
+
+/**
+ * Shared tail of both `prepare_profile_item_acquisition` and
+ * `prepare_profile_item_acquisition_for_item`.
+ * Everything from the existing-stack scan onward is identical whether the granted stack and its
+ * (possible) material charge came from a Collections row or a direct reward; only how
+ * `definitionHash`, `detail`, `actionSource` and the charged account were produced differs.
+ * @param account Uncharged snapshot the caller resolved the grant against.
+ * @param chargedAccount Account after any material requirement set was applied; equal to
+ * `account` for a free grant.
+ * @param definitionHash Installed stackable item definition hash being granted.
+ * @param detail Installed item detail row naming the bucket and max stack size.
+ * @param actionSource Whether the granted definition is a profile action source.
+ * @param quantity Units to grant. Must be positive and must fit the stack.
+ * @param collectibleIndex Collections row the grant re-verifies at commit, or 0 for a free grant.
+ * @param materialRequirementSetHash Charged requirement set, or 0 for a free grant.
+ * @param materialRequirementCount Charged requirement row count, or 0 for a free grant.
+ * @param freeGrant True when commit must skip the collectible re-verification entirely.
+ * @param mutation Gets the checked profile before/after images without changing account State.
+ */
+[[nodiscard]] bool
+finalize_profile_item_acquisition(const AccountState& account,
+                                  const AccountState& chargedAccount,
+                                  std::uint32_t definitionHash,
+                                  const item_details::Definition& detail,
+                                  bool actionSource,
+                                  std::int32_t quantity,
+                                  std::uint16_t collectibleIndex,
+                                  std::uint32_t materialRequirementSetHash,
+                                  std::uint8_t materialRequirementCount,
+                                  bool freeGrant,
+                                  PendingProfileItemAcquisition& mutation) noexcept {
+    if (quantity <= 0) {
         report_profile_acquisition("prepare",
                                    "fail",
-                                   "materials",
+                                   "quantity",
                                    definitionHash,
                                    account.primarySoid,
                                    0,
@@ -340,14 +400,10 @@ bool prepare_profile_item_acquisition(std::uint16_t collectibleIndex,
                                    0,
                                    account.profileItemCount,
                                    0,
-                                   0,
+                                   quantity,
                                    false);
         return false;
     }
-    (void)materialsChanged;
-    const bool actionSource =
-        build_data::is_profile_action_source(item.definitionIndex, item.bucketId);
-
     std::size_t profileIndex = chargedAccount.profileItemCount;
     std::int32_t previousQuantity = 0;
     std::int32_t previousMutationSerial = 0;
@@ -440,10 +496,10 @@ bool prepare_profile_item_acquisition(std::uint16_t collectibleIndex,
     const std::int32_t acquiredMutationSerial = greatestMutationSerial + 1;
     if (appended) {
         after.profileItems[profileIndex] = {
-            acquiredInstanceSoid, definitionHash, 1, acquiredMutationSerial};
+            acquiredInstanceSoid, definitionHash, quantity, acquiredMutationSerial};
         ++after.profileItemCount;
     } else {
-        ++after.profileItems[profileIndex].quantity;
+        after.profileItems[profileIndex].quantity += quantity;
         after.profileItems[profileIndex].mutationSerial = acquiredMutationSerial;
     }
     const std::int32_t acquiredQuantity = after.profileItems[profileIndex].quantity;
@@ -470,7 +526,7 @@ bool prepare_profile_item_acquisition(std::uint16_t collectibleIndex,
     mutation.accountSoid = account.primarySoid;
     mutation.acquiredInstanceSoid = acquiredInstanceSoid;
     mutation.acquiredDefinitionHash = definitionHash;
-    mutation.materialRequirementSetHash = collectible.materialRequirementSetHash;
+    mutation.materialRequirementSetHash = materialRequirementSetHash;
     mutation.expectedItemCount = account.profileItemCount;
     mutation.afterItemCount = after.profileItemCount;
     mutation.profileIndex = profileIndex;
@@ -480,9 +536,10 @@ bool prepare_profile_item_acquisition(std::uint16_t collectibleIndex,
     mutation.acquiredMutationSerial = acquiredMutationSerial;
     mutation.collectibleIndex = collectibleIndex;
     mutation.bucketId = detail.bucketId;
-    mutation.materialRequirementCount = collectible.materialRequirementCount;
+    mutation.materialRequirementCount = materialRequirementCount;
     mutation.actionSource = actionSource;
     mutation.appended = appended;
+    mutation.freeGrant = freeGrant;
     mutation.prepared = true;
     if (!valid_profile_mutation_shape(mutation)) {
         mutation = {};
@@ -515,6 +572,130 @@ bool prepare_profile_item_acquisition(std::uint16_t collectibleIndex,
     return true;
 }
 
+} // namespace
+
+/** Prepares one checked profile-stack increment or append for a Collections pull. */
+bool prepare_profile_item_acquisition(std::uint16_t collectibleIndex,
+                                      std::uint32_t definitionHash,
+                                      PendingProfileItemAcquisition& mutation) noexcept {
+    mutation = {};
+    const AccountState account = account_snapshot();
+    build_data::collectibles::Definition collectible{};
+    build_data::items::Definition item{};
+    item_details::Definition detail{};
+    inventory_buckets::Descriptor bucket{};
+    if (definitionHash == authored_inventory::kNoDefinitionHash || !account::valid(account)
+        || !valid_profile_inventory(account)
+        || !build_data::find_collectible_definition(collectibleIndex, collectible)
+        || collectible.itemDefinitionIndex
+               == build_data::collectibles::kUnavailableItemDefinitionIndex
+        || !build_data::find_item_definition_hash(definitionHash, item)
+        || item.definitionHash != definitionHash
+        || item.definitionIndex != collectible.itemDefinitionIndex
+        || !build_data::find_configured_item_detail(item.definitionIndex, detail)
+        || detail.definitionIndex != item.definitionIndex || detail.definitionHash != definitionHash
+        || detail.bucketId != item.bucketId
+        || detail.instancedDefinitionState != item_details::InstancedDefinitionState::stackable
+        || detail.maxStackSize <= 0
+        || !build_data::find_inventory_bucket_descriptor(detail.bucketId, bucket)
+        || bucket.arraySelector != inventory_buckets::ArraySelector::profile) {
+        report_profile_acquisition("prepare",
+                                   "fail",
+                                   "definition_or_profile",
+                                   definitionHash,
+                                   account.primarySoid,
+                                   0,
+                                   0,
+                                   0,
+                                   account.profileItemCount,
+                                   0,
+                                   0,
+                                   false);
+        return false;
+    }
+    AccountState chargedAccount{};
+    bool materialsChanged = false;
+    if (!apply_collection_materials(account, collectible, chargedAccount, materialsChanged)) {
+        report_profile_acquisition("prepare",
+                                   "fail",
+                                   "materials",
+                                   definitionHash,
+                                   account.primarySoid,
+                                   0,
+                                   detail.bucketId,
+                                   0,
+                                   account.profileItemCount,
+                                   0,
+                                   0,
+                                   false);
+        return false;
+    }
+    (void)materialsChanged;
+    const bool actionSource =
+        build_data::is_profile_action_source(item.definitionIndex, item.bucketId);
+
+    return finalize_profile_item_acquisition(account,
+                                             chargedAccount,
+                                             definitionHash,
+                                             detail,
+                                             actionSource,
+                                             1,
+                                             collectibleIndex,
+                                             collectible.materialRequirementSetHash,
+                                             collectible.materialRequirementCount,
+                                             false,
+                                             mutation);
+}
+
+/** Prepares one direct profile-stack grant, with no Collections row or material charge. */
+bool prepare_profile_item_acquisition_for_item(std::uint16_t itemDefinitionIndex,
+                                               std::int32_t quantity,
+                                               PendingProfileItemAcquisition& mutation) noexcept {
+    mutation = {};
+    const AccountState account = account_snapshot();
+    build_data::items::Definition item{};
+    item_details::Definition detail{};
+    inventory_buckets::Descriptor bucket{};
+    if (quantity <= 0 || !account::valid(account) || !valid_profile_inventory(account)
+        || !build_data::find_item_definition_index(itemDefinitionIndex, item)
+        || item.definitionHash == authored_inventory::kNoDefinitionHash
+        || !build_data::find_configured_item_detail(item.definitionIndex, detail)
+        || detail.definitionIndex != item.definitionIndex
+        || detail.definitionHash != item.definitionHash || detail.bucketId != item.bucketId
+        || detail.instancedDefinitionState != item_details::InstancedDefinitionState::stackable
+        || detail.maxStackSize <= 0
+        || !build_data::find_inventory_bucket_descriptor(detail.bucketId, bucket)
+        || bucket.arraySelector != inventory_buckets::ArraySelector::profile) {
+        report_profile_acquisition("prepare",
+                                   "fail",
+                                   "definition_or_profile",
+                                   0,
+                                   account.primarySoid,
+                                   0,
+                                   0,
+                                   0,
+                                   account.profileItemCount,
+                                   0,
+                                   0,
+                                   false);
+        return false;
+    }
+    const bool actionSource =
+        build_data::is_profile_action_source(item.definitionIndex, item.bucketId);
+
+    return finalize_profile_item_acquisition(account,
+                                             account,
+                                             item.definitionHash,
+                                             detail,
+                                             actionSource,
+                                             quantity,
+                                             0,
+                                             0,
+                                             0,
+                                             true,
+                                             mutation);
+}
+
 /** Produces the exact account after-image while the captured profile view is still current. */
 bool preview_profile_item_acquisition(const PendingProfileItemAcquisition& mutation,
                                       AccountState& after) noexcept {

+ 22 - 10
Sunrise/src/state/runtime/state_account_profile_runtime.cpp

@@ -449,7 +449,6 @@ valid_profile_mutation_shape(const PendingProfileItemAcquisition& mutation) noex
         || mutation.afterItemCount > authored_inventory::kProfileItemCapacity
         || mutation.profileIndex >= mutation.afterItemCount || mutation.previousQuantity < 0
         || mutation.acquiredQuantity <= mutation.previousQuantity
-        || mutation.acquiredQuantity - mutation.previousQuantity != 1
         || mutation.previousMutationSerial < 0
         || mutation.acquiredMutationSerial <= mutation.previousMutationSerial) {
         return false;
@@ -509,15 +508,28 @@ valid_profile_mutation_shape(const PendingProfileItemAcquisition& mutation) noex
     item_details::Definition detail{};
     inventory_buckets::Descriptor bucket{};
     build_data::items::Definition item{};
-    build_data::collectibles::Definition collectible{};
-    if (!build_data::find_collectible_definition(mutation.collectibleIndex, collectible)
-        || collectible.itemDefinitionIndex
-               == build_data::collectibles::kUnavailableItemDefinitionIndex
-        || collectible.materialRequirementSetHash != mutation.materialRequirementSetHash
-        || collectible.materialRequirementCount != mutation.materialRequirementCount
-        || !build_data::find_item_definition_hash(mutation.acquiredDefinitionHash, item)
-        || collectible.itemDefinitionIndex != item.definitionIndex
-        || !build_data::find_configured_item_detail(item.definitionIndex, detail)
+    if (!build_data::find_item_definition_hash(mutation.acquiredDefinitionHash, item)) {
+        return false;
+    }
+    if (mutation.freeGrant) {
+        // A record-reward grant charges nothing and names no Collections row, so there is no
+        // collectible to re-verify.
+        if (mutation.collectibleIndex != 0 || mutation.materialRequirementSetHash != 0
+            || mutation.materialRequirementCount != 0) {
+            return false;
+        }
+    } else {
+        build_data::collectibles::Definition collectible{};
+        if (!build_data::find_collectible_definition(mutation.collectibleIndex, collectible)
+            || collectible.itemDefinitionIndex
+                   == build_data::collectibles::kUnavailableItemDefinitionIndex
+            || collectible.materialRequirementSetHash != mutation.materialRequirementSetHash
+            || collectible.materialRequirementCount != mutation.materialRequirementCount
+            || collectible.itemDefinitionIndex != item.definitionIndex) {
+            return false;
+        }
+    }
+    if (!build_data::find_configured_item_detail(item.definitionIndex, detail)
         || detail.definitionHash != mutation.acquiredDefinitionHash
         || detail.definitionIndex != item.definitionIndex || detail.bucketId != item.bucketId
         || detail.bucketId != mutation.bucketId

+ 4 - 0
Sunrise/src/state/runtime/state_runtime.cpp

@@ -1,4 +1,5 @@
 #include "../build_data/records/record_persistence.h"
+#include "../build_data/records/rewards/reward_persistence.h"
 #include "../build_data/nodes/node_persistence.h"
 #include "../record_claims/record_claims.h"
 #include <Windows.h>
@@ -212,6 +213,9 @@ bool initialize(void* module,
     // as Acquired. A missing file is a first run, not a failure.
     (void)build_data::nodes::initialize(module);
     (void)build_data::records::initialize(module);
+    // Only resolves the shipped reward file's path; the file itself loads lazily at first use, the
+    // same as the claim-index and lore-node tables above.
+    (void)build_data::records::rewards::initialize(module);
     (void)record_claims::initialize(module);
     // A cache hit already has the complete plug relation, so publish canonical profile identities
     // in the first State image.  On a first cache build, snapshot preparation repeats this step