فهرست منبع

feat(appearance): refresh equipped gear across live families

Rebuild the selected character's appearance from the staged after-image and publish monotonic Family 0 and Family 3 character updates alongside Family 4 equipment or equipped-socket mutations.

Refresh render rows, materials, perks, weapon stat tables, emblem roster data, and the account roster companion in dependency-safe order so weapons, armor, Ghosts, ships, Sparrows, shaders, ornaments, and emblems do not remain stale outside inventory screens.

Explicit Family 3 subscriptions still seed a measured full version-zero snapshot; incremental changes advance only the active peer ladder and remain part of the same atomic service outcome.
Thomas Shields 3 هفته پیش
والد
کامیت
df8dd6a14a

+ 20 - 0
Sunrise/src/middleware/datagen/character_record/appearance/character_appearance_render.cpp

@@ -96,6 +96,25 @@ void apply_material_pairs(const details::Definition& detail,
     }
     }
 }
 }
 
 
+/** Applies plug-owned gear art and arrangements after the base item. */
+void apply_plug_art(const Equipped& equipped, layout::RenderEntry& entry) noexcept {
+    for (std::size_t lane = 0; lane < equipped.laneCount; ++lane) {
+        details::Definition plug{};
+        if (equipped.plugs[lane] == details::kUnavailableItemIndex
+            || !state::build_data::find_configured_item_detail(equipped.plugs[lane], plug)) {
+            continue;
+        }
+        // Shaders normally carry material rows only. Ornaments carry one or both art indices;
+        // the effective plug therefore replaces only the fields it actually declares.
+        if (plug.gearArtIndex != details::kUnavailableArtIndex) {
+            entry.art[layout::kGearArtSlot] = plug.gearArtIndex;
+        }
+        if (plug.artArrangementIndex != details::kUnavailableArtIndex) {
+            entry.art[layout::kArtArrangementSlot] = plug.artArrangementIndex;
+        }
+    }
+}
+
 } // namespace
 } // namespace
 
 
 /** Resolves one equipped instance to its detail and the plugs its sockets hold. */
 /** Resolves one equipped instance to its detail and the plugs its sockets hold. */
@@ -142,6 +161,7 @@ bool apply_render(const family4::loadout::ResolvedInstances& instances,
         // rather than taking art row 0.
         // rather than taking art row 0.
         entry.art[layout::kGearArtSlot] = detail.gearArtIndex;
         entry.art[layout::kGearArtSlot] = detail.gearArtIndex;
         entry.art[layout::kArtArrangementSlot] = detail.artArrangementIndex;
         entry.art[layout::kArtArrangementSlot] = detail.artArrangementIndex;
+        apply_plug_art(equipped, entry);
         apply_material_pairs(detail, equipped, entry);
         apply_material_pairs(detail, equipped, entry);
     }
     }
     return true;
     return true;

+ 209 - 0
Sunrise/src/server/bap/encrypted/push/queuez/queuez_banner_push.cpp

@@ -3,8 +3,10 @@
 #include <span>
 #include <span>
 
 
 #include "../../../../../core/logging/log.h"
 #include "../../../../../core/logging/log.h"
+#include "../../../../../middleware/datagen/definitions.h"
 #include "../../../../../middleware/secure_channel/runtime.h"
 #include "../../../../../middleware/secure_channel/runtime.h"
 #include "../../../../../state/account/account_state.h"
 #include "../../../../../state/account/account_state.h"
+#include "../../../../../state/build_data/runtime.h"
 #include "../../../../../state/runtime/runtime.h"
 #include "../../../../../state/runtime/runtime.h"
 #include "../../queuez/queuez_state_validation.h"
 #include "../../queuez/queuez_state_validation.h"
 #include "../snapshot/snapshot.h"
 #include "../snapshot/snapshot.h"
@@ -34,6 +36,95 @@ void report_skip(const char* reason) noexcept {
     }
     }
 }
 }
 
 
