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

Merge pull request #48 from Nyxaraa/emote-wheel

Add emote wheel support via the real Emotes collection item
stan 5 дней назад
Родитель
Сommit
14f7eb38a9

+ 1 - 0
Sunrise/Sunrise.vcxproj

@@ -884,6 +884,7 @@
     <ClCompile Include="src\server\bap\encrypted\queuez\queuez_deferred_push.cpp" />
     <ClCompile Include="src\server\bap\encrypted\queuez\queuez_outcome_staging.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\queuez\queuez_update_frame.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\push\queuez\queuez_account_preflight.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\queuez\queuez_subscription.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\queuez\queuez_change_character.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\queuez\queuez_select_character.cpp" />

+ 152 - 0
Sunrise/docs/emote-unlocks.md

@@ -0,0 +1,152 @@
+# Emote ownership flags
+
+How this build decides an emote is owned, how the flags behind that were
+recovered, and the data itself. None of this is public: the Bungie manifest
+exposes only the failure message for these rules, never the expression behind
+it, so everything here was read out of the installed packages directly.
+
+## Where ownership actually lives
+
+**An emote's ownership is gated by its own item definition, not by a
+collectible.** Each emote item carries a plug rule -- the one whose failure
+message is *"You do not own this emote"* -- and that rule's unlock expression
+sits at **item-definition offset 720**. The enabled rule repeats the same
+expression at offset 800.
+
+The collectible table's acquired expression (`+112`) is a dead end for emotes:
+only 94 of this build's 307 emote items have a collectible row at all, so a
+collectible-driven pass cannot reach the other 213 no matter how it is written.
+
+Decoding offset 720 for every item in the individual-emote bucket (**41**)
+gives:
+
+| | count |
+| --- | ---: |
+| emote items in this build | 307 |
+| gated by an ownership flag | 291 |
+| distinct flag slots behind them | 288 |
+| carrying no expression at all | 16 |
+
+The 16 ungated ones are always owned -- that is why Yes, Nope and Cheer worked
+before any of this.
+
+## Slots are not indices
+
+This is the part that silently wastes a day.
+
+`state.unlocks.account_flag_runs` in `default_settings.json` does **not** hold
+flag slot numbers. It fills the account object's acquired-flag byte array, and
+the client addresses that array **by row number in the unlock flag mapping
+table**, not by slot. The two are unrelated number spaces.
+
+The mapping table for this bank:
+
+| | |
+| --- | --- |
+| investment root slot | 111 |
+| tag | `81319322` |
+| rows | 11923 |
+| row shape | `{ u32 unlock_hash; i16 destination_slot; u16 zero; }` |
+
+To set flag slot `s`, find the row whose `destination_slot == s`; that row's
+**number** is the index to write. Writing `s` itself sets an unrelated flag.
+
+Symptoms of getting this wrong, both observed here:
+
+- Writing slot numbers as indices does nothing visible, because the indices
+  that happen to be hit belong to unrelated slots.
+- Filling the whole bank "works" for emotes but also sets every entitlement
+  flag, which leaves the account unable to open the Director, the map, or
+  orbit. Do not blanket fill.
+
+Translating the 288 slots through the table yields **282 row indices**. The 6
+that do not resolve have no row in this table (they are reachable only through
+the family-5 override list, which is capped at 127 rows and so is not a route
+for a set this size).
+
+## The data
+
+Ownership flag **slots** (288) -- what the item definitions name:
+
+```
+229, 237-239, 264, 271, 2218, 2222, 2232-2238, 2240, 2242-2259, 3813-3820,
+3822-3836, 5200-5205, 5209-5220, 5228-5231, 5426-5427, 5429-5436, 6245-6264,
+6896-6902, 7350-7371, 7373-7374, 8159-8165, 8167-8182, 8999-9010, 9012-9013,
+9016-9021, 10408-10428, 10907-10932, 11431-11457, 11740-11770
+```
+
+Mapping-table **row indices** (282) -- what `account_flag_runs` must contain:
+
+```
+807, 811, 821-827, 829, 831-848, 2090-2097, 2099-2113, 3080-3085, 3089-3100,
+3107-3110, 3217-3218, 3220-3227, 3729-3748, 4195-4201, 4428-4449, 4451-4452,
+5057-5063, 5065-5080, 5676-5687, 5689-5690, 5693-5698, 6353-6373, 6734-6759,
+7148-7174, 7404-7434
+```
+
+161 of those indices were already set by the authored data, which is the 179
+emotes that were already owned (161 gated + 16 ungated + 2 sharing a flag).
+The change added the remaining 121.
+
+None of the 288 slots collides with any documented entitlement, platform, or
+pre-release slot. The Director, map and orbit were verified working afterwards.
+
+## Ownership is not always the only gate
+
+An emote can carry more than one plug rule. The ownership rule at offset 720 is
+the common case, but a few items add a second rule immediately after it, and
+that one is a **value** comparison rather than a flag read. Such an emote reads
+as owned and still refuses to equip, showing *"Access Restricted"*.
+
+`X Marks The Spot` (`0x1682F6A3`) is the worked example in this build:
+
+| offset | expression | meaning |
+| ---: | --- | --- |
+| 720 | `FLAG(9004)` | ownership, set by `account_flag_runs` |
+| 736 | `VAL(5549) CONST(50) >=` | `VAL(5549) >= 50` |
+| 864 | `FLAG(9004)` | the enabled rule, pushed down by the extra rule |
+
+`VAL(5549)` is the objective progress counter for the *Golden Offerings*
+triumph, so the real requirement is completing that triumph. Value slots are
+not part of the flag banks; the route to them is the family-5 override list:
+
+```json
+"family5_value_overrides": [ ..., [5549,50] ]
+```
+
+Setting it to the threshold exactly, rather than inflating it, keeps any other
+expression that compares the same slot honest -- raising a value slot too far
+is what breaks unrelated content, per the shared-pool warning in the unlock
+documentation.
+
+Note this also shifts the layout: an emote with the extra rule has its enabled
+rule at 864 rather than 800. 32 of the 307 emotes in this build do not have a
+plain single-flag expression at 800 for this reason, which is expected and not
+a fault.
+
+## Regenerating this
+
+The flag list is specific to this build; a content change invalidates it. To
+rebuild it, three temporary passes over the package data are needed:
+
+1. For every item with `bucketId == 41`, decode the unlock expression at
+   definition offset 720. A single `opcode 1` instruction carries the flag slot
+   as its operand. Expressions resolve as
+   `target = (offset of the pointer field) + (value stored there) + 16`, the
+   same self-relative form plus 16-byte block header that `find_array_at` uses.
+2. Read the mapping table at investment root slot 111 and build
+   `destination_slot -> row index`.
+3. Run-length-encode the union of the existing runs and the new indices.
+
+Watch the cache while doing this. `stale_format()` treats only
+`version < kCacheFormatVersion` as rebuildable, so a `build_data.bin` written
+by a *newer* format version is rejected outright rather than regenerated, which
+fails state initialisation and surfaces in the client as
+*"Verify integrity of game files"*. If the cache format version is changed and
+then reverted, delete `build_data.bin`.
+
+## Known gap
+
+These flags are recorded as a set. Which flag belongs to which *named* emote
+was never captured -- the extraction logged slots without their item hashes. It
+matters only if emotes ever need unlocking selectively rather than all at once.

