Преглед изворни кода

Implement seasonal artifact progression

Millie пре 1 недеља
родитељ
комит
d6e67dd7df
29 измењених фајлова са 1373 додато и 35 уклоњено
  1. 2 2
      Sunrise/resources/default_settings.json
  2. 1 3
      Sunrise/src/client/hooks/network/investment/internal.h
  3. 7 0
      Sunrise/src/client/hooks/network/investment/investment_derived_rebuild.h
  4. 106 4
      Sunrise/src/client/hooks/network/investment/investment_family5_rearm.cpp
  5. 18 0
      Sunrise/src/middleware/datagen/character_record/appearance/character_appearance_stats.cpp
  6. 17 4
      Sunrise/src/middleware/datagen/family4/account/account_encoder.cpp
  7. 5 1
      Sunrise/src/middleware/datagen/family4/character/character_encoder.cpp
  8. 15 3
      Sunrise/src/server/bap/bap_route.cpp
  9. 1 0
      Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_message_receipts.cpp
  10. 46 0
      Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp
  11. 17 3
      Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp
  12. 39 0
      Sunrise/src/server/bap/encrypted/internal.h
  13. 82 0
      Sunrise/src/server/bap/encrypted/push/queuez/queuez_select_character.cpp
  14. 213 0
      Sunrise/src/server/bap/encrypted/push/snapshot/family4_selection_move.cpp
  15. 19 0
      Sunrise/src/server/bap/encrypted/push/snapshot/internal.h
  16. 117 0
      Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp
  17. 49 0
      Sunrise/src/server/bap/encrypted/queuez/queuez_outcome_staging.cpp
  18. 7 0
      Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp
  19. 6 0
      Sunrise/src/server/bap/internal.h
  20. 72 1
      Sunrise/src/server/web_service/web_service_runtime.cpp
  21. 4 0
      Sunrise/src/server/web_service/web_service_runtime.h
  22. 1 0
      Sunrise/src/state/account/inventory/inventory_state.cpp
  23. 2 1
      Sunrise/src/state/account/inventory/inventory_state.h
  24. 2 1
      Sunrise/src/state/equipment/light/resolution/configured_equipment_light_resolver.cpp
  25. 224 10
      Sunrise/src/state/progression/seasonal_experience.cpp
  26. 35 0
      Sunrise/src/state/progression/seasonal_experience.h
  27. 34 0
      Sunrise/src/state/runtime/runtime.h
  28. 4 1
      Sunrise/src/state/runtime/state_account_equipment_runtime.cpp
  29. 228 1
      Sunrise/src/state/runtime/state_runtime.cpp

Разлика између датотеке није приказан због своје велике величине
+ 2 - 2
Sunrise/resources/default_settings.json


+ 1 - 3
Sunrise/src/client/hooks/network/investment/internal.h

@@ -22,9 +22,7 @@ using patterns::signature_length;
 [[nodiscard]] bool family5_rearm_is_installed() noexcept;
 
 /**
- * Arms one derived-state rebuild, used up by the next freshness verdict. Armed twice: when the
- * family-four lookup first returns a real object, and after each family-five commit, which is the
- * first point the account's unlock overrides can be read back.
+ * Arms one derived-state rebuild after replicated investment state changes.
  */
 void arm_derived_rebuild() noexcept;
 

+ 7 - 0
Sunrise/src/client/hooks/network/investment/investment_derived_rebuild.h

@@ -1,7 +1,14 @@
 #pragma once
 