+/** Validates and appends one same-character Family-0 appearance refresh. */
+[[nodiscard]] bool append_appearance_frame(Scratch& scratch,
+                                           const queuez::CharacterAppearanceRefresh& refresh,
+                                           snapshot::Prepared& prepared,
+                                           const char* stage,
+                                           std::span<const std::byte, state::kAesKeySize> key,
+                                           std::array<std::byte, state::kBapNonceSize>& nonce,
+                                           std::span<std::byte> response,
+                                           std::size_t& written) noexcept {
+    const std::size_t objectCount = prepared.family.objects.size();
+    const std::size_t beforeBytes = written;
+    const bool replacement =
+        objectCount >= 2
+        && prepared.family.objects.front().id == middleware::datagen::kBannerCharacterObjectId
+        && prepared.family.objects.front().version == refresh.characterSoid
+        && prepared.family.objects.front().payload.empty();
+    const std::size_t characterIndex = replacement ? 1U : 0U;
+    const bool hasAnchor = objectCount == characterIndex + 2U;
+    if ((objectCount != characterIndex + 1U && !hasAnchor)
+        || prepared.family.type != queuez::kBannerFamilyType
+        || prepared.family.rootSoid != refresh.after.family4RootSoid
+        || prepared.family.version != refresh.after.family0Version || prepared.family.flags != 0
+        || (replacement
+            && prepared.family.objects.front().encoding != middleware::queuez::Encoding::raw)
+        || prepared.family.objects[characterIndex].id
+               != middleware::datagen::kBannerCharacterObjectId
+        || prepared.family.objects[characterIndex].version != refresh.characterSoid
+        || prepared.family.objects[characterIndex].encoding != middleware::queuez::Encoding::oodle
+        || prepared.family.objects[characterIndex].payload.empty()
+        || (hasAnchor
+            && (prepared.family.objects.back().id != middleware::datagen::kBannerAnchorObjectId
+                || prepared.family.objects.back().version != refresh.after.family4RootSoid
+                || prepared.family.objects.back().encoding != middleware::queuez::Encoding::oodle
+                || prepared.family.objects.back().payload.empty()))
+        || !queuez_frame::append(scratch,
+                                 prepared.family,
+                                 prepared.rawClearSize,
+                                 prepared.compressedClearSize,
+                                 key,
+                                 nonce,
+                                 response,
+                                 written)) {
+        return false;
+    }
+    middleware::secure_channel::advance_nonce(nonce);
+    queuez_report::push(stage, queuez::kBannerFamilyType, objectCount, written - beforeBytes, 1);
+    return true;
+}
+
+/** Validates and appends one incremental Family-3 appearance frame. */
+[[nodiscard]] bool
+append_roster_appearance_frame(Scratch& scratch,
+                               const queuez::RosterAppearanceRefresh& refresh,
+                               snapshot::Prepared& prepared,
+                               const char* stage,
+                               std::span<const std::byte, state::kAesKeySize> key,
+                               std::array<std::byte, state::kBapNonceSize>& nonce,
+                               std::span<std::byte> response,
+                               std::size_t& written) noexcept {
+    const std::size_t expectedObjects = refresh.includeRoster ? 2U : 1U;
+    const std::size_t objectCount = prepared.family.objects.size();
+    const std::size_t beforeBytes = written;
+    if (objectCount != expectedObjects || prepared.family.type != queuez::kRosterFamilyType
+        || prepared.family.rootSoid != refresh.after.family3RootSoid
+        || prepared.family.version != refresh.after.family3Version || prepared.family.flags != 0
+        || prepared.family.objects.front().id != middleware::datagen::kRosterCharacterObjectId
+        || prepared.family.objects.front().version != refresh.characterSoid
+        || prepared.family.objects.front().encoding != middleware::queuez::Encoding::oodle
+        || prepared.family.objects.front().payload.empty()
+        || (refresh.includeRoster
+            && (prepared.family.objects.back().id != middleware::datagen::kRosterObjectId
+                || prepared.family.objects.back().version != refresh.after.family3RootSoid
+                || prepared.family.objects.back().encoding != middleware::queuez::Encoding::oodle
+                || prepared.family.objects.back().payload.empty()))
+        || !queuez_frame::append(scratch,
+                                 prepared.family,
+                                 prepared.rawClearSize,
+                                 prepared.compressedClearSize,
+                                 key,
+                                 nonce,
+                                 response,
+                                 written)) {
+        return false;
+    }
+    middleware::secure_channel::advance_nonce(nonce);
+    queuez_report::push(stage, queuez::kRosterFamilyType, objectCount, written - beforeBytes, 1);
+    return true;
+}
+
 } // namespace
 } // namespace
 
 
 /**
 /**
@@ -187,4 +278,122 @@ bool append_banner_move_notification(Scratch& scratch,
     return true;
     return true;
 }
 }
 
 
+/** Appends one same-character Family-0 appearance-record upsert after an equipment swap. */
+bool append_equipment_appearance_refresh_notification(
+    Scratch& scratch,
+    const queuez::CharacterAppearanceRefresh& refresh,
+    const state::PendingEquipmentSwap& mutation,
+    std::span<const std::byte, state::kAesKeySize> key,
+    std::array<std::byte, state::kBapNonceSize>& nonce,
+    std::span<std::byte> response,
+    std::size_t& written) noexcept {
+    if (!mutation.prepared || mutation.characterSoid != refresh.characterSoid) {
+        return false;
+    }
+    snapshot::Prepared prepared{};
+    if (!snapshot::prepare_character_appearance_refresh(scratch,
+                                                        refresh,
+                                                        mutation.afterCharacter,
+                                                        mutation.characterIndex,
+                                                        mutation.nativeEquipmentSlot,
+                                                        false,
+                                                        prepared)) {
+        return false;
+    }
+    return append_appearance_frame(
+        scratch, refresh, prepared, "equip_appearance", key, nonce, response, written);
+}
+
+/** Appends one Family-0 record upsert after a socket change on an equipped item. */
+bool append_socket_appearance_refresh_notification(
+    Scratch& scratch,
+    const queuez::CharacterAppearanceRefresh& refresh,
+    const state::PendingSocketPlug& mutation,
+    std::span<const std::byte, state::kAesKeySize> key,
+    std::array<std::byte, state::kBapNonceSize>& nonce,
+    std::span<std::byte> response,
+    std::size_t& written) noexcept {
+    if (!mutation.prepared || !mutation.targetEquipped
+        || mutation.characterSoid != refresh.characterSoid
+        || mutation.itemIndex >= mutation.afterCharacter.equipment.slots.size()
+        || !mutation.afterCharacter.equipment.slots[mutation.itemIndex].has_value()) {
+        return false;
+    }
+    const state::account::inventory::Item& target =
+        *mutation.afterCharacter.equipment.slots[mutation.itemIndex];
+    if (target.instanceSoid != mutation.targetInstanceSoid) {
+        return false;
+    }
+    state::build_data::items::details::Definition detail{};
+    if (!state::build_data::find_configured_item_detail(mutation.targetDefinitionIndex, detail)
+        || detail.definitionIndex != mutation.targetDefinitionIndex
+        || detail.definitionHash != mutation.targetDefinitionHash
+        || detail.bucketId != mutation.targetBucketId || !detail.equipmentSlot.has_value()
+        || *detail.equipmentSlot < 0
+        || static_cast<std::size_t>(*detail.equipmentSlot)
+               >= state::build_data::items::details::kEquipmentSlotCount) {
+        return false;
+    }
+    snapshot::Prepared prepared{};
+    if (!snapshot::prepare_character_appearance_refresh(
+            scratch,
+            refresh,
+            mutation.afterCharacter,
+            mutation.characterIndex,
+            static_cast<std::uint8_t>(*detail.equipmentSlot),
+            true,
+            prepared)) {
+        return false;
+    }
+    return append_appearance_frame(
+        scratch, refresh, prepared, "socket_appearance", key, nonce, response, written);
+}
+
+/** Appends the Family-3 character-then-roster refresh owed by one equipment mutation. */
+bool append_equipment_roster_refresh_notification(
+    Scratch& scratch,
+    const queuez::RosterAppearanceRefresh& refresh,
+    const state::PendingEquipmentSwap& mutation,
+    std::span<const std::byte, state::kAesKeySize> key,
+    std::array<std::byte, state::kBapNonceSize>& nonce,
+    std::span<std::byte> response,
+    std::size_t& written) noexcept {
+    if (!mutation.prepared || !refresh.includeRoster
+        || mutation.characterSoid != refresh.characterSoid) {
+        return false;
+    }
+    snapshot::Prepared prepared{};
+    if (!snapshot::prepare_roster_appearance_refresh(
+            scratch, refresh, mutation.afterCharacter, mutation.characterIndex, prepared)) {
+        return false;
+    }
+    return append_roster_appearance_frame(
+        scratch, refresh, prepared, "equip_roster", key, nonce, response, written);
+}
+
+/** Appends the Family-3 character-only refresh owed by a socket change on equipped gear. */
+bool append_socket_roster_refresh_notification(Scratch& scratch,
+                                               const queuez::RosterAppearanceRefresh& refresh,
+                                               const state::PendingSocketPlug& mutation,
+                                               std::span<const std::byte, state::kAesKeySize> key,
+                                               std::array<std::byte, state::kBapNonceSize>& nonce,
+                                               std::span<std::byte> response,
+                                               std::size_t& written) noexcept {
+    if (!mutation.prepared || !mutation.targetEquipped || refresh.includeRoster
+        || mutation.characterSoid != refresh.characterSoid
+        || mutation.itemIndex >= mutation.afterCharacter.equipment.slots.size()
+        || !mutation.afterCharacter.equipment.slots[mutation.itemIndex].has_value()
+        || mutation.afterCharacter.equipment.slots[mutation.itemIndex]->instanceSoid
+               != mutation.targetInstanceSoid) {
+        return false;
+    }
+    snapshot::Prepared prepared{};
+    if (!snapshot::prepare_roster_appearance_refresh(
+            scratch, refresh, mutation.afterCharacter, mutation.characterIndex, prepared)) {
+        return false;
+    }
+    return append_roster_appearance_frame(
+        scratch, refresh, prepared, "socket_roster", key, nonce, response, written);
+}
+
 } // namespace sunrise::server::bap::encrypted::push
 } // namespace sunrise::server::bap::encrypted::push

