34 Комити 6f63b2134e ... 211774f88b

Аутор SHA1 Порука Датум
  stan 211774f88b PR cleanup пре 3 недеља
  stan 0a4b6b9629 Merge pull request #60 from Confetti3/fix/msvc-physics-storage-initialization пре 3 недеља
  stan 61ca2668c0 Merge pull request #56 from throgsoft/disable-afk-timer пре 3 недеља
  stan a20bef7726 Merge pull request #41 from joshrad-dev/fix-inventory-replacement-order пре 3 недеља
  stan 7ff4f746d5 Merge pull request #47 from Haze-xyz/fix-software-cursor пре 3 недеља
  stan 1e93ae3295 Merge pull request #33 from TotalTaxAmount/readme-update пре 3 недеља
  stan 943228ff97 Merge pull request #61 from pinksickle/master пре 3 недеља
  stan d0fe8886fa Merge pull request #37 from KyleThmpsn/feature/account-preference-replication пре 3 недеља
  stan 2643911413 Merge pull request #27 from Nyxaraa/master пре 3 недеља
  stan 38faacfdd3 Merge pull request #55 from HiddenInvariant/master пре 3 недеља
  Thomas Shields 400568580b Merge remote-tracking branch 'upstream/master' пре 3 недеља
  Confetti3 15fcb46dfd Fix MSVC heap exhaustion in physics storage пре 3 недеља
  MeltyOnIce 363b6a3aaa fix compiling on linux пре 3 недеља
  Zach Humes 2f585014c5 Hook idle and session timers, poll fix пре 3 недеља
  Zach Humes 2ff7c2f1f3 reword the inactivity description пре 3 недеља
  Zach Humes a9583f0c95 move the inactivity interface into the player page пре 3 недеља
  Zach Humes 4339282137 add inactivity timeout override пре 3 недеља
  Thomas Shields 5dced037b1 feat(dismantle): pay by rarity, gear class and masterwork state пре 3 недеља
  Thomas Shields 028dd1cf0a feat(sockets): answer rolled socket actions with a result plug from the installed roll set пре 3 недеља
  Thomas Shields 6cc137ef95 fix(dismantle): pay materials for energy/heavy weapons and credit below currency caps пре 3 недеља
  Thomas Shields 6164a3b3c2 fix(equipment): keep displaced item in the clicked grid cell on equip swap пре 3 недеља
  Haze 441d3118c2 Keep the software mouse cursor visible below 1920 by 1080 пре 3 недеља
  joshrad-dev 8907e24682 Fix inventory replacements keeping their ordering пре 3 недеља
  Kyle Thompson 03b7b9973c replicate FOV, VSync, and keybinding source from settings пре 3 недеља
  totaltaxamount d9617c6a89 misc: update README.md with linux build instructions пре 3 недеља
  Millie 784659919d Store ability-node picks per subclass item instead of per character пре 3 недеља
  Millie 05111ebc47 Arm the delayed ability refresh on subclass equipment swaps too пре 3 недеља
  Millie 637bddcc17 Invalidate ability buckets when the equipped subclass changes пре 3 недеља
  Millie eadcd30783 Merge upstream master (bcrypt-free P-224 fix) into subclass work пре 3 недеља
  Millie e0640a83d9 Restore investment-worker and socket-list plumbing the subclass commits depend on пре 3 недеља
  Millie 0ddf119eca Resolved Phoenix Dive mapping to incorrect bucket пре 3 недеља
  Millie 085ba1829e Reset subclass ability picks to defaults every login пре 3 недеља
  Millie 8660090607 Add subclass ability node selection (opcode 801) пре 3 недеља
  Millie bfa37e704c Make subclasses equippable through the standard inventory swap пре 3 недеља
94 измењених фајлова са 4385 додато и 567 уклоњено
  1. 24 0
      README.md
  2. 11 0
      Sunrise/Sunrise.vcxproj
  3. 18 13
      Sunrise/resources/default_settings.json
  4. 6 4
      Sunrise/src/client/content/investment/investment_refresh.cpp
  5. 7 0
      Sunrise/src/client/content/investment/worker.h
  6. 10 0
      Sunrise/src/client/content/investment/worker/investment_refresh_worker.cpp
  7. 40 27
      Sunrise/src/client/content/items/packages/internal.h
  8. 108 11
      Sunrise/src/client/content/items/packages/package_ability_build.cpp
  9. 2 0
      Sunrise/src/client/content/items/packages/package_item_build.cpp
  10. 19 8
      Sunrise/src/client/content/items/packages/package_item_rows.cpp
  11. 137 35
      Sunrise/src/client/content/items/packages/package_subclass_build.cpp
  12. 7 0
      Sunrise/src/client/hooks/graphics/renderer/graphics_renderer_lifecycle.cpp
  13. 436 0
      Sunrise/src/client/hooks/inactivity/inactivity_override.cpp
  14. 53 0
      Sunrise/src/client/hooks/inactivity/inactivity_override.h
  15. 56 42
      Sunrise/src/client/hooks/membership_probe/membership_probe.cpp
  16. 273 0
      Sunrise/src/client/inactivity/inactivity_settings_store.cpp
  17. 99 0
      Sunrise/src/client/inactivity/inactivity_settings_store.h
  18. 3 0
      Sunrise/src/client/runtime/client_hook_activation.cpp
  19. 6 0
      Sunrise/src/client/runtime/client_runtime_lifecycle.cpp
  20. 154 0
      Sunrise/src/client/ui/player/player_panel.cpp
  21. 0 6
      Sunrise/src/core/settings/parser.h
  22. 1 1
      Sunrise/src/core/settings/settings.h
  23. 1 0
      Sunrise/src/core/settings/settings_runtime.cpp
  24. 5 2
      Sunrise/src/core/settings/settings_upgrade.cpp
  25. 78 30
      Sunrise/src/core/settings/state/account_rows_parser.cpp
  26. 16 0
      Sunrise/src/core/settings/state/account_settings_parser.cpp
  27. 20 0
      Sunrise/src/core/settings/state/display_parser.cpp
  28. 4 0
      Sunrise/src/core/ui/theme/sunrise_ui_theme.cpp
  29. 67 0
      Sunrise/src/middleware/content/packages/tables/item_definition_reader.cpp
  30. 12 8
      Sunrise/src/middleware/content/packages/tables/items.h
  31. 24 10
      Sunrise/src/middleware/datagen/character_record/appearance/character_appearance_abilities.cpp
  32. 1 0
      Sunrise/src/middleware/datagen/family4/account/preferences/layout.h
  33. 11 4
      Sunrise/src/middleware/datagen/family4/account/preferences/preferences_encoder.cpp
  34. 1 1
      Sunrise/src/middleware/datagen/family4/instance/instance_encoder.cpp
  35. 2 0
      Sunrise/src/middleware/datagen/family4/instance/instance_encoder.h
  36. 2 1
      Sunrise/src/middleware/datagen/family4/inventory/layout.h
  37. 3 1
      Sunrise/src/middleware/datagen/family4/loadout/loadout_item_resolver.cpp
  38. 42 37
      Sunrise/src/middleware/datagen/family4/loadout/subclass_socket_selection.cpp
  39. 16 9
      Sunrise/src/middleware/datagen/family4/loadout/subclass_socket_selection.h
  40. 26 0
      Sunrise/src/middleware/web_service/messages/opcode801.h
  41. 47 0
      Sunrise/src/middleware/web_service/messages/opcode801_codec.cpp
  42. 10 0
      Sunrise/src/server/bap/encrypted/bap_connection_publication.cpp
  43. 38 6
      Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp
  44. 37 0
      Sunrise/src/server/bap/encrypted/internal.h
  45. 2 3
      Sunrise/src/server/bap/encrypted/push/activity/activity_keepalive_push.cpp
  46. 74 4
      Sunrise/src/server/bap/encrypted/push/queuez/queuez_banner_push.cpp
  47. 33 0
      Sunrise/src/server/bap/encrypted/push/queuez/queuez_select_character.cpp
  48. 113 0
      Sunrise/src/server/bap/encrypted/push/snapshot/family4_selection_move.cpp
  49. 17 28
      Sunrise/src/server/bap/encrypted/push/snapshot/internal.h
  50. 13 6
      Sunrise/src/server/bap/encrypted/queuez/definition.h
  51. 76 0
      Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp
  52. 78 3
      Sunrise/src/server/bap/encrypted/queuez/queuez_outcome_staging.cpp
  53. 8 3
      Sunrise/src/server/bap/encrypted/queuez/queuez_state_validation.h
  54. 70 0
      Sunrise/src/server/bap/encrypted/queuez/staging/queuez_character_staging.cpp
  55. 24 0
      Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp
  56. 10 3
      Sunrise/src/server/bap/internal.h
  57. 67 78
      Sunrise/src/server/gameplay/peer/peer_transport.cpp
  58. 1 1
      Sunrise/src/server/gameplay/physics/host/physics_session.cpp
  59. 93 0
      Sunrise/src/server/web_service/web_service_actions.cpp
  60. 2 0
      Sunrise/src/server/web_service/web_service_actions.h
  61. 5 4
      Sunrise/src/server/web_service/web_service_runtime.cpp
  62. 7 0
      Sunrise/src/server/web_service/web_service_runtime.h
  63. 13 3
      Sunrise/src/state/account/account_state.cpp
  64. 68 15
      Sunrise/src/state/account/account_state.h
  65. 15 1
      Sunrise/src/state/account/inventory/inventory_state.h
  66. 12 3
      Sunrise/src/state/account/settings/settings_state.cpp
  67. 11 0
      Sunrise/src/state/account/settings/settings_state.h
  68. 8 2
      Sunrise/src/state/build_data/abilities/definition.h
  69. 10 7
      Sunrise/src/state/build_data/cache/records/cache_record_codec.cpp
  70. 7 4
      Sunrise/src/state/build_data/cache/records/format.h
  71. 33 0
      Sunrise/src/state/build_data/items/item_catalog.h
  72. 3 0
      Sunrise/src/state/build_data/items/socket_plugs/definition.h
  73. 18 9
      Sunrise/src/state/build_data/items/socket_plugs/socket_plug_build_data_runtime.cpp
  74. 31 0
      Sunrise/src/state/build_data/items/socket_plugs/socket_plug_catalog.cpp
  75. 9 0
      Sunrise/src/state/build_data/items/socket_plugs/socket_plug_catalog.h
  76. 66 6
      Sunrise/src/state/build_data/runtime.h
  77. 52 3
      Sunrise/src/state/build_data/runtime/build_data_catalog_runtime.cpp
  78. 38 0
      Sunrise/src/state/build_data/runtime/build_data_routing_catalogs.cpp
  79. 17 0
      Sunrise/src/state/build_data/runtime/domain_markers.cpp
  80. 14 0
      Sunrise/src/state/build_data/runtime/domain_markers.h
  81. 26 0
      Sunrise/src/state/build_data/socket_entry_buckets/definition.h
  82. 62 0
      Sunrise/src/state/build_data/socket_entry_buckets/socket_entry_bucket_catalog.cpp
  83. 38 0
      Sunrise/src/state/build_data/socket_entry_buckets/socket_entry_bucket_catalog.h
  84. 41 35
      Sunrise/src/state/gameplay/physics/physics_state_runtime.cpp
  85. 8 15
      Sunrise/src/state/runtime/equipment/configured_equipment_identity.cpp
  86. 54 23
      Sunrise/src/state/runtime/runtime.h
  87. 289 15
      Sunrise/src/state/runtime/state_account_dismantle_staging.cpp
  88. 8 10
      Sunrise/src/state/runtime/state_account_equipment_runtime.cpp
  89. 110 7
      Sunrise/src/state/runtime/state_account_item_action_runtime.cpp
  90. 246 1
      Sunrise/src/state/runtime/state_account_runtime.cpp
  91. 87 18
      Sunrise/src/state/runtime/state_account_socket_runtime.cpp
  92. 11 1
      Sunrise/src/state/runtime/state_account_transaction_helpers.h
  93. 271 0
      Sunrise/src/state/runtime/state_rolled_socket_plugs.cpp
  94. 83 0
      Sunrise/src/state/runtime/state_rolled_socket_plugs.h

+ 24 - 0
README.md

@@ -39,6 +39,30 @@ PRs are for pull requests only.
 
 Do not go and argue/chat there, you can do that on the discord.
 
+## Building
+
+### Linux
+
+Make sure you have `git`, `cmake`, `clang`, `ninja`, `llvm`, and `xwin` installed.
+
+1. Clone the repository
+```bash
+$ git clone https://github.com/stanuwu/Sunrise
+$ cd Sunrise
+```
+
+2. Download Windows headers:
+```bash
+$ xwin --accept-license splat --include-debug-libs --sdk-version 10.0.26100 --output .xwin-cache
+```
+
+3. Configure and build the project
+```bash
+$ cmake -B build -G Ninja -DCMAKE_TOOLCHAIN_FILE=$(pwd)/linux-to-win-toolchain.cmake -DCMAKE_BUILD_TYPE=Release
+$ cmake --build build --config Release
+```
+4. Copy the dll from `build/x64/Release/steam_api64.dll` into your Destiny install 
+
 ## Contributing
 
 Pull Requests are welcome. Please follow these rules:

+ 11 - 0
Sunrise/Sunrise.vcxproj

@@ -144,6 +144,7 @@
     <ClCompile Include="src\state\runtime\state_account_identity_runtime.cpp" />
     <ClCompile Include="src\state\runtime\state_account_profile_runtime.cpp" />
     <ClCompile Include="src\state\runtime\state_account_socket_runtime.cpp" />
+    <ClCompile Include="src\state\runtime\state_rolled_socket_plugs.cpp" />
     <ClCompile Include="src\state\runtime\state_account_item_action_runtime.cpp" />
     <ClCompile Include="src\core\ui\busy\ui_busy_overlay.cpp" />
     <ClCompile Include="src\core\ui\busy\ui_busy_state.cpp" />
@@ -217,6 +218,8 @@
     <ClCompile Include="src\client\ui\runtime\client_ui_module_runtime.cpp" />
     <ClCompile Include="src\client\ui\movement\movement_panel.cpp" />
     <ClCompile Include="src\client\movement\movement_settings_store.cpp" />
+    <ClCompile Include="src\client\inactivity\inactivity_settings_store.cpp" />
+    <ClCompile Include="src\client\hooks\inactivity\inactivity_override.cpp" />
     <ClCompile Include="src\client\ui\player\player_panel.cpp" />
     <ClCompile Include="src\client\player\player_position.cpp" />
     <ClCompile Include="src\client\player\player_settings_store.cpp" />
@@ -791,6 +794,8 @@
     <ClCompile Include="src\middleware\gameplay\descriptor\join_descriptor.cpp" />
     <ClCompile Include="src\server\gameplay\gameplay_advertisement.cpp" />
     <ClCompile Include="src\middleware\web_service\messages\opcode901\opcode901_codec.cpp" />
+    <ClCompile Include="src\middleware\web_service\messages\opcode801_codec.cpp" />
+    <ClCompile Include="src\state\build_data\socket_entry_buckets\socket_entry_bucket_catalog.cpp" />
     <ClCompile Include="src\middleware\gameplay\peer\reliable_assembly.cpp" />
     <ClCompile Include="src\middleware\gameplay\peer\join_messages.cpp" />
     <ClCompile Include="src\middleware\gameplay\group\session_messages.cpp" />
@@ -898,6 +903,8 @@
     <ClInclude Include="src\core\ui\layout\credits\sunrise_credits_badge.h" />
     <ClInclude Include="src\client\runtime\runtime.h" />
     <ClInclude Include="src\client\movement\movement_settings_store.h" />
+    <ClInclude Include="src\client\inactivity\inactivity_settings_store.h" />
+    <ClInclude Include="src\client\hooks\inactivity\inactivity_override.h" />
     <ClInclude Include="src\client\ui\player\player_panel.h" />
     <ClInclude Include="src\client\player\player_position.h" />
     <ClInclude Include="src\client\player\player_settings_store.h" />
@@ -996,6 +1003,7 @@
     <ClInclude Include="src\state\unlocks\unlocks_runtime.h" />
     <ClInclude Include="src\state\runtime\runtime.h" />
     <ClInclude Include="src\state\runtime\state_account_transaction_helpers.h" />
+    <ClInclude Include="src\state\runtime\state_rolled_socket_plugs.h" />
     <ClInclude Include="src\state\runtime\state.h" />
     <ClInclude Include="src\state\runtime\storage\internal.h" />
     <ClInclude Include="src\state\activity\definition.h" />
@@ -1383,6 +1391,9 @@
     <ClInclude Include="src\middleware\gameplay\descriptor\join_descriptor.h" />
     <ClInclude Include="src\server\gameplay\gameplay_advertisement.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode901\opcode901_codec.h" />
+    <ClInclude Include="src\middleware\web_service\messages\opcode801.h" />
+    <ClInclude Include="src\state\build_data\socket_entry_buckets\definition.h" />
+    <ClInclude Include="src\state\build_data\socket_entry_buckets\socket_entry_bucket_catalog.h" />
     <ClInclude Include="src\middleware\gameplay\peer\reliable_assembly.h" />
     <ClInclude Include="src\middleware\gameplay\peer\join_messages.h" />
     <ClInclude Include="src\middleware\gameplay\group\session_messages.h" />

+ 18 - 13
Sunrise/resources/default_settings.json

