Sfoglia il codice sorgente

Sunrise 0.2
- changed keybind config from id to names
- fixed some startup issues
- fixed some load in issues
- fixed some ability config issues
- fixed some issues with plugs converting to active perks
- implemented some entity lifecycle handling
- implemented some other gameplay related handling
- overall stability improvements
- fixed loading / overlay issues on some older hardware
- fixed some typo
- fixed some stale cache issues
- fixed destination default spawn when using activity override (yes sunken loads now)

stan 3 settimane fa
parent
commit
da7bfed54f
73 ha cambiato i file con 2294 aggiunte e 373 eliminazioni
  1. 1 1
      README.md
  2. 11 0
      Sunrise/Sunrise.vcxproj
  3. 52 50
      Sunrise/resources/default_settings.json
  4. 2 2
      Sunrise/resources/resource.h
  5. 6 6
      Sunrise/src/client/content/items/packages/internal.h
  6. 25 12
      Sunrise/src/client/content/items/packages/package_ability_build.cpp
  7. 1 1
      Sunrise/src/client/content/items/packages/package_build_report.cpp
  8. 16 9
      Sunrise/src/client/content/items/packages/package_subclass_build.cpp
  9. 8 13
      Sunrise/src/client/hooks/bootflow/spawn_hold.cpp
  10. 1 1
      Sunrise/src/client/hooks/bootflow/world_step.cpp
  11. 91 33
      Sunrise/src/client/hooks/graphics/discovery/graphics_target_discovery.cpp
  12. 2 0
      Sunrise/src/client/hooks/graphics/graphics_hook_lifecycle.cpp
  13. 30 3
      Sunrise/src/client/hooks/graphics/graphics_hook_replacements.cpp
  14. 9 3
      Sunrise/src/client/hooks/graphics/graphics_hook_replacements.h
  15. 18 12
      Sunrise/src/client/hooks/graphics/renderer/graphics_renderer_device.cpp
  16. 2 0
      Sunrise/src/client/hooks/graphics/renderer/graphics_renderer_frame.cpp
  17. 68 6
      Sunrise/src/client/hooks/graphics/renderer/graphics_renderer_lifecycle.cpp
  18. 102 0
      Sunrise/src/client/hooks/graphics/renderer/graphics_renderer_report.cpp
  19. 51 0
      Sunrise/src/client/hooks/graphics/renderer/graphics_renderer_report.h
  20. 4 4
      Sunrise/src/client/hooks/graphics/renderer/renderer.h
  21. 39 16
      Sunrise/src/client/hooks/graphics/renderer/selection/graphics_swap_chain_selection.cpp
  22. 2 2
      Sunrise/src/client/hooks/graphics/renderer/state.h
  23. 3 3
      Sunrise/src/client/hooks/teleport/runtime.h
  24. 9 8
      Sunrise/src/client/hooks/teleport/teleport_action_key.cpp
  25. 9 0
      Sunrise/src/core/settings/client/client_settings_parser.cpp
  26. 9 0
      Sunrise/src/core/settings/client/definition.h
  27. 9 2
      Sunrise/src/core/settings/parser.h
  28. 5 0
      Sunrise/src/core/settings/server/definition.h
  29. 11 0
      Sunrise/src/core/settings/server/server_settings_parser.cpp
  30. 2 2
      Sunrise/src/core/settings/settings.h
  31. 100 12
      Sunrise/src/core/settings/settings_runtime.cpp
  32. 242 0
      Sunrise/src/core/settings/settings_upgrade.cpp
  33. 29 0
      Sunrise/src/core/settings/settings_upgrade.h
  34. 1 3
      Sunrise/src/core/settings/state/account_rows_parser.cpp
  35. 249 5
      Sunrise/src/core/settings/state/key_bindings_parser.cpp
  36. 1 1
      Sunrise/src/core/ui/busy/busy.h
  37. 19 2
      Sunrise/src/core/ui/busy/ui_busy_state.cpp
  38. 5 14
      Sunrise/src/middleware/bap/activity_host_manager/request/selection/activity_manager_descriptor_parser.cpp
  39. 0 6
      Sunrise/src/middleware/bap/activity_host_manager/request/selection/definition.h
  40. 111 0
      Sunrise/src/middleware/bap/activity_message/activity_entity_authority_parser.cpp
  41. 114 0
      Sunrise/src/middleware/bap/activity_message/activity_incident_parser.cpp
  42. 30 14
      Sunrise/src/middleware/bap/activity_message/activity_sensor_auth_encoder.cpp
  43. 95 0
      Sunrise/src/middleware/bap/activity_message/entity_authority.h
  44. 79 0
      Sunrise/src/middleware/bap/activity_message/incident.h
  45. 20 0
      Sunrise/src/middleware/bap/frame.h
  46. 11 2
      Sunrise/src/middleware/datagen/character_record/appearance/character_appearance_abilities.cpp
  47. 1 1
      Sunrise/src/middleware/datagen/character_record/appearance/internal.h
  48. 3 0
      Sunrise/src/middleware/datagen/family4/instance/instance_encoder.cpp
  49. 9 1
      Sunrise/src/middleware/datagen/family4/instance/layout.h
  50. 12 1
      Sunrise/src/middleware/encoding/bit_writer.cpp
  51. 9 0
      Sunrise/src/middleware/encoding/bit_writer.h
  52. 68 0
      Sunrise/src/middleware/web_service/messages/opcode601/opcode601_codec.cpp
  53. 34 0
      Sunrise/src/middleware/web_service/messages/opcode601/opcode601_codec.h
  54. 3 6
      Sunrise/src/server/bap/encrypted/activity_host_manager/activity_host_manager_route.cpp
  55. 209 0
      Sunrise/src/server/bap/encrypted/activity_message/activity_message_route.cpp
  56. 2 1
      Sunrise/src/server/bap/encrypted/push/activity/activity_global_state_push.cpp
  57. 2 1
      Sunrise/src/server/bap/encrypted/push/activity/activity_roster_snapshot.cpp
  58. 33 0
      Sunrise/src/server/bap/encrypted/routing/bap_service_routing.cpp
  59. 13 4
      Sunrise/src/server/transport/bap_listener.cpp
  60. 0 3
      Sunrise/src/server/transport/bap_listener.h
  61. 0 6
      Sunrise/src/server/ui/activity_override/activity_override_lists.cpp
  62. 0 6
      Sunrise/src/server/ui/activity_override/activity_override_lists.h
  63. 5 12
      Sunrise/src/server/ui/activity_override/activity_override_panel.cpp
  64. 1 1
      Sunrise/src/server/web_service/opcode_routes.cpp
  65. 7 0
      Sunrise/src/server/web_service/web_service_runtime.cpp
  66. 2 1
      Sunrise/src/state/account/account_state.h
  67. 106 0
      Sunrise/src/state/activity/destination/activity_destination_spawn_binding.cpp
  68. 20 0
      Sunrise/src/state/activity/destination/activity_destination_spawn_binding.h
  69. 0 8
      Sunrise/src/state/activity/destination/definition.h
  70. 8 57
      Sunrise/src/state/activity/forced/activity_forced_destination.cpp
  71. 3 10
      Sunrise/src/state/activity/forced/definition.h
  72. 38 3
      Sunrise/src/state/runtime/equipment/configured_equipment_identity.cpp
  73. 2 3
      Sunrise/src/state/runtime/state_runtime.cpp

+ 1 - 1
README.md

@@ -20,7 +20,7 @@ a lack of documentation. This will improve over the coming weeks.
 
 ## Support Me
 
