فهرست منبع

Add the emote wheel via the real "Emotes" collection item

Reverse-engineered the actual retail mechanism: a separate, non-equippable
"Emotes" collection item (hash 3183180185) with 4 real ordinary sockets and
its own dedicated client-side "Configure" screen, distinct from the single
worn emote. It equips in place of the individual emote in the real emote
slot; the stock client already knows how to render and open its wheel UI
once the item exists there with valid socket data.

Unlike every other character-scoped item, this one's real content carries
no native equipment-slot mapping at all. That gap surfaced as four separate,
independent copies of the same "item must have an equipment slot" check
across the loadout resolver, the light/power calculation, and the socket
appearance-refresh push -- each one failing (or in the light resolver's
case, invalidating the whole account) only once the item was actually
equipped rather than sitting in inventory. All four now fall back to the
emote slot's own native number for this item specifically.

Also fixes the two ordinary-socket auxiliary hash fields, previously left
at a blanket 0 fill for every item's sockets; they now carry each socket's
own resolved plug definition hash, which is what let the wheel's icon and
the individually socketed emotes actually render instead of silently
blanking.
Millie 3 هفته پیش
والد
کامیت
5ac678d106

+ 4 - 1
Sunrise/src/client/content/investment/investment_refresh.cpp

@@ -50,6 +50,7 @@ bool refresh() noexcept {
         AcquireSRWLockExclusive(&g_refreshLock);
         const bool persisted = state::ensure_profile_item_identities()
                                && state::ensure_character_subclasses()
+                               && state::ensure_character_emote_collection()
                                && state::build_data::persist();
         // Nothing reads a package again until the next boot, so the open files and the held
         // tables go back now rather than at process exit.
@@ -68,7 +69,9 @@ bool refresh() noexcept {
     (void)items::packages::build();
     const bool domainsReady = ready();
     const bool complete = domainsReady && state::ensure_profile_item_identities()
-                          && state::ensure_character_subclasses() && state::build_data::persist();
+                          && state::ensure_character_subclasses()
+                          && state::ensure_character_emote_collection()
+                          && state::build_data::persist();
     // The overlay ends with the work, not with the slice, so it spans every retry the pass needs.
     if (complete) {
         core::ui::busy::end(core::ui::busy::Task::contentExtraction);

+ 11 - 0
Sunrise/src/middleware/datagen/family4/instance/instance_encoder.cpp

@@ -3,6 +3,7 @@
 #include <algorithm>
 #include <cstring>
 
+#include "../../../../state/build_data/runtime.h"
 #include "abi.h"
 #include "layout.h"
 
@@ -150,6 +151,16 @@ bool encode(const ResolvedInstance& input, std::span<std::byte> output) noexcept
             const std::optional<std::uint16_t>& plug = input.ordinarySockets.plugs[index];
             if (plug.has_value()) {
                 object.ordinarySockets.sockets[index].plugDefinitionIndex = *plug;
+                // There is no universally safe constant for the two auxiliary hashes; a wrong
+                // one silently blanks the socket's render instead of failing loudly. Each
+                // socket's own plug definition hash is what the client expects here. Falls back
+                // to the zero fill from initialize_empty_fields if the hash cannot be resolved,
+                // so this cannot regress a working socket into a worse state than before.
+                state::build_data::items::Definition plugDefinition{};
+                if (state::build_data::find_item_definition_index(*plug, plugDefinition)) {
+                    object.ordinarySockets.sockets[index].auxiliaryHashes.fill(
+                        plugDefinition.definitionHash);
+                }
             }
         }
     }

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

@@ -139,8 +139,8 @@ bool resolve_item(const authored_inventory::Item& authored,
     if (!state::build_data::find_item_definition_hash(authored.definitionHash, itemDefinition)
         || !state::build_data::find_configured_item_detail(itemDefinition.definitionIndex,
                                                            itemDetail)
-        || itemDefinition.bucketId != itemDetail.bucketId || !itemDetail.equipmentSlot.has_value()
-        || *itemDetail.equipmentSlot < 0
+        || itemDefinition.bucketId != itemDetail.bucketId
+        || (itemDetail.equipmentSlot.has_value() && *itemDetail.equipmentSlot < 0)
         || !state::build_data::find_inventory_bucket_descriptor(itemDetail.bucketId, bucket)
         || bucket.arraySelector != build_buckets::ArraySelector::character
         || !state::build_data::find_socket_entry_list(itemDetail.socketEntryListIndex, socketList)
@@ -152,7 +152,14 @@ bool resolve_item(const authored_inventory::Item& authored,
 
     Candidate candidate{};
     candidate.bucket = bucket;
-    candidate.item.equipmentSlot = static_cast<std::uint8_t>(*itemDetail.equipmentSlot);
+    // The "Emotes" collection item's real content carries no native equipment-slot mapping at
+    // all, unlike every other character-scoped item. It is equipped in place of the individual
+    // emote (state::ensure_character_emote_collection), so it takes that slot's own native
+    // number here.
+    constexpr std::uint8_t kEmoteCollectionNativeSlot = 14;
+    candidate.item.equipmentSlot =
+        itemDetail.equipmentSlot.has_value() ? static_cast<std::uint8_t>(*itemDetail.equipmentSlot)
+                                             : kEmoteCollectionNativeSlot;
     candidate.item.mutationSerial = authored.mutationSerial;
     candidate.item.flags = authored.flags;
     if (!resolve_quantity(authored, itemDetail, candidate.item.quantity)

+ 14 - 5
Sunrise/src/server/bap/encrypted/push/queuez/queuez_banner_push.cpp

@@ -343,23 +343,32 @@ bool append_socket_appearance_refresh_notification(
     if (target.instanceSoid != mutation.targetInstanceSoid) {
         return false;
     }
+    // The "Emotes" collection item's real content carries no native equipment-slot mapping at
+    // all, unlike every other character-scoped item. Kept self-consistent with the loadout
+    // resolver's own fallback: the emote slot's own native number, since that is where it is
+    // equipped.
+    constexpr std::uint8_t kEmoteCollectionNativeSlot = 14;
     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) {
+        || detail.bucketId != mutation.targetBucketId
+        || (detail.equipmentSlot.has_value() && *detail.equipmentSlot < 0)
+        || (detail.equipmentSlot.has_value()
+            && static_cast<std::size_t>(*detail.equipmentSlot)
+                   >= state::build_data::items::details::kEquipmentSlotCount)) {
         return false;
     }
+    const std::uint8_t nativeEquipmentSlot = detail.equipmentSlot.has_value()
+                                                 ? static_cast<std::uint8_t>(*detail.equipmentSlot)
+                                                 : kEmoteCollectionNativeSlot;
     snapshot::Prepared prepared{};
     if (!snapshot::prepare_character_appearance_refresh(
             scratch,
             refresh,
             mutation.afterCharacter,
             mutation.characterIndex,
-            static_cast<std::uint8_t>(*detail.equipmentSlot),
+            nativeEquipmentSlot,
             true,
             prepared)) {
         return false;

+ 15 - 3
Sunrise/src/state/equipment/light/resolution/configured_equipment_light_resolver.cpp

@@ -43,13 +43,25 @@ using NativeSlotMap = std::array<std::optional<std::size_t>, build_details::kEqu
  */
 [[nodiscard]] bool
 resolve_item(const authored::Item& item, std::size_t& nativeSlot, ItemScore& itemScore) noexcept {
+    // The "Emotes" collection item's real content carries no native equipment-slot mapping at
+    // all, unlike every other character-scoped item; it does not contribute to light either way.
+    // Kept self-consistent with the loadout resolver's own fallback for the same case (the emote
+    // slot's own native number, since that is where this item is equipped).
+    constexpr std::size_t kEmoteCollectionNativeSlot = 14;
     build_items::Definition definition{};
     build_details::Definition detail{};
     if (!build_data::find_item_definition_hash(item.definitionHash, definition)
         || !build_data::find_configured_item_detail(definition.definitionIndex, detail)
-        || detail.definitionIndex != definition.definitionIndex || !detail.equipmentSlot.has_value()
-        || *detail.equipmentSlot < 0
-        || static_cast<std::size_t>(*detail.equipmentSlot) >= build_details::kEquipmentSlotCount) {
+        || detail.definitionIndex != definition.definitionIndex
+        || (detail.equipmentSlot.has_value() && *detail.equipmentSlot < 0)) {
+        return false;
+    }
+    if (!detail.equipmentSlot.has_value()) {
+        nativeSlot = kEmoteCollectionNativeSlot;
+        itemScore = ItemScore{definition.definitionIndex, 0};
+        return true;
+    }
+    if (static_cast<std::size_t>(*detail.equipmentSlot) >= build_details::kEquipmentSlotCount) {
         return false;
     }
     nativeSlot = static_cast<std::size_t>(*detail.equipmentSlot);

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

@@ -57,6 +57,14 @@ struct PendingSubclassSelection {
 /** Commits a prepared subclass selection behind the exact full-character staleness guard. */
 [[nodiscard]] bool commit_subclass_selection(PendingSubclassSelection& mutation) noexcept;
 
+/**
+ * Equips each character with the "Emotes" collection item (hash 3183180185) in the emote slot,
+ * in place of an individual emote, if it is not already equipped there. The stock client opens
+ * its own wheel-configuration screen for this item; the 4 ordinary sockets seed default lanes
+ * from its real plug pool so the wheel has something in every slot the first time it opens.
+ */
+[[nodiscard]] bool ensure_character_emote_collection() noexcept;
+
 /** Direction of one checked character equipment mutation. */
 enum class EquipmentMutationKind : std::uint8_t {
     none,

+ 60 - 0
Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp

@@ -579,4 +579,64 @@ bool commit_profile_item_acquisition(PendingProfileItemAcquisition& mutation) no
     return ready;
 }
 
+/**
+ * Equips each character with the "Emotes" collection item in the real emote slot, in place of an
+ * individual emote. Unlike every other character-scoped item, its real content carries no native
+ * equipment-slot mapping at all, so the resolvers this depends on (loadout resolution, light,
+ * appearance refresh) fall back to that slot's own native number (14) for it specifically.
+ * Its 4 ordinary sockets carry no native default plug, so 4 hashes from its real reusable plug
+ * pool seed a default wheel; the client's own generic socket-plug request (opcode 1901) lets the
+ * player reassign them afterward, the same mechanism it already uses for weapon mods and shaders.
+ */
+bool ensure_character_emote_collection() noexcept {
+    constexpr std::uint32_t kEmoteCollectionHash = 3183180185U;
+    constexpr std::array<std::uint32_t, 4> kDefaultPlugHashes{
+        3134905452U, 4049365947U, 1046955906U, 181754010U};
+    constexpr std::size_t kEmoteCollectionSlot =
+        static_cast<std::size_t>(authored_inventory::EquipmentSlot::emote);
+
+    AcquireSRWLockExclusive(&runtime::storage::g_stateLock);
+    AccountState candidate = runtime::storage::g_state.account;
+    if (!account::valid(candidate)) {
+        ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
+        return true;
+    }
+    bool changed = false;
+    bool failed = false;
+    for (std::size_t characterIndex = 0;
+         characterIndex < candidate.characterCount && !failed;
+         ++characterIndex) {
+        CharacterState& character = candidate.characters[characterIndex];
+        auto& collectionSlot = character.equipment.slots[kEmoteCollectionSlot];
+        if (collectionSlot.has_value() && collectionSlot->definitionHash == kEmoteCollectionHash) {
+            continue;
+        }
+        std::uint64_t instanceSoid = 0;
+        if (!next_item_instance_soid(candidate, instanceSoid)) {
+            failed = true;
+            break;
+        }
+        authored_inventory::Item granted{};
+        granted.instanceSoid = instanceSoid;
+        granted.definitionHash = kEmoteCollectionHash;
+        granted.level = 0;
+        granted.quantity = 1;
+        granted.mutationSerial = static_cast<std::int32_t>(character.nextInventorySerial++);
+        granted.sockets.policy = authored_inventory::SocketPolicy::authored;
+        granted.sockets.plugCount = kDefaultPlugHashes.size();
+        for (std::size_t lane = 0; lane < kDefaultPlugHashes.size(); ++lane) {
+            granted.sockets.plugs[lane] = kDefaultPlugHashes[lane];
+        }
+        collectionSlot = granted;
+        changed = true;
+    }
+    if (failed || !changed || !account::valid(candidate)) {
+        ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
+        return !failed;
+    }
+    runtime::storage::g_state.account = candidate;
+    ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
+    return true;
+}
+
 } // namespace sunrise::state