Разница между файлами не показана из-за своего большого размера
+ 1 - 1
Sunrise/resources/default_settings.json


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

@@ -38,6 +38,18 @@ core::threading::SrwLock g_refreshLock{};
            && state::build_data::investment_constants_ready();
 }
 
+/**
+ * Runs the emote-collection canonicalization on the extraction path, where it is an opportunistic
+ * head start rather than a precondition: the snapshot path runs the same step behind its own
+ * preflight, so nothing here is the last chance to apply it.
+ * @return False only when the account itself could not be updated, which is the one outcome that
+ * says something is wrong rather than merely unfinished. A build that cannot carry the item, and
+ * one whose data is still being extracted, both leave the cache worth writing.
+ */
+[[nodiscard]] bool emote_collection_settled() noexcept {
+    return state::ensure_character_emote_collection() != state::EmoteCollectionOutcome::failed;
+}
+
 } // namespace
 
 /** @return True when the next refresh slice needs a visible overlay for a package sweep. */
@@ -53,6 +65,7 @@ bool refresh() noexcept {
         const std::lock_guard lock(g_refreshLock);
         const bool persisted = state::ensure_profile_item_identities()
                                && state::ensure_character_subclasses()
+                               && emote_collection_settled()
                                && 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.
@@ -70,7 +83,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()
+                          && emote_collection_settled()
+                          && 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);
+                }
             }
         }
     }

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

