Explorar el Código

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 hace 3 semanas
padre
commit
da7bfed54f
Se han modificado 73 ficheros con 2294 adiciones y 373 borrados
  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
 ## 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
 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.
 [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_scalar.cpp" />
     <ClCompile Include="src\core\settings\json_structure.cpp" />
     <ClCompile Include="src\core\settings\json_structure.cpp" />
     <ClCompile Include="src\core\settings\settings_runtime.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_settings_parser.cpp" />
     <ClCompile Include="src\core\settings\client\client_ui_settings_parser.cpp" />
     <ClCompile Include="src\core\settings\client\client_ui_settings_parser.cpp" />
     <ClCompile Include="src\core\settings\server\server_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\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_lifecycle.cpp" />
     <ClCompile Include="src\client\hooks\graphics\renderer\graphics_renderer_frame.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\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_forward.cpp" />
     <ClCompile Include="src\client\hooks\egress\resolver\egress_resolver_reverse.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_snapshot.cpp" />
     <ClCompile Include="src\state\activity\defaults\activity_defaults_validation.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_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\destination\activity_destination_validation.cpp" />
     <ClCompile Include="src\state\activity\transactions\activity_session_prepare.cpp" />
     <ClCompile Include="src\state\activity\transactions\activity_session_prepare.cpp" />
     <ClCompile Include="src\state\activity\transactions\activity_session_commit.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_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_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_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\activity_message\activity_entity_slots_encoder.cpp" />
     <ClCompile Include="src\middleware\bap\certificate.cpp" />
     <ClCompile Include="src\middleware\bap\certificate.cpp" />
     <ClCompile Include="src\middleware\bap\client_config\client_config_response.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\opcode206_codec.cpp" />
     <ClCompile Include="src\middleware\web_service\messages\opcode501_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\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\core\settings\state_settings.cpp" />
     <ClCompile Include="src\middleware\secure_channel\envelope.cpp" />
     <ClCompile Include="src\middleware\secure_channel\envelope.cpp" />
     <ClCompile Include="src\middleware\secure_channel\encrypted_frame.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\input\input.h" />
     <ClInclude Include="src\client\hooks\graphics\renderer\renderer.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\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\ui\runtime\client_ui_module_runtime.h" />
     <ClInclude Include="src\client\memory\current_process_memory.h" />
     <ClInclude Include="src\client\memory\current_process_memory.h" />
     <ClInclude Include="src\client\content\handles\layout.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\forced\activity_forced_destination.h" />
     <ClInclude Include="src\state\activity\destination\activity_destination_snapshot.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_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\definition.h" />
     <ClInclude Include="src\state\activity\entity_slots\runtime.h" />
     <ClInclude Include="src\state\activity\entity_slots\runtime.h" />
     <ClInclude Include="src\state\activity\entity_slots\transactions\internal.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_join_result_encoder.h" />
     <ClInclude Include="src\middleware\bap\activity_message\activity_entity_slot_request_parser.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_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\certificate.h" />
     <ClInclude Include="src\middleware\bap\client_config\client_config_response.h" />
     <ClInclude Include="src\middleware\bap\client_config\client_config_response.h" />
     <ClInclude Include="src\middleware\bap\family_subscription.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\opcode206.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode501_codec.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\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\datagen\family4\account\selection_patch\account_selection_patch_encoder.h" />
     <ClInclude Include="src\middleware\secure_channel\runtime.h" />
     <ClInclude Include="src\middleware\secure_channel\runtime.h" />
     <ClInclude Include="src\middleware\signon\response.h" />
     <ClInclude Include="src\middleware\signon\response.h" />

+ 52 - 50
Sunrise/resources/default_settings.json

@@ -1,5 +1,5 @@
 {
 {
-  "version": 2,
+  "version": 3,
   "core": {
   "core": {
     "logging": {
     "logging": {
       "debugger_sink": true,
       "debugger_sink": true,
@@ -32,9 +32,11 @@
     "fade_release": true,
     "fade_release": true,
     "force_join_request_ready": true,
     "force_join_request_ready": true,
     "pin_replicated_record": true,
     "pin_replicated_record": true,
-    "hold_spawn": true
+    "hold_spawn": true,
+    "spawn_hold_ms": 30000
   },
   },
   "server": {
   "server": {
+    "bap_port": 30974,
     "entitlements": [
     "entitlements": [
       { "name": "1085660", "owned": "handle" },
       { "name": "1085660", "owned": "handle" },
       { "name": "STEAM_PAID_TIER", "owned": "handle" },
       { "name": "STEAM_PAID_TIER", "owned": "handle" },
@@ -223,66 +225,66 @@
           "chat_auto_hide_mode": 1
           "chat_auto_hide_mode": 1
         },
         },
         "key_bindings": {
         "key_bindings": {
-          "fire": { "primary": 109, "secondary": null },
+          "fire": { "primary": "left mouse button", "secondary": null },
           "toggle_zoom": { "primary": null, "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 },
           "hold_crouch": { "primary": null, "secondary": null },
-          "toggle_sprint": { "primary": 105, "secondary": null },
+          "toggle_sprint": { "primary": "shift", "secondary": null },
           "hold_sprint": { "primary": null, "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_store_tab": { "primary": null, "secondary": null },
           "ui_open_director_pursuits_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_map_tab": { "primary": null, "secondary": null },
           "ui_open_director_destinations_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_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_records_tab": { "primary": null, "secondary": null },
           "ui_open_start_menu_collections_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_clan_tab": { "primary": null, "secondary": null },
           "ui_open_start_menu_inventory_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_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,
         "race": 0,
         "gender": 0,
         "gender": 0,
         "class": 0,
         "class": 0,
-        "movement_ability": 5,
+        "movement_ability": 6,
         "grenade_ability": 7,
         "grenade_ability": 7,
         "super_ability": 10,
         "super_ability": 10,
         "melee_ability": 11,
         "melee_ability": 11,

+ 2 - 2
Sunrise/resources/resource.h

@@ -9,8 +9,8 @@
 
 
 /** The four numeric fields of the version resource, in FILEVERSION order. */
 /** The four numeric fields of the version resource, in FILEVERSION order. */
 #define SUNRISE_VER_MAJOR 0
 #define SUNRISE_VER_MAJOR 0
-#define SUNRISE_VER_MINOR 1
+#define SUNRISE_VER_MINOR 2
 #define SUNRISE_VER_PATCH 0
 #define SUNRISE_VER_PATCH 0
 #define SUNRISE_VER_BUILD 0
 #define SUNRISE_VER_BUILD 0
 /** The same version as display text. Windows shows this string, not the four fields. */
 /** 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;
                           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 source Package source.
  * @param scratch Reader scratch.
  * @param scratch Reader scratch.
  * @param listDefinition Socket-entry-list definition bytes of the subclass.
  * @param listDefinition Socket-entry-list definition bytes of the subclass.
  * @param blob Scratch storage reused for every pool blob.
  * @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.
  * @param output Receives the 12 buckets and the overflow bank.
  * @return True when every selected entry reaches a bucket of its own.
  * @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,
                                          reader::Scratch& scratch,
                                          std::span<const std::byte> listDefinition,
                                          std::span<const std::byte> listDefinition,
                                          std::vector<std::byte>& blob,
                                          std::vector<std::byte>& blob,
-                                         std::uint8_t movementEntry,
+                                         const state::build_data::abilities::Selection& selection,
                                          state::build_data::abilities::Definition& output) noexcept;
                                          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.
  * buckets, so the row is keyed by both and built once.
  * @param source Package source.
  * @param source Package source.
  * @param scratch Reader scratch.
  * @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. */
 /** @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;
 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;
 void report_ability_count(std::size_t count) noexcept;
 
 
 /** @param count Detail rows the pass built, covering equipped items and every plug they socket. */
 /** @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 <array>
 
 
 #include "../../../../middleware/content/packages/tables/ability_pool_reader.h"
 #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;
 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. */
 /** Entry kind of the super, which stays active without a plug source of its own. */
 constexpr std::uint8_t kSuperKind = 34;
 constexpr std::uint8_t kSuperKind = 34;
 /** A selector chain longer than this is a cycle, not a chain. */
 /** A selector chain longer than this is a cycle, not a chain. */
@@ -33,9 +31,25 @@ struct Walk {
     std::vector<std::byte>* blob{};
     std::vector<std::byte>* blob{};
     std::array<pool::Entry, pool::kEntryCapacity> entries{};
     std::array<pool::Entry, pool::kEntryCapacity> entries{};
     std::size_t entryCount{};
     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.
  * Reads one entry's pool records.
  * @param walk Subclass walk state.
  * @param walk Subclass walk state.
@@ -175,12 +189,12 @@ void file_hash(const pool::PoolRecord& record, domain::Definition& output) noexc
 
 
 } // namespace
 } // 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,
 bool build_ability_buckets(const reader::Source& source,
                            reader::Scratch& scratch,
                            reader::Scratch& scratch,
                            std::span<const std::byte> listDefinition,
                            std::span<const std::byte> listDefinition,
                            std::vector<std::byte>& blob,
                            std::vector<std::byte>& blob,
-                           std::uint8_t movementEntry,
+                           const state::build_data::abilities::Selection& selection,
                            state::build_data::abilities::Definition& output) noexcept {
                            state::build_data::abilities::Definition& output) noexcept {
     Walk walk{};
     Walk walk{};
     walk.source = &source;
     walk.source = &source;
@@ -190,8 +204,7 @@ bool build_ability_buckets(const reader::Source& source,
     if (walk.entryCount == 0) {
     if (walk.entryCount == 0) {
         return false;
         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) {
     for (domain::Bucket& bucket : output.buckets) {
         bucket = {};
         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 {
 void report_ability_count(std::size_t count) noexcept {
     std::array<char, 96> line{};
     std::array<char, 96> line{};
     const int written =
     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 {
                         const domain::Definition& row) noexcept {
     for (const domain::Definition& existing : rows) {
     for (const domain::Definition& existing : rows) {
         if (existing.socketEntryListIndex == row.socketEntryListIndex
         if (existing.socketEntryListIndex == row.socketEntryListIndex
-            && existing.movementEntry == row.movementEntry) {
+            && existing.selection == row.selection) {
             return true;
             return true;
         }
         }
     }
     }
     return false;
     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
 } // 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,
 bool build_character_abilities(const reader::Source& source,
                                reader::Scratch& scratch,
                                reader::Scratch& scratch,
                                std::span<const std::byte> root,
                                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)) {
         if (!subclass_list(account.characters[character], row.socketEntryListIndex)) {
             continue;
             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)) {
         if (held(output.first(count), row)) {
             continue;
             continue;
         }
         }
@@ -82,12 +93,8 @@ bool build_character_abilities(const reader::Source& source,
                 std::span<const std::byte>{table}, rows, row.socketEntryListIndex, indexRow)
                 std::span<const std::byte>{table}, rows, row.socketEntryListIndex, indexRow)
             || indexRow.targetTag == 0
             || indexRow.targetTag == 0
             || !reader::read_tag(source, scratch, indexRow.targetTag, definition)
             || !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;
             continue;
         }
         }
         output[count++] = row;
         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. */
 /** Answer that holds the spawn for this tick. The gate is polled, so a refusal only delays it. */
 constexpr bool kHeld = false;
 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;
 using SpawnGate = bool(__fastcall*)(std::int32_t) noexcept;
 
 
 hooking::detour::Handle g_handle{};
 hooking::detour::Handle g_handle{};
@@ -50,12 +43,14 @@ __declspec(noinline) bool __fastcall spawn_gate(std::int32_t datum) noexcept {
     observe_world_step();
     observe_world_step();
     const state::activity::WorldPhase phase = state::activity::world_phase();
     const state::activity::WorldPhase phase = state::activity::world_phase();
     const bool transitioning = phase == state::activity::WorldPhase::transitioning;
     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();
         release_world_fade();
     }
     }
     return allowed && loading ? kHeld : allowed;
     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. */
 /** Compiled pattern bytes of the signature text above. */
 constexpr auto kStepSignature = signature<signature_length(kStepSignatureText)>(kStepSignatureText);
 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;
 constexpr std::int32_t kActivityLoadFirst = 33;
 /** `activity:in_world`. The fade is armed by then, so a spawn now releases it. */
 /** `activity:in_world`. The fade is armed by then, so a spawn now releases it. */
 constexpr std::int32_t kInWorld = 38;
 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 <array>
+#include <cstddef>
+#include <cstdio>
+#include <string_view>
 
 
+#include "../../../../core/logging/log.h"
 #include "../graphics_hook_replacements.h"
 #include "../graphics_hook_replacements.h"
 
 
 namespace sunrise::client::hooks::graphics {
 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 create SDK D3D11 factory entry from the system module.
  * @param probe Receives all temporary COM objects.
  * @param probe Receives all temporary COM objects.
  * @return True when a classic IDXGISwapChain vtable is available.
  * @return True when a classic IDXGISwapChain vtable is available.
@@ -68,6 +118,9 @@ void release_probe(Probe& probe) noexcept {
                                    GetModuleHandleW(nullptr),
                                    GetModuleHandleW(nullptr),
                                    nullptr);
                                    nullptr);
     if (probe.window == 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;
         return false;
     }
     }
 
 
@@ -81,38 +134,33 @@ void release_probe(Probe& probe) noexcept {
     description.OutputWindow = probe.window;
     description.OutputWindow = probe.window;
     description.Windowed = TRUE;
     description.Windowed = TRUE;
     description.SwapEffect = DXGI_SWAP_EFFECT_DISCARD;
     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);
         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
 } // namespace
@@ -145,6 +193,9 @@ bool resolve(Targets& output) noexcept {
         if (dxgiModule != nullptr) {
         if (dxgiModule != nullptr) {
             FreeLibrary(dxgiModule);
             FreeLibrary(dxgiModule);
         }
         }
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         "ev=graphics stage=probe result=fail reason=module");
         return false;
         return false;
     }
     }
 
 
@@ -161,17 +212,24 @@ bool resolve(Targets& output) noexcept {
     void** methods = *reinterpret_cast<void***>(probe.swapChain);
     void** methods = *reinterpret_cast<void***>(probe.swapChain);
     void* present = methods[kPresentMethodIndex];
     void* present = methods[kPresentMethodIndex];
     void* resizeBuffers = methods[kResizeBuffersMethodIndex];
     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)
     const bool valid = executable_image_target(present, dxgiModule)
                        && executable_image_target(resizeBuffers, dxgiModule)
                        && executable_image_target(resizeBuffers, dxgiModule)
-                       && present != resizeBuffers;
+                       && executable_image_target(setFullscreenState, dxgiModule)
+                       && present != resizeBuffers && present != setFullscreenState
+                       && resizeBuffers != setFullscreenState;
     release_probe(probe);
     release_probe(probe);
     if (!valid) {
     if (!valid) {
         FreeLibrary(d3d11Module);
         FreeLibrary(d3d11Module);
         FreeLibrary(dxgiModule);
         FreeLibrary(dxgiModule);
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         "ev=graphics stage=probe result=fail reason=vtable");
         return false;
         return false;
     }
     }
 
 
-    output = Targets{present, resizeBuffers, d3d11Module, dxgiModule};
+    output = Targets{present, resizeBuffers, setFullscreenState, d3d11Module, dxgiModule};
     return true;
     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)]},
                               entryPoints[static_cast<std::size_t>(HookSlot::present)]},
         hooking::detour::Spec{g_targets.resizeBuffers,
         hooking::detour::Spec{g_targets.resizeBuffers,
                               entryPoints[static_cast<std::size_t>(HookSlot::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) {
     for (std::size_t index = 0; index < specs.size(); ++index) {
         g_targetEntries[index] = specs[index].target;
         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;
     HRESULT result = DXGI_ERROR_INVALID_CALL;
     if (call != nullptr) {
     if (call != nullptr) {
         if (rendererEnabled) {
         if (rendererEnabled) {
-            renderer::before_resize(swapChain);
+            renderer::before_surface_change(swapChain);
         }
         }
         result = call(swapChain, bufferCount, width, height, format, flags);
         result = call(swapChain, bufferCount, width, height, format, flags);
         if (rendererEnabled) {
         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();
     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. */
 /** @return Direct internal-linkage bodies that cannot become linker thunks. */
 EntryPoints entry_points() noexcept {
 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. */
 /** @return Every unwind-backed body that can own an admitted replacement call. */
@@ -102,6 +128,7 @@ ProtectedEntries protected_entries() noexcept {
     return {
     return {
         hooking::detour::ProtectedCodeEntry{reinterpret_cast<void*>(&present_body)},
         hooking::detour::ProtectedCodeEntry{reinterpret_cast<void*>(&present_body)},
         hooking::detour::ProtectedCodeEntry{reinterpret_cast<void*>(&resize_buffers_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*>(&enter_hook_call)},
         hooking::detour::ProtectedCodeEntry{reinterpret_cast<void*>(&leave_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. */
 /** SDK ABI for IDXGISwapChain::ResizeBuffers. */
 using ResizeBuffers =
 using ResizeBuffers =
     HRESULT(STDMETHODCALLTYPE*)(IDXGISwapChain*, UINT, UINT, UINT, DXGI_FORMAT, UINT);
     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. */
 /** Stable slots shared by discovery targets and Detours handles. */
 enum class HookSlot : std::size_t {
 enum class HookSlot : std::size_t {
     present,
     present,
     resizeBuffers,
     resizeBuffers,
+    setFullscreenState,
     count,
     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);
 inline constexpr std::size_t kHandleCount = static_cast<std::size_t>(HookSlot::count);
 /** IDXGISwapChain inherits 7 methods before its own Present. */
 /** IDXGISwapChain inherits 7 methods before its own Present. */
 inline constexpr std::size_t kPresentMethodIndex = 8;
 inline constexpr std::size_t kPresentMethodIndex = 8;
 /** ResizeBuffers comes 5 IDXGISwapChain methods after Present. */
 /** ResizeBuffers comes 5 IDXGISwapChain methods after Present. */
 inline constexpr std::size_t kResizeBuffersMethodIndex = 13;
 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. */
 /** System methods and module references kept through detachment. */
 struct Targets {
 struct Targets {
     void* present{};
     void* present{};
     void* resizeBuffers{};
     void* resizeBuffers{};
+    void* setFullscreenState{};
     HMODULE d3d11Module{};
     HMODULE d3d11Module{};
     HMODULE dxgiModule{};
     HMODULE dxgiModule{};
 };
 };
@@ -98,8 +104,8 @@ void release(Targets& targets) noexcept;
 
 
 namespace replacement {
 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. */
 /** Direct replacement bodies indexed by HookSlot. */
 using EntryPoints = std::array<void*, kHandleCount>;
 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 <dxgi.h>
 
 
+#include "graphics_renderer_report.h"
 #include "state.h"
 #include "state.h"
 
 
 namespace sunrise::client::hooks::graphics::renderer {
 namespace sunrise::client::hooks::graphics::renderer {
@@ -15,31 +16,35 @@ namespace {
 
 
 } // 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);
     AcquireSRWLockExclusive(&g_rendererLock);
     if (g_resources.swapChain == swapChain) {
     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.
             // The first of any overlapping calls drops the RTV until they have all returned.
             release_render_target(g_resources);
             release_render_target(g_resources);
         }
         }
-        ++g_resources.activeResizeCalls;
+        ++g_resources.activeSurfaceChanges;
     }
     }
     ReleaseSRWLockExclusive(&g_rendererLock);
     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);
     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 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) {
             if (deviceLost || !targetReady) {
                 // No RTV goes out until the last overlapping call has returned.
                 // 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();
                 (void)shutdown_locked();
             }
             }
         }
         }
@@ -54,6 +59,7 @@ void present_result(IDXGISwapChain* swapChain, HRESULT result) noexcept {
     }
     }
     AcquireSRWLockExclusive(&g_rendererLock);
     AcquireSRWLockExclusive(&g_rendererLock);
     if (g_resources.swapChain == swapChain) {
     if (g_resources.swapChain == swapChain) {
+        report::note(report::Stage::shutdown, report::Reason::deviceLost);
         (void)shutdown_locked();
         (void)shutdown_locked();
     }
     }
     ReleaseSRWLockExclusive(&g_rendererLock);
     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/scaling/dpi/ui_dpi_scaling.h"
 #include "../../../../core/ui/theme/sunrise_ui_theme.h"
 #include "../../../../core/ui/theme/sunrise_ui_theme.h"
 #include "../input/input.h"
 #include "../input/input.h"
+#include "graphics_renderer_report.h"
 #include "state.h"
 #include "state.h"
 
 
 extern IMGUI_IMPL_API LRESULT ImGui_ImplWin32_WndProcHandler(HWND window,
 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.
         // Style and text scale change together, before the backend sets up the frame.
         core::ui::theme::apply();
         core::ui::theme::apply();
         if (!core::ui::fonts::runtime::apply_scale(core::ui::scaling::dpi::current())) {
         if (!core::ui::fonts::runtime::apply_scale(core::ui::scaling::dpi::current())) {
+            report::note(report::Stage::frame, report::Reason::fontScale);
             (void)shutdown_locked();
             (void)shutdown_locked();
             return;
             return;
         }
         }

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

@@ -1,5 +1,6 @@
 #include <Windows.h>
 #include <Windows.h>
 
 
+#include <array>
 #include <imgui_impl_dx11.h>
 #include <imgui_impl_dx11.h>
 #include <imgui_impl_win32.h>
 #include <imgui_impl_win32.h>
 
 
@@ -8,6 +9,7 @@
 #include "../../cursor/runtime.h"
 #include "../../cursor/runtime.h"
 #include "../../polled_input/runtime.h"
 #include "../../polled_input/runtime.h"
 #include "../input/input.h"
 #include "../input/input.h"
+#include "graphics_renderer_report.h"
 #include "state.h"
 #include "state.h"
 
 
 namespace sunrise::client::hooks::graphics::renderer {
 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.
  * Stops the started layers in reverse order, then frees all SDK resources.
  * @param resources Fully or partly started renderer 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 {
 [[nodiscard]] bool initialize_locked(IDXGISwapChain* swapChain) noexcept {
     Resources staged;
     Resources staged;
     if (!selection::acquire(swapChain, staged)) {
     if (!selection::acquire(swapChain, staged)) {
+        // acquire reports its own step.
         return false;
         return false;
     }
     }
     if (!core::ui::layout::initialize()) {
     if (!core::ui::layout::initialize()) {
         release_resources(staged);
         release_resources(staged);
+        report::note(report::Stage::init, report::Reason::layout);
         return false;
         return false;
     }
     }
     staged.layoutInitialized = true;
     staged.layoutInitialized = true;
     if (!ImGui_ImplWin32_Init(staged.window)) {
     if (!ImGui_ImplWin32_Init(staged.window)) {
+        report::note(report::Stage::init, report::Reason::win32Backend);
         return discard_staged(staged);
         return discard_staged(staged);
     }
     }
     staged.win32BackendInitialized = true;
     staged.win32BackendInitialized = true;
     if (!ImGui_ImplDX11_Init(staged.device, staged.context)) {
     if (!ImGui_ImplDX11_Init(staged.device, staged.context)) {
+        report::note(report::Stage::init, report::Reason::dx11Backend);
         return discard_staged(staged);
         return discard_staged(staged);
     }
     }
     staged.dx11BackendInitialized = true;
     staged.dx11BackendInitialized = true;
     if (!input::install(staged.window)) {
     if (!input::install(staged.window)) {
+        report::note(report::Stage::init, report::Reason::windowInput);
         return discard_staged(staged);
         return discard_staged(staged);
     }
     }
     staged.inputInstalled = true;
     staged.inputInstalled = true;
     g_resources = staged;
     g_resources = staged;
+    report::note_active();
     return true;
     return true;
 }
 }
 
 
@@ -169,12 +215,27 @@ bool create_render_target(Resources& resources) noexcept {
         kBackBufferIndex, __uuidof(ID3D11Texture2D), reinterpret_cast<void**>(&backBuffer));
         kBackBufferIndex, __uuidof(ID3D11Texture2D), reinterpret_cast<void**>(&backBuffer));
     if (FAILED(bufferResult) || backBuffer == nullptr) {
     if (FAILED(bufferResult) || backBuffer == nullptr) {
         release_com(backBuffer);
         release_com(backBuffer);
+        report::note(report::Stage::target, report::Reason::backBuffer);
         return false;
         return false;
     }
     }
-    const HRESULT targetResult =
+    HRESULT targetResult =
         resources.device->CreateRenderTargetView(backBuffer, nullptr, &resources.renderTarget);
         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);
     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. */
 /** @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.context != nullptr && g_resources.renderTarget != nullptr
            && g_resources.layoutInitialized && g_resources.win32BackendInitialized
            && g_resources.layoutInitialized && g_resources.win32BackendInitialized
            && g_resources.dx11BackendInitialized && g_resources.inputInstalled
            && 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. */
 /** Shuts the whole renderer down under the exclusive state lock. */
@@ -228,7 +289,7 @@ bool active() noexcept {
 void present(IDXGISwapChain* swapChain) noexcept {
 void present(IDXGISwapChain* swapChain) noexcept {
     AcquireSRWLockExclusive(&g_rendererLock);
     AcquireSRWLockExclusive(&g_rendererLock);
     if (g_resources.swapChain != nullptr && g_resources.swapChain != swapChain
     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)) {
         && selection::matches_output_window(swapChain, g_resources.window)) {
         // A new valid surface for the same window replaces the retired chain.
         // A new valid surface for the same window replaces the retired chain.
         if (!shutdown_locked()) {
         if (!shutdown_locked()) {
@@ -237,11 +298,12 @@ void present(IDXGISwapChain* swapChain) noexcept {
         }
         }
     }
     }
     if (g_resources.swapChain != nullptr && !fully_active_locked()) {
     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);
             ReleaseSRWLockExclusive(&g_rendererLock);
             return;
             return;
         }
         }
+        report::note(report::Stage::shutdown, report::Reason::surfaceLost);
         if (!shutdown_locked()) {
         if (!shutdown_locked()) {
             ReleaseSRWLockExclusive(&g_rendererLock);
             ReleaseSRWLockExclusive(&g_rendererLock);
             return;
             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. */
 /** Draws the UI frame, if any, for a checked swap chain. */
 void present(IDXGISwapChain* swapChain) noexcept;
 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. */
 /** Frees our resources when Present reports a lost device. */
 void present_result(IDXGISwapChain* swapChain, HRESULT result) noexcept;
 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 <cstdint>
 
 
 #include "../../graphics_hook_replacements.h"
 #include "../../graphics_hook_replacements.h"
+#include "../graphics_renderer_report.h"
 #include "../state.h"
 #include "../state.h"
 
 
 namespace sunrise::client::hooks::graphics::renderer::selection {
 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.
  * 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.
  * @param window Swap-chain output window.
  * @return True for a visible root window of this process, on this thread.
  * @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
     if (window == nullptr || IsWindow(window) == FALSE || IsWindowVisible(window) == FALSE
         || GetAncestor(window, GA_ROOT) != window) {
         || GetAncestor(window, GA_ROOT) != window) {
-        return false;
+        return reject(report::Reason::window);
     }
     }
 
 
     DWORD processId = 0;
     DWORD processId = 0;
     const DWORD threadId = GetWindowThreadProcessId(window, &processId);
     const DWORD threadId = GetWindowThreadProcessId(window, &processId);
     if (processId != GetCurrentProcessId() || threadId != GetCurrentThreadId()) {
     if (processId != GetCurrentProcessId() || threadId != GetCurrentThreadId()) {
-        return false;
+        return reject(report::Reason::windowThread);
     }
     }
 
 
     RECT client{};
     RECT client{};
     if (GetClientRect(window, &client) == FALSE || client.right <= client.left
     if (GetClientRect(window, &client) == FALSE || client.right <= client.left
         || client.bottom <= client.top) {
         || 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 width = static_cast<std::int64_t>(client.right) - client.left;
     const std::int64_t height = static_cast<std::int64_t>(client.bottom) - client.top;
     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.
  * @param window Window that already passed the other checks.
  * @return True when the class can belong to the shipped game executable.
  * @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);
     SetLastError(ERROR_SUCCESS);
     const ULONG_PTR procedure = GetClassLongPtrW(window, GCLP_WNDPROC);
     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.
  * Checks the swap-chain description before we take any COM reference.
  * @param window Receives the output window.
  * @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.
  * @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) {
     if (swapChain == nullptr) {
         return false;
         return false;
     }
     }
     DXGI_SWAP_CHAIN_DESC description{};
     DXGI_SWAP_CHAIN_DESC description{};
     if (FAILED(swapChain->GetDesc(&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;
         return false;
     }
     }
     window = description.OutputWindow;
     window = description.OutputWindow;
@@ -85,7 +105,7 @@ constexpr std::int64_t kMinimumClientExtent = 64;
 bool acquire(IDXGISwapChain* swapChain, Resources& output) noexcept {
 bool acquire(IDXGISwapChain* swapChain, Resources& output) noexcept {
     output = {};
     output = {};
     HWND window = nullptr;
     HWND window = nullptr;
-    if (!eligible_swap_chain(swapChain, window)) {
+    if (!eligible_swap_chain(swapChain, window, true)) {
         return false;
         return false;
     }
     }
 
 
@@ -94,11 +114,14 @@ bool acquire(IDXGISwapChain* swapChain, Resources& output) noexcept {
     const HRESULT deviceResult =
     const HRESULT deviceResult =
         swapChain->GetDevice(__uuidof(ID3D11Device), reinterpret_cast<void**>(&staged.device));
         swapChain->GetDevice(__uuidof(ID3D11Device), reinterpret_cast<void**>(&staged.device));
     if (FAILED(deviceResult) || staged.device == nullptr) {
     if (FAILED(deviceResult) || staged.device == nullptr) {
+        // A D3D12 or D3D11On12 surface fails here.
+        report::note(report::Stage::acquire, report::Reason::device);
         release_resources(staged);
         release_resources(staged);
         return false;
         return false;
     }
     }
     staged.device->GetImmediateContext(&staged.context);
     staged.device->GetImmediateContext(&staged.context);
     if (staged.context == nullptr) {
     if (staged.context == nullptr) {
+        report::note(report::Stage::acquire, report::Reason::context);
         release_resources(staged);
         release_resources(staged);
         return false;
         return false;
     }
     }
@@ -121,7 +144,7 @@ bool acquire(IDXGISwapChain* swapChain, Resources& output) noexcept {
  */
  */
 bool matches_output_window(IDXGISwapChain* swapChain, HWND window) noexcept {
 bool matches_output_window(IDXGISwapChain* swapChain, HWND window) noexcept {
     HWND candidateWindow = nullptr;
     HWND candidateWindow = nullptr;
-    return window != nullptr && eligible_swap_chain(swapChain, candidateWindow)
+    return window != nullptr && eligible_swap_chain(swapChain, candidateWindow, false)
            && candidateWindow == window;
            && candidateWindow == window;
 }
 }
 
 

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

@@ -20,8 +20,8 @@ struct Resources {
     bool dx11BackendInitialized{};
     bool dx11BackendInitialized{};
     bool inputInstalled{};
     bool inputInstalled{};
     bool inputVisible{};
     bool inputVisible{};
-    bool resizeDeviceLost{};
-    std::size_t activeResizeCalls{};
+    bool surfaceChangeDeviceLost{};
+    std::size_t activeSurfaceChanges{};
 };
 };
 
 
 extern SRWLOCK g_rendererLock;
 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;
 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.
  * @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.
  * 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>
 #include <Windows.h>
@@ -39,6 +36,8 @@ constexpr std::array<std::byte, 5> kScanCodeLoad{
 constexpr std::size_t kSearchBytes = 0x140;
 constexpr std::size_t kSearchBytes = 0x140;
 /** Both tables carry one byte per supported key index. */
 /** Both tables carry one byte per supported key index. */
 constexpr std::size_t kKeyTableCount = 105;
 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. */
 /** The table byte meaning the index resolves through its scan code instead. */
 constexpr std::uint8_t kAbsentVirtualKey = 0xFF;
 constexpr std::uint8_t kAbsentVirtualKey = 0xFF;
 
 
@@ -89,8 +88,10 @@ void clear_action_keys() noexcept {
     g_scanCodes = nullptr;
     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) {
     if (g_virtualKeys == nullptr || g_scanCodes == nullptr || index >= kKeyTableCount) {
         return 0;
         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 hasForceJoinRequestReady = false;
     bool hasPinReplicatedRecord = false;
     bool hasPinReplicatedRecord = false;
     bool hasHoldSpawn = false;
     bool hasHoldSpawn = false;
+    bool hasSpawnHoldMs = false;
     if (consume('}')) {
     if (consume('}')) {
         return true;
         return true;
     }
     }
@@ -52,6 +53,14 @@ bool Parser::client_settings(client::Settings& output) noexcept {
                 return false;
                 return false;
             }
             }
             hasHoldSpawn = true;
             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)) {
         } else if (!skip_value(0)) {
             return false;
             return false;
         }
         }

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

@@ -1,10 +1,17 @@
 #pragma once
 #pragma once
 
 
+#include <cstdint>
+
 #include "../../ui/runtime/settings.h"
 #include "../../ui/runtime/settings.h"
 #include "external/definition.h"
 #include "external/definition.h"
 
 
 namespace sunrise::core::settings::client {
 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. */
 /** Read-only Client settings parsed by Core. */
 struct Settings {
 struct Settings {
     /** In-game UI visibility and input policy. */
     /** 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.
      * the only thing that can turn an allowed spawn into a refusal.
      */
      */
     bool holdSpawn{true};
     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
 } // 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;
     [[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.
      * @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;
     [[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.
      * Parses logging sinks and channel levels.
      * @param output Receives supported logging values.
      * @param output Receives supported logging values.

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

@@ -4,10 +4,15 @@
 
 
 namespace sunrise::core::settings::server {
 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. */
 /** Read-only Server settings. */
 struct Settings {
 struct Settings {
     /** Authored ownership policy declared by SignOn and defined by the content manifest. */
     /** Authored ownership policy declared by SignOn and defined by the content manifest. */
     state::entitlements::Table entitlements{};
     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
 } // 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 "../../../state/entitlements/validation.h"
 #include "../parser.h"
 #include "../parser.h"
 
 
@@ -14,6 +16,7 @@ bool Parser::server_settings(server::Settings& output) noexcept {
         return true;
         return true;
     }
     }
     bool hasEntitlements = false;
     bool hasEntitlements = false;
+    bool hasBapPort = false;
     for (;;) {
     for (;;) {
         std::string_view key;
         std::string_view key;
         if (!string(key) || !consume(':')) {
         if (!string(key) || !consume(':')) {
@@ -24,6 +27,14 @@ bool Parser::server_settings(server::Settings& output) noexcept {
                 return false;
                 return false;
             }
             }
             hasEntitlements = true;
             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)) {
         } else if (!skip_value(0)) {
             return false;
             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.
  * 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,
  * 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. */
 /** Parsed read-only process settings. */
 struct Settings {
 struct Settings {

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

@@ -9,12 +9,15 @@
 #include "../filesystem/path.h"
 #include "../filesystem/path.h"
 #include "../logging/log.h"
 #include "../logging/log.h"
 #include "settings.h"
 #include "settings.h"
+#include "settings_upgrade.h"
 
 
 namespace sunrise::core::settings {
 namespace sunrise::core::settings {
 namespace {
 namespace {
 
 
 /** The JSON settings file is the only file stored directly in the owned folder. */
 /** The JSON settings file is the only file stored directly in the owned folder. */
 constexpr std::wstring_view kSettingsFileSuffix = L"\\settings.json";
 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. */
 /** Largest settings file accepted into fixed stack storage. */
 constexpr std::size_t kConfigCapacity = 64 * 1024;
 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.
  * @param fileVersion Version read from the file, or zero when the key was missing.
  */
  */
 void report_version(std::uint32_t fileVersion) noexcept {
 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 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 HMODULE loadedModule = static_cast<HMODULE>(module);
     const HRSRC resource =
     const HRSRC resource =
         FindResourceW(loadedModule, MAKEINTRESOURCEW(IDR_DEFAULT_SETTINGS), RT_RCDATA);
         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 DWORD size = SizeofResource(loadedModule, resource);
     const HGLOBAL loaded = LoadResource(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) {
     if (size == 0 || bytes == nullptr) {
         return false;
         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(),
     const HANDLE file = CreateFileW(configPath.chars.data(),
                                     GENERIC_WRITE,
                                     GENERIC_WRITE,
                                     0,
                                     0,
@@ -92,7 +107,9 @@ void report_version(std::uint32_t fileVersion) noexcept {
         return false;
         return false;
     }
     }
     DWORD written = 0;
     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;
     complete = CloseHandle(file) != FALSE && complete;
     if (!complete) {
     if (!complete) {
         // A half-written default must not become the next boot's settings.
         // 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;
     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
 } // namespace
 
 
 /** Loads the settings file from the owned folder, or creates the default one. */
 /** 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) {
     if (!readOk || !closed) {
         return fail("read");
         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;
     Settings parsed;
-    if (!parse(std::string_view(buffer.data(), read), parsed)) {
+    if (!parse(document, parsed)) {
         return fail("parse");
         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);
     report_version(parsed.version);
     g_settings = parsed;
     g_settings = parsed;
     return true;
     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;
                 return false;
             }
             }
         } else if (key == "movement_ability") {
         } else if (key == "movement_ability") {
-            std::uint64_t value = 0;
-            if (!unsigned_integer(value) || value > state::kMaximumMovementAbilityEntry) {
+            if (!ability_entry(output.movementAbilityEntry)) {
                 return false;
                 return false;
             }
             }
-            output.movementAbilityEntry = static_cast<std::uint8_t>(value);
         } else if (key == "grenade_ability") {
         } else if (key == "grenade_ability") {
             if (!ability_entry(output.grenadeAbilityEntry)) {
             if (!ability_entry(output.grenadeAbilityEntry)) {
                 return false;
                 return false;

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

@@ -1,6 +1,5 @@
 #include <algorithm>
 #include <algorithm>
 #include <array>
 #include <array>
-#include <limits>
 
 
 #include "../parser.h"
 #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());
     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
 } // namespace
 
 
 /** Parses the whole fixed action table under named JSON properties. */
 /** 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 {
 bool Parser::optional_input_code(std::optional<std::uint16_t>& output) noexcept {
     if (literal("null")) {
     if (literal("null")) {
         output.reset();
         output.reset();
         return true;
         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;
         return false;
     }
     }
-    output = static_cast<std::uint16_t>(value);
+    output = static_cast<std::uint16_t>(flag | key);
     return true;
     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.
  * 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
  * 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.
  * @param task Work that is about to start.
  * @return True while the overlay has not reached the screen yet.
  * @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 <cstdint>
 #include <limits>
 #include <limits>
 
 
+#include "../../logging/log.h"
 #include "busy.h"
 #include "busy.h"
 
 
 namespace sunrise::core::ui::busy {
 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
 /** The Present hook is installed just before the first raise, so the first frame gets this long
  * before the overlay is given up on. */
  * before the overlay is given up on. */
 constexpr ULONGLONG kFirstPresentGraceMilliseconds = 250;
 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. */
 /** An overlay drawn this recently is still on screen, so the next task need not wait for it. */
 constexpr ULONGLONG kVisibleRecentlyMilliseconds = 250;
 constexpr ULONGLONG kVisibleRecentlyMilliseconds = 250;
 /** One millisecond yields the core between polls of the present counter. */
 /** One millisecond yields the core between polls of the present counter. */
@@ -49,6 +52,7 @@ struct EarlyState {
     std::uint32_t start{};
     std::uint32_t start{};
     ULONGLONG armedTick{};
     ULONGLONG armedTick{};
     bool armed{};
     bool armed{};
+    bool expiryReported{};
 };
 };
 
 
 SRWLOCK g_earlyLock{SRWLOCK_INIT};
 SRWLOCK g_earlyLock{SRWLOCK_INIT};
@@ -115,15 +119,28 @@ bool raise_early(Task task) noexcept {
         early.armedTick = now;
         early.armedTick = now;
         early.armed = true;
         early.armed = true;
     }
     }
+    const ULONGLONG waited = now - early.armedTick;
     bool waiting = false;
     bool waiting = false;
+    bool expired = false;
     if (g_lastPresentTick.load(std::memory_order_acquire) == 0) {
     if (g_lastPresentTick.load(std::memory_order_acquire) == 0) {
         // No frame has passed through the hook yet, which is expected on the call that
         // 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.
         // installed it. The game gets a bounded chance to draw one.
-        waiting = now - early.armedTick < kFirstPresentGraceMilliseconds;
+        waiting = waited < kFirstPresentGraceMilliseconds;
     } else if (drawing()) {
     } 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);
     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;
     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.
  * @param selection Temporary scalar-only selection output.
  * @return True when the presence marker and all 40 biased bytes are complete.
  * @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;
     bool present = false;
     if (!read_presence(reader, present)) {
     if (!read_presence(reader, present)) {
         return false;
         return false;
@@ -122,7 +120,6 @@ constexpr std::size_t kPairEntryWidth = 13;
     if (!present) {
     if (!present) {
         return true;
         return true;
     }
     }
-    const std::size_t nameBit = descriptorStart - reader.remaining_bits();
     bool ended = false;
     bool ended = false;
     std::uint8_t length = 0;
     std::uint8_t length = 0;
     for (std::int8_t& character : selection.packageName) {
     for (std::int8_t& character : selection.packageName) {
@@ -140,7 +137,6 @@ constexpr std::size_t kPairEntryWidth = 13;
     }
     }
     selection.hasPackageName = true;
     selection.hasPackageName = true;
     selection.packageNameLength = length;
     selection.packageNameLength = length;
-    selection.packageNameBit = nameBit;
     return true;
     return true;
 }
 }
 
 
@@ -164,14 +160,12 @@ constexpr std::size_t kPairEntryWidth = 13;
  * @param selection Temporary scalar-only selection output.
  * @param selection Temporary scalar-only selection output.
  * @return True when every destination field is complete.
  * @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)
     return reader.skip(kUnknownByteWidth) && skip_optional(reader, kUnknownByteWidth)
            && read_hash(reader, selection.hasArrivalBubbleHash, selection.arrivalBubbleHash)
            && read_hash(reader, selection.hasArrivalBubbleHash, selection.arrivalBubbleHash)
            && read_hash(reader, selection.hasSpawnSetHash, selection.spawnSetHash)
            && 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. */
 /** Reads one complete descriptor into safe scalar-only output. */
 bool parse(Reader& reader, ActivityManagerSelection& selection) noexcept {
 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);
            && 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{};
     std::uint8_t packageNameLength{};
     /** Checked package-name bytes, then zero padding. */
     /** Checked package-name bytes, then zero padding. */
     std::array<std::int8_t, kActivityManagerPackageNameCapacity> packageName{};
     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. */
     /** Last root boolean. Its gameplay meaning is not known. */
     bool trailingFlag{};
     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;
     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.
     // The hardwipe token is unchecked unless the client's `use_hardwipe_tokens` config is on.
     bool encoded = writer.write(0, kHardwipeWidth)
     bool encoded = writer.write(0, kHardwipeWidth)
                    && writer.write(snapshot.patchEpoch.first, kEpochWidth)
                    && writer.write(snapshot.patchEpoch.first, kEpochWidth)
@@ -116,10 +111,31 @@ bool encode_sensor_auth_update(const Snapshot& snapshot,
         encoded = write_phase_two(writer, snapshot);
         encoded = write_phase_two(writer, snapshot);
     }
     }
     // The entity-group loop end, then the trailing pair, which short-circuits to one bit.
     // 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;
     std::size_t produced = 0;
-    if (!encoded || !writer.finish(produced)) {
+    if (!write_body(writer, snapshot) || !writer.finish(produced) || produced != required) {
         return false;
         return false;
     }
     }
     written = produced;
     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,
     start = 30,
     /** Requests the current server-to-client user-message response. */
     /** Requests the current server-to-client user-message response. */
     userMessage = 32,
     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. */
     /** Carries one of the 8 matchmaking request variants. Body field two picks it. */
     matchmaking = 42,
     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. */
     /** Carries a clan protobuf request that the minimal liveness route leaves unparsed. */
     clan = 44,
     clan = 44,
     /** Registers the client as a notification subscriber. */
     /** Registers the client as a notification subscriber. */
@@ -90,8 +100,18 @@ enum class ResponseService : std::uint16_t {
     start = 31,
     start = 31,
     /** Returns the current user-message fields. */
     /** Returns the current user-message fields. */
     userMessage = 33,
     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. */
     /** Returns the request-kind-specific matchmaking result. */
     matchmaking = 43,
     matchmaking = 43,
+    /** Acknowledges service 48 with an empty status-200 body. */
+    response49 = 49,
     /** Returns schema-valid empty clan data with status 200. */
     /** Returns schema-valid empty clan data with status 200. */
     clan = 45,
     clan = 45,
     /** Acknowledges notification subscriber registration. */
     /** 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;
 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
 } // 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,
 bool apply_ability_buckets(const state::CharacterState& character,
                            const family4::loadout::ResolvedInstances& instances,
                            const family4::loadout::ResolvedInstances& instances,
                            layout::Appearance& appearance) noexcept {
                            layout::Appearance& appearance) noexcept {
@@ -21,7 +30,7 @@ bool apply_ability_buckets(const state::CharacterState& character,
         if (!state::build_data::find_configured_item_detail(
         if (!state::build_data::find_configured_item_detail(
                 instances.items[index].instance.baseDefinitionIndex, detail)
                 instances.items[index].instance.baseDefinitionIndex, detail)
             || !state::build_data::find_ability_buckets(
             || !state::build_data::find_ability_buckets(
-                detail.socketEntryListIndex, character.movementAbilityEntry, published)) {
+                detail.socketEntryListIndex, selection_of(character), published)) {
             return false;
             return false;
         }
         }
         for (std::size_t bucket = 0; bucket < appearance.abilityBuckets.size(); ++bucket) {
         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;
                                 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 character Validated authored character.
  * @param instances Resolved item instances belonging to that character.
  * @param instances Resolved item instances belonging to that character.
  * @param appearance Appearance block receiving the buckets.
  * @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;
     object.roll.socketEntryListIndex = input.socketEntryListIndex;
 
 
     if (input.ordinarySockets.state == OrdinarySocketBlockState::present) {
     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.activeMask = layout::kAllSocketBits;
+        object.ordinarySockets.definitionUnlockMask = layout::kAllSocketBits;
         for (std::size_t index = 0; index < input.ordinarySockets.plugs.size(); ++index) {
         for (std::size_t index = 0; index < input.ordinarySockets.plugs.size(); ++index) {
             const std::optional<std::uint16_t>& plug = input.ordinarySockets.plugs[index];
             const std::optional<std::uint16_t>& plug = input.ordinarySockets.plugs[index];
             if (plug.has_value()) {
             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{};
     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 {
 struct OrdinarySocketBlock {
     std::array<OrdinarySocket, kOrdinarySocketCapacity> sockets{};
     std::array<OrdinarySocket, kOrdinarySocketCapacity> sockets{};
+    /** Set to pass a plug read from this instance's own socket lanes. */
     std::uint32_t activeMask{};
     std::uint32_t activeMask{};
+    /** Set to pass a plug read from the item definition's declared socket block. */
     std::uint32_t definitionUnlockMask{};
     std::uint32_t definitionUnlockMask{};
+    /** Clear to pass a plug read from this instance's own socket lanes. */
     std::uint32_t blockedMask{};
     std::uint32_t blockedMask{};
+    /** Clear to pass a plug read from the item definition's declared socket block. */
     std::uint32_t expressionUnlockMask{};
     std::uint32_t expressionUnlockMask{};
+    /** The client ANDs this into `activeMask`, so a zero blanks every socket's state. */
     std::uint32_t gateMask{};
     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{});
     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. */
 /** Writes one bounded unsigned field without allocation or byte alignment. */
 bool Writer::write(std::uint64_t value, std::uint8_t width) noexcept {
 bool Writer::write(std::uint64_t value, std::uint8_t width) noexcept {
     const std::size_t capacity = output_.size() * kBitsPerByte;
     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;
         failed_ = true;
         return false;
         return false;
     }
     }
+    if (measuring_) {
+        bitPosition_ += width;
+        return true;
+    }
     for (std::uint8_t index = 0; index < width; ++index) {
     for (std::uint8_t index = 0; index < width; ++index) {
         const std::uint8_t sourceShift = static_cast<std::uint8_t>(width - index - 1);
         const std::uint8_t sourceShift = static_cast<std::uint8_t>(width - index - 1);
         const std::uint64_t bit = (value >> sourceShift) & 1U;
         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. */
     /** @param output Caller-owned storage cleared before the first bit is written. */
     explicit Writer(std::span<std::byte> output) noexcept;
     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.
      * Writes the low bits of one unsigned value in most-significant-bit-first order.
      * @param value Unsigned source value.
      * @param value Unsigned source value.
@@ -34,6 +41,8 @@ private:
     std::span<std::byte> output_;
     std::span<std::byte> output_;
     std::size_t bitPosition_{};
     std::size_t bitPosition_{};
     bool failed_{};
     bool failed_{};
+    /** A measuring writer stores nothing and never runs out of room. */
+    bool measuring_{};
 };
 };
 
 
 } // namespace sunrise::middleware::encoding::bits
 } // 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,
                          core::log::Level::warn,
                          "ev=bap svc=6 stage=package result=fallback");
                          "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) {
     if (!hasCopy || !source.hasPackageName) {
         state::activity::destination::DestinationSelection forced{};
         state::activity::destination::DestinationSelection forced{};
         if (state::activity::forced::apply(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) {
         && source.descriptorBitLength <= destination.descriptorBits.size() * CHAR_BIT) {
         destination.descriptorBits = source.descriptorBits;
         destination.descriptorBits = source.descriptorBits;
         destination.descriptorBitLength = static_cast<std::uint16_t>(source.descriptorBitLength);
         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 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.
     // the same arrival instead of each push working it out again.
     state::activity::defaults::ActivityDefaults defaults{};
     state::activity::defaults::ActivityDefaults defaults{};
     state::activity::defaults::snapshot(defaults);
     state::activity::defaults::snapshot(defaults);
     state::activity::defaults::apply_arrival_override(defaults, destination);
     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)) {
     if (state::activity::forced::apply(destination)) {
         report_forced(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_message_request_parser.h"
 #include "../../../../middleware/bap/activity_message/activity_state_refresh_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/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/entity_slots.h"
+#include "../../../../middleware/bap/activity_message/incident.h"
 #include "../../../../state/activity/runtime.h"
 #include "../../../../state/activity/runtime.h"
 #include "membership/activity_membership_route.h"
 #include "membership/activity_membership_route.h"
 #include "middleware/bap/activity_message/activity_entity_slot_request_parser.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 {
 
 
 namespace service = middleware::bap::activity_message;
 namespace service = middleware::bap::activity_message;
+namespace authority = service::entity_authority;
 namespace client_keepalive = service::client_keepalive;
 namespace client_keepalive = service::client_keepalive;
 namespace high_water = service::high_water;
 namespace high_water = service::high_water;
 namespace epoch_message = service::patch_epoch;
 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. */
 /** Activity message type 3 starts the client join transaction. */
 constexpr std::uint32_t kJoinRequestMessageType = 3;
 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.
  * 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
  * 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;
     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.
  * Prepares only the slots that are both held and in the returned mask.
  * @param request Validated owned svc8 envelope.
  * @param request Validated owned svc8 envelope.
@@ -163,6 +349,29 @@ bool process(std::uint64_t boundSessionId,
         prepared = membership::prepare_authoritative(request, plan);
         prepared = membership::prepare_authoritative(request, plan);
     } else if (request.messageType == service::membership_acknowledgement::kMessageType) {
     } else if (request.messageType == service::membership_acknowledgement::kMessageType) {
         prepared = membership::prepare_acknowledgement(request, plan);
         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 {
     } else {
         // Later message handlers are independent. An owned envelope is a safe no-op.
         // Later message handlers are independent. An owned envelope is a safe no-op.
         report_message(request.messageType, request.accountHandle, "unhandled");
         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 "../../../../../middleware/secure_channel/runtime.h"
 #include "../../../../../state/activity/defaults/activity_defaults_snapshot.h"
 #include "../../../../../state/activity/defaults/activity_defaults_snapshot.h"
 #include "../../../../../state/activity/destination/activity_destination_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 "../../../../../state/build_data/runtime.h"
 #include "activity_arrival.h"
 #include "activity_arrival.h"
 #include "activity_notification_frame.h"
 #include "activity_notification_frame.h"
@@ -66,7 +67,7 @@ resolve_state(std::uint64_t sessionId,
     output.fromActivityIndex = selection.previousActivityIndex;
     output.fromActivityIndex = selection.previousActivityIndex;
     output.activityIndex = selection.activityIndex;
     output.activityIndex = selection.activityIndex;
     output.spawnSetHash =
     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
     // 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
     // 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/account/account_state.h"
 #include "../../../../../state/activity/defaults/activity_defaults_snapshot.h"
 #include "../../../../../state/activity/defaults/activity_defaults_snapshot.h"
 #include "../../../../../state/activity/destination/activity_destination_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/activity/membership/activity_membership_query.h"
 #include "../../../../../state/build_data/runtime.h"
 #include "../../../../../state/build_data/runtime.h"
 #include "../../../../../state/runtime/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.
     // The spawn override always names the destination's own arrival, never the player's position.
     snapshot.spawnSliceSet = sliceSet;
     snapshot.spawnSliceSet = sliceSet;
     snapshot.spawnSetHash =
     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.hasSpawnOverride =
         snapshot.spawnSetHash != 0 && snapshot.spawnSetHash != message::kAbsentSpawnSetHash;
         snapshot.spawnSetHash != 0 && snapshot.spawnSetHash != message::kAbsentSpawnSetHash;
     snapshot.stateSequence = next_state_sequence(session, fold_groups(snapshot.roster), burst);
     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,
                  BodyCodec::accountTranslationResponse,
                  "ev=bap svc=23 rsp=24 result=ok"};
                  "ev=bap svc=23 rsp=24 result=ok"};
         return true;
         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:
     case middleware::bap::RequestService::matchmaking:
         route = {ResponseMode::reply,
         route = {ResponseMode::reply,
                  middleware::bap::ResponseService::matchmaking,
                  middleware::bap::ResponseService::matchmaking,
@@ -126,6 +158,7 @@ bool resolve(std::uint16_t request, ServiceRoute& route) noexcept {
         return true;
         return true;
     default:
     default:
         // Unknown services stay quiet. Failing the send would drop the whole BAP link.
         // 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 = {
         route = {
             ResponseMode::none, {}, BodyCodec::empty, "ev=bap svc=unhandled rsp=none result=ok"};
             ResponseMode::none, {}, BodyCodec::empty, "ev=bap svc=unhandled rsp=none result=ok"};
         return true;
         return true;

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

@@ -6,6 +6,7 @@
 #include <cstdio>
 #include <cstdio>
 
 
 #include "../../core/logging/log.h"
 #include "../../core/logging/log.h"
+#include "../../core/settings/settings.h"
 #include "internal.h"
 #include "internal.h"
 
 
 namespace sunrise::server::transport {
 namespace sunrise::server::transport {
@@ -193,15 +194,23 @@ bool initialize_on_port(std::uint16_t port) noexcept {
     }
     }
     g_listener.active = true;
     g_listener.active = true;
     g_listener.nextPollTick = 0;
     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);
     ReleaseSRWLockExclusive(&g_listenerLock);
     return true;
     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 {
 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. */
 /** 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 {
 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. */
 /** Starts the nonblocking loopback BAP listener. @return True when the listener is bound. */
 [[nodiscard]] bool initialize() noexcept;
 [[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.spawnNarrowed = false;
     rows.spawnHidden = 0;
     rows.spawnHidden = 0;
     rows.spawnForeign = 0;
     rows.spawnForeign = 0;
-    rows.spawnFallback = state::activity::forced::kAbsentSpawnSetHash;
 }
 }
 
 
 /** Clears every list that belongs to the selected destination. */
 /** 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.spawns[rows.spawnCount] = label;
         rows.spawnHashes[rows.spawnCount] = hash;
         rows.spawnHashes[rows.spawnCount] = hash;
         ++rows.spawnCount;
         ++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{};
     std::size_t spawnHidden{};
     /** Rows whose package this destination does not load, which are shown and marked. */
     /** Rows whose package this destination does not load, which are shown and marked. */
     std::size_t spawnForeign{};
     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. */
 /** @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. */
 /** Preview shown by a picker whose list is empty. */
 constexpr char kEmptyList[] = "nothing to pick";
 constexpr char kEmptyList[] = "nothing to pick";
 /** Spawn row that names no set, which forces the default one. */
 /** 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. */
 /** Room for the longest status line this module builds. */
 constexpr std::size_t kStatusCapacity = 64;
 constexpr std::size_t kStatusCapacity = 64;
 /** Widest picker list this module draws, so no list is silently cut short. */
 /** 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.hasSpawnSetHash = false;
     value.spawnSetHash = 0;
     value.spawnSetHash = 0;
     follow_destination(value, rows);
     follow_destination(value, rows);
-    value.spawnFallback = rows.spawnFallback;
     return true;
     return true;
 }
 }
 
 
@@ -205,13 +204,8 @@ void draw_status(const forced::ForcedDestination& value, const Lists& rows) noex
         ImGui::TextUnformatted("incomplete, select valid activity");
         ImGui::TextUnformatted("incomplete, select valid activity");
         return;
         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) {
     if (rows.spawnUnavailable) {
         ImGui::TextDisabled("this destination's spawn sets could not be listed");
         ImGui::TextDisabled("this destination's spawn sets could not be listed");
     }
     }
@@ -255,9 +249,8 @@ void draw() noexcept {
         g_activityRow = kNoRow;
         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);
     bool changed = core::ui::components::toggle::control("Enabled", value.enabled);
     ImGui::SameLine();
     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,
     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,
     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,
     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. */
 /** 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/opcode501_codec.h"
 #include "../../middleware/web_service/messages/opcode503.h"
 #include "../../middleware/web_service/messages/opcode503.h"
 #include "../../middleware/web_service/messages/opcode504.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 "../../middleware/web_service/web_service_envelope.h"
 #include "../../state/account/account_state.h"
 #include "../../state/account/account_state.h"
 #include "../../state/runtime/runtime.h"
 #include "../../state/runtime/runtime.h"
@@ -175,6 +176,12 @@ bool consume(std::span<const std::byte> request,
                || encode_echo(message, response, written);
                || 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.
     // A subscribe whose body does not parse is still answered; only the subscription is dropped.
     middleware::queuez::Subscription subscription;
     middleware::queuez::Subscription subscription;
     const bool subscribes =
     const bool subscribes =

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

@@ -40,10 +40,11 @@ enum class CharacterClass : std::uint8_t {
     warlock = 2,
     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;
 inline constexpr std::uint8_t kDefaultMovementAbilityEntry = 4;
 /** No socket entry list declares more entries than this, so a larger value is not an entry. */
 /** No socket entry list declares more entries than this, so a larger value is not an entry. */
 inline constexpr std::uint8_t kMaximumMovementAbilityEntry = 63;
 inline constexpr std::uint8_t kMaximumMovementAbilityEntry = 63;
+
 /**
 /**
  * Socket entries of the other abilities a subclass lets the player choose. Each names one entry
  * 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
  * 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{};
     std::array<std::byte, kDescriptorCapacity> descriptorBits{};
     /** Meaningful bits in descriptorBits, or zero when no descriptor was captured. */
     /** Meaningful bits in descriptorBits, or zero when no descriptor was captured. */
     std::uint16_t descriptorBitLength{};
     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 <Windows.h>
 
 
-#include <algorithm>
+#include <cstddef>
 
 
 #include "../../runtime/storage/internal.h"
 #include "../../runtime/storage/internal.h"
 
 
 namespace sunrise::state::activity::forced {
 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. */
 /** Replaces the forced destination. */
 bool publish(const ForcedDestination& value) noexcept {
 bool publish(const ForcedDestination& value) noexcept {
@@ -107,18 +62,14 @@ bool apply(destination::DestinationSelection& selection) noexcept {
     selection.hasArrivalBubbleOverride = true;
     selection.hasArrivalBubbleOverride = true;
     selection.sliceSetOverride = value.sliceSet;
     selection.sliceSetOverride = value.sliceSet;
     selection.hasSliceSetOverride = true;
     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;
     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;
     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. */
 /** The set normal arrivals use. */
 inline constexpr std::uint32_t kDefaultSpawnSetHash = 0x2EA8FB98U;
 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;
 inline constexpr std::uint32_t kAbsentSpawnSetHash = 0x811C9DC5U;
 
 
@@ -33,11 +32,6 @@ struct ForcedDestination {
     std::uint16_t sliceSet{};
     std::uint16_t sliceSet{};
     /** Spawn-set name hash, used only when one was chosen. */
     /** Spawn-set name hash, used only when one was chosen. */
     std::uint32_t spawnSetHash{};
     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 hasBubble{};
     bool hasSliceSet{};
     bool hasSliceSet{};
     bool hasSpawnSetHash{};
     bool hasSpawnSetHash{};
@@ -47,8 +41,7 @@ struct ForcedDestination {
 
 
 /**
 /**
  * Tests whether a forced destination names enough to replace a client selection.
  * 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.
  * @param value Candidate selection.
  * @return True when the switch is on and the destination, bubble, and slice set are all named.
  * @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
 } // namespace
 
 
 /** Builds a nonsecret cache identity from ordered authored equipment. */
 /** 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));
     mix_byte(hash, static_cast<std::uint8_t>(accountState.characterCount));
     for (std::size_t characterIndex = 0; characterIndex < accountState.characterCount;
     for (std::size_t characterIndex = 0; characterIndex < accountState.characterCount;
          ++characterIndex) {
          ++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()) {
             if (!item.has_value()) {
                 mix_byte(hash, kAbsentItemMarker);
                 mix_byte(hash, kAbsentItemMarker);
                 continue;
                 continue;
             }
             }
             mix_byte(hash, kPresentItemMarker);
             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, item->definitionHash);
             mix_value(hash, static_cast<std::uint32_t>(item->level));
             mix_value(hash, static_cast<std::uint32_t>(item->level));
+            mix_sockets(hash, item->sockets);
         }
         }
     }
     }
     return hash;
     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. */
 /** Network-order IPv4 loopback returned by the in-process SignOn route. */
 constexpr std::uint32_t kLoopbackAddress = 0x7F000001;
 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. */
 /** Default one-hour lifetime for generated SignOn session tokens. */
 constexpr std::uint32_t kDefaultTokenLifetimeSeconds = 3600;
 constexpr std::uint32_t kDefaultTokenLifetimeSeconds = 3600;
 /** Family 5 uses the largest signed 64-bit value as its process-global object key. */
 /** 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;
         return false;
     }
     }
     initialized.signOn.relayAddress = kLoopbackAddress;
     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.signOn.tokenLifetimeSeconds = kDefaultTokenLifetimeSeconds;
     initialized.account = initialAccount;
     initialized.account = initialAccount;
     initialized.activity.defaults = activityDefaults;
     initialized.activity.defaults = activityDefaults;