@@ -1,5 +1,5 @@
 {
-  "version": 6,
+  "version": 8,
   "core": {
     "logging": {
       "debugger_sink": true,
@@ -147,27 +147,29 @@
     "account": {
       "primary_soid": "0x9EAA300100100100",
       "dismantle_rewards": [
-        {
-          "definition_hash": "0x3CF2E8E2",
-          "quantity": 4
-        },
-        {
-          "definition_hash": "0x28D6AC07",
-          "quantity": 3
-        }
+        { "definition_hash": "0xBC53E66E", "quantity": 25, "rarity": "common" },
+        { "definition_hash": "0xBC53E66E", "quantity": 50, "rarity": "uncommon" },
+        { "definition_hash": "0xBC53E66E", "quantity": 100, "rarity": "rare" },
+        { "definition_hash": "0xBC53E66E", "quantity": 250, "rarity": "legendary" },
+        { "definition_hash": "0xBC53E66E", "quantity": 500, "rarity": "exotic" },
+        { "definition_hash": "0x3CF2E8E2", "quantity": 3, "rarity": "legendary" },
+        { "definition_hash": "0x3CF2E8E2", "quantity": 5, "rarity": "exotic" },
+        { "definition_hash": "0x28D6AC07", "quantity": 1, "rarity": "rare", "class": "weapon" },
+        { "definition_hash": "0x28D6AC07", "quantity": 3, "rarity": ["legendary", "exotic"], "class": "weapon" },
+        { "definition_hash": "0xE5B38AD2", "quantity": 3, "rarity": ["legendary", "exotic"], "masterworked": true }
       ],
       "profile_items": [
         {
           "definition_hash": "0xBC53E66E",
-          "quantity": 250000
+          "quantity": 100000
         },
         {
           "definition_hash": "0x3CF2E8E2",
-          "quantity": 999999
+          "quantity": 50000
         },
         {
           "definition_hash": "0xA7EE4765",
-          "quantity": 999999
+          "quantity": 50000
         },
         {
           "definition_hash": "0xB19439E5",
@@ -175,7 +177,7 @@
         },
         {
           "definition_hash": "0x28D6AC07",
-          "quantity": 9999
+          "quantity": 500
         },
         {
           "definition_hash": "0xE5B38AD2",
@@ -235,6 +237,8 @@
           "brightness": 3,
           "show_fps": false,
           "hdr_mode": 0,
+          "vertical_sync_interval": 0,
+          "field_of_view": 85,
           "calibration_primary": 10000.0,
           "calibration_alpha": 0.0
         },
@@ -267,6 +271,7 @@
           "clan_chat_join_mode": 1,
           "chat_auto_hide_mode": 1
         },
+        "key_binding_source": "computer",
         "key_bindings": {
           "fire": { "primary": "left mouse button", "secondary": null },
           "toggle_zoom": { "primary": null, "secondary": null },

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

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

@@ -16,4 +16,11 @@ 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 an already-published build-data domain needs this: the
+ * completion latch 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

+ 40 - 27
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{};
@@ -125,10 +128,8 @@ void request(std::uint16_t definitionIndex, DetailRequests& requested) noexcept;
 /**
  * Adds every socket lane's initial plug to the requested set.
  * A lane using native defaults falls back to this plug, so its detail must exist.
- * @param row
- * Item row already read from its definition blob.
- * @param itemDefinitionCount Installed
- * item-table bound.
+ * @param row Item row already read from its definition blob.
+ * @param itemDefinitionCount Installed item-table bound.
  * @param requested Requested-set storage.
  */
 void append_initial_plugs(const tables::items::Row& row,
@@ -137,13 +138,10 @@ void append_initial_plugs(const tables::items::Row& row,
 
 /**
  * Materializes requested native indices in ascending order.
- * @param requested Deduplicated
- * native-index set.
+ * @param requested Deduplicated native-index set.
  * @param output Fixed detail-index storage.
- * @param count Receives the
- * number of selected rows, or zero when output is too small.
- * @return True when every selected
- * row fits.
+ * @param count Receives the number of selected rows, or zero when output is too small.
+ * @return True when every selected row fits.
  */
 [[nodiscard]] bool materialize_requests(const DetailRequests& requested,
                                         std::span<std::uint16_t> output,
@@ -209,15 +207,34 @@ read_investment_constants(const reader::Source& source,
  * @param count Receives the number of rows built.
  * @return True when the table reads; a subclass that fails is skipped, not fatal.
  */
-[[nodiscard]] bool
-build_character_abilities(const reader::Source& source,
-                          reader::Scratch& scratch,
-                          std::span<const std::byte> root,
-                          std::vector<std::byte>& table,
-                          std::vector<std::byte>& definition,
-                          std::vector<std::byte>& blob,
-                          std::span<state::build_data::abilities::Definition> output,
-                          std::size_t& count) noexcept;
+[[nodiscard]] bool build_character_abilities(
+    const reader::Source& source,
+    reader::Scratch& scratch,
+    std::span<const std::byte> root,
+    std::vector<std::byte>& table,
+    std::vector<std::byte>& definition,
+    std::vector<std::byte>& blob,
+    std::span<state::build_data::abilities::Definition> output,
+    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.
@@ -330,15 +347,11 @@ void report(std::size_t published, const char* reason) noexcept;
 
 /**
  * Reads and publishes the root's dense collectible-to-item mapping table.
- * @param source
- * Package source.
+ * @param source Package source.
  * @param storage Pass storage, including scratch bytes and bounded row storage.
-
- * * @param root Investment root bytes.
- * @param itemDefinitionCount Number of rows in the
- * installed item index table.
- * @return True when every tag, class, bound, and item link validates
- * and publishes.
+ * @param root Investment root bytes.
+ * @param itemDefinitionCount Number of rows in the installed item index table.
+ * @return True when every tag, class, bound, and item link validates and publishes.
  */
 [[nodiscard]] bool build_collectibles(const reader::Source& source,
                                       Storage& storage,

+ 108 - 11
Sunrise/src/client/content/items/packages/package_ability_build.cpp

@@ -108,21 +108,44 @@ selector_destination(const Walk& walk, std::uint8_t entryIndex, std::uint8_t& bu
 }
 
 /**
- * Chooses the active plug source of every entry group.
- * An entry group holds alternatives, and the summary selection names which one the character has.
+ * Chooses the active plug source of every entry group, and any bundled siblings a pick carries.
+ * A group holds alternatives and the summary selection names one. A pick can also bundle same-group
+ * entries that publish together; those carry their own source, so they are marked forced-active.
  * @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 is mutually exclusive alternatives: exactly one contributes its
+    // hashes. An Attunement's group packs several 4-node options into one group id, so a
+    // population past the widest single bundle means its members activate in same-sized runs.
+    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 +153,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 +195,33 @@ 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 miss.
+ * A bundle member can replace an ability outright under its own bucket, and nothing else ever
+ * claims that kind. A sibling with no bucket, or one already claimed, is skipped, not failed.
+ * @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 +271,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 +290,41 @@ 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.
+ * Table position does not say which slot an entry fills and a bundled group can mix slots; only
+ * the selector chain does, so every entry is walked once, independent of any character.
+ * @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();
 }

+ 19 - 8
Sunrise/src/client/content/items/packages/package_item_rows.cpp

@@ -56,9 +56,8 @@ bool build_item_rows(const reader::Source& source,
     const std::span<const std::byte> container{storage.child};
     reason = "rows";
     // The detail closure is gathered during this one walk. Collections can name any installed
-    // item row, including profile-owned shaders and modifications, so retain every readable row
-    // rather than only startup-authored/equippable definitions. The fixed request bitset still
-    // bounds this to the installed 16-bit item-table domain.
+    // item row, including profile-owned shaders and modifications, so retain every readable row.
+    // The fixed request bitset bounds this to the installed 16-bit item-table domain.
     storage.detailRequests.reset();
     storage.specialPlugCategories.fill(0);
     std::size_t detailCount = 0;
@@ -84,7 +83,11 @@ bool build_item_rows(const reader::Source& source,
                                                  item.definitionIndex,
                                                  item.bucketId,
                                                  item.insertionMaterialRequirementSetIndex,
-                                                 item.enabledMaterialRequirementSetIndex};
+                                                 item.enabledMaterialRequirementSetIndex,
+                                                 item.tier,
+                                                 item.plugCategoryHash,
+                                                 item.rollSetIndex,
+                                                 item.linkedPlugIndex};
         if (needSocketPlugs) {
             storage.specialPlugCategories[item.definitionIndex] =
                 special_plug_category(item.plugCategoryHash);
@@ -166,10 +169,14 @@ 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 never joins the on-disk cache, so a warm boot 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},
@@ -177,10 +184,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);
         }

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

@@ -58,27 +58,42 @@ 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
 
 /** Builds one ability bucket row per distinct subclass and ability selection in use. */
-bool build_character_abilities(const reader::Source& source,
-                               reader::Scratch& scratch,
-                               std::span<const std::byte> root,
-                               std::vector<std::byte>& table,
-                               std::vector<std::byte>& definition,
-                               std::vector<std::byte>& blob,
-                               std::span<state::build_data::abilities::Definition> output,
-                               std::size_t& count) noexcept {
+bool build_character_abilities(
+    const reader::Source& source,
+    reader::Scratch& scratch,
+    std::span<const std::byte> root,
+    std::vector<std::byte>& table,
+    std::vector<std::byte>& definition,
+    std::vector<std::byte>& blob,
+    std::span<state::build_data::abilities::Definition> output,
+    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,34 +110,54 @@ 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. Equipping one resets its picks to
+    // these, so every owned subclass publishes a row at this selection. The row must exist
+    // synchronously: the equip response is built inline with the commit.
+    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)
+                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;
+                "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)) {
@@ -130,10 +165,77 @@ bool build_character_abilities(const reader::Source& source,
                 selection.movementEntry | (selection.grenadeEntry << 8U)
                 | (selection.superEntry << 16U) | (selection.meleeEntry << 24U);
             report_ability_failure(
-                "bucket_build", character, row.socketEntryListIndex, packedSelection);
-            continue;
+                "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(
+                subclassReason, character, subclass.has_value() ? subclass->definitionHash : 0, 0);
+            continue;
+        }
+
+        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 lands on an already-built row instead of racing the next refresh
+        // slice. When the group cannot be resolved, the equipped one still publishes.
+        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, so every member is checked for a
+            // non-default selection to publish on top of its default row. A fresh boot needs the
+            // actual selection for subclasses 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());

+ 7 - 0
Sunrise/src/client/hooks/graphics/renderer/graphics_renderer_lifecycle.cpp

@@ -7,6 +7,7 @@
 #include "../../../../core/ui/layout/layout.h"
 #include "../../../../core/ui/runtime/ui_visibility_runtime.h"
 #include "../../cursor/runtime.h"
+#include "../../inactivity/inactivity_override.h"
 #include "../../polled_input/runtime.h"
 #include "../input/input.h"
 #include "graphics_renderer_report.h"
@@ -315,11 +316,17 @@ void present(IDXGISwapChain* swapChain) noexcept {
     if (g_resources.swapChain == nullptr) {
         (void)initialize_locked(swapChain);
     }
+    bool framed = false;
     if (g_resources.swapChain == swapChain && fully_active_locked()) {
         render_frame_locked();
+        framed = true;
     }
     ReleaseSRWLockExclusive(&g_rendererLock);
 
+    if (framed) {
+        // The timeout hold enters game code, so it runs only after the renderer lock is gone.
+        inactivity::poll();
+    }
     // The cursor policy calls Win32, so it runs only after the renderer lock is gone.
     const bool visible = core::ui::runtime::snapshot().visible;
     cursor::apply_visibility(visible);

+ 436 - 0
Sunrise/src/client/hooks/inactivity/inactivity_override.cpp

@@ -0,0 +1,436 @@
+/**
+ * Inactivity timeout override.
+ *
+ * The Client keeps 14 activity timeouts in one live object and ends a session idle for longer.
+ * The pointer to that object is obfuscated, so this module resolves the Client's own getter by
+ * signature and calls it. Lanes are re-applied on an interval because an activity change
+ * re-authors the whole block.
+ */
+
+#include "inactivity_override.h"
+
+#include <Windows.h>
+
+#include <algorithm>
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <string_view>
+
+#include "../../../core/logging/log.h"
+#include "../../inactivity/inactivity_settings_store.h"
+#include "../../patterns/image_scan.h"
+
+namespace sunrise::client::hooks::inactivity {
+namespace {
+
+namespace settings = client::inactivity;
+
+using patterns::scan_main_image_unique;
+using patterns::signature;
+using patterns::signature_length;
+
+/**
+ * The activity config getter. Every obfuscated pointer getter shares this prologue, so the load
+ * of its own global is the only part that tells this one apart: wildcarding that displacement
+ * takes the pattern from one match to 242. It is image-relative, so ASLR does not move it.
+ */
+constexpr std::string_view kConfigGetterText =
+    "40 53 48 83 EC 20 48 8B 1D 2B 10 1A 02 48 85 DB 0F 84 ? ? ? ? 48 89 5C 24 30 "
+    "E8 ? ? ? ? 33 C3";
+/** Compiled pattern bytes of the config getter signature. */
+constexpr auto kConfigGetter = signature<signature_length(kConfigGetterText)>(kConfigGetterText);
+
+/** The controlled player's index, which the idle clock is keyed by. */
+constexpr std::string_view kControlledIndexText = "48 8B 05 ? ? ? ? 8B 80 60 04 00 00 C3";
+/** Compiled pattern bytes of the controlled index signature. */
+constexpr auto kControlledIndex =
+    signature<signature_length(kControlledIndexText)>(kControlledIndexText);
+
+/** The idle clock, in the lanes' own unit. Runs to the 0x1B0 stride, which makes it unique. */
+constexpr std::string_view kIdleClockText =
+    "40 53 48 83 EC 20 48 63 D9 48 8D 0D ? ? ? ? 8B D3 E8 ? ? ? ? 84 C0 74 ? "
+    "48 8B 05 ? ? ? ? 48 69 CB B0 01 00 00";
+/** Compiled pattern bytes of the idle clock signature. */
+constexpr auto kIdleClock = signature<signature_length(kIdleClockText)>(kIdleClockText);
+
+/** The session clock, which is what the grace is measured against. */
+constexpr std::string_view kSessionClockText =
+    "48 83 EC 28 E8 ? ? ? ? 48 85 C0 74 ? 80 3D ? ? ? ? 00 48 89 5C 24 20";
+/** Compiled pattern bytes of the session clock signature. */
+constexpr auto kSessionClock = signature<signature_length(kSessionClockText)>(kSessionClockText);
+
+/**
+ * The session grace. Read for the interface and never written, because shortening it can only
+ * bring a kick forward. It gates the matchmade lanes only, not orbit.
+ */
+constexpr std::size_t kGraceOffset = 0x84;
+
+/** Where the lanes start in the object the getter returns. */
+constexpr std::size_t kTimeoutBlockOffset = 0xAC;
+/** Milliseconds between re-applications, so an activity change cannot outlast the hold. */
+constexpr std::uint64_t kHoldIntervalMs = 2000;
+
+/** Fourteen consecutive milliseconds, in block order. */
+using Lanes = std::array<std::uint32_t, settings::kActivityCount>;
+/** Bytes of the block. */
+constexpr std::size_t kBlockBytes = sizeof(Lanes);
+
+/** Returns the activity config object. The pointer in its global is obfuscated, so we call it. */
+using ConfigGetter = std::byte*(__fastcall*)();
+/** Answers -1 when nothing is being controlled, which is not an error and not an index. */
+using IndexGetter = std::int32_t(__fastcall*)();
+using IdleGetter = std::uint64_t(__fastcall*)(std::int32_t);
+using SessionGetter = std::uint64_t(__fastcall*)();
+
+SRWLOCK g_lock{SRWLOCK_INIT};
+ConfigGetter g_getter{};
+std::uint64_t g_nextHoldTick{};
+/** The block this module last wrote. Anything else in the object is the Client's own. */
+Lanes g_applied{};
+bool g_appliedValid{};
+/** The Client's own lanes for the activity in play. */
+Lanes g_captured{};
+bool g_capturedValid{};
+/** Set while a hold is in place, so releasing it writes the captured lanes exactly once. */
+bool g_holding{};
+/** The intent the last poll acted on, so a changed one does not wait for the hold interval. */
+Lanes g_intentLanes{};
+bool g_intentHolding{};
+bool g_intentValid{};
+Lanes g_live{};
+bool g_liveValid{};
+/** Never written, so unlike the lanes there is nothing to capture and put back. */
+std::uint32_t g_liveGrace{};
+bool g_liveGraceValid{};
+
+/** Null is a normal state: a build that does not match still holds its lanes without them. */
+IndexGetter g_indexGetter{};
+IdleGetter g_idleGetter{};
+SessionGetter g_sessionGetter{};
+
+/** Last sample, taken only while a caller is asking for one. */
+Timers g_timers{};
+bool g_timersWanted{};
+
+/**
+ * Calls the getter without faulting. The body is obfuscated game code, and it runs before the
+ * Client has published its global on an early frame.
+ * @return The activity config object, or null.
+ */
+[[nodiscard]] std::byte* config_object() noexcept {
+    if (g_getter == nullptr) {
+        return nullptr;
+    }
+    __try {
+        return g_getter();
+    } __except (EXCEPTION_EXECUTE_HANDLER) {
+        return nullptr;
+    }
+}
+
+/**
+ * Reads the block out of the object.
+ * @param object Config object.
+ * @param values Receives the lanes.
+ * @return True when Windows copied all of them.
+ */
+[[nodiscard]] bool read_block(const std::byte* object, Lanes& values) noexcept {
+    SIZE_T read = 0;
+    return ReadProcessMemory(
+               GetCurrentProcess(), object + kTimeoutBlockOffset, values.data(), kBlockBytes, &read)
+               != FALSE
+           && read == kBlockBytes;
+}
+
+/**
+ * @param object Config object.
+ * @param value Receives the milliseconds.
+ * @return True when Windows copied it.
+ */
+[[nodiscard]] bool read_grace(const std::byte* object, std::uint32_t& value) noexcept {
+    SIZE_T read = 0;
+    return ReadProcessMemory(
+               GetCurrentProcess(), object + kGraceOffset, &value, sizeof value, &read)
+               != FALSE
+           && read == sizeof value;
+}
+
+/**
+ * Writes one run of milliseconds into the object.
+ * @param object Config object.
+ * @param values Lanes in block order.
+ * @return True when Windows copied all of them.
+ */
+[[nodiscard]] bool write_block(std::byte* object, const Lanes& values) noexcept {
+    SIZE_T written = 0;
+    return WriteProcessMemory(GetCurrentProcess(),
+                              object + kTimeoutBlockOffset,
+                              values.data(),
+                              kBlockBytes,
+                              &written)
+               != FALSE
+           && written == kBlockBytes;
+}
+
+/**
+ * Takes the Client's own lanes, which are any lanes this module did not write.
+ * @param current Block just read out of the object.
+ */
+void capture_locked(const Lanes& current) noexcept {
+    // An all-zero block is an object the Client has published but not filled in. One zero lane is
+    // the Client's own way of switching that lane off, so it is captured like any other value.
+    const bool authored = std::any_of(
+        current.begin(), current.end(), [](std::uint32_t value) noexcept { return value != 0; });
+    if (!authored || (g_appliedValid && current == g_applied)) {
+        return;
+    }
+    g_captured = current;
+    g_capturedValid = true;
+}
+
+/** @return True while either switch asks for a hold. */
+[[nodiscard]] bool holds(const settings::Settings& configured) noexcept {
+    return configured.enabled || configured.custom;
+}
+
+/**
+ * @param configured Current configuration.
+ * @return The lanes a hold puts in place.
+ */
+[[nodiscard]] Lanes held_lanes(const settings::Settings& configured) noexcept {
+    // A hand-edited file can carry both switches, so the blanket hold wins as the safer one.
+    const bool set = configured.custom && !configured.enabled;
+    Lanes values = set ? configured.timeouts : settings::kDefaultTimeouts;
+    // Orbit is held at its longest whatever the grid or the file carries, because a timeout that
+    // fires there ends a session this Client cannot re-establish.
+    values[settings::kOrbitLane] = settings::kMaximumTimeoutMs;
+    return values;
+}
+
+/**
+ * Writes the captured lanes back and ends the hold.
+ * @return True when lanes were put back, false when there was no hold to end.
+ */
+[[nodiscard]] bool release_locked(std::byte* object) noexcept {
+    if (!g_holding || !g_capturedValid || !write_block(object, g_captured)) {
+        return false;
+    }
+    g_holding = false;
+    g_appliedValid = false;
+    return true;
+}
+
+/**
+ * Caller holds the lock. Failure is not propagated: a build whose signatures have moved should
+ * still install and still hold its lanes.
+ */
+void resolve_clocks_locked() noexcept {
+    std::byte* const index =
+        scan_main_image_unique(kControlledIndex, "inactivity_controlled_index");
+    std::byte* const idle = scan_main_image_unique(kIdleClock, "inactivity_idle_clock");
+    std::byte* const session = scan_main_image_unique(kSessionClock, "inactivity_session_clock");
+    if (index == nullptr || idle == nullptr || session == nullptr) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         "ev=inactivity stage=clocks result=fail");
+        return;
+    }
+    g_indexGetter = reinterpret_cast<IndexGetter>(index);
+    g_idleGetter = reinterpret_cast<IdleGetter>(idle);
+    g_sessionGetter = reinterpret_cast<SessionGetter>(session);
+    core::log::write(
+        core::log::Channel::client, core::log::Level::info, "ev=inactivity stage=clocks result=ok");
+}
+
+/**
+ * Refreshes the cached clocks when a caller asked for them. The getters are obfuscated Client
+ * code, so they are called with no lock held and guarded against an early frame.
+ */
+void sample_timers() noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    const bool wanted = g_timersWanted;
+    g_timersWanted = false;
+    const IndexGetter index = g_indexGetter;
+    const IdleGetter idle = g_idleGetter;
+    const SessionGetter session = g_sessionGetter;
+    ReleaseSRWLockExclusive(&g_lock);
+    if (!wanted || index == nullptr || idle == nullptr || session == nullptr) {
+        return;
+    }
+    Timers sampled{};
+    __try {
+        const std::int32_t controlled = index();
+        if (controlled >= 0) {
+            sampled.idleMs = idle(controlled);
+            sampled.idleValid = true;
+        }
+    } __except (EXCEPTION_EXECUTE_HANDLER) {
+        sampled.idleValid = false;
+    }
+    __try {
+        sampled.sessionMs = session();
+        sampled.sessionValid = true;
+    } __except (EXCEPTION_EXECUTE_HANDLER) {
+        sampled.sessionValid = false;
+    }
+    AcquireSRWLockExclusive(&g_lock);
+    g_timers = sampled;
+    ReleaseSRWLockExclusive(&g_lock);
+}
+
+} // namespace
+
+/** Resolves the activity config getter. */
+bool install() noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    if (g_getter != nullptr) {
+        ReleaseSRWLockExclusive(&g_lock);
+        return true;
+    }
+    std::byte* const match = scan_main_image_unique(kConfigGetter, "inactivity_config_getter");
+    if (match == nullptr) {
+        ReleaseSRWLockExclusive(&g_lock);
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         "ev=inactivity stage=install result=fail reason=target");
+        return false;
+    }
+    g_getter = reinterpret_cast<ConfigGetter>(match);
+    // Scanned here rather than on first display, because the scan walks the whole image.
+    resolve_clocks_locked();
+    ReleaseSRWLockExclusive(&g_lock);
+    core::log::write(core::log::Channel::client,
+                     core::log::Level::info,
+                     "ev=inactivity stage=install result=ok");
+    return true;
+}
+
+/** Puts the Client's own lanes back and drops the resolved getter. */
+void uninstall() noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    if (std::byte* const object = config_object(); object != nullptr) {
+        // Nothing to report on the way out; the lanes are put back or there was no hold.
+        (void)release_locked(object);
+    }
+    g_getter = nullptr;
+    g_indexGetter = nullptr;
+    g_idleGetter = nullptr;
+    g_sessionGetter = nullptr;
+    g_nextHoldTick = 0;
+    g_applied = Lanes{};
+    g_appliedValid = false;
+    g_captured = Lanes{};
+    g_capturedValid = false;
+    g_holding = false;
+    g_intentLanes = Lanes{};
+    g_intentHolding = false;
+    g_intentValid = false;
+    g_live = Lanes{};
+    g_liveValid = false;
+    g_liveGrace = 0;
+    g_liveGraceValid = false;
+    g_timers = Timers{};
+    g_timersWanted = false;
+    ReleaseSRWLockExclusive(&g_lock);
+}
+
+/** Holds the configured milliseconds, or puts back the ones the Client authored. */
+void poll() noexcept {
+    sample_timers();
+    const settings::Settings configured = settings::get();
+    const bool holding = holds(configured);
+    const Lanes desired = held_lanes(configured);
+    AcquireSRWLockExclusive(&g_lock);
+    const std::uint64_t now = GetTickCount64();
+    // A changed intent is the operator waiting on this call, so it does not wait for the interval.
+    const bool changed =
+        !g_intentValid || g_intentHolding != holding || (holding && g_intentLanes != desired);
+    if (g_getter == nullptr || (now < g_nextHoldTick && !changed)) {
+        ReleaseSRWLockExclusive(&g_lock);
+        return;
+    }
+    g_nextHoldTick = now + kHoldIntervalMs;
+    // Recorded before the object is reached, so a poll that finds no activity cannot leave the
+    // intent looking changed and skip the interval on every later frame.
+    g_intentLanes = desired;
+    g_intentHolding = holding;
+    g_intentValid = true;
+    std::byte* const object = config_object();
+    if (object == nullptr) {
+        ReleaseSRWLockExclusive(&g_lock);
+        return;
+    }
+    if (Lanes current{}; read_block(object, current)) {
+        g_live = current;
+        g_liveValid = true;
+        capture_locked(current);
+    }
+    if (std::uint32_t grace = 0; read_grace(object, grace)) {
+        g_liveGrace = grace;
+        g_liveGraceValid = true;
+    }
+    if (!holding) {
+        const bool released = release_locked(object);
+        ReleaseSRWLockExclusive(&g_lock);
+        // Nothing to put back is not a failure: it is the ordinary state with the feature off.
+        if (changed) {
+            core::log::write(core::log::Channel::client,
+                             core::log::Level::info,
+                             released ? "ev=inactivity stage=release result=ok"
+                                      : "ev=inactivity stage=release result=noop");
+        }
+        return;
+    }
+    // Held rather than written once, because an activity change re-authors these lanes.
+    const bool wrote = write_block(object, desired);
+    if (wrote) {
+        g_applied = desired;
+        g_appliedValid = true;
+        g_holding = true;
+    }
+    // The Client picks its lane at runtime, so the shortest is the only figure that says when a
+    // kick can first happen without naming a lane that may not be in force.
+    const std::uint32_t shortest = *std::min_element(desired.begin(), desired.end());
+    ReleaseSRWLockExclusive(&g_lock);
+    if (changed) {
+        // Only on a change, so a steady hold does not fill the log every interval.
+        std::array<char, 128> line{};
+        const int length =
+            std::snprintf(line.data(),
+                          line.size(),
+                          "ev=inactivity stage=hold mode=%s shortest_ms=%u result=%s",
+                          configured.enabled ? "disable" : "set",
+                          shortest,
+                          wrote ? "ok" : "fail");
+        if (length > 0) {
+            core::log::write(core::log::Channel::client,
+                             core::log::Level::info,
+                             {line.data(), static_cast<std::size_t>(length)});
+        }
+    }
+}
+
+/** Reports what the override reached. */
+Status status() noexcept {
+    Status output{};
+    AcquireSRWLockShared(&g_lock);
+    output.live = g_live;
+    output.liveValid = g_liveValid;
+    output.liveGraceMs = g_liveGrace;
+    output.liveGraceValid = g_liveGraceValid;
+    ReleaseSRWLockShared(&g_lock);
+    return output;
+}
+
+/** Reads the last sample and arms the next one. */
+Timers timers() noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    g_timersWanted = true;
+    const Timers output = g_timers;
+    ReleaseSRWLockExclusive(&g_lock);
+    return output;
+}
+
+} // namespace sunrise::client::hooks::inactivity

+ 53 - 0
Sunrise/src/client/hooks/inactivity/inactivity_override.h

@@ -0,0 +1,53 @@
+#pragma once
+
+#include <array>
+#include <cstdint>
+
+#include "../../inactivity/inactivity_settings_store.h"
+
+namespace sunrise::client::hooks::inactivity {
+
+/** What the Client is holding in its lanes now, read back rather than assumed. */
+struct Status {
+    std::array<std::uint32_t, client::inactivity::kActivityCount> live{};
+    bool liveValid{};
+    /** Session grace. Zero stops the Client gating on it at all. */
+    std::uint32_t liveGraceMs{};
+    bool liveGraceValid{};
+};
+
+/** The Client's own two clocks. A lane fires once idle passes its milliseconds. */
+struct Timers {
+    /** Input resets this, so it does not track the session and the two can diverge widely. */
+    std::uint64_t idleMs{};
+    std::uint64_t sessionMs{};
+    bool idleValid{};
+    bool sessionValid{};
+};
+
+/**
+ * Reads the clocks poll() last sampled and asks it to sample again next frame.
+ * A sample enters Client code, so it is taken from poll() and never from a draw.
+ * @return The clocks, with a validity flag for each.
+ */
+[[nodiscard]] Timers timers() noexcept;
+
+/**
+ * Resolves the activity config getter, which the lanes are reached through.
+ * @return True when it was found.
+ */
+[[nodiscard]] bool install() noexcept;
+
+/** Puts the Client's own lanes back and drops the resolved getter. */
+void uninstall() noexcept;
+
+/**
+ * Holds the configured milliseconds, or puts back the ones the Client authored.
+ * Enters Client code, so call it once a frame from a tick that holds no lock.
+ */
+void poll() noexcept;
+
+/** @return A consistent copy of what the override reached. */
+[[nodiscard]] Status status() noexcept;
+
+} // namespace sunrise::client::hooks::inactivity

+ 56 - 42
Sunrise/src/client/hooks/membership_probe/membership_probe.cpp

@@ -38,15 +38,14 @@ using patterns::signature_length;
 constexpr std::string_view kReceiveText = "40 55 53 41 56 41 57 48 8D AC 24 ? ? ? ? B8 C8 96 05 00";
 constexpr auto kReceive = signature<signature_length(kReceiveText)>(kReceiveText);
 
-/** Status word the handler writes. `RE/25 "The +304 status word, mapped"` owns its bits. */
+/** Status word the handler writes, one bit per membership step. */
 constexpr std::size_t kStatusWordOffset = 304;
 /** Membership header. Its leading qword is the member key the client matches itself by. */
 constexpr std::size_t kMembershipHeaderOffset = 27696;
 /**
  * The two slot axes and the printable label the constructor builds from them.
- * Axis 1 is PRIVATE or PUBLIC, axis 2 is CURRENT or TARGET, and the slot map indexes them as
- * `axis1 + 2 * axis2`. A TARGET slot is index 2 or 3, which the public-first current-slot pick
- * never reads -- so the label says outright which readers can ever see this client.
+ * Axis 1 is PRIVATE or PUBLIC, axis 2 is CURRENT or TARGET, indexed as `axis1 + 2 * axis2`. A
+ * TARGET slot is index 2 or 3, which the public-first current-slot pick never reads.
  */
 constexpr std::size_t kSlotAxisOneOffset = 24;
 constexpr std::size_t kSlotAxisTwoOffset = 28;
@@ -87,16 +86,31 @@ constexpr std::uint64_t kSampleIntervalMs = 2'000;
 /** Clients the probe tracks at once. One private and one public target is the live shape. */
 constexpr std::size_t kTrackedCapacity = 4;
 
-using Receive = char(__fastcall*)(std::int64_t, std::int64_t, int);
+using Receive = char(__fastcall*)(const std::byte*, std::int64_t, int);
 
 /** One ActivityClient seen carrying a membership body, sampled until its window closes. */
 struct Tracked {
-    std::int64_t client{};
+    const std::byte* client{};
     std::uint64_t expiresAt{};
     std::uint64_t nextSample{};
     bool occupied{};
 };
 
+/**
+ * Reads one field out of the client.
+ * @param client ActivityClient.
+ * @param offset Byte offset of the field.
+ * @return The field's value.
+ */
+template <typename T> [[nodiscard]] T field(const std::byte* client, std::size_t offset) noexcept {
+    return *reinterpret_cast<const T*>(client + offset);
+}
+
+/** @param client ActivityClient. @return Its address, for a log line. */
+[[nodiscard]] unsigned long long address_of(const std::byte* client) noexcept {
+    return static_cast<unsigned long long>(reinterpret_cast<std::uintptr_t>(client));
+}
+
 hooking::detour::Handle g_handle{};
 std::atomic_bool g_installed{false};
 /** The detour runs on the client's network thread and the sampler on the callback pump. */
@@ -109,19 +123,18 @@ std::array<Tracked, kTrackedCapacity> g_tracked{};
  * @param before Status word before the call.
  * @param after Status word after it.
  */
-void report(std::int64_t client, std::uint16_t before, std::uint16_t after) noexcept {
+void report(const std::byte* client, std::uint16_t before, std::uint16_t after) noexcept {
     std::array<char, core::log::kLineCapacity> line{};
-    const std::uint64_t memberKey =
-        *reinterpret_cast<const std::uint64_t*>(client + kMembershipHeaderOffset);
-    const auto axisOne = *reinterpret_cast<const std::uint32_t*>(client + kSlotAxisOneOffset);
-    const auto axisTwo = *reinterpret_cast<const std::uint32_t*>(client + kSlotAxisTwoOffset);
+    const auto memberKey = field<std::uint64_t>(client, kMembershipHeaderOffset);
+    const auto axisOne = field<std::uint32_t>(client, kSlotAxisOneOffset);
+    const auto axisTwo = field<std::uint32_t>(client, kSlotAxisTwoOffset);
     const auto* label = reinterpret_cast<const char*>(client + kSlotLabelOffset);
     const int written = std::snprintf(line.data(),
                                       line.size(),
                                       "ev=probe stage=msg12 result=received client=0x%llX "
                                       "slot=%u label=%.*s member=0x%016llX "
                                       "status=0x%04X->0x%04X flag=%u",
-                                      static_cast<unsigned long long>(client),
+                                      address_of(client),
                                       axisOne + 2U * axisTwo,
                                       static_cast<int>(kSlotLabelCapacity),
                                       label,
@@ -142,19 +155,17 @@ void report(std::int64_t client, std::uint16_t before, std::uint16_t after) noex
  * of the public-first pick, so between them they name which reader can ever see this client.
  * @param client ActivityClient.
  */
-void report_bind_inputs(std::int64_t client) noexcept {
+void report_bind_inputs(const std::byte* client) noexcept {
     std::array<char, core::log::kLineCapacity> line{};
-    const auto established =
-        *reinterpret_cast<const std::uint64_t*>(client + kEstablishedSessionOffset);
-    const auto slotRecord = *reinterpret_cast<const std::uint64_t*>(client + kSlotRecordOffset);
-    const auto rosterContainer =
-        *reinterpret_cast<const std::uint64_t*>(client + kRosterContainerOffset);
-    const auto receipt = *reinterpret_cast<const std::uint8_t*>(client + kBindReceiptOffset);
+    const auto established = field<std::uint64_t>(client, kEstablishedSessionOffset);
+    const auto slotRecord = field<std::uint64_t>(client, kSlotRecordOffset);
+    const auto rosterContainer = field<std::uint64_t>(client, kRosterContainerOffset);
+    const auto receipt = field<std::uint8_t>(client, kBindReceiptOffset);
     const int written = std::snprintf(line.data(),
                                       line.size(),
                                       "ev=probe stage=bind client=0x%llX established=0x%016llX "
                                       "slotrec=0x%llX roster=0x%llX receipt=%u",
-                                      static_cast<unsigned long long>(client),
+                                      address_of(client),
                                       static_cast<unsigned long long>(established),
                                       static_cast<unsigned long long>(slotRecord),
                                       static_cast<unsigned long long>(rosterContainer),
@@ -167,7 +178,7 @@ void report_bind_inputs(std::int64_t client) noexcept {
 }
 
 /** @param client ActivityClient. @return Entity-slot bits it holds but has not applied. */
-[[nodiscard]] std::size_t pending_slots(std::int64_t client) noexcept {
+[[nodiscard]] std::size_t pending_slots(const std::byte* client) noexcept {
     const auto* mask = reinterpret_cast<const std::uint8_t*>(client + kPendingMaskOffset);
     std::size_t count = 0;
     for (std::size_t index = 0; index < kPendingMaskSize; ++index) {
@@ -177,7 +188,7 @@ void report_bind_inputs(std::int64_t client) noexcept {
 }
 
 /** Opens or refreshes the sampling window for one client. */
-void track(std::int64_t client, std::uint64_t now) noexcept {
+void track(const std::byte* client, std::uint64_t now) noexcept {
     AcquireSRWLockExclusive(&g_lock);
     Tracked* free = nullptr;
     for (Tracked& entry : g_tracked) {
@@ -197,14 +208,17 @@ void track(std::int64_t client, std::uint64_t now) noexcept {
 }
 
 /** Reads the status word, defers to the original, then reads it again. */
-char __fastcall receive(std::int64_t client, std::int64_t body, int size) noexcept {
-    const auto* original = reinterpret_cast<Receive>(g_handle.original);
-    if (original == nullptr || client == 0) {
-        return original != nullptr ? original(client, body, size) : 0;
+char __fastcall receive(const std::byte* client, std::int64_t body, int size) noexcept {
+    auto* original = reinterpret_cast<Receive>(g_handle.original);
+    if (original == nullptr) {
+        return 0;
     }
-    const auto before = *reinterpret_cast<const std::uint16_t*>(client + kStatusWordOffset);
+    if (client == nullptr) {
+        return original(client, body, size);
+    }
+    const auto before = field<std::uint16_t>(client, kStatusWordOffset);
     const char result = original(client, body, size);
-    const auto after = *reinterpret_cast<const std::uint16_t*>(client + kStatusWordOffset);
+    const auto after = field<std::uint16_t>(client, kStatusWordOffset);
     report(client, before, after);
     report_bind_inputs(client);
     track(client, GetTickCount64());
@@ -215,20 +229,20 @@ char __fastcall receive(std::int64_t client, std::int64_t body, int size) noexce
  * Reports what one client did with its grant after the message.
  * @param client ActivityClient.
  */
-void sample(std::int64_t client) noexcept {
+void sample(const std::byte* client) noexcept {
     std::array<char, core::log::kLineCapacity> line{};
-    const auto status = *reinterpret_cast<const std::uint16_t*>(client + kStatusWordOffset);
-    const auto dirty = *reinterpret_cast<const std::uint8_t*>(client + kGrantDirtyOffset);
-    const int written = std::snprintf(
-        line.data(),
-        line.size(),
-        "ev=probe stage=grant client=0x%llX status=0x%04X "
-        "pending=%zu dirty=%u receipt=%u",
-        static_cast<unsigned long long>(client),
-        static_cast<unsigned>(status),
-        pending_slots(client),
-        static_cast<unsigned>(dirty),
-        static_cast<unsigned>(*reinterpret_cast<const std::uint8_t*>(client + kBindReceiptOffset)));
+    const auto status = field<std::uint16_t>(client, kStatusWordOffset);
+    const auto dirty = field<std::uint8_t>(client, kGrantDirtyOffset);
+    const int written =
+        std::snprintf(line.data(),
+                      line.size(),
+                      "ev=probe stage=grant client=0x%llX status=0x%04X "
+                      "pending=%zu dirty=%u receipt=%u",
+                      address_of(client),
+                      static_cast<unsigned>(status),
+                      pending_slots(client),
+                      static_cast<unsigned>(dirty),
+                      static_cast<unsigned>(field<std::uint8_t>(client, kBindReceiptOffset)));
     if (written > 0) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::info,
@@ -278,7 +292,7 @@ void service(std::uint64_t now) noexcept {
     }
     // Copied under the lock, sampled outside it: a read walks 1024 bytes and must not hold a lock
     // the detour needs on the client's own thread.
-    std::array<std::int64_t, kTrackedCapacity> due{};
+    std::array<const std::byte*, kTrackedCapacity> due{};
     std::size_t count = 0;
     AcquireSRWLockExclusive(&g_lock);
     for (Tracked& entry : g_tracked) {

+ 273 - 0
Sunrise/src/client/inactivity/inactivity_settings_store.cpp

@@ -0,0 +1,273 @@
+/**
+ * The inactivity configuration store. It is separate from Core settings because the interface
+ * changes these values while the game runs and saves each change at once, which Core settings,
+ * read once at boot, do not do.
+ */
+
+#include "inactivity_settings_store.h"
+
+#include <Windows.h>
+
+#include <algorithm>
+#include <array>
+#include <cstddef>
+#include <cstdio>
+#include <cstdlib>
+#include <string_view>
+
+#include "../../core/filesystem/path.h"
+#include "../../core/logging/log.h"
+
+namespace sunrise::client::inactivity {
+namespace {
+
+/** The module-owned configuration file, beside the generated settings and logs. */
+constexpr std::wstring_view kFileSuffix = L"\\inactivity.json";
+/** Fourteen scalars and their keys fit well inside this. */
+constexpr std::size_t kFileCapacity = 2048;
+/** Longest scalar accepted from the file. Anything longer is malformed rather than large. */
+constexpr std::size_t kScalarCapacity = 32;
+
+SRWLOCK g_lock{SRWLOCK_INIT};
+Settings g_settings{};
+core::path::Buffer g_path{};
+bool g_pathResolved{};
+
+/** @param settings Candidate configuration. @return True when every lane is in range. */
+[[nodiscard]] bool valid(const Settings& settings) noexcept {
+    return std::all_of(
+        settings.timeouts.begin(), settings.timeouts.end(), [](std::uint32_t value) noexcept {
+            return value >= kMinimumTimeoutMs && value <= kMaximumTimeoutMs;
+        });
+}
+
+/** @param reason Key naming the step that failed. */
+void report_fail(const char* reason) noexcept {
+    std::array<char, 96> line{};
+    const int written = std::snprintf(
+        line.data(), line.size(), "ev=inactivity stage=store result=fail reason=%s", reason);
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Finds one key's raw scalar text.
+ * @param text Whole document.
+ * @param key Quoted key to locate.
+ * @param output Receives the text between the colon and the next separator.
+ * @return True when the key exists and carries a non-empty value.
+ */
+[[nodiscard]] bool
+scalar_for(std::string_view text, std::string_view key, std::string_view& output) noexcept {
+    const std::size_t at = text.find(key);
+    if (at == std::string_view::npos) {
+        return false;
+    }
+    const std::size_t colon = text.find(':', at + key.size());
+    if (colon == std::string_view::npos) {
+        return false;
+    }
+    std::size_t begin = colon + 1;
+    while (begin < text.size() && (text[begin] == ' ' || text[begin] == '\t')) {
+        ++begin;
+    }
+    std::size_t end = begin;
+    while (end < text.size() && text[end] != ',' && text[end] != '}' && text[end] != '\n'
+           && text[end] != '\r') {
+        ++end;
+    }
+    output = text.substr(begin, end - begin);
+    return !output.empty();
+}
+
+/**
+ * Copies one scalar into null-terminated storage the C conversions require.
+ * @param value Scalar text taken from the document.
+ * @param output Receives the terminated copy.
+ * @return True when the scalar fits.
+ */
+[[nodiscard]] bool terminated(std::string_view value,
+                              std::array<char, kScalarCapacity>& output) noexcept {
+    if (value.size() >= output.size()) {
+        return false;
+    }
+    for (std::size_t index = 0; index < value.size(); ++index) {
+        output[index] = value[index];
+    }
+    output[value.size()] = '\0';
+    return true;
+}
+
+/**
+ * Layers one document over the current defaults. A missing or malformed key keeps its default,
+ * so a hand-edited file cannot stop the module loading.
+ * @param text Whole document.
+ * @param output Receives the parsed configuration.
+ */
+void parse(std::string_view text, Settings& output) noexcept {
+    std::string_view scalar;
+    if (scalar_for(text, "\"enabled\"", scalar)) {
+        output.enabled = scalar.starts_with("true");
+    }
+    if (scalar_for(text, "\"custom\"", scalar)) {
+        output.custom = scalar.starts_with("true");
+    }
+    std::array<char, kScalarCapacity> buffer{};
+    for (std::size_t index = 0; index < kActivityCount; ++index) {
+        std::array<char, 64> quoted{};
+        const int written = std::snprintf(quoted.data(),
+                                          quoted.size(),
+                                          "\"%.*s\"",
+                                          static_cast<int>(kActivities[index].key.size()),
+                                          kActivities[index].key.data());
+        if (written <= 0
+            || !scalar_for(
+                text, std::string_view(quoted.data(), static_cast<std::size_t>(written)), scalar)
+            || !terminated(scalar, buffer)) {
+            continue;
+        }
+        // Clamped, not refused. One out-of-range lane must not drop every other saved value.
+        output.timeouts[index] =
+            std::clamp(static_cast<std::uint32_t>(std::strtoul(buffer.data(), nullptr, 0)),
+                       kMinimumTimeoutMs,
+                       kMaximumTimeoutMs);
+    }
+    // A hand-edited file can carry both exclusive switches. Removing every timeout wins.
+    if (output.enabled) {
+        output.custom = false;
+    }
+}
+
+/**
+ * Writes the whole document. It is small enough that a complete rewrite is the simplest correct
+ * save, which the shared settings file is not.
+ * @param settings Configuration to store.
+ * @return True when every byte reached the file.
+ */
+[[nodiscard]] bool store(const Settings& settings) noexcept {
+    if (!g_pathResolved) {
+        return false;
+    }
+    std::array<char, kFileCapacity> document{};
+    int size = std::snprintf(document.data(),
+                             document.size(),
+                             "{\n  \"enabled\": %s,\n  \"custom\": %s",
+                             settings.enabled ? "true" : "false",
+                             settings.custom ? "true" : "false");
+    if (size <= 0) {
+        return false;
+    }
+    for (std::size_t index = 0; index < kActivityCount; ++index) {
+        const int written = std::snprintf(document.data() + size,
+                                          document.size() - static_cast<std::size_t>(size),
+                                          ",\n  \"%.*s\": %u",
+                                          static_cast<int>(kActivities[index].key.size()),
+                                          kActivities[index].key.data(),
+                                          static_cast<unsigned>(settings.timeouts[index]));
+        if (written <= 0 || static_cast<std::size_t>(size + written) >= document.size()) {
+            return false;
+        }
+        size += written;
+    }
+    const int tail = std::snprintf(
+        document.data() + size, document.size() - static_cast<std::size_t>(size), "\n}\n");
+    if (tail <= 0 || static_cast<std::size_t>(size + tail) >= document.size()) {
+        return false;
+    }
+    size += tail;
+    const HANDLE file = CreateFileW(g_path.chars.data(),
+                                    GENERIC_WRITE,
+                                    0,
+                                    nullptr,
+                                    CREATE_ALWAYS,
+                                    FILE_ATTRIBUTE_NORMAL,
+                                    nullptr);
+    if (file == INVALID_HANDLE_VALUE) {
+        return false;
+    }
+    DWORD written = 0;
+    bool complete =
+        WriteFile(file, document.data(), static_cast<DWORD>(size), &written, nullptr) != FALSE
+        && written == static_cast<DWORD>(size);
+    complete = CloseHandle(file) != FALSE && complete;
+    return complete;
+}
+
+/** Reads the configuration file into the active settings when one exists. */
+void load() noexcept {
+    const HANDLE file = CreateFileW(g_path.chars.data(),
+                                    GENERIC_READ,
+                                    FILE_SHARE_READ,
+                                    nullptr,
+                                    OPEN_EXISTING,
+                                    FILE_ATTRIBUTE_NORMAL,
+                                    nullptr);
+    if (file == INVALID_HANDLE_VALUE) {
+        return;
+    }
+    std::array<char, kFileCapacity> buffer{};
+    DWORD read = 0;
+    const bool readOk =
+        ReadFile(file, buffer.data(), static_cast<DWORD>(buffer.size() - 1), &read, nullptr)
+        != FALSE;
+    (void)CloseHandle(file);
+    if (!readOk || read == 0) {
+        return;
+    }
+    Settings parsed{};
+    parse(std::string_view(buffer.data(), read), parsed);
+    g_settings = parsed;
+}
+
+} // namespace
+
+/** Resolves the configuration file and loads it when one exists. */
+void initialize(void* module) noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    g_settings = Settings{};
+    g_pathResolved =
+        core::path::artifact_directory(module, g_path) && core::path::append(g_path, kFileSuffix);
+    if (g_pathResolved) {
+        load();
+    } else {
+        report_fail("path");
+    }
+    ReleaseSRWLockExclusive(&g_lock);
+}
+
+/** Drops the runtime configuration and the resolved file path. */
+void shutdown() noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    g_settings = Settings{};
+    g_path = core::path::Buffer{};
+    g_pathResolved = false;
+    ReleaseSRWLockExclusive(&g_lock);
+}
+
+/** @return One lock-consistent copy of the current configuration. */
+Settings get() noexcept {
+    AcquireSRWLockShared(&g_lock);
+    const Settings snapshot = g_settings;
+    ReleaseSRWLockShared(&g_lock);
+    return snapshot;
+}
+
+/** Publishes one configuration and writes it straight to disk. */
+bool publish(const Settings& settings) noexcept {
+    if (!valid(settings)) {
+        return false;
+    }
+    AcquireSRWLockExclusive(&g_lock);
+    g_settings = settings;
+    const bool stored = store(settings);
+    ReleaseSRWLockExclusive(&g_lock);
+    if (!stored) {
+        report_fail("write");
+    }
+    return true;
+}
+
+} // namespace sunrise::client::inactivity

+ 99 - 0
Sunrise/src/client/inactivity/inactivity_settings_store.h

@@ -0,0 +1,99 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <string_view>
+
+namespace sunrise::client::inactivity {
+
+/** Activity lanes the Client keeps a separate inactivity timeout for. */
+inline constexpr std::size_t kActivityCount = 14;
+
+/**
+ * Shortest timeout offered, in milliseconds. A file carrying less is clamped up, not refused.
+ * The matchmade lanes cannot fire until the session outlives the grace, about a minute here, so
+ * a shorter value would only look like a hold that is not working.
+ */
+inline constexpr std::uint32_t kMinimumTimeoutMs = 60000;
+/** Longest timeout offered, in milliseconds. A day outlasts any session. */
+inline constexpr std::uint32_t kMaximumTimeoutMs = 86400000;
+
+/**
+ * The orbit lane. It is the one lane the Client reads without the grace gate, so a short value
+ * fires at once, drops the session and leaves a marionberry error the process cannot recover
+ * from. Held at its longest whenever the hold is on, and no field or file value reaches it.
+ */
+inline constexpr std::size_t kOrbitLane = 13;
+
+/** One activity lane's names and its key in the stored document. */
+struct ActivityInfo {
+    /** Full name, shown where there is room for it. */
+    std::string_view name;
+    /** Column heading, short enough for fourteen lanes across one grid. */
+    std::string_view column;
+    /** Key this lane is stored under. */
+    std::string_view key;
+};
+
+/** Every lane, in block order. */
+inline constexpr std::array<ActivityInfo, kActivityCount> kActivities{{
+    {"PvE", "PvE", "pve"},
+    {"PvE (guided)", "PvE gd", "pve_guided"},
+    {"PvE (matchmade, multiple fireteams)", "PvE mm+", "pve_mm_multiple"},
+    {"PvE (matchmade, single fireteam)", "PvE mm", "pve_mm_single"},
+    {"PvE (special)", "PvE sp", "pve_special"},
+    {"PvP", "PvP", "pvp"},
+    {"PvP (guided)", "PvP gd", "pvp_guided"},
+    {"PvP (matchmade, multiple fireteams)", "PvP mm+", "pvp_mm_multiple"},
+    {"PvP (matchmade, single fireteam)", "PvP mm", "pvp_mm_single"},
+    {"PvP (special)", "PvP sp", "pvp_special"},
+    {"PvP (private)", "Private", "pvp_private"},
+    {"PvP (Trials)", "Trials", "pvp_trials"},
+    {"Social", "Social", "social"},
+    {"Orbit", "Orbit", "orbit"},
+}};
+
+/** @return Every lane at its longest. */
+[[nodiscard]] consteval std::array<std::uint32_t, kActivityCount> longest_timeouts() noexcept {
+    std::array<std::uint32_t, kActivityCount> values{};
+    values.fill(kMaximumTimeoutMs);
+    return values;
+}
+
+/** Compiled lanes a fresh install holds. */
+inline constexpr std::array<std::uint32_t, kActivityCount> kDefaultTimeouts = longest_timeouts();
+
+/**
+ * Runtime inactivity configuration. This module owns it; Core settings do not carry it.
+ * The two switches are exclusive: one removes every timeout, the other replaces each with a
+ * chosen one. Neither leaves the Client's own timeouts in place.
+ */
+struct Settings {
+    /** Milliseconds per lane, in block order. Held only while custom is set. */
+    std::array<std::uint32_t, kActivityCount> timeouts{kDefaultTimeouts};
+    bool enabled{false};
+    /** Never set alongside enabled; the two ask for opposite things. */
+    bool custom{false};
+};
+
+/**
+ * Resolves the configuration file and loads it when one exists.
+ * @param module Loaded DLL used to resolve the owned artifact directory.
+ */
+void initialize(void* module) noexcept;
+
+/** Drops the runtime configuration and the resolved file path. */
+void shutdown() noexcept;
+
+/** @return One lock-consistent copy of the current configuration. */
+[[nodiscard]] Settings get() noexcept;
+
+/**
+ * Publishes one configuration and writes it straight to disk.
+ * @param settings Candidate configuration, refused when a lane is out of range.
+ * @return True when the value was published. A failed write is logged, not returned.
+ */
+bool publish(const Settings& settings) noexcept;
+
+} // namespace sunrise::client::inactivity

+ 3 - 0
Sunrise/src/client/runtime/client_hook_activation.cpp

@@ -18,6 +18,7 @@
 #include "../hooks/config_getter/config_getter_lifecycle.h"
 #include "../hooks/cursor/runtime.h"
 #include "../hooks/graphics/graphics_hook_lifecycle.h"
+#include "../hooks/inactivity/inactivity_override.h"
 #include "../hooks/infinite_ammo/infinite_ammo.h"
 #include "../hooks/membership_probe/membership_probe.h"
 #include "../hooks/network/runtime.h"
@@ -174,6 +175,8 @@ void clear_game_targets() noexcept {
     (void)hooks::noclip::install();
     // Attaches whether or not the feature is on, so the interface can enable it without a restart.
     (void)hooks::infinite_ammo::install();
+    // Resolves the activity config getter here; the hold itself runs on the frame tick.
+    (void)hooks::inactivity::install();
     (void)hooks::queuez::install();
     // The bitmap reference guard puts the none sentinel in place of a reference outside tag
     // space. Without it the widget's stored-reference reader faults.

+ 6 - 0
Sunrise/src/client/runtime/client_runtime_lifecycle.cpp

@@ -6,6 +6,7 @@
 #include "../hooks/config_getter/config_getter_lifecycle.h"
 #include "../hooks/cursor/runtime.h"
 #include "../hooks/graphics/graphics_hook_lifecycle.h"
+#include "../hooks/inactivity/inactivity_override.h"
 #include "../hooks/infinite_ammo/infinite_ammo.h"
 #include "../hooks/network/runtime.h"
 #include "../hooks/noclip/runtime.h"
@@ -14,6 +15,7 @@
 #include "../hooks/queuez/queuez_hook_lifecycle.h"
 #include "../hooks/retail_log/retail_log_lifecycle.h"
 #include "../hooks/teleport/runtime.h"
+#include "../inactivity/inactivity_settings_store.h"
 #include "../movement/movement_settings_store.h"
 #include "../player/player_settings_store.h"
 #include "../targets/game.h"
@@ -29,6 +31,7 @@ bool initialize(void* module) noexcept {
     // Loaded before the pages register, so each page draws saved values on its first frame.
     movement::initialize(module);
     player::initialize(module);
+    inactivity::initialize(module);
     return ui::runtime::initialize();
 }
 
@@ -62,6 +65,7 @@ bool shutdown() noexcept {
     hooks::bitmap::uninstall();
     hooks::bootflow::uninstall();
     hooks::infinite_ammo::uninstall();
+    hooks::inactivity::uninstall();
     hooks::noclip::uninstall();
     hooks::teleport::uninstall();
     hooks::queuez::uninstall();
@@ -99,6 +103,8 @@ bool shutdown() noexcept {
     runtime::g_graphicsStage = runtime::StageState::pending;
     runtime::g_platformStage = runtime::StageState::pending;
     ui::runtime::shutdown();
+    // The reverse of the order the stores initialize in.
+    inactivity::shutdown();
     player::shutdown();
     movement::shutdown();
     core::log::write(core::log::Channel::client, core::log::Level::info, "ev=shutdown result=ok");

+ 154 - 0
Sunrise/src/client/ui/player/player_panel.cpp

@@ -2,12 +2,162 @@
 
 #include "player_panel.h"
 
+#include <algorithm>
+#include <cstddef>
+#include <cstdint>
 #include <imgui.h>
 
 #include "../../../core/ui/components/toggle/ui_toggle_component.h"
+#include "../../hooks/inactivity/inactivity_override.h"
+#include "../../inactivity/inactivity_settings_store.h"
 #include "../../player/player_settings_store.h"
 
 namespace sunrise::client::ui::player {
+namespace {
+
+namespace inactivity = client::inactivity;
+namespace toggle = core::ui::components::toggle;
+
+/** Grid width. Seven columns lays the fourteen lanes out in two rows. */
+constexpr int kLaneColumns = 7;
+
+/**
+ * Draws one lane's field and, under it, the milliseconds the Client is holding in that lane now.
+ * @param index Lane in block order.
+ * @param configured Configuration updated on an edit.
+ * @param status What the override reached, for the live figure.
+ * @return True when this lane changed.
+ */
+[[nodiscard]] bool draw_lane(std::size_t index,
+                             inactivity::Settings& configured,
+                             const hooks::inactivity::Status& status) noexcept {
+    const bool orbit = index == inactivity::kOrbitLane;
+    bool changed = false;
+    ImGui::PushID(static_cast<int>(index));
+    ImGui::BeginDisabled(orbit);
+    ImGui::TextUnformatted(inactivity::kActivities[index].column.data());
+    if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
+        ImGui::SetTooltip("%s", inactivity::kActivities[index].name.data());
+    }
+    // The live figure is the one the Client is timing by, so it reads as active and the set value
+    // is dimmed. A lane held at its longest is timing nothing, so neither is active.
+    const std::uint32_t live = status.liveValid ? status.live[index] : 0;
+    const bool liveActive = status.liveValid && live != inactivity::kMaximumTimeoutMs;
+
+    ImGui::SetNextItemWidth(-FLT_MIN);
+    std::uint32_t milliseconds = configured.timeouts[index];
+    if (liveActive) {
+        ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyle().Colors[ImGuiCol_TextDisabled]);
+    }
+    ImGui::InputScalar("##lane",
+                       ImGuiDataType_U32,
+                       &milliseconds,
+                       nullptr,
+                       nullptr,
+                       "%u",
+                       ImGuiInputTextFlags_CharsDecimal);
+    if (liveActive) {
+        ImGui::PopStyleColor();
+    }
+    if (ImGui::IsItemDeactivatedAfterEdit()) {
+        configured.timeouts[index] =
+            std::clamp(milliseconds, inactivity::kMinimumTimeoutMs, inactivity::kMaximumTimeoutMs);
+        changed = true;
+    }
+    ImGui::EndDisabled();
+    // Outside the disabled block: the live value reads the same whether or not the field can be
+    // edited.
+    if (!status.liveValid) {
+        ImGui::TextDisabled("-");
+    } else if (liveActive) {
+        ImGui::Text("%u", live);
+    } else {
+        ImGui::TextDisabled("%u", live);
+    }
+    ImGui::PopID();
+    return changed;
+}
+
+/** @param status What the override reached, for the live grace. */
+void draw_inactivity_clocks(const hooks::inactivity::Status& status) noexcept {
+    // Read from the draw rather than the hold, so the Client is only asked while this section is
+    // on screen to answer to.
+    const hooks::inactivity::Timers timers = hooks::inactivity::timers();
+    if (timers.idleValid) {
+        ImGui::Text("Idle %.1f s", static_cast<double>(timers.idleMs) / 1000.0);
+    } else {
+        ImGui::TextDisabled("Idle -");
+    }
+    ImGui::SameLine();
+    if (timers.sessionValid) {
+        ImGui::Text("Session %.1f s", static_cast<double>(timers.sessionMs) / 1000.0);
+    } else {
+        ImGui::TextDisabled("Session -");
+    }
+    if (!status.liveGraceValid) {
+        return;
+    }
+    const bool passed =
+        status.liveGraceMs == 0 || (timers.sessionValid && timers.sessionMs > status.liveGraceMs);
+    ImGui::SameLine();
+    // Reported, never written.
+    const double grace = static_cast<double>(status.liveGraceMs) / 1000.0;
+    if (passed) {
+        ImGui::TextDisabled("Grace %.1f s (passed)", grace);
+    } else {
+        ImGui::Text("Grace %.1f s (no kick until then)", grace);
+    }
+}
+
+/** Draws the inactivity section: the main switch, the lane grid and the Client's clocks. */
+void draw_inactivity() noexcept {
+    inactivity::Settings configured = inactivity::get();
+    // Taken once, so every line below and the grid all describe the same poll.
+    const hooks::inactivity::Status status = hooks::inactivity::status();
+
+    ImGui::TextUnformatted("Inactivity");
+    ImGui::Separator();
+    ImGui::TextWrapped("Disable AFK timeouts from activities kicking to orbit and the title "
+                       "screen.");
+    ImGui::Spacing();
+
+    // The two switches are exclusive, so turning this one on drops the set timeouts.
+    bool changed = toggle::control("Enabled##inactivity", configured.enabled);
+    if (changed && configured.enabled) {
+        configured.custom = false;
+    }
+
+    if (ImGui::CollapsingHeader("Advanced##inactivity")) {
+        // A per-lane timeout has nothing to act on once every lane is already removed.
+        ImGui::BeginDisabled(configured.enabled);
+        if (toggle::control("Use set timeouts##inactivity_custom", configured.custom)) {
+            if (configured.custom) {
+                configured.enabled = false;
+            }
+            changed = true;
+        }
+        ImGui::EndDisabled();
+        ImGui::TextDisabled("In milliseconds");
+        ImGui::Spacing();
+        ImGui::BeginDisabled(configured.enabled || !configured.custom);
+        if (ImGui::BeginTable("lanes", kLaneColumns, ImGuiTableFlags_SizingStretchSame)) {
+            for (std::size_t index = 0; index < inactivity::kActivityCount; ++index) {
+                ImGui::TableNextColumn();
+                changed = draw_lane(index, configured, status) || changed;
+            }
+            ImGui::EndTable();
+        }
+        ImGui::EndDisabled();
+        ImGui::Spacing();
+        draw_inactivity_clocks(status);
+    }
+
+    if (changed) {
+        (void)inactivity::publish(configured);
+    }
+}
+
+} // namespace
 
 /** Draws the player module inside the active Core UI frame. */
 void draw() noexcept {
@@ -23,6 +173,10 @@ void draw() noexcept {
     if (changed) {
         (void)client::player::publish(settings);
     }
+
+    ImGui::Spacing();
+    ImGui::Spacing();
+    draw_inactivity();
 }
 
 } // namespace sunrise::client::ui::player

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

+ 1 - 1
Sunrise/src/core/settings/settings.h

@@ -18,7 +18,7 @@ namespace sunrise::core::settings {
  * Raise it when a key is renamed, removed, changes meaning, or must take a new default.
  * Adding a key needs no raise, because a missing key already takes its default.
  */
-inline constexpr std::uint32_t kSettingsVersion = 6;
+inline constexpr std::uint32_t kSettingsVersion = 8;
 
 /** Parsed read-only process settings. */
 struct Settings {

+ 1 - 0
Sunrise/src/core/settings/settings_runtime.cpp

@@ -181,6 +181,7 @@ void report_upgrade(bool stored) noexcept {
  * @return The same text with any BOM removed.
  */
 [[nodiscard]] std::string_view without_byte_order_mark(std::string_view document) noexcept {
+    // UTF-8 byte order mark. An editor writes it and the parser must not see it.
     constexpr std::string_view kMark = "\xEF\xBB\xBF";
     return document.starts_with(kMark) ? document.substr(kMark.size()) : document;
 }

+ 5 - 2
Sunrise/src/core/settings/settings_upgrade.cpp

@@ -30,12 +30,15 @@ struct ReplacedMember {
  * Members replaced with the bundled default, each with the version that changed it.
  * A member is listed because its value form changed, or because its default changed.
  */
-constexpr std::array<ReplacedMember, 5> kReplacedMembers{{
+constexpr std::array<ReplacedMember, 6> kReplacedMembers{{
     {"\"key_bindings\"", 3},
     {"\"region_private\"", 5},
     {"\"topology\"", 5},
     {"\"characters\"", 5},
-    {"\"profile_items\"", 6},
+    {"\"profile_items\"", 7},
+    // Version 8 turned the flat payout list into rows filtered by rarity, gear class and
+    // masterwork state.
+    {"\"dismantle_rewards\"", 8},
 }};
 /** One splice per replaced member, plus the version member itself. */
 constexpr std::size_t kSpliceCapacity = kReplacedMembers.size() + 1;

+ 78 - 30
Sunrise/src/core/settings/state/account_rows_parser.cpp

@@ -1,5 +1,6 @@
 #include <limits>
 
+#include "../../../state/build_data/items/item_catalog.h"
 #include "../parser.h"
 
 namespace sunrise::core::settings::parser {
@@ -12,7 +13,37 @@ constexpr std::uint64_t kMaximumDestinationHash = (std::numeric_limits<std::uint
 
 } // namespace
 
-/** Parses the definition hashes and quantities credited by ordinary gear dismantles. */
+namespace {
+
+/** Sets the tier bit one rarity name stands for. */
+[[nodiscard]] bool dismantle_tier_bit(std::string_view name, std::uint8_t& mask) noexcept {
+    using Tier = state::build_data::items::Tier;
+    Tier tier = Tier::none;
+    if (name == "common") {
+        tier = Tier::common;
+    } else if (name == "uncommon") {
+        tier = Tier::uncommon;
+    } else if (name == "rare") {
+        tier = Tier::rare;
+    } else if (name == "legendary") {
+        tier = Tier::legendary;
+    } else if (name == "exotic") {
+        tier = Tier::exotic;
+    } else {
+        return false;
+    }
+    const std::uint8_t bit = static_cast<std::uint8_t>(1U << static_cast<unsigned>(tier));
+    if ((mask & bit) != 0) {
+        return false;
+    }
+    mask |= bit;
+    return true;
+}
+
+} // namespace
+
+/** Parses the materials credited by ordinary gear dismantles, with optional rarity/class filters.
+ */
 bool Parser::dismantle_rewards(state::AccountState& output) noexcept {
     output.dismantleRewards = {};
     output.dismantleRewardCount = 0;
@@ -49,6 +80,45 @@ bool Parser::dismantle_rewards(state::AccountState& output) noexcept {
                 }
                 reward.quantity = static_cast<std::int32_t>(value);
                 hasQuantity = true;
+            } else if (key == "rarity") {
+                // One name or an array of names; each sets its tier bit.
+                if (reward.tierMask != 0) {
+                    return false;
+                }
+                const bool list = consume('[');
+                for (;;) {
+                    std::string_view name;
+                    if (!string(name) || !dismantle_tier_bit(name, reward.tierMask)) {
+                        return false;
+                    }
+                    if (!list || consume(']')) {
+                        break;
+                    }
+                    if (!consume(',')) {
+                        return false;
+                    }
+                }
+            } else if (key == "class") {
+                std::string_view name;
+                if (reward.classMask != 0 || !string(name)) {
+                    return false;
+                }
+                if (name == "weapon") {
+                    reward.classMask = static_cast<std::uint8_t>(state::DismantleGearClass::weapon);
+                } else if (name == "armor") {
+                    reward.classMask = static_cast<std::uint8_t>(state::DismantleGearClass::armor);
+                } else {
+                    return false;
+                }
+            } else if (key == "masterworked") {
+                bool masterworked = false;
+                if (reward.masterwork != state::DismantleMasterworkFilter::any
+                    || !boolean(masterworked)) {
+                    return false;
+                }
+                reward.masterwork = masterworked
+                                        ? state::DismantleMasterworkFilter::masterworked
+                                        : state::DismantleMasterworkFilter::notMasterworked;
             } else if (!skip_value(0)) {
                 return false;
             }
@@ -60,7 +130,7 @@ bool Parser::dismantle_rewards(state::AccountState& output) noexcept {
             }
         }
         for (std::size_t index = 0; index < output.dismantleRewardCount; ++index) {
-            if (output.dismantleRewards[index].definitionHash == reward.definitionHash) {
+            if (state::same_dismantle_policy_key(output.dismantleRewards[index], reward)) {
                 return false;
             }
         }
@@ -163,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 = {};
@@ -244,24 +304,12 @@ 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 shows
+            // the ability-entry struct defaults below, so restoring a persisted pick would leave
+            // that paint disagreeing with what is equipped. Still written out, never read back.
+            if (!skip_value(0)) {
                 return false;
             }
         } else if (key == "equipment") {

+ 16 - 0
Sunrise/src/core/settings/state/account_settings_parser.cpp

@@ -25,6 +25,9 @@ bool Parser::account_settings(state::account::settings::AccountSettings& output)
         return false;
     }
     std::bitset<static_cast<std::size_t>(Group::count)> supplied;
+    // Tracked apart from the bitset because this key is optional: an enumerated group is required
+    // by the supplied.all() below, and an older settings file does not carry it.
+    bool hasKeyBindingSource = false;
     const auto mark = [&supplied](Group group) noexcept {
         const std::size_t index = static_cast<std::size_t>(group);
         if (supplied.test(index)) {
@@ -61,6 +64,19 @@ bool Parser::account_settings(state::account::settings::AccountSettings& output)
             if (!mark(Group::social) || !social_settings(output.social)) {
                 return false;
             }
+        } else if (key == "key_binding_source") {
+            std::string_view source;
+            if (hasKeyBindingSource || !string(source)) {
+                return false;
+            }
+            if (source == "account") {
+                output.keyBindingSource = state::account::settings::KeyBindingSource::account;
+            } else if (source == "computer") {
+                output.keyBindingSource = state::account::settings::KeyBindingSource::computer;
+            } else {
+                return false;
+            }
+            hasKeyBindingSource = true;
         } else if (key == "key_bindings") {
             if (!mark(Group::keyBindings) || !key_bindings(output.keyBindings)) {
                 return false;

+ 20 - 0
Sunrise/src/core/settings/state/display_parser.cpp

@@ -1,4 +1,5 @@
 #include <bitset>
+#include <limits>
 
 #include "../parser.h"
 
@@ -20,6 +21,10 @@ bool Parser::display_settings(state::account::settings::Display& output) noexcep
         return false;
     }
     std::bitset<static_cast<std::size_t>(Field::count)> supplied;
+    // Tracked apart from the bitset because these two keys are optional: an enumerated field is
+    // required by the supplied.all() below, and an older settings file does not carry them.
+    bool hasVerticalSyncInterval = false;
+    bool hasFieldOfView = false;
     const auto mark = [&supplied](Field field) noexcept {
         const std::size_t index = static_cast<std::size_t>(field);
         if (supplied.test(index)) {
@@ -48,6 +53,21 @@ bool Parser::display_settings(state::account::settings::Display& output) noexcep
             if (!mark(Field::hdrMode) || !signed_byte(output.hdrMode)) {
                 return false;
             }
+        } else if (key == "vertical_sync_interval") {
+            std::uint64_t value = 0;
+            // Bounds the narrowing only. The 0 to 4 presentation domain is checked with the rest
+            // of the account settings, so it is stated once.
+            if (hasVerticalSyncInterval || !unsigned_integer(value)
+                || value > (std::numeric_limits<std::uint8_t>::max)()) {
+                return false;
+            }
+            output.verticalSyncInterval = static_cast<std::uint8_t>(value);
+            hasVerticalSyncInterval = true;
+        } else if (key == "field_of_view") {
+            if (hasFieldOfView || !signed_32(output.fieldOfView)) {
+                return false;
+            }
+            hasFieldOfView = true;
         } else if (key == "calibration_primary") {
             if (!mark(Field::calibrationPrimary) || !floating_point(output.calibrationPrimary)) {
                 return false;

+ 4 - 0
Sunrise/src/core/ui/theme/sunrise_ui_theme.cpp

@@ -1,5 +1,6 @@
 #include "sunrise_ui_theme.h"
 
+#include <algorithm>
 #include <imgui.h>
 
 #include "../scaling/dpi/ui_dpi_scaling.h"
@@ -107,6 +108,9 @@ void apply() noexcept {
     // Scaling a fresh default style stops repeated monitor changes from building up error.
     const float scale = scaling::dpi::current();
     style.ScaleAllSizes(scale);
+    // ScaleAllSizes truncates this one to a whole number, so any factor below 1 zeroes it and the
+    // cursor draws with no area. Held at 1 instead, which is the size it is authored at.
+    style.MouseCursorScale = (std::max)(1.0F, style.MouseCursorScale);
     style.FontSizeBase = fontSizeBase;
     style.FontScaleMain = scale;
     ImGui::GetStyle() = style;

+ 67 - 0
Sunrise/src/middleware/content/packages/tables/item_definition_reader.cpp

@@ -11,6 +11,12 @@ namespace {
 constexpr std::size_t kMaxStackSizeOffset = 180;
 /** A nonzero predicate byte marks an instanced definition. */
 constexpr std::size_t kInstancedOffset = 187;
+/**
+ * Definition byte 186 is the item tier: 1 common, 2 uncommon, 3 rare, 4 legendary, 5 exotic, and
+ * 0 for rows outside the rarity ladder such as currencies. Confirmed against every installed
+ * legendary weapon and armor row and the currency rows.
+ */
+constexpr std::size_t kTierOffset = 186;
 /** The equipment block is self-relative from this offset, zero when absent. */
 constexpr std::size_t kEquipmentBlockOffset = 16;
 /** The equipment block stores its signed slot id here. */
@@ -28,6 +34,25 @@ constexpr std::size_t kSocketPlugOffset = 2;
 constexpr std::size_t kFixedFieldEnd = kInstancedOffset + 1;
 /** Optional plug category used to expand three native reusable plug families. */
 constexpr std::size_t kPlugCategoryOffset = 392;
+/**
+ * The plug block is an embedded record whose class marker precedes its fields. It usually starts
+ * at byte 388, so the category sits at kPlugCategoryOffset, but an optional record ahead of it
+ * moves it, so the block is located by its marker inside this window rather than assumed.
+ */
+constexpr std::uint32_t kPlugBlockClass = 0x808077E3U;
+constexpr std::size_t kPlugBlockSearchStart = 0x100;
+constexpr std::size_t kPlugBlockSearchEnd = 0x300;
+/** Category hash and server-roll set ordinal, relative to the plug block marker. */
+constexpr std::size_t kPlugBlockCategoryOffset = 4;
+constexpr std::size_t kPlugBlockRollSetOffset = 0x26;
+/**
+ * A plug that grants another plug's effect (a Year-1 armor masterwork result naming the stat
+ * perk it stands for) carries one linked-plug record; its item index sits at byte 12.
+ */
+constexpr std::uint32_t kLinkedPlugClass = 0x80803036U;
+constexpr std::size_t kLinkedPlugIndexOffset = 12;
+/** A roll-set ordinal outside the ladder: none, or the marker for a plug rolled by no set. */
+constexpr std::uint16_t kNoRollSet = 0;
 /** Embedded reusable-list array descriptor inside one 80-byte ordinary socket entry. */
 constexpr std::size_t kEmbeddedPlugListOffset = 64;
 /** Reusable and randomized shared plug-set row indices inside one socket entry. */
@@ -220,6 +245,46 @@ constexpr std::size_t kStatEntryValue = 20;
  * @param definition Whole item definition bytes.
  * @param row Receives the declared stat rows and values.
  */
+/** @return The offset of the first record of one class inside a window, or the blob size. */
+[[nodiscard]] std::size_t find_record(std::span<const std::byte> definition,
+                                      std::uint32_t recordClass,
+                                      std::size_t start,
+                                      std::size_t end) noexcept {
+    const std::size_t limit = (std::min)(end, definition.size());
+    for (std::size_t offset = start; offset + sizeof(std::uint32_t) <= limit;
+         offset += sizeof(std::uint32_t)) {
+        std::uint32_t marker = 0;
+        if (read(definition, offset, marker) && marker == recordClass) {
+            return offset;
+        }
+    }
+    return definition.size();
+}
+
+/**
+ * Reads the located plug block: its category (which corrects the fixed-offset read for a shifted
+ * block), the ordinal of the server roll set that grants the plug, and any linked plug.
+ */
+void read_plug_block(std::span<const std::byte> definition, Row& row) noexcept {
+    row.rollSetIndex = kNoRollSet;
+    row.linkedPlugIndex = kUnavailablePlug;
+    const std::size_t block =
+        find_record(definition, kPlugBlockClass, kPlugBlockSearchStart, kPlugBlockSearchEnd);
+    if (block < definition.size()) {
+        (void)read(definition, block + kPlugBlockCategoryOffset, row.plugCategoryHash);
+        (void)read(definition, block + kPlugBlockRollSetOffset, row.rollSetIndex);
+    }
+    const std::size_t linked = find_record(definition, kLinkedPlugClass, 0, definition.size());
+    if (linked < definition.size()) {
+        (void)read(definition, linked + kLinkedPlugIndexOffset, row.linkedPlugIndex);
+    }
+}
+
+/**
+ * Reads the investment stat block into the row.
+ * @param definition Whole item definition record.
+ * @param row Receives the stats, left empty when the block is absent or malformed.
+ */
 void read_stats(std::span<const std::byte> definition, Row& row) noexcept {
     row.statCount = 0;
     std::int64_t blockRelative = 0;
@@ -273,12 +338,14 @@ bool read_definition(std::span<const std::byte> definition, Row& row) noexcept {
     std::uint8_t instanced = 0;
     if (!read(definition, kBucketIdOffset, row.bucketId)
         || !read(definition, kMaxStackSizeOffset, row.maxStackSize)
+        || !read(definition, kTierOffset, row.tier)
         || !read(definition, kInstancedOffset, instanced)) {
         return false;
     }
     row.instanced = instanced != 0;
     // Short legacy definitions simply do not declare a plug category.
     (void)read(definition, kPlugCategoryOffset, row.plugCategoryHash);
+    read_plug_block(definition, row);
     (void)read(definition,
                kInsertionMaterialRequirementSetIndexOffset,
                row.insertionMaterialRequirementSetIndex);

+ 12 - 8
Sunrise/src/middleware/content/packages/tables/items.h

@@ -41,6 +41,8 @@ struct Row {
     std::uint32_t definitionHash{};
     std::uint16_t definitionIndex{};
     std::uint8_t bucketId{};
+    /** Native rarity ladder: 1 common through 5 exotic; 0 outside the ladder. */
+    std::uint8_t tier{};
     std::int32_t maxStackSize{};
     bool instanced{};
     std::optional<std::int8_t> equipmentSlot{};
@@ -53,6 +55,13 @@ struct Row {
     std::uint16_t socketTypes[kSocketCapacity]{};
     /** Plug category used by a few native sockets to expand a seed into its whole safe family. */
     std::uint32_t plugCategoryHash{};
+    /**
+     * Ordinal of the server roll set that grants this plug in place of a socket's action plug;
+     * 0 when the plug is socketed directly, 0xFFFF for a plug the service granted by other means.
+     */
+    std::uint16_t rollSetIndex{};
+    /** Item index of the plug this one stands for, or kUnavailablePlug when it stands alone. */
+    std::uint16_t linkedPlugIndex{kUnavailablePlug};
     /** Native material sets used when this definition is inserted or enabled as a plug. */
     std::uint16_t insertionMaterialRequirementSetIndex{kUnavailableMaterialRequirementSetIndex};
     std::uint16_t enabledMaterialRequirementSetIndex{kUnavailableMaterialRequirementSetIndex};
@@ -90,19 +99,14 @@ void read_appearance(std::span<const std::byte> definition, Row& row) noexcept;
 using AllowedPlugVisitor = bool (*)(void* context, std::uint32_t itemDefinitionIndex) noexcept;
 
 /**
- * Visits the embedded, reusable, and randomized plug-list members declared for one socket
- * lane.
+ * Visits the embedded, reusable, and randomized plug-list members declared for one socket lane.
  * The initial plug is a separate fixed field and is intentionally left to the caller.
- *
  * @param definition Whole base-item definition bytes.
- * @param plugSetTable Whole shared plug-set
- * definition table from investment-root slot 51.
+ * @param plugSetTable Whole shared plug-set definition table from investment-root slot 51.
  * @param lane Ordinary socket lane to inspect.
- *
  * @param visitor Required bounded consumer.
  * @param context Opaque consumer state.
- * @return
- * True when every referenced array is structurally valid and accepted by the visitor.
+ * @return True when every referenced array is structurally valid and accepted by the visitor.
  */
 [[nodiscard]] bool visit_allowed_plugs(std::span<const std::byte> definition,
                                        std::span<const std::byte> plugSetTable,

+ 24 - 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,23 @@ 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. Publish empty buckets for
+            // this encode, like a character with no subclass, instead of failing: a hard failure
+            // aborts the whole Family-0/3 snapshot even though the 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 - 0
Sunrise/src/middleware/datagen/family4/account/preferences/layout.h

@@ -104,6 +104,7 @@ struct BindingsRecord {
     std::uint8_t voiceChatMirror{};
     std::uint8_t verticalSyncMirror{};
     std::array<std::byte, kFieldOfViewPaddingSize> fieldOfViewPadding{};
+    /** Stores the absolute FOV menu value used by the target client. */
     std::int32_t fieldOfViewAdjustment{};
     /** 0 picks this replicated array after the client's initial seed pass. */
     std::uint8_t sourceSelector{};

+ 11 - 4
Sunrise/src/middleware/datagen/family4/account/preferences/preferences_encoder.cpp

@@ -11,12 +11,14 @@ constexpr std::uint16_t kUnboundInputCode = 0x0074;
 constexpr std::int32_t kOpenSeedVersion = 0;
 /**
  * Seed version 1 closes a gate so the client keeps the replicated values behind it.
- * The keybinding gate stays open so the client seeds its own defaults. The post-processing gate
- * stays closed, or local cvars would overwrite its 3 replicated fields on every sign-in.
+ * The keybinding gate stays closed because Sunrise supplies the modeled preferences. The
+ * post-processing gate stays closed, or local cvars overwrite its 3 replicated fields each login.
  */
 constexpr std::int32_t kClosedSeedVersion = 1;
 /** Source 0 makes later input reads use the replicated keybinding array. */
 constexpr std::uint8_t kReplicatedBindingSource = 0;
+/** Source 1 makes later input reads use the computer-local keybindings. */
+constexpr std::uint8_t kComputerBindingSource = 1;
 
 /**
  * Converts a semantic boolean to the native 1-byte form.
@@ -53,8 +55,11 @@ bool encode(const state::account::settings::AccountSettings& settings,
     record = {};
     bindingsRecord = {};
     record.postProcessingSeedVersion = kClosedSeedVersion;
-    bindingsRecord.accountSeedVersion = kOpenSeedVersion;
-    bindingsRecord.sourceSelector = kReplicatedBindingSource;
+    bindingsRecord.accountSeedVersion = kClosedSeedVersion;
+    bindingsRecord.sourceSelector =
+        settings.keyBindingSource == state::account::settings::KeyBindingSource::account
+            ? kReplicatedBindingSource
+            : kComputerBindingSource;
 
     const auto& controls = settings.controls;
     record.buttonLayout = controls.buttonLayout;
@@ -88,6 +93,8 @@ bool encode(const state::account::settings::AccountSettings& settings,
     record.brightness = display.brightness;
     record.showFps = native_boolean(display.showFps);
     record.hdrMode = display.hdrMode;
+    bindingsRecord.verticalSyncMirror = display.verticalSyncInterval;
+    bindingsRecord.fieldOfViewAdjustment = display.fieldOfView;
     record.calibrationPrimary = display.calibrationPrimary;
     record.calibrationAlpha = display.calibrationAlpha;
 

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

+ 2 - 1
Sunrise/src/middleware/datagen/family4/inventory/layout.h

@@ -35,7 +35,8 @@ struct Entry {
     std::uint64_t instanceSoid{};
     /** Current stack quantity, set from the item definition for non-instanced rows. */
     std::int32_t quantity{};
-    /** Rising serial that keeps inventory mutation and eviction order. */
+    /** Rising serial that keeps inventory mutation and eviction order; the Client also orders a
+     *  bucket's grid cells by it. */
     std::int32_t mutationSerial{};
     /** Native item-state bits accumulated by inventory mutations. */
     std::uint32_t flags{};

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

+ 42 - 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,23 @@ void resolve_socket_states(
         return;
     }
     SubclassSelection selection{};
-    subclass_selection(character, selection);
+    subclass_selection(item, characterClass, selection);
 
+    // A group of 2 or 3 entries is mutually exclusive alternatives: exactly one lights up. An
+    // Attunement's group packs several 4-node options into one group id, so a population past the
+    // widest single bundle means its members activate in same-sized runs.
+    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 +84,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. Siblings carry their own plug source, so force the whole contiguous run
+        // active rather than relying on the plug-source match below.
+        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 +106,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 - 9
Sunrise/src/middleware/datagen/family4/loadout/subclass_socket_selection.h

@@ -27,26 +27,33 @@ 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; grenade, super, melee,
+ * movement and class entries are that item's own authored choices, and the class-ability bucket
+ * 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.
+ * Only a list carrying 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 a subclass carries ability choices.
+ * @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;

+ 38 - 6
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 =
@@ -211,9 +213,8 @@ bool process(const ServiceRoute& route,
             web_service::mutation_if<state::PendingItemDismantle>(webOutcome);
         if (equipmentSwap != nullptr) {
             // Equip is an optimistic Character-screen action. Its status-pair value is the exact
-            // Family-4 revision whose following Queuez frame makes the action authoritative. Stage
-            // that revision before encoding the reply so the Client cannot complete the action
-            // against the old object store.
+            // Family-4 revision whose following Queuez frame makes it authoritative. Stage that
+            // revision before encoding the reply, or the Client completes against the old store.
             auto& transaction = outcome.transaction.emplace<EquipmentSwapTransaction>();
             if (!queuez::stage_equipment_swap(
                     queuezState, equipmentSwap->characterSoid, transaction.update)) {
@@ -241,6 +242,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.
@@ -382,9 +415,8 @@ bool process(const ServiceRoute& route,
         }
         if (itemDismantle != nullptr) {
             // Dismantle is another optimistic Character-screen action. Promise only the exact
-            // Family-4 revision that carries both the character after-image and the empty
-            // item-instance release descriptor; otherwise retain the generic sentinel reply and
-            // publish no removal.
+            // Family-4 revision carrying both the character after-image and the empty release
+            // descriptor; otherwise keep the generic sentinel reply and publish no removal.
             auto& transaction = outcome.transaction.emplace<ItemDismantleTransaction>();
             if (!queuez::stage_item_dismantle(queuezState,
                                               itemDismantle->accountSoid,

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

+ 2 - 3
Sunrise/src/server/bap/encrypted/push/activity/activity_keepalive_push.cpp

@@ -137,9 +137,8 @@ bool consume_activity_keepalive(Session& session,
         // and the citizen advertisement inside the body is already gated on `privateCurrent`.
         //
         // It still owes one membership body. The client's msg 12 handler is the only writer of the
-        // flag that binds a world container to this ActivityClient, and until that bind lands the
-        // entity-slot grant sent at join has no view to reach:
-        // `RE/31 "A grant reaches a view only through a bound world container"`.
+        // flag that binds a world container to this ActivityClient, and a grant reaches a view
+        // only through a bound container, so until that bind lands the join grant has no view.
         //
         // Exactly one body per binding. The flag the client sets is one-way, it never acknowledges
         // one on this link, and a link that joined a session it did not allocate never reports a

+ 74 - 4
Sunrise/src/server/bap/encrypted/push/queuez/queuez_banner_push.cpp

@@ -216,10 +216,9 @@ bool append_banner_notification(Scratch& scratch,
 }
 
 /**
- * Appends the family-zero pair that follows an opcode-504 pick.
- * The Client holds the objIdx-1 buffer for one character at a time, allocated from the character
- * the anchor names, so the pair moves with the pick or the banner keeps the old emblem. A pick
- * naming the character the pair already holds republishes it in place.
+ * Appends the family-zero pair that follows an opcode-504 pick. The Client holds the objIdx-1
+ * buffer for one character at a time, so the pair moves with the pick or the banner keeps the old
+ * emblem. A pick on the character it already holds republishes in place.
  * @param scratch Lock-owned transform buffers.
  * @param before Queuez state after the family-four move.
  * @param selectedCharacter Character the pick named.
@@ -369,6 +368,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 {
+    // Index of the subclass slot in the authored equipment array.
+    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 +458,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 {
+    // Index of the subclass slot in the authored equipment array.
+    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,

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

@@ -166,6 +166,39 @@ 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

+ 17 - 28
Sunrise/src/server/bap/encrypted/push/snapshot/internal.h

@@ -103,15 +103,11 @@ inline constexpr std::size_t kSingleObjectCount = 1;
 
 /**
  * Builds the Family-4 character upsert for one prepared equipment swap.
- * @param scratch
- * Object and compression storage owned by the lock.
- * @param swap Checked queuez version
- * after-image and resident character definition.
- * @param mutation Checked State after-image that
- * is not committed yet.
+ * @param scratch Object and compression storage owned by the lock.
+ * @param swap Checked queuez version after-image and resident character definition.
+ * @param mutation Checked State after-image that is not committed yet.
  * @param prepared Gets the single character upsert descriptor.
- * @return
- * True when the after-image encodes and the complete object fits.
+ * @return True when the after-image encodes and the complete object fits.
  */
 [[nodiscard]] bool prepare_equipment_swap(Scratch& scratch,
                                           const queuez::EquipmentSwap& swap,
@@ -126,9 +122,7 @@ inline constexpr std::size_t kSingleObjectCount = 1;
 
 /**
  * Builds the Family-4 item-instance upsert for one prepared ordinary-socket selection.
- * The
- * character object is unchanged because item identity, placement, and mutation generation are
- *
+ * The character object is unchanged because item identity, placement and mutation generation are
  * preserved; the socket block lives entirely in the resident instance object.
  */
 [[nodiscard]] bool prepare_socket_plug(Scratch& scratch,
@@ -136,19 +130,19 @@ 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
- *
  * changed character that references it.
- * @param scratch Object and compression storage owned by
- * the lock.
+ * @param scratch Object and compression storage owned by the lock.
  * @param acquisition Exact queuez after-image promised by the correlated response.
- *
  * @param mutation Checked State after-image that remains uncommitted while output is staged.
- *
- * @param prepared Gets the two complete upsert descriptors in item-then-character dependency
- *
- * order.
+ * @param prepared Gets the two upsert descriptors in item-then-character dependency order.
  * @return True when both after-image objects encode and fit atomically.
  */
 [[nodiscard]] bool prepare_item_acquisition(Scratch& scratch,
@@ -169,16 +163,11 @@ prepare_profile_item_acquisition(Scratch& scratch,
 
 /**
  * Builds one Family-4 increment containing the changed character and released item instance.
- *
  * @param scratch Object and compression storage owned by the lock.
- * @param dismantle Exact queuez
- * after-image promised by the correlated response.
- * @param mutation Checked State after-image
- * that remains uncommitted while output is staged.
- * @param prepared Gets the character upsert
- * followed by the empty release descriptor.
- * @return True when the character after-image and
- * complete two-operation update fit atomically.
+ * @param dismantle Exact queuez after-image promised by the correlated response.
+ * @param mutation Checked State after-image that remains uncommitted while output is staged.
+ * @param prepared Gets the character upsert followed by the empty release descriptor.
+ * @return True when the character after-image and two-operation update fit atomically.
  */
 [[nodiscard]] bool prepare_item_dismantle(Scratch& scratch,
                                           const queuez::ItemDismantle& dismantle,

+ 13 - 6
Sunrise/src/server/bap/encrypted/queuez/definition.h

@@ -19,12 +19,8 @@ inline constexpr std::uint32_t kAccountFamilyType = 4;
 inline constexpr std::int32_t kInitialFamilyVersion = 0;
 /**
  * Family four holds account, character, one id per character-owned item, and one id per
- *
- * resident-backed mod or shader stack. Profile currency rows deliberately carry no instance
- *
- * SOID; the fixed addition covers all 50 native rows in each supported action-source bucket.
- * It
- * matches the snapshot descriptor size, so a snapshot that builds always stages.
+ * resident-backed mod or shader stack. Profile currency rows carry no instance SOID; the fixed
+ * addition covers all 50 native rows in each supported action-source bucket.
  */
 inline constexpr std::size_t kResidentCapacity =
     2 + state::kCharacterCapacity * middleware::datagen::family4::loadout::kItemCapacity
@@ -145,6 +141,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 +194,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

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

@@ -171,6 +171,79 @@ void report_repush(const char* stage, std::size_t bytes) noexcept {
     return true;
 }
 
+/**
+ * Re-derives the selected character's appearance and roster once the ability-bucket rebuild owed
+ * by a subclass selection has landed. The refresh sent inline with the opcode-801 response can
+ * still carry empty buckets, because that rebuild runs off the Client content-extraction pump.
+ * @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;
+    }
+    // Nothing is owed until a family that reads abilities is subscribed. The arm stays set, the
+    // same way the banner re-push below keeps its own.
+    if (!session.queuez.family0Active && !session.queuez.family3Active) {
+        return 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;
+    // The frame is committed here, so the arm is committed with it. Disarming any earlier drops
+    // the owed refresh on a transient encode failure.
+    session.abilityRefreshArmed = false;
+    report_repush("ability_refresh", framedSize);
+    return true;
+}
+
 } // namespace
 
 /**
@@ -198,6 +271,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)

+ 78 - 3
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,10 +75,16 @@ 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
-        // binding.
+        // the cosmetic panels and world player consume. Its resident character record is updated
+        // in place: releasing and re-adding the same key tears down the ship/banner binding.
         if (after.family0Active) {
             CharacterAppearanceRefresh refresh{};
             if (!stage_character_appearance_refresh(
@@ -134,6 +142,72 @@ 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 opcode 801 promised. The instance
+        // upsert goes first, then the appearance and roster refreshes, so gameplay reads the new
+        // selection now rather than 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 ability-bucket rebuild runs off the Client content-extraction pump, so the two
+        // refreshes below can race it and carry empty buckets. The delayed re-derivation is owed
+        // either way.
+        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 +489,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;
 }
 

+ 8 - 3
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.
  *
@@ -153,9 +160,7 @@ namespace sunrise::server::bap::encrypted::queuez {
 /**
  * Stages one Family-4 version increment for a full resident account-object upsert.
  * A profile row with a nonzero action-source SOID must already be resident when its stack grows,
- * or is appended exactly once when Collections creates the row. Currency/material rows keep a
- * zero SOID and preserve the manifest.
- *
+ * or is appended once when Collections creates it. Currency rows keep a zero SOID.
  * @param before Current active peer state.
  * @param accountSoid Account root receiving the profile stack.
  * @param acquiredInstanceSoid Profile action-source key, or zero for a non-actionable stack.

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

@@ -299,6 +299,76 @@ 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,

+ 24 - 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,34 @@ 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 stops showing the previous subclass's resolution.
+            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)) {

+ 10 - 3
Sunrise/src/server/bap/internal.h

@@ -121,9 +121,8 @@ struct Session {
     std::uint64_t activityRosterDueTick{};
     /**
      * Binding generation whose membership body this link has already delivered.
-     * The client sets its own membership flag once and never clears it, and never acknowledges a
-     * body on a public-target link, so a send condition has to be a one-shot per binding rather
-     * than a revision or acknowledgement gate. Latched on delivery, never on encode.
+     * The client sets its membership flag once and never clears it, and never acknowledges a body
+     * on a public-target link, so this is a one-shot per binding. Latched on delivery, not encode.
      */
     std::uint64_t activityMembershipSentGeneration{};
     /**
@@ -173,6 +172,14 @@ 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 and the rebuild runs off the Client
+     * content-extraction pump, so the inline refresh can carry empty ones; this one re-derives.
+     */
+    std::uint64_t abilityRefreshDueTick{};
+    /** True while one ability-icon refresh is still owed to this peer. */
+    bool abilityRefreshArmed{};
 };
 
 namespace plaintext {

+ 67 - 78
Sunrise/src/server/gameplay/peer/peer_transport.cpp

@@ -23,6 +23,7 @@ namespace sunrise::server::gameplay::peer {
 
 namespace {
 
+namespace gp = state::gameplay;
 namespace wire = middleware::gameplay::peer;
 namespace bits = middleware::encoding::bits;
 
@@ -33,7 +34,7 @@ namespace bits = middleware::encoding::bits;
  * @param payload Payload bytes.
  * @return True when one of the two carried it.
  */
-[[nodiscard]] bool send_transport(const state::gameplay::Endpoint& to,
+[[nodiscard]] bool send_transport(const gp::Endpoint& to,
                                   std::span<const std::byte> payload) noexcept {
     return dtls::send_payload(to, payload) || association::send_payload(to, payload);
 }
@@ -49,7 +50,7 @@ constexpr std::uint32_t kByteMask = 0xFF;
 /** Delay sentinel used until a round trip has been measured. */
 constexpr std::uint16_t kDelaySentinel = 1023;
 /** Packet sequences are published as ten bits. */
-constexpr std::uint16_t kPacketSequenceModulus = state::gameplay::kPacketSequenceModulus;
+constexpr std::uint16_t kPacketSequenceModulus = gp::kPacketSequenceModulus;
 /** Sequence the first packet to a peer carries, because the head advances before it is written. */
 constexpr std::uint16_t kFirstPacketSequence = 1;
 /** Smallest head-minus-cursor the peer accepts. This host keeps at most one packet in flight. */
@@ -63,22 +64,19 @@ constexpr std::size_t kMessageReportCapacity = 8;
 constexpr std::uint64_t kResendInterval = 250;
 
 SRWLOCK g_lock{SRWLOCK_INIT};
-std::array<state::gameplay::PeerLink, state::gameplay::kAssociationCapacity> g_peers;
+std::array<gp::PeerLink, gp::kAssociationCapacity> g_peers;
 /** Channel ids this host hands out. The peer refuses one that does not increase. */
 std::uint32_t g_channelId{0};
 
 /** @return True when both endpoints name the same address and port. */
-[[nodiscard]] bool same_endpoint(const state::gameplay::Endpoint& left,
-                                 const state::gameplay::Endpoint& right) noexcept {
+[[nodiscard]] bool same_endpoint(const gp::Endpoint& left, const gp::Endpoint& right) noexcept {
     return left.address == right.address && left.port == right.port;
 }
 
 /** @return Peer for one endpoint, or null. Callers already hold the lock. */
-[[nodiscard]] state::gameplay::PeerLink*
-find_locked(const state::gameplay::Endpoint& from) noexcept {
-    for (state::gameplay::PeerLink& peer : g_peers) {
-        if (peer.stage != state::gameplay::PeerStage::absent
-            && same_endpoint(peer.endpoint, from)) {
+[[nodiscard]] gp::PeerLink* find_locked(const gp::Endpoint& from) noexcept {
+    for (gp::PeerLink& peer : g_peers) {
+        if (peer.stage != gp::PeerStage::absent && same_endpoint(peer.endpoint, from)) {
             return &peer;
         }
     }
@@ -86,8 +84,7 @@ find_locked(const state::gameplay::Endpoint& from) noexcept {
 }
 
 /** @return True when the link carries one group session. Callers hold the lock. */
-[[nodiscard]] bool carries_locked(const state::gameplay::PeerLink& peer,
-                                  std::uint64_t sessionId) noexcept {
+[[nodiscard]] bool carries_locked(const gp::PeerLink& peer, std::uint64_t sessionId) noexcept {
     for (const std::uint64_t held : peer.sessions) {
         if (held == sessionId) {
             return true;
@@ -97,12 +94,12 @@ find_locked(const state::gameplay::Endpoint& from) noexcept {
 }
 
 /** @return Link carrying one group session, or null. Callers hold the lock. */
-[[nodiscard]] state::gameplay::PeerLink* find_session_locked(std::uint64_t sessionId) noexcept {
+[[nodiscard]] gp::PeerLink* find_session_locked(std::uint64_t sessionId) noexcept {
     if (sessionId == 0) {
         return nullptr;
     }
-    for (state::gameplay::PeerLink& peer : g_peers) {
-        if (peer.stage != state::gameplay::PeerStage::absent && carries_locked(peer, sessionId)) {
+    for (gp::PeerLink& peer : g_peers) {
+        if (peer.stage != gp::PeerStage::absent && carries_locked(peer, sessionId)) {
             return &peer;
         }
     }
@@ -115,7 +112,7 @@ find_locked(const state::gameplay::Endpoint& from) noexcept {
  * @param peer Link the message arrived on.
  * @return The session id, or zero when the link carries none or several.
  */
-[[nodiscard]] std::uint64_t sole_session_locked(const state::gameplay::PeerLink& peer) noexcept {
+[[nodiscard]] std::uint64_t sole_session_locked(const gp::PeerLink& peer) noexcept {
     std::uint64_t only = 0;
     for (const std::uint64_t held : peer.sessions) {
         if (held == 0) {
@@ -134,18 +131,18 @@ find_locked(const state::gameplay::Endpoint& from) noexcept {
  * @param from Peer endpoint.
  * @return The session id, or zero when it cannot be resolved.
  */
-[[nodiscard]] std::uint64_t session_for_endpoint(const state::gameplay::Endpoint& from) noexcept {
+[[nodiscard]] std::uint64_t session_for_endpoint(const gp::Endpoint& from) noexcept {
     AcquireSRWLockShared(&g_lock);
-    const state::gameplay::PeerLink* const peer = find_locked(from);
+    const gp::PeerLink* const peer = find_locked(from);
     const std::uint64_t only = peer == nullptr ? 0 : sole_session_locked(*peer);
     ReleaseSRWLockShared(&g_lock);
     return only;
 }
 
 /** @return A free peer slot, or null. Callers already hold the lock. */
-[[nodiscard]] state::gameplay::PeerLink* allocate_locked() noexcept {
-    for (state::gameplay::PeerLink& peer : g_peers) {
-        if (peer.stage == state::gameplay::PeerStage::absent) {
+[[nodiscard]] gp::PeerLink* allocate_locked() noexcept {
+    for (gp::PeerLink& peer : g_peers) {
+        if (peer.stage == gp::PeerStage::absent) {
             return &peer;
         }
     }
@@ -154,7 +151,7 @@ find_locked(const state::gameplay::Endpoint& from) noexcept {
 
 /** Fills the address blob that names this host on the direct path. */
 void local_address(std::array<std::byte, wire::kAddressBlobSize>& output) noexcept {
-    const state::gameplay::Endpoint advertised = endpoint::advertised();
+    const gp::Endpoint advertised = endpoint::advertised();
     middleware::gameplay::descriptor::write_net_addr(advertised.address, advertised.port, output);
 }
 
@@ -178,7 +175,7 @@ void local_address(std::array<std::byte, wire::kAddressBlobSize>& output) noexce
  * @param remoteChannelId Channel id the request carried. The link must still hold it.
  * @param body Both channel ids.
  */
-void answer_establish(const state::gameplay::Endpoint& to,
+void answer_establish(const gp::Endpoint& to,
                       std::uint32_t remoteChannelId,
                       const wire::ConnectEstablish& body) noexcept {
     std::array<std::byte, kReplyCapacity> buffer{};
@@ -190,7 +187,7 @@ void answer_establish(const state::gameplay::Endpoint& to,
     }
     AcquireSRWLockExclusive(&g_lock);
     // The endpoint's link. A channel the peer has retired has no link of its own to answer on.
-    state::gameplay::PeerLink* peer = find_locked(to);
+    gp::PeerLink* peer = find_locked(to);
     const bool queued =
         peer != nullptr && peer->remoteConnectionSequence == remoteChannelId
         && wire::enqueue_message(peer->outbound,
@@ -216,7 +213,7 @@ void answer_establish(const state::gameplay::Endpoint& to,
  * @param request Decoded request body.
  * @param now Monotonic tick count.
  */
-void answer_connect(const state::gameplay::Endpoint& from,
+void answer_connect(const gp::Endpoint& from,
                     const wire::ConnectRequest& request,
                     std::uint64_t now) noexcept {
     wire::ConnectResponse response{};
@@ -228,7 +225,7 @@ void answer_connect(const state::gameplay::Endpoint& from,
     AcquireSRWLockExclusive(&g_lock);
     // Keyed by endpoint. The client holds one channel per host peer, so a second link would stamp
     // packets with a channel id the client has already retired.
-    state::gameplay::PeerLink* peer = find_locked(from);
+    gp::PeerLink* peer = find_locked(from);
     // A repeat of the same request is a retransmission and leaves the link alone. A different
     // channel or sequence is a new incarnation the peer built without announcing the teardown.
     const bool rebuilt = peer != nullptr
@@ -237,15 +234,13 @@ void answer_connect(const state::gameplay::Endpoint& from,
     if (peer == nullptr) {
         peer = allocate_locked();
     }
-    const bool fresh =
-        peer != nullptr && (peer->stage == state::gameplay::PeerStage::absent || rebuilt);
+    const bool fresh = peer != nullptr && (peer->stage == gp::PeerStage::absent || rebuilt);
     if (fresh) {
         // The sessions outlive the channel. The client rebuilds one channel under every group
         // session it holds and rejoins none of them, so dropping them here strands each one.
-        const std::array<std::uint64_t, state::gameplay::kSessionsPerLink> held =
-            peer->stage == state::gameplay::PeerStage::absent
-                ? std::array<std::uint64_t, state::gameplay::kSessionsPerLink>{}
-                : peer->sessions;
+        const std::array<std::uint64_t, gp::kSessionsPerLink> held =
+            peer->stage == gp::PeerStage::absent ? std::array<std::uint64_t, gp::kSessionsPerLink>{}
+                                                 : peer->sessions;
         *peer = {};
         peer->sessions = held;
         peer->endpoint = from;
@@ -267,7 +262,7 @@ void answer_connect(const state::gameplay::Endpoint& from,
         peer->remoteAddressPresent = true;
         // A retransmission must not move an established link back a stage.
         if (fresh) {
-            peer->stage = state::gameplay::PeerStage::connecting;
+            peer->stage = gp::PeerStage::connecting;
         }
         peer->lastTick = now;
         response.channelId = peer->localConnectionSequence;
@@ -311,15 +306,14 @@ void answer_connect(const state::gameplay::Endpoint& from,
  * @param sessionId Session the join request named.
  * @return True when a link now carries that session.
  */
-[[nodiscard]] bool bind_session(const state::gameplay::Endpoint& from,
-                                std::uint64_t sessionId) noexcept {
+[[nodiscard]] bool bind_session(const gp::Endpoint& from, std::uint64_t sessionId) noexcept {
     if (sessionId == 0) {
         return false;
     }
     AcquireSRWLockExclusive(&g_lock);
     // The endpoint's link, whatever it already carries. A join for a second region arrives on the
     // same channel as the first, and out of band when that channel is still being rebuilt.
-    state::gameplay::PeerLink* const peer = find_locked(from);
+    gp::PeerLink* const peer = find_locked(from);
     const char* result = "nolink";
     bool bound = false;
     std::uint32_t channel = 0;
@@ -356,7 +350,7 @@ void answer_connect(const state::gameplay::Endpoint& from,
  * @param from Requesting endpoint.
  * @param request Decoded admission prefix.
  */
-void answer_join(const state::gameplay::Endpoint& from, const wire::JoinRequest& request) noexcept {
+void answer_join(const gp::Endpoint& from, const wire::JoinRequest& request) noexcept {
     const std::uint64_t hostSession = endpoint::identity().onlineSessionId;
     wire::RefuseReason reason = wire::RefuseReason::notFound;
     if (wire::admit(request, hostSession, reason)) {
@@ -425,8 +419,7 @@ void answer_join(const state::gameplay::Endpoint& from, const wire::JoinRequest&
  * @param reader Reader positioned at the ping body.
  * @return True when the body read, whether or not the reply left the endpoint.
  */
-[[nodiscard]] bool answer_ping(const state::gameplay::Endpoint& from,
-                               bits::Reader& reader) noexcept {
+[[nodiscard]] bool answer_ping(const gp::Endpoint& from, bits::Reader& reader) noexcept {
     wire::PingBody ping{};
     if (!wire::read_ping(reader, ping)) {
         return false;
@@ -452,7 +445,7 @@ void answer_join(const state::gameplay::Endpoint& from, const wire::JoinRequest&
  * @param payload Whole decrypted payload.
  * @param now Monotonic tick count.
  */
-void consume_container(const state::gameplay::Endpoint& from,
+void consume_container(const gp::Endpoint& from,
                        std::span<const std::byte> payload,
                        std::uint64_t now) noexcept {
     bits::Reader reader(payload);
@@ -541,7 +534,7 @@ void consume_container(const state::gameplay::Endpoint& from,
  * @param peer Peer receiving the packet.
  * @param sequence Sequence the packet published.
  */
-void record_sequence(state::gameplay::PeerLink& peer, std::uint16_t sequence) noexcept {
+void record_sequence(gp::PeerLink& peer, std::uint16_t sequence) noexcept {
     if (!peer.ringInitialized) {
         peer.ringInitialized = true;
         peer.receiveHead = sequence;
@@ -550,13 +543,12 @@ void record_sequence(state::gameplay::PeerLink& peer, std::uint16_t sequence) no
     }
     // Add the modulus before subtracting. A bare difference is signed and goes negative on a wrap.
     const std::uint16_t advance = static_cast<std::uint16_t>(
-        (sequence + state::gameplay::kPacketSequenceModulus - peer.receiveHead)
-        % state::gameplay::kPacketSequenceModulus);
-    if (advance == 0 || advance >= state::gameplay::kPacketSequenceHalf) {
+        (sequence + gp::kPacketSequenceModulus - peer.receiveHead) % gp::kPacketSequenceModulus);
+    if (advance == 0 || advance >= gp::kPacketSequenceHalf) {
         // A repeat or an older packet leaves the published history alone.
         return;
     }
-    std::array<bool, state::gameplay::kAckHistory> shifted{};
+    std::array<bool, gp::kAckHistory> shifted{};
     for (std::size_t index = 0; index < shifted.size(); ++index) {
         // Entry `index` is the packet `index + 1` before the new head, so the old head lands at
         // `advance - 1`. Anything newer than the old head and older than this packet was skipped.
@@ -579,12 +571,11 @@ void record_sequence(state::gameplay::PeerLink& peer, std::uint16_t sequence) no
  * @param peer Peer that sent it, held under the lock.
  * @param message Reassembled message and its inner header.
  */
-void apply_message(state::gameplay::PeerLink& peer,
-                   const wire::AssembledMessage& message) noexcept {
+void apply_message(gp::PeerLink& peer, const wire::AssembledMessage& message) noexcept {
     if (message.id == static_cast<std::uint8_t>(wire::ConnectId::establish)
-        && peer.stage == state::gameplay::PeerStage::connecting) {
+        && peer.stage == gp::PeerStage::connecting) {
         // The reliable establish is what moves a connected peer past the out-of-band pair.
-        peer.stage = state::gameplay::PeerStage::connected;
+        peer.stage = gp::PeerStage::connected;
     }
 }
 
@@ -594,14 +585,14 @@ void apply_message(state::gameplay::PeerLink& peer,
  * @param ack Acknowledgement state the packet published.
  * @return True when this acknowledgement emptied the queue.
  */
-bool apply_acknowledgement(state::gameplay::PeerLink& peer, const wire::AckState& ack) noexcept {
+bool apply_acknowledgement(gp::PeerLink& peer, const wire::AckState& ack) noexcept {
     if (!peer.outbound.awaitingAcknowledgement
         || !wire::acknowledgement_covers(ack, peer.outbound.sentInPacket)) {
         return false;
     }
     // The peer has the packet, so every fragment in it is delivered. The next sequence is kept
     // because message sequences continue across messages.
-    for (state::gameplay::OutboundFragment& fragment : peer.outbound.fragments) {
+    for (gp::OutboundFragment& fragment : peer.outbound.fragments) {
         fragment = {};
     }
     peer.outbound.count = 0;
@@ -615,7 +606,7 @@ bool apply_acknowledgement(state::gameplay::PeerLink& peer, const wire::AckState
  * @param payload Whole decrypted payload.
  * @param now Monotonic tick count.
  */
-void consume_established(const state::gameplay::Endpoint& from,
+void consume_established(const gp::Endpoint& from,
                          std::span<const std::byte> payload,
                          std::uint64_t now) noexcept {
     wire::EstablishedPacket packet{};
@@ -638,7 +629,7 @@ void consume_established(const state::gameplay::Endpoint& from,
     bool guardAccepted = false;
     std::uint8_t expectedGuard = 0;
     AcquireSRWLockExclusive(&g_lock);
-    state::gameplay::PeerLink* peer = find_locked(from);
+    gp::PeerLink* peer = find_locked(from);
     std::array<wire::AssembledMessage, kMessageReportCapacity> bodies{};
     if (peer != nullptr) {
         peerFound = true;
@@ -700,7 +691,7 @@ void consume_established(const state::gameplay::Endpoint& from,
             continue;
         }
         // Group handling runs outside the lock because answering takes it again.
-        bits::Reader reader({body.bytes.data(), state::gameplay::kReassemblyCapacity});
+        bits::Reader reader({body.bytes.data(), gp::kReassemblyCapacity});
         if (reader.skip(body.bodyBitOffset)
             && !group::consume(from, sessionId, body.id, reader, now)) {
             report(core::log::Level::debug,
@@ -733,7 +724,7 @@ void consume_established(const state::gameplay::Endpoint& from,
  * @param peer Peer state copied under the lock before the send.
  * @return True when the packet left the endpoint.
  */
-[[nodiscard]] bool send_acknowledgement(const state::gameplay::PeerLink& peer) noexcept {
+[[nodiscard]] bool send_acknowledgement(const gp::PeerLink& peer) noexcept {
     wire::AckState ack{};
     ack.outboundHead = peer.outboundHead;
     ack.outboundHeadPresent = peer.outboundHeadPresent;
@@ -762,7 +753,7 @@ void consume_established(const state::gameplay::Endpoint& from,
 } // namespace
 
 /** Consumes one decrypted transport payload. */
-void deliver(const state::gameplay::Endpoint& from,
+void deliver(const gp::Endpoint& from,
              std::span<const std::byte> payload,
              std::uint64_t now) noexcept {
     if (payload.empty()) {
@@ -776,7 +767,7 @@ void deliver(const state::gameplay::Endpoint& from,
 }
 
 /** Sends one already-encoded out-of-band body in its own container. */
-bool send_container(const state::gameplay::Endpoint& to,
+bool send_container(const gp::Endpoint& to,
                     std::uint8_t id,
                     std::uint32_t declaredSize,
                     std::span<const std::byte> body,
@@ -811,7 +802,7 @@ bool enqueue_reliable(std::uint64_t sessionId,
                       std::span<const std::byte> body,
                       std::size_t bodyBits) noexcept {
     AcquireSRWLockExclusive(&g_lock);
-    state::gameplay::PeerLink* peer = find_session_locked(sessionId);
+    gp::PeerLink* peer = find_session_locked(sessionId);
     const bool queued =
         peer != nullptr && wire::enqueue_message(peer->outbound, id, declaredSize, body, bodyBits);
     if (queued) {
@@ -826,9 +817,9 @@ bool enqueue_reliable(std::uint64_t sessionId,
 
 /** Reports the NetAddr one peer sent in its own connect request. */
 bool remote_address(std::uint64_t sessionId,
-                    std::array<std::byte, state::gameplay::kNetAddrBlobSize>& output) noexcept {
+                    std::array<std::byte, gp::kNetAddrBlobSize>& output) noexcept {
     AcquireSRWLockShared(&g_lock);
-    const state::gameplay::PeerLink* peer = find_session_locked(sessionId);
+    const gp::PeerLink* peer = find_session_locked(sessionId);
     const bool present = peer != nullptr && peer->remoteAddressPresent;
     if (present) {
         output = peer->remoteAddress;
@@ -838,12 +829,11 @@ bool remote_address(std::uint64_t sessionId,
 }
 
 /** Binds one peer's view signature. */
-void bind_view(const state::gameplay::Endpoint& from,
-               const state::gameplay::ViewSignature& signature) noexcept {
+void bind_view(const gp::Endpoint& from, const gp::ViewSignature& signature) noexcept {
     AcquireSRWLockExclusive(&g_lock);
     // Keyed by endpoint, not by session: the view body carries no session id, and a link holding
     // both a current and a target region resolves no sole session to key it by.
-    state::gameplay::PeerLink* peer = find_locked(from);
+    gp::PeerLink* peer = find_locked(from);
     if (peer != nullptr) {
         peer->view = signature;
     }
@@ -853,20 +843,20 @@ void bind_view(const state::gameplay::Endpoint& from,
 /** Reports whether the link carrying one session holds a bound view and is established. */
 bool view_bound(std::uint64_t sessionId) noexcept {
     AcquireSRWLockShared(&g_lock);
-    const state::gameplay::PeerLink* peer = find_session_locked(sessionId);
+    const gp::PeerLink* peer = find_session_locked(sessionId);
     // A bound body alone is not readiness. The link also has to be past its connect exchange, or
     // the view belongs to a channel the peer has already rebuilt.
     const bool ready =
-        peer != nullptr && peer->view.bound && peer->stage == state::gameplay::PeerStage::connected;
+        peer != nullptr && peer->view.bound && peer->stage == gp::PeerStage::connected;
     ReleaseSRWLockShared(&g_lock);
     return ready;
 }
 
 /** Reports how far the link carrying one group session has got. */
-bool link_stage(std::uint64_t sessionId, state::gameplay::PeerStage& stage) noexcept {
-    stage = state::gameplay::PeerStage::absent;
+bool link_stage(std::uint64_t sessionId, gp::PeerStage& stage) noexcept {
+    stage = gp::PeerStage::absent;
     AcquireSRWLockShared(&g_lock);
-    const state::gameplay::PeerLink* peer = find_session_locked(sessionId);
+    const gp::PeerLink* peer = find_session_locked(sessionId);
     const bool present = peer != nullptr;
     if (present) {
         stage = peer->stage;
@@ -879,7 +869,7 @@ bool link_stage(std::uint64_t sessionId, state::gameplay::PeerStage& stage) noex
 bool link_identity(std::uint64_t sessionId, LinkIdentity& output) noexcept {
     output = {};
     AcquireSRWLockShared(&g_lock);
-    const state::gameplay::PeerLink* peer = find_session_locked(sessionId);
+    const gp::PeerLink* peer = find_session_locked(sessionId);
     const bool present = peer != nullptr;
     if (present) {
         output.localConnectionSequence = peer->localConnectionSequence;
@@ -891,15 +881,15 @@ bool link_identity(std::uint64_t sessionId, LinkIdentity& output) noexcept {
 
 /** Sends any owed acknowledgement. */
 void service(std::uint64_t now) noexcept {
-    std::array<state::gameplay::PeerLink, state::gameplay::kAssociationCapacity> owed{};
+    std::array<gp::PeerLink, gp::kAssociationCapacity> owed{};
     std::size_t count = 0;
     AcquireSRWLockExclusive(&g_lock);
-    for (state::gameplay::PeerLink& peer : g_peers) {
+    for (gp::PeerLink& peer : g_peers) {
         // An unacknowledged send queue keeps the packet going out until the peer confirms it.
         // Every packet burns one sequence, so the resend is paced.
         const bool resendDue = peer.outbound.count != 0 && now - peer.lastSend >= kResendInterval;
         const bool due = peer.acknowledgementOwed || resendDue;
-        if (peer.stage == state::gameplay::PeerStage::absent || !due) {
+        if (peer.stage == gp::PeerStage::absent || !due) {
             continue;
         }
         peer.acknowledgementOwed = false;
@@ -930,7 +920,7 @@ void service(std::uint64_t now) noexcept {
 /** Drops one group session, leaving the link and its other sessions alone. */
 void drop(std::uint64_t sessionId) noexcept {
     AcquireSRWLockExclusive(&g_lock);
-    state::gameplay::PeerLink* const peer = find_session_locked(sessionId);
+    gp::PeerLink* const peer = find_session_locked(sessionId);
     if (peer != nullptr) {
         // The channel outlives the session. A leave names one region, and the client keeps playing
         // the other over the same channel.
@@ -944,11 +934,10 @@ void drop(std::uint64_t sessionId) noexcept {
 }
 
 /** Drops every link at one endpoint, which is what a connect-closed names. */
-void drop_endpoint(const state::gameplay::Endpoint& endpoint) noexcept {
+void drop_endpoint(const gp::Endpoint& endpoint) noexcept {
     AcquireSRWLockExclusive(&g_lock);
-    for (state::gameplay::PeerLink& peer : g_peers) {
-        if (peer.stage != state::gameplay::PeerStage::absent
-            && same_endpoint(peer.endpoint, endpoint)) {
+    for (gp::PeerLink& peer : g_peers) {
+        if (peer.stage != gp::PeerStage::absent && same_endpoint(peer.endpoint, endpoint)) {
             peer = {};
         }
     }
@@ -958,7 +947,7 @@ void drop_endpoint(const state::gameplay::Endpoint& endpoint) noexcept {
 /** Drops every peer. */
 void reset() noexcept {
     AcquireSRWLockExclusive(&g_lock);
-    for (state::gameplay::PeerLink& peer : g_peers) {
+    for (gp::PeerLink& peer : g_peers) {
         peer = {};
     }
     ReleaseSRWLockExclusive(&g_lock);

+ 1 - 1
Sunrise/src/server/gameplay/physics/host/physics_session.cpp

@@ -29,7 +29,7 @@ constexpr std::size_t kSessionCapacity = kWorldCapacity;
 constexpr std::size_t kAdmittedCapacity = 8;
 /** Host-session rows one snapshot reads. */
 constexpr std::size_t kHostRowCapacity = 8;
-/** `RE/49` runs the world at 30 Hz, so one tick is due every 33 ms. */
+/** The world runs at 30 Hz, so one tick is due every 33 ms. */
 constexpr std::uint64_t kTickIntervalMs = 33;
 /** Empty scene scale. Nothing reads it until an actor carries a transform. */
 constexpr float kMillimetersPerUnit = 1000.0F;

+ 93 - 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,40 @@ 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;
+    }
+    // Upper-case hex, which is the form the rest of the log lines use.
+    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 +299,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;

+ 5 - 4
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"
@@ -270,10 +271,8 @@ bool consume(std::span<const std::byte> request,
         && middleware::web_service::messages::opcode206::parse_request(message, subscription);
 
     // The action runs before its reply is encoded, because the reply reports whether it worked.
-    // Each action fills the outcome only once it has prepared its whole transition, so an outcome
-    // still empty after one ran is that action refusing the request. Nothing is published here:
-    // both the prepared mutation and the subscription are handed back for the caller to publish
-    // once the whole response is framed.
+    // An action fills the outcome only once it has prepared its whole transition, so an outcome
+    // still empty afterwards is that action refusing the request. Nothing is published here.
     bool dispatched = true;
     if (message.opcode == middleware::web_service::messages::opcode504::kOpcode) {
         select_character(message, outcome);
@@ -283,6 +282,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.

+ 13 - 3
Sunrise/src/state/account/account_state.cpp

@@ -18,9 +18,16 @@ inline constexpr std::size_t kIdentityCapacity =
            && item.mutationSerial == 0;
 }
 
+/** Tier bits 1-5 are the native rarity ladder; bit 0 (no tier) is never a payout target. */
+constexpr std::uint8_t kDismantleTierMaskBits = 0b0011'1110U;
+constexpr std::uint8_t kDismantleClassMaskBits =
+    static_cast<std::uint8_t>(DismantleGearClass::weapon)
+    | static_cast<std::uint8_t>(DismantleGearClass::armor);
+
 /** @return True when one unused dismantle policy row is canonical zero. */
 [[nodiscard]] bool empty_dismantle_reward(const DismantleRewardPolicy& reward) noexcept {
-    return reward.definitionHash == 0 && reward.quantity == 0;
+    return reward.definitionHash == 0 && reward.quantity == 0 && reward.tierMask == 0
+           && reward.classMask == 0 && reward.masterwork == DismantleMasterworkFilter::any;
 }
 
 /** Checks filled policy rows, uniqueness, and the zero tail. */
@@ -36,11 +43,14 @@ inline constexpr std::size_t kIdentityCapacity =
             }
             continue;
         }
-        if (reward.definitionHash == inventory::kNoDefinitionHash || reward.quantity <= 0) {
+        if (reward.definitionHash == inventory::kNoDefinitionHash || reward.quantity <= 0
+            || (reward.tierMask & ~kDismantleTierMaskBits) != 0
+            || (reward.classMask & ~kDismantleClassMaskBits) != 0
+            || reward.masterwork > DismantleMasterworkFilter::notMasterworked) {
             return false;
         }
         for (std::size_t prior = 0; prior < index; ++prior) {
-            if (state.dismantleRewards[prior].definitionHash == reward.definitionHash) {
+            if (same_dismantle_policy_key(state.dismantleRewards[prior], reward)) {
                 return false;
             }
         }

+ 68 - 15
Sunrise/src/state/account/account_state.h

@@ -11,15 +11,44 @@ namespace sunrise::state {
 
 /** One account can own at most the 3 playable character slots. */
 inline constexpr std::size_t kCharacterCapacity = 3;
-/** A server-authored dismantle policy stays small but leaves room for build variants. */
-inline constexpr std::size_t kDismantleRewardPolicyCapacity = 8;
+/** A server-authored dismantle policy: a few rows per rarity and gear class. */
+inline constexpr std::size_t kDismantleRewardPolicyCapacity = 32;
 
-/** One profile material credited when ordinary character gear is dismantled. */
+/** Gear classes a dismantle payout row can be limited to. */
+enum class DismantleGearClass : std::uint8_t {
+    weapon = 1U << 0U,
+    armor = 1U << 1U,
+};
+
+/** Whether a payout row wants the dismantled item masterworked. */
+enum class DismantleMasterworkFilter : std::uint8_t {
+    any = 0,
+    masterworked = 1,
+    notMasterworked = 2,
+};
+
+/**
+ * One profile material credited when ordinary character gear is dismantled. Every filter left at
+ * its "any" value matches every item; the payout is the sum of the matching rows.
+ */
 struct DismantleRewardPolicy {
     std::uint32_t definitionHash{};
     std::int32_t quantity{};
+    /** Bit (1 << tier) per native tier 1-5 the row pays for; 0 pays for every tier. */
+    std::uint8_t tierMask{};
+    /** DismantleGearClass bits the row pays for; 0 pays for both. */
+    std::uint8_t classMask{};
+    DismantleMasterworkFilter masterwork{DismantleMasterworkFilter::any};
 };
 
+/** @return True when both rows are the same row: same material under the same filters. */
+[[nodiscard]] constexpr bool
+same_dismantle_policy_key(const DismantleRewardPolicy& left,
+                          const DismantleRewardPolicy& right) noexcept {
+    return left.definitionHash == right.definitionHash && left.tierMask == right.tierMask
+           && left.classMask == right.classMask && left.masterwork == right.masterwork;
+}
+
 /** Stable character race values authored independently of package definition mappings. */
 enum class CharacterRace : std::uint8_t {
     /** Wire value 0 is a Human character. */
@@ -66,6 +95,38 @@ 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 it reaches; a bundled pick can 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{};
@@ -84,19 +145,11 @@ 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. Unverified:
+     * defaulted all-set, assuming 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. */

+ 15 - 1
Sunrise/src/state/account/inventory/inventory_state.h

@@ -83,11 +83,25 @@ struct Item {
     std::uint32_t definitionHash{};
     std::int32_t level{};
     std::int32_t quantity{};
-    /** Rising per-character generation assigned whenever this item changes inventory rows. */
+    /**
+     * Rising per-character generation assigned whenever this item changes inventory rows. The
+     * Client also orders a bucket's grid by it, so an equip swap hands the displaced item the
+     * clicked item's prior serial to keep it in the clicked cell.
+     */
     std::int32_t mutationSerial{};
     /** Native accumulated item-state bits such as the finisher favorite marker. */
     std::uint32_t flags{};
     Sockets sockets;
+    /**
+     * Selected ability-node socket entries. Only meaningful on a subclass. Kept on the item, not
+     * the character, so each owned subclass remembers its own picks. Defaults match
+     * state::kDefault*AbilityEntry, 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. */

+ 12 - 3
Sunrise/src/state/account/settings/settings_state.cpp

@@ -35,6 +35,10 @@ constexpr Range<std::int8_t> kAudioVolume{0, 10};
 
 /** Brightness stores the 7 menu choices, 0 to 6. */
 constexpr Range<std::int8_t> kBrightness{0, 6};
+/** VSync stores the DXGI presentation interval from 0 to 4. */
+constexpr Range<std::uint8_t> kVerticalSyncInterval{0, 4};
+/** Field of view exposes the 55 through 105 degree range. */
+constexpr Range<std::int32_t> kFieldOfView{55, 105};
 /** The first unidentified calibration field uses the working renderer fallback. */
 constexpr float kCalibrationPrimary = 10000.0F;
 /** The second unidentified calibration field uses the working renderer alpha. */
@@ -116,6 +120,8 @@ template <typename Value, std::size_t Count>
  */
 [[nodiscard]] bool valid_display(const Display& value) noexcept {
     return within(value.brightness, kBrightness) && within(value.hdrMode, kTwoChoiceSelector)
+           && within(value.verticalSyncInterval, kVerticalSyncInterval)
+           && within(value.fieldOfView, kFieldOfView)
            && value.calibrationPrimary == kCalibrationPrimary
            && value.calibrationAlpha == kCalibrationAlpha;
 }
@@ -155,9 +161,12 @@ template <typename Value, std::size_t Count>
 
 /** Checks a whole account-settings object against the supported menu domains. */
 bool valid(const AccountSettings& value) noexcept {
-    return value.configured && value.keyBindings.configured && valid_controls(value.controls)
-           && valid_audio(value.audio) && valid_display(value.display)
-           && valid_interface(value.interface) && valid_social(value.social);
+    const bool validBindingSource = value.keyBindingSource == KeyBindingSource::account
+                                    || value.keyBindingSource == KeyBindingSource::computer;
+    return value.configured && value.keyBindings.configured && validBindingSource
+           && valid_controls(value.controls) && valid_audio(value.audio)
+           && valid_display(value.display) && valid_interface(value.interface)
+           && valid_social(value.social);
 }
 
 } // namespace sunrise::state::account::settings

+ 11 - 0
Sunrise/src/state/account/settings/settings_state.h

@@ -9,6 +9,12 @@ namespace sunrise::state::account::settings {
 /** The account record's one-time audio migration treats version 8 as finished. */
 inline constexpr std::int8_t kCompletedAudioMigrationVersion = 8;
 
+/** Selects account-replicated or computer-local keyboard and mouse bindings. */
+enum class KeyBindingSource : std::uint8_t {
+    account,
+    computer,
+};
+
 /** Authored controller and mouse input preferences. */
 struct Controls {
     std::int8_t buttonLayout{};
@@ -48,6 +54,10 @@ struct Display {
     std::int8_t brightness{};
     bool showFps{};
     std::int8_t hdrMode{};
+    /** Presentation interval: 0 disables VSync, 1 to 4 wait that many refreshes. */
+    std::uint8_t verticalSyncInterval{};
+    /** Horizontal field of view in degrees. */
+    std::int32_t fieldOfView{85};
     /** First unidentified renderer-calibration scalar. */
     float calibrationPrimary{};
     /** Second unidentified renderer-calibration scalar. */
@@ -95,6 +105,7 @@ struct AccountSettings {
     Display display;
     Interface interface;
     Social social;
+    KeyBindingSource keyBindingSource{KeyBindingSource::computer};
     bindings::KeyBindings keyBindings;
     /** True only when a settings object was supplied by configuration. */
     bool configured{};

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

+ 10 - 7
Sunrise/src/state/build_data/cache/records/cache_record_codec.cpp

@@ -59,24 +59,27 @@ bool encode(const items::Definition& value, ItemRecord& record) noexcept {
         value.definitionHash,
         value.definitionIndex,
         value.bucketId,
-        kReservedFieldValue,
+        value.tier,
         value.insertionMaterialRequirementSetIndex,
         value.enabledMaterialRequirementSetIndex,
+        value.plugCategoryHash,
+        value.rollSetIndex,
+        value.linkedPlugIndex,
     };
     return true;
 }
 
-/** Decodes one installed-build item mapping after checking its padding. */
+/** Decodes one installed-build item mapping. */
 bool decode(const ItemRecord& record, items::Definition& value) noexcept {
-    value = {};
-    if (record.reserved != kReservedFieldValue) {
-        return false;
-    }
     value = {record.definitionHash,
              record.definitionIndex,
              record.bucketId,
              record.insertionMaterialRequirementSetIndex,
-             record.enabledMaterialRequirementSetIndex};
+             record.enabledMaterialRequirementSetIndex,
+             record.tier,
+             record.plugCategoryHash,
+             record.rollSetIndex,
+             record.linkedPlugIndex};
     return true;
 }
 

+ 7 - 4
Sunrise/src/state/build_data/cache/records/format.h

@@ -29,7 +29,7 @@ inline constexpr std::array<char, 8> kCacheMagic{'S', 'U', 'N', 'R', 'I', 'S', '
  * Bump it when a stored shape changes, and when the extraction filling it changes what it writes.
  * A cached row survives a code change, so a corrected walk keeps publishing the old rows.
  */
-inline constexpr std::uint32_t kCacheFormatVersion = 43;
+inline constexpr std::uint32_t kCacheFormatVersion = 44;
 /** Signed -1 on disk means there is no equipment slot. */
 inline constexpr std::int8_t kAbsentEquipmentSlot = -1;
 /** The standard 64-bit FNV-1a offset basis starts the payload checksum. */
@@ -107,12 +107,15 @@ struct ItemRecord {
     std::uint32_t definitionHash{};
     std::uint16_t definitionIndex{};
     std::uint8_t bucketId{items::kUnresolvedBucketId};
-    /** Must be zero, so the packed item row matches across compilers. */
-    std::uint8_t reserved{};
+    /** Native rarity ladder byte; 0 outside the ladder. */
+    std::uint8_t tier{};
     std::uint16_t insertionMaterialRequirementSetIndex{
         items::kUnavailableMaterialRequirementSetIndex};
     std::uint16_t enabledMaterialRequirementSetIndex{
         items::kUnavailableMaterialRequirementSetIndex};
+    std::uint32_t plugCategoryHash{};
+    std::uint16_t rollSetIndex{};
+    std::uint16_t linkedPlugIndex{items::kUnavailableLinkedPlugIndex};
 };
 
 /** Disk form of one material charged by a native Collections acquisition. */
@@ -466,7 +469,7 @@ static_assert(sizeof(NamedRecord)
               == content::kDefinitionNameCapacity + 2 * sizeof(std::uint16_t)
                      + 2 * sizeof(std::uint32_t));
 static_assert(sizeof(ItemRecord)
-              == sizeof(std::uint32_t) + 3 * sizeof(std::uint16_t) + 2 * sizeof(std::uint8_t));
+              == 2 * sizeof(std::uint32_t) + 5 * sizeof(std::uint16_t) + 2 * sizeof(std::uint8_t));
 static_assert(sizeof(MaterialRequirementRecord)
               == sizeof(std::uint32_t) + 2 * sizeof(std::uint16_t) + 2 * sizeof(std::uint8_t));
 static_assert(sizeof(CollectibleRecord)

+ 33 - 0
Sunrise/src/state/build_data/items/item_catalog.h

@@ -12,6 +12,8 @@ inline constexpr std::size_t kDefinitionCapacity = 32768;
 inline constexpr std::uint8_t kUnresolvedBucketId = 0xFF;
 /** A plug with no authored insertion/enabled price carries all set-index bits. */
 inline constexpr std::uint16_t kUnavailableMaterialRequirementSetIndex = 0xFFFFU;
+/** An item-index field naming no row carries all bits set. */
+inline constexpr std::uint16_t kUnavailableLinkedPlugIndex = 0xFFFFU;
 
 /** One installed-build item identity, used to look up authored definition hashes. */
 struct Definition {
@@ -20,6 +22,37 @@ struct Definition {
     std::uint8_t bucketId{kUnresolvedBucketId};
     std::uint16_t insertionMaterialRequirementSetIndex{kUnavailableMaterialRequirementSetIndex};
     std::uint16_t enabledMaterialRequirementSetIndex{kUnavailableMaterialRequirementSetIndex};
+    /** Native rarity ladder: 1 common through 5 exotic; 0 outside the ladder. */
+    std::uint8_t tier{};
+    /** Plug category the definition declares, or 0 when it declares none. */
+    std::uint32_t plugCategoryHash{};
+    /**
+     * Ordinal of the server roll set that grants this plug in place of a socket's action plug;
+     * kNoRollSet when the plug is socketed directly, kForeignRollSet when the service granted it
+     * by other means.
+     */
+    std::uint16_t rollSetIndex{};
+    /** Item index of the plug this one stands for, or kUnavailableLinkedPlugIndex when it stands
+     * alone. */
+    std::uint16_t linkedPlugIndex{kUnavailableLinkedPlugIndex};
+};
+
+/** Roll-set ordinals outside the rolled ladder. */
+inline constexpr std::uint16_t kNoRollSet = 0;
+inline constexpr std::uint16_t kForeignRollSet = 0xFFFFU;
+/** @return True when a definition is a plug the service rolled from a socket action. */
+[[nodiscard]] constexpr bool rolled_result(const Definition& definition) noexcept {
+    return definition.rollSetIndex != kNoRollSet && definition.rollSetIndex != kForeignRollSet;
+}
+
+/** Native item tiers, as the definition's rarity byte encodes them. */
+enum class Tier : std::uint8_t {
+    none = 0,
+    common = 1,
+    uncommon = 2,
+    rare = 3,
+    legendary = 4,
+    exotic = 5,
 };
 
 /** Clears every generated item mapping. */

+ 3 - 0
Sunrise/src/state/build_data/items/socket_plugs/definition.h

@@ -36,4 +36,7 @@ struct Pool {
 /** Native item-definition index of one allowed plug. */
 using Member = std::uint16_t;
 
+/** Called once per pool member; returning false stops the walk. */
+using MemberVisitor = bool (*)(void* context, Member plugDefinitionIndex) noexcept;
+
 } // namespace sunrise::state::build_data::items::socket_plugs

+ 18 - 9
Sunrise/src/state/build_data/items/socket_plugs/socket_plug_build_data_runtime.cpp

@@ -65,13 +65,24 @@ bool is_socket_plug_allowed(std::uint16_t itemDefinitionIndex,
            && items::socket_plugs::allowed(itemDefinitionIndex, lane, plugDefinitionIndex);
 }
 
+/** Walks one lane's pool once the whole relation is in State. */
+bool visit_socket_plug_pool(std::uint16_t itemDefinitionIndex,
+                            std::uint8_t lane,
+                            items::socket_plugs::MemberVisitor visitor,
+                            void* context) noexcept {
+    return socket_plug_rules_ready()
+           && items::socket_plugs::visit_pool(itemDefinitionIndex, lane, visitor, context);
+}
+
+/** Answers pool membership anywhere in the installed relation. */
+bool is_socket_plug_pooled(std::uint16_t plugDefinitionIndex) noexcept {
+    return socket_plug_rules_ready() && items::socket_plugs::contains(plugDefinitionIndex);
+}
+
 /**
  * Answers whether applying one plug spends a stack the account has to hold.
- *
- * Only a shader is spent: an ornament stays owned once applied. The plug also has to be
- * one an account can come to own, which means Collections can grant it. A socket's default plug
- * is not in Collections and belongs to no stack, so clearing a socket back to it costs nothing
- * and is always available, whatever the item's own factory plug happens to be.
+ * Only a shader is spent; an ornament stays owned. The plug must also be one Collections can
+ * grant, so clearing a socket back to its default costs nothing and is always available.
  */
 bool is_consumed_on_apply(std::uint16_t itemDefinitionIndex, std::uint8_t bucketId) noexcept {
     return bucketId == kShaderBucketId && collectibles::grants_item(itemDefinitionIndex);
@@ -79,10 +90,8 @@ bool is_consumed_on_apply(std::uint16_t itemDefinitionIndex, std::uint8_t bucket
 
 /**
  * Answers whether one installed profile row is a materializable socket action source.
- *
- * Only ornaments and shaders qualify. Mods are bucket 37 and sit outside this model on purpose:
- * they are permanent unlocks rather than stacks, and nothing in the account records which are
- * unlocked, so no answer here could say whether it holds one.
+ * Only ornaments and shaders qualify. Mods are bucket 37 and sit outside this model: they are
+ * permanent unlocks, and nothing in the account records which are unlocked.
  */
 bool is_profile_action_source(std::uint16_t itemDefinitionIndex, std::uint8_t bucketId) noexcept {
     items::details::Definition detail{};

+ 31 - 0
Sunrise/src/state/build_data/items/socket_plugs/socket_plug_catalog.cpp

@@ -112,6 +112,37 @@ bool allowed(std::uint16_t itemDefinitionIndex,
     return std::binary_search(range.begin(), range.end(), plugDefinitionIndex);
 }
 
+/** Walks the members of one lane's pool under a shared hold. */
+bool visit_pool(std::uint16_t itemDefinitionIndex,
+                std::uint8_t lane,
+                MemberVisitor visitor,
+                void* context) noexcept {
+    if (lane >= kLaneCapacity || visitor == nullptr) {
+        return false;
+    }
+    const Lock::Shared guard(g_lock);
+    const auto rules = g_rules.rows();
+    const auto pools = g_pools.rows();
+    const auto members = g_members.rows();
+    const Rule key{itemDefinitionIndex, lane, 0, 0};
+    const auto found = std::lower_bound(rules.begin(), rules.end(), key, rule_less);
+    if (found == rules.end() || found->itemDefinitionIndex != itemDefinitionIndex
+        || found->lane != lane || found->poolIndex >= pools.size()) {
+        return false;
+    }
+    const Pool& pool = pools[found->poolIndex];
+    if (pool.memberOffset > members.size()
+        || pool.memberCount > members.size() - pool.memberOffset) {
+        return false;
+    }
+    for (const Member member : members.subspan(pool.memberOffset, pool.memberCount)) {
+        if (!visitor(context, member)) {
+            return false;
+        }
+    }
+    return true;
+}
+
 /** Answers whether one definition occurs in any installed ordinary-socket plug pool. */
 bool contains(Member plugDefinitionIndex) noexcept {
     const Lock::Shared guard(g_lock);

+ 9 - 0
Sunrise/src/state/build_data/items/socket_plugs/socket_plug_catalog.h

@@ -41,6 +41,15 @@ void clear() noexcept;
  */
 [[nodiscard]] bool contains(Member plugDefinitionIndex) noexcept;
 
+/**
+ * Walks every plug one exact ordinary socket lane accepts.
+ * @return True when the lane has a pool and the visitor saw every member.
+ */
+[[nodiscard]] bool visit_pool(std::uint16_t itemDefinitionIndex,
+                              std::uint8_t lane,
+                              MemberVisitor visitor,
+                              void* context) noexcept;
+
 /** Copies the complete relation while holding its single shared lock. */
 [[nodiscard]] bool snapshot(std::span<Rule> rules,
                             std::size_t& ruleCount,

+ 66 - 6
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"
@@ -86,12 +87,9 @@ publish_item_definitions(std::span<const items::Definition> definitions) noexcep
 
 /**
  * Finds one installed item by the native dense definition index used by Collections requests.
-
- * * @param definitionIndex Native item-definition row index.
- * @param definition Receives the
- * exact installed mapping.
- * @return True when the complete table is ready and contains the
- * requested row.
+ * @param definitionIndex Native item-definition row index.
+ * @param definition Receives the exact installed mapping.
+ * @return True when the complete table is ready and contains the requested row.
  */
 [[nodiscard]] bool find_item_definition_index(std::uint16_t definitionIndex,
                                               items::Definition& definition) noexcept;
@@ -179,6 +177,18 @@ publish_socket_plug_rules(std::span<const items::socket_plugs::Rule> rules,
                                           std::uint8_t lane,
                                           std::uint16_t plugDefinitionIndex) noexcept;
 
+/**
+ * Walks every plug one exact ordinary socket lane accepts. Missing relations fail closed.
+ * @return True when the lane has a pool and the visitor saw every member.
+ */
+[[nodiscard]] bool visit_socket_plug_pool(std::uint16_t itemDefinitionIndex,
+                                          std::uint8_t lane,
+                                          items::socket_plugs::MemberVisitor visitor,
+                                          void* context) noexcept;
+
+/** @return True when one plug definition occurs in any installed ordinary-socket plug pool. */
+[[nodiscard]] bool is_socket_plug_pooled(std::uint16_t plugDefinitionIndex) noexcept;
+
 /**
  * Answers whether one profile definition needs an item-instance resident so the native socket
  * action route can materialize it. Only stackable installed socket plugs in the supported mod and
@@ -241,6 +251,41 @@ 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.
+ * Never part of the on-disk content cache: it is small and cheap to recompute, so a warm boot that
+ * skips re-extraction must not leave it empty for the whole session.
+ */
+[[nodiscard]] bool socket_entry_buckets_ready() noexcept;
+
+/**
+ * Publishes every socket-entry list's resolved per-entry ability-bucket destinations.
+ * A derived cache of static content, 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;
 
@@ -318,6 +363,21 @@ 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 as one dense run per class, in native
+ * definition-index order, so the run holding a known member gives every other member.
+ * @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;
 

+ 52 - 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,22 @@ 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 updates this one domain in memory.
+    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 +269,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 {
 

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

@@ -0,0 +1,26 @@
+#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 table position does not say which slot it fills; only its selector chain does. Computed
+ * once during content extraction so runtime code can route a click without re-reading 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

+ 41 - 35
Sunrise/src/state/gameplay/physics/physics_state_runtime.cpp

@@ -16,69 +16,75 @@ constexpr std::uint8_t kWireIncarnationMask = kWireIncarnationCount - 1;
 /** The first process-local handle generation follows the cleared zero sentinel. */
 constexpr std::uint64_t kFirstHandleGeneration = 1;
 
+// No member initializers below: MSVC exhausts its heap on the 4 MB g_storage aggregate.
+
 /** One fixed allocator record. Exact owners live in context or peer ticket tables. */
 struct SlotRecord final {
-    std::uint64_t actorId{};
-    std::uint64_t allocationGeneration{};
-    std::uint8_t allocationSequence{};
-    std::uint8_t incarnation{};
-    std::uint8_t incarnationUseCount{};
-    LeaseOwner owner{LeaseOwner::free};
-    Lifecycle lifecycle{Lifecycle::absent};
-    QuarantineReason quarantineReason{QuarantineReason::none};
+    std::uint64_t actorId;
+    std::uint64_t allocationGeneration;
+    std::uint8_t allocationSequence;
+    std::uint8_t incarnation;
+    std::uint8_t incarnationUseCount;
+    LeaseOwner owner;
+    Lifecycle lifecycle;
+    QuarantineReason quarantineReason;
 };
 
 /** One exact allocation retained by one peer replica. */
 struct PeerAllocationReferenceRecord final {
-    Allocation allocation{};
-    bool occupied{};
+    Allocation allocation;
+    bool occupied;
 };
 
 /** One exact packet or replay reference retained by one peer replica. */
 struct ContributionTicketRecord final {
-    Allocation allocation{};
-    std::uint64_t generation{};
-    ContributionKind kind{ContributionKind::packet};
-    bool occupied{};
+    Allocation allocation;
+    std::uint64_t generation;
+    ContributionKind kind;
+    bool occupied;
 };
 
 /** One exact dependency or command reference retained by the activity. */
 struct HostReferenceTicketRecord final {
-    Allocation allocation{};
-    std::uint64_t generation{};
-    ReferenceKind kind{ReferenceKind::dependency};
-    bool occupied{};
+    Allocation allocation;
+    std::uint64_t generation;
+    ReferenceKind kind;
+    bool occupied;
 };
 
 /** One peer-owned view with bounded allocation references and tickets. */
 struct PeerReplicaRecord final {
-    std::array<PeerAllocationReferenceRecord, kPeerAllocationReferenceCapacity> references{};
-    std::array<ContributionTicketRecord, kContributionTicketCapacity> tickets{};
-    ViewKey view{};
-    std::uint64_t generation{};
-    bool occupied{};
+    std::array<PeerAllocationReferenceRecord, kPeerAllocationReferenceCapacity> references;
+    std::array<ContributionTicketRecord, kContributionTicketCapacity> tickets;
+    ViewKey view;
+    std::uint64_t generation;
+    bool occupied;
 };
 
 /** One activity identity, its allocator, and every peer-owned view. */
 struct ContextRecord final {
-    std::array<SlotRecord, kEntitySlotCount> slots{};
-    std::array<PeerReplicaRecord, kPeerReplicaCapacity> peers{};
-    std::array<HostReferenceTicketRecord, kHostReferenceTicketCapacity> hostTickets{};
-    ActivityReplicaContext context{};
-    std::uint64_t generation{};
-    std::uint64_t reuseEpoch{};
-    bool occupied{};
+    std::array<SlotRecord, kEntitySlotCount> slots;
+    std::array<PeerReplicaRecord, kPeerReplicaCapacity> peers;
+    std::array<HostReferenceTicketRecord, kHostReferenceTicketCapacity> hostTickets;
+    ActivityReplicaContext context;
+    std::uint64_t generation;
+    std::uint64_t reuseEpoch;
+    bool occupied;
 };
 
 /** State-owned fixed storage and its publication lock. */
 struct Storage final {
-    SRWLOCK lock{SRWLOCK_INIT};
-    std::array<ContextRecord, kReplicaContextCapacity> contexts{};
-    std::uint64_t nextHandleGeneration{kFirstHandleGeneration};
-    bool ready{};
+    SRWLOCK lock;
+    std::array<ContextRecord, kReplicaContextCapacity> contexts;
+    std::uint64_t nextHandleGeneration;
+    bool ready;
 };
 
-Storage g_storage;
+// Designated so a reordered member cannot silently shift these values.
+Storage g_storage{.lock = SRWLOCK_INIT,
+                  .contexts = {},
+                  .nextHandleGeneration = kFirstHandleGeneration,
+                  .ready = false};
 
 /** Clears all records but preserves the process-local handle sequence and Windows lock. */
 void clear_storage() noexcept {

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

+ 54 - 23
Sunrise/src/state/runtime/runtime.h

@@ -15,6 +15,48 @@ namespace sunrise::state {
  */
 [[nodiscard]] bool ensure_profile_item_identities() noexcept;
 
+/**
+ * Grants each character the other 2 subclasses of its equipped subclass's class, placing missing
+ * ones into unequipped inventory with native socket defaults. Idempotent: one already equipped or
+ * already in inventory is left alone.
+ * @return True when every such character holds its whole class, or there was nothing to check.
+ */
+[[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
+ * picks 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,
@@ -163,7 +205,10 @@ struct PendingSocketPlug {
     /** Equipment semantic index or dense inventory index, selected by `targetEquipped`. */
     std::size_t itemIndex{};
     std::uint16_t targetDefinitionIndex{};
+    /** Plug that lands in the lane. Differs from the request only for a rolled socket. */
     std::uint16_t plugDefinitionIndex{};
+    /** Plug the Client asked for, which decides the pool check and the material charge. */
+    std::uint16_t requestedPlugDefinitionIndex{};
     std::uint16_t materialRequirementSetIndex{0xFFFFU};
     std::uint8_t socketLane{};
     std::uint8_t targetBucketId{};
@@ -242,12 +287,9 @@ void shutdown() noexcept;
 /**
  * Prepares an equip operation for one unequipped instance on the selected character.
  * An occupied slot is swapped; an empty semantic slot receives the requested item directly.
- *
  * @param requestedInstanceSoid Unequipped item instance selected by the Client.
- * @param mutation
- * Gets the checked after-image without changing account State.
- * @return True when the instance is
- * owned, unequipped, and maps to one supported native equipment slot.
+ * @param mutation Gets the checked after-image without changing account State.
+ * @return True when the instance is owned, unequipped, and maps to one native equipment slot.
  */
 [[nodiscard]] bool prepare_equipment_swap(std::uint64_t requestedInstanceSoid,
                                           PendingEquipmentSwap& mutation) noexcept;
@@ -339,14 +381,11 @@ commit_profile_item_acquisition(PendingProfileItemAcquisition& mutation) noexcep
 
 /**
  * Prepares removal of one unequipped instance from the selected character.
- *
  * The authored inventory prefix is compacted. Any surviving item whose installed native row
  * changes receives a fresh mutation generation. Equipped items are never accepted.
- *
  * @param instanceSoid Unequipped item-instance key selected by the Client.
  * @param mutation Gets checked before/after images without changing account State.
- * @return True when the instance is uniquely owned by the selected character and both loadouts
- * resolve completely.
+ * @return True when the selected character uniquely owns it and both loadouts resolve.
  */
 [[nodiscard]] bool prepare_item_dismantle(std::uint64_t instanceSoid,
                                           PendingItemDismantle& mutation) noexcept;
@@ -366,11 +405,8 @@ commit_profile_item_acquisition(PendingProfileItemAcquisition& mutation) noexcep
 
 /**
  * Prepares one exact opcode-903 ordinary-socket selection on a selected-character item.
- *
- * The target may be equipped or unequipped. Native defaults are first materialized into a complete
- * authored socket block, then only the requested lane changes. Item identity, native row,
- * quantity, level, and mutation generation remain byte-for-byte stable.
- *
+ * The target may be equipped or unequipped. Native defaults are materialized into a complete
+ * authored socket block, then only the requested lane changes; everything else stays byte-stable.
  * @param targetInstanceSoid Selected-character item-instance key named by the Client.
  * @param socketLane Zero-based ordinary socket lane.
  * @param plugDefinitionIndex Installed plug-definition row selected by the Client.
@@ -384,18 +420,13 @@ commit_profile_item_acquisition(PendingProfileItemAcquisition& mutation) noexcep
 
 /**
  * Prepares one ordinary-socket selection for an exact character-screen item selector.
- *
- * The resolved selected-character instance is passed through the same checked transition as an
- * instance-addressed action, so acquired and unequipped items do not depend on a coincidental
- * menu-row ordinal.
- *
+ * The resolved instance runs through the same checked transition as an instance-addressed action,
+ * so acquired and unequipped items do not depend on a coincidental menu-row ordinal.
  * @param instanceIdentityToken Item-instance identity decoded from the opcode-1901 selector.
- * @param requestedSocketLane Native socket action lane; the installed compatibility relation
- * resolves the target's exact physical lane.
+ * @param requestedSocketLane Native socket action lane; compatibility resolves the physical lane.
  * @param plugDefinitionIndex Installed plug-definition row selected by the Client.
  * @param mutation Gets the checked before/after images without changing account State.
- * @return True when the location has one matching item, the plug resolves to exactly the
- * requested compatible ordinary socket lane, and the socket transition is valid.
+ * @return True when one item matches, the plug resolves to that lane, and the transition is valid.
  */
 [[nodiscard]] bool prepare_character_selector_socket_plug(std::uint64_t instanceIdentityToken,
                                                           std::uint8_t requestedSocketLane,

+ 289 - 15
Sunrise/src/state/runtime/state_account_dismantle_staging.cpp

@@ -1,13 +1,15 @@
-/** Dismantle staging: the payout it credits and the after-image it is committed against. */
+/** Dismantle staging: the payout it credits and the after-image it is committed against. */
 
 #include <Windows.h>
 
 #include <algorithm>
 #include <array>
+#include <bit>
 #include <cstddef>
 #include <cstdint>
 #include <cstdio>
 #include <limits>
+#include <optional>
 #include <string_view>
 #include <utility>
 
@@ -17,6 +19,7 @@
 #include "runtime.h"
 #include "state.h"
 #include "state_account_transaction_helpers.h"
+#include "state_rolled_socket_plugs.h"
 #include "storage/internal.h"
 
 namespace sunrise::state {
@@ -27,9 +30,6 @@ namespace item_details = build_data::items::details;
 namespace inventory_buckets = build_data::inventory::buckets;
 namespace family4_loadout = middleware::datagen::family4::loadout;
 
-/** Equipment slots 0-2 are weapons and 3-7 are class-specific armor. */
-constexpr std::uint8_t kGearEquipmentSlotCount = 8;
-
 /** Writes one exhaustive item-dismantle transaction checkpoint. */
 void report_dismantle(std::string_view stage,
                       std::string_view result,
@@ -70,16 +70,232 @@ void report_dismantle(std::string_view stage,
     }
 }
 
+/** Records how the dismantled item was classified and how many payout materials matched. */
+void report_dismantle_reward_match(std::uint32_t definitionHash,
+                                   std::uint8_t tier,
+                                   std::uint8_t gearClass,
+                                   bool isMasterworked,
+                                   std::size_t materialCount) noexcept {
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count = std::snprintf(line.data(),
+                                    line.size(),
+                                    "ev=dismantle stage=reward result=ok reason=matched "
+                                    "definition_hash=0x%08X tier=%u gear_class=%u masterworked=%u "
+                                    "materials=%zu",
+                                    definitionHash,
+                                    static_cast<unsigned>(tier),
+                                    static_cast<unsigned>(gearClass),
+                                    isMasterworked ? 1U : 0U,
+                                    materialCount);
+    if (count > 0) {
+        core::log::write(core::log::Channel::state,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+}
+
+/** Records one payout row that could not be credited, so a silent zero payout is visible. */
+void report_dismantle_reward_dropped(std::string_view reason,
+                                     std::uint32_t definitionHash,
+                                     std::int32_t policyQuantity,
+                                     std::int32_t previousQuantity,
+                                     std::int32_t maxStackSize) noexcept {
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count = std::snprintf(line.data(),
+                                    line.size(),
+                                    "ev=dismantle stage=reward result=dropped reason=%.*s "
+                                    "definition_hash=0x%08X policy_quantity=%d held=%d "
+                                    "max_stack=%d",
+                                    static_cast<int>(reason.size()),
+                                    reason.data(),
+                                    definitionHash,
+                                    policyQuantity,
+                                    previousQuantity,
+                                    maxStackSize);
+    if (count > 0) {
+        core::log::write(core::log::Channel::state,
+                         core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+}
+
+/**
+ * @return The gear class one native equipment slot belongs to, or 0 outside the gear the
+ *         supported client pays materials for. Native slot numbers are not the semantic enum
+ *         order (kinetic is 7, energy 8, heavy 9), so the check goes through the semantic map.
+ */
+[[nodiscard]] std::uint8_t gear_class_of(std::uint8_t nativeSlot) noexcept {
+    using EquipmentSlot = authored_inventory::EquipmentSlot;
+    std::size_t semanticIndex = authored_inventory::kEquipmentSlotCount;
+    if (!semantic_equipment_slot(nativeSlot, semanticIndex)) {
+        return 0;
+    }
+    switch (static_cast<EquipmentSlot>(semanticIndex)) {
+    case EquipmentSlot::kinetic:
+    case EquipmentSlot::energy:
+    case EquipmentSlot::heavy:
+        return static_cast<std::uint8_t>(DismantleGearClass::weapon);
+    case EquipmentSlot::helmet:
+    case EquipmentSlot::gauntlets:
+    case EquipmentSlot::chest:
+    case EquipmentSlot::legs:
+    case EquipmentSlot::classItem:
+        return static_cast<std::uint8_t>(DismantleGearClass::armor);
+    default:
+        return 0;
+    }
+}
+
+/** Per-stat-row tally of one lane's pool, enough to recognise a masterwork tier ladder. */
+struct LadderTally {
+    /** A stat row is one byte, so this covers every row a definition can name. */
+    static constexpr std::size_t kRowCount = 256;
+    /** Distinct values one row can track, which is the width of the seen mask. */
+    static constexpr std::size_t kValueBits = 64;
+    std::array<std::uint16_t, kRowCount> members{};
+    std::array<std::int32_t, kRowCount> greatest{};
+    /** Bit per distinct value below kValueBits; a larger value marks the row unusable. */
+    std::array<std::uint64_t, kRowCount> seen{};
+    std::array<bool, kRowCount> overflow{};
+};
+
+/** Folds one pool member's stats into the tally. */
+bool tally_member(void* context, std::uint16_t plugIndex) noexcept {
+    auto& tally = *static_cast<LadderTally*>(context);
+    item_details::Definition detail{};
+    if (!build_data::find_configured_item_detail(plugIndex, detail)
+        || detail.statCount > detail.stats.size()) {
+        return true;
+    }
+    for (std::size_t index = 0; index < detail.statCount; ++index) {
+        const item_details::Stat& stat = detail.stats[index];
+        ++tally.members[stat.row];
+        tally.greatest[stat.row] = (std::max)(tally.greatest[stat.row], stat.value);
+        if (stat.value < 0 || stat.value >= static_cast<std::int32_t>(LadderTally::kValueBits)) {
+            tally.overflow[stat.row] = true;
+        } else {
+            tally.seen[stat.row] |= 1ULL << static_cast<unsigned>(stat.value);
+        }
+    }
+    return true;
+}
+
+/**
+ * @return True when the plug sits high enough on its lane's masterwork ladder, a pool whose
+ *         plugs share one stat row with a distinct value each. Assumed: the weapon-at-top and
+ *         armor-at-halfway cut-offs come from service behaviour, not from the Client.
+ */
+[[nodiscard]] bool on_masterwork_ladder(const item_details::Definition& target,
+                                        std::uint8_t lane,
+                                        const item_details::Definition& plug,
+                                        bool weapon) noexcept {
+    // Below three rungs a pool cannot be told apart from a mod pool that happens not to repeat.
+    constexpr std::uint16_t kMinimumLadderRungs = 3;
+    if (plug.statCount == 0 || plug.statCount > plug.stats.size()) {
+        return false;
+    }
+    LadderTally tally{};
+    if (!build_data::visit_socket_plug_pool(target.definitionIndex, lane, &tally_member, &tally)) {
+        return false;
+    }
+    // The ladder row is the plug's stat row most of the pool shares.
+    std::size_t ladderRow = LadderTally::kRowCount;
+    for (std::size_t index = 0; index < plug.statCount; ++index) {
+        const std::uint8_t row = plug.stats[index].row;
+        if (ladderRow == LadderTally::kRowCount || tally.members[row] > tally.members[ladderRow]) {
+            ladderRow = row;
+        }
+    }
+    if (ladderRow >= LadderTally::kRowCount || tally.overflow[ladderRow]
+        || tally.members[ladderRow] < kMinimumLadderRungs
+        || std::popcount(tally.seen[ladderRow]) != tally.members[ladderRow]) {
+        return false;
+    }
+    std::int32_t value = 0;
+    for (std::size_t index = 0; index < plug.statCount; ++index) {
+        if (plug.stats[index].row == ladderRow) {
+            value = plug.stats[index].value;
+        }
+    }
+    const std::int32_t top = tally.greatest[ladderRow];
+    return weapon ? value == top : value * 2 >= top;
+}
+
+/**
+ * @return True when the item is masterworked for the payout: a lane holds a rolled result plug
+ *         (a Year-1 masterwork), or a plug high enough on its lane's tier ladder. An item still
+ *         on its native defaults is read through the definition's initial plugs, since
+ *         default-equipped gear can ship masterworked.
+ */
+[[nodiscard]] bool masterworked(const authored_inventory::Item& item,
+                                const item_details::Definition& detail,
+                                bool weapon) noexcept {
+    const bool authored = item.sockets.policy == authored_inventory::SocketPolicy::authored;
+    for (std::size_t lane = 0;
+         lane < detail.ordinarySocketCount && lane < authored_inventory::kPlugCapacity;
+         ++lane) {
+        build_data::items::Definition plug{};
+        if (authored) {
+            const std::optional<std::uint32_t>& hash = item.sockets.plugs[lane];
+            if (!hash.has_value() || !build_data::find_item_definition_hash(*hash, plug)
+                || plug.definitionHash != *hash) {
+                continue;
+            }
+        } else {
+            const std::uint16_t plugIndex = detail.initialPlugIndices[lane];
+            if (plugIndex == item_details::kUnavailableItemIndex
+                || !build_data::find_item_definition_index(plugIndex, plug)
+                || plug.definitionIndex != plugIndex) {
+                continue;
+            }
+        }
+        if (is_rolled_result(plug.definitionHash)) {
+            return true;
+        }
+        item_details::Definition plugDetail{};
+        if (build_data::find_configured_item_detail(plug.definitionIndex, plugDetail)
+            && plugDetail.definitionHash == plug.definitionHash
+            && on_masterwork_ladder(detail, static_cast<std::uint8_t>(lane), plugDetail, weapon)) {
+            return true;
+        }
+    }
+    return false;
+}
+
+/** @return True when one policy row pays for the dismantled item. */
+[[nodiscard]] bool policy_matches(const DismantleRewardPolicy& policy,
+                                  std::uint8_t tier,
+                                  std::uint8_t gearClass,
+                                  bool isMasterworked) noexcept {
+    // The mask is eight bits wide, so a manifest tier past it cannot be selected. Tested before
+    // the shift, which is undefined once the tier reaches the width of the shifted type.
+    constexpr std::uint8_t kTierBits = 8;
+    if (policy.tierMask != 0 && (tier >= kTierBits || (policy.tierMask & (1U << tier)) == 0)) {
+        return false;
+    }
+    if (policy.classMask != 0 && (policy.classMask & gearClass) == 0) {
+        return false;
+    }
+    switch (policy.masterwork) {
+    case DismantleMasterworkFilter::masterworked:
+        return isMasterworked;
+    case DismantleMasterworkFilter::notMasterworked:
+        return !isMasterworked;
+    default:
+        return true;
+    }
+}
+
 /**
  * Credits the supported client's ordinary weapon/armor dismantle payout.
- *
- * Capped stacks
- * lose only the overflowing part, matching normal profile-inventory behavior.
- * Every credited row
- * receives a new mutation serial so the account observer can display it.
+ * Matching policy rows are summed per material first, so one material lands as one credited row.
+ * A capped stack loses only the overflow and says so in the log. Each credited row gets a serial.
  */
 [[nodiscard]] bool
 apply_dismantle_rewards(const AccountState& before,
+                        const authored_inventory::Item& dismantledItem,
+                        const build_data::items::Definition& dismantledDefinition,
+                        const item_details::Definition& dismantledDetail,
                         std::uint8_t equipmentSlot,
                         AccountState& after,
                         std::array<DismantleReward, kDismantleRewardCapacity>& rewards,
@@ -90,9 +306,44 @@ apply_dismantle_rewards(const AccountState& before,
     if (!valid_profile_inventory(before)) {
         return false;
     }
-    if (equipmentSlot >= kGearEquipmentSlotCount) {
+    const std::uint8_t gearClass = gear_class_of(equipmentSlot);
+    if (gearClass == 0) {
         return true;
     }
+    const std::uint8_t tier = dismantledDefinition.tier;
+    const bool isMasterworked =
+        masterworked(dismantledItem,
+                     dismantledDetail,
+                     gearClass == static_cast<std::uint8_t>(DismantleGearClass::weapon));
+
+    // Sum the matching rows per material before crediting anything.
+    std::array<DismantleRewardPolicy, kDismantleRewardPolicyCapacity> payout{};
+    std::size_t payoutCount = 0;
+    for (std::size_t policyIndex = 0; policyIndex < before.dismantleRewardCount; ++policyIndex) {
+        const DismantleRewardPolicy& policy = before.dismantleRewards[policyIndex];
+        if (!policy_matches(policy, tier, gearClass, isMasterworked)) {
+            continue;
+        }
+        std::size_t slot = payoutCount;
+        for (std::size_t index = 0; index < payoutCount; ++index) {
+            if (payout[index].definitionHash == policy.definitionHash) {
+                slot = index;
+                break;
+            }
+        }
+        if (slot == payoutCount) {
+            if (payoutCount >= payout.size()) {
+                return false;
+            }
+            payout[payoutCount++] = {policy.definitionHash, 0};
+        }
+        if (policy.quantity > (std::numeric_limits<std::int32_t>::max)() - payout[slot].quantity) {
+            return false;
+        }
+        payout[slot].quantity += policy.quantity;
+    }
+    report_dismantle_reward_match(
+        dismantledDefinition.definitionHash, tier, gearClass, isMasterworked, payoutCount);
 
     std::int32_t greatestMutationSerial = 0;
     for (std::size_t index = 0; index < before.profileItemCount; ++index) {
@@ -100,8 +351,8 @@ apply_dismantle_rewards(const AccountState& before,
             (std::max)(greatestMutationSerial, before.profileItems[index].mutationSerial);
     }
 
-    for (std::size_t policyIndex = 0; policyIndex < before.dismantleRewardCount; ++policyIndex) {
-        const DismantleRewardPolicy& policy = before.dismantleRewards[policyIndex];
+    for (std::size_t policyIndex = 0; policyIndex < payoutCount; ++policyIndex) {
+        const DismantleRewardPolicy& policy = payout[policyIndex];
         build_data::items::Definition definition{};
         item_details::Definition detail{};
         inventory_buckets::Descriptor bucket{};
@@ -137,8 +388,13 @@ apply_dismantle_rewards(const AccountState& before,
         }
 
         const bool appended = profileIndex == after.profileItemCount;
-        if ((appended && after.profileItemCount >= after.profileItems.size())
-            || greatestMutationSerial == (std::numeric_limits<std::int32_t>::max)()) {
+        const bool profileFull = appended && after.profileItemCount >= after.profileItems.size();
+        if (profileFull || greatestMutationSerial == (std::numeric_limits<std::int32_t>::max)()) {
+            report_dismantle_reward_dropped(profileFull ? "profile_full" : "serial_exhausted",
+                                            policy.definitionHash,
+                                            policy.quantity,
+                                            0,
+                                            detail.maxStackSize);
             continue;
         }
         const std::int32_t previousQuantity =
@@ -146,6 +402,12 @@ apply_dismantle_rewards(const AccountState& before,
         const std::int32_t available = detail.maxStackSize - previousQuantity;
         const std::int32_t credited = (std::min)(policy.quantity, available);
         if (credited <= 0) {
+            // Every stack of this currency is already at its native cap.
+            report_dismantle_reward_dropped("stack_capped",
+                                            policy.definitionHash,
+                                            policy.quantity,
+                                            previousQuantity,
+                                            detail.maxStackSize);
             continue;
         }
 
@@ -162,6 +424,11 @@ apply_dismantle_rewards(const AccountState& before,
         }
         // A full native bucket drops this reward, but never blocks deletion of the source item.
         if (!account::valid(candidate) || !valid_profile_inventory(candidate)) {
+            report_dismantle_reward_dropped("bucket_full",
+                                            policy.definitionHash,
+                                            policy.quantity,
+                                            previousQuantity,
+                                            detail.maxStackSize);
             continue;
         }
         if (rewardCount >= rewards.size()) {
@@ -313,7 +580,14 @@ apply_dismantle_rewards(const AccountState& before,
     AccountState rewarded{};
     std::array<DismantleReward, kDismantleRewardCapacity> rewards{};
     std::size_t rewardCount = 0;
-    if (!apply_dismantle_rewards(candidate, dismantledSlot, rewarded, rewards, rewardCount)) {
+    if (!apply_dismantle_rewards(candidate,
+                                 dismantledItem,
+                                 dismantledDefinition,
+                                 dismantledDetail,
+                                 dismantledSlot,
+                                 rewarded,
+                                 rewards,
+                                 rewardCount)) {
         return false;
     }
     candidate = rewarded;

+ 8 - 10
Sunrise/src/state/runtime/state_account_equipment_runtime.cpp

@@ -158,10 +158,8 @@ namespace family4_loadout = middleware::datagen::family4::loadout;
 
 /**
  * Applies canonical mutation generations after one shape-only equipment transition.
- *
- * Every surviving instance must preserve its native bucket. A generation advances exactly when
- * its published native row or equipped marker changes, and a second resolution proves that the
- * stamped after-image retained the staged placement.
+ * Every surviving instance keeps its native bucket, and a generation advances exactly when its
+ * published row or equipped marker changes. An equip swap rewrites the moved serial afterwards.
  */
 [[nodiscard]] bool
 finalize_equipment_transition(const AccountState& account,
@@ -292,7 +290,12 @@ finalize_equipment_transition(const AccountState& account,
            && left.level == right.level && left.quantity == right.quantity
            && left.flags == right.flags && left.sockets.policy == right.sockets.policy
            && left.sockets.plugCount == right.sockets.plugCount
-           && left.sockets.plugs == right.sockets.plugs;
+           && left.sockets.plugs == right.sockets.plugs
+           && left.movementAbilityEntry == right.movementAbilityEntry
+           && left.grenadeAbilityEntry == right.grenadeAbilityEntry
+           && left.superAbilityEntry == right.superAbilityEntry
+           && left.meleeAbilityEntry == right.meleeAbilityEntry
+           && left.classAbilityEntry == right.classAbilityEntry;
 }
 
 /** Records one checked native item-state transition. */
@@ -347,11 +350,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;

+ 110 - 7
Sunrise/src/state/runtime/state_account_item_action_runtime.cpp

@@ -138,9 +138,8 @@ bool prepare_character_selector_socket_plug(std::uint64_t instanceIdentityToken,
         && targetDetail.ordinarySocketState == item_details::OrdinarySocketState::present
         && targetDetail.ordinarySocketCount <= authored_inventory::kPlugCapacity) {
         // Most action kinds are the physical ordinary-socket lane. Prefer that exact lane when
-        // its installed pool accepts the plug; this disambiguates armour items whose two mod
-        // sockets intentionally expose the same pool. Some action kinds are semantic categories
-        // instead (notably shaders), so retain the unique-compatible-lane fallback for those.
+        // its pool accepts the plug, which disambiguates armour with two mod sockets on one pool.
+        // Some kinds are semantic instead (shaders), so keep the unique-compatible-lane fallback.
         if (requestedSocketLane < targetDetail.ordinarySocketCount
             && build_data::is_socket_plug_allowed(
                 targetDefinition.definitionIndex, requestedSocketLane, plugDefinitionIndex)) {
@@ -223,8 +222,9 @@ bool preview_socket_plug(const PendingSocketPlug& mutation, AccountState& after)
                            mutation.characterIndex,
                            mutation.targetInstanceSoid,
                            mutation.socketLane,
-                           mutation.plugDefinitionIndex,
-                           canonical)
+                           mutation.requestedPlugDefinitionIndex,
+                           canonical,
+                           mutation.plugDefinitionHash)
         || canonical.accountSoid != mutation.accountSoid
         || canonical.characterSoid != mutation.characterSoid
         || canonical.targetDefinitionHash != mutation.targetDefinitionHash
@@ -236,6 +236,7 @@ bool preview_socket_plug(const PendingSocketPlug& mutation, AccountState& after)
         || canonical.itemIndex != mutation.itemIndex
         || canonical.targetDefinitionIndex != mutation.targetDefinitionIndex
         || canonical.plugDefinitionIndex != mutation.plugDefinitionIndex
+        || canonical.requestedPlugDefinitionIndex != mutation.requestedPlugDefinitionIndex
         || canonical.materialRequirementSetIndex != mutation.materialRequirementSetIndex
         || canonical.socketLane != mutation.socketLane
         || canonical.targetBucketId != mutation.targetBucketId
@@ -327,8 +328,9 @@ bool commit_socket_plug(PendingSocketPlug& mutation) noexcept {
                            prepared.characterIndex,
                            prepared.targetInstanceSoid,
                            prepared.socketLane,
-                           prepared.plugDefinitionIndex,
-                           canonical)
+                           prepared.requestedPlugDefinitionIndex,
+                           canonical,
+                           prepared.plugDefinitionHash)
         || canonical.characterSoid != prepared.characterSoid
         || canonical.accountSoid != prepared.accountSoid
         || canonical.targetDefinitionHash != prepared.targetDefinitionHash
@@ -340,6 +342,7 @@ bool commit_socket_plug(PendingSocketPlug& mutation) noexcept {
         || canonical.itemIndex != prepared.itemIndex
         || canonical.targetDefinitionIndex != prepared.targetDefinitionIndex
         || canonical.plugDefinitionIndex != prepared.plugDefinitionIndex
+        || canonical.requestedPlugDefinitionIndex != prepared.requestedPlugDefinitionIndex
         || canonical.materialRequirementSetIndex != prepared.materialRequirementSetIndex
         || canonical.socketLane != prepared.socketLane
         || canonical.targetBucketId != prepared.targetBucketId
@@ -511,4 +514,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

+ 246 - 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,140 @@ void report_acquisition(std::string_view stage,
     }
 }
 
+/**
+ * Prepares a subclass ability-entry transition without publishing account State.
+ * The requested entry must share a socket-entry group with exactly one of the character's 5
+ * authored picks; that pick is updated, mirroring how `resolve_socket_states` reads a selection.
+ */
+[[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;
+    }
+    // Index of the subclass slot in the authored equipment array.
+    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 can mix members across slots, so every member of 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 other quadrants
+    // are passive nodes with no destination bucket. The bundle's start is found by scanning
+    // backward, then every member is routed from there, and only while the group is wide.
+    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, so only one bundle's
+        // fields stay set. A bundle that does not touch every field the group reaches must not
+        // leave an earlier bundle's value behind, so every bucket is reset before the pick writes.
+        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;
@@ -256,6 +391,30 @@ bool prepare_equipment_swap(std::uint64_t requestedInstanceSoid,
         return false;
     }
 
+    if (previousInstanceSoid != 0) {
+        // The serial on an unequipped row is also the Client's ordering token for that bucket. A
+        // fresh greatest serial would move the displaced item to the first cell, so transfer the
+        // selected row's prior token instead and it keeps the cell the player clicked.
+        authored_inventory::Item& displaced = after.inventory.values[inventoryIndex];
+        if (displaced.instanceSoid != previousInstanceSoid) {
+            return false;
+        }
+        displaced.mutationSerial = requestedPosition.mutationSerial;
+
+        AccountState checkedAccount = account;
+        checkedAccount.characters[characterIndex] = after;
+        family4_loadout::ResolvedLoadout checkedLoadout{};
+        ResolvedPosition displacedPosition{};
+        if (!account::valid(checkedAccount)
+            || !family4_loadout::resolve(checkedAccount, characterIndex, checkedLoadout)
+            || !find_resolved_position(checkedLoadout, previousInstanceSoid, displacedPosition)
+            || displacedPosition.equipped || displacedPosition.equipmentSlot != requestedNativeSlot
+            || displacedPosition.inventoryRow != requestedPosition.inventoryRow
+            || displacedPosition.mutationSerial != requestedPosition.mutationSerial) {
+            return false;
+        }
+    }
+
     mutation.beforeCharacter = before;
     mutation.afterCharacter = after;
     mutation.characterSoid = before.soid;
@@ -459,6 +618,14 @@ bool commit_equipment_swap(PendingEquipmentSwap& mutation) noexcept {
     runtime::storage::g_state.account = candidate;
     ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
 
+    // The published ability buckets resolve against whichever subclass is equipped, so swapping
+    // that item away makes the domain stale the same way an ability-entry pick does. It needs the
+    // same invalidation or the character screen keeps showing the previous resolution.
+    if (prepared.equipmentSlotIndex
+        == static_cast<std::size_t>(authored_inventory::EquipmentSlot::subclass)) {
+        build_data::invalidate_ability_buckets();
+    }
+
     report_equipment("commit_end",
                      "ok",
                      prepared.kind,
@@ -482,4 +649,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 {
+    // Index of the subclass slot in the authored equipment array.
+    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

+ 87 - 18
Sunrise/src/state/runtime/state_account_socket_runtime.cpp

@@ -17,6 +17,7 @@
 #include "runtime.h"
 #include "state.h"
 #include "state_account_transaction_helpers.h"
+#include "state_rolled_socket_plugs.h"
 #include "storage/internal.h"
 
 namespace sunrise::state {
@@ -101,7 +102,8 @@ void report_socket_plug(std::string_view stage,
                                      std::uint64_t targetInstanceSoid,
                                      std::uint8_t socketLane,
                                      std::uint16_t plugDefinitionIndex,
-                                     PendingSocketPlug& mutation) noexcept {
+                                     PendingSocketPlug& mutation,
+                                     std::uint32_t pinnedPlugHash) noexcept {
     mutation = {};
     CharacterItemLocation location{};
     build_data::items::Definition targetDefinition{};
@@ -158,11 +160,9 @@ void report_socket_plug(std::string_view stage,
         return fail("definition_or_compatibility");
     }
 
-    // Ownership is only meaningful where the plug is a finite supply the account draws down. A
-    // shader is one: it is pulled from Collections into a profile stack and spent by applying it.
-    // An ornament is a permanent unlock the account holds once earned, not a stack it draws
-    // down, which is why the Client offers every valid one for a socket. Requiring a stack for
-    // one would refuse a plug the account already has.
+    // Ownership only matters where the plug is a finite supply the account draws down. A shader is
+    // one: pulled from Collections into a profile stack and spent on apply. An ornament is a
+    // permanent unlock, so requiring a stack for one would refuse a plug the account already has.
     const bool consumesStack =
         build_data::is_profile_action_source(plugDefinitionIndex, plugDefinition.bucketId)
         && build_data::is_consumed_on_apply(plugDefinitionIndex, plugDefinition.bucketId)
@@ -183,13 +183,9 @@ void report_socket_plug(std::string_view stage,
         return fail("materials");
     }
 
-    // Applying spends the stack the plug came from. The insertion cost above is a separate
-    // authored charge that leaves the plug itself untouched, so the unit is taken here.
-    //
-    // The authored-cost path cannot do this. It refuses any row carrying an instance key, because
-    // it exists for the non-instanced currency and material stacks, and an action source always
-    // carries one. Spending one is therefore its own transition: the row keeps its identity while
-    // any unit remains, and releases it with the row once the last unit goes.
+    // Applying spends the stack the plug came from; the insertion cost above is a separate charge.
+    // The authored-cost path refuses any row carrying an instance key, and an action source always
+    // has one, so the row keeps its identity until the last unit goes and is released with it.
     if (consumesStack && !spend_plug_source(chargedAccount, plugDefinition.definitionHash)) {
         return fail("plug_stack");
     }
@@ -212,7 +208,78 @@ void report_socket_plug(std::string_view stage,
         && *authoredSockets.plugs[socketLane] == plugDefinition.definitionHash) {
         return fail("already_applied");
     }
-    authoredSockets.plugs[socketLane] = plugDefinition.definitionHash;
+
+    // A rolled socket's apply or re-roll plug is an action, not a plug: the service answered it
+    // by socketing a result plug from the socket's roll set. The requested plug still decides the
+    // pool check and the material charge above; only the plug that lands in the lane changes.
+    build_data::items::Definition grantedDefinition = plugDefinition;
+    if (classify_rolled_plug(plugDefinition, targetDefinition, socketLane)
+        == RolledPlugAction::roll) {
+        const std::uint32_t currentPlugHash = authoredSockets.plugs[socketLane].value_or(0);
+        const bool reroll = is_rolled_result(currentPlugHash);
+        // A re-staging must land on the plug the first staging rolled, so the pinned roll is
+        // taken as long as it is still one the fresh roll could have produced.
+        RolledPlug rolled{};
+        if (pinnedPlugHash != 0) {
+            if (pinnedPlugHash == currentPlugHash
+                || !pin_rolled_plug(pinnedPlugHash, detail, rolled)) {
+                return fail("rolled_plug_pin");
+            }
+        } else {
+            const std::uint64_t seed = targetInstanceSoid
+                                       ^ (static_cast<std::uint64_t>(GetTickCount64()) << 8U)
+                                       ^ static_cast<std::uint64_t>(before.nextInventorySerial);
+            if (!roll_socket_plug(plugDefinition,
+                                  detail,
+                                  static_cast<std::uint8_t>(before.characterClass),
+                                  currentPlugHash,
+                                  seed,
+                                  rolled)) {
+                return fail("rolled_plug_roll");
+            }
+        }
+        if (!build_data::find_item_definition_hash(rolled.plugHash, grantedDefinition)
+            || grantedDefinition.definitionHash != rolled.plugHash) {
+            return fail("rolled_plug_roll");
+        }
+        // A result that stands for another plug re-rolls that plug's lane as well: the service
+        // swapped the piece's stat perk to the one the result names, which is what moves the
+        // stats.
+        if (rolled.linkedPerkHash != 0) {
+            build_data::items::Definition linkedDefinition{};
+            if (rolled.linkedLane == socketLane || rolled.linkedLane >= detail.ordinarySocketCount
+                || !build_data::find_item_definition_hash(rolled.linkedPerkHash, linkedDefinition)
+                || linkedDefinition.definitionHash != rolled.linkedPerkHash) {
+                return fail("rolled_plug_link");
+            }
+            authoredSockets.plugs[rolled.linkedLane] = rolled.linkedPerkHash;
+            report_socket_plug("rolled_plug_link",
+                               "ok",
+                               reroll ? "reroll" : "apply",
+                               before.soid,
+                               targetInstanceSoid,
+                               targetDefinition.definitionIndex,
+                               rolled.linkedLane,
+                               linkedDefinition.definitionIndex,
+                               targetDefinition.bucketId,
+                               linkedDefinition.bucketId,
+                               location.equipped,
+                               location.index);
+        }
+        report_socket_plug("rolled_plug",
+                           "ok",
+                           reroll ? "reroll" : "apply",
+                           before.soid,
+                           targetInstanceSoid,
+                           targetDefinition.definitionIndex,
+                           socketLane,
+                           grantedDefinition.definitionIndex,
+                           targetDefinition.bucketId,
+                           grantedDefinition.bucketId,
+                           location.equipped,
+                           location.index);
+    }
+    authoredSockets.plugs[socketLane] = grantedDefinition.definitionHash;
 
     CharacterState after = before;
     authored_inventory::Item* changed = character_item_at(after, location);
@@ -259,7 +326,8 @@ void report_socket_plug(std::string_view stage,
         || resolvedTarget->instance.ordinarySockets.state
                != middleware::datagen::family4::instance::OrdinarySocketBlockState::present
         || !resolvedTarget->instance.ordinarySockets.plugs[socketLane].has_value()
-        || *resolvedTarget->instance.ordinarySockets.plugs[socketLane] != plugDefinitionIndex) {
+        || *resolvedTarget->instance.ordinarySockets.plugs[socketLane]
+               != grantedDefinition.definitionIndex) {
         return fail("after_socket");
     }
 
@@ -271,18 +339,19 @@ void report_socket_plug(std::string_view stage,
     mutation.characterSoid = before.soid;
     mutation.targetInstanceSoid = targetInstanceSoid;
     mutation.targetDefinitionHash = targetDefinition.definitionHash;
-    mutation.plugDefinitionHash = plugDefinition.definitionHash;
+    mutation.plugDefinitionHash = grantedDefinition.definitionHash;
     mutation.materialRequirementSetHash = materialSet.requirementSetHash;
     mutation.characterIndex = characterIndex;
     mutation.expectedProfileItemCount = snapshot.profileItemCount;
     mutation.afterProfileItemCount = chargedAccount.profileItemCount;
     mutation.itemIndex = location.index;
     mutation.targetDefinitionIndex = targetDefinition.definitionIndex;
-    mutation.plugDefinitionIndex = plugDefinitionIndex;
+    mutation.plugDefinitionIndex = grantedDefinition.definitionIndex;
+    mutation.requestedPlugDefinitionIndex = plugDefinitionIndex;
     mutation.materialRequirementSetIndex = materialSetIndex;
     mutation.socketLane = socketLane;
     mutation.targetBucketId = targetDefinition.bucketId;
-    mutation.plugBucketId = plugDefinition.bucketId;
+    mutation.plugBucketId = grantedDefinition.bucketId;
     mutation.materialRequirementCount = materialSet.requirementCount;
     mutation.profileChanged = profileChanged;
     mutation.targetEquipped = location.equipped;

+ 11 - 1
Sunrise/src/state/runtime/state_account_transaction_helpers.h

@@ -133,18 +133,28 @@ find_resolved_position(const middleware::datagen::family4::loadout::ResolvedLoad
     CharacterState& after,
     std::size_t& movedItemCount) noexcept;
 [[nodiscard]] bool same_character(const CharacterState& left, const CharacterState& right) noexcept;
+/**
+ * @param pinnedPlugHash For a rolled socket's apply or re-roll, the result plug an earlier
+ *        staging rolled, so a re-staging reproduces the same after-image; 0 rolls afresh.
+ */
 [[nodiscard]] bool stage_socket_plug(const AccountState& snapshot,
                                      std::size_t characterIndex,
                                      std::uint64_t targetInstanceSoid,
                                      std::uint8_t socketLane,
                                      std::uint16_t plugDefinitionIndex,
-                                     PendingSocketPlug& mutation) noexcept;
+                                     PendingSocketPlug& mutation,
+                                     std::uint32_t pinnedPlugHash = 0) noexcept;
 [[nodiscard]] bool stage_item_state(const AccountState& snapshot,
                                     std::size_t characterIndex,
                                     std::uint64_t targetInstanceSoid,
                                     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,

+ 271 - 0
Sunrise/src/state/runtime/state_rolled_socket_plugs.cpp

@@ -0,0 +1,271 @@
+/** Rolled socket plugs: answers a socket's action plug with a result plug from its roll set. */
+
+#include "state_rolled_socket_plugs.h"
+
+#include <algorithm>
+#include <array>
+
+#include "../build_data/runtime.h"
+
+namespace sunrise::state::runtime::detail {
+namespace {
+
+namespace item_details = build_data::items::details;
+namespace items = build_data::items;
+
+/** Result plugs of one category, in installed-table order. */
+struct ResultSet {
+    /** Most result plugs any one category carries in the installed table. */
+    static constexpr std::size_t kCapacity = 64;
+    std::array<items::Definition, kCapacity> results{};
+    std::size_t count{};
+    /** True when at least one result stands for another plug. */
+    bool linking{};
+};
+
+/** One splitmix64 step, enough spread for a cosmetic roll. */
+[[nodiscard]] std::uint64_t mix(std::uint64_t value) noexcept {
+    value += 0x9E3779B97F4A7C15ULL;
+    value = (value ^ (value >> 30U)) * 0xBF58476D1CE4E5B9ULL;
+    value = (value ^ (value >> 27U)) * 0x94D049BB133111EBULL;
+    return value ^ (value >> 31U);
+}
+
+/** @return The installed detail of one definition, or false when it is not configured. */
+[[nodiscard]] bool detail_of(const items::Definition& definition,
+                             item_details::Definition& detail) noexcept {
+    return build_data::find_configured_item_detail(definition.definitionIndex, detail)
+           && detail.definitionHash == definition.definitionHash;
+}
+
+/**
+ * Collects every rolled result plug sharing one category. Results are never in a socket pool;
+ * one that is would be an ordinary plug and is left out.
+ */
+[[nodiscard]] bool collect_results(std::uint32_t plugCategoryHash, ResultSet& set) noexcept {
+    set = {};
+    if (plugCategoryHash == 0) {
+        return false;
+    }
+    const std::size_t count = build_data::item_definition_count();
+    for (std::size_t index = 0; index < count && index <= 0xFFFFU; ++index) {
+        items::Definition definition{};
+        if (!build_data::find_item_definition_index(static_cast<std::uint16_t>(index), definition)
+            || definition.plugCategoryHash != plugCategoryHash || !items::rolled_result(definition)
+            || build_data::is_socket_plug_pooled(definition.definitionIndex)) {
+            continue;
+        }
+        if (set.count >= set.results.size()) {
+            return false;
+        }
+        if (definition.linkedPlugIndex != items::kUnavailableLinkedPlugIndex) {
+            set.linking = true;
+        }
+        set.results[set.count++] = definition;
+    }
+    return set.count != 0;
+}
+
+/** @return True when every stat the plug contributes is one the target declares. */
+[[nodiscard]] bool plug_fits_target(const item_details::Definition& plug,
+                                    const item_details::Definition& target) noexcept {
+    if (plug.statCount == 0 || plug.statCount > plug.stats.size()
+        || target.statCount > target.stats.size()) {
+        return false;
+    }
+    for (std::size_t index = 0; index < plug.statCount; ++index) {
+        const std::uint8_t row = plug.stats[index].row;
+        const bool declared =
+            std::any_of(target.stats.begin(),
+                        target.stats.begin() + static_cast<std::ptrdiff_t>(target.statCount),
+                        [row](const item_details::Stat& stat) { return stat.row == row; });
+        if (!declared) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** @return True when one of the target's socket pools offers this plug, naming the lane. */
+[[nodiscard]] bool target_offers(const item_details::Definition& target,
+                                 std::uint16_t plugIndex,
+                                 std::uint8_t& lane) noexcept {
+    for (std::uint8_t candidate = 0; candidate < target.ordinarySocketCount; ++candidate) {
+        if (build_data::is_socket_plug_allowed(target.definitionIndex, candidate, plugIndex)) {
+            lane = candidate;
+            return true;
+        }
+    }
+    return false;
+}
+
+/**
+ * Linked results are authored in groups of three consecutive rows, one group per class and
+ * slot, each row standing for a different stat focus. Delegate-less rows sit between groups
+ * and are skipped, so the group of a linked result is its ordinal among linked results / 3.
+ */
+constexpr std::size_t kLinkedGroupSize = 3;
+
+/** @return The ordinal of one result among the linked results of its set, or the count. */
+[[nodiscard]] std::size_t linked_ordinal(const ResultSet& set, std::size_t resultIndex) noexcept {
+    std::size_t ordinal = 0;
+    for (std::size_t index = 0; index < set.count; ++index) {
+        if (set.results[index].linkedPlugIndex == items::kUnavailableLinkedPlugIndex) {
+            continue;
+        }
+        if (index == resultIndex) {
+            return ordinal;
+        }
+        ++ordinal;
+    }
+    return set.count;
+}
+
+/** @return The lane on the target offering any linked perk of one result's group, if any. */
+[[nodiscard]] bool group_lane(const ResultSet& set,
+                              std::size_t resultIndex,
+                              const item_details::Definition& target,
+                              std::uint8_t& lane) noexcept {
+    const std::size_t ordinal = linked_ordinal(set, resultIndex);
+    if (ordinal >= set.count) {
+        return false;
+    }
+    const std::size_t group = ordinal / kLinkedGroupSize;
+    for (std::size_t index = 0; index < set.count; ++index) {
+        const items::Definition& other = set.results[index];
+        if (other.linkedPlugIndex == items::kUnavailableLinkedPlugIndex
+            || linked_ordinal(set, index) / kLinkedGroupSize != group) {
+            continue;
+        }
+        if (target_offers(target, other.linkedPlugIndex, lane)) {
+            return true;
+        }
+    }
+    return false;
+}
+
+/** Fills the outcome's linked perk from one result, when the result stands for one. */
+[[nodiscard]] bool
+resolve_link(const items::Definition& result, std::uint8_t lane, RolledPlug& rolled) noexcept {
+    rolled.linkedLane = lane;
+    rolled.linkedPerkHash = 0;
+    if (result.linkedPlugIndex == items::kUnavailableLinkedPlugIndex) {
+        return true;
+    }
+    items::Definition perk{};
+    if (!build_data::find_item_definition_index(result.linkedPlugIndex, perk)
+        || perk.definitionIndex != result.linkedPlugIndex) {
+        return false;
+    }
+    rolled.linkedPerkHash = perk.definitionHash;
+    return true;
+}
+
+} // namespace
+
+/** Classifies one requested plug for one target lane. */
+RolledPlugAction classify_rolled_plug(const items::Definition& requested,
+                                      const items::Definition& target,
+                                      std::uint8_t lane) noexcept {
+    item_details::Definition detail{};
+    if (requested.plugCategoryHash == 0 || items::rolled_result(requested)
+        || !detail_of(requested, detail) || detail.statCount != 0
+        || !build_data::is_socket_plug_allowed(
+            target.definitionIndex, lane, requested.definitionIndex)) {
+        return RolledPlugAction::none;
+    }
+    ResultSet set{};
+    return collect_results(requested.plugCategoryHash, set) ? RolledPlugAction::roll
+                                                            : RolledPlugAction::none;
+}
+
+bool is_rolled_result(std::uint32_t plugHash) noexcept {
+    items::Definition definition{};
+    return plugHash != 0 && build_data::find_item_definition_hash(plugHash, definition)
+           && definition.definitionHash == plugHash && items::rolled_result(definition);
+}
+
+/** Rolls one result plug for a target item. */
+bool roll_socket_plug(const items::Definition& requested,
+                      const item_details::Definition& target,
+                      std::uint8_t characterClass,
+                      std::uint32_t currentPlugHash,
+                      std::uint64_t seed,
+                      RolledPlug& rolled) noexcept {
+    rolled = {};
+    ResultSet set{};
+    if (!collect_results(requested.plugCategoryHash, set)) {
+        return false;
+    }
+
+    std::array<std::size_t, ResultSet::kCapacity> pick{};
+    std::array<std::uint8_t, ResultSet::kCapacity> pickLanes{};
+    std::size_t pickCount = 0;
+    // A delegate-less, stat-less result is a class-item masterwork; the k-th one in table order
+    // belongs to the k-th class. It only counts when no linked group fits the piece.
+    std::size_t classItemResult = set.count;
+    std::size_t delegateless = 0;
+    for (std::size_t index = 0; index < set.count; ++index) {
+        const items::Definition& result = set.results[index];
+        std::uint8_t lane = 0;
+        if (result.linkedPlugIndex != items::kUnavailableLinkedPlugIndex) {
+            if (result.definitionHash != currentPlugHash && group_lane(set, index, target, lane)) {
+                pick[pickCount] = index;
+                pickLanes[pickCount++] = lane;
+            }
+            continue;
+        }
+        item_details::Definition detail{};
+        if (detail_of(result, detail) && detail.statCount != 0) {
+            if (result.definitionHash != currentPlugHash && plug_fits_target(detail, target)) {
+                pick[pickCount] = index;
+                pickLanes[pickCount++] = 0;
+            }
+            continue;
+        }
+        if (set.linking && delegateless++ == characterClass
+            && result.definitionHash != currentPlugHash) {
+            classItemResult = index;
+        }
+    }
+    if (pickCount == 0 && classItemResult < set.count) {
+        pick[pickCount] = classItemResult;
+        pickLanes[pickCount++] = 0;
+    }
+    if (pickCount == 0) {
+        return false;
+    }
+    const std::size_t chosen = static_cast<std::size_t>(mix(seed) % pickCount);
+    const items::Definition& result = set.results[pick[chosen]];
+    rolled.plugHash = result.definitionHash;
+    return resolve_link(result, pickLanes[chosen], rolled);
+}
+
+/** Re-derives the linked perk for a result an earlier staging rolled. */
+bool pin_rolled_plug(std::uint32_t plugHash,
+                     const item_details::Definition& target,
+                     RolledPlug& rolled) noexcept {
+    rolled = {};
+    items::Definition result{};
+    if (!build_data::find_item_definition_hash(plugHash, result)
+        || result.definitionHash != plugHash || !items::rolled_result(result)) {
+        return false;
+    }
+    rolled.plugHash = plugHash;
+    if (result.linkedPlugIndex == items::kUnavailableLinkedPlugIndex) {
+        return true;
+    }
+    ResultSet set{};
+    if (!collect_results(result.plugCategoryHash, set)) {
+        return false;
+    }
+    std::uint8_t lane = 0;
+    for (std::size_t index = 0; index < set.count; ++index) {
+        if (set.results[index].definitionHash == plugHash) {
+            return group_lane(set, index, target, lane) && resolve_link(result, lane, rolled);
+        }
+    }
+    return false;
+}
+
+} // namespace sunrise::state::runtime::detail

+ 83 - 0
Sunrise/src/state/runtime/state_rolled_socket_plugs.h

@@ -0,0 +1,83 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+
+#include "../build_data/items/details/definition.h"
+#include "../build_data/items/item_catalog.h"
+
+namespace sunrise::state::runtime::detail {
+
+/**
+ * Some sockets offer only action plugs, which carry no effect of their own. The service answered
+ * them with a result plug from a roll set: result plugs sit outside the socket's own pool but each
+ * names its set and shares the action plugs' category. All of it is derived from the item table.
+ */
+
+/** How one requested plug should be socketed. */
+enum class RolledPlugAction : std::uint8_t {
+    /** An ordinary plug; socket it as asked. */
+    none,
+    /** An action plug of a rolled socket; roll a result plug and socket that instead. */
+    roll,
+};
+
+/**
+ * Classifies one requested plug for one target lane.
+ * @param requested Installed definition of the plug the Client asked to socket.
+ * @param target Installed definition of the item being socketed.
+ * @param lane Ordinary socket lane the request names.
+ * @return roll when the plug is stat-less, pooled for that lane, and its category has result
+ *         plugs the pool does not offer.
+ */
+[[nodiscard]] RolledPlugAction classify_rolled_plug(const build_data::items::Definition& requested,
+                                                    const build_data::items::Definition& target,
+                                                    std::uint8_t lane) noexcept;
+
+/**
+ * @return True when a plug already in a lane is a rolled result of any set, which is what a
+ *         re-roll replaces and what marks the item as masterworked.
+ */
+[[nodiscard]] bool is_rolled_result(std::uint32_t plugHash) noexcept;
+
+/** One roll outcome: the result plug and, for a delegating result, the perk it stands for. */
+struct RolledPlug {
+    std::uint32_t plugHash{};
+    /** Lane whose pool offers the linked perk; only meaningful when linkedPerkHash is nonzero. */
+    std::uint8_t linkedLane{};
+    /** Stat perk the result stands for, which the service swapped into linkedLane; 0 for none. */
+    std::uint32_t linkedPerkHash{};
+};
+
+/**
+ * Rolls one result plug for a target item. A stat-carrying result needs every stat it contributes
+ * declared by the target; a delegating result needs the target's pools to offer a perk of its
+ * group, else the delegate-less result for the class. The current plug is never rolled again.
+ * @param requested The action plug, which names the roll set through its category.
+ * @param target Installed detail of the item being socketed.
+ * @param characterClass Wire class of the owning character.
+ * @param currentPlugHash Plug already in the lane, or 0.
+ * @param seed Any per-request entropy.
+ * @param rolled Receives the outcome.
+ * @return True when at least one eligible result existed.
+ */
+[[nodiscard]] bool roll_socket_plug(const build_data::items::Definition& requested,
+                                    const build_data::items::details::Definition& target,
+                                    std::uint8_t characterClass,
+                                    std::uint32_t currentPlugHash,
+                                    std::uint64_t seed,
+                                    RolledPlug& rolled) noexcept;
+
+/**
+ * Re-derives the linked perk for a result an earlier staging rolled, so a re-staging lands on
+ * the same after-image.
+ * @param plugHash Result plug the earlier staging chose.
+ * @param target Installed detail of the item being socketed.
+ * @param rolled Receives the plug and its linked perk, if it has one.
+ * @return True when the plug is a rolled result the target could have rolled.
+ */
+[[nodiscard]] bool pin_rolled_plug(std::uint32_t plugHash,
+                                   const build_data::items::details::Definition& target,
+                                   RolledPlug& rolled) noexcept;
+
+} // namespace sunrise::state::runtime::detail