@@ -136,11 +136,14 @@ bool resolve_item(const authored_inventory::Item& authored,
     build_details::Definition itemDetail{};
     build_buckets::Descriptor bucket{};
     build_socket_lists::Definition socketList{};
+    std::uint8_t nativeEquipmentSlot = 0;
     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
+        || !authored_inventory::resolve_native_equipment_slot(
+            authored.definitionHash, itemDetail.equipmentSlot, nativeEquipmentSlot)
+        || static_cast<std::size_t>(nativeEquipmentSlot) >= build_details::kEquipmentSlotCount
         || !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 +155,7 @@ bool resolve_item(const authored_inventory::Item& authored,
 
     Candidate candidate{};
     candidate.bucket = bucket;
-    candidate.item.equipmentSlot = static_cast<std::uint8_t>(*itemDetail.equipmentSlot);
+    candidate.item.equipmentSlot = nativeEquipmentSlot;
     candidate.item.mutationSerial = authored.mutationSerial;
     candidate.item.flags = authored.flags;
     if (!resolve_quantity(authored, itemDetail, candidate.item.quantity)

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

@@ -211,6 +211,18 @@ namespace body {
 /** Owns server-initiated encrypted frames appended after correlated replies. */
 namespace push {
 
+/**
+ * Canonicalizes the account ahead of the family-specific snapshot dispatch.
+ * Families 0, 3 and 4 each take their own account snapshot, and the roster is built before the
+ * account companion, so a migration performed inside one family's builder would leave the others
+ * describing a different account: a Family-3 character record naming an emote instance the
+ * Family-4 manifest has already replaced, with no correction published afterwards. Running it
+ * ahead of every builder is what keeps the three images talking about one account.
+ * Idempotent, and one relaxed load once the answer can no longer change, so calling it from every
+ * entry point that reaches a builder costs nothing.
+ */
+void ensure_account_canonical() noexcept;
+
 /**
  * Appends the queuez snapshots one subscription needs, including the Family-4 companion.
  * A snapshot that cannot be built is reported and skipped. The subscribe is answered either way,

+ 64 - 0
Sunrise/src/server/bap/encrypted/push/queuez/queuez_account_preflight.cpp

@@ -0,0 +1,64 @@
+#include <array>
+#include <atomic>
+#include <cstdio>
+
+#include "../../../../../core/logging/log.h"
+#include "../../../../../state/runtime/runtime.h"
+#include "../../internal.h"
+
+namespace sunrise::server::bap::encrypted::push {
+namespace {
+
+/**
+ * Set once the answer can no longer change within this process, so the common path costs one
+ * relaxed load rather than a lock and a whole account copy on every pushed frame.
+ */
+std::atomic<bool> g_settled{false};
+
+/**
+ * Reports a preflight that left the account uncanonical, naming which of the two reasons it was.
+ * Silence here would be indistinguishable from a migration that ran, which is the confusion this
+ * whole preflight exists to remove.
+ */
+void report(const char* reason) noexcept {
+    std::array<char, 96> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=queuez stage=account_preflight result=skip reason=%s",
+                                      reason);
+    if (written > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+} // namespace
+
+/** Canonicalizes the account before any family image is allowed to read it. */
+void ensure_account_canonical() noexcept {
+    if (g_settled.load(std::memory_order_acquire)) {
+        return;
+    }
+    switch (state::ensure_character_emote_collection()) {
+    case state::EmoteCollectionOutcome::ready:
+        g_settled.store(true, std::memory_order_release);
+        break;
+    case state::EmoteCollectionOutcome::unsupported:
+        // The installed content decides this one and cannot change under a running process, so
+        // the verdict is final. Reported once rather than on every frame that follows.
+        g_settled.store(true, std::memory_order_release);
+        report("unsupported");
+        break;
+    case state::EmoteCollectionOutcome::notReady:
+        // Content extraction or account setup has not finished. Every family reads the same
+        // un-migrated account meanwhile, so they still agree with each other.
+        report("not_ready");
+        break;
+    case state::EmoteCollectionOutcome::failed:
+        report("failed");
+        break;
+    }
+}
+
+} // namespace sunrise::server::bap::encrypted::push

+ 18 - 11
Sunrise/src/server/bap/encrypted/push/queuez/queuez_banner_push.cpp

@@ -164,6 +164,9 @@ bool append_banner_notification(Scratch& scratch,
                                 std::size_t& written,
                                 queuez::SessionState& after) noexcept {
     after = before;
+    // Before the account is read, so this pair cannot describe a different account than the
+    // family-three roster or the family-four manifest.
+    ensure_account_canonical();
     // The pair names the first character when none is picked yet. The client's family-zero record
     // accepts a snapshot for about ten seconds, then clears the family and refuses every later
     // one, so holding the pair for the pick spends that window and the subscription times out.
@@ -240,6 +243,7 @@ bool append_banner_move_notification(Scratch& scratch,
     bool publish = false;
     bool incremental = false;
     after = before;
+    ensure_account_canonical();
     // A family zero with no first delivery yet has no ladder to move, and no root to name it with.
     const char* reason = nullptr;
     if (!queuez::stage_family0_subscription(
@@ -344,24 +348,25 @@ bool append_socket_appearance_refresh_notification(
         return false;
     }
     state::build_data::items::details::Definition detail{};
+    std::uint8_t nativeEquipmentSlot = 0;
     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)
+        || detail.bucketId != mutation.targetBucketId
+        || !state::account::inventory::resolve_native_equipment_slot(
+            mutation.targetDefinitionHash, detail.equipmentSlot, nativeEquipmentSlot)
+        || static_cast<std::size_t>(nativeEquipmentSlot)
                >= 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)) {
+    if (!snapshot::prepare_character_appearance_refresh(scratch,
+                                                        refresh,
+                                                        mutation.afterCharacter,
+                                                        mutation.characterIndex,
+                                                        nativeEquipmentSlot,
+                                                        true,
+                                                        prepared)) {
         return false;
     }
     return append_appearance_frame(
@@ -496,6 +501,7 @@ bool append_account_resync_appearance_notification(
     std::size_t& written,
     queuez::SessionState& after) noexcept {
     after = before;
+    ensure_account_canonical();
     if (!before.family0Active) {
         return true;
     }
@@ -538,6 +544,7 @@ bool append_account_resync_roster_notification(Scratch& scratch,
                                                std::size_t& written,
                                                queuez::SessionState& after) noexcept {
     after = before;
+    ensure_account_canonical();
     if (!before.family3Active) {
         return true;
     }

+ 5 - 0
Sunrise/src/server/bap/encrypted/push/queuez/queuez_subscription.cpp

@@ -90,6 +90,7 @@ bool append_account_resync_notification(Scratch& scratch,
                                         std::size_t& written,
                                         queuez::SessionState& after) noexcept {
     after = before;
+    ensure_account_canonical();
     if (!queuez::valid(before) || !before.family4Active || before.family4RootSoid == 0
         || before.family4Version == (std::numeric_limits<std::int32_t>::max)()) {
         return false;
@@ -146,6 +147,10 @@ void append_queuez_notification(Scratch& scratch,
     after = before;
     armsRepush = false;
     armsBannerRepush = false;
+    // Ahead of the dispatch below, not inside one family's builder: family zero reads the account
+    // directly and family three is built before the family-four companion, so a migration run any
+    // later would leave the three images describing different accounts.
+    ensure_account_canonical();
     if (subscription.familyType == queuez::kAccountFamilyType && before.family4Active
         && before.family4Version != queuez::kInitialFamilyVersion) {
         // Our mirror of the Client's records is an observation, not an authority on what may be

+ 4 - 0
Sunrise/src/server/bap/encrypted/push/snapshot/family4_snapshot_preparer.cpp

@@ -68,6 +68,10 @@ bool prepare(Scratch& scratch,
     if (!state::ensure_profile_item_identities()) {
         return report_failure("profile_identities");
     }
+    // The emote-collection canonicalization deliberately does not live here. Family zero and
+    // family three build their own images from the same account and neither passes through this
+    // function, so it runs in the shared preflight ahead of the whole dispatch instead
+    // (push::ensure_account_canonical).
     const state::AccountState account = state::account_snapshot();
     if (!state::account::valid(account)) {
         return report_failure("account_state");

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

@@ -45,6 +45,24 @@ std::optional<EquipmentSlot> slot_from_name(std::string_view name) noexcept {
     return std::nullopt;
 }
 
+/** Resolves the native equipment slot a configured item detail occupies. */
+bool resolve_native_equipment_slot(std::uint32_t definitionHash,
+                                   const std::optional<std::int8_t>& detailEquipmentSlot,
+                                   std::uint8_t& nativeSlot) noexcept {
+    if (detailEquipmentSlot.has_value()) {
+        if (*detailEquipmentSlot < 0) {
+            return false;
+        }
+        nativeSlot = static_cast<std::uint8_t>(*detailEquipmentSlot);
+        return true;
+    }
+    if (definitionHash != kEmoteCollectionDefinitionHash) {
+        return false;
+    }
+    nativeSlot = kEmoteCollectionNativeEquipmentSlot;
+    return true;
+}
+
 /** Checks the canonical socket policy and every authored plug hash. */
 bool valid(const Sockets& sockets) noexcept {
     if (sockets.plugCount > sockets.plugs.size()) {

+ 31 - 0
Sunrise/src/state/account/inventory/inventory_state.h

@@ -64,6 +64,37 @@ inline constexpr std::uint64_t kFirstProfileItemInstanceSoid = 0x500000000000000
  */
 inline constexpr std::size_t kCharacterItemCapacity = 135;
 
+/**
+ * Definition hash of the real, non-equippable "Emotes" collection item. The Client opens its own
+ * wheel-configuration screen for this exact item once it is equipped with valid socket data.
+ */
+inline constexpr std::uint32_t kEmoteCollectionDefinitionHash = 3183180185U;
+/** Ordinary socket lane count the "Emotes" collection item's real content declares. */
+inline constexpr std::size_t kEmoteCollectionSocketLaneCount = 4;
+/**
+ * Native equipment slot the "Emotes" collection item is equipped under, in place of the individual
+ * emote it replaces. Its own real content carries no native equipment-slot mapping at all, unlike
+ * every other character-scoped item, so callers that need one for this item specifically fall back
+ * to this constant through resolve_native_equipment_slot() below.
+ */
+inline constexpr std::uint8_t kEmoteCollectionNativeEquipmentSlot =
+    static_cast<std::uint8_t>(EquipmentSlot::emote);
+
+/**
+ * Resolves the native equipment slot a configured item detail occupies.
+ * Every character-scoped item declares its own native slot except the "Emotes" collection item
+ * (kEmoteCollectionDefinitionHash), the one item whose real content has none. Any other item
+ * missing a native slot is rejected instead of silently aliasing this fallback.
+ * @param definitionHash Authored item definition hash being resolved.
+ * @param detailEquipmentSlot The installed item detail's own native slot, if it declares one.
+ * @param nativeSlot Receives the resolved native slot on success.
+ * @return True when the item declares its own non-negative slot, or is the Emotes collection item.
+ */
+[[nodiscard]] bool
+resolve_native_equipment_slot(std::uint32_t definitionHash,
+                              const std::optional<std::int8_t>& detailEquipmentSlot,
+                              std::uint8_t& nativeSlot) noexcept;
+
 /** One authored account-wide item, placed by the inventory bucket its definition names. */
 struct ProfileItem {
     /** Stable runtime identity required to materialize this row as an inventory action source. */

+ 8 - 5
Sunrise/src/state/equipment/light/resolution/configured_equipment_light_resolver.cpp

@@ -45,16 +45,19 @@ using NativeSlotMap = std::array<std::optional<std::size_t>, build_details::kEqu
 resolve_item(const authored::Item& item, std::size_t& nativeSlot, ItemScore& itemScore) noexcept {
     build_items::Definition definition{};
     build_details::Definition detail{};
+    std::uint8_t resolvedSlot = 0;
     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
+        || !authored::resolve_native_equipment_slot(
+            item.definitionHash, detail.equipmentSlot, resolvedSlot)
+        || static_cast<std::size_t>(resolvedSlot) >= build_details::kEquipmentSlotCount) {
         return false;
     }
-    nativeSlot = static_cast<std::size_t>(*detail.equipmentSlot);
+    nativeSlot = static_cast<std::size_t>(resolvedSlot);
+    // The "Emotes" collection item's real content contributes no light either way.
     std::int32_t power = 0;
-    if (!item_power(item.level, power)) {
+    if (detail.equipmentSlot.has_value() && !item_power(item.level, power)) {
         return false;
     }
     itemScore = ItemScore{definition.definitionIndex, power};

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

@@ -21,6 +21,18 @@ namespace sunrise::state {
  */
 [[nodiscard]] bool ensure_profile_item_identities() noexcept;
 
+/** Why one attempt to canonicalize the "Emotes" collection item ended. */
+enum class EmoteCollectionOutcome : std::uint8_t {
+    /** Every character carries a sound collection item, either already or as of this call. */
+    ready,
+    /** The build data or account this reads is not published yet, so a retry is still owed. */
+    notReady,
+    /** The installed content does not carry the item this expects, so it can never be applied. */
+    unsupported,
+    /** The item could not be placed, so no character was changed and a retry is still owed. */
+    failed,
+};
+
 /**
  * Grants each character the other 2 subclasses of its equipped subclass's class, placing missing
  * ones into unequipped inventory with native socket defaults. Idempotent: one already equipped or
@@ -63,6 +75,19 @@ 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. The stock client opens its own wheel-configuration screen for this
+ * item; its 4 ordinary sockets seed default lanes from the item's real plug pool so the wheel has
+ * something in every slot the first time it opens.
+ * Idempotent, and safe to call from more than one boundary: a character already carrying a sound
+ * copy is left alone, and one whose sockets no longer resolve is repaired in place, keeping its
+ * instance identity and every field this does not own.
+ * The outcome distinguishes "nothing to do" from "could not be done", so a caller never records
+ * the account as canonical on the strength of a prerequisite that was never met.
+ */
+[[nodiscard]] EmoteCollectionOutcome ensure_character_emote_collection() noexcept;
+
 /** Direction of one checked character equipment mutation. */
 enum class EquipmentMutationKind : std::uint8_t {
     none,

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

@@ -579,4 +579,189 @@ bool commit_profile_item_acquisition(PendingProfileItemAcquisition& mutation) no
     return ready;
 }
 
+namespace {
+
+/**
+ * Default plug hashes the wheel seeds when granting or repairing the collection item's sockets.
+ * All four are universal Common emotes from Bright Engrams, with no class or race restriction:
+ * "Yes" (3184938442), "Nope" (48790291), "Casual Sit" (383973261), "Cheer" (2834933816).
+ * Lane order follows the client's own wheel layout, confirmed empirically in-game:
+ * lane 0 = top, lane 1 = bottom, lane 2 = left, lane 3 = right.
+ */
+constexpr std::uint32_t kYesEmoteDefinitionHash = 3184938442U;
+constexpr std::uint32_t kNopeEmoteDefinitionHash = 48790291U;
+constexpr std::uint32_t kCasualSitEmoteDefinitionHash = 383973261U;
+constexpr std::uint32_t kCheerEmoteDefinitionHash = 2834933816U;
+
+constexpr std::array<std::uint32_t, authored_inventory::kEmoteCollectionSocketLaneCount>
+    kEmoteCollectionDefaultPlugHashes{
+        kCheerEmoteDefinitionHash,     // lane 0 -- top
+        kCasualSitEmoteDefinitionHash, // lane 1 -- bottom
+        kYesEmoteDefinitionHash,       // lane 2 -- left
+        kNopeEmoteDefinitionHash,      // lane 3 -- right
+    };
+
+/**
+ * Resolves and cross-checks the "Emotes" collection item's own configured content. The detail row
+ * is only read to validate the definition, so it stays local rather than reaching the caller.
+ * @param definition Receives the matching native item-definition row.
+ * @return True only when both rows agree with each other, carry no native equipment slot (the one
+ *         trait that singles this item out among every character-scoped item), and declare exactly
+ *         the expected 4 ordinary socket lanes.
+ */
+[[nodiscard]] bool
+resolve_emote_collection_definition(build_data::items::Definition& definition) noexcept {
+    item_details::Definition detail{};
+    return build_data::find_item_definition_hash(authored_inventory::kEmoteCollectionDefinitionHash,
+                                                 definition)
+           && definition.definitionHash == authored_inventory::kEmoteCollectionDefinitionHash
+           && build_data::find_configured_item_detail(definition.definitionIndex, detail)
+           && detail.definitionIndex == definition.definitionIndex
+           && detail.definitionHash == authored_inventory::kEmoteCollectionDefinitionHash
+           && detail.bucketId == definition.bucketId && !detail.equipmentSlot.has_value()
+           && detail.ordinarySocketState == item_details::OrdinarySocketState::present
+           && detail.ordinarySocketCount == authored_inventory::kEmoteCollectionSocketLaneCount;
+}
+
+/**
+ * Checks that every one of the collection item's real plug pool candidates is actually installed
+ * and allowed in its intended lane, so a granted item can never carry a plug the client rejects.
+ */
+[[nodiscard]] bool default_plugs_valid(std::uint16_t collectionDefinitionIndex) noexcept {
+    for (std::size_t lane = 0; lane < kEmoteCollectionDefaultPlugHashes.size(); ++lane) {
+        build_data::items::Definition plugDefinition{};
+        if (!build_data::find_item_definition_hash(kEmoteCollectionDefaultPlugHashes[lane],
+                                                   plugDefinition)
+            || !build_data::is_socket_plug_allowed(collectionDefinitionIndex,
+                                                   static_cast<std::uint8_t>(lane),
+                                                   plugDefinition.definitionIndex)) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/**
+ * Checks an already-equipped collection item's own socket state, so a corrupted or stale set of
+ * plugs is repaired instead of trusted just because the definition hash already matches.
+ */
+[[nodiscard]] bool socket_state_sound(const authored_inventory::Item& item,
+                                      std::uint16_t collectionDefinitionIndex) noexcept {
+    if (item.sockets.policy != authored_inventory::SocketPolicy::authored
+        || item.sockets.plugCount != authored_inventory::kEmoteCollectionSocketLaneCount) {
+        return false;
+    }
+    for (std::size_t lane = 0; lane < authored_inventory::kEmoteCollectionSocketLaneCount; ++lane) {
+        const std::optional<std::uint32_t>& plugHash = item.sockets.plugs[lane];
+        build_data::items::Definition plugDefinition{};
+        if (!plugHash.has_value()
+            || !build_data::find_item_definition_hash(*plugHash, plugDefinition)
+            || !build_data::is_socket_plug_allowed(collectionDefinitionIndex,
+                                                   static_cast<std::uint8_t>(lane),
+                                                   plugDefinition.definitionIndex)) {
+            return false;
+        }
+    }
+    return true;
+}
+
+} // namespace
+
+/**
+ * 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 authored_inventory::kEmoteCollectionNativeEquipmentSlot for it
+ * specifically, gated to its exact definition hash (state::account::inventory::
+ * resolve_native_equipment_slot). 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.
+ */
+EmoteCollectionOutcome ensure_character_emote_collection() noexcept {
+    constexpr std::size_t kEmoteCollectionSlot =
+        static_cast<std::size_t>(authored_inventory::EquipmentSlot::emote);
+
+    // The domains every check below reads have to be published first. Until they are, nothing can
+    // be concluded about the installed content, so this is a retry rather than a verdict.
+    if (!build_data::item_definitions_ready() || !build_data::configured_item_details_ready()
+        || !build_data::socket_plug_rules_ready()) {
+        return EmoteCollectionOutcome::notReady;
+    }
+    // With those published, an item that still does not resolve this way is a build that cannot
+    // carry the wheel at all. Retrying that within this process would never change the answer.
+    build_data::items::Definition collectionDefinition{};
+    if (!resolve_emote_collection_definition(collectionDefinition)
+        || !default_plugs_valid(collectionDefinition.definitionIndex)) {
+        return EmoteCollectionOutcome::unsupported;
+    }
+
+    AcquireSRWLockExclusive(&runtime::storage::g_stateLock);
+    AccountState candidate = runtime::storage::g_state.account;
+    if (!account::valid(candidate)) {
+        ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
+        return EmoteCollectionOutcome::notReady;
+    }
+    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];
+        const bool present =
+            collectionSlot.has_value()
+            && collectionSlot->definitionHash == authored_inventory::kEmoteCollectionDefinitionHash;
+        if (present && socket_state_sound(*collectionSlot, collectionDefinition.definitionIndex)) {
+            continue;
+        }
+        if (character.nextInventorySerial
+            >= static_cast<std::uint32_t>((std::numeric_limits<std::int32_t>::max)())) {
+            failed = true;
+            break;
+        }
+        // A repair owns only the definition, the sockets and the serial. Everything else the item
+        // already carries, the accumulated item-state flags above all, belongs to the player and
+        // survives. The account was checked whole on entry, so a present item's remaining scalars
+        // are already known good and need no normalizing here.
+        authored_inventory::Item granted = present ? *collectionSlot : authored_inventory::Item{};
+        if (!present) {
+            std::uint64_t instanceSoid = 0;
+            if (!next_item_instance_soid(candidate, instanceSoid)) {
+                failed = true;
+                break;
+            }
+            granted.instanceSoid = instanceSoid;
+            granted.level = 0;
+            granted.quantity = 1;
+        }
+        granted.definitionHash = authored_inventory::kEmoteCollectionDefinitionHash;
+        granted.mutationSerial = static_cast<std::int32_t>(character.nextInventorySerial++);
+        // Replaced whole rather than edited: the lanes past the used prefix have to be empty for
+        // the socket block to validate, whatever the malformed copy left behind.
+        granted.sockets = authored_inventory::Sockets{};
+        granted.sockets.policy = authored_inventory::SocketPolicy::authored;
+        granted.sockets.plugCount = kEmoteCollectionDefaultPlugHashes.size();
+        for (std::size_t lane = 0; lane < kEmoteCollectionDefaultPlugHashes.size(); ++lane) {
+            granted.sockets.plugs[lane] = kEmoteCollectionDefaultPlugHashes[lane];
+        }
+        collectionSlot = granted;
+        changed = true;
+    }
+    if (failed) {
+        ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
+        return EmoteCollectionOutcome::failed;
+    }
+    if (!changed) {
+        ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
+        return EmoteCollectionOutcome::ready;
+    }
+    if (!account::valid(candidate)) {
+        ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
+        return EmoteCollectionOutcome::failed;
+    }
+    runtime::storage::g_state.account = candidate;
+    ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
+    return EmoteCollectionOutcome::ready;
+}
+
 } // namespace sunrise::state

Некоторые файлы не были показаны из-за большого количества измененных файлов