-Leave a start on this repo.
+Leave a star on this repo.
 
 If you want to support my open source work you can find the means on my
 [profile](https://github.com/stanuwu). Also consider donating to charity instead.

+ 11 - 0
Sunrise/Sunrise.vcxproj

@@ -100,6 +100,7 @@
     <ClCompile Include="src\core\settings\json_scalar.cpp" />
     <ClCompile Include="src\core\settings\json_structure.cpp" />
     <ClCompile Include="src\core\settings\settings_runtime.cpp" />
+    <ClCompile Include="src\core\settings\settings_upgrade.cpp" />
     <ClCompile Include="src\core\settings\client\client_settings_parser.cpp" />
     <ClCompile Include="src\core\settings\client\client_ui_settings_parser.cpp" />
     <ClCompile Include="src\core\settings\server\server_settings_parser.cpp" />
@@ -166,6 +167,7 @@
     <ClCompile Include="src\client\hooks\graphics\renderer\selection\graphics_swap_chain_selection.cpp" />
     <ClCompile Include="src\client\hooks\graphics\renderer\graphics_renderer_lifecycle.cpp" />
     <ClCompile Include="src\client\hooks\graphics\renderer\graphics_renderer_frame.cpp" />
+    <ClCompile Include="src\client\hooks\graphics\renderer\graphics_renderer_report.cpp" />
     <ClCompile Include="src\client\hooks\egress\dns\egress_dns_replacements.cpp" />
     <ClCompile Include="src\client\hooks\egress\resolver\egress_resolver_forward.cpp" />
     <ClCompile Include="src\client\hooks\egress\resolver\egress_resolver_reverse.cpp" />
@@ -340,6 +342,7 @@
     <ClCompile Include="src\state\activity\defaults\activity_defaults_snapshot.cpp" />
     <ClCompile Include="src\state\activity\defaults\activity_defaults_validation.cpp" />
     <ClCompile Include="src\state\activity\destination\activity_destination_snapshot.cpp" />
+    <ClCompile Include="src\state\activity\destination\activity_destination_spawn_binding.cpp" />
     <ClCompile Include="src\state\activity\destination\activity_destination_validation.cpp" />
     <ClCompile Include="src\state\activity\transactions\activity_session_prepare.cpp" />
     <ClCompile Include="src\state\activity\transactions\activity_session_commit.cpp" />
@@ -512,6 +515,8 @@
     <ClCompile Include="src\middleware\bap\activity_message\activity_join_result_encoder.cpp" />
     <ClCompile Include="src\middleware\bap\activity_message\activity_entity_slot_request_parser.cpp" />
     <ClCompile Include="src\middleware\bap\activity_message\activity_entity_slots_decoder.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_entity_authority_parser.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_incident_parser.cpp" />
     <ClCompile Include="src\middleware\bap\activity_message\activity_entity_slots_encoder.cpp" />
     <ClCompile Include="src\middleware\bap\certificate.cpp" />
     <ClCompile Include="src\middleware\bap\client_config\client_config_response.cpp" />
@@ -573,6 +578,7 @@
     <ClCompile Include="src\middleware\web_service\messages\opcode206_codec.cpp" />
     <ClCompile Include="src\middleware\web_service\messages\opcode501_codec.cpp" />
     <ClCompile Include="src\middleware\web_service\messages\opcode505\opcode505_codec.cpp" />
+    <ClCompile Include="src\middleware\web_service\messages\opcode601\opcode601_codec.cpp" />
     <ClCompile Include="src\core\settings\state_settings.cpp" />
     <ClCompile Include="src\middleware\secure_channel\envelope.cpp" />
     <ClCompile Include="src\middleware\secure_channel\encrypted_frame.cpp" />
@@ -676,6 +682,7 @@
     <ClInclude Include="src\client\hooks\graphics\input\input.h" />
     <ClInclude Include="src\client\hooks\graphics\renderer\renderer.h" />
     <ClInclude Include="src\client\hooks\graphics\renderer\state.h" />
+    <ClInclude Include="src\client\hooks\graphics\renderer\graphics_renderer_report.h" />
     <ClInclude Include="src\client\ui\runtime\client_ui_module_runtime.h" />
     <ClInclude Include="src\client\memory\current_process_memory.h" />
     <ClInclude Include="src\client\content\handles\layout.h" />
@@ -764,6 +771,7 @@
     <ClInclude Include="src\state\activity\forced\activity_forced_destination.h" />
     <ClInclude Include="src\state\activity\destination\activity_destination_snapshot.h" />
     <ClInclude Include="src\state\activity\destination\activity_destination_validation.h" />
+    <ClInclude Include="src\state\activity\destination\activity_destination_spawn_binding.h" />
     <ClInclude Include="src\state\activity\entity_slots\definition.h" />
     <ClInclude Include="src\state\activity\entity_slots\runtime.h" />
     <ClInclude Include="src\state\activity\entity_slots\transactions\internal.h" />
@@ -892,6 +900,8 @@
     <ClInclude Include="src\middleware\bap\activity_message\activity_join_result_encoder.h" />
     <ClInclude Include="src\middleware\bap\activity_message\activity_entity_slot_request_parser.h" />
     <ClInclude Include="src\middleware\bap\activity_message\entity_slots.h" />
+    <ClInclude Include="src\middleware\bap\activity_message\entity_authority.h" />
+    <ClInclude Include="src\middleware\bap\activity_message\incident.h" />
     <ClInclude Include="src\middleware\bap\certificate.h" />
     <ClInclude Include="src\middleware\bap\client_config\client_config_response.h" />
     <ClInclude Include="src\middleware\bap\family_subscription.h" />
@@ -942,6 +952,7 @@
     <ClInclude Include="src\middleware\web_service\messages\opcode206.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode501_codec.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode505\opcode505_codec.h" />
+    <ClInclude Include="src\middleware\web_service\messages\opcode601\opcode601_codec.h" />
     <ClInclude Include="src\middleware\datagen\family4\account\selection_patch\account_selection_patch_encoder.h" />
     <ClInclude Include="src\middleware\secure_channel\runtime.h" />
     <ClInclude Include="src\middleware\signon\response.h" />

+ 52 - 50
Sunrise/resources/default_settings.json

@@ -1,5 +1,5 @@
 {
-  "version": 2,
+  "version": 3,
   "core": {
     "logging": {
       "debugger_sink": true,
@@ -32,9 +32,11 @@
     "fade_release": true,
     "force_join_request_ready": true,
     "pin_replicated_record": true,
-    "hold_spawn": true
+    "hold_spawn": true,
+    "spawn_hold_ms": 30000
   },
   "server": {
+    "bap_port": 30974,
     "entitlements": [
       { "name": "1085660", "owned": "handle" },
       { "name": "STEAM_PAID_TIER", "owned": "handle" },
@@ -223,66 +225,66 @@
           "chat_auto_hide_mode": 1
         },
         "key_bindings": {
-          "fire": { "primary": 109, "secondary": null },
+          "fire": { "primary": "left mouse button", "secondary": null },
           "toggle_zoom": { "primary": null, "secondary": null },
-          "hold_zoom": { "primary": 111, "secondary": null },
-          "melee": { "primary": 60, "secondary": null },
-          "grenade": { "primary": 31, "secondary": null },
-          "super": { "primary": 48, "secondary": null },
-          "reload": { "primary": 34, "secondary": null },
-          "light_attack": { "primary": 109, "secondary": null },
-          "heavy_attack": { "primary": 111, "secondary": null },
-          "block": { "primary": 60, "secondary": null },
-          "switch_weapons": { "primary": 16, "secondary": null },
-          "next_weapon": { "primary": 114, "secondary": null },
-          "previous_weapon": { "primary": 115, "secondary": null },
-          "primary_weapon": { "primary": 17, "secondary": null },
-          "special_weapon": { "primary": 18, "secondary": null },
-          "heavy_weapon": { "primary": 19, "secondary": null },
-          "move_forward": { "primary": 32, "secondary": null },
-          "move_backward": { "primary": 46, "secondary": null },
-          "move_left": { "primary": 45, "secondary": null },
-          "move_right": { "primary": 47, "secondary": null },
-          "jump": { "primary": 72, "secondary": null },
-          "toggle_crouch": { "primary": 106, "secondary": null },
+          "hold_zoom": { "primary": "right mouse button", "secondary": null },
+          "melee": { "primary": "c", "secondary": null },
+          "grenade": { "primary": "q", "secondary": null },
+          "super": { "primary": "f", "secondary": null },
+          "reload": { "primary": "r", "secondary": null },
+          "light_attack": { "primary": "left mouse button", "secondary": null },
+          "heavy_attack": { "primary": "right mouse button", "secondary": null },
+          "block": { "primary": "c", "secondary": null },
+          "switch_weapons": { "primary": "`", "secondary": null },
+          "next_weapon": { "primary": "mouse wheel up", "secondary": null },
+          "previous_weapon": { "primary": "mouse wheel down", "secondary": null },
+          "primary_weapon": { "primary": "1", "secondary": null },
+          "special_weapon": { "primary": "2", "secondary": null },
+          "heavy_weapon": { "primary": "3", "secondary": null },
+          "move_forward": { "primary": "w", "secondary": null },
+          "move_backward": { "primary": "s", "secondary": null },
+          "move_left": { "primary": "a", "secondary": null },
+          "move_right": { "primary": "d", "secondary": null },
+          "jump": { "primary": "space", "secondary": null },
+          "toggle_crouch": { "primary": "control", "secondary": null },
           "hold_crouch": { "primary": null, "secondary": null },
-          "toggle_sprint": { "primary": 105, "secondary": null },
+          "toggle_sprint": { "primary": "shift", "secondary": null },
           "hold_sprint": { "primary": null, "secondary": null },
-          "vehicle_boost": { "primary": 111, "secondary": null },
-          "vehicle_brake": { "primary": 105, "secondary": null },
-          "vehicle_zoom": { "primary": 111, "secondary": null },
-          "vehicle_fire_primary": { "primary": 109, "secondary": null },
-          "vehicle_fire_secondary": { "primary": 33, "secondary": null },
-          "vehicle_exit": { "primary": 72, "secondary": null },
-          "interact": { "primary": 33, "secondary": null },
-          "highlight_player": { "primary": 49, "secondary": null },
-          "emote_1": { "primary": 77, "secondary": null },
-          "emote_2": { "primary": 78, "secondary": null },
-          "emote_3": { "primary": 79, "secondary": null },
-          "emote_4": { "primary": 80, "secondary": null },
-          "air_move": { "primary": 59, "secondary": null },
-          "class_ability": { "primary": 61, "secondary": null },
-          "death_cam_zoom_in": { "primary": 114, "secondary": null },
-          "death_cam_zoom_out": { "primary": 115, "secondary": null },
-          "push_to_talk": { "primary": 35, "secondary": null },
-          "ui_gamepad_button_back": { "primary": 30, "secondary": null },
-          "ui_open_director": { "primary": 64, "secondary": null },
+          "vehicle_boost": { "primary": "right mouse button", "secondary": null },
+          "vehicle_brake": { "primary": "shift", "secondary": null },
+          "vehicle_zoom": { "primary": "right mouse button", "secondary": null },
+          "vehicle_fire_primary": { "primary": "left mouse button", "secondary": null },
+          "vehicle_fire_secondary": { "primary": "e", "secondary": null },
+          "vehicle_exit": { "primary": "space", "secondary": null },
+          "interact": { "primary": "e", "secondary": null },
+          "highlight_player": { "primary": "g", "secondary": null },
+          "emote_1": { "primary": "up", "secondary": null },
+          "emote_2": { "primary": "down", "secondary": null },
+          "emote_3": { "primary": "left", "secondary": null },
+          "emote_4": { "primary": "right", "secondary": null },
+          "air_move": { "primary": "x", "secondary": null },
+          "class_ability": { "primary": "v", "secondary": null },
+          "death_cam_zoom_in": { "primary": "mouse wheel up", "secondary": null },
+          "death_cam_zoom_out": { "primary": "mouse wheel down", "secondary": null },
+          "push_to_talk": { "primary": "t", "secondary": null },
+          "ui_gamepad_button_back": { "primary": "tab", "secondary": null },
+          "ui_open_director": { "primary": "m", "secondary": null },
           "ui_open_director_store_tab": { "primary": null, "secondary": null },
           "ui_open_director_pursuits_tab": { "primary": null, "secondary": null },
           "ui_open_director_map_tab": { "primary": null, "secondary": null },
           "ui_open_director_destinations_tab": { "primary": null, "secondary": null },
-          "ui_open_director_roster_tab": { "primary": 37, "secondary": null },
+          "ui_open_director_roster_tab": { "primary": "u", "secondary": null },
           "ui_open_director_seasons_tab": { "primary": null, "secondary": null },
-          "ui_open_start_menu_alternative": { "primary": 38, "secondary": null },
+          "ui_open_start_menu_alternative": { "primary": "i", "secondary": null },
           "ui_open_start_menu_records_tab": { "primary": null, "secondary": null },
           "ui_open_start_menu_collections_tab": { "primary": null, "secondary": null },
           "ui_open_start_menu_clan_tab": { "primary": null, "secondary": null },
           "ui_open_start_menu_inventory_tab": { "primary": null, "secondary": null },
           "ui_open_start_menu_settings_tab": { "primary": null, "secondary": null },
-          "ui_open_exit_dialog_confirm": { "primary": 1024, "secondary": null },
-          "ui_abort_activity": { "primary": 39, "secondary": null },
-          "ui_text_chat_toggle_state": { "primary": 53, "secondary": null },
-          "screenshot": { "primary": 13, "secondary": null }
+          "ui_open_exit_dialog_confirm": { "primary": "shift+escape", "secondary": null },
+          "ui_abort_activity": { "primary": "o", "secondary": null },
+          "ui_text_chat_toggle_state": { "primary": "l", "secondary": null },
+          "screenshot": { "primary": "print screen", "secondary": null }
         }
       }
     },
@@ -525,7 +527,7 @@
         "race": 0,
         "gender": 0,
         "class": 0,
-        "movement_ability": 5,
+        "movement_ability": 6,
         "grenade_ability": 7,
         "super_ability": 10,
         "melee_ability": 11,

+ 2 - 2
Sunrise/resources/resource.h

@@ -9,8 +9,8 @@
 
 /** The four numeric fields of the version resource, in FILEVERSION order. */
 #define SUNRISE_VER_MAJOR 0
-#define SUNRISE_VER_MINOR 1
+#define SUNRISE_VER_MINOR 2
 #define SUNRISE_VER_PATCH 0
 #define SUNRISE_VER_BUILD 0
 /** The same version as display text. Windows shows this string, not the four fields. */
-#define SUNRISE_VER_STRING "0.1.0.0"
+#define SUNRISE_VER_STRING "0.2.0.0"

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

@@ -132,12 +132,12 @@ read_investment_constants(const reader::Source& source,
                           state::build_data::constants::InvestmentConstants& output) noexcept;
 
 /**
- * Builds the ability buckets one subclass publishes under one movement selection.
+ * Builds the ability buckets one subclass publishes under one ability selection.
  * @param source Package source.
  * @param scratch Reader scratch.
  * @param listDefinition Socket-entry-list definition bytes of the subclass.
  * @param blob Scratch storage reused for every pool blob.
- * @param movementEntry Selected movement entry.
+ * @param selection The character's 5 selected socket entries.
  * @param output Receives the 12 buckets and the overflow bank.
  * @return True when every selected entry reaches a bucket of its own.
  */
@@ -145,12 +145,12 @@ read_investment_constants(const reader::Source& source,
                                          reader::Scratch& scratch,
                                          std::span<const std::byte> listDefinition,
                                          std::vector<std::byte>& blob,
-                                         std::uint8_t movementEntry,
+                                         const state::build_data::abilities::Selection& selection,
                                          state::build_data::abilities::Definition& output) noexcept;
 
 /**
- * Builds one ability bucket row per distinct subclass and movement selection in use.
- * Two characters on the same subclass with the same movement selection publish identical
+ * Builds one ability bucket row per distinct subclass and ability selection in use.
+ * Two characters on the same subclass with the same ability selection publish identical
  * buckets, so the row is keyed by both and built once.
  * @param source Package source.
  * @param scratch Reader scratch.
@@ -214,7 +214,7 @@ investment_globals_tags(std::array<std::uint32_t, kContainerCandidates>& candida
 /** @param slot Requested-set position. @param definitionIndex Native item index that failed. */
 void report_detail_failure(std::size_t slot, std::uint16_t definitionIndex) noexcept;
 
-/** @param count Ability bucket rows the pass built, one per subclass and movement selection. */
+/** @param count Ability bucket rows the pass built, one per subclass and ability selection. */
 void report_ability_count(std::size_t count) noexcept;
 
 /** @param count Detail rows the pass built, covering equipped items and every plug they socket. */

+ 25 - 12
Sunrise/src/client/content/items/packages/package_ability_build.cpp

@@ -1,4 +1,3 @@
-#include <algorithm>
 #include <array>
 
 #include "../../../../middleware/content/packages/tables/ability_pool_reader.h"
@@ -14,13 +13,12 @@ namespace pool = middleware::content::packages::tables::abilities;
 namespace domain = state::build_data::abilities;
 
 /**
- * Socket-entry-list entries the character sheet's summary selects.
- * They are sprint, class ability, movement, grenade, super and melee. Only movement varies, and
- * the character's authored selection replaces this placeholder before the walk runs.
+ * Socket entry of the sprint ability, the one entry the character cannot choose.
+ * The other five come from the character's own selection.
  */
-constexpr std::uint8_t kSummaryEntries[]{1, 2, 4, 7, 10, 11};
-/** Position of the movement entry inside the summary selection. */
-constexpr std::size_t kMovementSummarySlot = 2;
+constexpr std::uint8_t kSprintEntry = 1;
+/** Number of socket entries the character sheet's summary selects. */
+constexpr std::size_t kSummaryEntryCount = 6;
 /** Entry kind of the super, which stays active without a plug source of its own. */
 constexpr std::uint8_t kSuperKind = 34;
 /** A selector chain longer than this is a cycle, not a chain. */
@@ -33,9 +31,25 @@ struct Walk {
     std::vector<std::byte>* blob{};
     std::array<pool::Entry, pool::kEntryCapacity> entries{};
     std::size_t entryCount{};
-    std::array<std::uint8_t, std::size(kSummaryEntries)> selected{};
+    std::array<std::uint8_t, kSummaryEntryCount> selected{};
 };
 
+/**
+ * Orders one character's selection the way the walk reads it.
+ * When two entries share a group the first one claims it, so this order is fixed.
+ * @param selection The character's 5 selected socket entries.
+ * @return The 6 summary entries in claim order.
+ */
+[[nodiscard]] std::array<std::uint8_t, kSummaryEntryCount>
+summary_entries(const domain::Selection& selection) noexcept {
+    return {kSprintEntry,
+            selection.classEntry,
+            selection.movementEntry,
+            selection.grenadeEntry,
+            selection.superEntry,
+            selection.meleeEntry};
+}
+
 /**
  * Reads one entry's pool records.
  * @param walk Subclass walk state.
@@ -175,12 +189,12 @@ void file_hash(const pool::PoolRecord& record, domain::Definition& output) noexc
 
 } // namespace
 
-/** Builds the ability buckets one subclass publishes under one movement selection. */
+/** Builds the ability buckets one subclass publishes under one ability selection. */
 bool build_ability_buckets(const reader::Source& source,
                            reader::Scratch& scratch,
                            std::span<const std::byte> listDefinition,
                            std::vector<std::byte>& blob,
-                           std::uint8_t movementEntry,
+                           const state::build_data::abilities::Selection& selection,
                            state::build_data::abilities::Definition& output) noexcept {
     Walk walk{};
     walk.source = &source;
@@ -190,8 +204,7 @@ bool build_ability_buckets(const reader::Source& source,
     if (walk.entryCount == 0) {
         return false;
     }
-    std::copy(std::begin(kSummaryEntries), std::end(kSummaryEntries), walk.selected.begin());
-    walk.selected[kMovementSummarySlot] = movementEntry;
+    walk.selected = summary_entries(selection);
 
     for (domain::Bucket& bucket : output.buckets) {
         bucket = {};

+ 1 - 1
Sunrise/src/client/content/items/packages/package_build_report.cpp

@@ -27,7 +27,7 @@ void report_detail_failure(std::size_t slot, std::uint16_t definitionIndex) noex
     }
 }
 
-/** @param count Ability bucket rows the pass built, one per subclass and movement selection. */
+/** @param count Ability bucket rows the pass built, one per subclass and ability selection. */
 void report_ability_count(std::size_t count) noexcept {
     std::array<char, 96> line{};
     const int written =

+ 16 - 9
Sunrise/src/client/content/items/packages/package_subclass_build.cpp

@@ -39,16 +39,25 @@ constexpr std::size_t kSubclassSlot =
                         const domain::Definition& row) noexcept {
     for (const domain::Definition& existing : rows) {
         if (existing.socketEntryListIndex == row.socketEntryListIndex
-            && existing.movementEntry == row.movementEntry) {
+            && existing.selection == row.selection) {
             return true;
         }
     }
     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};
+}
+
 } // namespace
 
-/** Builds one ability bucket row per distinct subclass and movement selection in use. */
+/** 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,
@@ -73,7 +82,9 @@ bool build_character_abilities(const reader::Source& source,
         if (!subclass_list(account.characters[character], row.socketEntryListIndex)) {
             continue;
         }
-        row.movementEntry = account.characters[character].movementAbilityEntry;
+        // 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]);
+        row.selection = selection;
         if (held(output.first(count), row)) {
             continue;
         }
@@ -82,12 +93,8 @@ bool build_character_abilities(const reader::Source& source,
                 std::span<const std::byte>{table}, rows, row.socketEntryListIndex, indexRow)
             || indexRow.targetTag == 0
             || !reader::read_tag(source, scratch, indexRow.targetTag, definition)
-            || !build_ability_buckets(source,
-                                      scratch,
-                                      std::span<const std::byte>{definition},
-                                      blob,
-                                      row.movementEntry,
-                                      row)) {
+            || !build_ability_buckets(
+                source, scratch, std::span<const std::byte>{definition}, blob, selection, row)) {
             continue;
         }
         output[count++] = row;

+ 8 - 13
Sunrise/src/client/hooks/bootflow/spawn_hold.cpp

@@ -25,13 +25,6 @@ constexpr auto kSpawnGateSignature =
 /** Answer that holds the spawn for this tick. The gate is polled, so a refusal only delays it. */
 constexpr bool kHeld = false;
 
-/**
- * Longest the spawn is held for one load. The slice-set load step takes 9.2 to 14.1 s, so this is
- * twice the worst load seen. Only a destination that never reaches the in-world step hits it, and
- * spawning such a load early beats never spawning it at all.
- */
-constexpr std::uint64_t kMaximumHoldMs = 30'000;
-
 using SpawnGate = bool(__fastcall*)(std::int32_t) noexcept;
 
 hooking::detour::Handle g_handle{};
@@ -50,12 +43,14 @@ __declspec(noinline) bool __fastcall spawn_gate(std::int32_t datum) noexcept {
     observe_world_step();
     const state::activity::WorldPhase phase = state::activity::world_phase();
     const bool transitioning = phase == state::activity::WorldPhase::transitioning;
-    const bool gaveUp = transitioning && state::activity::world_transition_age() >= kMaximumHoldMs;
-    const bool loading = transitioning && !gaveUp && core::settings::get().client.holdSpawn;
-    // The release runs once the load is over, and also when the hold gave up on kMaximumHoldMs
-    // instead of on arrival. That second case spawns with the fade still up, and nothing polls
-    // this gate after, so a release put off past here never runs.
-    if (phase == state::activity::WorldPhase::arrived || gaveUp) {
+    // Zero unless a load is running.
+    const std::uint64_t age = state::activity::world_transition_age();
+    const core::settings::client::Settings& client = core::settings::get().client;
+    const bool gaveUp = age >= client.spawnHoldMs;
+    const bool loading = transitioning && !gaveUp && client.holdSpawn;
+    // Release only on arrival. The step-37 exit re-arms the fade unless one is already up, and
+    // nothing polls this gate after the spawn, so an early release leaves a fade nobody clears.
+    if (phase == state::activity::WorldPhase::arrived) {
         release_world_fade();
     }
     return allowed && loading ? kHeld : allowed;

+ 1 - 1
Sunrise/src/client/hooks/bootflow/world_step.cpp

@@ -20,7 +20,7 @@ constexpr std::string_view kStepSignatureText =
 /** Compiled pattern bytes of the signature text above. */
 constexpr auto kStepSignature = signature<signature_length(kStepSignatureText)>(kStepSignatureText);
 
-/** First step that loads the map with no player in it yet. Step names: RE/16 "The step table". */
+/** First step that loads the map with no player in it yet. */
 constexpr std::int32_t kActivityLoadFirst = 33;
 /** `activity:in_world`. The fade is armed by then, so a spawn now releases it. */
 constexpr std::int32_t kInWorld = 38;

+ 91 - 33
Sunrise/src/client/hooks/graphics/discovery/graphics_target_discovery.cpp

@@ -1,5 +1,9 @@
 #include <array>
+#include <cstddef>
+#include <cstdio>
+#include <string_view>
 
+#include "../../../../core/logging/log.h"
 #include "../graphics_hook_replacements.h"
 
 namespace sunrise::client::hooks::graphics {
@@ -48,7 +52,53 @@ void release_probe(Probe& probe) noexcept {
 }
 
 /**
- * Creates one SDK swap chain: hardware first, the WARP fallback second.
+ * Levels down to the D3D11 floor. The vtable is the same at each, so the probe takes whatever
+ * the installed adapter supports.
+ */
+constexpr std::array kFeatureLevels{
+    D3D_FEATURE_LEVEL_11_0,
+    D3D_FEATURE_LEVEL_10_1,
+    D3D_FEATURE_LEVEL_10_0,
+    D3D_FEATURE_LEVEL_9_3,
+    D3D_FEATURE_LEVEL_9_2,
+    D3D_FEATURE_LEVEL_9_1,
+};
+
+/** One probe creation attempt, named for the log line it produces. */
+struct Attempt {
+    D3D_DRIVER_TYPE driver{};
+    bool requestLevels{};
+    std::string_view name{};
+};
+
+/**
+ * Attempt order. The second asks for no level list, which some older drivers need.
+ * WARP is last because it is the slowest to create.
+ */
+constexpr std::array<Attempt, 3> kAttempts{
+    Attempt{D3D_DRIVER_TYPE_HARDWARE, true, "hardware"},
+    Attempt{D3D_DRIVER_TYPE_HARDWARE, false, "hardware_default"},
+    Attempt{D3D_DRIVER_TYPE_WARP, true, "warp"},
+};
+
+/** @param level Level the probe device reached. @param name Attempt that created it. */
+void report_probe(std::string_view name, D3D_FEATURE_LEVEL level) noexcept {
+    std::array<char, 96> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=graphics stage=probe result=ok driver=%.*s level=0x%04X",
+                                      static_cast<int>(name.size()),
+                                      name.data(),
+                                      static_cast<unsigned>(level));
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Creates one SDK swap chain, trying each attempt in order.
  * @param create SDK D3D11 factory entry from the system module.
  * @param probe Receives all temporary COM objects.
  * @return True when a classic IDXGISwapChain vtable is available.
@@ -68,6 +118,9 @@ void release_probe(Probe& probe) noexcept {
                                    GetModuleHandleW(nullptr),
                                    nullptr);
     if (probe.window == nullptr) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         "ev=graphics stage=probe result=fail reason=window");
         return false;
     }
 
@@ -81,38 +134,33 @@ void release_probe(Probe& probe) noexcept {
     description.OutputWindow = probe.window;
     description.Windowed = TRUE;
     description.SwapEffect = DXGI_SWAP_EFFECT_DISCARD;
-    /** D3D11 baseline support is enough to expose the classic swap-chain ABI. */
-    constexpr std::array kFeatureLevels{D3D_FEATURE_LEVEL_11_0};
-
-    HRESULT result = create(nullptr,
-                            D3D_DRIVER_TYPE_HARDWARE,
-                            nullptr,
-                            0,
-                            kFeatureLevels.data(),
-                            static_cast<UINT>(kFeatureLevels.size()),
-                            D3D11_SDK_VERSION,
-                            &description,
-                            &probe.swapChain,
-                            &probe.device,
-                            nullptr,
-                            &probe.context);
-    if (FAILED(result)) {
+
+    for (const Attempt& attempt : kAttempts) {
+        D3D_FEATURE_LEVEL level{};
+        const HRESULT result =
+            create(nullptr,
+                   attempt.driver,
+                   nullptr,
+                   0,
+                   attempt.requestLevels ? kFeatureLevels.data() : nullptr,
+                   attempt.requestLevels ? static_cast<UINT>(kFeatureLevels.size()) : 0,
+                   D3D11_SDK_VERSION,
+                   &description,
+                   &probe.swapChain,
+                   &probe.device,
+                   &level,
+                   &probe.context);
+        if (SUCCEEDED(result) && probe.swapChain != nullptr && probe.device != nullptr
+            && probe.context != nullptr) {
+            report_probe(attempt.name, level);
+            return true;
+        }
         release_probe_com(probe);
-        result = create(nullptr,
-                        D3D_DRIVER_TYPE_WARP,
-                        nullptr,
-                        0,
-                        kFeatureLevels.data(),
-                        static_cast<UINT>(kFeatureLevels.size()),
-                        D3D11_SDK_VERSION,
-                        &description,
-                        &probe.swapChain,
-                        &probe.device,
-                        nullptr,
-                        &probe.context);
     }
-    return SUCCEEDED(result) && probe.swapChain != nullptr && probe.device != nullptr
-           && probe.context != nullptr;
+    core::log::write(core::log::Channel::client,
+                     core::log::Level::warn,
+                     "ev=graphics stage=probe result=fail reason=device");
+    return false;
 }
 
 } // namespace
@@ -145,6 +193,9 @@ bool resolve(Targets& output) noexcept {
         if (dxgiModule != nullptr) {
             FreeLibrary(dxgiModule);
         }
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         "ev=graphics stage=probe result=fail reason=module");
         return false;
     }
 
@@ -161,17 +212,24 @@ bool resolve(Targets& output) noexcept {
     void** methods = *reinterpret_cast<void***>(probe.swapChain);
     void* present = methods[kPresentMethodIndex];
     void* resizeBuffers = methods[kResizeBuffersMethodIndex];
+    void* setFullscreenState = methods[kSetFullscreenStateMethodIndex];
+    // Distinct entries prove the slots were read from a real vtable, not from a shared thunk.
     const bool valid = executable_image_target(present, dxgiModule)
                        && executable_image_target(resizeBuffers, dxgiModule)
-                       && present != resizeBuffers;
+                       && executable_image_target(setFullscreenState, dxgiModule)
+                       && present != resizeBuffers && present != setFullscreenState
+                       && resizeBuffers != setFullscreenState;
     release_probe(probe);
     if (!valid) {
         FreeLibrary(d3d11Module);
         FreeLibrary(dxgiModule);
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         "ev=graphics stage=probe result=fail reason=vtable");
         return false;
     }
 
-    output = Targets{present, resizeBuffers, d3d11Module, dxgiModule};
+    output = Targets{present, resizeBuffers, setFullscreenState, d3d11Module, dxgiModule};
     return true;
 }
 

+ 2 - 0
Sunrise/src/client/hooks/graphics/graphics_hook_lifecycle.cpp

@@ -57,6 +57,8 @@ bool install() noexcept {
                               entryPoints[static_cast<std::size_t>(HookSlot::present)]},
         hooking::detour::Spec{g_targets.resizeBuffers,
                               entryPoints[static_cast<std::size_t>(HookSlot::resizeBuffers)]},