+namespace sunrise::state {
+struct Family5State;
+}
+
 namespace sunrise::client::hooks::network::investment {
 
+/** Replaces the live Family-5 override lists and invalidates their derived evaluation. */
+[[nodiscard]] bool publish_live_family5(const state::Family5State& family) noexcept;
+
 /** @return True when freshness and both real-arrival rebuild arms are attached. */
 [[nodiscard]] bool install() noexcept;
 

+ 106 - 4
Sunrise/src/client/hooks/network/investment/investment_family5_rearm.cpp

@@ -8,10 +8,14 @@
 #include <atomic>
 #include <cstddef>
 #include <cstdint>
+#include <cstring>
+#include <limits>
 #include <string_view>
 
 #include "../../../../core/logging/log.h"
+#include "../../../../state/investment/investment.h"
 #include "../../../hooking/detour.h"
+#include "../../../targets/game/content.h"
 #include "internal.h"
 
 namespace sunrise::client::hooks::network::investment {
@@ -31,29 +35,66 @@ constexpr auto kCommitSignature =
 
 /** Result returned when the trampoline is gone, so no commit ran. */
 constexpr std::int64_t kNoCommit = 0;
-
 using CommitFamily5 = std::int64_t(__fastcall*)(void*, std::uint64_t*);
 
 hooking::detour::Handle g_handle{};
 std::atomic<CommitFamily5> g_original{nullptr};
+std::atomic<void*> g_manager{nullptr};
 std::atomic_bool g_reportedArm{false};
 
+constexpr std::size_t kObjectArraysOffset = 33'624;
+constexpr std::size_t kObjectArraysSize = 878'184;
+constexpr std::size_t kDescriptorSize = 16;
+constexpr std::size_t kFamily5Type = 5;
+constexpr std::size_t kFamily5Slot = 5;
+constexpr std::uint32_t kFamily5Stride = 1'712;
+constexpr std::uint64_t kFamily5Soid =
+    static_cast<std::uint64_t>((std::numeric_limits<std::int64_t>::max)());
+constexpr std::size_t kFlagListOffset = 124;
+constexpr std::size_t kValueListOffset = 528;
+
+struct SlotDescriptor {
+    std::uint32_t base{};
+    std::uint32_t count{};
+    std::uint32_t stride{};
+    std::uint32_t schemaId{};
+};
+
+struct FlagRow {
+    std::int16_t slot{};
+    std::int8_t value{};
+    std::uint8_t padding{};
+};
+
+struct ValueRow {
+    std::int16_t slot{};
+    std::array<std::uint8_t, 2> padding{};
+    std::int32_t value{};
+};
+
+static_assert(sizeof(SlotDescriptor) == 16);
+static_assert(sizeof(FlagRow) == 4);
+static_assert(sizeof(ValueRow) == 8);
+
+using ObjectStoreGetter = std::byte*(__fastcall*)();
+
 /**
  * Runs the family-five commit, then arms one derived-state rebuild. The two callers pass different
  * second arguments, so it is passed on unread. Arming twice is harmless, and the next freshness
  * verdict uses it up, so repeat commits need no latch.
- * @param primaryRecordBlock Borrowed record block the commit writes into.
+ * @param manager Borrowed queuez manager owning the Family-5 commit.
  * @param nested4 Borrowed caller-owned argument, passed on unread.
  * @return The commit's own result, or the no-commit result when the trampoline is gone.
  */
-__declspec(noinline) std::int64_t __fastcall commit(void* primaryRecordBlock,
+__declspec(noinline) std::int64_t __fastcall commit(void* manager,
                                                     std::uint64_t* nested4) noexcept {
     const CommitFamily5 original = g_original.load(std::memory_order_acquire);
     if (original == nullptr) {
         return kNoCommit;
     }
+    g_manager.store(manager, std::memory_order_release);
     // Arm on the way out: the overrides are in the object only once the commit has run.
-    const std::int64_t result = original(primaryRecordBlock, nested4);
+    const std::int64_t result = original(manager, nested4);
     arm_derived_rebuild();
     if (!g_reportedArm.exchange(true, std::memory_order_relaxed)) {
         core::log::write(core::log::Channel::client,
@@ -65,6 +106,66 @@ __declspec(noinline) std::int64_t __fastcall commit(void* primaryRecordBlock,
 
 } // namespace
 
+bool publish_live_family5(const state::Family5State& family) noexcept {
+    const auto& targets = client::targets::game::content::get();
+    if (!client::targets::game::content::is_resolved()
+        || targets.queuezObjectStoreGetter == nullptr
+        || family.objectSoid != kFamily5Soid || family.flagCount > family.flags.size()
+        || family.valueCount > family.values.size()) {
+        return false;
+    }
+    const auto getter = reinterpret_cast<ObjectStoreGetter>(targets.queuezObjectStoreGetter);
+    std::byte* const store = getter();
+    if (store == nullptr) {
+        return false;
+    }
+    const std::size_t descriptorIndex =
+        kFamily5Slot + 6U * (kFamily5Type + targets.queuezDescriptorFamilyBias);
+    SlotDescriptor descriptor{};
+    std::memcpy(&descriptor,
+                store + descriptorIndex * kDescriptorSize,
+                sizeof descriptor);
+    if (descriptor.base > kObjectArraysSize - kFamily5Stride || descriptor.count != 1
+        || descriptor.stride != kFamily5Stride) {
+        return false;
+    }
+    std::byte* const object = store + kObjectArraysOffset + descriptor.base;
+    std::uint64_t objectSoid = 0;
+    std::memcpy(&objectSoid, object, sizeof objectSoid);
+    if (objectSoid != kFamily5Soid) {
+        return false;
+    }
+
+    const CommitFamily5 original = g_original.load(std::memory_order_acquire);
+    void* const manager = g_manager.load(std::memory_order_acquire);
+    if (original == nullptr || manager == nullptr) {
+        return false;
+    }
+
+    alignas(16) std::array<std::byte, kFamily5Stride> updated{};
+    std::memcpy(updated.data(), object, updated.size());
+    std::array<FlagRow, state::kUnlockOverrideCapacity> flags{};
+    for (std::size_t index = 0; index < family.flagCount; ++index) {
+        flags[index].slot = static_cast<std::int16_t>(family.flags[index].slot);
+        flags[index].value = static_cast<std::int8_t>(family.flags[index].value);
+    }
+    std::array<ValueRow, state::kUnlockOverrideCapacity> values{};
+    for (std::size_t index = 0; index < family.valueCount; ++index) {
+        values[index].slot = static_cast<std::int16_t>(family.values[index].slot);
+        values[index].value = family.values[index].value;
+    }
+    const auto flagCount = static_cast<std::uint32_t>(family.flagCount);
+    const auto valueCount = static_cast<std::uint32_t>(family.valueCount);
+    std::memcpy(
+        updated.data() + kFlagListOffset + sizeof flagCount, flags.data(), sizeof flags);
+    std::memcpy(
+        updated.data() + kValueListOffset + sizeof valueCount, values.data(), sizeof values);
+    std::memcpy(updated.data() + kFlagListOffset, &flagCount, sizeof flagCount);
+    std::memcpy(updated.data() + kValueListOffset, &valueCount, sizeof valueCount);
+    (void)commit(manager, reinterpret_cast<std::uint64_t*>(updated.data()));
+    return true;
+}
+
 /**
  * Attaches the family-five commit rearm.
  * @return True when the target is found and the detour attaches.
@@ -100,6 +201,7 @@ bool uninstall_family5_rearm() noexcept {
         return false;
     }
     g_original.store(nullptr, std::memory_order_release);
+    g_manager.store(nullptr, std::memory_order_release);
     g_reportedArm.store(false, std::memory_order_release);
     return true;
 }

+ 18 - 0
Sunrise/src/middleware/datagen/character_record/appearance/character_appearance_stats.cpp

@@ -3,6 +3,7 @@
 
 #include "../../../../core/logging/log.h"
 #include "../../../../state/build_data/runtime.h"
+#include "../../../../state/progression/seasonal_experience.h"
 #include "internal.h"
 
 namespace sunrise::middleware::datagen::character_record::appearance {
@@ -10,6 +11,9 @@ namespace {
 
 namespace constants = state::build_data::constants;
 
+/** Season 11's visible artifact; its sole declared stat is the conditional Power bonus. */
+constexpr std::uint32_t kSeedOfSilverWingsHash = 0x613A3DA6U;
+
 /**
  * Sums one definition's declared contribution to a single stat row.
  * @param definitionIndex Native item or plug index.
@@ -148,6 +152,20 @@ bool apply_stats(const family4::loadout::ResolvedInstances& instances,
 
     std::size_t written = 0;
     append(named.lightStatRow, light, appearance.characterStats, written);
+    for (std::size_t index = 0; index < instances.itemCount; ++index) {
+        details::Definition detail{};
+        Equipped equipped{};
+        if (!resolve_equipped(instances.items[index], detail, equipped)
+            || detail.definitionHash != kSeedOfSilverWingsHash || detail.statCount == 0
+            || detail.stats.front().row == details::kEmptyStatRow) {
+            continue;
+        }
+        append(detail.stats.front().row,
+               state::progression::seasonal_experience::artifact_power_bonus(),
+               appearance.characterStats,
+               written);
+        break;
+    }
     for (const std::uint8_t row : rows) {
         std::int32_t total = 0;
         for (std::size_t index = 0; index < instances.itemCount; ++index) {

+ 17 - 4
Sunrise/src/middleware/datagen/family4/account/account_encoder.cpp

@@ -128,14 +128,27 @@ bool encode(const state::AccountState& state,
         return false;
     }
     const std::int32_t earnedExperience = state::progression::seasonal_experience::earned();
+    constexpr std::int32_t experiencePerRank = 100'000;
+    constexpr std::int32_t maximumPassExperience = 9'900'000;
     for (std::size_t slot = 0; slot < object.progressions.size(); ++slot) {
         progression::layout::Entry& entry = object.progressions[slot];
-        if (entry.definitionIndex != state::progression::season_pass::kProgressionDefinitionIndex
-            && entry.definitionIndex
-                   != state::progression::season_pass::kHudProgressionDefinitionIndex) {
+        std::int32_t projectedExperience = earnedExperience;
+        if (entry.definitionIndex == state::progression::season_pass::kProgressionDefinitionIndex) {
+            projectedExperience = (std::min)(earnedExperience, maximumPassExperience);
+        } else if (entry.definitionIndex
+                   == state::progression::season_pass::kHudProgressionDefinitionIndex) {
+            projectedExperience = earnedExperience < maximumPassExperience
+                                      ? earnedExperience % experiencePerRank
+                                      : earnedExperience - maximumPassExperience;
+        } else if (entry.definitionIndex
+                       != state::progression::seasonal_experience::
+                           kArtifactPowerProgressionDefinitionIndex
+                   && entry.definitionIndex
+                          != state::progression::seasonal_experience::
+                              kArtifactUnlockProgressionDefinitionIndex) {
             continue;
         }
-        entry.values[0] = (std::max)(entry.values[0], earnedExperience);
+        entry.values[0] = (std::max)(entry.values[0], projectedExperience);
     }
     // Profile rows are sentinelled above, so placement only has to claim its own slots.
     std::array<std::uint16_t, kBucketIdentityCapacity> takenSlots{};

+ 5 - 1
Sunrise/src/middleware/datagen/family4/character/character_encoder.cpp

@@ -9,6 +9,7 @@
 
 #include "../../../../state/build_data/nodes/node_catalog.h"
 #include "../../../../state/build_data/runtime.h"
+#include "../../../../state/progression/seasonal_experience.h"
 #include "../../../../state/record_claims/record_claims.h"
 #include "../../../../state/unlocks/unlocks_runtime.h"
 #include "../instance/layout.h"
@@ -262,7 +263,10 @@ bool encode(const state::CharacterState& state,
         object.objectiveValues[index] =
             index < unlocks.characterObjectValues.size() ? unlocks.characterObjectValues[index] : 0;
     }
-
+    if (!state::progression::seasonal_experience::apply_artifact_character_state(
+            object.acquiredFlags, object.objectiveValues)) {
+        return false;
+    }
     // One lore book counts in the character bank rather than the account one.
     (void)state::record_claims::apply_character_node_progress(object.objectiveValues, pendingClaim);
 

+ 15 - 3
Sunrise/src/server/bap/bap_route.cpp

@@ -4,6 +4,7 @@
 #include <array>
 #include <limits>
 
+#include "../../client/hooks/network/investment/investment_derived_rebuild.h"
 #include "../../core/logging/log.h"
 #include "../../state/matchmaking/matchmaking_state.h"
 #include "../../state/progression/seasonal_experience.h"
@@ -202,9 +203,19 @@ void clear_session(Session& session) noexcept {
                                 client::network::BapResponse& response,
                                 bool& touchesScratch) noexcept {
     auto* session = session_for(request.connectionId);
-    return session != nullptr
-           && encrypted::consume_deferred(
-               *session, g_scratch, request.response, response.size, touchesScratch);
+    if (session == nullptr) {
+        return false;
+    }
+    // The purchase response carries the Family-4 ownership rows. Refresh Family 5 only after the
+    // client has consumed that response, so derived artifact state never mixes adjacent purchases.
+    if (session->artifactRefreshArmed) {
+        const state::Family5State family = state::investment_snapshot().family5;
+        if (client::hooks::network::investment::publish_live_family5(family)) {
+            session->artifactRefreshArmed = false;
+        }
+    }
+    return encrypted::consume_deferred(
+        *session, g_scratch, request.response, response.size, touchesScratch);
 }
 
 } // namespace
@@ -294,6 +305,7 @@ bool arm_seasonal_experience_presentation(std::int32_t amount) noexcept {
         if (!state::progression::seasonal_experience::grant(amount)) {
             return false;
         }
+        (void)state::refresh_artifact_progression();
         peer.pendingSeasonalExperienceAmount += amount;
         peer.pendingSeasonalExperienceFailures = 0;
         return true;

+ 1 - 0
Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_message_receipts.cpp

@@ -461,6 +461,7 @@ resolve_egg_context(const client::player::position::Snapshot& player,
         const bool queued = bap::arm_seasonal_experience_presentation(kBaseExperienceReward);
         if (!queued) {
             (void)state::progression::seasonal_experience::grant(kBaseExperienceReward);
+            (void)state::refresh_artifact_progression();
         }
     }
     return outcome;

+ 46 - 0
Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp

@@ -206,6 +206,8 @@ bool process(const ServiceRoute& route,
         }
         outcome.hasSubscription = webOutcome.hasSubscription;
         outcome.hasRecordClaim = webOutcome.hasRecordClaim;
+        outcome.hasArtifactReset = webOutcome.hasArtifactReset;
+        outcome.artifactReset = webOutcome.artifactReset;
         outcome.subscription = webOutcome.subscription;
         const auto* equipmentSwap =
             web_service::mutation_if<state::PendingEquipmentSwap>(webOutcome);
@@ -213,6 +215,8 @@ bool process(const ServiceRoute& route,
             web_service::mutation_if<state::PendingSubclassSelection>(webOutcome);
         const auto* socketPlug = web_service::mutation_if<state::PendingSocketPlug>(webOutcome);
         const auto* itemState = web_service::mutation_if<state::PendingItemState>(webOutcome);
+        const auto* artifactPurchase =
+            web_service::mutation_if<state::PendingArtifactPurchase>(webOutcome);
         const auto* itemAcquisition =
             web_service::mutation_if<state::PendingItemAcquisition>(webOutcome);
         const auto* profileItemAcquisition =
@@ -223,6 +227,48 @@ bool process(const ServiceRoute& route,
             web_service::mutation_if<state::PendingRecordRewardGrant>(webOutcome);
         auto* seasonPassReward =
             web_service::mutation_if<state::PendingSeasonPassReward>(webOutcome);
+        if (webOutcome.hasArtifactReset) {
+            if (queuezState.family4Version
+                == (std::numeric_limits<std::int32_t>::max)()) {
+                return refuse_web_action(message, output, written);
+            }
+            middleware::web_service::StatusResponse status{};
+            status.value = queuezState.family4Version + 1;
+            if (!middleware::web_service::encode_response(
+                    message,
+                    middleware::web_service::ResponseShape::statusPairWithBool,
+                    status,
+                    output,
+                    written)) {
+                return false;
+            }
+        }
+        if (artifactPurchase != nullptr) {
+            auto* transaction = emplace_transaction<ArtifactPurchaseTransaction>(outcome);
+            if (transaction == nullptr
+                || !queuez::stage_equipment_swap(
+                    queuezState, artifactPurchase->characterSoid, transaction->update)) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=ws901 stage=queuez_preflight result=fail");
+                clear_transaction(outcome);
+                return refuse_web_action(message, output, written);
+            }
+            middleware::web_service::StatusResponse status{};
+            status.value = transaction->update.after.family4Version;
+            status.trailingBool = true;
+            if (!middleware::web_service::encode_response(
+                    message,
+                    middleware::web_service::ResponseShape::statusPairWithBool,
+                    status,
+                    output,
+                    written)) {
+                clear_transaction(outcome);
+                return refuse_web_action(message, output, written);
+            }
+            transaction->pending =
+                web_service::take_mutation<state::PendingArtifactPurchase>(webOutcome);
+        }
         if (equipmentSwap != nullptr) {
             // Promise the Family-4 revision carrying this optimistic equip.
             auto* transaction = emplace_transaction<EquipmentSwapTransaction>(outcome);

+ 17 - 3
Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp

@@ -241,12 +241,14 @@ bool consume(Session& session,
             diagnostics::report_failure(frame.messageId, "notify");
         }
     }
+    const bool artifactPurchase = transaction_if<ArtifactPurchaseTransaction>(outcome) != nullptr;
     const bool mutatesAccount =
-        outcome.hasSelectCharacter || outcome.hasRecordClaim
+        outcome.hasSelectCharacter || outcome.hasRecordClaim || outcome.hasArtifactReset
         || transaction_if<EquipmentSwapTransaction>(outcome) != nullptr
         || transaction_if<SubclassSelectionTransaction>(outcome) != nullptr
         || transaction_if<SocketPlugTransaction>(outcome) != nullptr
         || transaction_if<ItemStateTransaction>(outcome) != nullptr
+        || artifactPurchase
         || transaction_if<ItemAcquisitionTransaction>(outcome) != nullptr
         || transaction_if<ProfileItemAcquisitionTransaction>(outcome) != nullptr
         || transaction_if<ItemDismantleTransaction>(outcome) != nullptr
@@ -258,9 +260,10 @@ bool consume(Session& session,
         || transaction_if<RecordRewardGrantTransaction>(outcome) != nullptr
         || transaction_if<SeasonPassRewardTransaction>(outcome) != nullptr;
     const bool invalidatesAcquisitionPresentation =
-        outcome.hasChangeCharacter || outcome.hasSelectCharacter
+        outcome.hasChangeCharacter || outcome.hasSelectCharacter || outcome.hasArtifactReset
         || transaction_if<ItemDismantleTransaction>(outcome) != nullptr;
-    const bool hasPrecommittedAccountAction = outcome.hasRecordClaim || outcome.hasSelectCharacter;
+    const bool hasPrecommittedAccountAction =
+        outcome.hasRecordClaim || outcome.hasSelectCharacter || outcome.hasArtifactReset;
     // Commit consumes pending payloads, so retain the connection fields first.
     const ConnectionFields connection = connection_fields(outcome);
     if (handled && processesBody) {
@@ -307,6 +310,17 @@ bool consume(Session& session,
             if (resyncsCommittedAccount) {
                 bap::arm_account_resync_everywhere();
             }
+            if (artifactPurchase || outcome.hasArtifactReset) {
+                session.artifactRefreshArmed = true;
+            }
+            if (artifactPurchase || outcome.hasArtifactReset) {
+                session.artifactFamily4RefreshDueTick = GetTickCount64() + 100;
+                session.artifactFamily4RefreshArmed = true;
+            }
+            if (outcome.hasArtifactReset) {
+                session.artifactResetRefresh = outcome.artifactReset;
+                session.artifactResetRefreshCursor = 0;
+            }
             session.accountMutationPublished = mutatesAccount && !resyncsCommittedAccount;
         }
     }

+ 39 - 0
Sunrise/src/server/bap/encrypted/internal.h

@@ -63,6 +63,12 @@ struct ItemStateTransaction {
     queuez::EquipmentSwap update{};
 };
 
+/** Artifact purchase and the exact selected-character after-image promised by opcode 901. */
+struct ArtifactPurchaseTransaction {
+    std::unique_ptr<state::PendingArtifactPurchase> pending{};
+    queuez::EquipmentSwap update{};
+};
+
 /** Character acquisition and its exact QueueZ after-image. */
 struct ItemAcquisitionTransaction {
     std::unique_ptr<state::PendingItemAcquisition> pending{};
@@ -98,6 +104,8 @@ struct ServiceOutcome {
     bool hasSubscription{};
     /** A Triumph claim changed the account flag bank and its image has to follow. */
     bool hasRecordClaim{};
+    bool hasArtifactReset{};
+    state::ArtifactResetResult artifactReset{};
     middleware::queuez::Subscription subscription{};
     bool hasUnsubscription{};
     middleware::bap::family_unsubscription::Request unsubscription{};
@@ -114,6 +122,7 @@ struct ServiceOutcome {
                                      std::unique_ptr<SubclassSelectionTransaction>,
                                      std::unique_ptr<SocketPlugTransaction>,
                                      std::unique_ptr<ItemStateTransaction>,
+                                     std::unique_ptr<ArtifactPurchaseTransaction>,
                                      std::unique_ptr<ItemAcquisitionTransaction>,
                                      std::unique_ptr<ProfileItemAcquisitionTransaction>,
                                      std::unique_ptr<ItemDismantleTransaction>,
@@ -407,6 +416,36 @@ append_select_character_notification(Scratch& scratch,
     std::span<std::byte> response,
     std::size_t& written) noexcept;
 
+/** Appends the selected-character upsert carrying one artifact ownership transition. */
+[[nodiscard]] bool append_artifact_purchase_notification(
+    Scratch& scratch,
+    const queuez::EquipmentSwap& update,
+    const state::PendingArtifactPurchase& mutation,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    std::span<const std::byte, state::kAesKeySize> key,
+    std::span<const std::byte, state::kBapNonceSize> nonce,
+    std::span<std::byte> response,
+    std::size_t& written) noexcept;
+
+/** Appends the account and selected-character state changed by an artifact reset. */
+[[nodiscard]] bool append_artifact_reset_notification(
+    Scratch& scratch,
+    const queuez::EquipmentSwap& update,
+    std::span<const std::byte, state::kAesKeySize> key,
+    std::span<const std::byte, state::kBapNonceSize> nonce,
+    std::span<std::byte> response,
+    std::size_t& written) noexcept;
+
+/** Appends one current item resident after artifact reset cleared an authored socket. */
+[[nodiscard]] bool append_artifact_item_refresh_notification(
+    Scratch& scratch,
+    const queuez::EquipmentSwap& update,
+    std::uint64_t instanceSoid,
+    std::span<const std::byte, state::kAesKeySize> key,
+    std::span<const std::byte, state::kBapNonceSize> nonce,
+    std::span<std::byte> response,
+    std::size_t& written) noexcept;
+
 /**
  * Appends the same-character Family-0 appearance upsert paired with one equipment swap.
  * The

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

@@ -92,6 +92,88 @@ bool append_item_state_notification(
     return true;
 }
 
+/** Appends one opcode-901 selected-character artifact ownership upsert. */
+bool append_artifact_purchase_notification(
+    Scratch& scratch,
+    const queuez::EquipmentSwap& update,
+    const state::PendingArtifactPurchase& mutation,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    std::span<const std::byte, state::kAesKeySize> key,
+    std::span<const std::byte, state::kBapNonceSize> nonce,
+    std::span<std::byte> response,
+    std::size_t& written) noexcept {
+    snapshot::Prepared prepared{};
+    if (!snapshot::prepare_artifact_purchase(
+            scratch, update, mutation, acquisitionPresentationRows, prepared)) {
+        return false;
+    }
+    return prepared.family.objects.size() == 1
+           && prepared.family.objects.front().id == update.characterDefinitionId
+           && prepared.family.objects.front().version == update.characterSoid
+           && prepared.family.objects.front().encoding == middleware::queuez::Encoding::oodle
+           && !prepared.family.objects.front().payload.empty()
+           && queuez_frame::append(scratch,
+                                   prepared.family,
+                                   prepared.rawClearSize,
+                                   prepared.compressedClearSize,
+                                   key,
+                                   nonce,
+                                   response,
+                                   written);
+}
+
+/** Appends a reset increment without the full-snapshot acquisition semantics. */
+bool append_artifact_reset_notification(
+    Scratch& scratch,
+    const queuez::EquipmentSwap& update,
+    std::span<const std::byte, state::kAesKeySize> key,
+    std::span<const std::byte, state::kBapNonceSize> nonce,
+    std::span<std::byte> response,
+    std::size_t& written) noexcept {
+    snapshot::Prepared prepared{};
+    if (!snapshot::prepare_artifact_reset(scratch, update, prepared)) {
+        return false;
+    }
+    return prepared.family.objects.size() == 2
+           && prepared.family.objects[1].id == update.characterDefinitionId
+           && prepared.family.objects[1].version == update.characterSoid
+           && queuez_frame::append(scratch,
+                                   prepared.family,
+                                   prepared.rawClearSize,
+                                   prepared.compressedClearSize,
+                                   key,
+                                   nonce,
+                                   response,
+                                   written);
+}
+
+/** Appends one exact resident upsert after artifact reset. */
+bool append_artifact_item_refresh_notification(
+    Scratch& scratch,
+    const queuez::EquipmentSwap& update,
+    std::uint64_t instanceSoid,
+    std::span<const std::byte, state::kAesKeySize> key,
+    std::span<const std::byte, state::kBapNonceSize> nonce,
+    std::span<std::byte> response,
+    std::size_t& written) noexcept {
+    snapshot::Prepared prepared{};
+    if (!snapshot::prepare_artifact_item_refresh(scratch, update, instanceSoid, prepared)) {
+        return false;
+    }
+    return prepared.family.objects.size() == 1
+           && prepared.family.objects.front().version == instanceSoid
+           && prepared.family.objects.front().encoding == middleware::queuez::Encoding::oodle
+           && !prepared.family.objects.front().payload.empty()
+           && queuez_frame::append(scratch,
+                                   prepared.family,
+                                   prepared.rawClearSize,
+                                   prepared.compressedClearSize,
+                                   key,
+                                   nonce,
+                                   response,
+                                   written);
+}
+
 /** Appends one socket item upsert and its charged account balances when required. */
 bool append_socket_plug_notification(Scratch& scratch,
                                      const queuez::SocketPlug& socketPlug,

+ 213 - 0
Sunrise/src/server/bap/encrypted/push/snapshot/family4_selection_move.cpp

@@ -5,6 +5,7 @@
 #include <optional>
 #include <span>
 
+#include "../../../../../middleware/datagen/definitions.h"
 #include "../../../../../middleware/datagen/family4/account/account_encoder.h"
 #include "../../../../../middleware/datagen/family4/account/layout.h"
 #include "../../../../../middleware/datagen/family4/account/selection_patch/\
@@ -14,6 +15,7 @@ account_selection_patch_encoder.h"
 #include "../../../../../middleware/datagen/family4/instance/instance_encoder.h"
 #include "../../../../../middleware/datagen/family4/instance/layout.h"
 #include "../../../../../state/runtime/runtime.h"
+#include "../../../../../state/progression/seasonal_experience.h"
 #include "internal.h"
 #include "snapshot_storage.h"
 
@@ -394,6 +396,217 @@ bool prepare_item_state(
     return true;
 }
 
+/** Builds a single-character Family-4 upsert from an uncommitted artifact mask. */
+bool prepare_artifact_purchase(
+    Scratch& scratch,
+    const queuez::EquipmentSwap& update,
+    const state::PendingArtifactPurchase& mutation,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    Prepared& prepared) noexcept {
+    const Reservation reservation = reserve_prior(scratch, prepared);
+    if (reservation.rawWriteOffset > scratch.plaintext.size()
+        || reservation.compressedWriteOffset > scratch.sealed.size()) {
+        return report_failure("artifact_reservation");
+    }
+    const state::AccountState account = state::account_snapshot();
+    if (!mutation.prepared || mutation.accountSoid == 0 || mutation.characterSoid == 0
+        || mutation.accountSoid != account.primarySoid
+        || mutation.characterSoid != update.characterSoid
+        || mutation.characterIndex >= account.characterCount
+        || account.characters[mutation.characterIndex].soid != mutation.characterSoid
+        || !account.characters[mutation.characterIndex].selected) {
+        return report_failure("artifact_mutation");
+    }
+    Resolved selected{};
+    const std::optional<std::size_t> selectedIndex = find_character_index(account);
+    if (!state::account::valid(account) || !selectedIndex.has_value()
+        || *selectedIndex != mutation.characterIndex
+        || !resolve(account, mutation.characterIndex, selected)
+        || selected.characterObjectId != update.characterDefinitionId) {
+        return report_failure("artifact_selection");
+    }
+    const auto rawStorage = std::span(scratch.plaintext).subspan(reservation.rawWriteOffset);
+    if (family4_datagen::character::layout::kObjectSize > rawStorage.size()) {
+        return report_failure("artifact_character_storage");
+    }
+    const auto characterBytes = rawStorage.first(family4_datagen::character::layout::kObjectSize);
+    if (!family4_datagen::character::encode(account.characters[mutation.characterIndex],
+                                            selected.loadout,
+                                            selected.lightEvaluation,
+                                            characterBytes)) {
+        return report_failure("artifact_character_object");
+    }
+    auto& object =
+        *reinterpret_cast<family4_datagen::character::layout::Object*>(characterBytes.data());
+    if (!state::progression::seasonal_experience::apply_artifact_character_state(
+            mutation.afterMask, object.acquiredFlags, object.objectiveValues)
+        || !apply_acquisition_presentation(
+            characterBytes, selected.loadout, acquisitionPresentationRows)) {
+        clear_after(scratch, reservation);
+        return report_failure("artifact_projection");
+    }
+
+    Prepared staged{};
+    staged.rawClearSize =
+        (std::max)(reservation.rawClearSize,
+                   reservation.rawWriteOffset + family4_datagen::character::layout::kObjectSize);
+    std::size_t compressedExtent = reservation.compressedWriteOffset;
+    if (!append_object(scratch,
+                       characterBytes,
+                       update.characterDefinitionId,
+                       update.characterSoid,
+                       staged.objects.front(),
+                       compressedExtent)) {
+        return report_failure("artifact_character_object");
+    }
+    staged.compressedClearSize = (std::max)(reservation.compressedClearSize, compressedExtent);
+    staged.family = middleware::queuez::Family{kAccountFamilyType,
+                                                update.after.family4RootSoid,
+                                                update.after.family4Version,
+                                                0,
+                                                std::span(staged.objects).first(1)};
+    if (!commit(staged, prepared)) {
+        clear_after(scratch, reservation);
+        return report_failure("artifact_commit");
+    }
+    return true;
+}
+
+/** Builds an incremental reset image without re-announcing every resident item. */
+bool prepare_artifact_reset(Scratch& scratch,
+                            const queuez::EquipmentSwap& update,
+                            Prepared& prepared) noexcept {
+    const Reservation reservation = reserve_prior(scratch, prepared);
+    const state::AccountState account = state::account_snapshot();
+    const std::optional<std::size_t> selectedIndex = find_character_index(account);
+    Resolved selected{};
+    std::uint32_t accountDefinitionId = 0;
+    if (!state::account::valid(account) || !selectedIndex.has_value()
+        || !resolve(account, *selectedIndex, selected)
+        || !middleware::datagen::object_id(
+            kAccountFamilyType, middleware::datagen::kAccountSlot, accountDefinitionId)
+        || account.primarySoid != update.after.family4RootSoid
+        || account.characters[*selectedIndex].soid != update.characterSoid
+        || selected.characterObjectId != update.characterDefinitionId) {
+        return report_failure("artifact_reset_state");
+    }
+    bool accountResident = false;
+    for (std::size_t index = 0; index < update.after.family4ResidentCount; ++index) {
+        const auto& resident = update.after.family4Residents[index];
+        accountResident = accountResident
+                          || (resident.definitionId == accountDefinitionId
+                              && resident.objectSoid == account.primarySoid);
+    }
+    const auto rawStorage = std::span(scratch.plaintext).subspan(reservation.rawWriteOffset);
+    const std::size_t required =
+        (std::max)(family4_datagen::account::layout::kObjectSize,
+                   family4_datagen::character::layout::kObjectSize);
+    if (!accountResident || required > rawStorage.size()) {
+        return report_failure("artifact_reset_storage");
+    }
+
+    Prepared staged{};
+    staged.rawClearSize =
+        (std::max)(reservation.rawClearSize, reservation.rawWriteOffset + required);
+    std::size_t compressedExtent = reservation.compressedWriteOffset;
+    const auto accountBytes = rawStorage.first(family4_datagen::account::layout::kObjectSize);
+    if (!family4_datagen::account::encode(account, accountBytes)
+        || !append_object(scratch,
+                          accountBytes,
+                          accountDefinitionId,
+                          account.primarySoid,
+                          staged.objects[0],
+                          compressedExtent)) {
+        clear_after(scratch, reservation);
+        return report_failure("artifact_reset_account");
+    }
+    const auto characterBytes = rawStorage.first(family4_datagen::character::layout::kObjectSize);
+    if (!family4_datagen::character::encode(account.characters[*selectedIndex],
+                                            selected.loadout,
+                                            selected.lightEvaluation,
+                                            characterBytes)
+        || !append_object(scratch,
+                          characterBytes,
+                          update.characterDefinitionId,
+                          update.characterSoid,
+                          staged.objects[1],
+                          compressedExtent)) {
+        clear_after(scratch, reservation);
+        return report_failure("artifact_reset_character");
+    }
+    staged.compressedClearSize = (std::max)(reservation.compressedClearSize, compressedExtent);
+    staged.family = middleware::queuez::Family{kAccountFamilyType,
+                                                update.after.family4RootSoid,
+                                                update.after.family4Version,
+                                                0,
+                                                std::span(staged.objects).first(2)};
+    if (!commit(staged, prepared)) {
+        clear_after(scratch, reservation);
+        return report_failure("artifact_reset_commit");
+    }
+    return true;
+}
+
+/** Builds one exact current item-resident upsert after artifact reset. */
+bool prepare_artifact_item_refresh(Scratch& scratch,
+                                   const queuez::EquipmentSwap& update,
+                                   std::uint64_t instanceSoid,
+                                   Prepared& prepared) noexcept {
+    const Reservation reservation = reserve_prior(scratch, prepared);
+    const state::AccountState account = state::account_snapshot();
+    const std::optional<std::size_t> selectedIndex = find_character_index(account);
+    Resolved selected{};
+    if (instanceSoid == 0 || !state::account::valid(account) || !selectedIndex.has_value()
+        || !resolve(account, *selectedIndex, selected)
+        || selected.characterObjectId != update.characterDefinitionId
+        || update.characterSoid != account.characters[*selectedIndex].soid) {
+        return report_failure("artifact_item_refresh_selection");
+    }
+    family4_datagen::loadout::ResolvedInstances changed{};
+    for (const auto& item : selected.loadout.items) {
+        if (item.instance.instanceSoid != instanceSoid) {
+            continue;
+        }
+        if (changed.itemCount != 0) {
+            return report_failure("artifact_item_refresh_duplicate");
+        }
+        changed.items[changed.itemCount++] = {item.equipmentSlot, item.instance};
+    }
+    if (changed.itemCount != 1) {
+        return report_failure("artifact_item_refresh_missing");
+    }
+    const auto rawStorage = std::span(scratch.plaintext).subspan(reservation.rawWriteOffset);
+    Prepared staged{};
+    std::size_t itemCursor = 0;
+    std::size_t compressedExtent = reservation.compressedWriteOffset;
+    if (!append_items(scratch,
+                      rawStorage,
+                      selected.itemInstanceObjectId,
+                      changed,
+                      0,
+                      staged,
+                      itemCursor,
+                      compressedExtent)
+        || itemCursor != 1) {
+        clear_after(scratch, reservation);
+        return report_failure("artifact_item_refresh_encode");
+    }
+    staged.rawClearSize = (std::max)(reservation.rawClearSize,
+                                     reservation.rawWriteOffset
+                                         + family4_datagen::instance::layout::kObjectSize);
+    staged.compressedClearSize = (std::max)(reservation.compressedClearSize, compressedExtent);
+    staged.family = middleware::queuez::Family{kAccountFamilyType,
+                                                update.after.family4RootSoid,
+                                                update.after.family4Version,
+                                                0,
+                                                std::span(staged.objects).first(1)};
+    if (!commit(staged, prepared)) {
+        clear_after(scratch, reservation);
+        return report_failure("artifact_item_refresh_commit");
+    }
+    return true;
+}
+
 /** Builds a resident item upsert followed by charged account balances when the cost consumes. */
 bool prepare_socket_plug(Scratch& scratch,
                          const queuez::SocketPlug& socketPlug,

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

@@ -132,6 +132,25 @@ prepare_item_state(Scratch& scratch,
                    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
                    Prepared& prepared) noexcept;
 
+/** Builds the selected-character upsert for one uncommitted artifact purchase. */
+[[nodiscard]] bool prepare_artifact_purchase(
+    Scratch& scratch,
+    const queuez::EquipmentSwap& update,
+    const state::PendingArtifactPurchase& mutation,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    Prepared& prepared) noexcept;
+
+/** Builds only the current account and selected-character objects after an artifact reset. */
+[[nodiscard]] bool prepare_artifact_reset(Scratch& scratch,
+                                          const queuez::EquipmentSwap& update,
+                                          Prepared& prepared) noexcept;
+
+/** Builds one current item-resident upsert after an artifact reset cleared its socket. */
+[[nodiscard]] bool prepare_artifact_item_refresh(Scratch& scratch,
+                                                 const queuez::EquipmentSwap& update,
+                                                 std::uint64_t instanceSoid,
+                                                 Prepared& prepared) noexcept;
+
 /**
  * Builds the Family-4 item-instance upsert for one prepared ordinary-socket selection.
  * The character object is unchanged because item identity, placement and mutation generation are

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

@@ -5,6 +5,7 @@
 #include "../../../../core/logging/log.h"
 #include "../../../../middleware/secure_channel/runtime.h"
 #include "../../../../state/account/account_state.h"
+#include "../../../../state/progression/seasonal_experience.h"
 #include "../../../../state/runtime/runtime.h"
 #include "../internal.h"
 #include "../push/activity/activity_keepalive_push.h"
@@ -409,6 +410,116 @@ void fail_seasonal_experience_presentation(Session& session) noexcept {
     return true;
 }
 
+/** Re-publishes only the selected character after an artifact purchase. */
+[[nodiscard]] bool consume_artifact_family4_refresh(Session& session,
+                                                    Scratch& scratch,
+                                                    std::span<std::byte> response,
+                                                    std::size_t& written,
+                                                    bool& touchesScratch) noexcept {
+    if (!session.artifactFamily4RefreshArmed
+        || GetTickCount64() < session.artifactFamily4RefreshDueTick) {
+        return false;
+    }
+    const state::AccountState account = state::account_snapshot();
+    std::size_t selected = account.characterCount;
+    for (std::size_t index = 0; index < account.characterCount; ++index) {
+        if (account.characters[index].selected) {
+            selected = index;
+            break;
+        }
+    }
+    if (!state::account::valid(account) || selected >= account.characterCount) {
+        return false;
+    }
+    state::PendingArtifactPurchase refresh{};
+    refresh.accountSoid = account.primarySoid;
+    refresh.characterSoid = account.characters[selected].soid;
+    refresh.characterIndex = selected;
+    refresh.beforeMask = state::progression::seasonal_experience::artifact_mod_mask();
+    refresh.afterMask = refresh.beforeMask;
+    refresh.prepared = true;
+
+    queuez::EquipmentSwap update{};
+    auto nextSendNonce = session.sendNonce;
+    std::size_t framedSize = 0;
+    touchesScratch = true;
+    if (!queuez::stage_equipment_swap(session.queuez, refresh.characterSoid, update)
+        || !push::append_artifact_purchase_notification(scratch,
+                                                        update,
+                                                        refresh,
+                                                        active_acquisition_presentation_rows(session),
+                                                        state::bap().sessionKey,
+                                                        nextSendNonce,
+                                                        scratch.framed,
+                                                        framedSize)
+        || framedSize == 0 || framedSize > response.size()) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::warn,
+                         "ev=queuez stage=artifact_refresh result=fail");
+        return false;
+    }
+    std::copy_n(scratch.framed.begin(), framedSize, response.begin());
+    written = framedSize;
+    middleware::secure_channel::advance_nonce(nextSendNonce);
+    session.sendNonce = nextSendNonce;
+    session.queuez = update.after;
+    session.artifactFamily4RefreshArmed = false;
+    session.artifactFamily4RefreshDueTick = 0;
+    return true;
+}
+
+/** Publishes one reset-affected item resident per poll using the proven socket-update shape. */
+[[nodiscard]] bool consume_artifact_item_refresh(Session& session,
+                                                 Scratch& scratch,
+                                                 std::span<std::byte> response,
+                                                 std::size_t& written,
+                                                 bool& touchesScratch) noexcept {
+    if (session.artifactResetRefreshCursor >= session.artifactResetRefresh.instanceCount) {
+        session.artifactResetRefresh = {};
+        session.artifactResetRefreshCursor = 0;
+        return false;
+    }
+    const state::AccountState account = state::account_snapshot();
+    std::size_t selected = account.characterCount;
+    for (std::size_t index = 0; index < account.characterCount; ++index) {
+        if (account.characters[index].selected) {
+            selected = index;
+            break;
+        }
+    }
+    if (!state::account::valid(account) || selected >= account.characterCount) {
+        return false;
+    }
+    const std::uint64_t instanceSoid =
+        session.artifactResetRefresh.instanceSoids[session.artifactResetRefreshCursor];
+    queuez::EquipmentSwap update{};
+    auto nextSendNonce = session.sendNonce;
+    std::size_t framedSize = 0;
+    touchesScratch = true;
+    if (!queuez::stage_equipment_swap(
+            session.queuez, account.characters[selected].soid, update)
+        || !push::append_artifact_item_refresh_notification(scratch,
+                                                            update,
+                                                            instanceSoid,
+                                                            state::bap().sessionKey,
+                                                            nextSendNonce,
+                                                            scratch.framed,
+                                                            framedSize)
+        || framedSize == 0 || framedSize > response.size()) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::warn,
+                         "ev=queuez stage=artifact_item_refresh result=fail");
+        return false;
+    }
+    std::copy_n(scratch.framed.begin(), framedSize, response.begin());
+    written = framedSize;
+    middleware::secure_channel::advance_nonce(nextSendNonce);
+    session.sendNonce = nextSendNonce;
+    session.queuez = update.after;
+    ++session.artifactResetRefreshCursor;
+    return true;
+}
+
 } // namespace
 
 /** Publishes the next due reward, refresh, retry, or keepalive. */
@@ -421,6 +532,12 @@ bool consume_deferred(Session& session,
     if (!session.authenticated) {
         return false;
     }
+    if (consume_artifact_family4_refresh(session, scratch, response, written, touchesScratch)) {
+        return true;
+    }
+    if (consume_artifact_item_refresh(session, scratch, response, written, touchesScratch)) {
+        return true;
+    }
     if (consume_account_resync(session, scratch, response, written, touchesScratch)) {
         return true;
     }

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

@@ -209,6 +209,7 @@ bool stage_service_outcome(Scratch& scratch,
     const auto* equipment = transaction_if<EquipmentSwapTransaction>(outcome);
     const auto* subclassSelection = transaction_if<SubclassSelectionTransaction>(outcome);
     const auto* itemState = transaction_if<ItemStateTransaction>(outcome);
+    const auto* artifactPurchase = transaction_if<ArtifactPurchaseTransaction>(outcome);
     const auto* socket = transaction_if<SocketPlugTransaction>(outcome);
     const auto* itemAcquisition = transaction_if<ItemAcquisitionTransaction>(outcome);
     const auto* profileAcquisition = transaction_if<ProfileItemAcquisitionTransaction>(outcome);
@@ -331,6 +332,34 @@ bool stage_service_outcome(Scratch& scratch,
             }
             after = refresh.after;
         }
+    } else if (artifactPurchase != nullptr) {
+        if (artifactPurchase->pending == nullptr) {
+            return false;
+        }
+        const auto& pending = *artifactPurchase->pending;
+        const EquipmentSwap& update = artifactPurchase->update;
+        bool preservedManifest = update.after.family4ResidentCount == before.family4ResidentCount;
+        for (std::size_t index = 0; preservedManifest && index < before.family4ResidentCount;
+             ++index) {
+            preservedManifest = update.after.family4Residents[index].objectSoid
+                                    == before.family4Residents[index].objectSoid
+                                && update.after.family4Residents[index].definitionId
+                                       == before.family4Residents[index].definitionId;
+        }
+        if (!valid(update.after) || !preservedManifest
+            || update.characterSoid != pending.characterSoid
+            || update.after.family4RootSoid != before.family4RootSoid
+            || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
+            || update.after.family4Version != before.family4Version + 1
+            || !push::append_artifact_purchase_notification(
+                scratch, update, pending, presentationRows, key, nonce, response, written)) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             "ev=queuez stage=artifact result=fail");
+            return false;
+        }
+        middleware::secure_channel::advance_nonce(nonce);
+        after = update.after;
     } else if (itemState != nullptr) {
         // Item-state bits live in the selected-character inventory row. Publish only that
         // resident character body; item-instance, appearance, roster and manifest are unchanged.
@@ -616,6 +645,26 @@ bool stage_service_outcome(Scratch& scratch,
                              "ev=ws2400 stage=queuez_reward result=fail");
             return false;
         }
+    } else if (outcome.hasArtifactReset) {
+        const state::AccountState account = state::account_snapshot();
+        std::uint64_t selectedCharacter = 0;
+        for (std::size_t index = 0; index < account.characterCount; ++index) {
+            if (account.characters[index].selected) {
+                selectedCharacter = account.characters[index].soid;
+                break;
+            }
+        }
+        EquipmentSwap reset{};
+        if (selectedCharacter == 0 || !stage_equipment_swap(before, selectedCharacter, reset)
+            || !push::append_artifact_reset_notification(
+                scratch, reset, key, nonce, response, written)) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             "ev=ws901 stage=artifact_reset_resync result=fail");
+            return true;
+        }
+        middleware::secure_channel::advance_nonce(nonce);
+        after = reset.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.

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

@@ -243,6 +243,13 @@ bool commit(ServiceOutcome& outcome, Publication& publication) noexcept {
         return report_commit(state::commit_item_state(*transaction->pending),
                              "ev=item_state stage=transaction_commit result=fail");
     }
+    if (auto* transaction = transaction_if<ArtifactPurchaseTransaction>(outcome)) {
+        if (transaction->pending == nullptr) {
+            return false;
+        }
+        return report_commit(state::commit_artifact_mod_unlock(*transaction->pending),
+                             "ev=ws901 stage=transaction_commit result=fail");
+    }
     if (auto* transaction = transaction_if<ProfileItemAcquisitionTransaction>(outcome)) {
         if (transaction->pending == nullptr) {
             return false;

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

@@ -153,6 +153,12 @@ struct Session {
     bool bannerRepushArmed{};
     bool accountMutationPublished{};
     bool accountResyncArmed{};
+    /** Publishes changed artifact overrides after its Web Service reply has left this call. */
+    bool artifactRefreshArmed{};
+    bool artifactFamily4RefreshArmed{};
+    std::uint64_t artifactFamily4RefreshDueTick{};
+    state::ArtifactResetResult artifactResetRefresh{};
+    std::size_t artifactResetRefreshCursor{};
     std::uint8_t acquisitionPresentationRowCount{};
     bool abilityRefreshArmed{};
     std::array<std::byte, state::kBapNonceSize> sendNonce{};

+ 72 - 1
Sunrise/src/server/web_service/web_service_runtime.cpp

@@ -55,6 +55,10 @@ constexpr std::size_t kEchoLineCapacity = 64;
  * Any non-zero value refuses. Zero is the success code, so it must not be used here.
  */
 constexpr std::int32_t kPurchaseRefusedCode = 1;
+/** Season of Arrivals artifact vendor row in the installed build's vendor index. */
+constexpr std::int16_t kArtifactVendorIndex = 430;
+constexpr std::uint16_t kArtifactSaleCount = 26;
+constexpr std::int32_t kArtifactResetGlimmerCost = 20'000;
 
 /**
  * Reads the server's own clock for the purchase clock rule.
@@ -113,6 +117,72 @@ constexpr std::int32_t kPurchaseRefusedCode = 1;
         written);
 }
 
+/** Accepts one affordable, unlocked-tier artifact mod and reports the local purchase effect. */
+[[nodiscard]] bool purchase_artifact_mod(const middleware::web_service::Message& message,
+                                         std::span<std::byte> response,
+                                         std::size_t& written,
+                                         Outcome& outcome) noexcept {
+    namespace purchase_codec = middleware::web_service::messages::opcode901;
+    purchase_codec::Request purchase{};
+    if (!purchase_codec::parse_request(message, purchase)
+        || purchase.vendorIndex != kArtifactVendorIndex || purchase.saleIndex < 0
+        || purchase.saleIndex >= static_cast<std::int16_t>(kArtifactSaleCount)) {
+        return false;
+    }
+    const auto saleIndex = static_cast<std::uint16_t>(purchase.saleIndex);
+    if (saleIndex == 5) {
+        state::ArtifactResetResult reset{};
+        if (!state::reset_artifact(kArtifactResetGlimmerCost, reset)) {
+            return false;
+        }
+        middleware::web_service::StatusResponse status{};
+        status.trailingBool = true;
+        const bool encoded = middleware::web_service::encode_response(
+            message,
+            middleware::web_service::ResponseShape::statusPairWithBool,
+            status,
+            response,
+            written);
+        outcome.hasArtifactReset = encoded;
+        if (encoded) {
+            outcome.artifactReset = reset;
+        }
+        return encoded;
+    }
+    auto* mutation = emplace_mutation<state::PendingArtifactPurchase>(outcome);
+    if (mutation == nullptr || !state::prepare_artifact_mod_unlock(saleIndex, *mutation)) {
+        clear_mutation(outcome);
+        return false;
+    }
+    std::array<char, kPurchaseLineCapacity> line{};
+    const int length =
+        std::snprintf(line.data(),
+                      line.size(),
+                      "ev=ws901 stage=artifact result=ok vendor=%d sale=%d policy=%s",
+                      static_cast<int>(purchase.vendorIndex),
+                      static_cast<int>(purchase.saleIndex),
+                      purchase_codec::clock_policy_name(
+                          purchase_codec::check_clock(purchase, server_clock_seconds())));
+    if (length > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(length)});
+    }
+    middleware::web_service::StatusResponse status{};
+    status.trailingBool = true;
+    const bool encoded = middleware::web_service::encode_response(
+        message,
+        middleware::web_service::ResponseShape::statusPairWithBool,
+        status,
+        response,
+        written);
+    if (!encoded) {
+        clear_mutation(outcome);
+        return false;
+    }
+    return true;
+}
+
 /**
  * Answers a request whose own codec refused with the bare correlated echo.
  * The Client matches on the echoed transaction id. A missing body is worse than a thin one. It
@@ -221,7 +291,8 @@ bool consume(std::span<const std::byte> request,
 
     // Runs before the shared response-shape path, which would answer the success status.
     if (message.opcode == middleware::web_service::messages::opcode901::kOpcode) {
-        return refuse_purchase(message, response, written)
+        return purchase_artifact_mod(message, response, written, outcome)
+               || refuse_purchase(message, response, written)
                || encode_echo(message, response, written);
     }
 

+ 4 - 0
Sunrise/src/server/web_service/web_service_runtime.h

@@ -26,6 +26,9 @@ struct Outcome {
     bool hasSelectedCharacter{};
     bool selectedCharacterChanged{};
     std::uint64_t selectedCharacterSoid{};
+    /** Reset is precommitted because it changes persistence and account currency together. */
+    bool hasArtifactReset{};
+    state::ArtifactResetResult artifactReset{};
     /** A request prepares at most one State mutation and allocates only that exact payload. */
     using Mutation = std::variant<std::monostate,
                                   std::unique_ptr<state::PendingEquipmentSwap>,
@@ -35,6 +38,7 @@ struct Outcome {
                                   std::unique_ptr<state::PendingItemDismantle>,
                                   std::unique_ptr<state::PendingSocketPlug>,
                                   std::unique_ptr<state::PendingItemState>,
+                                  std::unique_ptr<state::PendingArtifactPurchase>,
                                   std::unique_ptr<state::PendingRecordRewardGrant>,
                                   std::unique_ptr<state::PendingSeasonPassReward>>;
     Mutation mutation{};

+ 1 - 0
Sunrise/src/state/account/inventory/inventory_state.cpp

@@ -31,6 +31,7 @@ constexpr std::array<SlotName, kEquipmentSlotCount> kSlotNames{{
     {"emblem", EquipmentSlot::emblem},
     {"emote", EquipmentSlot::emote},
     {"finisher", EquipmentSlot::finisher},
+    {"artifact", EquipmentSlot::artifact},
 }};
 
 } // namespace

+ 2 - 1
Sunrise/src/state/account/inventory/inventory_state.h

@@ -8,7 +8,7 @@
 
 namespace sunrise::state::account::inventory {
 
-/** Authored equipment exposes the 16 named slots the first State supports. */
+/** Authored equipment exposes every named slot currently represented by State. */
 enum class EquipmentSlot : std::uint8_t {
     kinetic,
     energy,
@@ -26,6 +26,7 @@ enum class EquipmentSlot : std::uint8_t {
     emblem,
     emote,
     finisher,
+    artifact,
     count,
 };
 

+ 2 - 1
Sunrise/src/state/equipment/light/resolution/configured_equipment_light_resolver.cpp

@@ -8,6 +8,7 @@
 #include <span>
 
 #include "../../../build_data/runtime.h"
+#include "../../../progression/seasonal_experience.h"
 #include "../calculation/equipment_light_calculation.h"
 
 namespace sunrise::state::equipment::light::resolution {
@@ -174,7 +175,7 @@ bool character_light(const AccountState& account,
     if (!calculation::evaluate(scores, SlotScores{}, std::span<const SlotScores>{}, evaluation)) {
         return false;
     }
-    light = evaluation.average;
+    light = evaluation.average + progression::seasonal_experience::artifact_power_bonus();
     return true;
 }
 

+ 224 - 10
Sunrise/src/state/progression/seasonal_experience.cpp

@@ -11,6 +11,7 @@
 #include <string_view>
 
 #include "../../core/filesystem/path.h"
+#include "../investment/investment.h"
 #include "../unlocks/definition.h"
 #include "season_pass_reward_catalog.h"
 
@@ -19,22 +20,68 @@ namespace {
 
 constexpr std::wstring_view kFileSuffix = L"\\cache\\seasonal_experience.bin";
 constexpr std::wstring_view kTemporarySuffix = L".tmp";
-constexpr std::array<char, 8> kLegacyMagic{'S', 'N', 'R', 'S', 'X', 'P', '0', '1'};
-constexpr std::array<char, 8> kMagic{'S', 'N', 'R', 'S', 'X', 'P', '0', '2'};
+constexpr std::array<char, 8> kV1Magic{'S', 'N', 'R', 'S', 'X', 'P', '0', '1'};
+constexpr std::array<char, 8> kV2Magic{'S', 'N', 'R', 'S', 'X', 'P', '0', '2'};
+constexpr std::array<char, 8> kMagic{'S', 'N', 'R', 'S', 'X', 'P', '0', '3'};
 constexpr std::size_t kRewardCount = season_pass::kRewards.size();
 constexpr std::size_t kRewardClaimByteCount = (kRewardCount + 7U) / 8U;
-constexpr std::size_t kLegacyDocumentSize = kLegacyMagic.size() + sizeof(std::int32_t);
-constexpr std::size_t kDocumentSize = kLegacyDocumentSize + kRewardClaimByteCount;
+constexpr std::size_t kV1DocumentSize = kV1Magic.size() + sizeof(std::int32_t);
+constexpr std::size_t kV2DocumentSize = kV1DocumentSize + kRewardClaimByteCount;
+constexpr std::size_t kDocumentSize = kV2DocumentSize + sizeof(std::uint32_t);
 constexpr std::int32_t kExperiencePerRank = 100'000;
 constexpr std::uint16_t kMaximumRank = 100;
+constexpr std::int64_t kFirstArtifactPowerCost = 55'000;
+constexpr std::int64_t kArtifactPowerCostStep = 110'000;
+constexpr std::uint16_t kArtifactPowerBonusSlot = 602;
+constexpr std::uint16_t kArtifactPointsUsedSlot = 604;
+constexpr std::uint16_t kArtifactPointsEarnedSlot = 605;
+constexpr std::array<std::int32_t, 12> kArtifactPointCosts{0,
+                                                           40'000,
+                                                           60'000,
+                                                           100'000,
+                                                           200'000,
+                                                           246'000,
+                                                           274'000,
+                                                           420'000,
+                                                           500'000,
+                                                           600'000,
+                                                           790'000,
+                                                           900'000};
+constexpr std::array<std::uint16_t, 26> kArtifactModFlags{
+    1428, 1429, 1430, 1431, 1432, 0,    1393, 1394, 1395, 1396, 1397, 1388, 1389,
+    1390, 1391, 1392, 1398, 1399, 1400, 1401, 1402, 1403, 1404, 1405, 1406, 1407};
+/** Character acquired-flag mapping rows for each artifact vendor row. */
+constexpr std::array<std::uint16_t, 26> kArtifactModCharacterRows{
+    164, 165, 166, 167, 168, 0,   149, 150, 151, 152, 153, 144, 145,
+    146, 147, 148, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163};
+/** Character objective mapping row whose destination is artifact value slot 604. */
+constexpr std::uint16_t kArtifactPointsUsedCharacterRow = 38;
 
 std::mutex g_lock;
 std::int32_t g_experience{};
 std::array<std::uint8_t, kRewardClaimByteCount> g_rewardClaims{};
+std::uint32_t g_artifactMods{};
 core::path::Buffer g_path{};
 bool g_pathReady{};
 bool g_persistenceRequired{};
 
+[[nodiscard]] constexpr std::uint16_t
+artifact_power_bonus_for(std::int32_t experience) noexcept {
+    std::int64_t remaining = (std::max)(experience, 0);
+    std::uint16_t bonus = 1;
+    std::int64_t nextCost = kFirstArtifactPowerCost;
+    while (remaining >= nextCost && bonus < (std::numeric_limits<std::uint16_t>::max)()) {
+        remaining -= nextCost;
+        ++bonus;
+        nextCost += kArtifactPowerCostStep;
+    }
+    return bonus;
+}
+
+static_assert(artifact_power_bonus_for(0) == 1);
+static_assert(artifact_power_bonus_for(9'900'000) == 14);
+static_assert(artifact_power_bonus_for(19'855'000) == 20);
+
 [[nodiscard]] constexpr std::size_t reward_claim_byte(std::uint16_t rewardIndex) noexcept {
     return rewardIndex >> 3U;
 }
@@ -43,6 +90,97 @@ bool g_persistenceRequired{};
     return static_cast<std::uint8_t>(1U << (rewardIndex & 7U));
 }
 
+[[nodiscard]] std::uint16_t artifact_points_earned_locked() noexcept {
+    std::int32_t remaining = g_experience;
+    std::uint16_t points = 0;
+    for (const std::int32_t cost : kArtifactPointCosts) {
+        if (remaining < cost) {
+            break;
+        }
+        remaining -= cost;
+        ++points;
+    }
+    return points;
+}
+
+[[nodiscard]] std::uint16_t artifact_points_used(std::uint32_t artifactMods) noexcept {
+    std::uint16_t points = 0;
+    for (std::uint16_t row = 0; row < kArtifactModFlags.size(); ++row) {
+        points += static_cast<std::uint16_t>((artifactMods & (1U << row)) != 0);
+    }
+    return points;
+}
+
+[[nodiscard]] std::uint16_t artifact_points_used_locked() noexcept {
+    return artifact_points_used(g_artifactMods);
+}
+
+[[nodiscard]] bool
+upsert_flag(Family5State& family, std::uint16_t slot, std::uint8_t value) noexcept {
+    for (std::size_t index = 0; index < family.flagCount; ++index) {
+        if (family.flags[index].slot == slot) {
+            family.flags[index].value = value;
+            return true;
+        }
+    }
+    if (family.flagCount >= family.flags.size()) {
+        return false;
+    }
+    family.flags[family.flagCount++] = UnlockFlagOverride{slot, value};
+    return true;
+}
+
+[[nodiscard]] bool
+upsert_value(Family5State& family, std::uint16_t slot, std::int32_t value) noexcept {
+    for (std::size_t index = 0; index < family.valueCount; ++index) {
+        if (family.values[index].slot == slot) {
+            family.values[index].value = value;
+            return true;
+        }
+    }
+    if (family.valueCount >= family.values.size()) {
+        return false;
+    }
+    family.values[family.valueCount++] = UnlockValueOverride{slot, value};
+    return true;
+}
+
+[[nodiscard]] bool project_artifact_state_locked(Family5State& family) noexcept {
+    for (std::uint16_t row = 0; row < kArtifactModFlags.size(); ++row) {
+        const std::uint16_t slot = kArtifactModFlags[row];
+        if (slot != 0
+            && !upsert_flag(family,
+                            slot,
+                            (g_artifactMods & (1U << row)) != 0 ? unlocks::kFlagSet
+                                                                : unlocks::kFlagClear)) {
+            return false;
+        }
+    }
+    return upsert_value(family, kArtifactPowerBonusSlot, artifact_power_bonus_for(g_experience))
+           && upsert_value(family, kArtifactPointsUsedSlot, artifact_points_used_locked())
+           && upsert_value(family, kArtifactPointsEarnedSlot, artifact_points_earned_locked());
+}
+
+[[nodiscard]] bool
+project_artifact_character_state_locked(std::uint32_t artifactMods,
+                                        std::span<std::byte> acquiredFlags,
+                                        std::span<std::int32_t> objectiveValues) noexcept {
+    if (acquiredFlags.size()
+            <= *std::max_element(kArtifactModCharacterRows.begin(), kArtifactModCharacterRows.end())
+        || objectiveValues.size() <= kArtifactPointsUsedCharacterRow) {
+        return false;
+    }
+    for (std::uint16_t row = 0; row < kArtifactModCharacterRows.size(); ++row) {
+        const std::uint16_t mapped = kArtifactModCharacterRows[row];
+        if (mapped != 0) {
+            acquiredFlags[mapped] = static_cast<std::byte>(
+                (artifactMods & (1U << row)) != 0 ? unlocks::kFlagSet : unlocks::kFlagClear);
+        }
+    }
+    objectiveValues[kArtifactPointsUsedCharacterRow] = artifact_points_used(artifactMods);
+    return true;
+}
+
 [[nodiscard]] bool store_locked() noexcept {
     if (!g_pathReady) {
         return !g_persistenceRequired;
@@ -53,6 +191,7 @@ bool g_persistenceRequired{};
     std::memcpy(document.data() + kMagic.size() + sizeof g_experience,
                 g_rewardClaims.data(),
                 g_rewardClaims.size());
+    std::memcpy(document.data() + kV2DocumentSize, &g_artifactMods, sizeof g_artifactMods);
     core::path::Buffer temporaryPath = g_path;
     if (!core::path::append(temporaryPath, kTemporarySuffix)) {
         return false;
@@ -107,11 +246,13 @@ void load_locked() noexcept {
     const bool current = sized && fileSize.QuadPart == static_cast<std::int64_t>(document.size())
                          && readable && read == document.size()
                          && std::memcmp(document.data(), kMagic.data(), kMagic.size()) == 0;
-    const bool legacy =
-        sized && fileSize.QuadPart == static_cast<std::int64_t>(kLegacyDocumentSize) && readable
-        && read == kLegacyDocumentSize
-        && std::memcmp(document.data(), kLegacyMagic.data(), kLegacyMagic.size()) == 0;
-    if (!current && !legacy) {
+    const bool v2 = sized && fileSize.QuadPart == static_cast<std::int64_t>(kV2DocumentSize)
+                    && readable && read == kV2DocumentSize
+                    && std::memcmp(document.data(), kV2Magic.data(), kV2Magic.size()) == 0;
+    const bool v1 = sized && fileSize.QuadPart == static_cast<std::int64_t>(kV1DocumentSize)
+                    && readable && read == kV1DocumentSize
+                    && std::memcmp(document.data(), kV1Magic.data(), kV1Magic.size()) == 0;
+    if (!current && !v2 && !v1) {
         return;
     }
     std::memcpy(&restored, document.data() + kMagic.size(), sizeof restored);
@@ -119,11 +260,14 @@ void load_locked() noexcept {
         return;
     }
     g_experience = restored;
-    if (current) {
+    if (current || v2) {
         std::memcpy(g_rewardClaims.data(),
                     document.data() + kMagic.size() + sizeof g_experience,
                     g_rewardClaims.size());
     }
+    if (current) {
+        std::memcpy(&g_artifactMods, document.data() + kV2DocumentSize, sizeof g_artifactMods);
+    }
 }
 
 } // namespace
@@ -132,6 +276,7 @@ bool initialize(void* module) noexcept {
     const std::lock_guard<std::mutex> guard(g_lock);
     g_experience = 0;
     g_rewardClaims.fill(0);
+    g_artifactMods = 0;
     g_path = {};
     g_pathReady = false;
     g_persistenceRequired = module != nullptr;
@@ -151,6 +296,7 @@ void shutdown() noexcept {
     const std::lock_guard<std::mutex> guard(g_lock);
     g_experience = 0;
     g_rewardClaims.fill(0);
+    g_artifactMods = 0;
     g_path = {};
     g_pathReady = false;
     g_persistenceRequired = false;
@@ -185,6 +331,11 @@ std::uint16_t rank() noexcept {
         (std::min)(static_cast<std::int32_t>(kMaximumRank), earnedRanks + 1));
 }
 
+std::uint16_t artifact_power_bonus() noexcept {
+    const std::lock_guard<std::mutex> guard(g_lock);
+    return artifact_power_bonus_for(g_experience);
+}
+
 bool reward_claimed(std::uint16_t rewardIndex) noexcept {
     if (rewardIndex >= kRewardCount) {
         return false;
@@ -230,4 +381,67 @@ bool apply_reward_claims(std::span<std::uint8_t> acquiredFlags) noexcept {
     return true;
 }
 
+bool prepare_artifact_mod_unlock(std::uint16_t saleIndex,
+                                 std::uint32_t& expected,
+                                 std::uint32_t& replacement) noexcept {
+    expected = 0;
+    replacement = 0;
+    if (saleIndex >= kArtifactModFlags.size() || kArtifactModFlags[saleIndex] == 0) {
+        return false;
+    }
+    const std::lock_guard<std::mutex> guard(g_lock);
+    const std::uint32_t mask = 1U << saleIndex;
+    const std::uint16_t used = artifact_points_used_locked();
+    const std::uint16_t tierRequirement = saleIndex < 6    ? 0
+                                          : saleIndex < 11 ? 1
+                                          : saleIndex < 16 ? 4
+                                          : saleIndex < 21 ? 7
+                                                           : 10;
+    if ((g_artifactMods & mask) != 0 || used >= artifact_points_earned_locked()
+        || used < tierRequirement) {
+        return false;
+    }
+    expected = g_artifactMods;
+    replacement = g_artifactMods | mask;
+    return true;
+}
+
+std::uint32_t artifact_mod_mask() noexcept {
+    const std::lock_guard<std::mutex> guard(g_lock);
+    return g_artifactMods;
+}
+
+bool replace_artifact_mod_mask(std::uint32_t expected, std::uint32_t replacement) noexcept {
+    const std::lock_guard<std::mutex> guard(g_lock);
+    if (g_artifactMods != expected) {
+        return false;
+    }
+    g_artifactMods = replacement;
+    if (store_locked()) {
+        return true;
+    }
+    g_artifactMods = expected;
+    return false;
+}
+
+bool apply_artifact_state(Family5State& family) noexcept {
+    const std::lock_guard<std::mutex> guard(g_lock);
+    return project_artifact_state_locked(family);
+}
+
+bool apply_artifact_character_state(std::span<std::byte> acquiredFlags,
+                                    std::span<std::int32_t> objectiveValues) noexcept {
+    const std::lock_guard<std::mutex> guard(g_lock);
+    return project_artifact_character_state_locked(
+        g_artifactMods, acquiredFlags, objectiveValues);
+}
+
+bool apply_artifact_character_state(std::uint32_t artifactMask,
+                                    std::span<std::byte> acquiredFlags,
+                                    std::span<std::int32_t> objectiveValues) noexcept {
+    const std::lock_guard<std::mutex> guard(g_lock);
+    return project_artifact_character_state_locked(
+        artifactMask, acquiredFlags, objectiveValues);
+}
+
 } // namespace sunrise::state::progression::seasonal_experience

+ 35 - 0
Sunrise/src/state/progression/seasonal_experience.h

@@ -1,10 +1,18 @@
 #pragma once
 
+#include <cstddef>
 #include <cstdint>
 #include <span>
 
+namespace sunrise::state {
+struct Family5State;
+}
+
 namespace sunrise::state::progression::seasonal_experience {
 
+inline constexpr std::uint16_t kArtifactPowerProgressionDefinitionIndex = 38;
+inline constexpr std::uint16_t kArtifactUnlockProgressionDefinitionIndex = 39;
+
 /** Resolves persistent storage and restores earned seasonal XP. */
 [[nodiscard]] bool initialize(void* module) noexcept;
 
@@ -20,6 +28,9 @@ void shutdown() noexcept;
 /** Returns the one-based Season of Arrivals rank earned by the persisted XP total. */
 [[nodiscard]] std::uint16_t rank() noexcept;
 
+/** Returns the account-wide Power bonus earned by the seasonal artifact. */
+[[nodiscard]] std::uint16_t artifact_power_bonus() noexcept;
+
 /** Returns whether one native Season of Arrivals reward row was already claimed. */
 [[nodiscard]] bool reward_claimed(std::uint16_t rewardIndex) noexcept;
 
@@ -29,4 +40,28 @@ void shutdown() noexcept;
 /** Publishes persisted Season Pass claims into their mapped account acquired-flag bytes. */
 [[nodiscard]] bool apply_reward_claims(std::span<std::uint8_t> acquiredFlags) noexcept;
 
+/** Validates one artifact vendor row and returns its exact uncommitted mask transition. */
+[[nodiscard]] bool prepare_artifact_mod_unlock(std::uint16_t saleIndex,
+                                               std::uint32_t& expected,
+                                               std::uint32_t& replacement) noexcept;
+
+/** Returns the persisted artifact purchase mask. */
+[[nodiscard]] std::uint32_t artifact_mod_mask() noexcept;
+
+/** Replaces the exact persisted mask, refusing if another action changed it first. */
+[[nodiscard]] bool replace_artifact_mod_mask(std::uint32_t expected,
+                                             std::uint32_t replacement) noexcept;
+
+/** Projects artifact counters and ownership into the global Family-5 override object. */
+[[nodiscard]] bool apply_artifact_state(Family5State& family) noexcept;
+
+/** Projects artifact ownership and spent points into the live Family-4 character object. */
+[[nodiscard]] bool apply_artifact_character_state(std::span<std::byte> acquiredFlags,
+                                                  std::span<std::int32_t> objectiveValues) noexcept;
+
+/** Projects an explicit uncommitted artifact mask into a Family-4 character object. */
+[[nodiscard]] bool apply_artifact_character_state(std::uint32_t artifactMask,
+                                                  std::span<std::byte> acquiredFlags,
+                                                  std::span<std::int32_t> objectiveValues) noexcept;
+
 } // namespace sunrise::state::progression::seasonal_experience

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

@@ -310,6 +310,26 @@ struct PendingItemState {
     bool prepared{};
 };
 
+/** Prepared artifact ownership transition for the selected character. */
+struct PendingArtifactPurchase {
+    std::uint64_t accountSoid{};
+    std::uint64_t characterSoid{};
+    std::size_t characterIndex{};
+    std::uint32_t beforeMask{};
+    std::uint32_t afterMask{};
+    std::uint16_t saleIndex{};
+    bool prepared{};
+};
+
+/** Item residents whose authored artifact sockets were cleared by one reset. */
+struct ArtifactResetResult {
+    std::array<std::uint64_t,
+               account::inventory::kEquipmentSlotCount
+                   + account::inventory::kCharacterItemCapacity>
+        instanceSoids{};
+    std::size_t instanceCount{};
+};
+
 /**
  * Loads cached build data and generates secrets with Sunrise's authored activity defaults.
  * @param module Loaded Sunrise module, or null to disable disk persistence.
@@ -576,4 +596,18 @@ commit_profile_item_acquisition(PendingProfileItemAcquisition& mutation) noexcep
 /** @return A copy of the evaluated content state, read under the lock. */
 [[nodiscard]] InvestmentState investment_snapshot() noexcept;
 
+/** Refreshes artifact XP-derived global values after seasonal XP changes. */
+[[nodiscard]] bool refresh_artifact_progression() noexcept;
+
+/** Prepares one artifact purchase without changing persistent state. */
+[[nodiscard]] bool prepare_artifact_mod_unlock(std::uint16_t saleIndex,
+                                               PendingArtifactPurchase& mutation) noexcept;
+
+/** Commits one prepared artifact purchase if its character and mask remain current. */
+[[nodiscard]] bool commit_artifact_mod_unlock(PendingArtifactPurchase& mutation) noexcept;
+
+/** Charges Glimmer, removes artifact mods, and refunds every spent unlock point. */
+[[nodiscard]] bool reset_artifact(std::int32_t glimmerCost,
+                                  ArtifactResetResult& result) noexcept;
+
 } // namespace sunrise::state

+ 4 - 1
Sunrise/src/state/runtime/state_account_equipment_runtime.cpp

@@ -56,7 +56,7 @@ namespace family4_loadout = middleware::datagen::family4::loadout;
     return true;
 }
 
-/** Maps the 16 proven native equipment positions onto their stable authored State slots. */
+/** Maps supported native equipment positions onto stable authored State slots. */
 [[nodiscard]] bool semantic_equipment_slot(std::uint8_t nativeSlot,
                                            std::size_t& semanticIndex) noexcept {
     using EquipmentSlot = authored_inventory::EquipmentSlot;
@@ -110,6 +110,9 @@ namespace family4_loadout = middleware::datagen::family4::loadout;
     case 17:
         semanticSlot = EquipmentSlot::finisher;
         break;
+    case 18:
+        semanticSlot = EquipmentSlot::artifact;
+        break;
     default:
         return false;
     }

+ 228 - 1
Sunrise/src/state/runtime/state_runtime.cpp

@@ -313,9 +313,236 @@ const BapState& bap() noexcept {
 /** @return A copy of the evaluated content state, read under the lock. */
 InvestmentState investment_snapshot() noexcept {
     AcquireSRWLockShared(&runtime::storage::g_stateLock);
-    const InvestmentState snapshot = runtime::storage::g_state.investment;
+    InvestmentState snapshot = runtime::storage::g_state.investment;
     ReleaseSRWLockShared(&runtime::storage::g_stateLock);
+    (void)progression::seasonal_experience::apply_artifact_state(snapshot.family5);
     return snapshot;
 }
 
+bool refresh_artifact_progression() noexcept {
+    // Artifact progress is projected into each Family-4 account image at encode time.
+    return true;
+}
+
+bool prepare_artifact_mod_unlock(std::uint16_t saleIndex,
+                                 PendingArtifactPurchase& mutation) noexcept {
+    mutation = {};
+    const AccountState account = account_snapshot();
+    if (!account::valid(account)) {
+        return false;
+    }
+    std::size_t selected = account.characterCount;
+    for (std::size_t index = 0; index < account.characterCount; ++index) {
+        if (account.characters[index].selected) {
+            selected = index;
+            break;
+        }
+    }
+    if (selected >= account.characterCount
+        || !progression::seasonal_experience::prepare_artifact_mod_unlock(
+            saleIndex, mutation.beforeMask, mutation.afterMask)) {
+        mutation = {};
+        return false;
+    }
+    mutation.accountSoid = account.primarySoid;
+    mutation.characterSoid = account.characters[selected].soid;
+    mutation.characterIndex = selected;
+    mutation.saleIndex = saleIndex;
+    mutation.prepared = true;
+    return true;
+}
+
+bool commit_artifact_mod_unlock(PendingArtifactPurchase& mutation) noexcept {
+    const PendingArtifactPurchase prepared = mutation;
+    mutation = {};
+    const AccountState account = account_snapshot();
+    if (!prepared.prepared || prepared.accountSoid == 0 || prepared.characterSoid == 0
+        || prepared.beforeMask == prepared.afterMask
+        || prepared.characterIndex >= account.characterCount
+        || account.primarySoid != prepared.accountSoid
+        || account.characters[prepared.characterIndex].soid != prepared.characterSoid
+        || !account.characters[prepared.characterIndex].selected) {
+        return false;
+    }
+    return progression::seasonal_experience::replace_artifact_mod_mask(
+        prepared.beforeMask, prepared.afterMask);
+}
+
+bool reset_artifact(std::int32_t glimmerCost, ArtifactResetResult& result) noexcept {
+    result = {};
+    constexpr std::uint32_t kGlimmerHash = 3159615086U;
+    constexpr std::array<std::uint32_t, 25> kArtifactModHashes{
+        715026181U,  715026182U,  715026183U,  715026176U,  715026177U,
+        3213968582U, 3213968581U, 3213968580U, 3213968579U, 3213968578U,
+        3465659109U, 3465659110U, 3465659111U, 3465659104U, 3465659105U,
+        3175764264U, 3175764267U, 3175764266U, 3175764269U, 3175764268U,
+        4186620519U, 4186620516U, 4186620517U, 4186620514U, 4186620515U};
+    if (glimmerCost <= 0) {
+        return false;
+    }
+
+    const std::uint32_t previousMods = progression::seasonal_experience::artifact_mod_mask();
+    const AccountState before = account_snapshot();
+    if (previousMods == 0 || !account::valid(before)
+        || !runtime::detail::valid_profile_inventory(before)) {
+        return false;
+    }
+
+    std::int32_t remainingCost = glimmerCost;
+    auto compacted = before.profileItems;
+    std::size_t compactedCount = 0;
+    for (std::size_t index = 0; index < before.profileItemCount; ++index) {
+        auto item = before.profileItems[index];
+        if (item.definitionHash == kGlimmerHash && remainingCost > 0) {
+            const std::int32_t spent = (std::min)(item.quantity, remainingCost);
+            item.quantity -= spent;
+            remainingCost -= spent;
+        }
+        const bool artifactMod =
+            std::find(kArtifactModHashes.begin(), kArtifactModHashes.end(), item.definitionHash)
+            != kArtifactModHashes.end();
+        if (item.quantity > 0 && !artifactMod) {
+            compacted[compactedCount++] = item;
+        }
+    }
+    if (remainingCost != 0) {
+        return false;
+    }
+    std::fill(compacted.begin() + static_cast<std::ptrdiff_t>(compactedCount),
+              compacted.end(),
+              account::inventory::ProfileItem{});
+
+    std::int32_t serial = 0;
+    for (std::size_t index = 0; index < before.profileItemCount; ++index) {
+        serial = (std::max)(serial, before.profileItems[index].mutationSerial);
+    }
+    const auto same = [](const auto& left, const auto& right) noexcept {
+        return left.instanceSoid == right.instanceSoid
+               && left.definitionHash == right.definitionHash && left.quantity == right.quantity
+               && left.mutationSerial == right.mutationSerial;
+    };
+    std::size_t changedRows = 0;
+    for (std::size_t index = 0; index < compactedCount; ++index) {
+        changedRows += static_cast<std::size_t>(index >= before.profileItemCount
+                                                || !same(compacted[index],
+                                                         before.profileItems[index]));
+    }
+    if (changedRows > static_cast<std::size_t>((std::numeric_limits<std::int32_t>::max)()
+                                               - serial)) {
+        return false;
+    }
+    for (std::size_t index = 0; index < compactedCount; ++index) {
+        if (index >= before.profileItemCount
+            || !same(compacted[index], before.profileItems[index])) {
+            compacted[index].mutationSerial = ++serial;
+        }
+    }
+
+    AccountState candidate = before;
+    candidate.profileItems = compacted;
+    candidate.profileItemCount = compactedCount;
+    const auto clearArtifactSockets = [&](account::inventory::Item& item) noexcept {
+        if (item.sockets.policy != account::inventory::SocketPolicy::authored) {
+            return true;
+        }
+        build_data::items::Definition base{};
+        build_data::items::details::Definition detail{};
+        if (!build_data::find_item_definition_hash(item.definitionHash, base)
+            || !build_data::find_configured_item_detail(base.definitionIndex, detail)
+            || detail.definitionIndex != base.definitionIndex
+            || item.sockets.plugCount != detail.ordinarySocketCount) {
+            return false;
+        }
+        for (std::size_t lane = 0; lane < item.sockets.plugCount; ++lane) {
+            const auto& plug = item.sockets.plugs[lane];
+            if (!plug.has_value()
+                || std::find(kArtifactModHashes.begin(), kArtifactModHashes.end(), *plug)
+                       == kArtifactModHashes.end()) {
+                continue;
+            }
+            const std::uint16_t initial = detail.initialPlugIndices[lane];
+            if (initial == build_data::items::details::kUnavailableItemIndex) {
+                item.sockets.plugs[lane].reset();
+                continue;
+            }
+            build_data::items::Definition replacement{};
+            if (!build_data::find_item_definition_index(initial, replacement)) {
+                return false;
+            }
+            item.sockets.plugs[lane] = replacement.definitionHash;
+        }
+        return true;
+    };
+    for (std::size_t characterIndex = 0; characterIndex < candidate.characterCount;
+         ++characterIndex) {
+        auto& character = candidate.characters[characterIndex];
+        for (auto& item : character.equipment.slots) {
+            if (item.has_value() && !clearArtifactSockets(*item)) {
+                return false;
+            }
+        }
+        for (std::size_t itemIndex = 0; itemIndex < character.inventory.count; ++itemIndex) {
+            if (!clearArtifactSockets(character.inventory.values[itemIndex])) {
+                return false;
+            }
+        }
+    }
+    ArtifactResetResult changed{};
+    for (std::size_t characterIndex = 0; characterIndex < candidate.characterCount;
+         ++characterIndex) {
+        if (!before.characters[characterIndex].selected) {
+            continue;
+        }
+        const auto recordChanged = [&changed](const account::inventory::Item& prior,
+                                              const account::inventory::Item& current) noexcept {
+            if (prior.sockets.policy == current.sockets.policy
+                && prior.sockets.plugCount == current.sockets.plugCount
+                && prior.sockets.plugs == current.sockets.plugs) {
+                return true;
+            }
+            if (changed.instanceCount >= changed.instanceSoids.size()) {
+                return false;
+            }
+            changed.instanceSoids[changed.instanceCount++] = current.instanceSoid;
+            return true;
+        };
+        const auto& prior = before.characters[characterIndex];
+        const auto& current = candidate.characters[characterIndex];
+        for (std::size_t slot = 0; slot < current.equipment.slots.size(); ++slot) {
+            if (current.equipment.slots[slot].has_value()
+                && !recordChanged(*prior.equipment.slots[slot], *current.equipment.slots[slot])) {
+                return false;
+            }
+        }
+        for (std::size_t index = 0; index < current.inventory.count; ++index) {
+            if (!recordChanged(prior.inventory.values[index], current.inventory.values[index])) {
+                return false;
+            }
+        }
+    }
+    if (!account::valid(candidate) || !runtime::detail::valid_profile_inventory(candidate)
+        || !progression::seasonal_experience::replace_artifact_mod_mask(previousMods, 0)) {
+        return false;
+    }
+
+    AcquireSRWLockExclusive(&runtime::storage::g_stateLock);
+    bool current = runtime::detail::same_profile_inventory(
+        runtime::storage::g_state.account, before.profileItems, before.profileItemCount)
+                   && runtime::storage::g_state.account.characterCount == before.characterCount;
+    for (std::size_t index = 0; current && index < before.characterCount; ++index) {
+        current = runtime::detail::same_character(runtime::storage::g_state.account.characters[index],
+                                                  before.characters[index]);
+    }
+    if (current) {
+        runtime::storage::g_state.account = candidate;
+    }
+    ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
+    if (!current) {
+        (void)progression::seasonal_experience::replace_artifact_mod_mask(0, previousMods);
+    } else {
+        result = changed;
+    }
+    return current;
+}
+
 } // namespace sunrise::state

Неке датотеке нису приказане због велике количине промена