Browse Source

Merge pull request #27 from Nyxaraa/master

Add subclass equipping and full ability-node selection
stan 3 weeks ago
parent
commit
2643911413
53 changed files with 1944 additions and 181 deletions
  1. 6 4
      Sunrise/src/client/content/investment/investment_refresh.cpp
  2. 8 0
      Sunrise/src/client/content/investment/worker.h
  3. 10 0
      Sunrise/src/client/content/investment/worker/investment_refresh_worker.cpp
  4. 24 1
      Sunrise/src/client/content/items/packages/internal.h
  5. 118 10
      Sunrise/src/client/content/items/packages/package_ability_build.cpp
  6. 2 0
      Sunrise/src/client/content/items/packages/package_item_build.cpp
  7. 13 4
      Sunrise/src/client/content/items/packages/package_item_rows.cpp
  8. 138 33
      Sunrise/src/client/content/items/packages/package_subclass_build.cpp
  9. 0 6
      Sunrise/src/core/settings/parser.h
  10. 10 28
      Sunrise/src/core/settings/state/account_rows_parser.cpp
  11. 26 10
      Sunrise/src/middleware/datagen/character_record/appearance/character_appearance_abilities.cpp
  12. 1 1
      Sunrise/src/middleware/datagen/family4/instance/instance_encoder.cpp
  13. 2 0
      Sunrise/src/middleware/datagen/family4/instance/instance_encoder.h
  14. 3 1
      Sunrise/src/middleware/datagen/family4/loadout/loadout_item_resolver.cpp
  15. 44 37
      Sunrise/src/middleware/datagen/family4/loadout/subclass_socket_selection.cpp
  16. 16 7
      Sunrise/src/middleware/datagen/family4/loadout/subclass_socket_selection.h
  17. 26 0
      Sunrise/src/middleware/web_service/messages/opcode801.h
  18. 47 0
      Sunrise/src/middleware/web_service/messages/opcode801_codec.cpp
  19. 10 0
      Sunrise/src/server/bap/encrypted/bap_connection_publication.cpp
  20. 34 0
      Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp
  21. 37 0
      Sunrise/src/server/bap/encrypted/internal.h
  22. 71 0
      Sunrise/src/server/bap/encrypted/push/queuez/queuez_banner_push.cpp
  23. 34 0
      Sunrise/src/server/bap/encrypted/push/queuez/queuez_select_character.cpp
  24. 113 0
      Sunrise/src/server/bap/encrypted/push/snapshot/family4_selection_move.cpp
  25. 6 0
      Sunrise/src/server/bap/encrypted/push/snapshot/internal.h
  26. 11 0
      Sunrise/src/server/bap/encrypted/queuez/definition.h
  27. 72 0
      Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp
  28. 84 0
      Sunrise/src/server/bap/encrypted/queuez/queuez_outcome_staging.cpp
  29. 7 0
      Sunrise/src/server/bap/encrypted/queuez/queuez_state_validation.h
  30. 71 0
      Sunrise/src/server/bap/encrypted/queuez/staging/queuez_character_staging.cpp
  31. 25 0
      Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp
  32. 11 0
      Sunrise/src/server/bap/internal.h
  33. 92 0
      Sunrise/src/server/web_service/web_service_actions.cpp
  34. 2 0
      Sunrise/src/server/web_service/web_service_actions.h
  35. 3 0
      Sunrise/src/server/web_service/web_service_runtime.cpp
  36. 7 0
      Sunrise/src/server/web_service/web_service_runtime.h
  37. 38 12
      Sunrise/src/state/account/account_state.h
  38. 12 0
      Sunrise/src/state/account/inventory/inventory_state.h
  39. 8 2
      Sunrise/src/state/build_data/abilities/definition.h
  40. 56 0
      Sunrise/src/state/build_data/runtime.h
  41. 53 3
      Sunrise/src/state/build_data/runtime/build_data_catalog_runtime.cpp
  42. 38 0
      Sunrise/src/state/build_data/runtime/build_data_routing_catalogs.cpp
  43. 17 0
      Sunrise/src/state/build_data/runtime/domain_markers.cpp
  44. 14 0
      Sunrise/src/state/build_data/runtime/domain_markers.h
  45. 28 0
      Sunrise/src/state/build_data/socket_entry_buckets/definition.h
  46. 62 0
      Sunrise/src/state/build_data/socket_entry_buckets/socket_entry_bucket_catalog.cpp
  47. 38 0
      Sunrise/src/state/build_data/socket_entry_buckets/socket_entry_bucket_catalog.h
  48. 8 15
      Sunrise/src/state/runtime/equipment/configured_equipment_identity.cpp
  49. 44 0
      Sunrise/src/state/runtime/runtime.h
  50. 6 6
      Sunrise/src/state/runtime/state_account_equipment_runtime.cpp
  51. 100 0
      Sunrise/src/state/runtime/state_account_item_action_runtime.cpp
  52. 233 1
      Sunrise/src/state/runtime/state_account_runtime.cpp
  53. 5 0
      Sunrise/src/state/runtime/state_account_transaction_helpers.h

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

@@ -28,6 +28,7 @@ SRWLOCK g_refreshLock{SRWLOCK_INIT};
            && state::build_data::inventory_bucket_descriptors_ready()
            && state::build_data::socket_entry_lists_ready()
            && state::build_data::ability_buckets_ready()
+           && state::build_data::socket_entry_buckets_ready()
            && state::build_data::progression_definitions_ready()
            && state::build_data::scenario_layouts_ready() && state::build_data::spawn_sets_ready()
            && state::build_data::hash_names_ready()
@@ -47,8 +48,9 @@ bool refresh() noexcept {
         // The same lock as the extraction path. A cache write holds its own lock across file
         // calls, so a held thread stopped inside one would deadlock the freeze below.
         AcquireSRWLockExclusive(&g_refreshLock);
-        const bool persisted =
-            state::ensure_profile_item_identities() && state::build_data::persist();
+        const bool persisted = state::ensure_profile_item_identities()
+                                && state::ensure_character_subclasses()
+                                && 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.
         middleware::content::packages::reader::release_caches();
@@ -65,8 +67,8 @@ bool refresh() noexcept {
     // The package pass owns the item table and must not wait on runtime content lookups.
     (void)items::packages::build();
     const bool domainsReady = ready();
-    const bool complete =
-        domainsReady && state::ensure_profile_item_identities() && state::build_data::persist();
+    const bool complete = domainsReady && state::ensure_profile_item_identities()
+                          && state::ensure_character_subclasses() && 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);

+ 8 - 0
Sunrise/src/client/content/investment/worker.h

@@ -16,4 +16,12 @@ void service(std::uint64_t nowMilliseconds) noexcept;
 /** Stops taking refresh slices and clears the pending overlay. */
 void reset() noexcept;
 
+/**
+ * Makes the next `service` pump take another refresh slice even though a prior one completed.
+ * A committed mutation that invalidates one already-published build-data domain needs this: the
+ * completion latch that keeps a steady session from re-running `refresh` every pump would
+ * otherwise never notice the domain came back stale.
+ */
+void request_slice() noexcept;
+
 } // namespace sunrise::client::content::investment::worker

+ 10 - 0
Sunrise/src/client/content/investment/worker/investment_refresh_worker.cpp

@@ -76,4 +76,14 @@ void reset() noexcept {
     ReleaseSRWLockExclusive(&g_lifecycleLock);
 }
 
+/** Makes the next due pump take another refresh slice even though a prior one completed. */
+void request_slice() noexcept {
+    AcquireSRWLockExclusive(&g_lifecycleLock);
+    if (g_accepting) {
+        g_complete = false;
+        g_nextEligible = 0;
+    }
+    ReleaseSRWLockExclusive(&g_lifecycleLock);
+}
+
 } // namespace sunrise::client::content::investment::worker

+ 24 - 1
Sunrise/src/client/content/items/packages/internal.h

@@ -79,6 +79,9 @@ struct Storage {
     std::array<state::build_data::abilities::Definition,
                state::build_data::abilities::kDefinitionCapacity>
         abilityRows{};
+    std::array<state::build_data::socket_entry_buckets::Definition,
+               state::build_data::socket_entry_buckets::kDefinitionCapacity>
+        entryBucketRows{};
     std::array<state::build_data::progressions::Definition,
                state::build_data::progressions::kDefinitionCapacity>
         progressionRows{};
@@ -217,7 +220,27 @@ build_character_abilities(const reader::Source& source,
                           std::vector<std::byte>& definition,
                           std::vector<std::byte>& blob,
                           std::span<state::build_data::abilities::Definition> output,
-                          std::size_t& count) noexcept;
+                          std::size_t& count,
+                          std::span<state::build_data::socket_entry_buckets::Definition>
+                              entryBucketOutput,
+                          std::size_t& entryBucketCount) noexcept;
+
+/**
+ * Resolves which of the 12 semantic ability buckets every entry in one socket-entry list reaches.
+ * @param source Package source.
+ * @param scratch Reader scratch.
+ * @param listDefinition One socket-entry list's definition bytes.
+ * @param blob Scratch storage reused for every pool blob.
+ * @param output Receives one resolved bucket per entry, or the no-destination sentinel.
+ * @return True when the list's entries read.
+ */
+[[nodiscard]] bool resolve_entry_buckets(
+    const reader::Source& source,
+    reader::Scratch& scratch,
+    std::span<const std::byte> listDefinition,
+    std::vector<std::byte>& blob,
+    std::array<std::uint8_t, state::build_data::socket_entry_lists::kEntryCapacity>&
+        output) noexcept;
 
 /**
  * Reads the progression definition table and the object array each definition routes to.

+ 118 - 10
Sunrise/src/client/content/items/packages/package_ability_build.cpp

@@ -108,21 +108,48 @@ selector_destination(const Walk& walk, std::uint8_t entryIndex, std::uint8_t& bu
 }
 
 /**
- * Chooses the active plug source of every entry group.
+ * Chooses the active plug source of every entry group, and any bundled siblings a pick carries.
  * An entry group holds alternatives, and the summary selection names which one the character has.
+ * A pick can also bundle several consecutive same-group entries that publish together (an
+ * Attunement's melee, plus the passive nodes it carries with it); those siblings normally carry
+ * their own distinct plug source, so they are marked forced-active directly rather than relying on
+ * a plug-source match.
  * @param walk Subclass walk state.
  * @param sources Receives one active plug source per group, keyed by group.
+ * @param forcedActive Receives which entries are active regardless of plug source.
  */