+ 14 - 1
Sunrise/src/server/bap/encrypted/push/queuez/queuez_subscription.cpp

@@ -1,6 +1,7 @@
 #include <array>
 #include <array>
 
 
 #include "../../../../../core/logging/log.h"
 #include "../../../../../core/logging/log.h"
+#include "../../../../../middleware/datagen/definitions.h"
 #include "../../../../../middleware/secure_channel/runtime.h"
 #include "../../../../../middleware/secure_channel/runtime.h"
 #include "../../../../../state/runtime/runtime.h"
 #include "../../../../../state/runtime/runtime.h"
 #include "../../queuez/queuez_state_validation.h"
 #include "../../queuez/queuez_state_validation.h"
@@ -117,7 +118,9 @@ void append_queuez_notification(Scratch& scratch,
         && !queuez::stage_family3_subscription(before, subscription, publish, stagedAfter)) {
         && !queuez::stage_family3_subscription(before, subscription, publish, stagedAfter)) {
         queuez_report::subscription_state("stage_family3");
         queuez_report::subscription_state("stage_family3");
         stagedAfter = before;
         stagedAfter = before;
-        publish = true;
+        // A failed mirror check must never send a version-zero roster into an active incremental
+        // ladder. The correlated subscription response still goes out without a stale snapshot.
+        publish = false;
     }
     }
 
 
     snapshot::Prepared prepared{};
     snapshot::Prepared prepared{};
