Просмотр исходного кода

Merge pull request #80 from ocucor/master

feat: basic roster functionality
stan 4 дней назад
Родитель
Сommit
bf36864c30

+ 1 - 0
Sunrise/Sunrise.vcxproj

@@ -912,6 +912,7 @@
     <ClCompile Include="src\server\bap\encrypted\push\snapshot\initial_snapshot.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\snapshot\initial_snapshot.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\snapshot\banner_snapshot.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\snapshot\banner_snapshot.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\snapshot\roster_snapshot.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\snapshot\roster_snapshot.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\push\snapshot\social_roster_snapshot.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\snapshot\snapshot_storage.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\snapshot\snapshot_storage.cpp" />
     <ClCompile Include="src\server\web_service\opcode_routes.cpp" />
     <ClCompile Include="src\server\web_service\opcode_routes.cpp" />
     <ClCompile Include="src\server\web_service\web_service_runtime.cpp" />
     <ClCompile Include="src\server\web_service\web_service_runtime.cpp" />

+ 18 - 1
Sunrise/src/middleware/datagen/definitions.h

@@ -1,5 +1,6 @@
 #pragma once
 #pragma once
 
 
+#include <cstddef>
 #include <cstdint>
 #include <cstdint>
 
 
 namespace sunrise::middleware::datagen {
 namespace sunrise::middleware::datagen {
@@ -19,13 +20,25 @@ inline constexpr std::uint32_t kCharacterObjectId = 0xE5E86992U;
 /** Object id for the family-four item-instance slot. */
 /** Object id for the family-four item-instance slot. */
 inline constexpr std::uint32_t kItemInstanceObjectId = 0x6CFBA3ABU;
 inline constexpr std::uint32_t kItemInstanceObjectId = 0x6CFBA3ABU;
 
 
+/** Object id for the family-two social roster directory slot. */
+inline constexpr std::uint32_t kSocialRosterDirectoryObjectId = 0xDA277CE4U;
+/** Object id for the family-two social roster member slot. */
+inline constexpr std::uint32_t kSocialRosterMemberObjectId = 0x811115CEU;
+
+/** Sizes the family-two slot descriptors declare, in bytes. */
+inline constexpr std::size_t kSocialRosterDirectorySize = 96;
+inline constexpr std::size_t kSocialRosterMemberSize = 80;
+
 /** Families that carry a generated object. */
 /** Families that carry a generated object. */
 inline constexpr std::uint32_t kBannerFamily = 0;
 inline constexpr std::uint32_t kBannerFamily = 0;
+inline constexpr std::uint32_t kSocialRosterFamily = 2;
 inline constexpr std::uint32_t kRosterFamily = 3;
 inline constexpr std::uint32_t kRosterFamily = 3;
 inline constexpr std::uint32_t kAccountFamily = 4;
 inline constexpr std::uint32_t kAccountFamily = 4;
 
 
 /** Slots those objects occupy. */
 /** Slots those objects occupy. */
 inline constexpr std::uint32_t kRosterSlot = 0;
 inline constexpr std::uint32_t kRosterSlot = 0;
+inline constexpr std::uint32_t kSocialRosterDirectorySlot = 0;
+inline constexpr std::uint32_t kSocialRosterMemberSlot = 1;
 inline constexpr std::uint32_t kAccountSlot = 0;
 inline constexpr std::uint32_t kAccountSlot = 0;
 inline constexpr std::uint32_t kCharacterSlot = 1;
 inline constexpr std::uint32_t kCharacterSlot = 1;
 inline constexpr std::uint32_t kItemInstanceSlot = 3;
 inline constexpr std::uint32_t kItemInstanceSlot = 3;
@@ -40,7 +53,11 @@ inline constexpr std::uint32_t kItemInstanceSlot = 3;
 [[nodiscard]] constexpr bool
 [[nodiscard]] constexpr bool
 object_id(std::uint32_t familyType, std::uint32_t slotIndex, std::uint32_t& objectId) noexcept {
 object_id(std::uint32_t familyType, std::uint32_t slotIndex, std::uint32_t& objectId) noexcept {
     objectId = 0;
     objectId = 0;
-    if (familyType == kRosterFamily && slotIndex == kRosterSlot) {
+    if (familyType == kSocialRosterFamily && slotIndex == kSocialRosterDirectorySlot) {
+        objectId = kSocialRosterDirectoryObjectId;
+    } else if (familyType == kSocialRosterFamily && slotIndex == kSocialRosterMemberSlot) {
+        objectId = kSocialRosterMemberObjectId;
+    } else if (familyType == kRosterFamily && slotIndex == kRosterSlot) {
         objectId = kRosterObjectId;
         objectId = kRosterObjectId;
     } else if (familyType == kAccountFamily && slotIndex == kAccountSlot) {
     } else if (familyType == kAccountFamily && slotIndex == kAccountSlot) {
         objectId = kAccountObjectId;
         objectId = kAccountObjectId;

+ 21 - 0
Sunrise/src/server/bap/encrypted/bap_connection_publication.cpp

@@ -17,6 +17,14 @@ namespace {
 constexpr std::uint64_t kFamily4RepushDelayMs = 400;
 constexpr std::uint64_t kFamily4RepushDelayMs = 400;
 /** The banner pair lands the same unsolicited way and hits the same record-state race. */
 /** The banner pair lands the same unsolicited way and hits the same record-state race. */
 constexpr std::uint64_t kBannerRepushDelayMs = 400;
 constexpr std::uint64_t kBannerRepushDelayMs = 400;
+/**
+ * Delay before the family-two re-push owed by an emblem equip.
+ *
+ * Matched to the two above, which are the measured-working value for the same record-state race:
+ * a snapshot answered too soon reaches the record before it writes its new state and is refused
+ * silently. If a re-push ever fails to land, this constant is the one guess in the mechanism.
+ */
+constexpr std::uint64_t kSocialRosterRepushDelayMs = 400;
 /**
 /**
  * Delay before the ability-icon re-derivation owed by a subclass selection.
  * Delay before the ability-icon re-derivation owed by a subclass selection.
  * The Client content-extraction pump that rebuilds the invalidated ability buckets runs on the
  * The Client content-extraction pump that rebuilds the invalidated ability buckets runs on the
@@ -261,6 +269,19 @@ void arm_repushes(Session& session, const queuez::StagedPublication& queuezPubli
         session.bannerRepushRoot = queuezPublication.bannerRepushRoot;
         session.bannerRepushRoot = queuezPublication.bannerRepushRoot;
         session.bannerRepushArmed = true;
         session.bannerRepushArmed = true;
     }
     }
+    // Recorded whenever a family-two subscribe was answered, so a later equip has a root to
+    // publish against. A peer that never subscribed to family two keeps root zero and is left
+    // alone below, which is the correct no-op for it.
+    if (queuezPublication.socialRosterRepushRoot != 0) {
+        session.socialRosterRepushRoot = queuezPublication.socialRosterRepushRoot;
+    }
+    // Its own arm on its own signal, for the reason recorded above: the banner arm was once
+    // driven from the wrong family's and took the connection down. Re-arming is idempotent and
+    // coalescing, so a burst of equips owes one delayed send rather than one each.
+    if (queuezPublication.rearmsSocialRosterRepush && session.socialRosterRepushRoot != 0) {
+        session.socialRosterRepushDueTick = now + kSocialRosterRepushDelayMs;
+        session.socialRosterRepushArmed = true;
+    }
 }
 }
 
 
 } // namespace sunrise::server::bap::encrypted
 } // namespace sunrise::server::bap::encrypted

+ 5 - 1
Sunrise/src/server/bap/encrypted/push/snapshot/initial_snapshot.cpp

@@ -24,6 +24,8 @@ bool prepare_initial(Scratch& scratch,
     Prepared staged{};
     Prepared staged{};
     staged.rawClearSize = reservation.rawClearSize;
     staged.rawClearSize = reservation.rawClearSize;
     staged.compressedClearSize = reservation.compressedClearSize;
     staged.compressedClearSize = reservation.compressedClearSize;
+    // Family two's directory and the family-three roster object both live in slot zero, so one
+    // expression covers every family that reaches here.
     const std::uint32_t slotIndex = subscription.familyType == kAccountFamilyType
     const std::uint32_t slotIndex = subscription.familyType == kAccountFamilyType
                                         ? kAccountDefinitionSlotIndex
                                         ? kAccountDefinitionSlotIndex
                                         : kRosterDefinitionSlotIndex;
                                         : kRosterDefinitionSlotIndex;
@@ -31,7 +33,9 @@ bool prepare_initial(Scratch& scratch,
     const bool hasDefinition =
     const bool hasDefinition =
         middleware::datagen::object_id(subscription.familyType, slotIndex, objectId);
         middleware::datagen::object_id(subscription.familyType, slotIndex, objectId);
     bool success = false;
     bool success = false;
-    if (subscription.familyType == kRosterFamilyType && hasDefinition) {
+    if (subscription.familyType == kSocialRosterFamilyType && hasDefinition) {
+        success = prepare_social_roster(scratch, subscription, objectId, reservation, staged);
+    } else if (subscription.familyType == kRosterFamilyType && hasDefinition) {
         success = prepare_roster(scratch, subscription, objectId, reservation, staged);
         success = prepare_roster(scratch, subscription, objectId, reservation, staged);
     } else if (subscription.familyType == kAccountFamilyType && hasDefinition) {
     } else if (subscription.familyType == kAccountFamilyType && hasDefinition) {
         success = prepare(scratch, subscription, objectId, reservation, staged);
         success = prepare(scratch, subscription, objectId, reservation, staged);

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

@@ -12,6 +12,8 @@ namespace sunrise::server::bap::encrypted::push::snapshot {
 
 
 /** Initial family snapshots start at version zero. */
 /** Initial family snapshots start at version zero. */
 inline constexpr std::int32_t kInitialFamilyVersion = 0;
 inline constexpr std::int32_t kInitialFamilyVersion = 0;
+/** Family two carries the social roster the Roster and Fireteam panels draw. */
+inline constexpr std::uint32_t kSocialRosterFamilyType = 2;
 /** Family three carries the account roster selected by Web Service subscription. */
 /** Family three carries the account roster selected by Web Service subscription. */
 inline constexpr std::uint32_t kRosterFamilyType = 3;
 inline constexpr std::uint32_t kRosterFamilyType = 3;
 /** Family four carries account and selected-character investment state. */
 /** Family four carries account and selected-character investment state. */
@@ -56,6 +58,24 @@ inline constexpr std::size_t kSingleObjectCount = 1;
                                   const Reservation& reservation,
                                   const Reservation& reservation,
                                   Prepared& prepared) noexcept;
                                   Prepared& prepared) noexcept;
 
 
+/**
+ * Builds the family-two social roster snapshot.
+ *
+ * Both slots go out together: the row resolves the emblem by reading the link at directory +8
+ * and looking the member record up by it, and a full snapshot prunes whatever it does not name.
+ * @param scratch Object storage owned by the lock.
+ * @param subscription Family id the Client picked.
+ * @param objectId Unused. Both slot ids are resolved by the builder.
+ * @param reservation Prior payload prefixes that staging must keep.
+ * @param prepared Gets both descriptors and the scratch clear extents.
+ * @return True when an account is signed in and both objects fit.
+ */
+[[nodiscard]] bool prepare_social_roster(Scratch& scratch,
+                                         const middleware::queuez::Subscription& subscription,
+                                         std::uint32_t objectId,
+                                         const Reservation& reservation,
+                                         Prepared& prepared) noexcept;
+
 /**
 /**
  * Compresses one encoded family-four object into the next sealed scratch segment.
  * Compresses one encoded family-four object into the next sealed scratch segment.
  * @param scratch Raw and compressed snapshot storage owned by the lock.
  * @param scratch Raw and compressed snapshot storage owned by the lock.

+ 210 - 0
Sunrise/src/server/bap/encrypted/push/snapshot/social_roster_snapshot.cpp

@@ -0,0 +1,210 @@
+/**
+ * Family-two social roster snapshot: the directory and the member record it links to.
+ *
+ * The Roster and Fireteam panels draw a name and a blank emblem because family two is answered
+ * with an empty snapshot. The panel row resolves the emblem with two lookups, not one, and both
+ * objects have to be resident at the same time for the pair to resolve:
+ *
+ *     lookup 1: slot 0, keyed by the account soid, gives the directory
+ *     lookup 2: slot 1, keyed by the qword at directory +8, gives the member record
+ *     then the emblem definition index is read from member +36 and its variant from member +38
+ *
+ * A full snapshot prunes every object it does not name, so publishing one slot per message can
+ * never satisfy that chain whichever slot is chosen. Both go out in one message.
+ */
+
+#include <algorithm>
+#include <array>
+#include <cstdio>
+#include <cstring>
+#include <span>
+
+#include "../../../../../core/logging/log.h"
+#include "../../../../../middleware/datagen/definitions.h"
+#include "../../../../../state/account/inventory/inventory_state.h"
+#include "../../../../../state/build_data/items/item_catalog.h"
+#include "../../../../../state/runtime/runtime.h"
+#include "internal.h"
+#include "snapshot_storage.h"
+
+namespace sunrise::server::bap::encrypted::push::snapshot {
+namespace {
+
+/** One line carries the soid, the object count and the encoded size. */
+constexpr std::size_t kReportCapacity = 160;
+
+/** Where the member record carries the emblem the panel row reads. */
+constexpr std::size_t kEmblemDefinitionOffset = 36;
+constexpr std::size_t kEmblemVariantOffset = 38;
+
+/**
+ * A missing definition index is every bit set, and the variant is always sent empty.
+ *
+ * The reader tries the variant first and falls back to the definition index when the variant is
+ * the empty sentinel. Sending a real number there resolves art against a bogus variant entry: a
+ * light value written to +38 drew a grey placeholder, and a large value stalled the client outright
+ * because the field indexes a table.
+ */
+constexpr std::uint16_t kEmptyDefinitionIndex = 0xFFFFU;
+
+/**
+ * Resolves the selected character's equipped emblem to a native definition index.
+ *
+ * This has to track the live loadout rather than publish a constant. The client resolves this
+ * account-keyed object as the account's emblem rather than as roster decoration, so a fixed index
+ * here pins the emblem globally: character select, inventory and orbit all stop reflecting an equip
+ * while the equip itself keeps succeeding. Publishing what the player actually has on makes that
+ * harmless.
+ *
+ * @param account Account snapshot, already read under the lock by the caller.
+ * @param index Receives the native definition index of the equipped emblem.
+ * @return False when nothing is selected, the emblem slot is empty, or the hash is unknown. Every
+ *         one of those cases publishes the empty sentinel rather than a guess.
+ */
+[[nodiscard]] bool selected_emblem_definition_index(const state::AccountState& account,
+                                                    std::uint16_t& index,
+                                                    std::uint32_t& definitionHash) noexcept {
+    for (const state::CharacterState& character : account.characters) {
+        if (!character.selected) {
+            continue;
+        }
+        const auto& slot =
+            character.equipment
+                .slots[static_cast<std::size_t>(state::account::inventory::EquipmentSlot::emblem)];
+        if (!slot.has_value()) {
+            return false;
+        }
+        state::build_data::items::Definition definition{};
+        if (!state::build_data::items::find_hash(slot->definitionHash, definition)) {
+            return false;
+        }
+        index = definition.definitionIndex;
+        definitionHash = slot->definitionHash;
+        return true;
+    }
+    return false;
+}
+
+} // namespace
+
+/** Builds the family-two snapshot carrying the social roster directory and member record. */
+bool prepare_social_roster(Scratch& scratch,
+                           const middleware::queuez::Subscription& subscription,
+                           std::uint32_t objectId,
+                           const Reservation& reservation,
+                           Prepared& prepared) noexcept {
+    // Both slot ids are resolved here, so the caller's single id is not used.
+    (void)objectId;
+    const state::AccountState account = state::account_snapshot();
+    if (account.primarySoid == 0 || reservation.rawWriteOffset > scratch.plaintext.size()) {
+        return report_failure("social_roster_state");
+    }
+    const auto destination = std::span(scratch.plaintext).subspan(reservation.rawWriteOffset);
+    constexpr std::size_t kTotal = middleware::datagen::kSocialRosterDirectorySize
+                                   + middleware::datagen::kSocialRosterMemberSize;
+    if (destination.size() < kTotal) {
+        return report_failure("social_roster_storage");
+    }
+
+    std::uint16_t emblem = kEmptyDefinitionIndex;
+    std::uint32_t emblemHash = 0;
+    if (!selected_emblem_definition_index(account, emblem, emblemHash)) {
+        emblem = kEmptyDefinitionIndex;
+    }
+
+    Prepared staged{};
+    std::size_t objectCount = 0;
+    std::size_t compressedExtent = reservation.compressedWriteOffset;
+    std::size_t rawUsed = 0;
+
+    /**
+     * Writes one object and stages it.
+     *
+     * The two bodies are not interchangeable, because both lookups match on the object's first
+     * qword. The directory leads with the account soid the row searches by and carries the link at
+     * +8; the member record leads with that same link so the second lookup finds it. The account
+     * soid serves as the link because it is already proven to route.
+     *
+     * Only the member record carries the emblem. The directory is read for two flag bits and
+     * nothing else, so a copy of the pair there changes nothing.
+     */
+    const auto emit = [&](std::size_t size, std::uint32_t id, bool directory) noexcept {
+        if (objectCount >= staged.objects.size() || size < kEmblemVariantOffset + sizeof emblem) {
+            return false;
+        }
+        const auto body = destination.subspan(rawUsed, size);
+        std::fill(body.begin(), body.end(), std::byte{});
+        if (directory) {
+            std::memcpy(body.data(), &account.primarySoid, sizeof account.primarySoid);
+            std::memcpy(body.data() + sizeof account.primarySoid,
+                        &account.primarySoid,
+                        sizeof account.primarySoid);
+        } else {
+            std::memcpy(body.data(), &account.primarySoid, sizeof account.primarySoid);
+            std::memcpy(body.data() + kEmblemDefinitionOffset, &emblem, sizeof emblem);
+            std::memcpy(body.data() + kEmblemVariantOffset,
+                        &kEmptyDefinitionIndex,
+                        sizeof kEmptyDefinitionIndex);
+        }
+        std::size_t compressedSize = 0;
+        if (!compress_object(scratch,
+                             body,
+                             id,
+                             account.primarySoid,
+                             compressedExtent,
+                             staged.objects[objectCount],
+                             compressedSize)) {
+            return false;
+        }
+        compressedExtent += compressedSize;
+        rawUsed += size;
+        ++objectCount;
+        return true;
+    };
+
+    // The directory goes first so a partial land reads as the directory surviving without a member
+    // record, rather than as an unexplained miss.
+    if (!emit(middleware::datagen::kSocialRosterDirectorySize,
+              middleware::datagen::kSocialRosterDirectoryObjectId,
+              true)) {
+        return report_failure("social_roster_directory");
+    }
+    if (!emit(middleware::datagen::kSocialRosterMemberSize,
+              middleware::datagen::kSocialRosterMemberObjectId,
+              false)) {
+        return report_failure("social_roster_member");
+    }
+
+    staged.rawClearSize =
+        (std::max)(reservation.rawClearSize, reservation.rawWriteOffset + rawUsed);
+    staged.compressedClearSize = (std::max)(reservation.compressedClearSize, compressedExtent);
+    staged.family = middleware::queuez::Family{
+        subscription.familyType,
+        subscription.familyRootSoid,
+        kInitialFamilyVersion,
+        middleware::queuez::kFullSnapshotFlag,
+        std::span(staged.objects).first(objectCount),
+    };
+    if (!commit(staged, prepared)) {
+        return report_failure("social_roster_commit");
+    }
+
+    std::array<char, kReportCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=queuez stage=social_roster result=ok soid=0x%016llX"
+                                      " objects=%zu bytes=%zu emblem=%u hash=0x%08X",
+                                      static_cast<unsigned long long>(account.primarySoid),
+                                      objectCount,
+                                      rawUsed,
+                                      static_cast<unsigned>(emblem),
+                                      static_cast<unsigned>(emblemHash));
+    if (written > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+    return true;
+}
+
+} // namespace sunrise::server::bap::encrypted::push::snapshot

+ 21 - 0
Sunrise/src/server/bap/encrypted/queuez/definition.h

@@ -11,6 +11,8 @@ namespace sunrise::server::bap::encrypted::queuez {
 
 
 /** Family zero carries the banner anchor and the record for the character it names. */
 /** Family zero carries the banner anchor and the record for the character it names. */
 inline constexpr std::uint32_t kBannerFamilyType = 0;
 inline constexpr std::uint32_t kBannerFamilyType = 0;
+/** Family two carries the social roster the Roster and Fireteam panels draw. */
+inline constexpr std::uint32_t kSocialRosterFamilyType = 2;
 /** Family three carries the account character roster. */
 /** Family three carries the account character roster. */
 inline constexpr std::uint32_t kRosterFamilyType = 3;
 inline constexpr std::uint32_t kRosterFamilyType = 3;
 /** Family four carries account, character, and item state. */
 /** Family four carries account, character, and item state. */
@@ -194,6 +196,25 @@ struct StagedPublication {
     bool armsBannerRepush{};
     bool armsBannerRepush{};
     /** Root that copy must use. */
     /** Root that copy must use. */
     std::uint64_t bannerRepushRoot{};
     std::uint64_t bannerRepushRoot{};
+    /**
+     * Root a family-two subscribe was answered against, or zero when this frame answered none.
+     *
+     * A subscribe is the only moment a family-two root arrives. The connection keeps the last one
+     * so a later re-push can reuse it rather than deriving a value the peer never named.
+     */
+    std::uint64_t socialRosterRepushRoot{};
+    /**
+     * An emblem equip left the published family-two object stale and it owes a fresh copy.
+     *
+     * The family-two snapshot is built when the peer subscribes, so the emblem it carries is only
+     * correct as of that moment; the Client resolves that account-keyed object as *the* account
+     * emblem, so a stale one pins the display for the rest of the session while the equip itself
+     * keeps succeeding.
+     *
+     * Its own flag on its own signal. The banner arm is deliberately not reused: the consumer
+     * records that arming a re-push from another family's signal took the connection down.
+     */
+    bool rearmsSocialRosterRepush{};
     /** A subclass selection just staged and owes a delayed ability-icon refresh. */
     /** A subclass selection just staged and owes a delayed ability-icon refresh. */
     bool armsAbilityRefresh{};
     bool armsAbilityRefresh{};
 };
 };

+ 72 - 1
Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp

@@ -174,6 +174,76 @@ void report_repush(const char* stage, std::size_t bytes) noexcept {
     return true;
     return true;
 }
 }
 
 
+/**
+ * Sends the owed family-two re-push once its delay has passed.
+ *
+ * The family-two snapshot is built when the peer subscribes, so the emblem it carries is only
+ * correct as of that moment. An equip into the emblem slot leaves it stale, and the Client
+ * resolves that account-keyed object as *the* account emblem -- so the display stays pinned to
+ * whatever was worn at subscribe time while the equip itself keeps succeeding. This republishes
+ * the live value against the root the subscribe was answered with.
+ *
+ * **One attempt, spent whether or not it lands.** The arm is cleared before the frame is built, so
+ * a refusal cannot leave this re-arming every tick; this file records that a boot-shaped replay
+ * repeated after the ladder has moved took the connection down.
+ *
+ * @param session Auth, nonce and queuez state owned by the connection.
+ * @param scratch Transform buffers owned by the lock.
+ * @param response Whole-frame storage owned by the caller.
+ * @param written Gets the encoded notification size in bytes.
+ * @param touchesScratch Set before any scratch buffer is used.
+ * @return True when a whole family-two notification is published.
+ */
+[[nodiscard]] bool consume_social_roster_repush(Session& session,
+                                                Scratch& scratch,
+                                                std::span<std::byte> response,
+                                                std::size_t& written,
+                                                bool& touchesScratch) noexcept {
+    if (!session.socialRosterRepushArmed || session.socialRosterRepushRoot == 0
+        || GetTickCount64() < session.socialRosterRepushDueTick) {
+        return false;
+    }
+    // Spent up front, so no path below can leave it owed.
+    session.socialRosterRepushArmed = false;
+    touchesScratch = true;
+
+    // The same body the subscribe answer builds, rebuilt against current State so the emblem it
+    // carries is the one now worn.
+    middleware::queuez::Subscription subscription{};
+    subscription.familyType = queuez::kSocialRosterFamilyType;
+    subscription.familyRootSoid = session.socialRosterRepushRoot;
+
+    auto nextSendNonce = session.sendNonce;
+    std::size_t framedSize = 0;
+    queuez::SessionState rosterAfter{};
+    bool armsRepush = false;
+    bool armsBannerRepush = false;
+    push::append_queuez_notification(scratch,
+                                     session.queuez,
+                                     subscription,
+                                     state::bap().sessionKey,
+                                     nextSendNonce,
+                                     scratch.framed,
+                                     framedSize,
+                                     rosterAfter,
+                                     armsRepush,
+                                     armsBannerRepush);
+    if (framedSize == 0 || framedSize > response.size()) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::warn,
+                         "ev=queuez stage=social_roster_repush result=fail");
+        return false;
+    }
+    std::copy_n(scratch.framed.begin(), framedSize, response.begin());
+    written = framedSize;
+    session.sendNonce = nextSendNonce;
+    if (valid(rosterAfter)) {
+        session.queuez = rosterAfter;
+    }
+    report_repush("social_roster_repush", framedSize);
+    return true;
+}
+
 /**
 /**
  * Re-derives the selected character's appearance and roster once the ability-bucket rebuild owed
  * Re-derives the selected character's appearance and roster once the ability-bucket rebuild owed
  * by a subclass selection has landed. The refresh sent inline with the opcode-801 response can
  * by a subclass selection has landed. The refresh sent inline with the opcode-801 response can
@@ -279,7 +349,8 @@ bool consume_deferred(Session& session,
     }
     }
     if (!session.family4RepushArmed || session.family4RepushRoot == 0
     if (!session.family4RepushArmed || session.family4RepushRoot == 0
         || GetTickCount64() < session.family4RepushDueTick) {
         || GetTickCount64() < session.family4RepushDueTick) {
-        return consume_banner_repush(session, scratch, response, written, touchesScratch)
+        return consume_social_roster_repush(session, scratch, response, written, touchesScratch)
+               || consume_banner_repush(session, scratch, response, written, touchesScratch)
                || push::activity::consume_activity_keepalive(
                || push::activity::consume_activity_keepalive(
                    session, scratch, response, written, touchesScratch);
                    session, scratch, response, written, touchesScratch);
     }
     }

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

@@ -8,6 +8,30 @@
 #include "queuez_state_validation.h"
 #include "queuez_state_validation.h"
 
 
 namespace sunrise::server::bap::encrypted::queuez {
 namespace sunrise::server::bap::encrypted::queuez {
+namespace {
+
+/**
+ * Says whether equipping into one slot changes what the family-two member record publishes.
+ *
+ * That record carries two fields fed by different slots. The emblem comes from the emblem slot,
+ * and the light is the mean of the eight gear slots -- the three weapons and the five armour
+ * pieces -- so an armour swap moves the record just as surely as an emblem swap does. Gating on
+ * the emblem alone would leave the roster row holding a stale light until the next subscribe.
+ *
+ * Everything else is excluded because it moves neither field: a ghost, sparrow, ship, subclass,
+ * clan banner, emote or finisher carries no Power in this season and is not the emblem, so a
+ * swap there would spend a re-push republishing an unchanged object.
+ *
+ * @param equipmentSlotIndex Authored semantic slot the equip targeted.
+ * @return True when the slot feeds the emblem or the light the member record carries.
+ */
+[[nodiscard]] constexpr bool moves_social_roster(std::size_t equipmentSlotIndex) noexcept {
+    namespace inventory = state::account::inventory;
+    return equipmentSlotIndex <= static_cast<std::size_t>(inventory::EquipmentSlot::classItem)
+           || equipmentSlotIndex == static_cast<std::size_t>(inventory::EquipmentSlot::emblem);
+}
+
+} // namespace
 
 
 /** Stages queuez subscription, unsubscription, or character-move output for one peer. */
 /** Stages queuez subscription, unsubscription, or character-move output for one peer. */
 bool stage_service_outcome(Scratch& scratch,
 bool stage_service_outcome(Scratch& scratch,
@@ -23,6 +47,7 @@ bool stage_service_outcome(Scratch& scratch,
     bool armsRepush = false;
     bool armsRepush = false;
     bool armsBannerRepush = false;
     bool armsBannerRepush = false;
     std::uint64_t bannerRoot = 0;
     std::uint64_t bannerRoot = 0;
+    std::uint64_t socialRosterRoot = 0;
     bool armsAbilityRefresh = false;
     bool armsAbilityRefresh = false;
     const auto* equipment = transaction_if<EquipmentSwapTransaction>(outcome);
     const auto* equipment = transaction_if<EquipmentSwapTransaction>(outcome);
     const auto* subclassSelection = transaction_if<SubclassSelectionTransaction>(outcome);
     const auto* subclassSelection = transaction_if<SubclassSelectionTransaction>(outcome);
@@ -33,6 +58,11 @@ bool stage_service_outcome(Scratch& scratch,
     const auto* profileAcquisition = transaction_if<ProfileItemAcquisitionTransaction>(outcome);
     const auto* profileAcquisition = transaction_if<ProfileItemAcquisitionTransaction>(outcome);
     const auto* itemDismantle = transaction_if<ItemDismantleTransaction>(outcome);
     const auto* itemDismantle = transaction_if<ItemDismantleTransaction>(outcome);
     const auto* allocation = transaction_if<state::activity::PendingAllocation>(outcome);
     const auto* allocation = transaction_if<state::activity::PendingAllocation>(outcome);
+    // Set before the branch chain rather than inside the equipment arm. That arm returns early
+    // when the staged after-image fails validation, and the equip has already moved State by
+    // then -- so the published record is stale on exactly the path the arm never finishes.
+    publication.rearmsSocialRosterRepush =
+        equipment != nullptr && moves_social_roster(equipment->pending.equipmentSlotIndex);
     if (outcome.hasSubscription) {
     if (outcome.hasSubscription) {
         push::append_queuez_notification(scratch,
         push::append_queuez_notification(scratch,
                                          before,
                                          before,
@@ -45,6 +75,12 @@ bool stage_service_outcome(Scratch& scratch,
                                          armsRepush,
                                          armsRepush,
                                          armsBannerRepush);
                                          armsBannerRepush);
         bannerRoot = outcome.subscription.familyRootSoid;
         bannerRoot = outcome.subscription.familyRootSoid;
+        // The subscribe is the only moment a family-two root arrives. Recorded rather than acted
+        // on: the inline answer to this subscribe lands, so nothing is owed until an equip makes
+        // what it published stale.
+        if (outcome.subscription.familyType == kSocialRosterFamilyType) {
+            socialRosterRoot = outcome.subscription.familyRootSoid;
+        }
     } else if (outcome.hasUnsubscription) {
     } else if (outcome.hasUnsubscription) {
         stage_unsubscription(before,
         stage_unsubscription(before,
                              outcome.unsubscription.familyType,
                              outcome.unsubscription.familyType,
@@ -524,6 +560,7 @@ bool stage_service_outcome(Scratch& scratch,
     publication.family4RepushRoot = armsRepush ? outcome.subscription.familyRootSoid : 0;
     publication.family4RepushRoot = armsRepush ? outcome.subscription.familyRootSoid : 0;
     publication.armsBannerRepush = armsBannerRepush && bannerRoot != 0;
     publication.armsBannerRepush = armsBannerRepush && bannerRoot != 0;
     publication.bannerRepushRoot = publication.armsBannerRepush ? bannerRoot : 0;
     publication.bannerRepushRoot = publication.armsBannerRepush ? bannerRoot : 0;
+    publication.socialRosterRepushRoot = socialRosterRoot;
     publication.armsAbilityRefresh = armsAbilityRefresh;
     publication.armsAbilityRefresh = armsAbilityRefresh;
     return true;
     return true;
 }
 }

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

@@ -399,6 +399,12 @@ struct Session {
     std::uint64_t bannerRepushRoot{};
     std::uint64_t bannerRepushRoot{};
     /** True while one banner re-push is still owed to this peer. */
     /** True while one banner re-push is still owed to this peer. */
     bool bannerRepushArmed{};
     bool bannerRepushArmed{};
+    /** Tick count after which the owed social-roster re-push may go out. */
+    std::uint64_t socialRosterRepushDueTick{};
+    /** Root the last family-two subscribe was answered against, and the re-push must reuse. */
+    std::uint64_t socialRosterRepushRoot{};
+    /** True while one family-two re-push is still owed to this peer. */
+    bool socialRosterRepushArmed{};
     /** Latest shared-account generation this peer has received. */
     /** Latest shared-account generation this peer has received. */
     std::uint64_t accountGeneration{};
     std::uint64_t accountGeneration{};
     /** Newest shared-account generation owed as a full cross-peer refresh. */
     /** Newest shared-account generation owed as a full cross-peer refresh. */