-void chosen_sources(const Walk& walk, std::array<std::uint32_t, 256>& sources) noexcept {
+void chosen_sources(const Walk& walk,
+                    std::array<std::uint32_t, 256>& sources,
+                    std::array<bool, pool::kEntryCapacity>& forcedActive) noexcept {
     sources.fill(pool::kNoPlugSource);
+    forcedActive.fill(false);
+    // A group of 2 or 3 entries (grenade, movement, class ability) is an ordinary set of mutually
+    // exclusive alternatives: exactly one contributes its hashes. An Attunement's group is far
+    // wider (it packs several 4-node options into one group id), so a population past the widest
+    // single bundle is the signal that this group's members activate in same-sized runs rather
+    // than as lone alternatives.
+    std::array<std::uint16_t, 256> groupPopulation{};
+    for (std::size_t index = 0; index < walk.entryCount; ++index) {
+        ++groupPopulation[walk.entries[index].group];
+    }
     for (const std::uint8_t entryIndex : walk.selected) {
         if (entryIndex >= walk.entryCount) {
             continue;
         }
         const pool::Entry& entry = walk.entries[entryIndex];
-        if (entry.plugSource != pool::kNoPlugSource
-            && sources[entry.group] == pool::kNoPlugSource) {
-            sources[entry.group] = entry.plugSource;
+        if (entry.plugSource == pool::kNoPlugSource || sources[entry.group] != pool::kNoPlugSource) {
+            continue;
+        }
+        sources[entry.group] = entry.plugSource;
+        if (groupPopulation[entry.group] <= state::kMaxAttunementBundleSize) {
+            continue;
+        }
+        forcedActive[entryIndex] = true;
+        for (std::size_t offset = 1;
+             offset < state::kMaxAttunementBundleSize && entryIndex + offset < walk.entryCount
+             && walk.entries[entryIndex + offset].group == entry.group;
+             ++offset) {
+            forcedActive[entryIndex + offset] = true;
         }
     }
 }
@@ -130,11 +157,18 @@ void chosen_sources(const Walk& walk, std::array<std::uint32_t, 256>& sources) n
 /**
  * Decides whether one entry contributes its pool's hashes.
  * @param entry Candidate entry.
+ * @param entryIndex Its index, checked against the forced-active bundle siblings.
  * @param sources Active plug source per group.
- * @return True when the entry is the group's active alternative, or is the super.
+ * @param forcedActive Entries active regardless of plug source, from a bundled pick.
+ * @return True when the entry is the group's active alternative, a bundled sibling, or the super.
  */
 [[nodiscard]] bool active(const pool::Entry& entry,
-                          const std::array<std::uint32_t, 256>& sources) noexcept {
+                          std::size_t entryIndex,
+                          const std::array<std::uint32_t, 256>& sources,
+                          const std::array<bool, pool::kEntryCapacity>& forcedActive) noexcept {
+    if (entryIndex < forcedActive.size() && forcedActive[entryIndex]) {
+        return true;
+    }
     if (entry.plugSource == pool::kNoPlugSource) {
         return entry.kind == kSuperKind;
     }
@@ -165,6 +199,38 @@ void chosen_sources(const Walk& walk, std::array<std::uint32_t, 256>& sources) n
     return true;
 }
 
+/**
+ * Claims a bucket kind for every forced-active bundle sibling the 6 canonical selections do not
+ * already cover. An Attunement bundle can carry a member that fully replaces an ability (Phoenix
+ * Dive replacing the class ability, rather than an ordinary Rift variant) under its own distinct
+ * bucket, separate from the class's ordinary one, even though it is not itself one of the 6
+ * summary picks. Its hash would otherwise never be filed, because nothing ever claims that
+ * bucket's kind. A sibling with no destination bucket (a passive node) or one whose bucket is
+ * already claimed is skipped rather than treated as a failure, since most bundle members are
+ * exactly that.
+ * @param walk Subclass walk state.
+ * @param forcedActive Entries active regardless of plug source, from a bundled pick.
+ * @param output Bucket kinds, extended in place.
+ */
+void claim_bundle_kinds(const Walk& walk,
+                        const std::array<bool, pool::kEntryCapacity>& forcedActive,
+                        domain::Definition& output) noexcept {
+    for (std::size_t entryIndex = 0; entryIndex < walk.entryCount; ++entryIndex) {
+        if (!forcedActive[entryIndex]) {
+            continue;
+        }
+        std::array<pool::PoolRecord, pool::kPoolRecordCapacity> records{};
+        std::uint8_t bucket = 0;
+        if (records_of(walk, walk.entries[entryIndex], 0, records) == 0
+            || records[0].kind == pool::kEmptyByte
+            || !selector_destination(walk, static_cast<std::uint8_t>(entryIndex), bucket)
+            || output.buckets[bucket].kind != domain::kEmptyBucketKind) {
+            continue;
+        }
+        output.buckets[bucket].kind = records[0].kind;
+    }
+}
+
 /**
  * Files one pool record's hash into the bucket its category names, or into the overflow bank.
  * @param record Pool record carrying a definition hash.
@@ -214,11 +280,14 @@ bool build_ability_buckets(const reader::Source& source,
         return false;
     }
     // Kinds must be complete before any hash is filed, because a hash is routed by matching its
-    // category against a bucket's kind.
+    // category against a bucket's kind. Bundle siblings are folded in after the canonical 6, so a
+    // sibling can never steal a bucket one of the character's own picks already claimed.
     std::array<std::uint32_t, 256> sources{};
-    chosen_sources(walk, sources);
+    std::array<bool, pool::kEntryCapacity> forcedActive{};
+    chosen_sources(walk, sources, forcedActive);
+    claim_bundle_kinds(walk, forcedActive, output);
     for (std::size_t entryIndex = 0; entryIndex < walk.entryCount; ++entryIndex) {
-        if (!active(walk.entries[entryIndex], sources)) {
+        if (!active(walk.entries[entryIndex], entryIndex, sources, forcedActive)) {
             continue;
         }
         std::array<pool::PoolRecord, pool::kPoolRecordCapacity> records{};
@@ -230,4 +299,43 @@ bool build_ability_buckets(const reader::Source& source,
     return true;
 }
 
+/**
+ * Resolves which of the 12 semantic ability buckets every entry in one socket-entry list reaches.
+ * A pick's table position does not say which ability slot it fills; a bundled group (an
+ * Attunement, for example) can freely mix its members across slots. Only the selector chain each
+ * entry's own pool declares says where it lands, so this walks every entry once and records it,
+ * independent of any character's current selection.
+ * @param source Package source.
+ * @param scratch Reader scratch.
+ * @param listDefinition One socket-entry list's definition bytes.
+ * @param blob Scratch storage reused for every pool blob.
+ * @param output Receives one resolved bucket per entry, or the no-destination sentinel.
+ * @return True when the list's entries read.
+ */
+bool resolve_entry_buckets(
+    const reader::Source& source,
+    reader::Scratch& scratch,
+    std::span<const std::byte> listDefinition,
+    std::vector<std::byte>& blob,
+    std::array<std::uint8_t, state::build_data::socket_entry_lists::kEntryCapacity>&
+        output) noexcept {
+    output.fill(state::build_data::socket_entry_buckets::kNoDestinationBucket);
+    Walk walk{};
+    walk.source = &source;
+    walk.scratch = &scratch;
+    walk.blob = &blob;
+    walk.entryCount = pool::read_entries(listDefinition, walk.entries);
+    if (walk.entryCount == 0) {
+        return false;
+    }
+    for (std::size_t entryIndex = 0; entryIndex < walk.entryCount && entryIndex < output.size();
+         ++entryIndex) {
+        std::uint8_t bucket = 0;
+        if (selector_destination(walk, static_cast<std::uint8_t>(entryIndex), bucket)) {
+            output[entryIndex] = bucket;
+        }
+    }
+    return true;
+}
+
 } // namespace sunrise::client::content::items::packages

+ 2 - 0
Sunrise/src/client/content/items/packages/package_item_build.cpp

@@ -37,6 +37,7 @@ namespace {
            && state::build_data::inventory_bucket_descriptors_ready()
            && state::build_data::socket_entry_lists_ready()
            && state::build_data::ability_buckets_ready()
+           && state::build_data::socket_entry_buckets_ready()
            && state::build_data::progression_definitions_ready()
            && state::build_data::scenario_layouts_ready() && state::build_data::spawn_sets_ready()
            && state::build_data::hash_names_ready()
@@ -53,6 +54,7 @@ namespace {
            && state::build_data::inventory_bucket_descriptors_ready()
            && state::build_data::socket_entry_lists_ready()
            && state::build_data::ability_buckets_ready()
+           && state::build_data::socket_entry_buckets_ready()
            && state::build_data::progression_definitions_ready()
            && state::build_data::investment_constants_ready();
 }

+ 13 - 4
Sunrise/src/client/content/items/packages/package_item_rows.cpp

@@ -170,10 +170,15 @@ bool build_item_rows(const reader::Source& source,
         }
     }
     // Ability buckets read the socket entry list table again and depend on the detail domain, so
-    // they run last.
-    if (published && !state::build_data::ability_buckets_ready()) {
+    // they run last. The entry-bucket table is resolved in the same pass but never joins the
+    // on-disk cache, so a warm boot that finds the ability buckets already cached still has to
+    // run this once to fill it in for the session.
+    if (published
+        && (!state::build_data::ability_buckets_ready()
+            || !state::build_data::socket_entry_buckets_ready())) {
         reason = "abilities";
         std::size_t abilityCount = 0;
+        std::size_t entryBucketCount = 0;
         const bool built = build_character_abilities(source,
                                                      storage.scratch,
                                                      std::span<const std::byte>{storage.root},
@@ -181,10 +186,14 @@ bool build_item_rows(const reader::Source& source,
                                                      storage.definition,
                                                      storage.abilityPool,
                                                      storage.abilityRows,
-                                                     abilityCount);
+                                                     abilityCount,
+                                                     storage.entryBucketRows,
+                                                     entryBucketCount);
         published = built
                     && state::build_data::publish_ability_buckets(
-                        std::span(storage.abilityRows).first(abilityCount));
+                        std::span(storage.abilityRows).first(abilityCount))
+                    && state::build_data::publish_socket_entry_buckets(
+                        std::span(storage.entryBucketRows).first(entryBucketCount));
         if (built) {
             report_ability_count(abilityCount);
         }

+ 138 - 33
Sunrise/src/client/content/items/packages/package_subclass_build.cpp

@@ -58,13 +58,26 @@ constexpr std::size_t kSubclassSlot =
     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 rows Rows built so far. @return True when this list already has a resolved row. */
+[[nodiscard]] bool
+held(std::span<const state::build_data::socket_entry_buckets::Definition> rows,
+     std::uint16_t socketEntryListIndex) noexcept {
+    for (const auto& existing : rows) {
+        if (existing.socketEntryListIndex == socketEntryListIndex) {
+            return true;
+        }
+    }
+    return false;
+}
+
+/** @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
@@ -77,8 +90,12 @@ bool build_character_abilities(const reader::Source& source,
                                std::vector<std::byte>& definition,
                                std::vector<std::byte>& blob,
                                std::span<state::build_data::abilities::Definition> output,
-                               std::size_t& count) noexcept {
+                               std::size_t& count,
+                               std::span<state::build_data::socket_entry_buckets::Definition>
+                                   entryBucketOutput,
+                               std::size_t& entryBucketCount) noexcept {
     count = 0;
+    entryBucketCount = 0;
     std::uint32_t tableTag = 0;
     tables::Array rows{};
     if (!tables::slot_tag(root, tables::kSocketEntryListTableSlot, tableTag) || tableTag == 0) {
@@ -95,45 +112,133 @@ bool build_character_abilities(const reader::Source& source,
         return false;
     }
     const state::AccountState account = state::account_snapshot();
-    for (std::size_t character = 0; character < account.characterCount && count < output.size();
-         ++character) {
-        domain::Definition row{};
-        const char* subclassReason = "subclass";
-        if (!subclass_list(
-                account.characters[character], row.socketEntryListIndex, subclassReason)) {
-            const auto& subclass = account.characters[character].equipment.slots[kSubclassSlot];
-            report_ability_failure(
-                subclassReason, character, subclass.has_value() ? subclass->definitionHash : 0, 0);
-            continue;
+    // First-option defaults every shipped subclass starts at (see account_state.h). Equipping a
+    // subclass always resets its picks to these (state_account_runtime.cpp), so every owned
+    // subclass publishes a row at this fixed selection regardless of which one is equipped right
+    // now. That row has to exist synchronously, ahead of time: the equip response that needs it is
+    // built inline with the commit, with no room to wait on a later refresh slice.
+    const domain::Selection defaultSelection{state::kDefaultMovementAbilityEntry,
+                                             state::kDefaultGrenadeAbilityEntry,
+                                             state::kDefaultSuperAbilityEntry,
+                                             state::kDefaultMeleeAbilityEntry,
+                                             state::kDefaultClassAbilityEntry};
+    // Builds and stores one row, skipping a key already held. Best-effort: a failure here still
+    // lets the other rows in this pass publish.
+    const auto publish = [&](std::size_t character,
+                             std::uint16_t socketEntryListIndex,
+                             const domain::Selection& selection) noexcept {
+        if (count >= output.size()) {
+            return;
         }
-        // The selection is held in a local because the row it also keys is the build's output.
-        const domain::Selection selection = selection_of(account.characters[character]);
+        domain::Definition row{};
+        row.socketEntryListIndex = socketEntryListIndex;
         row.selection = selection;
         if (held(output.first(count), row)) {
-            continue;
+            return;
         }
         tables::IndexRow indexRow{};
-        if (!tables::index_row(
-                std::span<const std::byte>{table}, rows, row.socketEntryListIndex, indexRow)
+        if (!tables::index_row(std::span<const std::byte>{table}, rows, socketEntryListIndex, indexRow)
             || indexRow.targetTag == 0) {
-            report_ability_failure("index_row", character, row.socketEntryListIndex, rows.count);
-            continue;
+            report_ability_failure("index_row", character, socketEntryListIndex, rows.count);
+            return;
         }
         if (!reader::read_tag(source, scratch, indexRow.targetTag, definition)) {
-            report_ability_failure(
-                "definition_read", character, row.socketEntryListIndex, indexRow.targetTag);
-            continue;
+            report_ability_failure("definition_read", character, socketEntryListIndex, indexRow.targetTag);
+            return;
+        }
+        // A bundled group can freely mix which ability slot each of its members fills (an
+        // Attunement's melee and super swap can sit in either position), so this is resolved once
+        // per list here, independent of any selection, rather than assumed from table position.
+        if (entryBucketCount < entryBucketOutput.size()
+            && !held(entryBucketOutput.first(entryBucketCount), socketEntryListIndex)) {
+            state::build_data::socket_entry_buckets::Definition entryBuckets{};
+            entryBuckets.socketEntryListIndex = socketEntryListIndex;
+            if (resolve_entry_buckets(
+                    source, scratch, std::span<const std::byte>{definition}, blob,
+                    entryBuckets.buckets)) {
+                entryBucketOutput[entryBucketCount++] = entryBuckets;
+            }
         }
         if (!build_ability_buckets(
                 source, scratch, std::span<const std::byte>{definition}, blob, selection, row)) {
-            const std::size_t packedSelection =
-                selection.movementEntry | (selection.grenadeEntry << 8U)
-                | (selection.superEntry << 16U) | (selection.meleeEntry << 24U);
+            const std::size_t packedSelection = selection.movementEntry
+                                                | (selection.grenadeEntry << 8U)
+                                                | (selection.superEntry << 16U)
+                                                | (selection.meleeEntry << 24U);
+            report_ability_failure("bucket_build", character, socketEntryListIndex, packedSelection);
+            return;
+        }
+        output[count++] = row;
+    };
+    for (std::size_t character = 0; character < account.characterCount && count < output.size();
+         ++character) {
+        std::uint16_t equippedSocketEntryListIndex = 0;
+        const char* subclassReason = "subclass";
+        if (!subclass_list(
+                account.characters[character], equippedSocketEntryListIndex, subclassReason)) {
+            const auto& subclass = account.characters[character].equipment.slots[kSubclassSlot];
             report_ability_failure(
-                "bucket_build", character, row.socketEntryListIndex, packedSelection);
+                subclassReason, character, subclass.has_value() ? subclass->definitionHash : 0, 0);
             continue;
         }
-        output[count++] = row;
+
+        const auto& equippedSlot = account.characters[character].equipment.slots[kSubclassSlot];
+        state::build_data::items::Definition equippedItem{};
+        if (!state::build_data::find_item_definition_hash(equippedSlot->definitionHash,
+                                                           equippedItem)) {
+            continue;
+        }
+
+        // Every subclass the character owns publishes a row, not just the equipped one, so a
+        // later equip swap always lands on an already-built row instead of racing the next
+        // refresh slice. When the group cannot be resolved, at least the equipped one still
+        // publishes, matching the prior single-row behaviour.
+        std::array<std::uint16_t, state::build_data::kSubclassGroupSize> group{};
+        std::array<std::uint16_t, state::build_data::kSubclassGroupSize> members{};
+        std::size_t memberCount = 1;
+        members[0] = equippedItem.definitionIndex;
+        if (state::build_data::find_subclass_group(equippedItem.definitionIndex, group)) {
+            members = group;
+            memberCount = group.size();
+        }
+
+        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{};
+            if (!state::build_data::find_configured_item_detail(memberDefinitionIndex,
+                                                                 memberDetail)) {
+                continue;
+            }
+            publish(character, memberDetail.socketEntryListIndex, defaultSelection);
+            // 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);
+            }
+        }
     }
     if (count == 0) {
         report_ability_failure("empty", account.characterCount, rows.count, output.size());

+ 0 - 6
Sunrise/src/core/settings/parser.h

@@ -168,12 +168,6 @@ private:
      */
     [[nodiscard]] bool character(state::CharacterState& output) noexcept;
 
-    /**
-     * Reads one selectable ability's socket entry.
-     * @param output Receives the entry only when it is inside the socket-entry bound.
-     * @return True when the value parses and names a possible entry.
-     */
-    [[nodiscard]] bool ability_entry(std::uint8_t& output) noexcept;
     /**
      * Parses the optional equipment object with its fixed named slots.
      * @param output Receives present items only after the whole object is valid.

+ 10 - 28
Sunrise/src/core/settings/state/account_rows_parser.cpp

@@ -233,16 +233,6 @@ bool Parser::characters(state::AccountState& output) noexcept {
     }
 }
 
-/** Reads one selectable ability's socket entry. */
-bool Parser::ability_entry(std::uint8_t& output) noexcept {
-    std::uint64_t value = 0;
-    if (!unsigned_integer(value) || value > state::kMaximumMovementAbilityEntry) {
-        return false;
-    }
-    output = static_cast<std::uint8_t>(value);
-    return true;
-}
-
 /** Parses one authored character identity. */
 bool Parser::character(state::CharacterState& output) noexcept {
     output = {};
@@ -314,24 +304,16 @@ bool Parser::character(state::CharacterState& output) noexcept {
             if (!boolean(output.contentBypass)) {
                 return false;
             }
-        } else if (key == "movement_ability") {
-            if (!ability_entry(output.movementAbilityEntry)) {
-                return false;
-            }
-        } else if (key == "grenade_ability") {
-            if (!ability_entry(output.grenadeAbilityEntry)) {
-                return false;
-            }
-        } else if (key == "super_ability") {
-            if (!ability_entry(output.superAbilityEntry)) {
-                return false;
-            }
-        } else if (key == "melee_ability") {
-            if (!ability_entry(output.meleeAbilityEntry)) {
-                return false;
-            }
-        } else if (key == "class_ability") {
-            if (!ability_entry(output.classAbilityEntry)) {
+        } else if (key == "movement_ability" || key == "grenade_ability" || key == "super_ability"
+                   || key == "melee_ability" || key == "class_ability") {
+            // Deliberately ignored on load: the subclass screen's first paint each login reads
+            // whatever the game's own UI initializes itself to before any interaction, which is
+            // always the ability-entry struct defaults below, not whatever State last committed.
+            // Restoring a persisted non-default pick here would leave that first paint showing
+            // something different from what is actually equipped until the player made any
+            // change and forced a redraw. Resetting every login keeps the two in sync from the
+            // start; the value is still written back out (see the writer), just never read back.
+            if (!skip_value(0)) {
                 return false;
             }
         } else if (key == "equipment") {

+ 26 - 10
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,14 +30,25 @@ 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(
-                instances.items[index].instance.baseDefinitionIndex, detail)
-            || !state::build_data::find_ability_buckets(
-                detail.socketEntryListIndex, selection_of(character), published)) {
+                instances.items[index].instance.baseDefinitionIndex, detail)) {
             return false;
         }
+        if (!state::build_data::find_ability_buckets(
+                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
+            // failing the whole record: a hard failure here aborts the entire Family-0/3 snapshot
+            // and surfaces as a request error even though the underlying selection did commit.
+            return true;
+        }
         for (std::size_t bucket = 0; bucket < appearance.abilityBuckets.size(); ++bucket) {
             layout::AbilityBucket& target = appearance.abilityBuckets[bucket];
             target.kind = static_cast<std::int8_t>(published.buckets[bucket].kind);

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

@@ -66,7 +66,7 @@ namespace {
     for (std::size_t index = 0; index < input.socketEntryStates.size(); ++index) {
         const SocketEntryState state = input.socketEntryStates[index];
         if (state != SocketEntryState::absent && state != SocketEntryState::ready
-            && state != SocketEntryState::active) {
+            && state != SocketEntryState::acquired && state != SocketEntryState::active) {
             return false;
         }
         if (index >= input.socketEntryCount && state != SocketEntryState::absent) {

+ 2 - 0
Sunrise/src/middleware/datagen/family4/instance/instance_encoder.h

@@ -16,6 +16,8 @@ enum class SocketEntryState : std::uint8_t {
     absent = 0,
     /** The entry exists but has not received a runtime selection. */
     ready = 16,
+    /** The character selected this entry previously, but another entry is active now. */
+    acquired = 17,
     /** The character has selected this entry, or it is the super lane. */
     active = 18,
 };

+ 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;

+ 44 - 37
Sunrise/src/middleware/datagen/family4/loadout/subclass_socket_selection.cpp

@@ -13,52 +13,30 @@ namespace {
 
 namespace build_socket_lists = state::build_data::socket_entry_lists;
 
-/** Bucket the grenade publishes into. Entry order is sprint, class, movement, grenade, super,
- * melee, so the entries themselves are the character's own choices. */
-constexpr std::uint8_t kGrenadeBucket = 0;
-/** Bucket the super publishes into. */
-constexpr std::uint8_t kSuperBucket = 1;
-/** Bucket the melee publishes into. */
-constexpr std::uint8_t kMeleeBucket = 2;
-/** Bucket the movement ability publishes into. */
-constexpr std::uint8_t kMovementBucket = 3;
-/** Bucket the sprint publishes into. Sprint is not selectable, so its entry is fixed. */
-constexpr std::uint8_t kSprintBucket = 4;
 /** Socket entry of the sprint ability. */
 constexpr std::uint8_t kSprintEntry = 1;
 
-/** @param characterClass Authored class. @return The bucket its class ability publishes into. */
-[[nodiscard]] std::uint8_t class_ability_bucket(state::CharacterClass characterClass) noexcept {
-    switch (characterClass) {
-    case state::CharacterClass::hunter:
-        return 9;
-    case state::CharacterClass::warlock:
-        return 11;
-    case state::CharacterClass::titan:
-    default:
-        return 6;
-    }
-}
-
 } // 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, kGrenadeBucket};
-    output.selected[1] = {character.superAbilityEntry, kSuperBucket};
-    output.selected[2] = {character.meleeAbilityEntry, kMeleeBucket};
-    output.selected[3] = {character.movementAbilityEntry, kMovementBucket};
-    output.selected[4] = {kSprintEntry, kSprintBucket};
-    output.selected[5] = {character.classAbilityEntry,
-                          class_ability_bucket(character.characterClass)};
+    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] = {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);
@@ -66,7 +44,9 @@ 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] = instance::SocketEntryState::ready;
+            output[index] = (acquiredSubclassAbilityMask & bit) != 0
+                                ? instance::SocketEntryState::acquired
+                                : instance::SocketEntryState::ready;
         }
     }
     // Only a subclass keeps an entry table, so this lookup is what identifies one.
@@ -75,12 +55,25 @@ 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
+    // wider (it packs several 4-node options into one group id), so a population past the widest
+    // single bundle is the signal that this group's members activate in same-sized runs rather
+    // than as lone alternatives.
+    std::array<std::uint16_t, build_socket_lists::kEntryCapacity> groupPopulation{};
+    for (std::size_t index = 0; index < definition.entryCount; ++index) {
+        const std::uint8_t group = entries.entries[index].group;
+        if (group < groupPopulation.size()) {
+            ++groupPopulation[group];
+        }
+    }
     // Each selected entry claims its group. Every entry sharing that group and plug source is
     // active too, which is why a run of duplicate lanes flips together.
     std::array<std::uint32_t, build_socket_lists::kEntryCapacity> chosen{};
     std::array<bool, build_socket_lists::kEntryCapacity> claimed{};
+    std::array<bool, build_socket_lists::kEntryCapacity> forcedActive{};
     for (const SelectedEntry& selected : selection.selected) {
         if (selected.entry >= definition.entryCount || selected.bucket >= selectors.size()) {
             continue;
@@ -93,6 +86,20 @@ void resolve_socket_states(
         }
         claimed[entry.group] = true;
         chosen[entry.group] = entry.plugSource;
+        if (groupPopulation[entry.group] <= state::kMaxAttunementBundleSize) {
+            continue;
+        }
+        // A pick can bundle several consecutive entries under the same group, all publishing
+        // together (an Attunement's melee, plus the passive nodes it carries with it). Siblings
+        // normally carry their own distinct plug source, so force the whole contiguous run active
+        // rather than relying on the plug-source match below to find them.
+        forcedActive[selected.entry] = true;
+        for (std::size_t offset = 1;
+             offset < state::kMaxAttunementBundleSize && selected.entry + offset < definition.entryCount
+             && entries.entries[selected.entry + offset].group == entry.group;
+             ++offset) {
+            forcedActive[selected.entry + offset] = true;
+        }
     }
     for (std::size_t index = 0; index < definition.entryCount; ++index) {
         const build_socket_lists::Entry& entry = entries.entries[index];
@@ -101,7 +108,7 @@ void resolve_socket_states(
                                   && chosen[entry.group] == entry.plugSource;
         const bool superLane = entry.plugSource == build_socket_lists::kNoPlugSource
                                && entry.kind == build_socket_lists::kSuperEntryKind;
-        if (matchesGroup || superLane) {
+        if (matchesGroup || superLane || forcedActive[index]) {
             output[index] = instance::SocketEntryState::active;
         }
     }

+ 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;
 

+ 26 - 0
Sunrise/src/middleware/web_service/messages/opcode801.h

@@ -0,0 +1,26 @@
+#pragma once
+
+#include <cstdint>
+
+#include "../web_service_envelope.h"
+
+namespace sunrise::middleware::web_service::messages::opcode801 {
+
+/** Web Service opcode used to select one subclass socket-entry node. */
+inline constexpr std::uint16_t kOpcode = 801;
+
+/** Exact logical fields carried by the native 80-bit subclass selection descriptor. */
+struct Request {
+    std::uint64_t subclassInstanceSoid{};
+    std::uint8_t socketEntry{};
+};
+
+/**
+ * Parses the exact reflected opcode-801 descriptor observed for class, grenade and path nodes.
+ * @param message Parsed Web Service envelope.
+ * @param request Receives the subclass instance and zero-based socket-entry index.
+ * @return True only for the complete canonical 10-byte request.
+ */
+[[nodiscard]] bool parse_request(const Message& message, Request& request) noexcept;
+
+} // namespace sunrise::middleware::web_service::messages::opcode801

+ 47 - 0
Sunrise/src/middleware/web_service/messages/opcode801_codec.cpp

@@ -0,0 +1,47 @@
+#include <cstddef>
+
+#include "../../encoding/bit_reader.h"
+#include "opcode801.h"
+
+namespace sunrise::middleware::web_service::messages::opcode801 {
+namespace {
+
+/** The reflected opcode-801 request occupies exactly 80 bits. */
+constexpr std::size_t kPayloadSize = 10;
+/** The subclass instance is a bare 64-bit SOID. */
+constexpr std::uint8_t kInstanceWidth = 64;
+/** A signed socket-entry index is biased from INT8_MIN into one 8-bit wire field. */
+constexpr std::uint8_t kSocketEntryWidth = 8;
+/** Two absent optional fields terminate the generic Web Service request descriptor. */
+constexpr std::uint8_t kOuterTrailerWidth = 2;
+/** The complete outer request is padded to its final byte. */
+constexpr std::uint8_t kFinalPaddingWidth = 6;
+/** Nonnegative signed 8-bit entries have this bit set after native descriptor biasing. */
+constexpr std::uint64_t kSocketEntryBias = 0x80ULL;
+
+} // namespace
+
+/** Parses the complete native subclass socket-entry selection descriptor. */
+bool parse_request(const Message& message, Request& request) noexcept {
+    request = {};
+    if (message.opcode != kOpcode || message.payload.size() != kPayloadSize) {
+        return false;
+    }
+
+    encoding::bits::Reader reader(message.payload);
+    std::uint64_t encodedSocketEntry = 0;
+    std::uint64_t outerTrailer = 0;
+    std::uint64_t finalPadding = 0;
+    if (!reader.read(kInstanceWidth, request.subclassInstanceSoid)
+        || !reader.read(kSocketEntryWidth, encodedSocketEntry)
+        || !reader.read(kOuterTrailerWidth, outerTrailer)
+        || !reader.read(kFinalPaddingWidth, finalPadding) || reader.remaining_bits() != 0
+        || encodedSocketEntry < kSocketEntryBias || outerTrailer != 0 || finalPadding != 0) {
+        request = {};
+        return false;
+    }
+    request.socketEntry = static_cast<std::uint8_t>(encodedSocketEntry - kSocketEntryBias);
+    return request.subclassInstanceSoid != 0;
+}
+
+} // namespace sunrise::middleware::web_service::messages::opcode801

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

@@ -15,6 +15,12 @@ namespace {
 constexpr std::uint64_t kFamily4RepushDelayMs = 400;
 /** The banner pair lands the same unsolicited way and hits the same record-state race. */
 constexpr std::uint64_t kBannerRepushDelayMs = 400;
+/**
+ * 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
+ * next few RunCallbacks pumps, well under this window.
+ */
+constexpr std::uint64_t kAbilityRefreshDelayMs = 500;
 /**
  * How long the roster keeps its faster cadence after a load starts.
  * The slice-set load step costs 9.2 to 14.1 s, so this covers it.
@@ -166,6 +172,10 @@ void release_activity_connection(Session& session) noexcept {
 /** Arms the owed Family-4 and banner re-pushes when the queuez publication asks for them. */
 void arm_repushes(Session& session, const queuez::StagedPublication& queuezPublication) noexcept {
     const std::uint64_t now = GetTickCount64();
+    if (queuezPublication.armsAbilityRefresh) {
+        session.abilityRefreshDueTick = now + kAbilityRefreshDelayMs;
+        session.abilityRefreshArmed = true;
+    }
     if (queuezPublication.armsFamily4Repush && queuezPublication.family4RepushRoot != 0) {
         session.family4RepushDueTick = now + kFamily4RepushDelayMs;
         session.family4RepushRoot = queuezPublication.family4RepushRoot;

+ 34 - 0
Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp

@@ -201,6 +201,8 @@ bool process(const ServiceRoute& route,
         outcome.subscription = webOutcome.subscription;
         const auto* equipmentSwap =
             web_service::mutation_if<state::PendingEquipmentSwap>(webOutcome);
+        const auto* subclassSelection =
+            web_service::mutation_if<state::PendingSubclassSelection>(webOutcome);
         const auto* socketPlug = web_service::mutation_if<state::PendingSocketPlug>(webOutcome);
         const auto* itemState = web_service::mutation_if<state::PendingItemState>(webOutcome);
         const auto* itemAcquisition =
@@ -241,6 +243,38 @@ bool process(const ServiceRoute& route,
                 transaction.pending = *equipmentSwap;
             }
         }
+        if (subclassSelection != nullptr) {
+            // Opcode 801 completes at the exact Family-4 revision carrying the selected subclass
+            // socket entry. The resident manifest and equipped subclass identity stay unchanged.
+            auto& transaction = outcome.transaction.emplace<SubclassSelectionTransaction>();
+            if (!queuez::stage_subclass_selection(queuezState,
+                                                  subclassSelection->accountSoid,
+                                                  subclassSelection->characterSoid,
+                                                  subclassSelection->subclassInstanceSoid,
+                                                  transaction.update)) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=subclass_select stage=queuez_preflight result=fail");
+                outcome.transaction = std::monostate{};
+            } else {
+                middleware::web_service::StatusResponse status{};
+                status.value = transaction.update.after.family4Version;
+                if (!middleware::web_service::encode_response(
+                        message,
+                        middleware::web_service::ResponseShape::statusPair,
+                        status,
+                        output,
+                        written)) {
+                    core::log::write(core::log::Channel::server,
+                                     core::log::Level::warn,
+                                     "ev=subclass_select stage=response result=fail");
+                    return false;
+                }
+                web_service::report_subclass_selection_response(
+                    message, status.value, *subclassSelection, output.first(written));
+                transaction.pending = *subclassSelection;
+            }
+        }
         if (socketPlug != nullptr) {
             // Opcode 903 completes at the exact Family-4 revision carrying the changed resident
             // item instance. The resident manifest and character placement remain unchanged.

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

@@ -46,6 +46,12 @@ struct SocketPlugTransaction {
     queuez::SocketPlug update{};
 };
 
+/** Subclass ability selection and the exact QueueZ after-image promised by its response. */
+struct SubclassSelectionTransaction {
+    state::PendingSubclassSelection pending{};
+    queuez::SubclassSelection update{};
+};
+
 /** Item-state mutation and the exact QueueZ character after-image promised by its response. */
 struct ItemStateTransaction {
     state::PendingItemState pending{};
@@ -86,6 +92,7 @@ struct ServiceOutcome {
                                      activity_message::ActivityPlan,
                                      state::matchmaking::PendingMutation,
                                      EquipmentSwapTransaction,
+                                     SubclassSelectionTransaction,
                                      SocketPlugTransaction,
                                      ItemStateTransaction,
                                      ItemAcquisitionTransaction,
@@ -351,6 +358,16 @@ append_socket_appearance_refresh_notification(Scratch& scratch,
                                               std::span<std::byte> response,
                                               std::size_t& written) noexcept;
 
+/** Appends the Family-0 character ability refresh owed by a subclass selection. */
+[[nodiscard]] bool
+append_subclass_appearance_refresh_notification(Scratch& scratch,
+                                                const queuez::CharacterAppearanceRefresh& refresh,
+                                                const state::PendingSubclassSelection& mutation,
+                                                std::span<const std::byte, state::kAesKeySize> key,
+                                                std::array<std::byte, state::kBapNonceSize>& nonce,
+                                                std::span<std::byte> response,
+                                                std::size_t& written) noexcept;
+
 /** Appends a Family-3 character record followed by the changed account roster after equip. */
 [[nodiscard]] bool
 append_equipment_roster_refresh_notification(Scratch& scratch,
@@ -371,6 +388,16 @@ append_socket_roster_refresh_notification(Scratch& scratch,
                                           std::span<std::byte> response,
                                           std::size_t& written) noexcept;
 
+/** Appends a Family-3 character-only appearance refresh after a subclass selection. */
+[[nodiscard]] bool
+append_subclass_roster_refresh_notification(Scratch& scratch,
+                                            const queuez::RosterAppearanceRefresh& refresh,
+                                            const state::PendingSubclassSelection& mutation,
+                                            std::span<const std::byte, state::kAesKeySize> key,
+                                            std::array<std::byte, state::kBapNonceSize>& nonce,
+                                            std::span<std::byte> response,
+                                            std::size_t& written) noexcept;
+
 /** Refreshes the selected character's complete Family-0 appearance from committed State. */
 [[nodiscard]] bool
 append_account_resync_appearance_notification(Scratch& scratch,
@@ -401,6 +428,16 @@ append_socket_plug_notification(Scratch& scratch,
                                 std::span<std::byte> response,
                                 std::size_t& written) noexcept;
 
+/** Appends the opcode-801 Family-4 subclass item-instance upsert. */
+[[nodiscard]] bool append_subclass_selection_notification(
+    Scratch& scratch,
+    const queuez::SubclassSelection& selection,
+    const state::PendingSubclassSelection& mutation,
+    std::span<const std::byte, state::kAesKeySize> key,
+    std::span<const std::byte, state::kBapNonceSize> nonce,
+    std::span<std::byte> response,
+    std::size_t& written) noexcept;
+
 /** Appends a Family-4 character upsert plus newly acquired item-instance upsert. */
 [[nodiscard]] bool
 append_item_acquisition_notification(Scratch& scratch,

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

@@ -369,6 +369,49 @@ bool append_socket_appearance_refresh_notification(
         scratch, refresh, prepared, "socket_appearance", key, nonce, response, written);
 }
 
+/** Appends one Family-0 character ability refresh after a subclass selection. */
+bool append_subclass_appearance_refresh_notification(
+    Scratch& scratch,
+    const queuez::CharacterAppearanceRefresh& refresh,
+    const state::PendingSubclassSelection& mutation,
+    std::span<const std::byte, state::kAesKeySize> key,
+    std::array<std::byte, state::kBapNonceSize>& nonce,
+    std::span<std::byte> response,
+    std::size_t& written) noexcept {
+    constexpr std::size_t kSubclassSlot =
+        static_cast<std::size_t>(state::account::inventory::EquipmentSlot::subclass);
+    if (!mutation.prepared || mutation.characterSoid != refresh.characterSoid
+        || kSubclassSlot >= mutation.afterCharacter.equipment.slots.size()
+        || !mutation.afterCharacter.equipment.slots[kSubclassSlot].has_value()
+        || mutation.afterCharacter.equipment.slots[kSubclassSlot]->instanceSoid
+               != mutation.subclassInstanceSoid) {
+        return false;
+    }
+    state::build_data::items::details::Definition detail{};
+    if (!state::build_data::find_configured_item_detail(
+            mutation.subclassDefinitionIndex, detail)
+        || detail.definitionIndex != mutation.subclassDefinitionIndex
+        || detail.definitionHash != mutation.subclassDefinitionHash
+        || !detail.equipmentSlot.has_value() || *detail.equipmentSlot < 0
+        || static_cast<std::size_t>(*detail.equipmentSlot)
+               >= state::build_data::items::details::kEquipmentSlotCount) {
+        return false;
+    }
+    snapshot::Prepared prepared{};
+    if (!snapshot::prepare_character_appearance_refresh(
+            scratch,
+            refresh,
+            mutation.afterCharacter,
+            mutation.characterIndex,
+            static_cast<std::uint8_t>(*detail.equipmentSlot),
+            true,
+            prepared)) {
+        return false;
+    }
+    return append_appearance_frame(
+        scratch, refresh, prepared, "subclass_appearance", key, nonce, response, written);
+}
+
 /** Appends the Family-3 character-then-roster refresh owed by one equipment mutation. */
 bool append_equipment_roster_refresh_notification(
     Scratch& scratch,
@@ -416,6 +459,34 @@ bool append_socket_roster_refresh_notification(Scratch& scratch,
         scratch, refresh, prepared, "socket_roster", key, nonce, response, written);
 }
 
+/** Appends the Family-3 character-only refresh owed by a subclass selection. */
+bool append_subclass_roster_refresh_notification(
+    Scratch& scratch,
+    const queuez::RosterAppearanceRefresh& refresh,
+    const state::PendingSubclassSelection& mutation,
+    std::span<const std::byte, state::kAesKeySize> key,
+    std::array<std::byte, state::kBapNonceSize>& nonce,
+    std::span<std::byte> response,
+    std::size_t& written) noexcept {
+    constexpr std::size_t kSubclassSlot =
+        static_cast<std::size_t>(state::account::inventory::EquipmentSlot::subclass);
+    if (!mutation.prepared || refresh.includeRoster
+        || mutation.characterSoid != refresh.characterSoid
+        || kSubclassSlot >= mutation.afterCharacter.equipment.slots.size()
+        || !mutation.afterCharacter.equipment.slots[kSubclassSlot].has_value()
+        || mutation.afterCharacter.equipment.slots[kSubclassSlot]->instanceSoid
+               != mutation.subclassInstanceSoid) {
+        return false;
+    }
+    snapshot::Prepared prepared{};
+    if (!snapshot::prepare_roster_appearance_refresh(
+            scratch, refresh, mutation.afterCharacter, mutation.characterIndex, prepared)) {
+        return false;
+    }
+    return append_roster_appearance_frame(
+        scratch, refresh, prepared, "subclass_roster", key, nonce, response, written);
+}
+
 /** Refreshes the selected character's complete Family-0 appearance from committed State. */
 bool append_account_resync_appearance_notification(
     Scratch& scratch,

+ 34 - 0
Sunrise/src/server/bap/encrypted/push/queuez/queuez_select_character.cpp

@@ -166,6 +166,40 @@ bool append_socket_plug_notification(Scratch& scratch,
     return true;
 }
 
+/** Appends one subclass item-instance upsert after an opcode-801 selection. */
+bool append_subclass_selection_notification(Scratch& scratch,
+                                            const queuez::SubclassSelection& selection,
+                                            const state::PendingSubclassSelection& mutation,
+                                            std::span<const std::byte, state::kAesKeySize> key,
+                                            std::span<const std::byte, state::kBapNonceSize> nonce,
+                                            std::span<std::byte> response,
+                                            std::size_t& written) noexcept {
+    snapshot::Prepared prepared{};
+    if (!snapshot::prepare_subclass_selection(scratch, selection, mutation, prepared)) {
+        return false;
+    }
+    const std::size_t objectCount = prepared.family.objects.size();
+    const std::size_t beforeBytes = written;
+    if (objectCount != 1
+        || prepared.family.objects.front().id != selection.itemInstanceDefinitionId
+        || prepared.family.objects.front().version != selection.subclassInstanceSoid
+        || prepared.family.objects.front().encoding != middleware::queuez::Encoding::oodle
+        || prepared.family.objects.front().payload.empty()
+        || !queuez_frame::append(scratch,
+                                 prepared.family,
+                                 prepared.rawClearSize,
+                                 prepared.compressedClearSize,
+                                 key,
+                                 nonce,
+                                 response,
+                                 written)) {
+        return false;
+    }
+    queuez_report::push(
+        "subclass_select", queuez::kAccountFamilyType, objectCount, written - beforeBytes, 1);
+    return true;
+}
+
 /** Appends one atomic new-instance-before-character Family-4 acquisition update. */
 bool append_item_acquisition_notification(Scratch& scratch,
                                           const queuez::ItemAcquisition& acquisition,

+ 113 - 0
Sunrise/src/server/bap/encrypted/push/snapshot/family4_selection_move.cpp

@@ -518,4 +518,117 @@ bool prepare_socket_plug(Scratch& scratch,
     return true;
 }
 
+/** Builds the Family-4 subclass item-instance upsert for one prepared node selection. */
+bool prepare_subclass_selection(Scratch& scratch,
+                                const queuez::SubclassSelection& selection,
+                                const state::PendingSubclassSelection& mutation,
+                                Prepared& prepared) noexcept {
+    const Reservation reservation = reserve_prior(scratch, prepared);
+    if (reservation.rawWriteOffset > scratch.plaintext.size()
+        || reservation.compressedWriteOffset > scratch.sealed.size()) {
+        return report_failure("subclass_select_reservation");
+    }
+
+    state::AccountState account{};
+    if (!mutation.prepared || mutation.accountSoid == 0 || mutation.characterSoid == 0
+        || mutation.subclassInstanceSoid == 0 || mutation.accountSoid != selection.accountSoid
+        || mutation.characterSoid != selection.characterSoid
+        || mutation.subclassInstanceSoid != selection.subclassInstanceSoid
+        || mutation.accountSoid != selection.after.family4RootSoid
+        || selection.itemInstanceDefinitionId == 0
+        || !state::preview_subclass_selection(mutation, account)
+        || mutation.characterIndex >= account.characterCount
+        || account.primarySoid != mutation.accountSoid
+        || account.characters[mutation.characterIndex].soid != mutation.characterSoid) {
+        return report_failure("subclass_select_mutation");
+    }
+
+    Resolved selected{};
+    const std::optional<std::size_t> selectedIndex = find_character_index(account);
+    if (!state::account::valid(account) || !selectedIndex.has_value()
+        || *selectedIndex != mutation.characterIndex
+        || !resolve(account, mutation.characterIndex, selected)
+        || selected.itemInstanceObjectId != selection.itemInstanceDefinitionId) {
+        return report_failure("subclass_select_selection");
+    }
+
+    family4_datagen::loadout::ResolvedInstances changed{};
+    for (std::size_t index = 0; index < selected.loadout.itemCount; ++index) {
+        const family4_datagen::loadout::ResolvedItem& item = selected.loadout.items[index];
+        if (item.instance.instanceSoid != mutation.subclassInstanceSoid) {
+            continue;
+        }
+        if (changed.itemCount != 0 || !item.equipped
+            || item.instance.baseDefinitionIndex != mutation.subclassDefinitionIndex
+            || item.instance.socketEntryListIndex != mutation.socketEntryListIndex
+            || !item.instance.socketEntryContentsResolved
+            || mutation.requestedEntry >= item.instance.socketEntryCount) {
+            return report_failure("subclass_select_item_shape");
+        }
+        changed.items[0] = {item.equipmentSlot, item.instance};
+        changed.itemCount = 1;
+    }
+    if (changed.itemCount != 1) {
+        return report_failure("subclass_select_item_missing");
+    }
+
+    const auto rawStorage = std::span(scratch.plaintext).subspan(reservation.rawWriteOffset);
+    if (family4_datagen::instance::layout::kObjectSize > rawStorage.size()) {
+        return report_failure("subclass_select_item_storage");
+    }
+    Prepared staged{};
+    staged.rawClearSize =
+        (std::max)(reservation.rawClearSize,
+                   reservation.rawWriteOffset + family4_datagen::instance::layout::kObjectSize);
+    std::size_t compressedExtent = reservation.compressedWriteOffset;
+    std::size_t itemCursor = 0;
+    if (!append_items(scratch,
+                      rawStorage,
+                      selection.itemInstanceDefinitionId,
+                      changed,
+                      0,
+                      staged,
+                      itemCursor,
+                      compressedExtent)
+        || itemCursor != 1) {
+        clear_after(scratch, reservation);
+        return report_failure("subclass_select_item_object");
+    }
+
+    staged.compressedClearSize = (std::max)(reservation.compressedClearSize, compressedExtent);
+    staged.family = middleware::queuez::Family{
+        kAccountFamilyType,
+        selection.after.family4RootSoid,
+        selection.after.family4Version,
+        0,
+        std::span(staged.objects).first(1),
+    };
+    if (!commit(staged, prepared)) {
+        clear_after(scratch, reservation);
+        return report_failure("subclass_select_commit");
+    }
+
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count = std::snprintf(
+        line.data(),
+        line.size(),
+        "ev=subclass_select stage=family4_object result=ok family_version=%d root=0x%llX "
+        "character=0x%llX instance=0x%llX item_definition=%u target_definition=%u "
+        "socket_list=%u requested_entry=%u objects=1",
+        selection.after.family4Version,
+        static_cast<unsigned long long>(selection.after.family4RootSoid),
+        static_cast<unsigned long long>(selection.characterSoid),
+        static_cast<unsigned long long>(selection.subclassInstanceSoid),
+        selection.itemInstanceDefinitionId,
+        static_cast<unsigned>(mutation.subclassDefinitionIndex),
+        static_cast<unsigned>(mutation.socketEntryListIndex),
+        static_cast<unsigned>(mutation.requestedEntry));
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+    return true;
+}
+
 } // namespace sunrise::server::bap::encrypted::push::snapshot

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

@@ -136,6 +136,12 @@ inline constexpr std::size_t kSingleObjectCount = 1;
                                        const state::PendingSocketPlug& mutation,
                                        Prepared& prepared) noexcept;
 
+/** Builds the Family-4 subclass item-instance upsert for one prepared node selection. */
+[[nodiscard]] bool prepare_subclass_selection(Scratch& scratch,
+                                              const queuez::SubclassSelection& selection,
+                                              const state::PendingSubclassSelection& mutation,
+                                              Prepared& prepared) noexcept;
+
 /**
  * Builds one Family-4 increment containing the newly resident item object followed by the
  *

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

@@ -145,6 +145,15 @@ struct SocketPlug {
     bool updatesAccount{};
 };
 
+/** Validated subclass item-instance after-image for one ability socket-entry selection. */
+struct SubclassSelection {
+    SessionState after{};
+    std::uint32_t itemInstanceDefinitionId{};
+    std::uint64_t accountSoid{};
+    std::uint64_t characterSoid{};
+    std::uint64_t subclassInstanceSoid{};
+};
+
 /** Validated profile-stack acquisition after-image for an account upsert and optional resident. */
 struct ProfileItemAcquisition {
     SessionState after{};
@@ -189,6 +198,8 @@ struct StagedPublication {
     bool armsBannerRepush{};
     /** Root that copy must use. */
     std::uint64_t bannerRepushRoot{};
+    /** A subclass selection just staged and owes a delayed ability-icon refresh. */
+    bool armsAbilityRefresh{};
 };
 
 } // namespace sunrise::server::bap::encrypted::queuez

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

@@ -171,6 +171,75 @@ void report_repush(const char* stage, std::size_t bytes) noexcept {
     return true;
 }
 
+/**
+ * Re-derives the selected character's appearance and roster records once the ability-bucket
+ * rebuild owed by a subclass selection has had time to land.
+ * The refresh sent inline with the opcode-801 response can still carry stale or empty ability
+ * buckets, because that rebuild runs asynchronously off the Client content-extraction pump. This
+ * reuses the same generic resync builders the cross-peer refresh uses, since both just need to
+ * re-derive the two records from whatever State holds right now.
+ * @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 at least one owed record refreshes.
+ */
+[[nodiscard]] bool consume_ability_refresh(Session& session,
+                                           Scratch& scratch,
+                                           std::span<std::byte> response,
+                                           std::size_t& written,
+                                           bool& touchesScratch) noexcept {
+    if (!session.abilityRefreshArmed || GetTickCount64() < session.abilityRefreshDueTick) {
+        return false;
+    }
+    session.abilityRefreshArmed = false;
+    touchesScratch = true;
+
+    auto nextSendNonce = session.sendNonce;
+    std::size_t framedSize = 0;
+    queuez::SessionState current = session.queuez;
+    bool wrote = false;
+    if (current.family0Active) {
+        queuez::SessionState appearanceAfter{};
+        if (push::append_account_resync_appearance_notification(scratch,
+                                                                 current,
+                                                                 state::bap().sessionKey,
+                                                                 nextSendNonce,
+                                                                 scratch.framed,
+                                                                 framedSize,
+                                                                 appearanceAfter)) {
+            current = appearanceAfter;
+            wrote = true;
+        }
+    }
+    if (current.family3Active) {
+        queuez::SessionState rosterAfter{};
+        if (push::append_account_resync_roster_notification(scratch,
+                                                             current,
+                                                             state::bap().sessionKey,
+                                                             nextSendNonce,
+                                                             scratch.framed,
+                                                             framedSize,
+                                                             rosterAfter)) {
+            current = rosterAfter;
+            wrote = true;
+        }
+    }
+    if (!wrote || framedSize == 0 || framedSize > response.size() || !queuez::valid(current)) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::warn,
+                         "ev=queuez stage=ability_refresh result=fail");
+        return false;
+    }
+    std::copy_n(scratch.framed.begin(), framedSize, response.begin());
+    written = framedSize;
+    session.sendNonce = nextSendNonce;
+    session.queuez = current;
+    report_repush("ability_refresh", framedSize);
+    return true;
+}
+
 } // namespace
 
 /**
@@ -198,6 +267,9 @@ bool consume_deferred(Session& session,
     if (session.accountResyncArmed) {
         return false;
     }
+    if (consume_ability_refresh(session, scratch, response, written, touchesScratch)) {
+        return true;
+    }
     if (!session.family4RepushArmed || session.family4RepushRoot == 0
         || GetTickCount64() < session.family4RepushDueTick) {
         return consume_banner_repush(session, scratch, response, written, touchesScratch)

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

@@ -22,7 +22,9 @@ bool stage_service_outcome(Scratch& scratch,
     bool armsRepush = false;
     bool armsBannerRepush = false;
     std::uint64_t bannerRoot = 0;
+    bool armsAbilityRefresh = false;
     const auto* equipment = transaction_if<EquipmentSwapTransaction>(outcome);
+    const auto* subclassSelection = transaction_if<SubclassSelectionTransaction>(outcome);
     const auto* itemState = transaction_if<ItemStateTransaction>(outcome);
     const auto* socket = transaction_if<SocketPlugTransaction>(outcome);
     const auto* itemAcquisition = transaction_if<ItemAcquisitionTransaction>(outcome);
@@ -73,6 +75,13 @@ bool stage_service_outcome(Scratch& scratch,
         }
         middleware::secure_channel::advance_nonce(nonce);
         after = swap.after;
+        // Swapping the subclass slot invalidates the published ability buckets the same way an
+        // opcode-801 pick does; the rebuild is likewise asynchronous, so this owes the same
+        // delayed re-derivation rather than risking a race with whatever refresh runs below.
+        if (equipment->pending.equipmentSlotIndex
+            == static_cast<std::size_t>(state::account::inventory::EquipmentSlot::subclass)) {
+            armsAbilityRefresh = true;
+        }
         // Family four drives inventory placement, while Family zero owns the rendered appearance
         // consumed by the open cosmetic panels and world player. Its resident character record is
         // updated in place: releasing and re-adding the same key tears down the ship/banner
@@ -134,6 +143,80 @@ bool stage_service_outcome(Scratch& scratch,
         }
         middleware::secure_channel::advance_nonce(nonce);
         after = update.after;
+    } else if (subclassSelection != nullptr) {
+        // Body processing already staged the exact +1 revision promised by opcode 801. Publish
+        // the resident subclass instance upsert, then the character-summary appearance and
+        // roster refreshes so gameplay's ability read picks up the new selection immediately
+        // instead of waiting on the next unrelated poll.
+        const SubclassSelection& selection = subclassSelection->update;
+        bool preservedManifest =
+            selection.after.family4ResidentCount == before.family4ResidentCount;
+        std::size_t targetMatches = 0;
+        for (std::size_t index = 0; preservedManifest && index < before.family4ResidentCount;
+             ++index) {
+            const ResidentObject& resident = before.family4Residents[index];
+            const ResidentObject& staged = selection.after.family4Residents[index];
+            preservedManifest = staged.objectSoid == resident.objectSoid
+                                && staged.definitionId == resident.definitionId;
+            targetMatches += static_cast<std::size_t>(
+                resident.objectSoid == selection.subclassInstanceSoid
+                && resident.definitionId == selection.itemInstanceDefinitionId);
+        }
+        if (!valid(selection.after) || !preservedManifest || targetMatches != 1
+            || selection.accountSoid != subclassSelection->pending.accountSoid
+            || selection.characterSoid != subclassSelection->pending.characterSoid
+            || selection.subclassInstanceSoid
+                   != subclassSelection->pending.subclassInstanceSoid
+            || selection.after.family4RootSoid != before.family4RootSoid
+            || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
+            || selection.after.family4Version != before.family4Version + 1
+            || !push::append_subclass_selection_notification(scratch,
+                                                              selection,
+                                                              subclassSelection->pending,
+                                                              key,
+                                                              nonce,
+                                                              response,
+                                                              written)) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             "ev=queuez stage=subclass_select result=fail");
+            return false;
+        }
+        middleware::secure_channel::advance_nonce(nonce);
+        after = selection.after;
+        // The rebuild that repopulates the invalidated ability buckets runs asynchronously, off
+        // the Client content-extraction pump, so the two refreshes below can still race it and
+        // carry stale or empty buckets. A delayed re-derivation is owed regardless of whether they
+        // do.
+        armsAbilityRefresh = true;
+        // A subclass is always equipped, so both the appearance and roster ability reads are
+        // always owed a refresh once one is active.
+        if (after.family0Active) {
+            CharacterAppearanceRefresh refresh{};
+            if (!stage_character_appearance_refresh(
+                    after, subclassSelection->pending.characterSoid, refresh)
+                || !push::append_subclass_appearance_refresh_notification(
+                    scratch, refresh, subclassSelection->pending, key, nonce, response, written)) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=queuez stage=subclass_appearance result=fail");
+                return false;
+            }
+            after = refresh.after;
+        }
+        if (after.family3Active) {
+            RosterAppearanceRefresh refresh{};
+            if (!stage_roster_appearance_refresh(
+                    after, subclassSelection->pending.characterSoid, false, refresh)
+                || !push::append_subclass_roster_refresh_notification(
+                    scratch, refresh, subclassSelection->pending, key, nonce, response, written)) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=queuez stage=subclass_roster result=fail");
+                return false;
+            }
+            after = refresh.after;
+        }
     } else if (socket != nullptr) {
         // Body processing staged this exact +1 revision before encoding opcode 903's status pair.
         // A socket selection changes only one already-resident item-instance body.
@@ -415,6 +498,7 @@ bool stage_service_outcome(Scratch& scratch,
     publication.family4RepushRoot = armsRepush ? outcome.subscription.familyRootSoid : 0;
     publication.armsBannerRepush = armsBannerRepush && bannerRoot != 0;
     publication.bannerRepushRoot = publication.armsBannerRepush ? bannerRoot : 0;
+    publication.armsAbilityRefresh = armsAbilityRefresh;
     return true;
 }
 

+ 7 - 0
Sunrise/src/server/bap/encrypted/queuez/queuez_state_validation.h

@@ -132,6 +132,13 @@ namespace sunrise::server::bap::encrypted::queuez {
                                      bool updatesAccount,
                                      SocketPlug& socketPlug) noexcept;
 
+/** Stages one resident subclass item-instance upsert without changing the Family-4 manifest. */
+[[nodiscard]] bool stage_subclass_selection(const SessionState& before,
+                                            std::uint64_t accountSoid,
+                                            std::uint64_t characterSoid,
+                                            std::uint64_t subclassInstanceSoid,
+                                            SubclassSelection& selection) noexcept;
+
 /**
  * Stages one Family-4 increment that adds a new resident item and updates its character.
  *

+ 71 - 0
Sunrise/src/server/bap/encrypted/queuez/staging/queuez_character_staging.cpp

@@ -299,6 +299,77 @@ bool stage_socket_plug(const SessionState& before,
     return staged;
 }
 
+/** Stages a resident subclass item-instance upsert while preserving the Family-4 manifest. */
+bool stage_subclass_selection(const SessionState& before,
+                              std::uint64_t accountSoid,
+                              std::uint64_t characterSoid,
+                              std::uint64_t subclassInstanceSoid,
+                              SubclassSelection& selection) noexcept {
+    selection = {};
+    std::uint32_t accountDefinitionId = 0;
+    std::uint32_t characterDefinitionId = 0;
+    std::uint32_t itemInstanceDefinitionId = 0;
+    if (!valid(before) || !before.family4Active || before.family4RootSoid == 0 || accountSoid == 0
+        || accountSoid != before.family4RootSoid || characterSoid == 0
+        || subclassInstanceSoid == 0 || characterSoid == subclassInstanceSoid
+        || before.family4ResidentCount == 0
+        || before.family4ResidentCount > before.family4Residents.size()
+        || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
+        || !middleware::datagen::object_id(
+            kAccountFamilyType, middleware::datagen::kAccountSlot, accountDefinitionId)
+        || !middleware::datagen::object_id(
+            kAccountFamilyType, middleware::datagen::kCharacterSlot, characterDefinitionId)
+        || !middleware::datagen::object_id(
+            kAccountFamilyType, middleware::datagen::kItemInstanceSlot, itemInstanceDefinitionId)) {
+        return false;
+    }
+
+    std::size_t accountMatches = 0;
+    std::size_t characterMatches = 0;
+    std::size_t targetMatches = 0;
+    for (std::size_t index = 0; index < before.family4ResidentCount; ++index) {
+        const ResidentObject& object = before.family4Residents[index];
+        accountMatches += static_cast<std::size_t>(object.objectSoid == accountSoid
+                                                   && object.definitionId == accountDefinitionId);
+        characterMatches += static_cast<std::size_t>(
+            object.objectSoid == characterSoid && object.definitionId == characterDefinitionId);
+        targetMatches += static_cast<std::size_t>(
+            object.objectSoid == subclassInstanceSoid
+            && object.definitionId == itemInstanceDefinitionId);
+    }
+    if (accountMatches != 1 || characterMatches != 1 || targetMatches != 1) {
+        return false;
+    }
+
+    selection.after = before;
+    ++selection.after.family4Version;
+    selection.itemInstanceDefinitionId = itemInstanceDefinitionId;
+    selection.accountSoid = accountSoid;
+    selection.characterSoid = characterSoid;
+    selection.subclassInstanceSoid = subclassInstanceSoid;
+    const bool staged = valid(selection.after);
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count = std::snprintf(
+        line.data(),
+        line.size(),
+        "ev=subclass_select stage=queuez_version result=%s root=0x%llX before=%d after=%d "
+        "residents=%u character=0x%llX instance=0x%llX item_definition=%u",
+        staged ? "ok" : "fail",
+        static_cast<unsigned long long>(before.family4RootSoid),
+        before.family4Version,
+        selection.after.family4Version,
+        static_cast<unsigned>(before.family4ResidentCount),
+        static_cast<unsigned long long>(characterSoid),
+        static_cast<unsigned long long>(subclassInstanceSoid),
+        itemInstanceDefinitionId);
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         staged ? core::log::Level::debug : core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+    return staged;
+}
+
 /** Stages the character upsert and appended resident required by one new item instance. */
 bool stage_item_acquisition(const SessionState& before,
                             std::uint64_t accountSoid,

+ 25 - 0
Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp

@@ -3,6 +3,7 @@
 #include <array>
 #include <cstdio>
 
+#include "../../../../client/content/investment/worker.h"
 #include "../../../../core/logging/log.h"
 #include "../../../../state/activity/runtime.h"
 #include "../../../../state/matchmaking/matchmaking_state.h"
@@ -181,11 +182,35 @@ bool commit(ServiceOutcome& outcome, Publication& publication) noexcept {
         return state::matchmaking::commit(*mutation);
     }
     if (auto* transaction = transaction_if<EquipmentSwapTransaction>(outcome)) {
+        const bool isSubclassSlot = transaction->pending.equipmentSlotIndex
+                                    == static_cast<std::size_t>(
+                                        state::account::inventory::EquipmentSlot::subclass);
         const bool committed = state::commit_equipment_swap(transaction->pending);
         core::log::write(core::log::Channel::server,
                          committed ? core::log::Level::debug : core::log::Level::warn,
                          committed ? "ev=equip stage=transaction_commit result=ok"
                                    : "ev=equip stage=transaction_commit result=fail");
+        if (committed && isSubclassSlot) {
+            // The equipped subclass just changed, which makes the published ability buckets
+            // stale the same way an ability-entry pick does; wake the investment worker so the
+            // character screen does not keep showing the previous subclass's resolution until
+            // some unrelated pump happens to refresh it.
+            client::content::investment::worker::request_slice();
+        }
+        return committed;
+    }
+    if (auto* transaction = transaction_if<SubclassSelectionTransaction>(outcome)) {
+        const bool committed = state::commit_subclass_selection(transaction->pending);
+        core::log::write(core::log::Channel::server,
+                         committed ? core::log::Level::debug : core::log::Level::warn,
+                         committed ? "ev=subclass_select stage=transaction_commit result=ok"
+                                   : "ev=subclass_select stage=transaction_commit result=fail");
+        if (committed) {
+            // The published ability buckets are keyed off the selection that just changed; wake
+            // the investment worker so its next pump rebuilds them instead of waiting on whatever
+            // cadence would otherwise trigger a fresh slice.
+            client::content::investment::worker::request_slice();
+        }
         return committed;
     }
     if (auto* transaction = transaction_if<ItemAcquisitionTransaction>(outcome)) {

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

@@ -173,6 +173,17 @@ struct Session {
     bool accountMutationPublished{};
     /** True while another peer's account mutation still needs a full local refresh. */
     bool accountResyncArmed{};
+    /**
+     * Tick count after which the owed ability-icon refresh may go out.
+     * A subclass selection invalidates the published ability buckets; the rebuild that restores
+     * them runs asynchronously off the Client content-extraction pump, so the appearance and
+     * roster refresh sent inline with the selection response can still carry the stale or empty
+     * buckets. This second, delayed refresh re-derives both records from committed State once the
+     * rebuild has had time to land.
+     */
+    std::uint64_t abilityRefreshDueTick{};
+    /** True while one ability-icon refresh is still owed to this peer. */
+    bool abilityRefreshArmed{};
 };
 
 namespace plaintext {

+ 92 - 0
Sunrise/src/server/web_service/web_service_actions.cpp

@@ -12,6 +12,7 @@
 #include "../../middleware/web_service/messages/opcode403.h"
 #include "../../middleware/web_service/messages/opcode406.h"
 #include "../../middleware/web_service/messages/opcode504.h"
+#include "../../middleware/web_service/messages/opcode801.h"
 #include "../../middleware/web_service/messages/opcode903.h"
 #include "../../state/account/account_state.h"
 #include "../../state/build_data/runtime.h"
@@ -151,6 +152,39 @@ void report_socket_plug_response(const middleware::web_service::Message& message
     core::log::write(core::log::Channel::server, core::log::Level::debug, {line.data(), length});
 }
 
+/** Logs the exact opcode-801 status pair and subclass item revision it promises. */
+void report_subclass_selection_response(const middleware::web_service::Message& message,
+                                        std::int32_t family4Version,
+                                        const state::PendingSubclassSelection& mutation,
+                                        std::span<const std::byte> response) noexcept {
+    std::array<char, core::log::kLineCapacity> line{};
+    const int prefix = std::snprintf(
+        line.data(),
+        line.size(),
+        "ev=subclass_select stage=response result=ok opcode=%u transaction=%u "
+        "family_version=%d instance=0x%llX entry=%u bytes=%zu hex=",
+        static_cast<unsigned>(message.opcode),
+        static_cast<unsigned>(message.transactionId),
+        family4Version,
+        static_cast<unsigned long long>(mutation.subclassInstanceSoid),
+        static_cast<unsigned>(mutation.requestedEntry),
+        response.size());
+    if (prefix <= 0 || static_cast<std::size_t>(prefix) >= line.size()) {
+        return;
+    }
+    constexpr char kHex[] = "0123456789ABCDEF";
+    std::size_t length = static_cast<std::size_t>(prefix);
+    for (const std::byte byte : response) {
+        if (length + 2 >= line.size()) {
+            break;
+        }
+        const unsigned value = std::to_integer<unsigned>(byte);
+        line[length++] = kHex[(value >> 4U) & 0xFU];
+        line[length++] = kHex[value & 0xFU];
+    }
+    core::log::write(core::log::Channel::server, core::log::Level::debug, {line.data(), length});
+}
+
 /** One line carries the picked id and whether the selection moved. */
 constexpr std::size_t kSelectLineCapacity = 96;
 
@@ -264,6 +298,64 @@ void mutate_equipment(const middleware::web_service::Message& message,
     }
 }
 
+/** Parses and prepares one exact selected-character opcode-801 subclass node selection. */
+void mutate_subclass_selection(const middleware::web_service::Message& message,
+                               Outcome& outcome) noexcept {
+    middleware::web_service::messages::opcode801::Request request{};
+    if (!middleware::web_service::messages::opcode801::parse_request(message, request)) {
+        std::array<char, 128> line{};
+        const int count = std::snprintf(
+            line.data(),
+            line.size(),
+            "ev=ws801 stage=parse result=fail transaction=%u payload_bytes=%zu",
+            static_cast<unsigned>(message.transactionId),
+            message.payload.size());
+        if (count > 0) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             {line.data(), static_cast<std::size_t>(count)});
+        }
+        return;
+    }
+
+    state::PendingSubclassSelection mutation{};
+    if (!state::prepare_subclass_selection(
+            request.subclassInstanceSoid, request.socketEntry, mutation)) {
+        std::array<char, 160> line{};
+        const int count = std::snprintf(
+            line.data(),
+            line.size(),
+            "ev=ws801 stage=prepare result=fail transaction=%u instance=0x%llX entry=%u",
+            static_cast<unsigned>(message.transactionId),
+            static_cast<unsigned long long>(request.subclassInstanceSoid),
+            static_cast<unsigned>(request.socketEntry));
+        if (count > 0) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             {line.data(), static_cast<std::size_t>(count)});
+        }
+        return;
+    }
+
+    outcome.mutation = mutation;
+    std::array<char, 224> line{};
+    const int count = std::snprintf(
+        line.data(),
+        line.size(),
+        "ev=ws801 stage=prepare result=ok transaction=%u character=0x%llX instance=0x%llX "
+        "entry=%u socket_list=%u",
+        static_cast<unsigned>(message.transactionId),
+        static_cast<unsigned long long>(mutation.characterSoid),
+        static_cast<unsigned long long>(mutation.subclassInstanceSoid),
+        static_cast<unsigned>(mutation.requestedEntry),
+        static_cast<unsigned>(mutation.socketEntryListIndex));
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+}
+
 /** Parses and prepares one exact selected-character opcode-903 socket selection. */
 void mutate_socket_plug(const middleware::web_service::Message& message,
                         Outcome& outcome) noexcept {

+ 2 - 0
Sunrise/src/server/web_service/web_service_actions.h

@@ -12,6 +12,8 @@ void mutate_equipment(const middleware::web_service::Message& message,
                       bool unequip,
                       Outcome& outcome) noexcept;
 void mutate_socket_plug(const middleware::web_service::Message& message, Outcome& outcome) noexcept;
+void mutate_subclass_selection(const middleware::web_service::Message& message,
+                               Outcome& outcome) noexcept;
 void mutate_equipped_socket_plug(const middleware::web_service::Message& message,
                                  Outcome& outcome) noexcept;
 void mutate_item_state(const middleware::web_service::Message& message, Outcome& outcome) noexcept;

+ 3 - 0
Sunrise/src/server/web_service/web_service_runtime.cpp

@@ -18,6 +18,7 @@
 #include "../../middleware/web_service/messages/opcode503.h"
 #include "../../middleware/web_service/messages/opcode504.h"
 #include "../../middleware/web_service/messages/opcode601/opcode601_codec.h"
+#include "../../middleware/web_service/messages/opcode801.h"
 #include "../../middleware/web_service/messages/opcode901/opcode901_codec.h"
 #include "../../middleware/web_service/messages/opcode903.h"
 #include "../../middleware/web_service/web_service_envelope.h"
@@ -283,6 +284,8 @@ bool consume(std::span<const std::byte> request,
         mutate_equipment(message, false, outcome);
     } else if (message.opcode == kUnequipOpcode) {
         mutate_equipment(message, true, outcome);
+    } else if (message.opcode == middleware::web_service::messages::opcode801::kOpcode) {
+        mutate_subclass_selection(message, outcome);
     } else if (message.opcode == middleware::web_service::messages::opcode903::kOpcode) {
         mutate_socket_plug(message, outcome);
     } else if (message.opcode == middleware::web_service::messages::opcode1901::kOpcode) {

+ 7 - 0
Sunrise/src/server/web_service/web_service_runtime.h

@@ -20,6 +20,7 @@ struct Outcome {
     /** A request prepares at most one State mutation; the alternative owns only that payload. */
     using Mutation = std::variant<std::monostate,
                                   state::PendingEquipmentSwap,
+                                  state::PendingSubclassSelection,
                                   state::PendingItemAcquisition,
                                   state::PendingProfileItemAcquisition,
                                   state::PendingItemDismantle,
@@ -66,6 +67,12 @@ void report_socket_plug_response(const middleware::web_service::Message& message
                                  std::uint16_t plugDefinitionIndex,
                                  std::span<const std::byte> response) noexcept;
 
+/** Records an opcode-801 reply after its exact subclass item-instance revision is known. */
+void report_subclass_selection_response(const middleware::web_service::Message& message,
+                                        std::int32_t family4Version,
+                                        const state::PendingSubclassSelection& mutation,
+                                        std::span<const std::byte> response) noexcept;
+
 /**
  * Answers one whole supported Web Service request body.
  * @param request Whole decrypted svc-10 body.

+ 38 - 12
Sunrise/src/state/account/account_state.h

@@ -95,6 +95,39 @@ inline constexpr std::uint8_t kDefaultMeleeAbilityEntry = 11;
 /** Default class-ability entry. Which bucket it publishes into follows the character class. */
 inline constexpr std::uint8_t kDefaultClassAbilityEntry = 2;
 
+/**
+ * Semantic ability-bucket destinations shared by the wire encoder and the selection logic that
+ * routes a clicked socket entry to a character field. A subclass entry's authored selector chain,
+ * not its table position, decides which of these it reaches; a bundled pick (an Attunement, for
+ * example) can freely mix members across them.
+ */
+inline constexpr std::uint8_t kGrenadeAbilityBucket = 0;
+inline constexpr std::uint8_t kSuperAbilityBucket = 1;
+inline constexpr std::uint8_t kMeleeAbilityBucket = 2;
+inline constexpr std::uint8_t kMovementAbilityBucket = 3;
+inline constexpr std::uint8_t kSprintAbilityBucket = 4;
+
+/**
+ * Widest node bundle one summary pick can publish together.
+ * Some groups (an Attunement pick, for example) hold several consecutive entries that all
+ * activate, and contribute their hashes, as one unit rather than a single alternative per group.
+ */
+inline constexpr std::size_t kMaxAttunementBundleSize = 4;
+
+/** @param characterClass Authored class. @return The bucket its class ability publishes into. */
+[[nodiscard]] inline constexpr std::uint8_t
+class_ability_bucket(CharacterClass characterClass) noexcept {
+    switch (characterClass) {
+    case CharacterClass::hunter:
+        return 9;
+    case CharacterClass::warlock:
+        return 11;
+    case CharacterClass::titan:
+    default:
+        return 6;
+    }
+}
+
 /** Authored state for one playable character slot. */
 struct CharacterState {
     std::uint64_t soid{};
@@ -113,19 +146,12 @@ struct CharacterState {
     /** 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.
+     * 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.
+     * EXPERIMENT: defaulted to all-set so every ready entry reads as acquired instead of new, to
+     * test whether the client only allows clicking an already-acquired node.
      */
-    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};
+    std::uint64_t acquiredSubclassAbilityMask{~std::uint64_t{0}};
     /** Authored loadout keyed only by stable semantic equipment slots. */
     account::inventory::Equipment equipment;
     /** Unequipped items routed into their installed character-inventory bucket ranges. */

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

@@ -93,6 +93,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 - 2
Sunrise/src/state/build_data/abilities/definition.h

@@ -12,8 +12,14 @@ inline constexpr std::size_t kBucketCapacity = 12;
 inline constexpr std::size_t kBucketHashCapacity = 16;
 /** The flat overflow bank holds 32 hashes no bucket category claims. */
 inline constexpr std::size_t kOverflowCapacity = 32;
-/** One row per distinct subclass and ability selection the configured characters use. */
-inline constexpr std::size_t kDefinitionCapacity = 8;
+/**
+ * One row per distinct subclass and ability selection the configured characters use. Each
+ * character publishes a row for every subclass it owns at the shared default selection (not just
+ * the equipped one), plus one extra row for the equipped subclass's real picks when those are not
+ * already the default — 3 characters by up to 4 rows (3 subclasses plus the equipped one's real
+ * picks) each.
+ */
+inline constexpr std::size_t kDefinitionCapacity = 12;
 /** All bits set marks a bucket no entry claimed. */
 inline constexpr std::uint8_t kEmptyBucketKind = 0xFF;
 

+ 56 - 0
Sunrise/src/state/build_data/runtime.h

@@ -18,6 +18,7 @@
 #include "material_requirements/material_requirement_catalog.h"
 #include "progressions/definition.h"
 #include "scenarios/definition.h"
+#include "socket_entry_buckets/definition.h"
 #include "socket_entry_lists/definition.h"
 #include "spawn_sets/definition.h"
 #include "vendors/definition.h"
@@ -253,6 +254,45 @@ publish_ability_buckets(std::span<const abilities::Definition> definitions) noex
                                         const abilities::Selection& selection,
                                         abilities::Definition& definition) noexcept;
 
+/**
+ * Drops the published ability bucket domain so the next investment refresh slice rebuilds it.
+ * A committed subclass ability-entry change makes the published rows stale for their character,
+ * since they were keyed by the selection in place when the domain was first built.
+ */
+void invalidate_ability_buckets() noexcept;
+
+/**
+ * @return True when at least one socket-entry list's resolved bucket destinations are published.
+ * Unlike the ability buckets above, this is never part of the on-disk content cache: it is a
+ * small derived table, cheap to recompute, so a warm boot that skips re-extraction (because the
+ * ability buckets it gates alongside are already cached) must not leave it permanently empty for
+ * the whole session. package_item_rows.cpp checks this independently of ability_buckets_ready()
+ * so a warm cache still triggers the one extraction pass this table needs.
+ */
+[[nodiscard]] bool socket_entry_buckets_ready() noexcept;
+
+/**
+ * Publishes every socket-entry list's resolved per-entry ability-bucket destinations.
+ * Purely a derived cache of static content (which of the 12 semantic ability buckets each entry's
+ * selector chain reaches), so unlike the ability buckets above it never needs invalidating: a
+ * subclass's entry table does not change after content extraction.
+ * @param definitions Complete rows, one per socket-entry list that carries a super lane.
+ * @return True when the rows pass the checks.
+ */
+[[nodiscard]] bool publish_socket_entry_buckets(
+    std::span<const socket_entry_buckets::Definition> definitions) noexcept;
+
+/**
+ * Finds which of the 12 semantic ability buckets one socket entry resolves to.
+ * @param socketEntryListIndex Native socket-entry-list index of the subclass.
+ * @param entryIndex The entry to look up.
+ * @param bucket Receives the resolved destination, or the no-destination sentinel.
+ * @return True when the list's row is published and the entry index is in range.
+ */
+[[nodiscard]] bool find_socket_entry_bucket(std::uint16_t socketEntryListIndex,
+                                            std::uint8_t entryIndex,
+                                            std::uint8_t& bucket) noexcept;
+
 /** @return True when the installed investment constants are in State. */
 [[nodiscard]] bool investment_constants_ready() noexcept;
 
@@ -330,6 +370,22 @@ publish_socket_entry_lists(std::span<const socket_entry_lists::Definition> defin
 [[nodiscard]] bool find_socket_entry_list(std::uint16_t definitionIndex,
                                           socket_entry_lists::Definition& definition) noexcept;
 
+/** Number of subclass items every character class ships in this build. */
+inline constexpr std::size_t kSubclassGroupSize = 3;
+
+/**
+ * Finds the 2 other subclasses that share one character class with a known member.
+ * The installed manifest lists every subclass item (any item carrying a socket-entry-list)
+ * together as one dense run per class, in native definition-index order, so the run holding a
+ * known member gives every other member with no per-class identity of its own to look up.
+ * @param memberDefinitionIndex Native item-definition index of one subclass in the class.
+ * @param group Receives the 3 member indices, in native definition-index order.
+ * @return True when every subclass item was found and `memberDefinitionIndex` is one of them.
+ */
+[[nodiscard]] bool
+find_subclass_group(std::uint16_t memberDefinitionIndex,
+                    std::array<std::uint16_t, kSubclassGroupSize>& group) noexcept;
+
 /** @return True when a complete destination-layout domain, empty or not, is published. */
 [[nodiscard]] bool scenario_layouts_ready() noexcept;
 

+ 53 - 3
Sunrise/src/state/build_data/runtime/build_data_catalog_runtime.cpp

@@ -12,6 +12,7 @@
 #include "../progressions/progression_catalog.h"
 #include "../runtime.h"
 #include "../scenarios/scenario_catalog.h"
+#include "../socket_entry_buckets/socket_entry_bucket_catalog.h"
 #include "../socket_entry_lists/socket_entry_list_catalog.h"
 #include "../spawn_sets/spawn_set_catalog.h"
 #include "../vendors/vendor_catalog.h"
@@ -242,12 +243,23 @@ bool ability_buckets_ready() noexcept {
 /** Publishes the ability buckets every configured subclass and ability selection publishes. */
 bool publish_ability_buckets(std::span<const abilities::Definition> definitions) noexcept {
     runtime::persistence::Transaction transaction;
-    if (!transaction.active() || !abilities::replace(definitions)) {
+    if (transaction.active()) {
+        if (!abilities::replace(definitions)) {
+            return false;
+        }
+        // Row count does not matter here, because a loadout with no subclass is a complete one.
+        runtime::ability_buckets::publish();
+        return transaction.finish(true, rollback_ability_publication);
+    }
+    // The disk cache already froze every domain at boot, so the transaction above refuses to run.
+    // Ability buckets track the player's live subclass selection rather than installed content, so
+    // a later in-session pick still has to update this one domain in memory; it just no longer
+    // takes part in the one-time disk snapshot.
+    if (!abilities::replace(definitions)) {
         return false;
     }
-    // Row count does not matter here, because a loadout with no subclass is a complete one.
     runtime::ability_buckets::publish();
-    return transaction.finish(true, rollback_ability_publication);
+    return true;
 }
 
 /** Finds the buckets one subclass publishes under one ability selection. */
@@ -258,6 +270,44 @@ bool find_ability_buckets(std::uint16_t socketEntryListIndex,
     return ability_buckets_ready() && abilities::find(socketEntryListIndex, selection, definition);
 }
 
+/** Drops the published ability bucket domain so the next investment refresh slice rebuilds it. */
+void invalidate_ability_buckets() noexcept {
+    runtime::ability_buckets::clear();
+    abilities::clear();
+}
+
+/** True when at least one socket-entry list's resolved bucket destinations are published. */
+bool socket_entry_buckets_ready() noexcept {
+    return runtime::socket_entry_buckets::ready();
+}
+
+/** Publishes every socket-entry list's resolved per-entry ability-bucket destinations. */
+bool publish_socket_entry_buckets(
+    std::span<const socket_entry_buckets::Definition> definitions) noexcept {
+    if (!socket_entry_buckets::replace(definitions)) {
+        return false;
+    }
+    // An empty domain counts as complete, matching the ability buckets it is resolved alongside:
+    // an account with no subclass equipped legitimately produces zero rows, and that must not
+    // make the extraction pass retry every refresh slice forever.
+    runtime::socket_entry_buckets::publish();
+    return true;
+}
+
+/** Finds which of the 12 semantic ability buckets one socket entry resolves to. */
+bool find_socket_entry_bucket(std::uint16_t socketEntryListIndex,
+                              std::uint8_t entryIndex,
+                              std::uint8_t& bucket) noexcept {
+    bucket = socket_entry_buckets::kNoDestinationBucket;
+    socket_entry_buckets::Definition row{};
+    if (!socket_entry_buckets::find(socketEntryListIndex, row)
+        || entryIndex >= row.buckets.size()) {
+        return false;
+    }
+    bucket = row.buckets[entryIndex];
+    return true;
+}
+
 /** @return True when the installed investment constants are in State. */
 bool investment_constants_ready() noexcept {
     constants::InvestmentConstants published{};

+ 38 - 0
Sunrise/src/state/build_data/runtime/build_data_routing_catalogs.cpp

@@ -1,4 +1,5 @@
 #include "../inventory/buckets/inventory_bucket_catalog.h"
+#include "../items/details/definition.h"
 #include "../items/item_catalog.h"
 #include "../runtime.h"
 #include "../socket_entry_lists/socket_entry_list_catalog.h"
@@ -69,4 +70,41 @@ bool find_socket_entry_list(std::uint16_t definitionIndex,
     return socket_entry_lists_ready() && socket_entry_lists::find(definitionIndex, definition);
 }
 
+/** Finds the 2 other subclasses sharing one character class with a known member. */
+bool find_subclass_group(std::uint16_t memberDefinitionIndex,
+                         std::array<std::uint16_t, kSubclassGroupSize>& group) noexcept {
+    group.fill(0);
+    const std::size_t itemCount = item_definition_count();
+    if (itemCount == 0) {
+        return false;
+    }
+    // Every subclass item (any item carrying a socket-entry-list), in native definition-index
+    // order. The installed manifest lists these as one dense run of kSubclassGroupSize per class.
+    std::array<std::uint16_t, socket_entry_lists::kEntryTableCapacity> subclasses{};
+    std::size_t subclassCount = 0;
+    for (std::size_t index = 0; index < itemCount && subclassCount < subclasses.size(); ++index) {
+        items::details::Definition detail{};
+        socket_entry_lists::EntryTable entries{};
+        if (find_configured_item_detail(static_cast<std::uint16_t>(index), detail)
+            && detail.definitionIndex == index
+            && find_socket_entry_table(detail.socketEntryListIndex, entries)) {
+            subclasses[subclassCount++] = static_cast<std::uint16_t>(index);
+        }
+    }
+    for (std::size_t base = 0; base + kSubclassGroupSize <= subclassCount;
+         base += kSubclassGroupSize) {
+        const bool matches = subclasses[base] == memberDefinitionIndex
+                             || subclasses[base + 1] == memberDefinitionIndex
+                             || subclasses[base + 2] == memberDefinitionIndex;
+        if (!matches) {
+            continue;
+        }
+        group[0] = subclasses[base];
+        group[1] = subclasses[base + 1];
+        group[2] = subclasses[base + 2];
+        return true;
+    }
+    return false;
+}
+
 } // namespace sunrise::state::build_data

+ 17 - 0
Sunrise/src/state/build_data/runtime/domain_markers.cpp

@@ -36,6 +36,7 @@ private:
 };
 
 DomainMarker g_abilityBuckets;
+DomainMarker g_socketEntryBuckets;
 DomainMarker g_details;
 DomainMarker g_named;
 DomainMarker g_spawnCatalog;
@@ -59,6 +60,22 @@ bool ready() noexcept {
 
 } // namespace ability_buckets
 
+namespace socket_entry_buckets {
+
+void clear() noexcept {
+    g_socketEntryBuckets.clear();
+}
+
+void publish() noexcept {
+    g_socketEntryBuckets.publish();
+}
+
+bool ready() noexcept {
+    return g_socketEntryBuckets.ready();
+}
+
+} // namespace socket_entry_buckets
+
 namespace details {
 
 void clear() noexcept {

+ 14 - 0
Sunrise/src/state/build_data/runtime/domain_markers.h

@@ -16,6 +16,20 @@ void publish() noexcept;
 
 } // namespace ability_buckets
 
+/** Socket-entry bucket domain publish marker. */
+namespace socket_entry_buckets {
+
+/** Clears the marker. */
+void clear() noexcept;
+
+/** Marks the domain complete. An empty domain counts as complete. */
+void publish() noexcept;
+
+/** @return True once a complete domain has been published. */
+[[nodiscard]] bool ready() noexcept;
+
+} // namespace socket_entry_buckets
+
 /** Configured item detail domain publish marker. */
 namespace details {
 

+ 28 - 0
Sunrise/src/state/build_data/socket_entry_buckets/definition.h

@@ -0,0 +1,28 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+
+#include "../socket_entry_lists/definition.h"
+
+namespace sunrise::state::build_data::socket_entry_buckets {
+
+/** No shipped subclass carries more socket-entry lists with a super lane than this. */
+inline constexpr std::size_t kDefinitionCapacity = 32;
+/** Marks an entry whose selector chain never reaches one of the 12 semantic ability buckets. */
+inline constexpr std::uint8_t kNoDestinationBucket = 0xFF;
+
+/**
+ * One socket-entry list's resolved ability-bucket destination per entry.
+ * A pick's socket-entry-table position does not say which ability slot it fills; only its
+ * selector chain does. This is computed once from the installed pool data during content
+ * extraction, so runtime code can route a click to the right character field without re-reading
+ * raw content.
+ */
+struct Definition {
+    std::uint16_t socketEntryListIndex{};
+    std::array<std::uint8_t, socket_entry_lists::kEntryCapacity> buckets{};
+};
+
+} // namespace sunrise::state::build_data::socket_entry_buckets

+ 62 - 0
Sunrise/src/state/build_data/socket_entry_buckets/socket_entry_bucket_catalog.cpp

@@ -0,0 +1,62 @@
+#include "socket_entry_bucket_catalog.h"
+
+#include "../table.h"
+
+namespace sunrise::state::build_data::socket_entry_buckets {
+namespace {
+
+Lock g_lock;
+Table<Definition, kDefinitionCapacity> g_definitions;
+
+} // namespace
+
+/** Clears every resolved entry-bucket row under the catalog lock. */
+void clear() noexcept {
+    const Lock::Exclusive guard(g_lock);
+    g_definitions.clear();
+}
+
+/** Checks that no two rows name the same socket-entry list. */
+bool valid(std::span<const Definition> definitions) noexcept {
+    if (definitions.size() > kDefinitionCapacity) {
+        return false;
+    }
+    for (std::size_t row = 0; row < definitions.size(); ++row) {
+        for (std::size_t other = row + 1; other < definitions.size(); ++other) {
+            if (definitions[row].socketEntryListIndex == definitions[other].socketEntryListIndex) {
+                return false;
+            }
+        }
+    }
+    return true;
+}
+
+/** Replaces the resolved entry-bucket rows in one step. */
+bool replace(std::span<const Definition> definitions) noexcept {
+    if (!valid(definitions)) {
+        return false;
+    }
+    const Lock::Exclusive guard(g_lock);
+    return g_definitions.replace(definitions);
+}
+
+/** Finds one socket-entry list's resolved per-entry ability-bucket destinations. */
+bool find(std::uint16_t socketEntryListIndex, Definition& definition) noexcept {
+    definition = {};
+    const Lock::Shared guard(g_lock);
+    for (const Definition& row : g_definitions.rows()) {
+        if (row.socketEntryListIndex == socketEntryListIndex) {
+            definition = row;
+            return true;
+        }
+    }
+    return false;
+}
+
+/** @return Number of resolved entry-bucket rows, read under the lock. */
+std::size_t count() noexcept {
+    const Lock::Shared guard(g_lock);
+    return g_definitions.count();
+}
+
+} // namespace sunrise::state::build_data::socket_entry_buckets

+ 38 - 0
Sunrise/src/state/build_data/socket_entry_buckets/socket_entry_bucket_catalog.h

@@ -0,0 +1,38 @@
+#pragma once
+
+#include <cstddef>
+#include <span>
+
+#include "definition.h"
+
+namespace sunrise::state::build_data::socket_entry_buckets {
+
+/** Clears every resolved entry-bucket row. */
+void clear() noexcept;
+
+/**
+ * Checks that no two rows name the same socket-entry list.
+ * @param definitions Candidate rows.
+ * @return True when the rows fit storage and every key is unique.
+ */
+[[nodiscard]] bool valid(std::span<const Definition> definitions) noexcept;
+
+/**
+ * Replaces the resolved entry-bucket rows in one step.
+ * @param definitions Complete rows in any order.
+ * @return True when the rows pass the checks and fit fixed State storage.
+ */
+[[nodiscard]] bool replace(std::span<const Definition> definitions) noexcept;
+
+/**
+ * Finds one socket-entry list's resolved per-entry ability-bucket destinations.
+ * @param socketEntryListIndex Native socket-entry-list index of the subclass.
+ * @param definition Receives the matching row.
+ * @return True when a row carries that exact key.
+ */
+[[nodiscard]] bool find(std::uint16_t socketEntryListIndex, Definition& definition) noexcept;
+
+/** @return Number of resolved entry-bucket rows, read under the lock. */
+[[nodiscard]] std::size_t count() noexcept;
+
+} // namespace sunrise::state::build_data::socket_entry_buckets

+ 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);

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

@@ -15,6 +15,50 @@ namespace sunrise::state {
  */
 [[nodiscard]] bool ensure_profile_item_identities() noexcept;
 
+/**
+ * Grants each character the other 2 subclasses of the class its equipped subclass belongs to,
+ * placing any missing ones into unequipped inventory with native socket defaults. Idempotent:
+ * a subclass already equipped or already sitting in inventory is left alone.
+ * @return True when every character with an equipped subclass ends up holding its whole class,
+ * or there was nothing to check (no account, or no character carries a subclass yet).
+ */
+[[nodiscard]] bool ensure_character_subclasses() noexcept;
+
+/** Prepared subclass socket-entry selection for the equipped selected-character subclass. */
+struct PendingSubclassSelection {
+    /** Exact prepare-time character view used as the commit staleness guard. */
+    CharacterState beforeCharacter{};
+    /** Canonical after-image. Only one authored ability-entry field differs. */
+    CharacterState afterCharacter{};
+    std::uint64_t accountSoid{};
+    std::uint64_t characterSoid{};
+    std::uint64_t subclassInstanceSoid{};
+    std::uint32_t subclassDefinitionHash{};
+    std::size_t characterIndex{};
+    std::uint16_t subclassDefinitionIndex{};
+    std::uint16_t socketEntryListIndex{};
+    /** Exact entry named by opcode 801. */
+    std::uint8_t requestedEntry{};
+    bool prepared{};
+};
+
+/**
+ * Prepares one opcode-801 selection against the selected character's exact equipped subclass.
+ * The installed socket-entry table maps the request to whichever of the character's 5 authored
+ * ability picks currently competes in the same group; no class-specific node indices are
+ * authored in State.
+ */
+[[nodiscard]] bool prepare_subclass_selection(std::uint64_t subclassInstanceSoid,
+                                              std::uint8_t requestedEntry,
+                                              PendingSubclassSelection& mutation) noexcept;
+
+/** Produces the complete uncommitted account after-image for a prepared subclass selection. */
+[[nodiscard]] bool preview_subclass_selection(const PendingSubclassSelection& mutation,
+                                              AccountState& after) noexcept;
+
+/** Commits a prepared subclass selection behind the exact full-character staleness guard. */
+[[nodiscard]] bool commit_subclass_selection(PendingSubclassSelection& mutation) noexcept;
+
 /** Direction of one checked character equipment mutation. */
 enum class EquipmentMutationKind : std::uint8_t {
     none,

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

@@ -293,7 +293,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. */
@@ -348,11 +353,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;

+ 100 - 0
Sunrise/src/state/runtime/state_account_item_action_runtime.cpp

@@ -515,4 +515,104 @@ bool commit_item_state(PendingItemState& mutation) noexcept {
     return true;
 }
 
+/** Prepares one checked subclass socket-entry selection without publishing account State. */
+bool prepare_subclass_selection(std::uint64_t subclassInstanceSoid,
+                                std::uint8_t requestedEntry,
+                                PendingSubclassSelection& mutation) noexcept {
+    mutation = {};
+    const AccountState snapshot = account_snapshot();
+    std::size_t characterIndex = snapshot.characterCount;
+    if (account::valid(snapshot)) {
+        for (std::size_t index = 0; index < snapshot.characterCount; ++index) {
+            if (snapshot.characters[index].selected) {
+                characterIndex = index;
+                break;
+            }
+        }
+    }
+    if (characterIndex >= snapshot.characterCount
+        || !stage_subclass_selection(
+            snapshot, characterIndex, subclassInstanceSoid, requestedEntry, mutation)) {
+        mutation = {};
+        return false;
+    }
+    return true;
+}
+
+/** Produces the complete account after-image while the prepared subclass action remains current. */
+bool preview_subclass_selection(const PendingSubclassSelection& mutation,
+                                AccountState& after) noexcept {
+    after = {};
+    if (!mutation.prepared || mutation.accountSoid == 0 || mutation.characterSoid == 0
+        || mutation.subclassInstanceSoid == 0 || mutation.characterIndex >= kCharacterCapacity) {
+        return false;
+    }
+    const AccountState current = account_snapshot();
+    if (mutation.characterIndex >= current.characterCount
+        || current.primarySoid != mutation.accountSoid
+        || !same_character(current.characters[mutation.characterIndex], mutation.beforeCharacter)) {
+        return false;
+    }
+    PendingSubclassSelection canonical{};
+    if (!stage_subclass_selection(current,
+                                  mutation.characterIndex,
+                                  mutation.subclassInstanceSoid,
+                                  mutation.requestedEntry,
+                                  canonical)
+        || !same_character(canonical.afterCharacter, mutation.afterCharacter)) {
+        return false;
+    }
+    after = current;
+    after.characters[mutation.characterIndex] = canonical.afterCharacter;
+    family4_loadout::ResolvedLoadout resolved{};
+    return account::valid(after)
+           && family4_loadout::resolve(after, mutation.characterIndex, resolved);
+}
+
+/** Commits one prepared subclass selection behind exact account and character guards. */
+bool commit_subclass_selection(PendingSubclassSelection& mutation) noexcept {
+    const PendingSubclassSelection prepared = mutation;
+    mutation = {};
+    if (!prepared.prepared || prepared.accountSoid == 0 || prepared.characterSoid == 0
+        || prepared.subclassInstanceSoid == 0 || prepared.characterIndex >= kCharacterCapacity
+        || prepared.beforeCharacter.soid != prepared.characterSoid
+        || prepared.afterCharacter.soid != prepared.characterSoid) {
+        return false;
+    }
+
+    AcquireSRWLockExclusive(&runtime::storage::g_stateLock);
+    AccountState candidate = runtime::storage::g_state.account;
+    if (prepared.characterIndex >= candidate.characterCount
+        || candidate.primarySoid != prepared.accountSoid
+        || !same_character(candidate.characters[prepared.characterIndex],
+                           prepared.beforeCharacter)) {
+        ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
+        return false;
+    }
+    PendingSubclassSelection canonical{};
+    if (!stage_subclass_selection(candidate,
+                                  prepared.characterIndex,
+                                  prepared.subclassInstanceSoid,
+                                  prepared.requestedEntry,
+                                  canonical)
+        || !same_character(canonical.afterCharacter, prepared.afterCharacter)) {
+        ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
+        return false;
+    }
+    candidate.characters[prepared.characterIndex] = canonical.afterCharacter;
+    family4_loadout::ResolvedLoadout checked{};
+    if (!account::valid(candidate)
+        || !family4_loadout::resolve(candidate, prepared.characterIndex, checked)) {
+        ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
+        return false;
+    }
+    runtime::storage::g_state.account = candidate;
+    ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
+
+    // The published ability buckets are keyed to whichever selection is currently active; that
+    // just changed, so the domain is stale the moment the account write above becomes visible.
+    build_data::invalidate_ability_buckets();
+    return true;
+}
+
 } // namespace sunrise::state

+ 233 - 1
Sunrise/src/state/runtime/state_account_runtime.cpp

@@ -1,4 +1,4 @@
-#include <Windows.h>
+#include <Windows.h>
 
 #include <algorithm>
 #include <array>
@@ -24,6 +24,7 @@ namespace authored_inventory = account::inventory;
 namespace item_details = build_data::items::details;
 namespace inventory_buckets = build_data::inventory::buckets;
 namespace family4_loadout = middleware::datagen::family4::loadout;
+namespace socket_lists = build_data::socket_entry_lists;
 
 /** Writes one exhaustive equipment-transaction checkpoint to the persistent diagnostic log. */
 void report_equipment(std::string_view stage,
@@ -105,6 +106,150 @@ void report_acquisition(std::string_view stage,
     }
 }
 
+/**
+ * Prepares a subclass ability-entry transition without publishing account State.
+ * The requested entry must currently compete (share a socket-entry group) with exactly one of the
+ * character's 5 authored ability picks; that pick is the one the transition updates. This mirrors
+ * how `resolve_socket_states` decides which entries a selection makes active, so the entry a
+ * request names always maps back to the same field that selection would have set.
+ */
+[[nodiscard]] bool stage_subclass_selection(const AccountState& snapshot,
+                                            std::size_t characterIndex,
+                                            std::uint64_t subclassInstanceSoid,
+                                            std::uint8_t requestedEntry,
+                                            PendingSubclassSelection& mutation) noexcept {
+    mutation = {};
+    if (!account::valid(snapshot) || characterIndex >= snapshot.characterCount
+        || subclassInstanceSoid == 0 || requestedEntry >= socket_lists::kEntryCapacity) {
+        return false;
+    }
+    const CharacterState& before = snapshot.characters[characterIndex];
+    if (!before.selected || before.soid == 0) {
+        return false;
+    }
+    constexpr std::size_t kSubclassSlot =
+        static_cast<std::size_t>(authored_inventory::EquipmentSlot::subclass);
+    const auto& subclass = before.equipment.slots[kSubclassSlot];
+    build_data::items::Definition subclassDefinition{};
+    item_details::Definition detail{};
+    socket_lists::EntryTable entries{};
+    if (!subclass.has_value() || subclass->instanceSoid != subclassInstanceSoid
+        || !build_data::find_item_definition_hash(subclass->definitionHash, subclassDefinition)
+        || !build_data::find_configured_item_detail(subclassDefinition.definitionIndex, detail)
+        || !build_data::find_socket_entry_table(detail.socketEntryListIndex, entries)
+        || requestedEntry >= entries.entries.size()) {
+        return false;
+    }
+
+    const socket_lists::Entry& requested = entries.entries[requestedEntry];
+    if (requested.plugSource == socket_lists::kNoPlugSource
+        || requested.group == socket_lists::kNoEntryGroup) {
+        return false;
+    }
+
+    // A clicked entry's table position does not say which ability slot it fills; only its
+    // resolved destination bucket does. A bundled pick (an Attunement, for example) can mix its
+    // 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, &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 {
+        std::uint8_t bucket = build_data::socket_entry_buckets::kNoDestinationBucket;
+        (void)build_data::find_socket_entry_bucket(
+            detail.socketEntryListIndex, entryIndex, bucket);
+        return bucket;
+    };
+    const auto route_entry = [&](std::uint8_t entryIndex) noexcept {
+        const std::uint8_t bucket = bucket_of(entryIndex);
+        for (const Route& route : routes) {
+            if (route.bucket == bucket) {
+                *route.field = entryIndex;
+                return;
+            }
+        }
+    };
+    // A click can land on any member of a bundle, not only the routable one: the diamond's other
+    // 3 quadrants are passive nodes with no destination bucket of their own (see the group-3
+    // dump: only one member of each 4-node group resolves to melee, or to super and melee both).
+    // The requested entry is only ever the whole bundle's anchor when it happens to be its lowest
+    // index, so the bundle's true start is found by scanning backward first, then every member is
+    // routed from there. Members share the anchor's group only while a wide group (a bundle, not
+    // a simple set of alternatives) is in play; see resolve_socket_states for the same threshold.
+    std::size_t groupPopulation = 0;
+    for (std::size_t index = 0; index < entries.entries.size(); ++index) {
+        if (entries.entries[index].group == requested.group) {
+            ++groupPopulation;
+        }
+    }
+    if (groupPopulation <= kMaxAttunementBundleSize) {
+        route_entry(requestedEntry);
+    } else {
+        // A wide group is several same-sized bundles competing for one pick, not several
+        // independent alternatives, so only one bundle's fields stay set at a time. A bundle that
+        // does not touch every field this group can reach (the top and bottom Attunement options
+        // here do not touch super, only the middle one does) must not leave an earlier bundle's
+        // value behind in the field it left alone: super stuck on a prior Attunement's pick while
+        // melee moves to a different one is a combination the game never produces on its own, and
+        // it stops accepting further picks once state reaches it. Every bucket this whole group
+        // can ever reach is reset to its ordinary default first, and only then does the picked
+        // bundle's own members overwrite the ones it actually claims.
+        for (std::size_t index = 0; index < entries.entries.size(); ++index) {
+            if (entries.entries[index].group != requested.group) {
+                continue;
+            }
+            const std::uint8_t bucket = bucket_of(static_cast<std::uint8_t>(index));
+            for (const Route& route : routes) {
+                if (route.bucket == bucket) {
+                    *route.field = route.defaultEntry;
+                }
+            }
+        }
+        std::uint8_t blockStart = requestedEntry;
+        while (blockStart > 0 && requestedEntry - blockStart < kMaxAttunementBundleSize - 1
+               && entries.entries[blockStart - 1].group == requested.group) {
+            --blockStart;
+        }
+        for (std::size_t offset = 0; offset < kMaxAttunementBundleSize
+             && blockStart + offset < entries.entries.size()
+             && entries.entries[blockStart + offset].group == requested.group;
+             ++offset) {
+            route_entry(static_cast<std::uint8_t>(blockStart + offset));
+        }
+    }
+    if (same_character(before, after)) {
+        return false;
+    }
+
+    mutation.beforeCharacter = before;
+    mutation.afterCharacter = after;
+    mutation.accountSoid = snapshot.primarySoid;
+    mutation.characterSoid = before.soid;
+    mutation.subclassInstanceSoid = subclassInstanceSoid;
+    mutation.subclassDefinitionHash = subclassDefinition.definitionHash;
+    mutation.characterIndex = characterIndex;
+    mutation.subclassDefinitionIndex = subclassDefinition.definitionIndex;
+    mutation.socketEntryListIndex = detail.socketEntryListIndex;
+    mutation.requestedEntry = requestedEntry;
+    mutation.prepared = true;
+    return true;
+}
+
 } // namespace runtime::detail
 
 using namespace runtime::detail;
@@ -486,6 +631,15 @@ bool commit_equipment_swap(PendingEquipmentSwap& mutation) noexcept {
     runtime::storage::g_state.account = candidate;
     ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
 
+    // The published ability buckets are resolved against whichever subclass is currently
+    // equipped; swapping that item away makes the domain stale the same way an ability-entry
+    // pick does, so it needs the same invalidation or the character screen keeps showing
+    // whatever the previous subclass resolved to until something else happens to refresh it.
+    if (prepared.equipmentSlotIndex
+        == static_cast<std::size_t>(authored_inventory::EquipmentSlot::subclass)) {
+        build_data::invalidate_ability_buckets();
+    }
+
     report_equipment("commit_end",
                      "ok",
                      prepared.kind,
@@ -509,4 +663,82 @@ AccountState account_snapshot() noexcept {
     return snapshot;
 }
 
+/** Grants each character the other 2 subclasses of its equipped subclass's class. */
+bool ensure_character_subclasses() noexcept {
+    constexpr std::size_t kSubclassSlot =
+        static_cast<std::size_t>(authored_inventory::EquipmentSlot::subclass);
+    AcquireSRWLockExclusive(&runtime::storage::g_stateLock);
+    AccountState candidate = runtime::storage::g_state.account;
+    if (!account::valid(candidate)) {
+        ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
+        return true;
+    }
+    std::uint64_t nextSoid = 0;
+    bool haveNextSoid = false;
+    bool changed = false;
+    bool failed = false;
+    for (std::size_t characterIndex = 0;
+         characterIndex < candidate.characterCount && !failed;
+         ++characterIndex) {
+        CharacterState& character = candidate.characters[characterIndex];
+        const std::optional<authored_inventory::Item>& equipped =
+            character.equipment.slots[kSubclassSlot];
+        if (!equipped.has_value()) {
+            continue;
+        }
+        build_data::items::Definition equippedDefinition{};
+        std::array<std::uint16_t, build_data::kSubclassGroupSize> group{};
+        if (!build_data::find_item_definition_hash(equipped->definitionHash, equippedDefinition)
+            || !build_data::find_subclass_group(equippedDefinition.definitionIndex, group)) {
+            continue;
+        }
+        for (const std::uint16_t memberIndex : group) {
+            if (memberIndex == equippedDefinition.definitionIndex) {
+                continue;
+            }
+            build_data::items::Definition memberDefinition{};
+            if (!build_data::find_item_definition_index(memberIndex, memberDefinition)
+                || memberDefinition.definitionIndex != memberIndex) {
+                continue;
+            }
+            bool present = false;
+            for (std::size_t itemIndex = 0; itemIndex < character.inventory.count; ++itemIndex) {
+                if (character.inventory.values[itemIndex].definitionHash
+                    == memberDefinition.definitionHash) {
+                    present = true;
+                    break;
+                }
+            }
+            if (present || character.inventory.count >= character.inventory.values.size()) {
+                continue;
+            }
+            if (!haveNextSoid) {
+                if (!next_item_instance_soid(candidate, nextSoid)) {
+                    failed = true;
+                    break;
+                }
+                haveNextSoid = true;
+            }
+            authored_inventory::Item granted{};
+            granted.instanceSoid = nextSoid++;
+            granted.definitionHash = memberDefinition.definitionHash;
+            granted.level = 0;
+            granted.quantity = 1;
+            // Every resolved item's serial must stay behind the character's own counter (checked
+            // by the character encoder, not by account::valid), so claim the next one here too.
+            granted.mutationSerial = static_cast<std::int32_t>(character.nextInventorySerial++);
+            granted.sockets.policy = authored_inventory::SocketPolicy::nativeDefaults;
+            character.inventory.values[character.inventory.count++] = 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

+ 5 - 0
Sunrise/src/state/runtime/state_account_transaction_helpers.h

@@ -150,6 +150,11 @@ find_resolved_position(const middleware::datagen::family4::loadout::ResolvedLoad
                                     std::uint16_t targetDefinitionIndex,
                                     std::uint32_t flags,
                                     PendingItemState& mutation) noexcept;
+[[nodiscard]] bool stage_subclass_selection(const AccountState& snapshot,
+                                            std::size_t characterIndex,
+                                            std::uint64_t subclassInstanceSoid,
+                                            std::uint8_t requestedEntry,
+                                            PendingSubclassSelection& mutation) noexcept;
 [[nodiscard]] bool next_item_instance_soid(const AccountState& account,
                                            std::uint64_t& output) noexcept;
 [[nodiscard]] bool next_profile_item_instance_soid(const AccountState& account,