@@ -171,6 +174,16 @@ void append_queuez_notification(Scratch& scratch,
         after = stagedAfter;
         after = stagedAfter;
         return;
         return;
     }
     }
+    if (subscription.familyType == queuez::kRosterFamilyType
+        && (!stagedAfter.family3Active || stagedAfter.family3RootSoid != subscription.familyRootSoid
+            || stagedAfter.family3Version != queuez::kInitialFamilyVersion
+            || prepared.family.type != queuez::kRosterFamilyType
+            || prepared.family.rootSoid != stagedAfter.family3RootSoid
+            || prepared.family.version != stagedAfter.family3Version
+            || prepared.family.flags != middleware::queuez::kFullSnapshotFlag)) {
+        queuez_report::subscription_failure("family3_ladder");
+        return;
+    }
     const std::size_t objectCount = prepared.family.objects.size();
     const std::size_t objectCount = prepared.family.objects.size();
     const std::size_t beforeBytes = written;
     const std::size_t beforeBytes = written;
     if (!queuez_frame::append(scratch,
     if (!queuez_frame::append(scratch,

+ 137 - 0
Sunrise/src/server/bap/encrypted/push/snapshot/banner_snapshot.cpp

@@ -1,6 +1,9 @@
 #include <algorithm>
 #include <algorithm>
+#include <array>
+#include <cstdio>
 #include <span>
 #include <span>
 
 
+#include "../../../../../core/logging/log.h"
 #include "../../../../../middleware/datagen/character_record/character_record_encoder.h"
 #include "../../../../../middleware/datagen/character_record/character_record_encoder.h"
 #include "../../../../../middleware/datagen/definitions.h"
 #include "../../../../../middleware/datagen/definitions.h"
 #include "../../../../../middleware/datagen/family4/loadout/loadout_resolver.h"
 #include "../../../../../middleware/datagen/family4/loadout/loadout_resolver.h"
@@ -118,4 +121,138 @@ bool prepare_banner(Scratch& scratch,
     return commit(staged, prepared);
     return commit(staged, prepared);
 }
 }
 
 
+/** Builds one in-place Family-0 character-record upsert from an uncommitted equipment after-image.
+ */
+bool prepare_character_appearance_refresh(Scratch& scratch,
+                                          const queuez::CharacterAppearanceRefresh& refresh,
+                                          const state::CharacterState& afterCharacter,
+                                          std::size_t characterIndex,
+                                          std::uint8_t nativeEquipmentSlot,
+                                          bool replaceCharacterRecord,
+                                          Prepared& prepared) noexcept {
+    const Reservation reservation = reserve_prior(scratch, prepared);
+    if (reservation.rawWriteOffset > scratch.plaintext.size()
+        || reservation.compressedWriteOffset > scratch.sealed.size()) {
+        return report_failure("equip_appearance_reservation");
+    }
+
+    state::AccountState account = state::account_snapshot();
+    if (refresh.characterSoid == 0 || afterCharacter.soid != refresh.characterSoid
+        || characterIndex >= account.characterCount
+        || account.characters[characterIndex].soid != refresh.characterSoid
+        || !refresh.after.family0Active || refresh.after.family0Character != refresh.characterSoid
+        || refresh.after.family4RootSoid == 0
+        || account.primarySoid != refresh.after.family4RootSoid) {
+        return report_failure("equip_appearance_mutation");
+    }
+    account.characters[characterIndex] = afterCharacter;
+    if (!state::account::valid(account)
+        || state::account::selected_character_soid(account) != refresh.characterSoid) {
+        return report_failure("equip_appearance_selection");
+    }
+
+    middleware::datagen::family4::loadout::ResolvedInstances instances{};
+    std::int32_t light = 0;
+    if (!middleware::datagen::family4::loadout::resolve_instances(
+            account, characterIndex, instances)
+        || !state::equipment::light::resolution::character_light(account, characterIndex, light)) {
+        return report_failure("equip_appearance_resolve");
+    }
+
+    // The banner-facing emblem consumers bind through the Family-0 anchor rather than directly
+    // observing the character record.  A normal equipment refresh can upsert the resident record
+    // alone, but an emblem move must touch the unchanged anchor as well so those consumers are
+    // dirtied without releasing either resident key.
+    constexpr std::uint8_t kEmblemEquipmentSlot = 13;
+    const bool refreshAnchor = nativeEquipmentSlot == kEmblemEquipmentSlot;
+    const std::size_t anchorSize = refreshAnchor ? character_record::kFamily0AnchorSize : 0U;
+    const std::size_t rawSize = anchorSize + character_record::kFamily0RecordSize;
+    const auto rawStorage = std::span(scratch.plaintext).subspan(reservation.rawWriteOffset);
+    if (rawSize > rawStorage.size()) {
+        return report_failure("equip_appearance_storage");
+    }
+    const auto anchor = rawStorage.first(anchorSize);
+    const auto record = rawStorage.subspan(anchorSize, character_record::kFamily0RecordSize);
+    if ((refreshAnchor
+         && !character_record::encode_family0_anchor(
+             account.primarySoid, refresh.characterSoid, anchor))
+        || !character_record::encode_family0(afterCharacter, instances, light, record)) {
+        return report_failure("equip_appearance_encode");
+    }
+
+    Prepared staged{};
+    staged.rawClearSize =
+        (std::max)(reservation.rawClearSize, reservation.rawWriteOffset + rawSize);
+    std::size_t compressedExtent = reservation.compressedWriteOffset;
+    std::size_t objectCount = 0;
+    if (replaceCharacterRecord) {
+        staged.objects[objectCount++] = middleware::queuez::Object{
+            middleware::datagen::kBannerCharacterObjectId,
+            refresh.characterSoid,
+            middleware::queuez::Encoding::raw,
+            {},
+        };
+    }
+    if (!append_object(scratch,
+                       record,
+                       middleware::datagen::kBannerCharacterObjectId,
+                       refresh.characterSoid,
+                       staged.objects[objectCount++],
+                       compressedExtent)) {
+        clear_after(scratch, reservation);
+        return report_failure("equip_appearance_object");
+    }
+    // On an incremental refresh the record is already resident.  Publish its new body first and
+    // touch the anchor second, so an anchor-driven banner observer resolves the new emblem rather
+    // than the prior record during the same family update.
+    if (refreshAnchor
+        && !append_object(scratch,
+                          anchor,
+                          middleware::datagen::kBannerAnchorObjectId,
+                          account.primarySoid,
+                          staged.objects[objectCount++],
+                          compressedExtent)) {
+        clear_after(scratch, reservation);
+        return report_failure("equip_appearance_anchor");
+    }
+    staged.compressedClearSize = (std::max)(reservation.compressedClearSize, compressedExtent);
+    staged.family = middleware::queuez::Family{
+        middleware::datagen::kBannerFamily,
+        refresh.after.family4RootSoid,
+        refresh.after.family0Version,
+        0,
+        std::span(staged.objects).first(objectCount),
+    };
+    if (!commit(staged, prepared)) {
+        clear_after(scratch, reservation);
+        return report_failure("equip_appearance_commit");
+    }
+
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count = std::snprintf(
+        line.data(),
+        line.size(),
+        "ev=equip stage=family0_object result=ok family_version=%d root=0x%llX "
+        "definition=%u character=0x%llX native_slot=%u items=%zu light=%d flags=0 objects=%zu "
+        "anchor=%u replace=%u order=%s",
+        refresh.after.family0Version,
+        static_cast<unsigned long long>(refresh.after.family4RootSoid),
+        middleware::datagen::kBannerCharacterObjectId,
+        static_cast<unsigned long long>(refresh.characterSoid),
+        static_cast<unsigned>(nativeEquipmentSlot),
+        instances.itemCount,
+        light,
+        objectCount,
+        refreshAnchor ? 1U : 0U,
+        replaceCharacterRecord ? 1U : 0U,
+        replaceCharacterRecord ? (refreshAnchor ? "release_character_anchor" : "release_character")
+                               : (refreshAnchor ? "character_anchor" : "character"));
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+    return true;
+}
+
 } // namespace sunrise::server::bap::encrypted::push::snapshot
 } // namespace sunrise::server::bap::encrypted::push::snapshot

+ 94 - 0
Sunrise/src/server/bap/encrypted/push/snapshot/roster_snapshot.cpp

@@ -116,4 +116,98 @@ bool prepare_roster(Scratch& scratch,
     return commit(staged, prepared);
     return commit(staged, prepared);
 }
 }
 
 
