Jelajahi Sumber

Store ability-node picks per subclass item instead of per character

Ability-entry picks (movement/grenade/super/melee/class) lived as 5 flat fields
on CharacterState, shared across every subclass a character owns rather than
scoped to whichever one is equipped. Picking a node on one subclass therefore
applied to all of them simultaneously, since there was nowhere for a
per-subclass value to live.

Moved the 5 fields onto the Item struct itself so each owned subclass instance
remembers its own picks independently, matching real per-subclass persistence:
- stage_subclass_selection now mutates the equipped subclass item's own
  fields instead of the character's.
- same_item/same_loadout comparisons moved accordingly.
- subclass_selection()/resolve_socket_states() take the specific item (plus
  the owning character's class, needed only for the class-ability bucket
  route) instead of the whole character.
- The ability-bucket extraction pass in package_subclass_build.cpp now
  checks every owned subclass group member for its own non-default
  selection, not just the equipped one, so swapping to a subclass configured
  earlier in the same session immediately resolves correctly instead of
  needing a fresh pick.
- configured_equipment_identity.cpp's cache-identity hash mixes the ability
  entries per item instead of per character, keeping it consistent with
  where the data now lives.
Millie 3 minggu lalu
induk
melakukan
784659919d

+ 35 - 14
Sunrise/src/client/content/items/packages/package_subclass_build.cpp

@@ -70,13 +70,14 @@ held(std::span<const state::build_data::socket_entry_buckets::Definition> rows,
     return false;
 }
 
-/** @param character Authored character. @return Its 5 selected socket entries. */
-[[nodiscard]] domain::Selection selection_of(const state::CharacterState& character) noexcept {
-    return {character.movementAbilityEntry,
-            character.grenadeAbilityEntry,
-            character.superAbilityEntry,
-            character.meleeAbilityEntry,
-            character.classAbilityEntry};
+/** @param item Authored subclass item. @return Its 5 selected socket entries. */
+[[nodiscard]] domain::Selection
+selection_of(const state::account::inventory::Item& item) noexcept {
+    return {item.movementAbilityEntry,
+            item.grenadeAbilityEntry,
+            item.superAbilityEntry,
+            item.meleeAbilityEntry,
+            item.classAbilityEntry};
 }
 
 } // namespace
@@ -201,7 +202,6 @@ bool build_character_abilities(const reader::Source& source,
             memberCount = group.size();
         }
 
-        const domain::Selection realSelection = selection_of(account.characters[character]);
         for (std::size_t member = 0; member < memberCount && count < output.size(); ++member) {
             const std::uint16_t memberDefinitionIndex = members[member];
             state::build_data::items::details::Definition memberDetail{};
@@ -210,12 +210,33 @@ bool build_character_abilities(const reader::Source& source,
                 continue;
             }
             publish(character, memberDetail.socketEntryListIndex, defaultSelection);
-            // The equipped member also publishes its real current picks, on top of the default
-            // row every member gets: a fresh boot that never swapped needs its actual selection
-            // to resolve, not the shared default.
-            if (memberDefinitionIndex == equippedItem.definitionIndex
-                && !(realSelection == defaultSelection)) {
-                publish(character, memberDetail.socketEntryListIndex, realSelection);
+            // Each owned subclass remembers its own picks now, not just the equipped one, so
+            // every member is checked for a non-default selection to publish on top of the
+            // default row every member gets: a fresh boot that never swapped needs its actual
+            // selection to resolve, not the shared default, for whichever subclasses were
+            // already configured before this boot.
+            const domain::Selection* memberSelection = nullptr;
+            domain::Selection resolvedSelection{};
+            if (memberDefinitionIndex == equippedItem.definitionIndex) {
+                resolvedSelection = selection_of(*equippedSlot);
+                memberSelection = &resolvedSelection;
+            } else {
+                state::build_data::items::Definition memberItemDefinition{};
+                if (state::build_data::find_item_definition_index(memberDefinitionIndex,
+                                                                   memberItemDefinition)) {
+                    const auto& inventory = account.characters[character].inventory;
+                    for (std::size_t itemIndex = 0; itemIndex < inventory.count; ++itemIndex) {
+                        if (inventory.values[itemIndex].definitionHash
+                            == memberItemDefinition.definitionHash) {
+                            resolvedSelection = selection_of(inventory.values[itemIndex]);
+                            memberSelection = &resolvedSelection;
+                            break;
+                        }
+                    }
+                }
+            }
+            if (memberSelection != nullptr && !(*memberSelection == defaultSelection)) {
+                publish(character, memberDetail.socketEntryListIndex, *memberSelection);
             }
         }
     }