+        hooking::detour::Spec{g_targets.setFullscreenState,
+                              entryPoints[static_cast<std::size_t>(HookSlot::setFullscreenState)]},
     };
     for (std::size_t index = 0; index < specs.size(); ++index) {
         g_targetEntries[index] = specs[index].target;

+ 30 - 3
Sunrise/src/client/hooks/graphics/graphics_hook_replacements.cpp

@@ -79,11 +79,35 @@ __declspec(noinline) HRESULT STDMETHODCALLTYPE resize_buffers_body(IDXGISwapChai
     HRESULT result = DXGI_ERROR_INVALID_CALL;
     if (call != nullptr) {
         if (rendererEnabled) {
-            renderer::before_resize(swapChain);
+            renderer::before_surface_change(swapChain);
         }
         result = call(swapChain, bufferCount, width, height, format, flags);
         if (rendererEnabled) {
-            renderer::after_resize(swapChain, result);
+            renderer::after_surface_change(swapChain, result);
+        }
+    }
+    leave_hook_call();
+    return result;
+}
+
+/**
+ * Drops and rebuilds the selected render target around a mode change.
+ * The change recreates the back buffers, so our view must not hold one across it.
+ * @param swapChain SDK swap chain supplied by DXGI.
+ * @return Exact HRESULT returned by the original SDK method.
+ */
+__declspec(noinline) HRESULT STDMETHODCALLTYPE set_fullscreen_state_body(
+    IDXGISwapChain* swapChain, BOOL fullscreen, IDXGIOutput* target) noexcept {
+    const bool rendererEnabled = enter_hook_call();
+    const auto call = original<SetFullscreenState>(HookSlot::setFullscreenState);
+    HRESULT result = DXGI_ERROR_INVALID_CALL;
+    if (call != nullptr) {
+        if (rendererEnabled) {
+            renderer::before_surface_change(swapChain);
+        }
+        result = call(swapChain, fullscreen, target);
+        if (rendererEnabled) {
+            renderer::after_surface_change(swapChain, result);
         }
     }
     leave_hook_call();
@@ -94,7 +118,9 @@ __declspec(noinline) HRESULT STDMETHODCALLTYPE resize_buffers_body(IDXGISwapChai
 
 /** @return Direct internal-linkage bodies that cannot become linker thunks. */
 EntryPoints entry_points() noexcept {
-    return {reinterpret_cast<void*>(&present_body), reinterpret_cast<void*>(&resize_buffers_body)};
+    return {reinterpret_cast<void*>(&present_body),
+            reinterpret_cast<void*>(&resize_buffers_body),
+            reinterpret_cast<void*>(&set_fullscreen_state_body)};
 }
 
 /** @return Every unwind-backed body that can own an admitted replacement call. */
@@ -102,6 +128,7 @@ ProtectedEntries protected_entries() noexcept {
     return {
         hooking::detour::ProtectedCodeEntry{reinterpret_cast<void*>(&present_body)},
         hooking::detour::ProtectedCodeEntry{reinterpret_cast<void*>(&resize_buffers_body)},
+        hooking::detour::ProtectedCodeEntry{reinterpret_cast<void*>(&set_fullscreen_state_body)},
         hooking::detour::ProtectedCodeEntry{reinterpret_cast<void*>(&enter_hook_call)},
         hooking::detour::ProtectedCodeEntry{reinterpret_cast<void*>(&leave_hook_call)},
     };

+ 9 - 3
Sunrise/src/client/hooks/graphics/graphics_hook_replacements.h

@@ -19,25 +19,31 @@ using Present = HRESULT(STDMETHODCALLTYPE*)(IDXGISwapChain*, UINT, UINT);
 /** SDK ABI for IDXGISwapChain::ResizeBuffers. */
 using ResizeBuffers =
     HRESULT(STDMETHODCALLTYPE*)(IDXGISwapChain*, UINT, UINT, UINT, DXGI_FORMAT, UINT);
+/** SDK ABI for IDXGISwapChain::SetFullscreenState. */
+using SetFullscreenState = HRESULT(STDMETHODCALLTYPE*)(IDXGISwapChain*, BOOL, IDXGIOutput*);
 
 /** Stable slots shared by discovery targets and Detours handles. */
 enum class HookSlot : std::size_t {
     present,
     resizeBuffers,
+    setFullscreenState,
     count,
 };
 
-/** Two SDK methods form the complete graphics hook transaction. */
+/** Every SDK method that forms the graphics hook transaction. */
 inline constexpr std::size_t kHandleCount = static_cast<std::size_t>(HookSlot::count);
 /** IDXGISwapChain inherits 7 methods before its own Present. */
 inline constexpr std::size_t kPresentMethodIndex = 8;
 /** ResizeBuffers comes 5 IDXGISwapChain methods after Present. */
 inline constexpr std::size_t kResizeBuffersMethodIndex = 13;
+/** SetFullscreenState comes 2 IDXGISwapChain methods after Present. */
+inline constexpr std::size_t kSetFullscreenStateMethodIndex = 10;
 
 /** System methods and module references kept through detachment. */
 struct Targets {
     void* present{};
     void* resizeBuffers{};
+    void* setFullscreenState{};
     HMODULE d3d11Module{};
     HMODULE dxgiModule{};
 };
@@ -98,8 +104,8 @@ void release(Targets& targets) noexcept;
 
 namespace replacement {
 
-/** Present, ResizeBuffers, ingress and egress: the bodies a suspended call can sit in. */
-inline constexpr std::size_t kProtectedEntryCount = 4;
+/** Every replacement body, plus ingress and egress: the bodies a suspended call can sit in. */
+inline constexpr std::size_t kProtectedEntryCount = 5;
 
 /** Direct replacement bodies indexed by HookSlot. */
 using EntryPoints = std::array<void*, kHandleCount>;

+ 18 - 12
Sunrise/src/client/hooks/graphics/renderer/graphics_renderer_device.cpp

@@ -2,6 +2,7 @@
 
 #include <dxgi.h>
 
+#include "graphics_renderer_report.h"
 #include "state.h"
 
 namespace sunrise::client::hooks::graphics::renderer {
@@ -15,31 +16,35 @@ namespace {
 
 } // namespace
 
-/** Drops our RTV before the original resize call runs. */
-void before_resize(IDXGISwapChain* swapChain) noexcept {
+/** Drops our RTV before the original call runs. Our view would pin the old back buffer. */
+void before_surface_change(IDXGISwapChain* swapChain) noexcept {
     AcquireSRWLockExclusive(&g_rendererLock);
     if (g_resources.swapChain == swapChain) {
-        if (g_resources.activeResizeCalls == 0) {
+        if (g_resources.activeSurfaceChanges == 0) {
             // The first of any overlapping calls drops the RTV until they have all returned.
             release_render_target(g_resources);
         }
-        ++g_resources.activeResizeCalls;
+        ++g_resources.activeSurfaceChanges;
     }
     ReleaseSRWLockExclusive(&g_rendererLock);
 }
 
-/** Rebuilds our render state after the original resize returns. */
-void after_resize(IDXGISwapChain* swapChain, HRESULT result) noexcept {
+/** Rebuilds our render state after the original call returns. */
+void after_surface_change(IDXGISwapChain* swapChain, HRESULT result) noexcept {
     AcquireSRWLockExclusive(&g_rendererLock);
-    if (g_resources.swapChain == swapChain && g_resources.activeResizeCalls != 0) {
-        g_resources.resizeDeviceLost = g_resources.resizeDeviceLost || is_device_loss(result);
-        --g_resources.activeResizeCalls;
-        if (g_resources.activeResizeCalls == 0) {
+    if (g_resources.swapChain == swapChain && g_resources.activeSurfaceChanges != 0) {
+        g_resources.surfaceChangeDeviceLost =
+            g_resources.surfaceChangeDeviceLost || is_device_loss(result);
+        --g_resources.activeSurfaceChanges;
+        if (g_resources.activeSurfaceChanges == 0) {
             const bool targetReady = create_render_target(g_resources);
-            const bool deviceLost = g_resources.resizeDeviceLost;
-            g_resources.resizeDeviceLost = false;
+            const bool deviceLost = g_resources.surfaceChangeDeviceLost;
+            g_resources.surfaceChangeDeviceLost = false;
             if (deviceLost || !targetReady) {
                 // No RTV goes out until the last overlapping call has returned.
+                report::note(report::Stage::shutdown,
+                             deviceLost ? report::Reason::deviceLost
+                                        : report::Reason::rebuildTarget);
                 (void)shutdown_locked();
             }
         }
@@ -54,6 +59,7 @@ void present_result(IDXGISwapChain* swapChain, HRESULT result) noexcept {
     }
     AcquireSRWLockExclusive(&g_rendererLock);
     if (g_resources.swapChain == swapChain) {
+        report::note(report::Stage::shutdown, report::Reason::deviceLost);
         (void)shutdown_locked();
     }
     ReleaseSRWLockExclusive(&g_rendererLock);

+ 2 - 0
Sunrise/src/client/hooks/graphics/renderer/graphics_renderer_frame.cpp

@@ -13,6 +13,7 @@
 #include "../../../../core/ui/scaling/dpi/ui_dpi_scaling.h"
 #include "../../../../core/ui/theme/sunrise_ui_theme.h"
 #include "../input/input.h"
+#include "graphics_renderer_report.h"
 #include "state.h"
 
 extern IMGUI_IMPL_API LRESULT ImGui_ImplWin32_WndProcHandler(HWND window,
@@ -128,6 +129,7 @@ void render_frame_locked() noexcept {
         // Style and text scale change together, before the backend sets up the frame.
         core::ui::theme::apply();
         if (!core::ui::fonts::runtime::apply_scale(core::ui::scaling::dpi::current())) {
+            report::note(report::Stage::frame, report::Reason::fontScale);
             (void)shutdown_locked();
             return;
         }

+ 68 - 6
Sunrise/src/client/hooks/graphics/renderer/graphics_renderer_lifecycle.cpp

@@ -1,5 +1,6 @@
 #include <Windows.h>
 
+#include <array>
 #include <imgui_impl_dx11.h>
 #include <imgui_impl_win32.h>
 
@@ -8,6 +9,7 @@
 #include "../../cursor/runtime.h"
 #include "../../polled_input/runtime.h"
 #include "../input/input.h"
+#include "graphics_renderer_report.h"
 #include "state.h"
 
 namespace sunrise::client::hooks::graphics::renderer {
@@ -68,6 +70,44 @@ template <typename Interface> void release_com(Interface*& object) noexcept {
     }
 }
 
+/** One typeless back-buffer format and the view format that reads it. */
+struct ViewFormat {
+    DXGI_FORMAT stored{};
+    DXGI_FORMAT view{};
+};
+
+/** Every typeless format DXGI allows for a back buffer, with the view format for each. */
+constexpr std::array<ViewFormat, 6> kTypelessViewFormats{
+    ViewFormat{DXGI_FORMAT_R8G8B8A8_TYPELESS, DXGI_FORMAT_R8G8B8A8_UNORM},
+    ViewFormat{DXGI_FORMAT_B8G8R8A8_TYPELESS, DXGI_FORMAT_B8G8R8A8_UNORM},
+    ViewFormat{DXGI_FORMAT_B8G8R8X8_TYPELESS, DXGI_FORMAT_B8G8R8X8_UNORM},
+    ViewFormat{DXGI_FORMAT_R10G10B10A2_TYPELESS, DXGI_FORMAT_R10G10B10A2_UNORM},
+    ViewFormat{DXGI_FORMAT_R16G16B16A16_TYPELESS, DXGI_FORMAT_R16G16B16A16_FLOAT},
+    ViewFormat{DXGI_FORMAT_R32G32B32A32_TYPELESS, DXGI_FORMAT_R32G32B32A32_FLOAT},
+};
+
+/**
+ * Describes the view for a typeless back buffer, which has no format to infer.
+ * @param output Receives the description. Untouched for any other format.
+ * @return True when an explicit description was written.
+ */
+[[nodiscard]] bool describe_view(ID3D11Texture2D* backBuffer,
+                                 D3D11_RENDER_TARGET_VIEW_DESC& output) noexcept {
+    D3D11_TEXTURE2D_DESC texture{};
+    backBuffer->GetDesc(&texture);
+    for (const ViewFormat& candidate : kTypelessViewFormats) {
+        if (candidate.stored != texture.Format) {
+            continue;
+        }
+        output = {};
+        output.Format = candidate.view;
+        output.ViewDimension = texture.SampleDesc.Count > 1 ? D3D11_RTV_DIMENSION_TEXTURE2DMS
+                                                            : D3D11_RTV_DIMENSION_TEXTURE2D;
+        return true;
+    }
+    return false;
+}
+
 /**
  * Stops the started layers in reverse order, then frees all SDK resources.
  * @param resources Fully or partly started renderer resources.
@@ -125,26 +165,32 @@ template <typename Interface> void release_com(Interface*& object) noexcept {
 [[nodiscard]] bool initialize_locked(IDXGISwapChain* swapChain) noexcept {
     Resources staged;
     if (!selection::acquire(swapChain, staged)) {
+        // acquire reports its own step.
         return false;
     }
     if (!core::ui::layout::initialize()) {
         release_resources(staged);
+        report::note(report::Stage::init, report::Reason::layout);
         return false;
     }
     staged.layoutInitialized = true;
     if (!ImGui_ImplWin32_Init(staged.window)) {
+        report::note(report::Stage::init, report::Reason::win32Backend);
         return discard_staged(staged);
     }
     staged.win32BackendInitialized = true;
     if (!ImGui_ImplDX11_Init(staged.device, staged.context)) {
+        report::note(report::Stage::init, report::Reason::dx11Backend);
         return discard_staged(staged);
     }
     staged.dx11BackendInitialized = true;
     if (!input::install(staged.window)) {
+        report::note(report::Stage::init, report::Reason::windowInput);
         return discard_staged(staged);
     }
     staged.inputInstalled = true;
     g_resources = staged;
+    report::note_active();
     return true;
 }
 
@@ -169,12 +215,27 @@ bool create_render_target(Resources& resources) noexcept {
         kBackBufferIndex, __uuidof(ID3D11Texture2D), reinterpret_cast<void**>(&backBuffer));
     if (FAILED(bufferResult) || backBuffer == nullptr) {
         release_com(backBuffer);
+        report::note(report::Stage::target, report::Reason::backBuffer);
         return false;
     }
-    const HRESULT targetResult =
+    HRESULT targetResult =
         resources.device->CreateRenderTargetView(backBuffer, nullptr, &resources.renderTarget);
+    if (FAILED(targetResult)) {
+        // The inferred description covers a typed buffer only.
+        D3D11_RENDER_TARGET_VIEW_DESC view{};
+        if (describe_view(backBuffer, view)) {
+            targetResult = resources.device->CreateRenderTargetView(
+                backBuffer, &view, &resources.renderTarget);
+        } else {
+            report::note(report::Stage::target, report::Reason::viewFormat);
+        }
+    }
     release_com(backBuffer);
-    return SUCCEEDED(targetResult) && resources.renderTarget != nullptr;
+    if (FAILED(targetResult) || resources.renderTarget == nullptr) {
+        report::note(report::Stage::target, report::Reason::view);
+        return false;
+    }
+    return true;
 }
 
 /** @param resources Chosen resources whose RTV is released. */
@@ -205,7 +266,7 @@ bool fully_active_locked() noexcept {
            && g_resources.context != nullptr && g_resources.renderTarget != nullptr
            && g_resources.layoutInitialized && g_resources.win32BackendInitialized
            && g_resources.dx11BackendInitialized && g_resources.inputInstalled
-           && g_resources.activeResizeCalls == 0 && input::active(g_resources.window);
+           && g_resources.activeSurfaceChanges == 0 && input::active(g_resources.window);
 }
 
 /** Shuts the whole renderer down under the exclusive state lock. */
@@ -228,7 +289,7 @@ bool active() noexcept {
 void present(IDXGISwapChain* swapChain) noexcept {
     AcquireSRWLockExclusive(&g_rendererLock);
     if (g_resources.swapChain != nullptr && g_resources.swapChain != swapChain
-        && g_resources.activeResizeCalls == 0
+        && g_resources.activeSurfaceChanges == 0
         && selection::matches_output_window(swapChain, g_resources.window)) {
         // A new valid surface for the same window replaces the retired chain.
         if (!shutdown_locked()) {
@@ -237,11 +298,12 @@ void present(IDXGISwapChain* swapChain) noexcept {
         }
     }
     if (g_resources.swapChain != nullptr && !fully_active_locked()) {
-        if (g_resources.activeResizeCalls != 0) {
-            // Present can run at the same time as the original resize, which owns the back buffer.
+        if (g_resources.activeSurfaceChanges != 0) {
+            // Present can run at the same time as the original call, which owns the back buffer.
             ReleaseSRWLockExclusive(&g_rendererLock);
             return;
         }
+        report::note(report::Stage::shutdown, report::Reason::surfaceLost);
         if (!shutdown_locked()) {
             ReleaseSRWLockExclusive(&g_rendererLock);
             return;

+ 102 - 0
Sunrise/src/client/hooks/graphics/renderer/graphics_renderer_report.cpp

@@ -0,0 +1,102 @@
+#include "graphics_renderer_report.h"
+
+#include <array>
+#include <atomic>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <limits>
+#include <string_view>
+
+#include "../../../../core/logging/log.h"
+
+namespace sunrise::client::hooks::graphics::renderer::report {
+namespace {
+
+/** One name per Stage value, in Stage order. */
+constexpr std::array<std::string_view, 6> kStageNames{
+    "none",
+    "acquire",
+    "target",
+    "init",
+    "frame",
+    "shutdown",
+};
+
+/** One name per Reason value, in Reason order. */
+constexpr std::array<std::string_view, 18> kReasonNames{
+    "none",
+    "window",
+    "window_thread",
+    "window_class",
+    "description",
+    "device",
+    "context",
+    "back_buffer",
+    "view",
+    "view_format",
+    "layout",
+    "win32_backend",
+    "dx11_backend",
+    "window_input",
+    "font_scale",
+    "device_lost",
+    "rebuild_target",
+    "surface_lost",
+};
+
+/** Both tables are indexed by the enum value, so a new entry must extend them. */
+static_assert(kStageNames.size() == static_cast<std::size_t>(Stage::shutdown) + 1);
+static_assert(kReasonNames.size() == static_cast<std::size_t>(Reason::surfaceLost) + 1);
+
+/** One reason never belongs to two stages, so its own bit identifies the whole outcome. */
+static_assert(kReasonNames.size() <= std::numeric_limits<std::uint32_t>::digits);
+
+/** Reasons written since the stack was last active. */
+std::atomic_uint32_t g_writtenReasons{};
+/** Set while the active stack has been written, so it is not written again. */
+std::atomic_bool g_writtenActive{};
+
+/** @return The reason's own bit. */
+[[nodiscard]] std::uint32_t bit_of(Reason reason) noexcept {
+    return 1U << static_cast<unsigned>(reason);
+}
+
+} // namespace
+
+/** Writes one outcome line, but only the first time that reason comes up. */
+void note(Stage stage, Reason reason) noexcept {
+    const std::uint32_t bit = bit_of(reason);
+    if ((g_writtenReasons.fetch_or(bit, std::memory_order_acq_rel) & bit) != 0) {
+        return;
+    }
+    g_writtenActive.store(false, std::memory_order_release);
+    const std::string_view stageName = kStageNames[static_cast<std::size_t>(stage)];
+    const std::string_view reasonName = kReasonNames[static_cast<std::size_t>(reason)];
+    std::array<char, 96> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=renderer stage=%.*s result=fail reason=%.*s",
+                                      static_cast<int>(stageName.size()),
+                                      stageName.data(),
+                                      static_cast<int>(reasonName.size()),
+                                      reasonName.data());
+    if (written <= 0) {
+        return;
+    }
+    core::log::write(core::log::Channel::client,
+                     core::log::Level::warn,
+                     {line.data(), static_cast<std::size_t>(written)});
+}
+
+/** Clears the written reasons so a later failure is written again, and reports the stack once. */
+void note_active() noexcept {
+    g_writtenReasons.store(0, std::memory_order_release);
+    if (g_writtenActive.exchange(true, std::memory_order_acq_rel)) {
+        return;
+    }
+    core::log::write(
+        core::log::Channel::client, core::log::Level::info, "ev=renderer stage=init result=ok");
+}
+
+} // namespace sunrise::client::hooks::graphics::renderer::report

+ 51 - 0
Sunrise/src/client/hooks/graphics/renderer/graphics_renderer_report.h

@@ -0,0 +1,51 @@
+#pragma once
+
+namespace sunrise::client::hooks::graphics::renderer::report {
+
+/** Stage of the presentation stack that produced an outcome. */
+enum class Stage : unsigned {
+    none,
+    acquire,
+    target,
+    init,
+    frame,
+    shutdown,
+};
+
+/** Why a stage ended the way it did. Ordered by the step that produces it. */
+enum class Reason : unsigned {
+    none,
+    // acquire
+    window,
+    windowThread,
+    windowClass,
+    description,
+    device,
+    context,
+    // target
+    backBuffer,
+    view,
+    viewFormat,
+    // init
+    layout,
+    win32Backend,
+    dx11Backend,
+    windowInput,
+    // frame
+    fontScale,
+    // shutdown
+    deviceLost,
+    rebuildTarget,
+    surfaceLost,
+};
+
+/**
+ * Writes one outcome the first time its reason comes up. Present calls this every frame.
+ * Reasons are unique across stages, so one bit per reason is enough.
+ */
+void note(Stage stage, Reason reason) noexcept;
+
+/** Records that the stack is active, so a later failure is written again. */
+void note_active() noexcept;
+
+} // namespace sunrise::client::hooks::graphics::renderer::report

+ 4 - 4
Sunrise/src/client/hooks/graphics/renderer/renderer.h

@@ -9,11 +9,11 @@ namespace sunrise::client::hooks::graphics::renderer {
 /** Draws the UI frame, if any, for a checked swap chain. */
 void present(IDXGISwapChain* swapChain) noexcept;
 
-/** Drops our render target before a resize. */
-void before_resize(IDXGISwapChain* swapChain) noexcept;
+/** Drops our render target before a call that recreates the back buffers. */
+void before_surface_change(IDXGISwapChain* swapChain) noexcept;
 
-/** Rebuilds our render state after the original resize returns. */
-void after_resize(IDXGISwapChain* swapChain, HRESULT result) noexcept;
+/** Rebuilds our render state after that call returns. */
+void after_surface_change(IDXGISwapChain* swapChain, HRESULT result) noexcept;
 
 /** Frees our resources when Present reports a lost device. */
 void present_result(IDXGISwapChain* swapChain, HRESULT result) noexcept;

+ 39 - 16
Sunrise/src/client/hooks/graphics/renderer/selection/graphics_swap_chain_selection.cpp

@@ -4,6 +4,7 @@
 #include <cstdint>
 
 #include "../../graphics_hook_replacements.h"
+#include "../graphics_renderer_report.h"
 #include "../state.h"
 
 namespace sunrise::client::hooks::graphics::renderer::selection {
@@ -14,29 +15,39 @@ constexpr std::int64_t kMinimumClientExtent = 64;
 
 /**
  * Checks the window's identity and size. Does not use a game class name.
+ * Each rejection reports its own reason.
  * @param window Swap-chain output window.
  * @return True for a visible root window of this process, on this thread.
  */
-[[nodiscard]] bool eligible_window(HWND window) noexcept {
+[[nodiscard]] bool eligible_window(HWND window, bool reporting) noexcept {
+    const auto reject = [reporting](report::Reason reason) noexcept {
+        if (reporting) {
+            report::note(report::Stage::acquire, reason);
+        }
+        return false;
+    };
     if (window == nullptr || IsWindow(window) == FALSE || IsWindowVisible(window) == FALSE
         || GetAncestor(window, GA_ROOT) != window) {
-        return false;
+        return reject(report::Reason::window);
     }
 
     DWORD processId = 0;
     const DWORD threadId = GetWindowThreadProcessId(window, &processId);
     if (processId != GetCurrentProcessId() || threadId != GetCurrentThreadId()) {
-        return false;
+        return reject(report::Reason::windowThread);
     }
 
     RECT client{};
     if (GetClientRect(window, &client) == FALSE || client.right <= client.left
         || client.bottom <= client.top) {
-        return false;
+        return reject(report::Reason::window);
     }
     const std::int64_t width = static_cast<std::int64_t>(client.right) - client.left;
     const std::int64_t height = static_cast<std::int64_t>(client.bottom) - client.top;
-    return width >= kMinimumClientExtent && height >= kMinimumClientExtent;
+    if (width < kMinimumClientExtent || height < kMinimumClientExtent) {
+        return reject(report::Reason::window);
+    }
+    return true;
 }
 
 /**
@@ -44,30 +55,39 @@ constexpr std::int64_t kMinimumClientExtent = 64;
  * @param window Window that already passed the other checks.
  * @return True when the class can belong to the shipped game executable.
  */
-[[nodiscard]] bool main_image_window_class(HWND window) noexcept {
+[[nodiscard]] bool main_image_window_class(HWND window, bool reporting) noexcept {
     SetLastError(ERROR_SUCCESS);
     const ULONG_PTR procedure = GetClassLongPtrW(window, GCLP_WNDPROC);
-    if (procedure == 0 && GetLastError() != ERROR_SUCCESS) {
-        return false;
+    const bool owned = (procedure != 0 || GetLastError() == ERROR_SUCCESS)
+                       && executable_image_target(std::bit_cast<const void*>(procedure),
+                                                  GetModuleHandleW(nullptr));
+    if (!owned && reporting) {
+        report::note(report::Stage::acquire, report::Reason::windowClass);
     }
-    return executable_image_target(std::bit_cast<const void*>(procedure),
-                                   GetModuleHandleW(nullptr));
+    return owned;
 }
 
 /**
  * Checks the swap-chain description before we take any COM reference.
  * @param window Receives the output window.
+ * @param reporting False for a query, so a second surface does not log an acquire failure.
  * @return True when the candidate can be the game's current D3D11 presentation surface.
  */
-[[nodiscard]] bool eligible_swap_chain(IDXGISwapChain* swapChain, HWND& window) noexcept {
+[[nodiscard]] bool
+eligible_swap_chain(IDXGISwapChain* swapChain, HWND& window, bool reporting) noexcept {
     if (swapChain == nullptr) {
         return false;
     }
     DXGI_SWAP_CHAIN_DESC description{};
     if (FAILED(swapChain->GetDesc(&description))
-        || (description.BufferUsage & DXGI_USAGE_RENDER_TARGET_OUTPUT) == 0
-        || !eligible_window(description.OutputWindow)
-        || !main_image_window_class(description.OutputWindow)) {
+        || (description.BufferUsage & DXGI_USAGE_RENDER_TARGET_OUTPUT) == 0) {
+        if (reporting) {
+            report::note(report::Stage::acquire, report::Reason::description);
+        }
+        return false;
+    }
+    if (!eligible_window(description.OutputWindow, reporting)
+        || !main_image_window_class(description.OutputWindow, reporting)) {
         return false;
     }
     window = description.OutputWindow;
@@ -85,7 +105,7 @@ constexpr std::int64_t kMinimumClientExtent = 64;
 bool acquire(IDXGISwapChain* swapChain, Resources& output) noexcept {
     output = {};
     HWND window = nullptr;
-    if (!eligible_swap_chain(swapChain, window)) {
+    if (!eligible_swap_chain(swapChain, window, true)) {
         return false;
     }
 
@@ -94,11 +114,14 @@ bool acquire(IDXGISwapChain* swapChain, Resources& output) noexcept {
     const HRESULT deviceResult =
         swapChain->GetDevice(__uuidof(ID3D11Device), reinterpret_cast<void**>(&staged.device));
     if (FAILED(deviceResult) || staged.device == nullptr) {
+        // A D3D12 or D3D11On12 surface fails here.
+        report::note(report::Stage::acquire, report::Reason::device);
         release_resources(staged);
         return false;
     }
     staged.device->GetImmediateContext(&staged.context);
     if (staged.context == nullptr) {
+        report::note(report::Stage::acquire, report::Reason::context);
         release_resources(staged);
         return false;
     }
@@ -121,7 +144,7 @@ bool acquire(IDXGISwapChain* swapChain, Resources& output) noexcept {
  */
 bool matches_output_window(IDXGISwapChain* swapChain, HWND window) noexcept {
     HWND candidateWindow = nullptr;
-    return window != nullptr && eligible_swap_chain(swapChain, candidateWindow)
+    return window != nullptr && eligible_swap_chain(swapChain, candidateWindow, false)
            && candidateWindow == window;
 }
 

+ 2 - 2
Sunrise/src/client/hooks/graphics/renderer/state.h

@@ -20,8 +20,8 @@ struct Resources {
     bool dx11BackendInitialized{};
     bool inputInstalled{};
     bool inputVisible{};
-    bool resizeDeviceLost{};
-    std::size_t activeResizeCalls{};
+    bool surfaceChangeDeviceLost{};
+    std::size_t activeSurfaceChanges{};
 };
 
 extern SRWLOCK g_rendererLock;

+ 3 - 3
Sunrise/src/client/hooks/teleport/runtime.h

@@ -51,11 +51,11 @@ void force_pending() noexcept;
 void clear_action_keys() noexcept;
 
 /**
- * Turns one authored binding index into the virtual key the scan will read.
- * @param index Key-table index taken from an authored binding.
+ * Turns one authored binding into the virtual key the scan will read.
+ * @param binding Input code taken from an authored binding half.
  * @return The virtual key, or 0 when there is none.
  */
-[[nodiscard]] std::uint32_t action_key(std::uint16_t index) noexcept;
+[[nodiscard]] std::uint32_t action_key(std::uint16_t binding) noexcept;
 
 /**
  * Calls the physics sync for one component through the installed trampoline.

+ 9 - 8
Sunrise/src/client/hooks/teleport/teleport_action_key.cpp

@@ -1,10 +1,7 @@
 /**
- * Turns an authored action binding into the Windows key the game will be asked about.
- *
- * A binding holds no virtual key. It holds an index into the game's own 105-entry key table, which
- * the per-frame scan reads through two image tables: a virtual key per index, and a scan code used
- * instead when that byte is the absent marker. Both are read here so an injected press matches
- * whatever the player bound, on any keyboard layout.
+ * Turns an authored binding into the Windows key the game is asked about.
+ * A binding holds an input code, not a virtual key. Its low byte indexes the game's 105-entry key
+ * table, which gives a virtual key, or a scan code to map when that byte is the absent marker.
  */
 
 #include <Windows.h>
@@ -39,6 +36,8 @@ constexpr std::array<std::byte, 5> kScanCodeLoad{
 constexpr std::size_t kSearchBytes = 0x140;
 /** Both tables carry one byte per supported key index. */
 constexpr std::size_t kKeyTableCount = 105;
+/** A binding half carries its key code in the low byte and its modifiers above it. */
+constexpr std::uint16_t kKeyCodeMask = 0x00FF;
 /** The table byte meaning the index resolves through its scan code instead. */
 constexpr std::uint8_t kAbsentVirtualKey = 0xFF;
 
@@ -89,8 +88,10 @@ void clear_action_keys() noexcept {
     g_scanCodes = nullptr;
 }
 
-/** Turns one authored binding index into the virtual key the scan will read. */
-std::uint32_t action_key(std::uint16_t index) noexcept {
+/** Turns one authored binding into the virtual key the scan will read. */
+std::uint32_t action_key(std::uint16_t binding) noexcept {
+    // The key code is the low byte. The bits above it are modifiers, which the tables do not index.
+    const std::uint16_t index = binding & kKeyCodeMask;
     if (g_virtualKeys == nullptr || g_scanCodes == nullptr || index >= kKeyTableCount) {
         return 0;
     }

+ 9 - 0
Sunrise/src/core/settings/client/client_settings_parser.cpp

@@ -14,6 +14,7 @@ bool Parser::client_settings(client::Settings& output) noexcept {
     bool hasForceJoinRequestReady = false;
     bool hasPinReplicatedRecord = false;
     bool hasHoldSpawn = false;
+    bool hasSpawnHoldMs = false;
     if (consume('}')) {
         return true;
     }
@@ -52,6 +53,14 @@ bool Parser::client_settings(client::Settings& output) noexcept {
                 return false;
             }
             hasHoldSpawn = true;
+        } else if (key == "spawn_hold_ms") {
+            std::uint64_t value = 0;
+            if (hasSpawnHoldMs || !unsigned_integer(value) || value == 0
+                || value > client::kMaximumSpawnHoldMs) {
+                return false;
+            }
+            candidate.spawnHoldMs = value;
+            hasSpawnHoldMs = true;
         } else if (!skip_value(0)) {
             return false;
         }

+ 9 - 0
Sunrise/src/core/settings/client/definition.h

@@ -1,10 +1,17 @@
 #pragma once
 
+#include <cstdint>
+
 #include "../../ui/runtime/settings.h"
 #include "external/definition.h"
 
 namespace sunrise::core::settings::client {
 
+/** A load this long has stopped making progress, so the spawn stops waiting for it. */
+inline constexpr std::uint64_t kDefaultSpawnHoldMs = 30'000;
+/** A load past this is a hang, not a slow machine, and holding the spawn would never end. */
+inline constexpr std::uint64_t kMaximumSpawnHoldMs = 600'000;
+
 /** Read-only Client settings parsed by Core. */
 struct Settings {
     /** In-game UI visibility and input policy. */
@@ -34,6 +41,8 @@ struct Settings {
      * the only thing that can turn an allowed spawn into a refusal.
      */
     bool holdSpawn{true};
+    /** How long the spawn waits for a load. `hold_spawn` decides whether it waits at all. */
+    std::uint64_t spawnHoldMs{kDefaultSpawnHoldMs};
 };
 
 } // namespace sunrise::core::settings::client

+ 9 - 2
Sunrise/src/core/settings/parser.h

@@ -233,11 +233,18 @@ private:
      */
     [[nodiscard]] bool key_binding(state::account::settings::bindings::Binding& output) noexcept;
     /**
-     * Parses one optional 16-bit input code. Lossy JSON numbers are refused.
+     * Parses one optional input name. Numbers are not accepted.
      * @param output Receives a code or the unbound null state.
-     * @return True for null or an unsigned integer that fits the authored form.
+     * @return True for null or a name the Client's own input table carries.
      */
     [[nodiscard]] bool optional_input_code(std::optional<std::uint16_t>& output) noexcept;
+    /**
+     * Turns one authored input name into its input code.
+     * @param name Key name, or one modifier and the key it prefixes joined by "+".
+     * @return True when every part of the name is in the Client's input table.
+     */
+    [[nodiscard]] static bool input_code_value(std::string_view name,
+                                               std::uint16_t& output) noexcept;
     /**
      * Parses logging sinks and channel levels.
      * @param output Receives supported logging values.

+ 5 - 0
Sunrise/src/core/settings/server/definition.h

@@ -4,10 +4,15 @@
 
 namespace sunrise::core::settings::server {
 
+/** The loopback port the BAP listener binds, and the relay port SignOn hands the Client. */
+inline constexpr std::uint16_t kDefaultBapPort = 30974;
+
 /** Read-only Server settings. */
 struct Settings {
     /** Authored ownership policy declared by SignOn and defined by the content manifest. */
     state::entitlements::Table entitlements{};
+    /** BAP port. The listener binds it and SignOn publishes it. Zero is the no-relay sentinel. */
+    std::uint16_t bapPort{kDefaultBapPort};
 };
 
 } // namespace sunrise::core::settings::server

+ 11 - 0
Sunrise/src/core/settings/server/server_settings_parser.cpp

@@ -1,3 +1,5 @@
+#include <limits>
+
 #include "../../../state/entitlements/validation.h"
 #include "../parser.h"
 
@@ -14,6 +16,7 @@ bool Parser::server_settings(server::Settings& output) noexcept {
         return true;
     }
     bool hasEntitlements = false;
+    bool hasBapPort = false;
     for (;;) {
         std::string_view key;
         if (!string(key) || !consume(':')) {
@@ -24,6 +27,14 @@ bool Parser::server_settings(server::Settings& output) noexcept {
                 return false;
             }
             hasEntitlements = true;
+        } else if (key == "bap_port") {
+            std::uint64_t value = 0;
+            if (hasBapPort || !unsigned_integer(value) || value == 0
+                || value > (std::numeric_limits<std::uint16_t>::max)()) {
+                return false;
+            }
+            output.bapPort = static_cast<std::uint16_t>(value);
+            hasBapPort = true;
         } else if (!skip_value(0)) {
             return false;
         }

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

@@ -16,9 +16,9 @@ namespace sunrise::core::settings {
 /**
  * Layout version of the settings file this build writes and expects.
  * Raise it when a key is renamed, removed, or changes meaning. Adding a key needs no raise,
- * because a missing key already takes its default.
+ * because a missing key already takes its default. An older file is upgraded in place at load.
  */
-inline constexpr std::uint32_t kSettingsVersion = 2;
+inline constexpr std::uint32_t kSettingsVersion = 3;
 
 /** Parsed read-only process settings. */
 struct Settings {

+ 100 - 12
Sunrise/src/core/settings/settings_runtime.cpp

@@ -9,12 +9,15 @@
 #include "../filesystem/path.h"
 #include "../logging/log.h"
 #include "settings.h"
+#include "settings_upgrade.h"
 
 namespace sunrise::core::settings {
 namespace {
 
 /** The JSON settings file is the only file stored directly in the owned folder. */
 constexpr std::wstring_view kSettingsFileSuffix = L"\\settings.json";
+/** An upgraded document is staged under this suffix before it replaces the settings file. */
+constexpr std::wstring_view kUpgradeStageSuffix = L".new";
 /** Largest settings file accepted into fixed stack storage. */
 constexpr std::size_t kConfigCapacity = 64 * 1024;
 
@@ -40,11 +43,7 @@ Settings g_settings = defaults();
 }
 
 /**
- * Reports a settings file written against a different layout version.
- *
- * Nothing needs repair: the file is parsed on top of the built-in defaults, so an added key takes
- * its default and a removed key is skipped. This line is the only sign either happened.
- *
+ * Reports a file this build did not upgrade, which means a newer build wrote it.
  * @param fileVersion Version read from the file, or zero when the key was missing.
  */
 void report_version(std::uint32_t fileVersion) noexcept {
@@ -63,12 +62,12 @@ void report_version(std::uint32_t fileVersion) noexcept {
 }
 
 /**
- * Copies the bundled default settings. An existing file is never overwritten.
+ * Borrows the default settings document out of the module resources.
  * @param module Loaded DLL holding the default JSON resource.
- * @param configPath Null-terminated destination path.
- * @return True when every bundled byte is written and the file closes cleanly.
+ * @param output Receives the resource bytes, owned by the module.
+ * @return True when the resource is present and not empty.
  */
-[[nodiscard]] bool write_default(void* module, const path::Buffer& configPath) noexcept {
+[[nodiscard]] bool bundled_document(void* module, std::string_view& output) noexcept {
     const HMODULE loadedModule = static_cast<HMODULE>(module);
     const HRSRC resource =
         FindResourceW(loadedModule, MAKEINTRESOURCEW(IDR_DEFAULT_SETTINGS), RT_RCDATA);
@@ -77,10 +76,26 @@ void report_version(std::uint32_t fileVersion) noexcept {
     }
     const DWORD size = SizeofResource(loadedModule, resource);
     const HGLOBAL loaded = LoadResource(loadedModule, resource);
-    const void* bytes = loaded != nullptr ? LockResource(loaded) : nullptr;
+    const auto* bytes =
+        loaded != nullptr ? static_cast<const char*>(LockResource(loaded)) : nullptr;
     if (size == 0 || bytes == nullptr) {
         return false;
     }
+    output = std::string_view(bytes, size);
+    return true;
+}
+
+/**
+ * Copies the bundled default settings. An existing file is never overwritten.
+ * @param module Loaded DLL holding the default JSON resource.
+ * @param configPath Null-terminated destination path.
+ * @return True when every bundled byte is written and the file closes cleanly.
+ */
+[[nodiscard]] bool write_default(void* module, const path::Buffer& configPath) noexcept {
+    std::string_view document;
+    if (!bundled_document(module, document)) {
+        return false;
+    }
     const HANDLE file = CreateFileW(configPath.chars.data(),
                                     GENERIC_WRITE,
                                     0,
@@ -92,7 +107,9 @@ void report_version(std::uint32_t fileVersion) noexcept {
         return false;
     }
     DWORD written = 0;
-    bool complete = WriteFile(file, bytes, size, &written, nullptr) != FALSE && written == size;
+    const auto size = static_cast<DWORD>(document.size());
+    bool complete =
+        WriteFile(file, document.data(), size, &written, nullptr) != FALSE && written == size;
     complete = CloseHandle(file) != FALSE && complete;
     if (!complete) {
         // A half-written default must not become the next boot's settings.
@@ -101,6 +118,60 @@ void report_version(std::uint32_t fileVersion) noexcept {
     return complete;
 }
 
+/**
+ * Replaces the settings file with an upgraded document.
+ * The text is staged beside the file and moved over it, so a failed write cannot leave half a file.
+ * @param configPath Null-terminated settings path.
+ * @param document Complete upgraded document.
+ * @return True when the file now holds the upgraded document.
+ */
+[[nodiscard]] bool store_upgraded(const path::Buffer& configPath,
+                                  std::string_view document) noexcept {
+    path::Buffer stagePath = configPath;
+    if (!path::append(stagePath, kUpgradeStageSuffix)) {
+        return false;
+    }
+    const HANDLE file = CreateFileW(stagePath.chars.data(),
+                                    GENERIC_WRITE,
+                                    0,
+                                    nullptr,
+                                    CREATE_ALWAYS,
+                                    FILE_ATTRIBUTE_NORMAL,
+                                    nullptr);
+    if (file == INVALID_HANDLE_VALUE) {
+        return false;
+    }
+    DWORD written = 0;
+    const auto size = static_cast<DWORD>(document.size());
+    bool complete =
+        WriteFile(file, document.data(), size, &written, nullptr) != FALSE && written == size;
+    complete = CloseHandle(file) != FALSE && complete;
+    complete =
+        complete
+        && MoveFileExW(stagePath.chars.data(), configPath.chars.data(), MOVEFILE_REPLACE_EXISTING)
+               != FALSE;
+    if (!complete) {
+        (void)DeleteFileW(stagePath.chars.data());
+    }
+    return complete;
+}
+
+/**
+ * Reports the outcome of an in-place upgrade of the settings file.
+ * @param stored True when the upgraded document replaced the file on disk.
+ */
+void report_upgrade(bool stored) noexcept {
+    std::array<char, 96> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=settings stage=upgrade version=%u stored=%u",
+                                      static_cast<unsigned>(kSettingsVersion),
+                                      stored ? 1U : 0U);
+    if (written > 0) {
+        log::early({line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
 } // namespace
 
 /** Loads the settings file from the owned folder, or creates the default one. */
@@ -160,10 +231,27 @@ bool initialize(void* module) noexcept {
     if (!readOk || !closed) {
         return fail("read");
     }
+    std::string_view document(buffer.data(), read);
+    std::array<char, kConfigCapacity> upgradedBuffer{};
+    const bool upgrading = upgrade::needed(document);
+    if (upgrading) {
+        std::string_view bundled;
+        std::size_t upgraded = 0;
+        if (!bundled_document(module, bundled)
+            || !upgrade::apply(document, bundled, upgradedBuffer, upgraded)) {
+            return fail("upgrade");
+        }
+        document = std::string_view(upgradedBuffer.data(), upgraded);
+    }
+
     Settings parsed;
-    if (!parse(std::string_view(buffer.data(), read), parsed)) {
+    if (!parse(document, parsed)) {
         return fail("parse");
     }
+    // The file is replaced only once the upgraded document is known to parse.
+    if (upgrading) {
+        report_upgrade(store_upgraded(configPath, document));
+    }
     report_version(parsed.version);
     g_settings = parsed;
     return true;

+ 242 - 0
Sunrise/src/core/settings/settings_upgrade.cpp

@@ -0,0 +1,242 @@
+/**
+ * Boot-time repair of a settings file written against an older layout version. A member whose
+ * value form changed cannot be read by the current parser, so the file would be refused and the
+ * boot would end. Such a member is replaced with the bundled default before the parse.
+ */
+
+#include "settings_upgrade.h"
+
+#include <array>
+#include <cstdint>
+#include <cstdio>
+
+#include "settings.h"
+
+namespace sunrise::core::settings::upgrade {
+namespace {
+
+/** The layout version member, quoted so a value string cannot match it. */
+constexpr std::string_view kVersionMember = "\"version\"";
+/** Members replaced with the bundled default because their value form changed. */
+constexpr std::array<std::string_view, 1> kReplacedMembers{"\"key_bindings\""};
+/** One splice per replaced member, plus the version member itself. */
+constexpr std::size_t kSpliceCapacity = kReplacedMembers.size() + 1;
+/** Room for the version member and its digits when the file predates versioning. */
+constexpr std::size_t kVersionTextCapacity = 32;
+
+/** One region of the document and the text that takes its place. */
+struct Splice {
+    std::size_t start;
+    std::size_t end;
+    std::string_view text;
+};
+
+/**
+ * Steps over one JSON string.
+ * @param text Document text.
+ * @param position Index of the opening quote.
+ * @return Index after the closing quote, or the length when it never closes.
+ */
+[[nodiscard]] std::size_t skip_string(std::string_view text, std::size_t position) noexcept {
+    for (++position; position < text.size(); ++position) {
+        if (text[position] == '\\') {
+            ++position;
+            continue;
+        }
+        if (text[position] == '"') {
+            return position + 1;
+        }
+    }
+    return text.size();
+}
+
+/**
+ * Steps over one JSON value of any type. Strings are skipped whole, so a brace inside one cannot
+ * close an object.
+ * @param text Document text.
+ * @param position Index of the first byte of the value.
+ * @return Index after the value.
+ */
+[[nodiscard]] std::size_t skip_value(std::string_view text, std::size_t position) noexcept {
+    if (position >= text.size()) {
+        return text.size();
+    }
+    if (text[position] == '"') {
+        return skip_string(text, position);
+    }
+    if (text[position] != '{' && text[position] != '[') {
+        std::size_t end = position;
+        while (end < text.size() && text[end] != ',' && text[end] != '}' && text[end] != ']') {
+            ++end;
+        }
+        // A scalar runs to its separator, so trailing layout belongs to the document, not the
+        // value.
+        while (end > position
+               && (text[end - 1] == ' ' || text[end - 1] == '\t' || text[end - 1] == '\r'
+                   || text[end - 1] == '\n')) {
+            --end;
+        }
+        return end;
+    }
+    std::size_t depth = 0;
+    for (; position < text.size(); ++position) {
+        const char value = text[position];
+        if (value == '"') {
+            position = skip_string(text, position) - 1;
+            continue;
+        }
+        if (value == '{' || value == '[') {
+            ++depth;
+            continue;
+        }
+        if (value == '}' || value == ']') {
+            --depth;
+            if (depth == 0) {
+                return position + 1;
+            }
+        }
+    }
+    return text.size();
+}
+
+/**
+ * Finds one member's value inside a document.
+ * @param text Document text.
+ * @param member Quoted member name.
+ * @param start Receives the first byte of the value.
+ * @param end Receives the byte after the value.
+ * @return True when the member is present with a value.
+ */
+[[nodiscard]] bool value_span(std::string_view text,
+                              std::string_view member,
+                              std::size_t& start,
+                              std::size_t& end) noexcept {
+    const std::size_t found = text.find(member);
+    if (found == std::string_view::npos) {
+        return false;
+    }
+    std::size_t position = found + member.size();
+    const std::string_view blanks = " \t\r\n";
+    position = text.find_first_not_of(blanks, position);
+    if (position == std::string_view::npos || text[position] != ':') {
+        return false;
+    }
+    position = text.find_first_not_of(blanks, position + 1);
+    if (position == std::string_view::npos) {
+        return false;
+    }
+    start = position;
+    end = skip_value(text, position);
+    return end > start;
+}
+
+/**
+ * Reads the layout version a document was written against.
+ * @param document Document text.
+ * @return The version, or zero when the member is absent or unreadable.
+ */
+[[nodiscard]] std::uint32_t document_version(std::string_view document) noexcept {
+    std::size_t start = 0;
+    std::size_t end = 0;
+    if (!value_span(document, kVersionMember, start, end)) {
+        return 0;
+    }
+    std::uint32_t version = 0;
+    for (std::size_t position = start; position < end; ++position) {
+        const char digit = document[position];
+        if (digit < '0' || digit > '9') {
+            return 0;
+        }
+        version = version * 10 + static_cast<std::uint32_t>(digit - '0');
+    }
+    return version;
+}
+
+} // namespace
+
+/** Reports whether the document predates the layout this build reads. */
+bool needed(std::string_view document) noexcept {
+    return document_version(document) < kSettingsVersion;
+}
+
+/** Replaces the changed members with the bundled defaults and stamps the current version. */
+bool apply(std::string_view document,
+           std::string_view bundled,
+           std::span<char> output,
+           std::size_t& written) noexcept {
+    written = 0;
+    std::array<Splice, kSpliceCapacity> splices{};
+    std::size_t count = 0;
+
+    std::array<char, kVersionTextCapacity> versionText{};
+    std::size_t start = 0;
+    std::size_t end = 0;
+    if (value_span(document, kVersionMember, start, end)) {
+        const int length = std::snprintf(
+            versionText.data(), versionText.size(), "%u", static_cast<unsigned>(kSettingsVersion));
+        if (length <= 0) {
+            return false;
+        }
+        splices[count++] = {start, end, {versionText.data(), static_cast<std::size_t>(length)}};
+    } else {
+        // A file that predates versioning carries no member to replace, so one is added first.
+        const std::size_t root = document.find('{');
+        if (root == std::string_view::npos) {
+            return false;
+        }
+        const int length = std::snprintf(versionText.data(),
+                                         versionText.size(),
+                                         "\"version\": %u,",
+                                         static_cast<unsigned>(kSettingsVersion));
+        if (length <= 0) {
+            return false;
+        }
+        splices[count++] = {
+            root + 1, root + 1, {versionText.data(), static_cast<std::size_t>(length)}};
+    }
+
+    for (const std::string_view member : kReplacedMembers) {
+        std::size_t replacementStart = 0;
+        std::size_t replacementEnd = 0;
+        if (!value_span(document, member, start, end)
+            || !value_span(bundled, member, replacementStart, replacementEnd)) {
+            // A member the file never carried needs no replacement.
+            continue;
+        }
+        splices[count++] = {
+            start, end, bundled.substr(replacementStart, replacementEnd - replacementStart)};
+    }
+
+    for (std::size_t index = 1; index < count; ++index) {
+        const Splice held = splices[index];
+        std::size_t position = index;
+        while (position > 0 && splices[position - 1].start > held.start) {
+            splices[position] = splices[position - 1];
+            --position;
+        }
+        splices[position] = held;
+    }
+
+    std::size_t read = 0;
+    for (std::size_t index = 0; index < count; ++index) {
+        const Splice& splice = splices[index];
+        const std::size_t copied = splice.start - read;
+        if (written + copied + splice.text.size() > output.size()) {
+            return false;
+        }
+        document.copy(output.data() + written, copied, read);
+        written += copied;
+        splice.text.copy(output.data() + written, splice.text.size());
+        written += splice.text.size();
+        read = splice.end;
+    }
+    const std::size_t tail = document.size() - read;
+    if (written + tail > output.size()) {
+        return false;
+    }
+    document.copy(output.data() + written, tail, read);
+    written += tail;
+    return true;
+}
+
+} // namespace sunrise::core::settings::upgrade

+ 29 - 0
Sunrise/src/core/settings/settings_upgrade.h

@@ -0,0 +1,29 @@
+#pragma once
+
+#include <cstddef>
+#include <span>
+#include <string_view>
+
+namespace sunrise::core::settings::upgrade {
+
+/**
+ * @param document Borrowed settings file text.
+ * @return True when the document was written against an older layout version.
+ */
+[[nodiscard]] bool needed(std::string_view document) noexcept;
+
+/**
+ * Rewrites an older settings document onto the current layout. Members whose value form changed
+ * take the bundled default, and every other member is copied through unchanged.
+ * @param document Borrowed settings file text.
+ * @param bundled Borrowed default document holding the replacement members.
+ * @param output Receives the rewritten document.
+ * @param written Receives the rewritten length.
+ * @return True when the whole rewritten document fits the output.
+ */
+[[nodiscard]] bool apply(std::string_view document,
+                         std::string_view bundled,
+                         std::span<char> output,
+                         std::size_t& written) noexcept;
+
+} // namespace sunrise::core::settings::upgrade

+ 1 - 3
Sunrise/src/core/settings/state/account_rows_parser.cpp

@@ -178,11 +178,9 @@ bool Parser::character(state::CharacterState& output) noexcept {
                 return false;
             }
         } else if (key == "movement_ability") {
-            std::uint64_t value = 0;
-            if (!unsigned_integer(value) || value > state::kMaximumMovementAbilityEntry) {
+            if (!ability_entry(output.movementAbilityEntry)) {
                 return false;
             }
-            output.movementAbilityEntry = static_cast<std::uint8_t>(value);
         } else if (key == "grenade_ability") {
             if (!ability_entry(output.grenadeAbilityEntry)) {
                 return false;

+ 249 - 5
Sunrise/src/core/settings/state/key_bindings_parser.cpp

@@ -1,6 +1,5 @@
 #include <algorithm>
 #include <array>
-#include <limits>
 
 #include "../parser.h"
 
@@ -83,6 +82,223 @@ constexpr std::array<std::string_view, bindings::kActionCount> kActionNames{
     return static_cast<std::size_t>(found - kActionNames.begin());
 }
 
+/** One authored input name and the input code it stands for. */
+struct InputName {
+    std::string_view name;
+    std::uint16_t code;
+};
+
+/**
+ * The Client's own input names and codes, read from its name table at `0x7FF7438A3400`. The names
+ * are its strings. The last four rows are its own aliases, and the JSON escape for the backslash.
+ */
+constexpr std::array<InputName, 121> kInputNames{{
+    {"escape", 0},
+    {"f1", 1},
+    {"f2", 2},
+    {"f3", 3},
+    {"f4", 4},
+    {"f5", 5},
+    {"f6", 6},
+    {"f7", 7},
+    {"f8", 8},
+    {"f9", 9},
+    {"f10", 10},
+    {"f11", 11},
+    {"f12", 12},
+    {"print screen", 13},
+    {"scroll lock", 14},
+    {"pause", 15},
+    {"`", 16},
+    {"1", 17},
+    {"2", 18},
+    {"3", 19},
+    {"4", 20},
+    {"5", 21},
+    {"6", 22},
+    {"7", 23},
+    {"8", 24},
+    {"9", 25},
+    {"0", 26},
+    {"-", 27},
+    {"=", 28},
+    {"backspace", 29},
+    {"tab", 30},
+    {"q", 31},
+    {"w", 32},
+    {"e", 33},
+    {"r", 34},
+    {"t", 35},
+    {"y", 36},
+    {"u", 37},
+    {"i", 38},
+    {"o", 39},
+    {"p", 40},
+    {"[", 41},
+    {"]", 42},
+    {"\\", 43},
+    {"caps lock", 44},
+    {"a", 45},
+    {"s", 46},
+    {"d", 47},
+    {"f", 48},
+    {"g", 49},
+    {"h", 50},
+    {"j", 51},
+    {"k", 52},
+    {"l", 53},
+    {";", 54},
+    {"'", 55},
+    {"return", 56},
+    {"left shift", 57},
+    {"z", 58},
+    {"x", 59},
+    {"c", 60},
+    {"v", 61},
+    {"b", 62},
+    {"n", 63},
+    {"m", 64},
+    {",", 65},
+    {".", 66},
+    {"/", 67},
+    {"right shift", 68},
+    {"left control", 69},
+    {"left windows", 70},
+    {"left alt", 71},
+    {"space", 72},
+    {"right alt", 73},
+    {"right windows", 74},
+    {"menu", 75},
+    {"right control", 76},
+    {"up", 77},
+    {"down", 78},
+    {"left", 79},
+    {"right", 80},
+    {"insert", 81},
+    {"home", 82},
+    {"page up", 83},
+    {"delete", 84},
+    {"end", 85},
+    {"page down", 86},
+    {"num lock", 87},
+    {"keypad /", 88},
+    {"keypad *", 89},
+    {"keypad 0", 90},
+    {"keypad 1", 91},
+    {"keypad 2", 92},
+    {"keypad 3", 93},
+    {"keypad 4", 94},
+    {"keypad 5", 95},
+    {"keypad 6", 96},
+    {"keypad 7", 97},
+    {"keypad 8", 98},
+    {"keypad 9", 99},
+    {"keypad -", 100},
+    {"keypad +", 101},
+    {"keypad enter", 102},
+    {"keypad .", 103},
+    {"<", 104},
+    {"shift", 105},
+    {"control", 106},
+    {"key_windows", 107},
+    {"alt", 108},
+    {"left mouse button", 109},
+    {"middle mouse button", 110},
+    {"right mouse button", 111},
+    {"extra mouse button 1", 112},
+    {"extra mouse button 2", 113},
+    {"mouse wheel up", 114},
+    {"mouse wheel down", 115},
+    {"unused", 116},
+    {"ctrl", 106},
+    {"left ctrl", 69},
+    {"right ctrl", 76},
+    {"\\\\", 43},
+}};
+
+/** A binding half carries its key code in the low byte and one modifier above it. */
+constexpr std::uint16_t kAltFlag = 0x0100;
+constexpr std::uint16_t kControlFlag = 0x0200;
+constexpr std::uint16_t kShiftFlag = 0x0400;
+
+/** One code that may prefix another key, and the flag it sets there. */
+struct ModifierName {
+    std::uint16_t code;
+    std::uint16_t flag;
+};
+
+/** Both sides of a modifier fold onto the same flag, as they do in the Client. */
+constexpr std::array<ModifierName, 9> kModifiers{{
+    {57, kShiftFlag},
+    {68, kShiftFlag},
+    {105, kShiftFlag},
+    {69, kControlFlag},
+    {76, kControlFlag},
+    {106, kControlFlag},
+    {71, kAltFlag},
+    {73, kAltFlag},
+    {108, kAltFlag},
+}};
+
+/** @return The name without leading and trailing ASCII blanks. */
+[[nodiscard]] constexpr std::string_view trim(std::string_view text) noexcept {
+    while (!text.empty() && (text.front() == ' ' || text.front() == '\t')) {
+        text.remove_prefix(1);
+    }
+    while (!text.empty() && (text.back() == ' ' || text.back() == '\t')) {
+        text.remove_suffix(1);
+    }
+    return text;
+}
+
+/** @return True when the two names match with ASCII case folded, as the Client compares them. */
+[[nodiscard]] constexpr bool same_name(std::string_view left, std::string_view right) noexcept {
+    if (left.size() != right.size()) {
+        return false;
+    }
+    for (std::size_t index = 0; index < left.size(); ++index) {
+        char first = left[index];
+        char second = right[index];
+        first = (first >= 'A' && first <= 'Z') ? static_cast<char>(first + ('a' - 'A')) : first;
+        second =
+            (second >= 'A' && second <= 'Z') ? static_cast<char>(second + ('a' - 'A')) : second;
+        if (first != second) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/**
+ * Finds one whole input name.
+ * @param output Receives the code the table gives that name.
+ * @return True when the name is in the table.
+ */
+[[nodiscard]] bool named_code(std::string_view name, std::uint16_t& output) noexcept {
+    for (const InputName& entry : kInputNames) {
+        if (same_name(entry.name, name)) {
+            output = entry.code;
+            return true;
+        }
+    }
+    return false;
+}
+
+/**
+ * @param code Code parsed from a prefix name.
+ * @param output Receives the flag that code sets on the key it prefixes.
+ * @return True when the code is a modifier.
+ */
+[[nodiscard]] bool modifier_flag(std::uint16_t code, std::uint16_t& output) noexcept {
+    for (const ModifierName& entry : kModifiers) {
+        if (entry.code == code) {
+            output = entry.flag;
+            return true;
+        }
+    }
+    return false;
+}
+
 } // namespace
 
 /** Parses the whole fixed action table under named JSON properties. */
@@ -161,17 +377,45 @@ bool Parser::key_binding(bindings::Binding& output) noexcept {
     }
 }
 
-/** Parses one optional 16-bit input code. Lossy JSON numbers are refused. */
+/** Parses one optional input name. Null is the unbound half. */
 bool Parser::optional_input_code(std::optional<std::uint16_t>& output) noexcept {
     if (literal("null")) {
         output.reset();
         return true;
     }
-    std::uint64_t value = 0;
-    if (!unsigned_integer(value) || value > (std::numeric_limits<std::uint16_t>::max)()) {
+    std::string_view name;
+    std::uint16_t code = 0;
+    if (!string(name) || !input_code_value(name, code)) {
+        return false;
+    }
+    output = code;
+    return true;
+}
+
+/** Turns one input name into the code the Client reads. */
+bool Parser::input_code_value(std::string_view name, std::uint16_t& output) noexcept {
+    const std::string_view text = trim(name);
+    if (text.empty()) {
+        return false;
+    }
+    if (named_code(text, output)) {
+        return true;
+    }
+    // Anything else is one modifier, then the key it prefixes. The key may hold a separator.
+    const std::size_t separator = text.find_first_of("+-");
+    if (separator == std::string_view::npos) {
+        return false;
+    }
+    std::uint16_t prefix = 0;
+    std::uint16_t flag = 0;
+    if (!named_code(trim(text.substr(0, separator)), prefix) || !modifier_flag(prefix, flag)) {
+        return false;
+    }
+    std::uint16_t key = 0;
+    if (!named_code(trim(text.substr(separator + 1)), key)) {
         return false;
     }
-    output = static_cast<std::uint16_t>(value);
+    output = static_cast<std::uint16_t>(flag | key);
     return true;
 }
 

+ 1 - 1
Sunrise/src/core/ui/busy/busy.h

@@ -30,7 +30,7 @@ void begin(Task task) noexcept;
 /**
  * Raises a task and reports whether the caller must let a frame pass before starting.
  * The presenting thread cannot wait for its own frame, so work on that thread raises the
- * overlay on one call and starts on a later one. A game that is not drawing never defers.
+ * overlay on one call and starts on a later one. Every deferral is bounded.
  * @param task Work that is about to start.
  * @return True while the overlay has not reached the screen yet.
  */

+ 19 - 2
Sunrise/src/core/ui/busy/ui_busy_state.cpp

@@ -8,6 +8,7 @@
 #include <cstdint>
 #include <limits>
 
+#include "../../logging/log.h"
 #include "busy.h"
 
 namespace sunrise::core::ui::busy {
@@ -22,6 +23,8 @@ constexpr ULONGLONG kPresentIdleMilliseconds = 250;
 /** The Present hook is installed just before the first raise, so the first frame gets this long
  * before the overlay is given up on. */
 constexpr ULONGLONG kFirstPresentGraceMilliseconds = 250;
+/** Longest a deferred caller waits for an overlay frame while the game is presenting. */
+constexpr ULONGLONG kEarlyWaitMilliseconds = 1000;
 /** An overlay drawn this recently is still on screen, so the next task need not wait for it. */
 constexpr ULONGLONG kVisibleRecentlyMilliseconds = 250;
 /** One millisecond yields the core between polls of the present counter. */
@@ -49,6 +52,7 @@ struct EarlyState {
     std::uint32_t start{};
     ULONGLONG armedTick{};
     bool armed{};
+    bool expiryReported{};
 };
 
 SRWLOCK g_earlyLock{SRWLOCK_INIT};
@@ -115,15 +119,28 @@ bool raise_early(Task task) noexcept {
         early.armedTick = now;
         early.armed = true;
     }
+    const ULONGLONG waited = now - early.armedTick;
     bool waiting = false;
+    bool expired = false;
     if (g_lastPresentTick.load(std::memory_order_acquire) == 0) {
         // No frame has passed through the hook yet, which is expected on the call that
         // installed it. The game gets a bounded chance to draw one.
-        waiting = now - early.armedTick < kFirstPresentGraceMilliseconds;
+        waiting = waited < kFirstPresentGraceMilliseconds;
     } else if (drawing()) {
-        waiting = g_shownPresents.load(std::memory_order_acquire) - early.start < kRequiredPresents;
+        const bool shown =
+            g_shownPresents.load(std::memory_order_acquire) - early.start >= kRequiredPresents;
+        // Bounded: an overlay that never completes must not defer the caller for the whole run.
+        waiting = !shown && waited < kEarlyWaitMilliseconds;
+        expired = !shown && !waiting && !early.expiryReported;
+        early.expiryReported = early.expiryReported || expired;
     }
     ReleaseSRWLockExclusive(&g_earlyLock);
+    if (expired) {
+        // The sink takes a process-wide lock, so it is written outside the state lock.
+        core::log::write(core::log::Channel::core,
+                         core::log::Level::warn,
+                         "ev=busy stage=early result=expired reason=no_overlay_frame");
+    }
     return waiting;
 }
 

+ 5 - 14
Sunrise/src/middleware/bap/activity_host_manager/request/selection/activity_manager_descriptor_parser.cpp

@@ -112,9 +112,7 @@ constexpr std::size_t kPairEntryWidth = 13;
  * @param selection Temporary scalar-only selection output.
  * @return True when the presence marker and all 40 biased bytes are complete.
  */
-[[nodiscard]] bool read_package_name(Reader& reader,
-                                     std::size_t descriptorStart,
-                                     ActivityManagerSelection& selection) noexcept {
+[[nodiscard]] bool read_package_name(Reader& reader, ActivityManagerSelection& selection) noexcept {
     bool present = false;
     if (!read_presence(reader, present)) {
         return false;
@@ -122,7 +120,6 @@ constexpr std::size_t kPairEntryWidth = 13;
     if (!present) {
         return true;
     }
-    const std::size_t nameBit = descriptorStart - reader.remaining_bits();
     bool ended = false;
     std::uint8_t length = 0;
     for (std::int8_t& character : selection.packageName) {
@@ -140,7 +137,6 @@ constexpr std::size_t kPairEntryWidth = 13;
     }
     selection.hasPackageName = true;
     selection.packageNameLength = length;
-    selection.packageNameBit = nameBit;
     return true;
 }
 
@@ -164,14 +160,12 @@ constexpr std::size_t kPairEntryWidth = 13;
  * @param selection Temporary scalar-only selection output.
  * @return True when every destination field is complete.
  */
-[[nodiscard]] bool read_destination(Reader& reader,
-                                    std::size_t descriptorStart,
-                                    ActivityManagerSelection& selection) noexcept {
+[[nodiscard]] bool read_destination(Reader& reader, ActivityManagerSelection& selection) noexcept {
     return reader.skip(kUnknownByteWidth) && skip_optional(reader, kUnknownByteWidth)
            && read_hash(reader, selection.hasArrivalBubbleHash, selection.arrivalBubbleHash)
            && read_hash(reader, selection.hasSpawnSetHash, selection.spawnSetHash)
-           && read_package_name(reader, descriptorStart, selection)
-           && skip_optional(reader, kUnknownHashWidth) && reader.skip(kBooleanWidth);
+           && read_package_name(reader, selection) && skip_optional(reader, kUnknownHashWidth)
+           && reader.skip(kBooleanWidth);
 }
 
 /**
@@ -208,10 +202,7 @@ constexpr std::size_t kPairEntryWidth = 13;
 
 /** Reads one complete descriptor into safe scalar-only output. */
 bool parse(Reader& reader, ActivityManagerSelection& selection) noexcept {
-    // Bits left at the descriptor's first bit. Every offset recorded below is then relative to
-    // the descriptor, not to the payload it sits in.
-    const std::size_t descriptorStart = reader.remaining_bits();
-    return read_prefix(reader, selection) && read_destination(reader, descriptorStart, selection)
+    return read_prefix(reader, selection) && read_destination(reader, selection)
            && skip_tail(reader);
 }
 

+ 0 - 6
Sunrise/src/middleware/bap/activity_host_manager/request/selection/definition.h

@@ -57,12 +57,6 @@ struct ActivityManagerSelection final {
     std::uint8_t packageNameLength{};
     /** Checked package-name bytes, then zero padding. */
     std::array<std::int8_t, kActivityManagerPackageNameCapacity> packageName{};
-    /**
-     * Bit offset of the first name element inside descriptorBits, valid with hasPackageName. A
-     * host sending the client elsewhere rewrites the name in the replayed bits instead of
-     * re-encoding the descriptor, which would drop the fields with no known name.
-     */
-    std::size_t packageNameBit{};
     /** Last root boolean. Its gameplay meaning is not known. */
     bool trailingFlag{};
     /**

+ 111 - 0
Sunrise/src/middleware/bap/activity_message/activity_entity_authority_parser.cpp

@@ -0,0 +1,111 @@
+/**
+ * The Client sends msgs 26, 27, 29, 31, 32 and 33. It never receives them.
+ * Msgs 26 and 33 return slots this host leased out, so both reach the lease store.
+ * Msgs 27, 29, 31 and 32 answer or ask for host state this build does not keep, so they are read
+ * and reported only. Msg 27's mask starts at bit 3 and is skipped for that reason.
+ */
+
+#include <algorithm>
+
+#include "../../encoding/bit_reader.h"
+#include "../../encoding/byte_order.h"
+#include "entity_authority.h"
+
+namespace sunrise::middleware::bap::activity_message::entity_authority {
+namespace {
+
+/** The selector byte and the mask that follows it, shared by msgs 26 and 33. */
+[[nodiscard]] bool read_selector_and_mask(std::span<const std::byte> payload,
+                                          Release& release) noexcept {
+    /** The compact bubble selector is one byte, so the mask behind it stays byte aligned. */
+    constexpr std::size_t kSelectorSize = 1;
+    if (payload.size() < kSelectorSize + entity_slots::kEncodedSize) {
+        return false;
+    }
+    release.selector = std::to_integer<std::uint8_t>(payload.front());
+    std::copy_n(payload.begin() + kSelectorSize, entity_slots::kEncodedSize, release.mask.begin());
+    return true;
+}
+
+} // namespace
+
+/** Parses msg 26, whose mask starts on a byte boundary after the selector. */
+bool parse_abandon(std::span<const std::byte> payload, Release& release) noexcept {
+    release = {};
+    if (!read_selector_and_mask(payload, release)) {
+        return false;
+    }
+    // The reason trails the mask, so the reader only has to reach the last 3 bits.
+    encoding::bits::Reader reader(payload);
+    std::uint64_t stored = 0;
+    if (!reader.skip(kSelectorWidth + entity_slots::kSlotCount)
+        || !reader.read(kReasonWidth, stored)) {
+        release = {};
+        return false;
+    }
+    release.reason = static_cast<std::int32_t>(stored) - kReasonBias;
+    release.hasReason = true;
+    return true;
+}
+
+/** Parses msg 33, which is the selector and the mask with no reason. */
+bool parse_abdicate(std::span<const std::byte> payload, Release& release) noexcept {
+    release = {};
+    if (!read_selector_and_mask(payload, release)) {
+        release = {};
+        return false;
+    }
+    return true;
+}
+
+/** Reads only the 3-bit leading field of msg 27. */
+bool parse_request_purge(std::span<const std::byte> payload, std::int32_t& reason) noexcept {
+    reason = 0;
+    encoding::bits::Reader reader(payload);
+    std::uint64_t stored = 0;
+    if (!reader.read(kReasonWidth, stored) || !reader.skip(entity_slots::kSlotCount)) {
+        return false;
+    }
+    reason = static_cast<std::int32_t>(stored) - kReasonBias;
+    return true;
+}
+
+/** Parses msg 29, 31 or 32. */
+bool parse_query_answer(std::uint32_t messageType,
+                        std::span<const std::byte> payload,
+                        QueryAnswer& answer) noexcept {
+    answer = {};
+    if (payload.size() < kCorrelationSize) {
+        return false;
+    }
+    answer.correlation = encoding::read_u32_be(payload.first<kCorrelationSize>());
+    if (messageType == kResetAcknowledgementMessageType) {
+        return true;
+    }
+
+    std::size_t offset = kCorrelationSize;
+    if (messageType == kQueryPerBubbleMessageType) {
+        if (payload.size() < offset + 1) {
+            answer = {};
+            return false;
+        }
+        answer.selector = std::to_integer<std::uint8_t>(payload[offset]);
+        answer.hasSelector = true;
+        offset += 1;
+    } else if (messageType != kQueryResponseMessageType) {
+        answer = {};
+        return false;
+    }
+
+    if (payload.size() < offset + entity_slots::kEncodedSize) {
+        answer = {};
+        return false;
+    }
+    std::copy_n(payload.begin() + static_cast<std::ptrdiff_t>(offset),
+                entity_slots::kEncodedSize,
+                answer.mask.begin());
+    answer.hasMask = true;
+    return true;
+}
+
+} // namespace sunrise::middleware::bap::activity_message::entity_authority

+ 114 - 0
Sunrise/src/middleware/bap/activity_message/activity_incident_parser.cpp

@@ -0,0 +1,114 @@
+/**
+ * Msg 19 targets index a 7,763-record table that the Client reads without a bound check, so a bad
+ * index is a crash and not a decode error. Rows 795, 4690 and 5375 hold type code -1 and are the
+ * same risk. This validator rejects both before anything acts on the body.
+ * A compressed target selector ends decoding: its wire length is not recoverable from this build,
+ * so the fields behind one cannot be located.
+ */
+
+#include <algorithm>
+#include <climits>
+
+#include "../../encoding/bit_reader.h"
+#include "incident.h"
+
+namespace sunrise::middleware::bap::activity_message::incident {
+namespace {
+
+/** @return True when one target index is safe to hand to the Client's table lookup. */
+[[nodiscard]] bool target_allowed(std::uint32_t target, Verdict& verdict) noexcept {
+    if (target > kTargetMaximum) {
+        verdict = Verdict::targetOutOfRange;
+        return false;
+    }
+    if (std::find(kPoisonTargets.begin(), kPoisonTargets.end(), target) != kPoisonTargets.end()) {
+        verdict = Verdict::targetPoisoned;
+        return false;
+    }
+    return true;
+}
+
+} // namespace
+
+/** @return A short stable name for one verdict, for the log line. */
+const char* verdict_name(Verdict verdict) noexcept {
+    switch (verdict) {
+    case Verdict::accepted:
+        return "accepted";
+    case Verdict::truncated:
+        return "truncated";
+    case Verdict::targetOutOfRange:
+        return "target_out_of_range";
+    case Verdict::targetPoisoned:
+        return "target_poisoned";
+    case Verdict::tooManyTargets:
+        return "too_many_targets";
+    case Verdict::payloadTooLong:
+        return "payload_too_long";
+    }
+    return "unknown";
+}
+
+/** Validates one msg-19 body as far as its wire shape allows. */
+Verdict validate(std::span<const std::byte> payload, Incident& parsed) noexcept {
+    parsed = {};
+    encoding::bits::Reader reader(payload);
+
+    std::uint64_t field = 0;
+    if (!reader.read(kTargetWidth, field)) {
+        return Verdict::truncated;
+    }
+    parsed.primaryTarget = static_cast<std::uint32_t>(field);
+    Verdict verdict = Verdict::accepted;
+    if (!target_allowed(parsed.primaryTarget, verdict)) {
+        return verdict;
+    }
+
+    if (!reader.read(kExtraCountWidth, field)) {
+        return Verdict::truncated;
+    }
+    parsed.extraTargetCount = static_cast<std::uint32_t>(field);
+    if (parsed.extraTargetCount > kExtraTargetMaximum) {
+        return Verdict::tooManyTargets;
+    }
+    for (std::uint32_t index = 0; index < parsed.extraTargetCount; ++index) {
+        if (!reader.read(kTargetWidth, field)) {
+            return Verdict::truncated;
+        }
+        parsed.extraTargets[index] = static_cast<std::uint32_t>(field);
+        if (!target_allowed(parsed.extraTargets[index], verdict)) {
+            return verdict;
+        }
+    }
+
+    if (!reader.read(kSelectorPresenceWidth, field)) {
+        return Verdict::truncated;
+    }
+    if (field != 0) {
+        // Every target is checked by now, which is the part that can crash the Client.
+        parsed.hasCompressedSelector = true;
+        return Verdict::accepted;
+    }
+
+    if (!reader.read(kOptionalPresenceWidth, field)) {
+        return Verdict::truncated;
+    }
+    if (field != 0 && !reader.skip(kOptionalFieldWidth)) {
+        return Verdict::truncated;
+    }
+
+    if (!reader.read(kPayloadLengthWidth, field)) {
+        return Verdict::truncated;
+    }
+    parsed.payloadLength = static_cast<std::uint32_t>(field);
+    if (parsed.payloadLength > kPayloadMaximum) {
+        return Verdict::payloadTooLong;
+    }
+    if (reader.remaining_bits() < static_cast<std::size_t>(parsed.payloadLength) * CHAR_BIT) {
+        return Verdict::truncated;
+    }
+    parsed.hasPayload = true;
+    return Verdict::accepted;
+}
+
+} // namespace sunrise::middleware::bap::activity_message::incident

+ 30 - 14
Sunrise/src/middleware/bap/activity_message/activity_sensor_auth_encoder.cpp

@@ -85,18 +85,13 @@ constexpr std::uint32_t kMaximumRegion = 0x7FFFFFFF;
     return encoded;
 }
 
-} // namespace
-
-/** Encodes one `sensor_auth_update` body. */
-bool encode_sensor_auth_update(const Snapshot& snapshot,
-                               std::span<std::byte> output,
-                               std::size_t& written) noexcept {
-    written = 0;
-    if (output.empty() || !valid(snapshot)) {
-        return false;
-    }
-
-    bits::Writer writer(output);
+/**
+ * Writes the whole body through one writer.
+ * @param writer Real or measuring writer positioned at the first bit.
+ * @param snapshot Message input.
+ * @return True when every field fit.
+ */
+[[nodiscard]] bool write_body(bits::Writer& writer, const Snapshot& snapshot) noexcept {
     // The hardwipe token is unchecked unless the client's `use_hardwipe_tokens` config is on.
     bool encoded = writer.write(0, kHardwipeWidth)
                    && writer.write(snapshot.patchEpoch.first, kEpochWidth)
@@ -116,10 +111,31 @@ bool encode_sensor_auth_update(const Snapshot& snapshot,
         encoded = write_phase_two(writer, snapshot);
     }
     // The entity-group loop end, then the trailing pair, which short-circuits to one bit.
-    encoded = encoded && writer.write(0, kPresenceWidth) && writer.write(0, kPresenceWidth);
+    return encoded && writer.write(0, kPresenceWidth) && writer.write(0, kPresenceWidth);
+}
 
+} // namespace
+
+/** Encodes one `sensor_auth_update` body. */
+bool encode_sensor_auth_update(const Snapshot& snapshot,
+                               std::span<std::byte> output,
+                               std::size_t& written) noexcept {
+    written = 0;
+    if (output.empty() || !valid(snapshot)) {
+        return false;
+    }
+
+    // Measure first. The writer clears and fills the caller's storage as it goes, so a body that
+    // does not fit would leave a partial one behind.
+    bits::Writer measure = bits::Writer::measuring();
+    std::size_t required = 0;
+    if (!write_body(measure, snapshot) || !measure.finish(required) || required > output.size()) {
+        return false;
+    }
+
+    bits::Writer writer(output);
     std::size_t produced = 0;
-    if (!encoded || !writer.finish(produced)) {
+    if (!write_body(writer, snapshot) || !writer.finish(produced) || produced != required) {
         return false;
     }
     written = produced;

+ 95 - 0
Sunrise/src/middleware/bap/activity_message/entity_authority.h

@@ -0,0 +1,95 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+#include "entity_slots.h"
+
+namespace sunrise::middleware::bap::activity_message::entity_authority {
+
+/** The Client abandons slots for bubbles it has left. */
+inline constexpr std::uint32_t kAbandonMessageType = 26;
+/** The Client asks the host to purge slots it could not claim. */
+inline constexpr std::uint32_t kRequestPurgeMessageType = 27;
+/** The Client acknowledges an authority-mask reset. */
+inline constexpr std::uint32_t kResetAcknowledgementMessageType = 29;
+/** The Client answers a mask query with one selector's slots. */
+inline constexpr std::uint32_t kQueryPerBubbleMessageType = 31;
+/** The Client answers a mask query with its whole mask. */
+inline constexpr std::uint32_t kQueryResponseMessageType = 32;
+/** The Client gives up authority over a set of slots. */
+inline constexpr std::uint32_t kAbdicateMessageType = 33;
+
+/** Msgs 26 and 33 lead with a compact bubble selector byte. */
+inline constexpr std::uint8_t kSelectorWidth = 8;
+/** The reason on msg 26 and the leading field on msg 27 are 3 bits, stored as local + 1. */
+inline constexpr std::uint8_t kReasonWidth = 3;
+/** The 3-bit reason stores local + 1, so the local range is -1 to 6. */
+inline constexpr std::int32_t kReasonBias = 1;
+/** Msg 26 is one selector byte, the mask, then the 3-bit reason. */
+inline constexpr std::size_t kAbandonBits =
+    kSelectorWidth + entity_slots::kSlotCount + kReasonWidth;
+/** Msg 27 is the 3-bit field then the mask, so its mask is not byte aligned. */
+inline constexpr std::size_t kRequestPurgeBits = kReasonWidth + entity_slots::kSlotCount;
+/** Msg 33 is one selector byte then the mask. */
+inline constexpr std::size_t kAbdicateBits = kSelectorWidth + entity_slots::kSlotCount;
+/** Msgs 29, 31 and 32 lead with the 4-byte correlation the host sent on msg 28 or 30. */
+inline constexpr std::size_t kCorrelationSize = sizeof(std::uint32_t);
+
+/** One decoded slot-release message. Msg 33 carries no reason. */
+struct Release {
+    entity_slots::EntitySlotMask mask{};
+    std::uint8_t selector{};
+    std::int32_t reason{};
+    bool hasReason{};
+};
+
+/** One decoded answer to a host mask query or reset. */
+struct QueryAnswer {
+    entity_slots::EntitySlotMask mask{};
+    std::uint32_t correlation{};
+    std::uint8_t selector{};
+    bool hasSelector{};
+    bool hasMask{};
+};
+
+/**
+ * Parses msg 26, whose mask starts on a byte boundary after the selector.
+ * @param payload Activity message payload after the 17-byte envelope.
+ * @param release Cleared first. Receives the selector, mask and reason on success.
+ * @return True when the whole fixed body is present.
+ */
+[[nodiscard]] bool parse_abandon(std::span<const std::byte> payload, Release& release) noexcept;
+
+/**
+ * Parses msg 33, which is the selector and the mask with no reason.
+ * @param payload Activity message payload after the 17-byte envelope.
+ * @param release Cleared first. Receives the selector and mask on success.
+ * @return True when the whole fixed body is present.
+ */
+[[nodiscard]] bool parse_abdicate(std::span<const std::byte> payload, Release& release) noexcept;
+
+/**
+ * Reads only the 3-bit leading field of msg 27. The mask that follows is not byte aligned, and
+ * this host takes no action on a purge request, so it is not decoded.
+ * @param payload Activity message payload after the 17-byte envelope.
+ * @param reason Receives the debiased leading value.
+ * @return True when the whole fixed body is present.
+ */
+[[nodiscard]] bool parse_request_purge(std::span<const std::byte> payload,
+                                       std::int32_t& reason) noexcept;
+
+/**
+ * Parses msg 29, 31 or 32. Msg 29 is the correlation alone, msg 31 adds a selector and a mask,
+ * and msg 32 adds a mask.
+ * @param messageType One of 29, 31 or 32.
+ * @param payload Activity message payload after the 17-byte envelope.
+ * @param answer Cleared first. Receives the fields the message type carries.
+ * @return True when the whole body for that type is present.
+ */
+[[nodiscard]] bool parse_query_answer(std::uint32_t messageType,
+                                      std::span<const std::byte> payload,
+                                      QueryAnswer& answer) noexcept;
+
+} // namespace sunrise::middleware::bap::activity_message::entity_authority

+ 79 - 0
Sunrise/src/middleware/bap/activity_message/incident.h

@@ -0,0 +1,79 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+namespace sunrise::middleware::bap::activity_message::incident {
+
+/** Activity message type 19 carries one incident. Both sides can send it. */
+inline constexpr std::uint32_t kMessageType = 19;
+
+/** Target indices are 13 bits and resolve through the 7,763-record global table. */
+inline constexpr std::uint8_t kTargetWidth = 13;
+/** The highest valid target index. Above it the Client indexes handler tables unbounded. */
+inline constexpr std::uint32_t kTargetMaximum = 7'762;
+/** These three rows carry type code -1 and are a crash risk, so they never pass. */
+inline constexpr std::array<std::uint32_t, 3> kPoisonTargets{795, 4'690, 5'375};
+
+/** The extra-target count is 5 bits, so the wire can ask for more than the limit allows. */
+inline constexpr std::uint8_t kExtraCountWidth = 5;
+/** At most 25 extra targets follow the primary one. */
+inline constexpr std::uint32_t kExtraTargetMaximum = 25;
+/** One bit says whether a compressed target selector follows. */
+inline constexpr std::uint8_t kSelectorPresenceWidth = 1;
+/** One bit says whether optional field K follows. */
+inline constexpr std::uint8_t kOptionalPresenceWidth = 1;
+/** Optional field K is two 32-bit words. */
+inline constexpr std::uint8_t kOptionalFieldWidth = 64;
+/** The payload byte length is 9 bits, so the wire can ask for more than the limit allows. */
+inline constexpr std::uint8_t kPayloadLengthWidth = 9;
+/** At most 500 payload bytes follow. */
+inline constexpr std::uint32_t kPayloadMaximum = 500;
+/** The smallest body is the five fixed fields with every count zero. */
+inline constexpr std::size_t kMinimumBodyBits = kTargetWidth + kExtraCountWidth
+                                                + kSelectorPresenceWidth + kOptionalPresenceWidth
+                                                + kPayloadLengthWidth;
+
+/** Why one incident did not pass validation. */
+enum class Verdict : std::uint8_t {
+    accepted,
+    /** The body is shorter than the fields it declares. */
+    truncated,
+    /** A target index is above 7,762. */
+    targetOutOfRange,
+    /** A target index is one of the three type-code -1 rows. */
+    targetPoisoned,
+    /** More than 25 extra targets were declared. */
+    tooManyTargets,
+    /** More than 500 payload bytes were declared. */
+    payloadTooLong,
+};
+
+/** One validated incident. Fields after a compressed selector are not decoded. */
+struct Incident {
+    std::uint32_t primaryTarget{};
+    std::uint32_t extraTargets[kExtraTargetMaximum]{};
+    std::uint32_t extraTargetCount{};
+    std::uint32_t payloadLength{};
+    /** Set when a compressed selector follows, which ends decoding for this body. */
+    bool hasCompressedSelector{};
+    /** Set when the payload length and its bytes were reached and checked. */
+    bool hasPayload{};
+};
+
+/** @return A short stable name for one verdict, for the log line. */
+[[nodiscard]] const char* verdict_name(Verdict verdict) noexcept;
+
+/**
+ * Validates one msg-19 body as far as its wire shape allows.
+ * Every target index is range and poison checked. Decoding stops at a compressed target selector,
+ * whose wire length is not recoverable from this artifact, so the payload behind one is not read.
+ * @param payload Activity message payload after the 17-byte envelope.
+ * @param parsed Cleared first. Receives every field reached before the verdict.
+ * @return accepted, or the first rule the body broke.
+ */
+[[nodiscard]] Verdict validate(std::span<const std::byte> payload, Incident& parsed) noexcept;
+
+} // namespace sunrise::middleware::bap::activity_message::incident

+ 20 - 0
Sunrise/src/middleware/bap/frame.h

@@ -46,8 +46,18 @@ enum class RequestService : std::uint16_t {
     start = 30,
     /** Requests the current server-to-client user-message response. */
     userMessage = 32,
+    /** Requests the skill records the Client keeps in its skill manager. */
+    skill = 34,
+    /** Carries the unnamed request paired with response service 37. */
+    request36 = 36,
+    /** Carries the unnamed request paired with response service 39. */
+    request38 = 38,
+    /** Carries the unnamed request paired with response service 41. */
+    request40 = 40,
     /** Carries one of the 8 matchmaking request variants. Body field two picks it. */
     matchmaking = 42,
+    /** Carries the unnamed request paired with response service 49. */
+    request48 = 48,
     /** Carries a clan protobuf request that the minimal liveness route leaves unparsed. */
     clan = 44,
     /** Registers the client as a notification subscriber. */
@@ -90,8 +100,18 @@ enum class ResponseService : std::uint16_t {
     start = 31,
     /** Returns the current user-message fields. */
     userMessage = 33,
+    /** Returns an empty skill record list, which is a count of zero. */
+    skill = 35,
+    /** Acknowledges service 36 with an empty status-200 body. */
+    response37 = 37,
+    /** Acknowledges service 38 with an empty status-200 body. */
+    response39 = 39,
+    /** Acknowledges service 40 with an empty status-200 body. */
+    response41 = 41,
     /** Returns the request-kind-specific matchmaking result. */
     matchmaking = 43,
+    /** Acknowledges service 48 with an empty status-200 body. */
+    response49 = 49,
     /** Returns schema-valid empty clan data with status 200. */
     clan = 45,
     /** Acknowledges notification subscriber registration. */

+ 11 - 2
Sunrise/src/middleware/datagen/character_record/appearance/character_appearance_abilities.cpp

@@ -6,9 +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};
+}
+
 } // namespace
 
-/** Fills the 12 ability buckets from the character's subclass and movement pick. */
+/** Fills the 12 ability buckets from the character's subclass and ability picks. */
 bool apply_ability_buckets(const state::CharacterState& character,
                            const family4::loadout::ResolvedInstances& instances,
                            layout::Appearance& appearance) noexcept {
@@ -21,7 +30,7 @@ bool apply_ability_buckets(const state::CharacterState& character,
         if (!state::build_data::find_configured_item_detail(
                 instances.items[index].instance.baseDefinitionIndex, detail)
             || !state::build_data::find_ability_buckets(
-                detail.socketEntryListIndex, character.movementAbilityEntry, published)) {
+                detail.socketEntryListIndex, selection_of(character), published)) {
             return false;
         }
         for (std::size_t bucket = 0; bucket < appearance.abilityBuckets.size(); ++bucket) {

+ 1 - 1
Sunrise/src/middleware/datagen/character_record/appearance/internal.h

@@ -54,7 +54,7 @@ void apply_sentinels(layout::Appearance& appearance) noexcept;
                                 layout::Appearance& appearance) noexcept;
 
 /**
- * Fills the 12 ability buckets from the character's subclass and movement pick.
+ * Fills the 12 ability buckets from the character's subclass and ability picks.
  * @param character Validated authored character.
  * @param instances Resolved item instances belonging to that character.
  * @param appearance Appearance block receiving the buckets.

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

@@ -142,7 +142,10 @@ bool encode(const ResolvedInstance& input, std::span<std::byte> output) noexcept
     object.roll.socketEntryListIndex = input.socketEntryListIndex;
 
     if (input.ordinarySockets.state == OrdinarySocketBlockState::present) {
+        // Both permit masks are filled. The plug walk reads the definition's declared plugs as
+        // well as this instance's lanes, and skips whichever source its mask leaves unset.
         object.ordinarySockets.activeMask = layout::kAllSocketBits;
+        object.ordinarySockets.definitionUnlockMask = layout::kAllSocketBits;
         for (std::size_t index = 0; index < input.ordinarySockets.plugs.size(); ++index) {
             const std::optional<std::uint16_t>& plug = input.ordinarySockets.plugs[index];
             if (plug.has_value()) {

+ 9 - 1
Sunrise/src/middleware/datagen/family4/instance/layout.h

@@ -80,13 +80,21 @@ struct OrdinarySocket {
     std::array<std::uint32_t, kSocketAuxiliaryHashCount> auxiliaryHashes{};
 };
 
-/** 12 ordinary sockets followed by their 5 replicated state masks. */
+/**
+ * 12 ordinary sockets followed by their 5 replicated state masks.
+ * A plug source passes only when its set mask holds the socket bit and its clear mask does not.
+ */
 struct OrdinarySocketBlock {
     std::array<OrdinarySocket, kOrdinarySocketCapacity> sockets{};
+    /** Set to pass a plug read from this instance's own socket lanes. */
     std::uint32_t activeMask{};
+    /** Set to pass a plug read from the item definition's declared socket block. */
     std::uint32_t definitionUnlockMask{};
+    /** Clear to pass a plug read from this instance's own socket lanes. */
     std::uint32_t blockedMask{};
+    /** Clear to pass a plug read from the item definition's declared socket block. */
     std::uint32_t expressionUnlockMask{};
+    /** The client ANDs this into `activeMask`, so a zero blanks every socket's state. */
     std::uint32_t gateMask{};
 };
 

+ 12 - 1
Sunrise/src/middleware/encoding/bit_writer.cpp

@@ -18,13 +18,24 @@ Writer::Writer(std::span<std::byte> output) noexcept : output_(output) {
     std::fill(output_.begin(), output_.end(), std::byte{});
 }
 
+/** Builds a writer that only counts bits. */
+Writer Writer::measuring() noexcept {
+    Writer writer{{}};
+    writer.measuring_ = true;
+    return writer;
+}
+
 /** Writes one bounded unsigned field without allocation or byte alignment. */
 bool Writer::write(std::uint64_t value, std::uint8_t width) noexcept {
     const std::size_t capacity = output_.size() * kBitsPerByte;
-    if (failed_ || width > kMaximumWriteWidth || width > capacity - bitPosition_) {
+    if (failed_ || width > kMaximumWriteWidth || (!measuring_ && width > capacity - bitPosition_)) {
         failed_ = true;
         return false;
     }
+    if (measuring_) {
+        bitPosition_ += width;
+        return true;
+    }
     for (std::uint8_t index = 0; index < width; ++index) {
         const std::uint8_t sourceShift = static_cast<std::uint8_t>(width - index - 1);
         const std::uint64_t bit = (value >> sourceShift) & 1U;

+ 9 - 0
Sunrise/src/middleware/encoding/bit_writer.h

@@ -12,6 +12,13 @@ public:
     /** @param output Caller-owned storage cleared before the first bit is written. */
     explicit Writer(std::span<std::byte> output) noexcept;
 
+    /**
+     * Builds a writer that only counts bits. Nothing is stored and no width is refused, so an
+     * encoder can measure its own body by running once before it writes.
+     * @return Writer that accepts every field and reports the total through bit_count.
+     */
+    [[nodiscard]] static Writer measuring() noexcept;
+
     /**
      * Writes the low bits of one unsigned value in most-significant-bit-first order.
      * @param value Unsigned source value.
@@ -34,6 +41,8 @@ private:
     std::span<std::byte> output_;
     std::size_t bitPosition_{};
     bool failed_{};
+    /** A measuring writer stores nothing and never runs out of room. */
+    bool measuring_{};
 };
 
 } // namespace sunrise::middleware::encoding::bits

+ 68 - 0
Sunrise/src/middleware/web_service/messages/opcode601/opcode601_codec.cpp

@@ -0,0 +1,68 @@
+/**
+ * Opcode 601 is a loot pickup. The Client sends it when it cannot pick the loot up on its own.
+ * The reply is 165 bits and no field can be left out, so the bare echo under-runs the decoder.
+ * Nothing reads the three tail fields. The request carries no completion handle.
+ */
+
+#include "opcode601_codec.h"
+
+#include <algorithm>
+#include <array>
+
+#include "../../../encoding/bit_writer.h"
+#include "../../../encoding/byte_order.h"
+#include "../../status_fields.h"
+
+namespace sunrise::middleware::web_service::messages::opcode601 {
+namespace {
+
+/** The three tail integers after the status pair are 32, 64 and 32 bits. */
+constexpr std::uint8_t kTailIntegerWidth = 32;
+constexpr std::uint8_t kTailLongWidth = 64;
+/** Required fields nothing reads still take their width. Zero is the neutral value. */
+constexpr std::uint64_t kUnusedValue = 0;
+
+} // namespace
+
+/** Reports whether this request is the remote loot pickup. */
+bool parse_request(const Message& message) noexcept {
+    return message.opcode == kOpcode;
+}
+
+/** Encodes the status pair and its three-field tail in descriptor order. */
+bool encode_response(const Message& message,
+                     std::span<std::byte> output,
+                     std::size_t& written) noexcept {
+    written = 0;
+    if (!parse_request(message) || output.size() < kResponseSize) {
+        return false;
+    }
+
+    std::array<std::byte, kResponseSize> staged{};
+    encoding::write_u16_be(std::span(staged).first<encoding::kU16Size>(), message.opcode);
+    encoding::write_u32_be(std::span(staged).subspan<encoding::kU16Size, encoding::kU32Size>(),
+                           message.transactionId);
+
+    // The status value is the Family-4 version the Client waits for. This route pushes no update,
+    // so the default value is right. It names a version the Client already has.
+    encoding::bits::Writer writer(std::span(staged).subspan(kEnvelopeHeaderSize));
+    bool encoded = status::write_fields(writer, ResponseShape::statusPair, StatusResponse{});
+    encoded = encoded && writer.write(kUnusedValue, kTailIntegerWidth)
+              && writer.write(kUnusedValue, kTailLongWidth)
+              && writer.write(kUnusedValue, kTailIntegerWidth)
+              && writer.write(0U, kAbsentTrailerWidth);
+
+    std::size_t payloadSize = 0;
+    if (!encoded || !writer.finish(payloadSize)) {
+        return false;
+    }
+    const std::size_t total = kEnvelopeHeaderSize + payloadSize;
+    if (output.size() < total) {
+        return false;
+    }
+    std::copy_n(staged.begin(), total, output.begin());
+    written = total;
+    return true;
+}
+
+} // namespace sunrise::middleware::web_service::messages::opcode601

+ 34 - 0
Sunrise/src/middleware/web_service/messages/opcode601/opcode601_codec.h

@@ -0,0 +1,34 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+#include "../../web_service_envelope.h"
+
+namespace sunrise::middleware::web_service::messages::opcode601 {
+
+/** Web Service opcode for a loot pickup the Client cannot do on its own. */
+inline constexpr std::uint16_t kOpcode = 601;
+/** The whole response is 27 bytes: 6 header, 165 body bits, 2 trailer bits. */
+inline constexpr std::size_t kResponseSize = 27;
+
+/**
+ * Reports whether this request is the loot pickup.
+ * No response field reads the request body, so its width and content are not tested.
+ * @param message Parsed Web Service envelope.
+ * @return True when the opcode matches.
+ */
+[[nodiscard]] bool parse_request(const Message& message) noexcept;
+
+/**
+ * Encodes the fixed-width response that completes the request.
+ * @param message Opcode-601 request whose envelope fields are echoed.
+ * @param output Caller-owned response storage.
+ * @param written Receives the exact response byte count on success.
+ * @return True when the opcode matches and the response fits.
+ */
+[[nodiscard]] bool
+encode_response(const Message& message, std::span<std::byte> output, std::size_t& written) noexcept;
+
+} // namespace sunrise::middleware::web_service::messages::opcode601

+ 3 - 6
Sunrise/src/server/bap/encrypted/activity_host_manager/activity_host_manager_route.cpp

@@ -124,8 +124,8 @@ prepare_allocation(const request_selection::ActivityManagerSelectionResult& pars
                          core::log::Level::warn,
                          "ev=bap svc=6 stage=package result=fallback");
     }
-    // Neither case carries a descriptor to rewrite, so a forced destination there sends the
-    // minimal one. The State fallback still stands when the switch is off.
+    // Neither case captures a descriptor, so a forced destination there sends the minimal one.
+    // The State fallback still stands when the switch is off.
     if (!hasCopy || !source.hasPackageName) {
         state::activity::destination::DestinationSelection forced{};
         if (state::activity::forced::apply(forced)) {
@@ -156,16 +156,13 @@ prepare_allocation(const request_selection::ActivityManagerSelectionResult& pars
         && source.descriptorBitLength <= destination.descriptorBits.size() * CHAR_BIT) {
         destination.descriptorBits = source.descriptorBits;
         destination.descriptorBitLength = static_cast<std::uint16_t>(source.descriptorBitLength);
-        destination.descriptorNameBit = static_cast<std::uint16_t>(source.packageNameBit);
-        destination.hasDescriptorName = source.hasPackageName;
     }
     // The authored override is applied here, once, so every message built from this selection sees
     // the same arrival instead of each push working it out again.
     state::activity::defaults::ActivityDefaults defaults{};
     state::activity::defaults::snapshot(defaults);
     state::activity::defaults::apply_arrival_override(defaults, destination);
-    // The forced destination lands last, on top of the client's own descriptor, so it rewrites the
-    // name inside those bits instead of replacing them.
+    // Forced lands last and drops the captured descriptor, so name and activity index agree.
     if (state::activity::forced::apply(destination)) {
         report_forced(destination);
     }

+ 209 - 0
Sunrise/src/server/bap/encrypted/activity_message/activity_message_route.cpp

@@ -13,7 +13,9 @@
 #include "../../../../middleware/bap/activity_message/activity_message_request_parser.h"
 #include "../../../../middleware/bap/activity_message/activity_state_refresh_parser.h"
 #include "../../../../middleware/bap/activity_message/client_authoritative_data.h"
+#include "../../../../middleware/bap/activity_message/entity_authority.h"
 #include "../../../../middleware/bap/activity_message/entity_slots.h"
+#include "../../../../middleware/bap/activity_message/incident.h"
 #include "../../../../state/activity/runtime.h"
 #include "membership/activity_membership_route.h"
 #include "middleware/bap/activity_message/activity_entity_slot_request_parser.h"
@@ -23,6 +25,7 @@ namespace sunrise::server::bap::encrypted::activity_message {
 namespace {
 
 namespace service = middleware::bap::activity_message;
+namespace authority = service::entity_authority;
 namespace client_keepalive = service::client_keepalive;
 namespace high_water = service::high_water;
 namespace epoch_message = service::patch_epoch;
@@ -30,6 +33,95 @@ namespace epoch_message = service::patch_epoch;
 /** Activity message type 3 starts the client join transaction. */
 constexpr std::uint32_t kJoinRequestMessageType = 3;
 
+/** One row per Client-sent message this route accepts but has no state to change for. */
+struct AcceptedMessage {
+    std::uint32_t type;
+    const char* name;
+};
+
+/**
+ * The Client senders that carry no work for this host. Each is one-way, so accepting is the whole
+ * contract. The names are the binary's own, so a log line says what arrived.
+ */
+constexpr std::array<AcceptedMessage, 14> kAcceptedMessages{{
+    {6, "sensor_sense_update"},
+    {8, "request_activity_host"},
+    {11, "start_new_activity"},
+    {13, "request_peer_reservation"},
+    {14, "release_peer_reservation"},
+    {15, "peer_leave_request"},
+    {34, "process_debug_command"},
+    {37, "connectivity_failure"},
+    {39, "send_client_heartbeat"},
+    {43, "bug_claw"},
+    {46, "report_lag_switch"},
+    {47, "connection_quality_report"},
+    {48, "speculative_migration"},
+    {50, "refresh_inspirations"},
+}};
+
+/** @return The binary name for one accepted message type, or nullptr when it is not one. */
+[[nodiscard]] const char* accepted_name(std::uint32_t messageType) noexcept {
+    const auto row = std::find_if(kAcceptedMessages.begin(),
+                                  kAcceptedMessages.end(),
+                                  [messageType](const AcceptedMessage& candidate) noexcept {
+                                      return candidate.type == messageType;
+                                  });
+    return row == kAcceptedMessages.end() ? nullptr : row->name;
+}
+
+/**
+ * Records one accepted message that changes no host state.
+ * @param messageType Activity message type from the envelope.
+ * @param name Binary name for that type.
+ * @param payloadSize Declared payload bytes, which is the only thing that varies here.
+ */
+void report_accepted(std::uint32_t messageType,
+                     const char* name,
+                     std::size_t payloadSize) noexcept {
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=activity stage=message result=accept type=%u name=%s "
+                                      "bytes=%zu",
+                                      messageType,
+                                      name,
+                                      payloadSize);
+    if (written > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Checks one incident and reports its verdict. Nothing relays msg 19 yet, so a pass changes
+ * nothing. A failure is named because a bad target index would crash the Client if it were sent on.
+ * @param request Validated owned svc8 envelope.
+ */
+void report_incident(const service::Request& request) noexcept {
+    namespace incident = service::incident;
+    incident::Incident parsed;
+    const incident::Verdict verdict = incident::validate(request.payload, parsed);
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=activity stage=incident result=%s target=%u extra=%u "
+                                      "selector=%u payload=%u",
+                                      incident::verdict_name(verdict),
+                                      parsed.primaryTarget,
+                                      parsed.extraTargetCount,
+                                      static_cast<unsigned>(parsed.hasCompressedSelector),
+                                      parsed.payloadLength);
+    if (written <= 0) {
+        return;
+    }
+    const auto level =
+        verdict == incident::Verdict::accepted ? core::log::Level::debug : core::log::Level::warn;
+    core::log::write(
+        core::log::Channel::server, level, {line.data(), static_cast<std::size_t>(written)});
+}
+
 /**
  * Reports one activity message the route did not stage, naming its type.
  * Every inbound activity message is one-way, so nothing here can jam the Client's reply ring. An
@@ -100,6 +192,100 @@ void report_message(std::uint32_t messageType,
     return true;
 }
 
+/**
+ * Stages a release for the slots msg 26 or msg 33 gives back.
+ * Both carry the same mask, so both return leases the same way.
+ * @param request Validated owned svc8 envelope.
+ * @param expectReason True for msg 26, which trails a 3-bit reason after the mask.
+ * @param plan Cleared, then receives the chosen release mask.
+ * @return True when the fixed body decodes and its session can stage a release.
+ */
+[[nodiscard]] bool prepare_authority_release(const service::Request& request,
+                                             bool expectReason,
+                                             ActivityPlan& plan) noexcept {
+    authority::Release decoded;
+    const bool parsed = expectReason ? authority::parse_abandon(request.payload, decoded)
+                                     : authority::parse_abdicate(request.payload, decoded);
+    if (!parsed) {
+        return false;
+    }
+    state::activity::entity_slots::LeaseMask returned{};
+    std::copy(decoded.mask.begin(), decoded.mask.end(), returned.begin());
+    if (!state::activity::entity_slots::prepare_release(
+            request.accountHandle, returned, plan.entitySlotMutation)) {
+        return false;
+    }
+    plan.sessionId = request.accountHandle;
+    plan.delivery = Delivery::none;
+    plan.mutationDomain = MutationDomain::entitySlots;
+
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=activity stage=authority result=ok type=%u selector=%u "
+                                      "reason=%d",
+                                      request.messageType,
+                                      static_cast<unsigned>(decoded.selector),
+                                      decoded.hasReason ? decoded.reason : 0);
+    if (written > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+    return true;
+}
+
+/**
+ * Reports one msg 29, 31 or 32 answer. This host sends no msg 28 or msg 30, so an answer here is
+ * the Client reconciling on its own. Nothing is staged.
+ * @param request Validated owned svc8 envelope.
+ * @return True when the body for that message type decodes.
+ */
+[[nodiscard]] bool report_query_answer(const service::Request& request) noexcept {
+    namespace authority = service::entity_authority;
+    authority::QueryAnswer answer;
+    if (!authority::parse_query_answer(request.messageType, request.payload, answer)) {
+        return false;
+    }
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=activity stage=authority result=ok type=%u corr=0x%08X "
+                                      "selector=%d mask=%u",
+                                      request.messageType,
+                                      answer.correlation,
+                                      answer.hasSelector ? static_cast<int>(answer.selector) : -1,
+                                      static_cast<unsigned>(answer.hasMask));
+    if (written > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+    return true;
+}
+
+/**
+ * Reports one msg 27 purge request. The host does not answer it: the reply is msg 25, whose
+ * consumer asserts unless the epoch is one above the Client's own, and nothing here tracks that.
+ * @param request Validated owned svc8 envelope.
+ * @return True when the fixed body is present.
+ */
+[[nodiscard]] bool report_request_purge(const service::Request& request) noexcept {
+    std::int32_t reason = 0;
+    if (!service::entity_authority::parse_request_purge(request.payload, reason)) {
+        return false;
+    }
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(
+        line.data(), line.size(), "ev=activity stage=purge result=noted reason=%d", reason);
+    if (written > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+    return true;
+}
+
 /**
  * Prepares only the slots that are both held and in the returned mask.
  * @param request Validated owned svc8 envelope.
@@ -163,6 +349,29 @@ bool process(std::uint64_t boundSessionId,
         prepared = membership::prepare_authoritative(request, plan);
     } else if (request.messageType == service::membership_acknowledgement::kMessageType) {
         prepared = membership::prepare_acknowledgement(request, plan);
+    } else if (request.messageType == authority::kAbandonMessageType) {
+        prepared = prepare_authority_release(request, true, plan);
+    } else if (request.messageType == authority::kAbdicateMessageType) {
+        prepared = prepare_authority_release(request, false, plan);
+    } else if (request.messageType == service::incident::kMessageType) {
+        report_incident(request);
+        return true;
+    } else if (request.messageType == authority::kRequestPurgeMessageType) {
+        if (!report_request_purge(request)) {
+            report_message(request.messageType, request.accountHandle, "parse");
+        }
+        return true;
+    } else if (request.messageType == authority::kResetAcknowledgementMessageType
+               || request.messageType == authority::kQueryPerBubbleMessageType
+               || request.messageType == authority::kQueryResponseMessageType) {
+        if (!report_query_answer(request)) {
+            report_message(request.messageType, request.accountHandle, "parse");
+        }
+        return true;
+    } else if (const char* name = accepted_name(request.messageType); name != nullptr) {
+        // One-way with nothing to change here. Accepting is the whole contract.
+        report_accepted(request.messageType, name, request.payload.size());
+        return true;
     } else {
         // Later message handlers are independent. An owned envelope is a safe no-op.
         report_message(request.messageType, request.accountHandle, "unhandled");

+ 2 - 1
Sunrise/src/server/bap/encrypted/push/activity/activity_global_state_push.cpp

@@ -9,6 +9,7 @@
 #include "../../../../../middleware/secure_channel/runtime.h"
 #include "../../../../../state/activity/defaults/activity_defaults_snapshot.h"
 #include "../../../../../state/activity/destination/activity_destination_snapshot.h"
+#include "../../../../../state/activity/destination/activity_destination_spawn_binding.h"
 #include "../../../../../state/build_data/runtime.h"
 #include "activity_arrival.h"
 #include "activity_notification_frame.h"
@@ -66,7 +67,7 @@ resolve_state(std::uint64_t sessionId,
     output.fromActivityIndex = selection.previousActivityIndex;
     output.activityIndex = selection.activityIndex;
     output.spawnSetHash =
-        state::activity::destination::resolve_spawn_set_hash(selection, fallback.spawnSetHash);
+        state::activity::destination::attachable_spawn_set_hash(selection, fallback.spawnSetHash);
 
     // The extracted layout wins where the packages carry one. The count and the output array must
     // come from the same source: a count from one and states from another is how uniform values

+ 2 - 1
Sunrise/src/server/bap/encrypted/push/activity/activity_roster_snapshot.cpp

@@ -6,6 +6,7 @@
 #include "../../../../../state/account/account_state.h"
 #include "../../../../../state/activity/defaults/activity_defaults_snapshot.h"
 #include "../../../../../state/activity/destination/activity_destination_snapshot.h"
+#include "../../../../../state/activity/destination/activity_destination_spawn_binding.h"
 #include "../../../../../state/activity/membership/activity_membership_query.h"
 #include "../../../../../state/build_data/runtime.h"
 #include "../../../../../state/runtime/runtime.h"
@@ -188,7 +189,7 @@ RosterOutcome build_roster_snapshot(Session& session,
     // The spawn override always names the destination's own arrival, never the player's position.
     snapshot.spawnSliceSet = sliceSet;
     snapshot.spawnSetHash =
-        state::activity::destination::resolve_spawn_set_hash(selection, fallback.spawnSetHash);
+        state::activity::destination::attachable_spawn_set_hash(selection, fallback.spawnSetHash);
     snapshot.hasSpawnOverride =
         snapshot.spawnSetHash != 0 && snapshot.spawnSetHash != message::kAbsentSpawnSetHash;
     snapshot.stateSequence = next_state_sequence(session, fold_groups(snapshot.roster), burst);

+ 33 - 0
Sunrise/src/server/bap/encrypted/routing/bap_service_routing.cpp

@@ -73,6 +73,38 @@ bool resolve(std::uint16_t request, ServiceRoute& route) noexcept {
                  BodyCodec::accountTranslationResponse,
                  "ev=bap svc=23 rsp=24 result=ok"};
         return true;
+    // These five need a reply: each holds the head of the Client's pending queue until one comes.
+    // Every field of their response bodies is optional, so an empty body is valid.
+    case middleware::bap::RequestService::skill:
+        route = {ResponseMode::reply,
+                 middleware::bap::ResponseService::skill,
+                 BodyCodec::empty,
+                 "ev=bap svc=34 rsp=35 result=ok"};
+        return true;
+    case middleware::bap::RequestService::request36:
+        route = {ResponseMode::reply,
+                 middleware::bap::ResponseService::response37,
+                 BodyCodec::empty,
+                 "ev=bap svc=36 rsp=37 result=ok"};
+        return true;
+    case middleware::bap::RequestService::request38:
+        route = {ResponseMode::reply,
+                 middleware::bap::ResponseService::response39,
+                 BodyCodec::empty,
+                 "ev=bap svc=38 rsp=39 result=ok"};
+        return true;
+    case middleware::bap::RequestService::request40:
+        route = {ResponseMode::reply,
+                 middleware::bap::ResponseService::response41,
+                 BodyCodec::empty,
+                 "ev=bap svc=40 rsp=41 result=ok"};
+        return true;
+    case middleware::bap::RequestService::request48:
+        route = {ResponseMode::reply,
+                 middleware::bap::ResponseService::response49,
+                 BodyCodec::empty,
+                 "ev=bap svc=48 rsp=49 result=ok"};
+        return true;
     case middleware::bap::RequestService::matchmaking:
         route = {ResponseMode::reply,
                  middleware::bap::ResponseService::matchmaking,
@@ -126,6 +158,7 @@ bool resolve(std::uint16_t request, ServiceRoute& route) noexcept {
         return true;
     default:
         // Unknown services stay quiet. Failing the send would drop the whole BAP link.
+        // Quiet is only safe when the service has no response id. A request needs a case above.
         route = {
             ResponseMode::none, {}, BodyCodec::empty, "ev=bap svc=unhandled rsp=none result=ok"};
         return true;

+ 13 - 4
Sunrise/src/server/transport/bap_listener.cpp

@@ -6,6 +6,7 @@
 #include <cstdio>
 
 #include "../../core/logging/log.h"
+#include "../../core/settings/settings.h"
 #include "internal.h"
 
 namespace sunrise::server::transport {
@@ -193,15 +194,23 @@ bool initialize_on_port(std::uint16_t port) noexcept {
     }
     g_listener.active = true;
     g_listener.nextPollTick = 0;
-    core::log::write(
-        core::log::Channel::server, core::log::Level::info, "ev=transport stage=listen result=ok");
+    std::array<char, 64> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=transport stage=listen result=ok port=%u",
+                                      static_cast<unsigned>(port));
+    if (written > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
     ReleaseSRWLockExclusive(&g_listenerLock);
     return true;
 }
 
-/** Starts the nonblocking listener on the Client's fixed BAP port. */
+/** Starts the nonblocking listener on the configured BAP port. */
 bool initialize() noexcept {
-    return initialize_on_port(kBapPort);
+    return initialize_on_port(core::settings::get().server.bapPort);
 }
 
 /** Runs one bounded listener slice on the caller thread. @param now Monotonic tick count. */

+ 0 - 3
Sunrise/src/server/transport/bap_listener.h

@@ -4,9 +4,6 @@
 
 namespace sunrise::server::transport {
 
-/** The Client maps the primary BAP endpoint to this loopback port. */
-inline constexpr std::uint16_t kBapPort = 30974;
-
 /** Starts the nonblocking loopback BAP listener. @return True when the listener is bound. */
 [[nodiscard]] bool initialize() noexcept;
 

+ 0 - 6
Sunrise/src/server/ui/activity_override/activity_override_lists.cpp

@@ -103,7 +103,6 @@ void clear_spawns(Lists& rows) noexcept {
     rows.spawnNarrowed = false;
     rows.spawnHidden = 0;
     rows.spawnForeign = 0;
-    rows.spawnFallback = state::activity::forced::kAbsentSpawnSetHash;
 }
 
 /** Clears every list that belongs to the selected destination. */
@@ -228,11 +227,6 @@ void build_spawns(Lists& rows, std::string_view stem, std::uint16_t mapIndex) no
         rows.spawns[rows.spawnCount] = label;
         rows.spawnHashes[rows.spawnCount] = hash;
         ++rows.spawnCount;
-        // The Client swaps in `default` for an unnamed point, so either one makes `default` the
-        // safe unchosen value. A map with neither gets the absent hash and its own search.
-        if (hash == tables::kDefaultSpawnNameHash || hash == tables::kUnnamedSpawnNameHash) {
-            rows.spawnFallback = state::activity::forced::kDefaultSpawnSetHash;
-        }
     }
 }
 

+ 0 - 6
Sunrise/src/server/ui/activity_override/activity_override_lists.h

@@ -49,12 +49,6 @@ struct Lists {
     std::size_t spawnHidden{};
     /** Rows whose package this destination does not load, which are shown and marked. */
     std::size_t spawnForeign{};
-    /**
-     * What to send when no spawn set is chosen on this destination.
-     * `default` where the map declares one, the absent hash where it does not. Forcing `default`
-     * onto a map without one makes the filtered search find nothing.
-     */
-    std::uint32_t spawnFallback{};
 };
 
 /** @return Process-lifetime picker rows, which no other module reads. */

+ 5 - 12
Sunrise/src/server/ui/activity_override/activity_override_panel.cpp

@@ -29,7 +29,7 @@ constexpr char kUnset[] = "none";
 /** Preview shown by a picker whose list is empty. */
 constexpr char kEmptyList[] = "nothing to pick";
 /** Spawn row that names no set, which forces the default one. */
-constexpr char kDefaultSpawnRow[] = "none  (forces default)";
+constexpr char kDefaultSpawnRow[] = "none  (client picks)";
 /** Room for the longest status line this module builds. */
 constexpr std::size_t kStatusCapacity = 64;
 /** Widest picker list this module draws, so no list is silently cut short. */
@@ -112,7 +112,6 @@ void follow_destination(const forced::ForcedDestination& value, Lists& rows) noe
     value.hasSpawnSetHash = false;
     value.spawnSetHash = 0;
     follow_destination(value, rows);
-    value.spawnFallback = rows.spawnFallback;
     return true;
 }
 
@@ -205,13 +204,8 @@ void draw_status(const forced::ForcedDestination& value, const Lists& rows) noex
         ImGui::TextUnformatted("incomplete, select valid activity");
         return;
     }
-    if (value.hasSpawnSetHash) {
-        ImGui::TextUnformatted("active");
-    } else {
-        ImGui::TextUnformatted(value.spawnFallback == forced::kDefaultSpawnSetHash
-                                   ? "active, forcing the default spawn"
-                                   : "active");
-    }
+    ImGui::TextUnformatted(value.hasSpawnSetHash ? "active, forcing the chosen spawn set"
+                                                 : "active, the client picks its own spawn");
     if (rows.spawnUnavailable) {
         ImGui::TextDisabled("this destination's spawn sets could not be listed");
     }
@@ -255,9 +249,8 @@ void draw() noexcept {
         g_activityRow = kNoRow;
     }
 
-    core::ui::components::section::header(
-        "Activity override",
-        "Forces every load to redirect to these values.");
+    core::ui::components::section::header("Activity override",
+                                          "Forces every load to redirect to these values.");
 
     bool changed = core::ui::components::toggle::control("Enabled", value.enabled);
     ImGui::SameLine();

+ 1 - 1
Sunrise/src/server/web_service/opcode_routes.cpp

@@ -18,7 +18,7 @@ constexpr auto kStatusPairOpcodes = std::to_array<std::uint16_t>({
     802,  803,  804,  902,  903,  904,  905,  1103, 1201, 1202, 1203, 1205, 1206, 1207,
     1208, 1209, 1210, 1211, 1212, 1213, 1215, 1216, 1219, 1220, 1221, 1222, 1223, 1224,
     1227, 1228, 1229, 1230, 1236, 1238, 1240, 1243, 1307, 1309, 1310, 1401, 1615, 1616,
-    1617, 1618, 1701, 1702, 1801, 1802, 1803, 1820, 1821, 1901, 2002, 2300, 2400,
+    1617, 1618, 1701, 1702, 1801, 1802, 1803, 1820, 1821, 1901, 2002, 2200, 2300, 2400,
 });
 
 /** Opcodes whose status pair has one required trailing boolean field. */

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

@@ -9,6 +9,7 @@
 #include "../../middleware/web_service/messages/opcode501_codec.h"
 #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/web_service_envelope.h"
 #include "../../state/account/account_state.h"
 #include "../../state/runtime/runtime.h"
@@ -175,6 +176,12 @@ bool consume(std::span<const std::byte> request,
                || encode_echo(message, response, written);
     }
 
+    if (message.opcode == middleware::web_service::messages::opcode601::kOpcode) {
+        return middleware::web_service::messages::opcode601::encode_response(
+                   message, response, written)
+               || encode_echo(message, response, written);
+    }
+
     // A subscribe whose body does not parse is still answered; only the subscription is dropped.
     middleware::queuez::Subscription subscription;
     const bool subscribes =

+ 2 - 1
Sunrise/src/state/account/account_state.h

@@ -40,10 +40,11 @@ enum class CharacterClass : std::uint8_t {
     warlock = 2,
 };
 
-/** The first movement entry of a subclass's movement group, used when none is authored. */
+/** Default movement entry. Each subclass offers 3, as entries 4, 5 and 6 of its group. */
 inline constexpr std::uint8_t kDefaultMovementAbilityEntry = 4;
 /** No socket entry list declares more entries than this, so a larger value is not an entry. */
 inline constexpr std::uint8_t kMaximumMovementAbilityEntry = 63;
+
 /**
  * Socket entries of the other abilities a subclass lets the player choose. Each names one entry
  * of that ability's group. The subclass offers several and the character picks one. These

+ 106 - 0
Sunrise/src/state/activity/destination/activity_destination_spawn_binding.cpp

@@ -0,0 +1,106 @@
+#include "activity_destination_spawn_binding.h"
+
+#include <array>
+#include <atomic>
+#include <cstddef>
+#include <cstdio>
+#include <span>
+#include <string_view>
+
+#include "../../../core/logging/log.h"
+#include "../../build_data/runtime.h"
+
+namespace sunrise::state::activity::destination {
+namespace {
+
+/** Spawn-set rows read for one stem. The widest installed stem declares 294. */
+constexpr std::size_t kSpawnRowCapacity = 512;
+
+/** Last reported hash, so a per-push decision is written once. */
+std::atomic_uint32_t g_reportedHash{};
+
+/** @return The destination's package name as a bounded view. */
+[[nodiscard]] std::string_view name_of(const DestinationSelection& selection) noexcept {
+    return {reinterpret_cast<const char*>(selection.packageName.data()),
+            selection.packageNameLength};
+}
+
+/**
+ * Tests whether a destination loads the package that declares one set.
+ * @param layout Destination row carrying the packages it loads.
+ * @param row Spawn-set row carrying the packages that declare it.
+ * @return True when the set is in the map package or in one the destination names.
+ */
+[[nodiscard]] bool loads_package(const build_data::scenarios::Definition& layout,
+                                 const build_data::spawn_sets::NameHash& row) noexcept {
+    if (row.inMapPackage != 0) {
+        return true;
+    }
+    const std::size_t declared =
+        layout.packageCount < layout.packages.size() ? layout.packageCount : layout.packages.size();
+    for (std::size_t index = 0; index < row.activityPackageCount; ++index) {
+        for (std::size_t package = 0; package < declared; ++package) {
+            if (layout.packages[package] == row.activityPackages[index]) {
+                return true;
+            }
+        }
+    }
+    return false;
+}
+
+/** Writes the drop once per hash. @param name Destination the set was dropped for. */
+void report_dropped(std::string_view name, std::uint32_t hash) noexcept {
+    if (g_reportedHash.exchange(hash, std::memory_order_acq_rel) == hash) {
+        return;
+    }
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=activity stage=spawn_set result=dropped name=%.*s "
+                                      "spawn=0x%08X reason=not_loaded",
+                                      static_cast<int>(name.size()),
+                                      name.data(),
+                                      hash);
+    if (written > 0) {
+        core::log::write(core::log::Channel::state,
+                         core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+} // namespace
+
+/** Drops a spawn set the destination cannot load. Only a proved miss is dropped. */
+std::uint32_t attachable_spawn_set_hash(const DestinationSelection& selection,
+                                        std::uint32_t fallback) noexcept {
+    const std::uint32_t hash = resolve_spawn_set_hash(selection, fallback);
+    if (hash == 0 || hash == kAbsentSpawnSetHash) {
+        return hash;
+    }
+    const std::string_view name = name_of(selection);
+    build_data::scenarios::Definition layout{};
+    if (name.empty() || !build_data::find_scenario_layout(name, layout)) {
+        return hash;
+    }
+    const std::string_view stem(layout.spawnStem.data(), layout.spawnStemLength);
+    static std::array<build_data::spawn_sets::NameHash, kSpawnRowCapacity> rows{};
+    std::size_t count = 0;
+    if (stem.empty() || !build_data::find_spawn_sets(stem, rows, count)) {
+        return hash;
+    }
+    for (std::size_t index = 0; index < count; ++index) {
+        if (rows[index].value != hash) {
+            continue;
+        }
+        if (loads_package(layout, rows[index])) {
+            g_reportedHash.store(0, std::memory_order_release);
+            return hash;
+        }
+        report_dropped(name, hash);
+        return kAbsentSpawnSetHash;
+    }
+    // A hash no row carries is not proof of a miss: the row set can be capped. Send it as picked.
+    return hash;
+}
+
+} // namespace sunrise::state::activity::destination

+ 20 - 0
Sunrise/src/state/activity/destination/activity_destination_spawn_binding.h

@@ -0,0 +1,20 @@
+#pragma once
+
+#include <cstdint>
+
+#include "definition.h"
+
+namespace sunrise::state::activity::destination {
+
+/**
+ * Picks the spawn-set hash to send, dropping one the destination does not load.
+ * A set declared only by a package the destination never loads cannot attach, and the player
+ * arrives with no spawn point. The absent hash goes out instead, so the Client picks its own.
+ * @param selection Committed destination.
+ * @param fallback Authored fallback hash.
+ * @return The resolved hash, or the absent hash when the set cannot attach.
+ */
+[[nodiscard]] std::uint32_t attachable_spawn_set_hash(const DestinationSelection& selection,
+                                                      std::uint32_t fallback) noexcept;
+
+} // namespace sunrise::state::activity::destination

+ 0 - 8
Sunrise/src/state/activity/destination/definition.h

@@ -80,14 +80,6 @@ struct DestinationSelection final {
     std::array<std::byte, kDescriptorCapacity> descriptorBits{};
     /** Meaningful bits in descriptorBits, or zero when no descriptor was captured. */
     std::uint16_t descriptorBitLength{};
-    /**
-     * Bit offset of the name field inside descriptorBits, valid with a nonzero length.
-     * Forcing a destination rewrites the name here instead of re-encoding the descriptor. The
-     * whole descriptor is what the Client's own ready state needs.
-     */
-    std::uint16_t descriptorNameBit{};
-    /** True only when the captured descriptor carried a name field to rewrite. */
-    bool hasDescriptorName{};
 };
 
 /**

+ 8 - 57
Sunrise/src/state/activity/forced/activity_forced_destination.cpp

@@ -2,56 +2,11 @@
 
 #include <Windows.h>
 
-#include <algorithm>
+#include <cstddef>
 
 #include "../../runtime/storage/internal.h"
 
 namespace sunrise::state::activity::forced {
-namespace {
-
-/** Bits in one byte, the step size of the descriptor's storage. */
-constexpr std::size_t kBitsPerByte = 8;
-/** The top bit of a byte, where every packed field starts. */
-constexpr unsigned kHighBit = 0x80;
-/** Every package-name element is one biased byte. */
-constexpr std::uint8_t kNameElementBias = 0x80;
-
-/**
- * Rewrites the package name inside one captured descriptor.
- * The field is a fixed 40 elements, so the descriptor keeps its length and every field
- * around the name keeps its bits.
- * @param selection Destination holding the captured descriptor.
- * @param name Forced package name.
- * @param length Bytes of that name.
- * @return True when the name field is inside the captured bits and was rewritten.
- */
-[[nodiscard]] bool
-rewrite_descriptor_name(destination::DestinationSelection& selection,
-                        const std::array<char, destination::kPackageNameCapacity>& name,
-                        std::size_t length) noexcept {
-    const std::size_t fieldBits = destination::kPackageNameCapacity * kBitsPerByte;
-    if (!selection.hasDescriptorName || selection.descriptorBitLength == 0
-        || selection.descriptorNameBit + fieldBits > selection.descriptorBitLength) {
-        return false;
-    }
-    for (std::size_t element = 0; element < destination::kPackageNameCapacity; ++element) {
-        // The field is fixed width, so every element past the name is written as a biased zero.
-        const auto character =
-            element < length ? static_cast<std::uint8_t>(name[element]) : std::uint8_t{};
-        const auto encoded = static_cast<unsigned>(character + kNameElementBias);
-        for (std::size_t bit = 0; bit < kBitsPerByte; ++bit) {
-            const std::size_t at = selection.descriptorNameBit + (element * kBitsPerByte) + bit;
-            std::byte& target = selection.descriptorBits[at / kBitsPerByte];
-            const unsigned mask = kHighBit >> (at % kBitsPerByte);
-            const bool set = (encoded >> (kBitsPerByte - 1 - bit) & 1U) != 0;
-            target = static_cast<std::byte>(set ? static_cast<unsigned>(target) | mask
-                                                : static_cast<unsigned>(target) & ~mask);
-        }
-    }
-    return true;
-}
-
-} // namespace
 
 /** Replaces the forced destination. */
 bool publish(const ForcedDestination& value) noexcept {
@@ -107,18 +62,14 @@ bool apply(destination::DestinationSelection& selection) noexcept {
     selection.hasArrivalBubbleOverride = true;
     selection.sliceSetOverride = value.sliceSet;
     selection.hasSliceSetOverride = true;
-    // With no set chosen the destination's own fallback stands: `default` where the map has one,
-    // and the absent hash where it does not, which leaves the Client its loaded-world search.
-    selection.spawnSetOverride = value.hasSpawnSetHash ? value.spawnSetHash : value.spawnFallback;
+    // With no set chosen the absent hash goes out, so the Client searches the loaded world itself.
+    // A map-wide set is not proof that the arrival bubble holds one of its points.
+    selection.spawnSetOverride = value.hasSpawnSetHash ? value.spawnSetHash : kAbsentSpawnSetHash;
     selection.hasSpawnSetOverride = true;
-    // Only the name inside the descriptor is rewritten. Re-encoding a small one drops the fields
-    // with no known name, which leaves the Client's waiting overlay on screen.
-    if (!rewrite_descriptor_name(selection, value.packageName, value.packageNameLength)) {
-        selection.descriptorBits = {};
-        selection.descriptorBitLength = 0;
-        selection.descriptorNameBit = 0;
-        selection.hasDescriptorName = false;
-    }
+    // The captured descriptor is dropped, not patched: its activity index names what the Client
+    // picked, and a forced name beside a picked index starts the wrong activity with no spawn.
+    selection.descriptorBits = {};
+    selection.descriptorBitLength = 0;
     return true;
 }
 

+ 3 - 10
Sunrise/src/state/activity/forced/definition.h

@@ -15,9 +15,8 @@ inline constexpr std::uint16_t kMaximumSliceSet = 1'022;
 /** The set normal arrivals use. */
 inline constexpr std::uint32_t kDefaultSpawnSetHash = 0x2EA8FB98U;
 /**
- * The hash that names no set at all.
- * Forcing `default` on a map with no such set makes the Client's search find nothing and fall
- * through to an unrelated point, so a map without one is sent this instead.
+ * The hash that names no set, so the Client searches the loaded world itself.
+ * A set belongs to the map, not the bubble. Send one the bubble lacks and nothing spawns.
  */
 inline constexpr std::uint32_t kAbsentSpawnSetHash = 0x811C9DC5U;
 
@@ -33,11 +32,6 @@ struct ForcedDestination {
     std::uint16_t sliceSet{};
     /** Spawn-set name hash, used only when one was chosen. */
     std::uint32_t spawnSetHash{};
-    /**
-     * What to send when no spawn set is chosen, taken from the destination's own sets.
-     * The interface fills this in when the destination is picked.
-     */
-    std::uint32_t spawnFallback{kAbsentSpawnSetHash};
     bool hasBubble{};
     bool hasSliceSet{};
     bool hasSpawnSetHash{};
@@ -47,8 +41,7 @@ struct ForcedDestination {
 
 /**
  * Tests whether a forced destination names enough to replace a client selection.
- * The spawn set is the one optional part. Without it the default set is forced, because an
- * arrival with no spawn filter picks a point from anywhere on the map.
+ * The spawn set is the one optional part. Without it the Client picks its own point.
  * @param value Candidate selection.
  * @return True when the switch is on and the destination, bubble, and slice set are all named.
  */

+ 38 - 3
Sunrise/src/state/runtime/equipment/configured_equipment_identity.cpp

@@ -49,6 +49,39 @@ void mix_value(std::uint64_t& hash, std::uint32_t value) noexcept {
     }
 }
 
+/**
+ * Mixes one item's socket policy and every authored plug lane.
+ * The extraction pass reads a detail row for each authored plug, so a changed plug must rebuild.
+ * @param hash Mutable 64-bit FNV-1a accumulator.
+ * @param sockets Authored socket policy and lanes.
+ */
+void mix_sockets(std::uint64_t& hash, const account::inventory::Sockets& sockets) noexcept {
+    mix_byte(hash, static_cast<std::uint8_t>(sockets.policy));
+    mix_byte(hash, static_cast<std::uint8_t>(sockets.plugCount));
+    for (std::size_t lane = 0; lane < sockets.plugCount && lane < sockets.plugs.size(); ++lane) {
+        if (!sockets.plugs[lane].has_value()) {
+            mix_byte(hash, kAbsentItemMarker);
+            continue;
+        }
+        mix_byte(hash, kPresentItemMarker);
+        mix_value(hash, *sockets.plugs[lane]);
+    }
+}
+
+/**
+ * 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);
+}
+
 } // namespace
 
 /** Builds a nonsecret cache identity from ordered authored equipment. */
@@ -57,16 +90,18 @@ std::uint64_t configured_hash(const AccountState& accountState) noexcept {
     mix_byte(hash, static_cast<std::uint8_t>(accountState.characterCount));
     for (std::size_t characterIndex = 0; characterIndex < accountState.characterCount;
          ++characterIndex) {
-        for (const std::optional<account::inventory::Item>& item :
-             accountState.characters[characterIndex].equipment.slots) {
+        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);
                 continue;
             }
             mix_byte(hash, kPresentItemMarker);
-            // SOIDs, quantity, plugs, selection, gates and secrets stay outside build identity.
+            // SOIDs, quantity, gates and secrets stay outside build identity.
             mix_value(hash, item->definitionHash);
             mix_value(hash, static_cast<std::uint32_t>(item->level));
+            mix_sockets(hash, item->sockets);
         }
     }
     return hash;

+ 2 - 3
Sunrise/src/state/runtime/state_runtime.cpp

@@ -30,8 +30,6 @@ namespace {
 
 /** Network-order IPv4 loopback returned by the in-process SignOn route. */
 constexpr std::uint32_t kLoopbackAddress = 0x7F000001;
-/** BAP relay port used by the generated SignOn response. */
-constexpr std::uint16_t kDefaultRelayPort = 30974;
 /** Default one-hour lifetime for generated SignOn session tokens. */
 constexpr std::uint32_t kDefaultTokenLifetimeSeconds = 3600;
 /** Family 5 uses the largest signed 64-bit value as its process-global object key. */
@@ -105,7 +103,8 @@ bool initialize(void* module,
         return false;
     }
     initialized.signOn.relayAddress = kLoopbackAddress;
-    initialized.signOn.relayPort = kDefaultRelayPort;
+    // The published relay port is the one the listener binds, so both move with one setting.
+    initialized.signOn.relayPort = core::settings::get().server.bapPort;
     initialized.signOn.tokenLifetimeSeconds = kDefaultTokenLifetimeSeconds;
     initialized.account = initialAccount;
     initialized.activity.defaults = activityDefaults;