+/** Builds one incremental Family-3 character record and optional changed account roster. */
+bool prepare_roster_appearance_refresh(Scratch& scratch,
+                                       const queuez::RosterAppearanceRefresh& refresh,
+                                       const state::CharacterState& afterCharacter,
+                                       std::size_t characterIndex,
+                                       Prepared& prepared) noexcept {
+    const Reservation reservation = reserve_prior(scratch, prepared);
+    if (reservation.rawWriteOffset > scratch.plaintext.size()
+        || reservation.compressedWriteOffset > scratch.sealed.size()) {
+        return false;
+    }
+
+    state::AccountState account = state::account_snapshot();
+    if (!refresh.after.family3Active || refresh.after.family3RootSoid == 0
+        || refresh.after.family3Version <= kInitialFamilyVersion || refresh.characterSoid == 0
+        || afterCharacter.soid != refresh.characterSoid || characterIndex >= account.characterCount
+        || account.characters[characterIndex].soid != refresh.characterSoid
+        || account.primarySoid != refresh.after.family3RootSoid) {
+        return false;
+    }
+    account.characters[characterIndex] = afterCharacter;
+    if (!state::account::valid(account)
+        || state::account::selected_character_soid(account) != refresh.characterSoid) {
+        return false;
+    }
+
+    middleware::datagen::family4::loadout::ResolvedInstances instances{};
+    std::int32_t light = 0;
+    if (!middleware::datagen::family4::loadout::resolve_instances(
+            account, characterIndex, instances)
+        || !state::equipment::light::resolution::character_light(account, characterIndex, light)) {
+        return false;
+    }
+
+    const std::size_t rosterSize =
+        refresh.includeRoster ? middleware::datagen::family3::kRosterSize : 0U;
+    const std::size_t rawSize = character_record::kFamily3RecordSize + rosterSize;
+    const auto rawStorage = std::span(scratch.plaintext).subspan(reservation.rawWriteOffset);
+    if (rawSize > rawStorage.size()) {
+        return false;
+    }
+    const auto characterBytes = rawStorage.first(character_record::kFamily3RecordSize);
+    if (!character_record::encode_family3(afterCharacter, instances, light, characterBytes)) {
+        return false;
+    }
+    std::span<std::byte> rosterBytes{};
+    if (refresh.includeRoster) {
+        rosterBytes = rawStorage.subspan(character_record::kFamily3RecordSize, rosterSize);
+        std::size_t encodedRosterSize = 0;
+        if (!middleware::datagen::family3::encode_roster(account, rosterBytes, encodedRosterSize)
+            || encodedRosterSize != rosterSize) {
+            return false;
+        }
+    }
+
+    Prepared staged{};
+    staged.rawClearSize =
+        (std::max)(reservation.rawClearSize, reservation.rawWriteOffset + rawSize);
+    std::size_t compressedExtent = reservation.compressedWriteOffset;
+    std::size_t objectCount = 0;
+    if (!append_object(scratch,
+                       characterBytes,
+                       middleware::datagen::kRosterCharacterObjectId,
+                       refresh.characterSoid,
+                       staged.objects[objectCount++],
+                       compressedExtent)) {
+        clear_after(scratch, reservation);
+        return false;
+    }
+    if (refresh.includeRoster
+        && !append_object(scratch,
+                          rosterBytes,
+                          middleware::datagen::kRosterObjectId,
+                          account.primarySoid,
+                          staged.objects[objectCount++],
+                          compressedExtent)) {
+        clear_after(scratch, reservation);
+        return false;
+    }
+    staged.compressedClearSize = (std::max)(reservation.compressedClearSize, compressedExtent);
+    staged.family = middleware::queuez::Family{
+        kRosterFamilyType,
+        refresh.after.family3RootSoid,
+        refresh.after.family3Version,
+        0,
+        std::span(staged.objects).first(objectCount),
+    };
+    if (!commit(staged, prepared)) {
+        clear_after(scratch, reservation);
+        return false;
+    }
+    return true;
+}
+
 } // namespace sunrise::server::bap::encrypted::push::snapshot
 } // namespace sunrise::server::bap::encrypted::push::snapshot