+ 17 - 8
Sunrise/src/middleware/datagen/character_record/appearance/character_appearance_abilities.cpp

@@ -6,13 +6,18 @@ namespace {
 
 namespace buckets = state::build_data::abilities;
 
-/** @param character Authored character. @return Its 5 selected socket entries. */
-[[nodiscard]] buckets::Selection selection_of(const state::CharacterState& character) noexcept {
-    return {character.movementAbilityEntry,
-            character.grenadeAbilityEntry,
-            character.superAbilityEntry,
-            character.meleeAbilityEntry,
-            character.classAbilityEntry};
+/** The authored equipment slot that holds the subclass. */
+constexpr std::size_t kSubclassSlot =
+    static_cast<std::size_t>(state::account::inventory::EquipmentSlot::subclass);
+
+/** @param item Authored subclass item. @return Its 5 selected socket entries. */
+[[nodiscard]] buckets::Selection
+selection_of(const state::account::inventory::Item& item) noexcept {
+    return {item.movementAbilityEntry,
+            item.grenadeAbilityEntry,
+            item.superAbilityEntry,
+            item.meleeAbilityEntry,
+            item.classAbilityEntry};
 }
 
 } // namespace
@@ -25,6 +30,10 @@ bool apply_ability_buckets(const state::CharacterState& character,
         if (instances.items[index].equipmentSlot != kSubclassEquipmentSlot) {
             continue;
         }
+        const auto& subclassItem = character.equipment.slots[kSubclassSlot];
+        if (!subclassItem.has_value()) {
+            return false;
+        }
         details::Definition detail{};
         buckets::Definition published{};
         if (!state::build_data::find_configured_item_detail(
@@ -32,7 +41,7 @@ bool apply_ability_buckets(const state::CharacterState& character,
             return false;
         }
         if (!state::build_data::find_ability_buckets(
-                detail.socketEntryListIndex, selection_of(character), published)) {
+                detail.socketEntryListIndex, selection_of(*subclassItem), published)) {
             // The domain has not caught up with this selection yet (a fresh pick invalidates it
             // until the next investment refresh slice republishes). Publish empty buckets for
             // this one encode, the same as a character with no subclass equipped, instead of

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

@@ -176,7 +176,9 @@ bool resolve_item(const authored_inventory::Item& authored,
     candidate.item.instance.socketEntryCount = socketList.entryCount;
     candidate.item.instance.socketEntryContentsResolved = true;
     resolve_socket_states(socketList,
-                          character,
+                          authored,
+                          character.characterClass,
+                          character.acquiredSubclassAbilityMask,
                           candidate.item.instance.socketEntryStates,
                           candidate.item.instance.socketSelectors);
     output = candidate;

+ 13 - 11
Sunrise/src/middleware/datagen/family4/loadout/subclass_socket_selection.cpp

@@ -18,23 +18,25 @@ constexpr std::uint8_t kSprintEntry = 1;
 
 } // namespace
 
-/** Builds the selection for one character. */
-void subclass_selection(const state::CharacterState& character,
+/** Builds the selection for one subclass item. */
+void subclass_selection(const state::account::inventory::Item& item,
+                        state::CharacterClass characterClass,
                         SubclassSelection& output) noexcept {
     output = {};
-    output.selected[0] = {character.grenadeAbilityEntry, state::kGrenadeAbilityBucket};
-    output.selected[1] = {character.superAbilityEntry, state::kSuperAbilityBucket};
-    output.selected[2] = {character.meleeAbilityEntry, state::kMeleeAbilityBucket};
-    output.selected[3] = {character.movementAbilityEntry, state::kMovementAbilityBucket};
+    output.selected[0] = {item.grenadeAbilityEntry, state::kGrenadeAbilityBucket};
+    output.selected[1] = {item.superAbilityEntry, state::kSuperAbilityBucket};
+    output.selected[2] = {item.meleeAbilityEntry, state::kMeleeAbilityBucket};
+    output.selected[3] = {item.movementAbilityEntry, state::kMovementAbilityBucket};
     output.selected[4] = {kSprintEntry, state::kSprintAbilityBucket};
-    output.selected[5] = {character.classAbilityEntry,
-                          state::class_ability_bucket(character.characterClass)};
+    output.selected[5] = {item.classAbilityEntry, state::class_ability_bucket(characterClass)};
 }
 
 /** Resolves one item's socket-entry states and selector lanes. */
 void resolve_socket_states(
     const build_socket_lists::Definition& definition,
-    const state::CharacterState& character,
+    const state::account::inventory::Item& item,
+    state::CharacterClass characterClass,
+    std::uint64_t acquiredSubclassAbilityMask,
     std::array<instance::SocketEntryState, instance::layout::kSocketEntryStateCapacity>& output,
     std::array<instance::SocketSelector, kSelectorBucketCount>& selectors) noexcept {
     output.fill(instance::SocketEntryState::absent);
@@ -42,7 +44,7 @@ void resolve_socket_states(
     for (std::size_t index = 0; index < definition.entryCount; ++index) {
         const std::uint64_t bit = std::uint64_t{1} << index;
         if ((definition.readyMask & bit) != 0) {
-            output[index] = (character.acquiredSubclassAbilityMask & bit) != 0
+            output[index] = (acquiredSubclassAbilityMask & bit) != 0
                                 ? instance::SocketEntryState::acquired
                                 : instance::SocketEntryState::ready;
         }
@@ -53,7 +55,7 @@ void resolve_socket_states(
         return;
     }
     SubclassSelection selection{};
-    subclass_selection(character, selection);
+    subclass_selection(item, characterClass, selection);
 
     // A group of 2 or 3 entries (grenade, movement, class ability) is an ordinary set of mutually
     // exclusive alternatives: exactly one is meant to light up. An Attunement's group is far

+ 16 - 7
Sunrise/src/middleware/datagen/family4/loadout/subclass_socket_selection.h

@@ -27,26 +27,35 @@ struct SubclassSelection {
 };
 
 /**
- * Builds the selection for one character. Only sprint is fixed; the grenade, super, melee,
- * movement and class entries are the character's own authored choices, and the bucket the class
- * ability publishes into follows its class.
- * @param character Authored character carrying its class and every ability choice.
+ * Builds the selection for one subclass item. Only sprint is fixed; the grenade, super, melee,
+ * movement and class entries are that item's own authored choices (each owned subclass remembers
+ * its own picks independently), and the bucket the class ability publishes into follows the
+ * owning character's class.
+ * @param item Authored subclass item carrying its own ability choices.
+ * @param characterClass Owning character's class, which the class-ability bucket follows.
  * @param output Receives the 6 selected entries.
  */
-void subclass_selection(const state::CharacterState& character, SubclassSelection& output) noexcept;
+void subclass_selection(const state::account::inventory::Item& item,
+                        state::CharacterClass characterClass,
+                        SubclassSelection& output) noexcept;
 
 /**
  * Resolves one item's socket-entry states and selector lanes.
  * Only a list that carries a super lane belongs to a subclass, so every other item keeps its
  * absent and ready states and publishes no selector.
  * @param definition Installed socket-entry-list mapping.
- * @param character Authored character carrying its class and every ability choice.
+ * @param item Authored item being resolved; only its own ability choices matter when it is a
+ * subclass.
+ * @param characterClass Owning character's class, which the class-ability bucket follows.
+ * @param acquiredSubclassAbilityMask Owning character's runtime acquired-entry mask.
  * @param output Receives the state of every fixed lane.
  * @param selectors Receives the selector lane of every semantic bucket.
  */
 void resolve_socket_states(
     const state::build_data::socket_entry_lists::Definition& definition,
-    const state::CharacterState& character,
+    const state::account::inventory::Item& item,
+    state::CharacterClass characterClass,
+    std::uint64_t acquiredSubclassAbilityMask,
     std::array<instance::SocketEntryState, instance::layout::kSocketEntryStateCapacity>& output,
     std::array<instance::SocketSelector, kSelectorBucketCount>& selectors) noexcept;
 

+ 0 - 14
Sunrise/src/state/account/account_state.h

@@ -116,20 +116,6 @@ struct CharacterState {
     std::uint32_t lastOrbitedDestination{};
     /** Server policy that arms content checks only with the matching family-5 flag. */
     bool contentBypass{};
-    /**
-     * Socket-entry-list entry naming the movement ability this character has selected.
-     * One subclass group holds several movement entries, and the selected one decides which
-     * ability buckets the character record publishes. A player choice, so it is authored.
-     */
-    std::uint8_t movementAbilityEntry{kDefaultMovementAbilityEntry};
-    /** Socket entry naming the grenade this character has selected. */
-    std::uint8_t grenadeAbilityEntry{kDefaultGrenadeAbilityEntry};
-    /** Socket entry naming the super this character has selected. */
-    std::uint8_t superAbilityEntry{kDefaultSuperAbilityEntry};
-    /** Socket entry naming the melee this character has selected. */
-    std::uint8_t meleeAbilityEntry{kDefaultMeleeAbilityEntry};
-    /** Socket entry naming the class ability this character has selected. */
-    std::uint8_t classAbilityEntry{kDefaultClassAbilityEntry};
     /**
      * Runtime-only socket entries the player has selected at least once. Selected entries still
      * publish active; this mask keeps a later inactive entry acquired instead of new/unclaimed.

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

@@ -88,6 +88,18 @@ struct Item {
     /** Native accumulated item-state bits such as the finisher favorite marker. */
     std::uint32_t flags{};
     Sockets sockets;
+    /**
+     * Selected ability-node socket entries. Only meaningful when this item is a subclass; every
+     * other item carries these unused. Kept on the item rather than the character so each owned
+     * subclass remembers its own picks independently instead of sharing one set across all of
+     * them. Defaults match state::kDefault*AbilityEntry (the first option of each group, where
+     * every shipped subclass starts) but are literal here to avoid a circular include.
+     */
+    std::uint8_t movementAbilityEntry{4};
+    std::uint8_t grenadeAbilityEntry{7};
+    std::uint8_t superAbilityEntry{10};
+    std::uint8_t meleeAbilityEntry{11};
+    std::uint8_t classAbilityEntry{2};
 };
 
 /** Ordered unequipped items placed into their native character-inventory bucket ranges. */

+ 8 - 15
Sunrise/src/state/runtime/equipment/configured_equipment_identity.cpp

@@ -74,20 +74,14 @@ void mix_item(std::uint64_t& hash, const account::inventory::Item& item) noexcep
     mix_value(hash, item.definitionHash);
     mix_value(hash, static_cast<std::uint32_t>(item.level));
     mix_sockets(hash, item.sockets);
-}
-
-/**
- * Mixes one character's 5 selected subclass entries.
- * The ability bucket rows are keyed by these, so a changed pick must rebuild.
- * @param hash Mutable 64-bit FNV-1a accumulator.
- * @param character Authored character.
- */
-void mix_ability_selection(std::uint64_t& hash, const CharacterState& character) noexcept {
-    mix_byte(hash, character.movementAbilityEntry);
-    mix_byte(hash, character.grenadeAbilityEntry);
-    mix_byte(hash, character.superAbilityEntry);
-    mix_byte(hash, character.meleeAbilityEntry);
-    mix_byte(hash, character.classAbilityEntry);
+    // Meaningful only for a subclass, but mixed for every item: the ability bucket rows are keyed
+    // by these, so a changed pick must rebuild, and they live on the item now (each owned
+    // subclass remembers its own picks independently rather than sharing one set).
+    mix_byte(hash, item.movementAbilityEntry);
+    mix_byte(hash, item.grenadeAbilityEntry);
+    mix_byte(hash, item.superAbilityEntry);
+    mix_byte(hash, item.meleeAbilityEntry);
+    mix_byte(hash, item.classAbilityEntry);
 }
 
 } // namespace
@@ -99,7 +93,6 @@ std::uint64_t configured_hash(const AccountState& accountState) noexcept {
     for (std::size_t characterIndex = 0; characterIndex < accountState.characterCount;
          ++characterIndex) {
         const CharacterState& character = accountState.characters[characterIndex];
-        mix_ability_selection(hash, character);
         for (const std::optional<account::inventory::Item>& item : character.equipment.slots) {
             if (!item.has_value()) {
                 mix_byte(hash, kAbsentItemMarker);

+ 6 - 6
Sunrise/src/state/runtime/state_account_equipment_runtime.cpp

@@ -292,7 +292,12 @@ finalize_equipment_transition(const AccountState& account,
            && left.level == right.level && left.quantity == right.quantity
            && left.flags == right.flags && left.sockets.policy == right.sockets.policy
            && left.sockets.plugCount == right.sockets.plugCount
-           && left.sockets.plugs == right.sockets.plugs;
+           && left.sockets.plugs == right.sockets.plugs
+           && left.movementAbilityEntry == right.movementAbilityEntry
+           && left.grenadeAbilityEntry == right.grenadeAbilityEntry
+           && left.superAbilityEntry == right.superAbilityEntry
+           && left.meleeAbilityEntry == right.meleeAbilityEntry
+           && left.classAbilityEntry == right.classAbilityEntry;
 }
 
 /** Records one checked native item-state transition. */
@@ -347,11 +352,6 @@ void report_item_state(std::string_view stage,
         || left.appearanceValue != right.appearanceValue
         || left.lastOrbitedDestination != right.lastOrbitedDestination
         || left.contentBypass != right.contentBypass
-        || left.movementAbilityEntry != right.movementAbilityEntry
-        || left.grenadeAbilityEntry != right.grenadeAbilityEntry
-        || left.superAbilityEntry != right.superAbilityEntry
-        || left.meleeAbilityEntry != right.meleeAbilityEntry
-        || left.classAbilityEntry != right.classAbilityEntry
         || left.nextInventorySerial != right.nextInventorySerial
         || left.inventory.count != right.inventory.count) {
         return false;

+ 9 - 5
Sunrise/src/state/runtime/state_account_runtime.cpp

@@ -152,17 +152,21 @@ void report_acquisition(std::string_view stage,
     // members freely across slots, so every member in the clicked entry's bundle is checked, not
     // just the one clicked.
     CharacterState after = before;
+    // The picks belong to the equipped subclass item itself, not the character, so each owned
+    // subclass remembers its own selection independently instead of sharing one set across all
+    // of them.
+    auto& afterSubclass = after.equipment.slots[kSubclassSlot];
     struct Route {
         std::uint8_t bucket;
         std::uint8_t* field;
         std::uint8_t defaultEntry;
     };
     const std::array<Route, 5> routes{{
-        {kMovementAbilityBucket, &after.movementAbilityEntry, kDefaultMovementAbilityEntry},
-        {kGrenadeAbilityBucket, &after.grenadeAbilityEntry, kDefaultGrenadeAbilityEntry},
-        {kSuperAbilityBucket, &after.superAbilityEntry, kDefaultSuperAbilityEntry},
-        {kMeleeAbilityBucket, &after.meleeAbilityEntry, kDefaultMeleeAbilityEntry},
-        {class_ability_bucket(after.characterClass), &after.classAbilityEntry,
+        {kMovementAbilityBucket, &afterSubclass->movementAbilityEntry, kDefaultMovementAbilityEntry},
+        {kGrenadeAbilityBucket, &afterSubclass->grenadeAbilityEntry, kDefaultGrenadeAbilityEntry},
+        {kSuperAbilityBucket, &afterSubclass->superAbilityEntry, kDefaultSuperAbilityEntry},
+        {kMeleeAbilityBucket, &afterSubclass->meleeAbilityEntry, kDefaultMeleeAbilityEntry},
+        {class_ability_bucket(after.characterClass), &afterSubclass->classAbilityEntry,
          kDefaultClassAbilityEntry},
     }};
     const auto bucket_of = [&](std::uint8_t entryIndex) noexcept {