Просмотр исходного кода

Merge remote-tracking branch 'origin/master' into triumph-work-code

# Conflicts:
#	Sunrise/resources/default_settings.json
#	Sunrise/src/client/content/items/packages/package_item_build.cpp
#	Sunrise/src/middleware/datagen/family4/account/account_encoder.cpp
#	Sunrise/src/middleware/datagen/family4/loadout/loadout_item_resolver.cpp
#	Sunrise/src/server/bap/bap_route.cpp
#	Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_message_receipts.cpp
#	Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp
#	Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp
#	Sunrise/src/server/bap/encrypted/internal.h
#	Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp
#	Sunrise/src/server/bap/encrypted/queuez/queuez_outcome_staging.cpp
#	Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp
#	Sunrise/src/server/web_service/web_service_actions.cpp
#	Sunrise/src/server/web_service/web_service_runtime.cpp
#	Sunrise/src/server/web_service/web_service_runtime.h
#	Sunrise/src/state/account/inventory/inventory_state.h
#	Sunrise/src/state/build_data/build_data_runtime.cpp
#	Sunrise/src/state/build_data/cache/read/cache_payload_reader.cpp
#	Sunrise/src/state/build_data/cache/records/codec.h
#	Sunrise/src/state/build_data/cache/records/format.h
#	Sunrise/src/state/build_data/runtime.h
#	Sunrise/src/state/runtime/state_runtime.cpp
stan 6 дней назад
Родитель
Сommit
17376ccb6e
100 измененных файлов с 5591 добавлено и 577 удалено
  1. 1 1
      .clang-tidy
  2. 21 1
      Sunrise/Sunrise.vcxproj
  3. 152 0
      Sunrise/docs/emote-unlocks.md
  4. 4 2
      Sunrise/resources/default_settings.json
  5. 22 6
      Sunrise/src/client/content/investment/investment_refresh.cpp
  6. 50 47
      Sunrise/src/client/content/investment/worker/investment_refresh_worker.cpp
  7. 16 0
      Sunrise/src/client/content/items/packages/internal.h
  8. 25 0
      Sunrise/src/client/content/items/packages/package_build_report.cpp
  9. 147 0
      Sunrise/src/client/content/items/packages/package_catalyst_condition_reader.cpp
  10. 19 1
      Sunrise/src/client/content/items/packages/package_item_build.cpp
  11. 57 8
      Sunrise/src/client/content/items/packages/package_item_rows.cpp
  12. 125 9
      Sunrise/src/client/content/items/packages/package_socket_plug_build.cpp
  13. 63 2
      Sunrise/src/client/content/items/packages/package_socket_plug_build.h
  14. 30 0
      Sunrise/src/client/content/scenarios/internal.h
  15. 2 1
      Sunrise/src/client/content/scenarios/scenario_roster_build.cpp
  16. 152 0
      Sunrise/src/client/content/scenarios/scenario_roster_groups.cpp
  17. 69 0
      Sunrise/src/client/content/scenarios/scenario_roster_publish.cpp
  18. 1119 0
      Sunrise/src/client/diagnostics/entity_create_probe.cpp
  19. 34 0
      Sunrise/src/client/diagnostics/entity_create_probe.h
  20. 278 0
      Sunrise/src/client/diagnostics/image_dump.cpp
  21. 26 0
      Sunrise/src/client/diagnostics/image_dump.h
  22. 169 30
      Sunrise/src/client/hooking/detour/transaction/detour_thread_transaction.cpp
  23. 4 3
      Sunrise/src/client/hooks/assert_handler/assert_handler_observer.cpp
  24. 0 3
      Sunrise/src/client/hooks/assert_handler/assert_handler_observer.h
  25. 86 12
      Sunrise/src/client/hooks/bootflow/bootflow_hook_lifecycle.cpp
  26. 15 9
      Sunrise/src/client/hooks/bootflow/character_select_hold.cpp
  27. 15 9
      Sunrise/src/client/hooks/bootflow/composition_check.cpp
  28. 75 24
      Sunrise/src/client/hooks/bootflow/internal.h
  29. 15 9
      Sunrise/src/client/hooks/bootflow/orbit_handoff.cpp
  30. 13 8
      Sunrise/src/client/hooks/bootflow/orbit_slice_set.cpp
  31. 13 8
      Sunrise/src/client/hooks/bootflow/owner_activity_slot.cpp
  32. 15 9
      Sunrise/src/client/hooks/bootflow/profile_setup_skip.cpp
  33. 21 13
      Sunrise/src/client/hooks/bootflow/region_private.cpp
  34. 13 8
      Sunrise/src/client/hooks/bootflow/spawn_hold.cpp
  35. 0 1
      Sunrise/src/client/hooks/egress/internal.h
  36. 6 12
      Sunrise/src/client/hooks/egress/lifecycle/egress_guard_lifecycle.cpp
  37. 7 18
      Sunrise/src/client/hooks/graphics/input/graphics_window_input.cpp
  38. 124 0
      Sunrise/src/client/hooks/membership_probe/membership_probe.cpp
  39. 53 0
      Sunrise/src/client/hooks/retail_log/retail_log_enqueue_observer.cpp
  40. 114 6
      Sunrise/src/client/patterns/registry.cpp
  41. 15 0
      Sunrise/src/client/runtime/client_hook_activation.cpp
  42. 12 0
      Sunrise/src/client/runtime/client_runtime_lifecycle.cpp
  43. 2 0
      Sunrise/src/client/runtime/internal.h
  44. 9 10
      Sunrise/src/core/logging/log.cpp
  45. 34 34
      Sunrise/src/core/logging/snapshot/log_snapshot_ring.cpp
  46. 5 10
      Sunrise/src/core/runtime/core_runtime.cpp
  47. 18 0
      Sunrise/src/core/settings/client/client_settings_parser.cpp
  48. 22 0
      Sunrise/src/core/settings/client/definition.h
  49. 34 0
      Sunrise/src/core/settings/server/gameplay/definition.h
  50. 15 0
      Sunrise/src/core/settings/server/gameplay/gameplay_settings_parser.cpp
  51. 15 0
      Sunrise/src/core/settings/server/gameplay/gameplay_settings_validation.cpp
  52. 2 0
      Sunrise/src/core/settings/settings.h
  53. 7 0
      Sunrise/src/core/settings/settings_parser.cpp
  54. 18 0
      Sunrise/src/core/settings/state/activity_default_destination_parser.cpp
  55. 2 4
      Sunrise/src/core/settings/state/inventory_parser.cpp
  56. 25 23
      Sunrise/src/core/settings/state/key_bindings_parser.cpp
  57. 81 0
      Sunrise/src/core/threading/data_mutex.h
  58. 17 0
      Sunrise/src/core/threading/sendable.h
  59. 47 0
      Sunrise/src/core/threading/srw_lock.h
  60. 195 3
      Sunrise/src/middleware/bap/activity_message/activity_sensor_auth_bodies.cpp
  61. 17 0
      Sunrise/src/middleware/bap/activity_message/sensor_auth_update.h
  62. 16 12
      Sunrise/src/middleware/content/packages/reader/package_handle_cache.cpp
  63. 69 0
      Sunrise/src/middleware/content/packages/tables/bubble_state_reader.cpp
  64. 19 0
      Sunrise/src/middleware/content/packages/tables/definition_index_table.h
  65. 9 0
      Sunrise/src/middleware/content/packages/tables/roster_intersection.cpp
  66. 21 0
      Sunrise/src/middleware/content/packages/tables/roster_intersection.h
  67. 4 3
      Sunrise/src/middleware/datagen/character_record/appearance/character_appearance_banks.cpp
  68. 14 0
      Sunrise/src/middleware/datagen/character_record/appearance/character_appearance_render.cpp
  69. 2 2
      Sunrise/src/middleware/datagen/character_record/appearance/character_appearance_stats.cpp
  70. 9 0
      Sunrise/src/middleware/datagen/character_record/appearance/internal.h
  71. 18 1
      Sunrise/src/middleware/datagen/definitions.h
  72. 3 0
      Sunrise/src/middleware/datagen/family4/account/account_encoder.cpp
  73. 0 96
      Sunrise/src/middleware/datagen/family4/account/preferences/native_key_binding_map.h
  74. 6 3
      Sunrise/src/middleware/datagen/family4/account/preferences/preferences_encoder.cpp
  75. 11 0
      Sunrise/src/middleware/datagen/family4/instance/instance_encoder.cpp
  76. 16 6
      Sunrise/src/middleware/datagen/family4/loadout/loadout_item_resolver.cpp
  77. 1 1
      Sunrise/src/middleware/web_service/messages/opcode406.h
  78. 3 3
      Sunrise/src/middleware/web_service/messages/opcode406_codec.cpp
  79. 875 0
      Sunrise/src/middleware/web_service/messages/opcode701/opcode701_codec.cpp
  80. 31 0
      Sunrise/src/middleware/web_service/messages/opcode701/opcode701_codec.h
  81. 41 73
      Sunrise/src/server/bap/bap_route.cpp
  82. 117 5
      Sunrise/src/server/bap/encrypted/activity_message/activity_message_route.cpp
  83. 15 1
      Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_message_receipts.cpp
  84. 21 0
      Sunrise/src/server/bap/encrypted/bap_connection_publication.cpp
  85. 9 0
      Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp
  86. 2 1
      Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp
  87. 14 1
      Sunrise/src/server/bap/encrypted/internal.h
  88. 2 0
      Sunrise/src/server/bap/encrypted/push/activity/activity_roster_snapshot.cpp
  89. 64 0
      Sunrise/src/server/bap/encrypted/push/queuez/queuez_account_preflight.cpp
  90. 18 11
      Sunrise/src/server/bap/encrypted/push/queuez/queuez_banner_push.cpp
  91. 5 0
      Sunrise/src/server/bap/encrypted/push/queuez/queuez_subscription.cpp
  92. 4 0
      Sunrise/src/server/bap/encrypted/push/snapshot/family4_snapshot_preparer.cpp
  93. 5 1
      Sunrise/src/server/bap/encrypted/push/snapshot/initial_snapshot.cpp
  94. 20 0
      Sunrise/src/server/bap/encrypted/push/snapshot/internal.h
  95. 210 0
      Sunrise/src/server/bap/encrypted/push/snapshot/social_roster_snapshot.cpp
  96. 21 0
      Sunrise/src/server/bap/encrypted/queuez/definition.h
  97. 71 1
      Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp
  98. 35 0
      Sunrise/src/server/bap/encrypted/queuez/queuez_outcome_staging.cpp
  99. 22 12
      Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp
  100. 6 0
      Sunrise/src/server/bap/internal.h

+ 1 - 1
.clang-tidy

@@ -74,5 +74,5 @@ ExtraArgsBefore:
   - -Wdocumentation
   - -Wdocumentation
 FormatStyle: file
 FormatStyle: file
 CheckOptions:
 CheckOptions:
-  portability-restrict-system-includes.Includes: '-*,Windows.h,WinSock2.h,WS2tcpip.h,MSWSock.h,WinDNS.h,TlHelp32.h,Shellapi.h,bcrypt.h,d3d11.h,detours.h,dxgi.h,wincodec.h,imgui.h,imgui_impl_dx11.h,imgui_impl_win32.h,intrin.h,algorithm,array,atomic,bit,bitset,cctype,charconv,chrono,climits,cmath,cstdarg,cstddef,cstdint,cstdio,cstdlib,cstring,cwchar,limits,memory,new,optional,span,string_view,type_traits,utility,variant,vector'
+  portability-restrict-system-includes.Includes: '-*,Windows.h,WinSock2.h,WS2tcpip.h,MSWSock.h,WinDNS.h,TlHelp32.h,Shellapi.h,bcrypt.h,d3d11.h,detours.h,dxgi.h,wincodec.h,imgui.h,imgui_impl_dx11.h,imgui_impl_win32.h,intrin.h,algorithm,array,atomic,bit,bitset,cctype,charconv,chrono,climits,cmath,concepts,cstdarg,cstddef,cstdint,cstdio,cstdlib,cstring,cwchar,limits,memory,mutex,new,optional,shared_mutex,span,string_view,type_traits,utility,variant,vector'
 ...
 ...

+ 21 - 1
Sunrise/Sunrise.vcxproj

@@ -136,6 +136,7 @@
     <ClCompile Include="src\client\hooks\cursor\cursor_hook_lifecycle.cpp" />
     <ClCompile Include="src\client\hooks\cursor\cursor_hook_lifecycle.cpp" />
     <ClCompile Include="src\client\hooks\graphics\renderer\graphics_renderer_device.cpp" />
     <ClCompile Include="src\client\hooks\graphics\renderer\graphics_renderer_device.cpp" />
     <ClCompile Include="src\middleware\web_service\messages\opcode504_codec.cpp" />
     <ClCompile Include="src\middleware\web_service\messages\opcode504_codec.cpp" />
+    <ClCompile Include="src\middleware\web_service\messages\opcode701\opcode701_codec.cpp" />
     <ClCompile Include="src\middleware\web_service\messages\opcode903_codec.cpp" />
     <ClCompile Include="src\middleware\web_service\messages\opcode903_codec.cpp" />
     <ClCompile Include="src\middleware\web_service\messages\opcode1901_codec.cpp" />
     <ClCompile Include="src\middleware\web_service\messages\opcode1901_codec.cpp" />
     <ClCompile Include="src\middleware\web_service\messages\opcode402_codec.cpp" />
     <ClCompile Include="src\middleware\web_service\messages\opcode402_codec.cpp" />
@@ -151,6 +152,7 @@
     <ClCompile Include="src\state\runtime\state_account_identity_runtime.cpp" />
     <ClCompile Include="src\state\runtime\state_account_identity_runtime.cpp" />
     <ClCompile Include="src\state\runtime\state_account_profile_runtime.cpp" />
     <ClCompile Include="src\state\runtime\state_account_profile_runtime.cpp" />
     <ClCompile Include="src\state\runtime\state_account_socket_runtime.cpp" />
     <ClCompile Include="src\state\runtime\state_account_socket_runtime.cpp" />
+    <ClCompile Include="src\state\runtime\state_account_settings_runtime.cpp" />
     <ClCompile Include="src\state\runtime\state_rolled_socket_plugs.cpp" />
     <ClCompile Include="src\state\runtime\state_rolled_socket_plugs.cpp" />
     <ClCompile Include="src\state\runtime\state_account_item_action_runtime.cpp" />
     <ClCompile Include="src\state\runtime\state_account_item_action_runtime.cpp" />
     <ClCompile Include="src\core\ui\busy\ui_busy_overlay.cpp" />
     <ClCompile Include="src\core\ui\busy\ui_busy_overlay.cpp" />
@@ -208,6 +210,7 @@
     <ClCompile Include="src\middleware\datagen\family4\loadout\subclass_socket_selection.cpp" />
     <ClCompile Include="src\middleware\datagen\family4\loadout\subclass_socket_selection.cpp" />
     <ClCompile Include="src\state\build_data\cache\records\cache_socket_record_codec.cpp" />
     <ClCompile Include="src\state\build_data\cache\records\cache_socket_record_codec.cpp" />
     <ClCompile Include="src\state\build_data\cache\records\cache_socket_plug_record_codec.cpp" />
     <ClCompile Include="src\state\build_data\cache\records\cache_socket_plug_record_codec.cpp" />
+    <ClCompile Include="src\state\build_data\cache\records\cache_exotic_catalyst_record_codec.cpp" />
     <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" />
@@ -611,6 +614,7 @@
     <ClCompile Include="src\state\account\account_state.cpp" />
     <ClCompile Include="src\state\account\account_state.cpp" />
     <ClCompile Include="src\state\account\inventory\inventory_state.cpp" />
     <ClCompile Include="src\state\account\inventory\inventory_state.cpp" />
     <ClCompile Include="src\state\account\settings\settings_state.cpp" />
     <ClCompile Include="src\state\account\settings\settings_state.cpp" />
+    <ClCompile Include="src\state\account\settings\settings_delta.cpp" />
     <ClCompile Include="src\state\equipment\light\calculation\equipment_light_calculation.cpp" />
     <ClCompile Include="src\state\equipment\light\calculation\equipment_light_calculation.cpp" />
     <ClCompile Include="src\state\equipment\light\resolution\configured_equipment_light_resolver.cpp" />
     <ClCompile Include="src\state\equipment\light\resolution\configured_equipment_light_resolver.cpp" />
     <ClCompile Include="src\state\content\content_catalog.cpp" />
     <ClCompile Include="src\state\content\content_catalog.cpp" />
@@ -682,6 +686,10 @@
     <ClCompile Include="src\state\build_data\items\details\item_detail_catalog.cpp" />
     <ClCompile Include="src\state\build_data\items\details\item_detail_catalog.cpp" />
     <ClCompile Include="src\state\build_data\items\socket_plugs\socket_plug_catalog.cpp" />
     <ClCompile Include="src\state\build_data\items\socket_plugs\socket_plug_catalog.cpp" />
     <ClCompile Include="src\state\build_data\items\socket_plugs\socket_plug_build_data_runtime.cpp" />
     <ClCompile Include="src\state\build_data\items\socket_plugs\socket_plug_build_data_runtime.cpp" />
+    <ClCompile Include="src\state\build_data\items\catalysts\exotic_catalyst_builder.cpp" />
+    <ClCompile Include="src\state\build_data\items\catalysts\exotic_catalyst_generated.cpp" />
+    <ClCompile Include="src\state\build_data\items\catalysts\exotic_catalyst_catalog.cpp" />
+    <ClCompile Include="src\state\build_data\items\catalysts\exotic_catalyst_build_data_runtime.cpp" />
     <ClCompile Include="src\state\build_data\constants\investment_constant_catalog.cpp" />
     <ClCompile Include="src\state\build_data\constants\investment_constant_catalog.cpp" />
     <ClCompile Include="src\state\build_data\abilities\ability_bucket_catalog.cpp" />
     <ClCompile Include="src\state\build_data\abilities\ability_bucket_catalog.cpp" />
     <ClCompile Include="src\state\build_data\progressions\progression_catalog.cpp" />
     <ClCompile Include="src\state\build_data\progressions\progression_catalog.cpp" />
@@ -876,6 +884,7 @@
     <ClCompile Include="src\server\bap\encrypted\queuez\queuez_deferred_push.cpp" />
     <ClCompile Include="src\server\bap\encrypted\queuez\queuez_deferred_push.cpp" />
     <ClCompile Include="src\server\bap\encrypted\queuez\queuez_outcome_staging.cpp" />
     <ClCompile Include="src\server\bap\encrypted\queuez\queuez_outcome_staging.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\queuez\queuez_update_frame.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\queuez\queuez_update_frame.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\push\queuez\queuez_account_preflight.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\queuez\queuez_subscription.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\queuez\queuez_subscription.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\queuez\queuez_change_character.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\queuez\queuez_change_character.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\queuez\queuez_select_character.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\queuez\queuez_select_character.cpp" />
@@ -903,6 +912,7 @@
     <ClCompile Include="src\server\bap\encrypted\push\snapshot\initial_snapshot.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\snapshot\initial_snapshot.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\snapshot\banner_snapshot.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\snapshot\banner_snapshot.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\snapshot\roster_snapshot.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\snapshot\roster_snapshot.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\push\snapshot\social_roster_snapshot.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\snapshot\snapshot_storage.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\snapshot\snapshot_storage.cpp" />
     <ClCompile Include="src\server\web_service\opcode_routes.cpp" />
     <ClCompile Include="src\server\web_service\opcode_routes.cpp" />
     <ClCompile Include="src\server\web_service\web_service_runtime.cpp" />
     <ClCompile Include="src\server\web_service\web_service_runtime.cpp" />
@@ -1015,6 +1025,7 @@
     <ClCompile Include="src\client\content\items\packages\package_collectible_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_collectible_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_material_requirement_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_material_requirement_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_detail_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_detail_build.cpp" />
+    <ClCompile Include="src\client\content\items\packages\package_catalyst_condition_reader.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_socket_plug_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_socket_plug_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_ability_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_ability_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_subclass_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_subclass_build.cpp" />
@@ -1057,6 +1068,8 @@
     <ClCompile Include="src\client\content\items\packages\package_build_report.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_build_report.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_root_tables.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_root_tables.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_item_rows.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_item_rows.cpp" />
+    <ClCompile Include="src\client\diagnostics\entity_create_probe.cpp" />
+    <ClCompile Include="src\client\diagnostics\image_dump.cpp" />
     <ClCompile Include="src\client\diagnostics\module_range.cpp" />
     <ClCompile Include="src\client\diagnostics\module_range.cpp" />
     <ClCompile Include="src\client\process\freeze\client_process_freeze.cpp" />
     <ClCompile Include="src\client\process\freeze\client_process_freeze.cpp" />
     <ClCompile Include="src\core\settings\address_text.cpp" />
     <ClCompile Include="src\core\settings\address_text.cpp" />
@@ -1217,6 +1230,7 @@
     <ClInclude Include="src\client\hooks\banner\banner_bind.h" />
     <ClInclude Include="src\client\hooks\banner\banner_bind.h" />
     <ClInclude Include="src\client\hooks\banner\banner_hook_lifecycle.h" />
     <ClInclude Include="src\client\hooks\banner\banner_hook_lifecycle.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode504.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode504.h" />
+    <ClInclude Include="src\middleware\web_service\messages\opcode701\opcode701_codec.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode903.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode903.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode1901.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode1901.h" />
     <ClInclude Include="src\core\ui\busy\busy.h" />
     <ClInclude Include="src\core\ui\busy\busy.h" />
@@ -1403,7 +1417,9 @@
     <ClInclude Include="src\state\account\account_state.h" />
     <ClInclude Include="src\state\account\account_state.h" />
     <ClInclude Include="src\state\account\inventory\inventory_state.h" />
     <ClInclude Include="src\state\account\inventory\inventory_state.h" />
     <ClInclude Include="src\state\account\settings\settings_state.h" />
     <ClInclude Include="src\state\account\settings\settings_state.h" />
+    <ClInclude Include="src\state\account\settings\settings_delta.h" />
     <ClInclude Include="src\state\account\settings\key_bindings.h" />
     <ClInclude Include="src\state\account\settings\key_bindings.h" />
+    <ClInclude Include="src\state\account\settings\native_key_binding_map.h" />
     <ClInclude Include="src\state\equipment\light\definition.h" />
     <ClInclude Include="src\state\equipment\light\definition.h" />
     <ClInclude Include="src\state\equipment\light\calculation\equipment_light_calculation.h" />
     <ClInclude Include="src\state\equipment\light\calculation\equipment_light_calculation.h" />
     <ClInclude Include="src\state\equipment\light\resolution\configured_equipment_light_resolver.h" />
     <ClInclude Include="src\state\equipment\light\resolution\configured_equipment_light_resolver.h" />
@@ -1472,6 +1488,9 @@
     <ClInclude Include="src\state\build_data\items\details\item_detail_catalog.h" />
     <ClInclude Include="src\state\build_data\items\details\item_detail_catalog.h" />
     <ClInclude Include="src\state\build_data\items\socket_plugs\definition.h" />
     <ClInclude Include="src\state\build_data\items\socket_plugs\definition.h" />
     <ClInclude Include="src\state\build_data\items\socket_plugs\socket_plug_catalog.h" />
     <ClInclude Include="src\state\build_data\items\socket_plugs\socket_plug_catalog.h" />
+    <ClInclude Include="src\state\build_data\items\catalysts\definition.h" />
+    <ClInclude Include="src\state\build_data\items\catalysts\exotic_catalyst_builder.h" />
+    <ClInclude Include="src\state\build_data\items\catalysts\exotic_catalyst_catalog.h" />
     <ClInclude Include="src\client\content\items\packages\package_socket_plug_build.h" />
     <ClInclude Include="src\client\content\items\packages\package_socket_plug_build.h" />
     <ClInclude Include="src\state\build_data\inventory\buckets\definition.h" />
     <ClInclude Include="src\state\build_data\inventory\buckets\definition.h" />
     <ClInclude Include="src\state\build_data\inventory\buckets\inventory_bucket_catalog.h" />
     <ClInclude Include="src\state\build_data\inventory\buckets\inventory_bucket_catalog.h" />
@@ -1656,7 +1675,6 @@
     <ClInclude Include="src\middleware\datagen\family4\account\account_encoder.h" />
     <ClInclude Include="src\middleware\datagen\family4\account\account_encoder.h" />
     <ClInclude Include="src\middleware\datagen\family4\account\layout.h" />
     <ClInclude Include="src\middleware\datagen\family4\account\layout.h" />
     <ClInclude Include="src\middleware\datagen\family4\account\preferences\layout.h" />
     <ClInclude Include="src\middleware\datagen\family4\account\preferences\layout.h" />
-    <ClInclude Include="src\middleware\datagen\family4\account\preferences\native_key_binding_map.h" />
     <ClInclude Include="src\middleware\datagen\family4\account\preferences\preferences_encoder.h" />
     <ClInclude Include="src\middleware\datagen\family4\account\preferences\preferences_encoder.h" />
     <ClInclude Include="src\middleware\datagen\family4\character\abi.h" />
     <ClInclude Include="src\middleware\datagen\family4\character\abi.h" />
     <ClInclude Include="src\middleware\datagen\family4\loadout\definition.h" />
     <ClInclude Include="src\middleware\datagen\family4\loadout\definition.h" />
@@ -1882,6 +1900,8 @@
     <ClInclude Include="src\server\transport\internal.h" />
     <ClInclude Include="src\server\transport\internal.h" />
     <ClInclude Include="src\middleware\content\packages\reader\locator_cache.h" />
     <ClInclude Include="src\middleware\content\packages\reader\locator_cache.h" />
     <ClInclude Include="src\server\bap\encrypted\push\queuez\queuez_push_reporting.h" />
     <ClInclude Include="src\server\bap\encrypted\push\queuez\queuez_push_reporting.h" />
+    <ClInclude Include="src\client\diagnostics\entity_create_probe.h" />
+    <ClInclude Include="src\client\diagnostics\image_dump.h" />
     <ClInclude Include="src\client\diagnostics\module_range.h" />
     <ClInclude Include="src\client\diagnostics\module_range.h" />
     <ClInclude Include="src\client\process\freeze\client_process_freeze.h" />
     <ClInclude Include="src\client\process\freeze\client_process_freeze.h" />
     <ClInclude Include="src\core\settings\address_text.h" />
     <ClInclude Include="src\core\settings\address_text.h" />

+ 152 - 0
Sunrise/docs/emote-unlocks.md

@@ -0,0 +1,152 @@
+# Emote ownership flags
+
+How this build decides an emote is owned, how the flags behind that were
+recovered, and the data itself. None of this is public: the Bungie manifest
+exposes only the failure message for these rules, never the expression behind
+it, so everything here was read out of the installed packages directly.
+
+## Where ownership actually lives
+
+**An emote's ownership is gated by its own item definition, not by a
+collectible.** Each emote item carries a plug rule -- the one whose failure
+message is *"You do not own this emote"* -- and that rule's unlock expression
+sits at **item-definition offset 720**. The enabled rule repeats the same
+expression at offset 800.
+
+The collectible table's acquired expression (`+112`) is a dead end for emotes:
+only 94 of this build's 307 emote items have a collectible row at all, so a
+collectible-driven pass cannot reach the other 213 no matter how it is written.
+
+Decoding offset 720 for every item in the individual-emote bucket (**41**)
+gives:
+
+| | count |
+| --- | ---: |
+| emote items in this build | 307 |
+| gated by an ownership flag | 291 |
+| distinct flag slots behind them | 288 |
+| carrying no expression at all | 16 |
+
+The 16 ungated ones are always owned -- that is why Yes, Nope and Cheer worked
+before any of this.
+
+## Slots are not indices
+
+This is the part that silently wastes a day.
+
+`state.unlocks.account_flag_runs` in `default_settings.json` does **not** hold
+flag slot numbers. It fills the account object's acquired-flag byte array, and
+the client addresses that array **by row number in the unlock flag mapping
+table**, not by slot. The two are unrelated number spaces.
+
+The mapping table for this bank:
+
+| | |
+| --- | --- |
+| investment root slot | 111 |
+| tag | `81319322` |
+| rows | 11923 |
+| row shape | `{ u32 unlock_hash; i16 destination_slot; u16 zero; }` |
+
+To set flag slot `s`, find the row whose `destination_slot == s`; that row's
+**number** is the index to write. Writing `s` itself sets an unrelated flag.
+
+Symptoms of getting this wrong, both observed here:
+
+- Writing slot numbers as indices does nothing visible, because the indices
+  that happen to be hit belong to unrelated slots.
+- Filling the whole bank "works" for emotes but also sets every entitlement
+  flag, which leaves the account unable to open the Director, the map, or
+  orbit. Do not blanket fill.
+
+Translating the 288 slots through the table yields **282 row indices**. The 6
+that do not resolve have no row in this table (they are reachable only through
+the family-5 override list, which is capped at 127 rows and so is not a route
+for a set this size).
+
+## The data
+
+Ownership flag **slots** (288) -- what the item definitions name:
+
+```
+229, 237-239, 264, 271, 2218, 2222, 2232-2238, 2240, 2242-2259, 3813-3820,
+3822-3836, 5200-5205, 5209-5220, 5228-5231, 5426-5427, 5429-5436, 6245-6264,
+6896-6902, 7350-7371, 7373-7374, 8159-8165, 8167-8182, 8999-9010, 9012-9013,
+9016-9021, 10408-10428, 10907-10932, 11431-11457, 11740-11770
+```
+
+Mapping-table **row indices** (282) -- what `account_flag_runs` must contain:
+
+```
+807, 811, 821-827, 829, 831-848, 2090-2097, 2099-2113, 3080-3085, 3089-3100,
+3107-3110, 3217-3218, 3220-3227, 3729-3748, 4195-4201, 4428-4449, 4451-4452,
+5057-5063, 5065-5080, 5676-5687, 5689-5690, 5693-5698, 6353-6373, 6734-6759,
+7148-7174, 7404-7434
+```
+
+161 of those indices were already set by the authored data, which is the 179
+emotes that were already owned (161 gated + 16 ungated + 2 sharing a flag).
+The change added the remaining 121.
+
+None of the 288 slots collides with any documented entitlement, platform, or
+pre-release slot. The Director, map and orbit were verified working afterwards.
+
+## Ownership is not always the only gate
+
+An emote can carry more than one plug rule. The ownership rule at offset 720 is
+the common case, but a few items add a second rule immediately after it, and
+that one is a **value** comparison rather than a flag read. Such an emote reads
+as owned and still refuses to equip, showing *"Access Restricted"*.
+
+`X Marks The Spot` (`0x1682F6A3`) is the worked example in this build:
+
+| offset | expression | meaning |
+| ---: | --- | --- |
+| 720 | `FLAG(9004)` | ownership, set by `account_flag_runs` |
+| 736 | `VAL(5549) CONST(50) >=` | `VAL(5549) >= 50` |
+| 864 | `FLAG(9004)` | the enabled rule, pushed down by the extra rule |
+
+`VAL(5549)` is the objective progress counter for the *Golden Offerings*
+triumph, so the real requirement is completing that triumph. Value slots are
+not part of the flag banks; the route to them is the family-5 override list:
+
+```json
+"family5_value_overrides": [ ..., [5549,50] ]
+```
+
+Setting it to the threshold exactly, rather than inflating it, keeps any other
+expression that compares the same slot honest -- raising a value slot too far
+is what breaks unrelated content, per the shared-pool warning in the unlock
+documentation.
+
+Note this also shifts the layout: an emote with the extra rule has its enabled
+rule at 864 rather than 800. 32 of the 307 emotes in this build do not have a
+plain single-flag expression at 800 for this reason, which is expected and not
+a fault.
+
+## Regenerating this
+
+The flag list is specific to this build; a content change invalidates it. To
+rebuild it, three temporary passes over the package data are needed:
+
+1. For every item with `bucketId == 41`, decode the unlock expression at
+   definition offset 720. A single `opcode 1` instruction carries the flag slot
+   as its operand. Expressions resolve as
+   `target = (offset of the pointer field) + (value stored there) + 16`, the
+   same self-relative form plus 16-byte block header that `find_array_at` uses.
+2. Read the mapping table at investment root slot 111 and build
+   `destination_slot -> row index`.
+3. Run-length-encode the union of the existing runs and the new indices.
+
+Watch the cache while doing this. `stale_format()` treats only
+`version < kCacheFormatVersion` as rebuildable, so a `build_data.bin` written
+by a *newer* format version is rejected outright rather than regenerated, which
+fails state initialisation and surfaces in the client as
+*"Verify integrity of game files"*. If the cache format version is changed and
+then reverted, delete `build_data.bin`.
+
+## Known gap
+
+These flags are recorded as a set. Which flag belongs to which *named* emote
+was never captured -- the extraction logged slots without their item hashes. It
+matters only if emotes ever need unlocking selectively rather than all at once.

Разница между файлами не показана из-за своего большого размера
+ 4 - 2
Sunrise/resources/default_settings.json


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

@@ -1,22 +1,37 @@
 #include <Windows.h>
 #include <Windows.h>
 
 
+#include <mutex>
+
 #include "../../../core/ui/busy/busy.h"
 #include "../../../core/ui/busy/busy.h"
 #include "../../../middleware/content/packages/reader/reader.h"
 #include "../../../middleware/content/packages/reader/reader.h"
 #include "../../../state/build_data/runtime.h"
 #include "../../../state/build_data/runtime.h"
 #include "../../../state/runtime/runtime.h"
 #include "../../../state/runtime/runtime.h"
 #include "../items/packages/build.h"
 #include "../items/packages/build.h"
+#include "core/threading/srw_lock.h"
 #include "internal.h"
 #include "internal.h"
 #include "runtime.h"
 #include "runtime.h"
 
 
 namespace sunrise::client::content::investment {
 namespace sunrise::client::content::investment {
 namespace {
 namespace {
 
 
-SRWLOCK g_refreshLock{SRWLOCK_INIT};
+core::threading::SrwLock g_refreshLock{};
 
 
 [[nodiscard]] bool ready() noexcept {
 [[nodiscard]] bool ready() noexcept {
     return state::build_data::named_catalog_ready() && items::packages::ready();
     return state::build_data::named_catalog_ready() && items::packages::ready();
 }
 }
 
 
+/**
+ * Runs the emote-collection canonicalization on the extraction path, where it is an opportunistic
+ * head start rather than a precondition: the snapshot path runs the same step behind its own
+ * preflight, so nothing here is the last chance to apply it.
+ * @return False only when the account itself could not be updated, which is the one outcome that
+ * says something is wrong rather than merely unfinished. A build that cannot carry the item, and
+ * one whose data is still being extracted, both leave the cache worth writing.
+ */
+[[nodiscard]] bool emote_collection_settled() noexcept {
+    return state::ensure_character_emote_collection() != state::EmoteCollectionOutcome::failed;
+}
+
 } // namespace
 } // namespace
 
 
 /** @return True when the next refresh slice needs a visible overlay for a package sweep. */
 /** @return True when the next refresh slice needs a visible overlay for a package sweep. */
@@ -29,19 +44,19 @@ bool refresh() noexcept {
     if (ready()) {
     if (ready()) {
         // The same lock as the extraction path. A cache write holds its own lock across file
         // The same lock as the extraction path. A cache write holds its own lock across file
         // calls, so a held thread stopped inside one would deadlock the freeze below.
         // calls, so a held thread stopped inside one would deadlock the freeze below.
-        AcquireSRWLockExclusive(&g_refreshLock);
+        const std::lock_guard lock(g_refreshLock);
         const bool persisted = state::ensure_profile_item_identities()
         const bool persisted = state::ensure_profile_item_identities()
                                && state::ensure_character_subclasses()
                                && state::ensure_character_subclasses()
+                               && emote_collection_settled()
                                && state::build_data::persist();
                                && state::build_data::persist();
         // Nothing reads a package again until the next boot, so the open files and the held
         // Nothing reads a package again until the next boot, so the open files and the held
         // tables go back now rather than at process exit.
         // tables go back now rather than at process exit.
         middleware::content::packages::reader::release_caches();
         middleware::content::packages::reader::release_caches();
-        ReleaseSRWLockExclusive(&g_refreshLock);
         core::ui::busy::end(core::ui::busy::Task::contentExtraction);
         core::ui::busy::end(core::ui::busy::Task::contentExtraction);
         return persisted;
         return persisted;
     }
     }
 
 
-    AcquireSRWLockExclusive(&g_refreshLock);
+    const std::lock_guard lock(g_refreshLock);
     // The package pass creates parallel readers. Suspending the client while those threads start
     // The package pass creates parallel readers. Suspending the client while those threads start
     // can block their DLL thread-attach work behind a suspended owner, so the visible preflight
     // can block their DLL thread-attach work behind a suspended owner, so the visible preflight
     // runs one frame early and extraction proceeds with the process live.
     // runs one frame early and extraction proceeds with the process live.
@@ -50,12 +65,13 @@ bool refresh() noexcept {
     (void)items::packages::build();
     (void)items::packages::build();
     const bool domainsReady = ready();
     const bool domainsReady = ready();
     const bool complete = domainsReady && state::ensure_profile_item_identities()
     const bool complete = domainsReady && state::ensure_profile_item_identities()
-                          && state::ensure_character_subclasses() && state::build_data::persist();
+                          && state::ensure_character_subclasses()
+                          && emote_collection_settled()
+                          && state::build_data::persist();
     // The overlay ends with the work, not with the slice, so it spans every retry the pass needs.
     // The overlay ends with the work, not with the slice, so it spans every retry the pass needs.
     if (complete) {
     if (complete) {
         core::ui::busy::end(core::ui::busy::Task::contentExtraction);
         core::ui::busy::end(core::ui::busy::Task::contentExtraction);
     }
     }
-    ReleaseSRWLockExclusive(&g_refreshLock);
     return complete;
     return complete;
 }
 }
 
 

+ 50 - 47
Sunrise/src/client/content/investment/worker/investment_refresh_worker.cpp

@@ -6,6 +6,7 @@
 #include "../internal.h"
 #include "../internal.h"
 #include "../runtime.h"
 #include "../runtime.h"
 #include "../worker.h"
 #include "../worker.h"
+#include "core/threading/data_mutex.h"
 
 
 namespace sunrise::client::content::investment::worker {
 namespace sunrise::client::content::investment::worker {
 namespace {
 namespace {
@@ -17,73 +18,75 @@ namespace {
  */
  */
 constexpr std::uint64_t kRefreshIntervalMilliseconds = 0;
 constexpr std::uint64_t kRefreshIntervalMilliseconds = 0;
 
 
-SRWLOCK g_lifecycleLock{SRWLOCK_INIT};
-bool g_accepting{};
-bool g_complete{};
-bool g_overlayPending{};
-std::uint64_t g_nextEligible{};
+struct Lifecycle {
+    bool accepting{};
+    bool complete{};
+    bool overlayPending{};
+    std::uint64_t nextEligible{};
+};
+
+core::threading::DataMutex<Lifecycle> g_lifecycle{};
 
 
 } // namespace
 } // namespace
 
 
 /** Allows cooperative investment refresh slices on the caller-owned game thread. */
 /** Allows cooperative investment refresh slices on the caller-owned game thread. */
 void activate() noexcept {
 void activate() noexcept {
-    AcquireSRWLockExclusive(&g_lifecycleLock);
-    g_accepting = true;
-    g_complete = false;
-    g_overlayPending = false;
-    g_nextEligible = 0;
-    sunrise::core::ui::busy::end(sunrise::core::ui::busy::Task::contentExtraction);
-    ReleaseSRWLockExclusive(&g_lifecycleLock);
+    g_lifecycle.lock([](Lifecycle& lifecycle) {
+        lifecycle.accepting = true;
+        lifecycle.complete = false;
+        lifecycle.overlayPending = false;
+        lifecycle.nextEligible = 0;
+        sunrise::core::ui::busy::end(sunrise::core::ui::busy::Task::contentExtraction);
+    });
 }
 }
 
 
 /** Runs one due bounded refresh slice on the caller-owned game thread. */
 /** Runs one due bounded refresh slice on the caller-owned game thread. */
 void service(std::uint64_t nowMilliseconds) noexcept {
 void service(std::uint64_t nowMilliseconds) noexcept {
-    AcquireSRWLockExclusive(&g_lifecycleLock);
-    if (!g_accepting || g_complete || !sunrise::client::targets::game::content::is_resolved()
-        || nowMilliseconds < g_nextEligible) {
-        ReleaseSRWLockExclusive(&g_lifecycleLock);
-        return;
-    }
-    g_nextEligible = nowMilliseconds + kRefreshIntervalMilliseconds;
-
-    if (sunrise::client::content::investment::requires_package_sweep()) {
-        g_overlayPending = true;
-        if (sunrise::core::ui::busy::raise_early(
-                sunrise::core::ui::busy::Task::contentExtraction)) {
-            ReleaseSRWLockExclusive(&g_lifecycleLock);
+    g_lifecycle.lock([nowMilliseconds](Lifecycle& lifecycle) {
+        if (!lifecycle.accepting || lifecycle.complete
+            || !sunrise::client::targets::game::content::is_resolved()
+            || nowMilliseconds < lifecycle.nextEligible) {
             return;
             return;
         }
         }
-    } else if (g_overlayPending) {
-        // A stale preflight must not leave a task raised after another path publishes the rows.
-        sunrise::core::ui::busy::end(sunrise::core::ui::busy::Task::contentExtraction);
-        g_overlayPending = false;
-    }
+        lifecycle.nextEligible = nowMilliseconds + kRefreshIntervalMilliseconds;
 
 
-    g_complete = sunrise::client::content::investment::refresh();
-    sunrise::client::content::diagnostics::report_readiness();
-    g_overlayPending = false;
-    ReleaseSRWLockExclusive(&g_lifecycleLock);
+        if (sunrise::client::content::investment::requires_package_sweep()) {
+            lifecycle.overlayPending = true;
+            if (sunrise::core::ui::busy::raise_early(
+                    sunrise::core::ui::busy::Task::contentExtraction)) {
+                return;
+            }
+        } else if (lifecycle.overlayPending) {
+            // A stale preflight must not leave a task raised after another path publishes the rows.
+            sunrise::core::ui::busy::end(sunrise::core::ui::busy::Task::contentExtraction);
+            lifecycle.overlayPending = false;
+        }
+
+        lifecycle.complete = sunrise::client::content::investment::refresh();
+        sunrise::client::content::diagnostics::report_readiness();
+        lifecycle.overlayPending = false;
+    });
 }
 }
 
 
 /** Stops taking refresh slices and clears the pending overlay. */
 /** Stops taking refresh slices and clears the pending overlay. */
 void reset() noexcept {
 void reset() noexcept {
-    AcquireSRWLockExclusive(&g_lifecycleLock);
-    g_accepting = false;
-    g_complete = false;
-    g_overlayPending = false;
-    g_nextEligible = 0;
-    sunrise::core::ui::busy::end(sunrise::core::ui::busy::Task::contentExtraction);
-    ReleaseSRWLockExclusive(&g_lifecycleLock);
+    g_lifecycle.lock([](Lifecycle& lifecycle) {
+        lifecycle.accepting = false;
+        lifecycle.complete = false;
+        lifecycle.overlayPending = false;
+        lifecycle.nextEligible = 0;
+        sunrise::core::ui::busy::end(sunrise::core::ui::busy::Task::contentExtraction);
+    });
 }
 }
 
 
 /** Makes the next due pump take another refresh slice even though a prior one completed. */
 /** Makes the next due pump take another refresh slice even though a prior one completed. */
 void request_slice() noexcept {
 void request_slice() noexcept {
-    AcquireSRWLockExclusive(&g_lifecycleLock);
-    if (g_accepting) {
-        g_complete = false;
-        g_nextEligible = 0;
-    }
-    ReleaseSRWLockExclusive(&g_lifecycleLock);
+    g_lifecycle.lock([](Lifecycle& lifecycle) {
+        if (lifecycle.accepting) {
+            lifecycle.complete = false;
+            lifecycle.nextEligible = 0;
+        }
+    });
 }
 }
 
 
 } // namespace sunrise::client::content::investment::worker
 } // namespace sunrise::client::content::investment::worker

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

@@ -15,6 +15,7 @@
 #include "../../../../state/build_data/collectibles/collectible_catalog.h"
 #include "../../../../state/build_data/collectibles/collectible_catalog.h"
 #include "../../../../state/build_data/constants/definition.h"
 #include "../../../../state/build_data/constants/definition.h"
 #include "../../../../state/build_data/inventory/buckets/definition.h"
 #include "../../../../state/build_data/inventory/buckets/definition.h"
+#include "../../../../state/build_data/items/catalysts/definition.h"
 #include "../../../../state/build_data/items/details/definition.h"
 #include "../../../../state/build_data/items/details/definition.h"
 #include "../../../../state/build_data/items/item_catalog.h"
 #include "../../../../state/build_data/items/item_catalog.h"
 #include "../../../../state/build_data/material_requirements/material_requirement_catalog.h"
 #include "../../../../state/build_data/material_requirements/material_requirement_catalog.h"
@@ -68,6 +69,13 @@ struct Storage {
     std::vector<std::byte> definition{};
     std::vector<std::byte> definition{};
     /** Shared reusable/randomized plug-set table read from investment-root slot 51. */
     /** Shared reusable/randomized plug-set table read from investment-root slot 51. */
     std::vector<std::byte> plugSetTable{};
     std::vector<std::byte> plugSetTable{};
+    /** Dense item-indexed catalyst completion expressions for this package pass. */
+    std::vector<state::build_data::items::catalysts::CompletionCondition>
+        catalystCompletionConditions{};
+    /** Dense socket-type-indexed acquired-state gates for this package pass. */
+    std::vector<state::build_data::items::catalysts::AcquisitionGate> catalystAcquisitionGates{};
+    /** Dense native objective completion values used by legacy catalyst progress items. */
+    std::vector<std::int32_t> catalystObjectiveValues{};
     /** Compact 0..3 special plug-category code of every dense installed item row. */
     /** Compact 0..3 special plug-category code of every dense installed item row. */
     std::array<std::uint8_t, state::build_data::items::kDefinitionCapacity> specialPlugCategories{};
     std::array<std::uint8_t, state::build_data::items::kDefinitionCapacity> specialPlugCategories{};
     /** Inventory routing rows held until the paired bucket-definition table is resolved. */
     /** Inventory routing rows held until the paired bucket-definition table is resolved. */
@@ -315,6 +323,14 @@ void report_socket_plug_count(std::size_t rules,
                               std::size_t members,
                               std::size_t members,
                               std::size_t skipped) noexcept;
                               std::size_t skipped) noexcept;
 
 
+/**
+ * Reports released, placeholder, and unsupported catalyst catalog counts.
+ * @param report Complete catalog report from the build pass.
+ * @param built True when all released catalyst relations were safe.
+ */
+void report_catalyst_catalog(const state::build_data::items::catalysts::Report& report,
+                             bool built) noexcept;
+
 /** Reports the validated installed bucket/equipment-slot coverage. */
 /** Reports the validated installed bucket/equipment-slot coverage. */
 void report_bucket_equipment_mapping(std::size_t mappedSlots) noexcept;
 void report_bucket_equipment_mapping(std::size_t mappedSlots) noexcept;
 
 

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

@@ -128,6 +128,31 @@ void report_socket_plug_count(std::size_t rules,
     }
     }
 }
 }
 
 
+/** Reports the build-scoped catalyst catalog and its first safe failure. */
+void report_catalyst_catalog(const state::build_data::items::catalysts::Report& report,
+                             bool built) noexcept {
+    std::array<char, 192> line{};
+    const int written = std::snprintf(
+        line.data(),
+        line.size(),
+        "ev=pkg stage=exotic_catalysts result=%s released=%zu placeholder=%zu unsupported=%zu "
+        "error=%s item=0x%08X lane=%u",
+        built ? "ok" : "fail",
+        report.released,
+        report.placeholder,
+        report.unsupported,
+        state::build_data::items::catalysts::error_name(report.error).data(),
+        report.itemDefinitionHash,
+        static_cast<unsigned>(report.socketLane));
+    if (written > 0) {
+        const core::log::Level level = !built                    ? core::log::Level::error
+                                       : report.unsupported != 0 ? core::log::Level::warn
+                                                                 : core::log::Level::info;
+        core::log::write(
+            core::log::Channel::client, level, {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
 /** Reports the installed bucket definition relation used by loadout resolution. */
 /** Reports the installed bucket definition relation used by loadout resolution. */
 void report_bucket_equipment_mapping(std::size_t mappedSlots) noexcept {
 void report_bucket_equipment_mapping(std::size_t mappedSlots) noexcept {
     std::array<char, 128> line{};
     std::array<char, 128> line{};

+ 147 - 0
Sunrise/src/client/content/items/packages/package_catalyst_condition_reader.cpp

@@ -0,0 +1,147 @@
+#include <algorithm>
+#include <array>
+#include <cstring>
+#include <limits>
+
+#include "../../../../middleware/content/packages/tables/definition_index_table.h"
+#include "package_socket_plug_build.h"
+
+namespace sunrise::client::content::items::packages {
+namespace {
+
+/** Native postfix opcodes for flag, value, literal, and greater-than-or-equal. */
+constexpr std::uint32_t kFlagOpcode = 1;
+constexpr std::uint32_t kValueOpcode = 10;
+constexpr std::uint32_t kLiteralOpcode = 11;
+constexpr std::uint32_t kGreaterEqualOpcode = 14;
+/** Each postfix token is an opcode and one 32-bit operand. */
+constexpr std::size_t kExpressionTokenSize = 8;
+constexpr std::size_t kValueExpressionTokenCount = 3;
+
+/** Adds one unique positive flag term to a bounded condition. */
+[[nodiscard]] bool append_flag(catalysts::CompletionRequirements& output,
+                               std::uint16_t definitionIndex) noexcept {
+    const auto end = output.flags.begin() + output.flagCount;
+    if (std::find(output.flags.begin(), end, definitionIndex) != end) {
+        return true;
+    }
+    if (output.flagCount >= output.flags.size()) {
+        return false;
+    }
+    output.flags[output.flagCount++] = definitionIndex;
+    return true;
+}
+
+/** Adds or raises one unique signed-value minimum in a bounded condition. */
+[[nodiscard]] bool upsert_value(catalysts::CompletionRequirements& output,
+                                std::uint16_t index,
+                                std::int32_t minimum) noexcept {
+    for (std::size_t row = 0; row < output.valueCount; ++row) {
+        if (output.values[row].index == index) {
+            output.values[row].minimum = (std::max)(output.values[row].minimum, minimum);
+            return true;
+        }
+    }
+    if (output.valueCount >= output.values.size()) {
+        return false;
+    }
+    output.values[output.valueCount++] = {index, minimum};
+    return true;
+}
+
+/** Marks a conflicting or over-capacity native rule and clears its partial operands. */
+void mark_ambiguous(catalysts::CompletionCondition& output) noexcept {
+    output.completion = {};
+    output.objectiveDefinitionIndex = catalysts::kUnavailableObjectiveIndex;
+    output.state = catalysts::CompletionConditionState::ambiguous;
+}
+
+} // namespace
+
+void read_catalyst_completion_condition(std::span<const std::byte> definition,
+                                        std::uint16_t itemDefinitionIndex,
+                                        catalysts::CompletionCondition& output) noexcept {
+    output = {};
+    output.itemDefinitionIndex = itemDefinitionIndex;
+    output.objectiveDefinitionIndex = catalysts::kUnavailableObjectiveIndex;
+    for (std::size_t descriptor = 0; descriptor + 2 * sizeof(std::uint64_t) <= definition.size();
+         descriptor += sizeof(std::uint64_t)) {
+        tables::Array expression{};
+        if (!tables::find_array_at(definition, descriptor, expression)) {
+            continue;
+        }
+        if (expression.elementClass == tables::kObjectiveReferenceArrayClass
+            && expression.count == 1 && expression.dataOffset <= definition.size()
+            && definition.size() - expression.dataOffset >= 2 * sizeof(std::uint32_t)) {
+            std::array<std::uint32_t, 2> reference{};
+            std::memcpy(reference.data(),
+                        definition.data() + expression.dataOffset,
+                        sizeof reference);
+            if (reference[0] < catalysts::kUnavailableObjectiveIndex
+                && reference[1] == tables::kObjectiveReferenceRowClass) {
+                const auto objective = static_cast<std::uint16_t>(reference[0]);
+                if (output.objectiveDefinitionIndex == catalysts::kUnavailableObjectiveIndex) {
+                    output.objectiveDefinitionIndex = objective;
+                } else if (output.objectiveDefinitionIndex != objective) {
+                    mark_ambiguous(output);
+                    return;
+                }
+            }
+            continue;
+        }
+        if (expression.elementClass != tables::kInvestmentExpressionRowClass
+            || expression.dataOffset > definition.size()
+            || expression.count
+                   > (definition.size() - expression.dataOffset) / kExpressionTokenSize) {
+            continue;
+        }
+        for (std::size_t token = 0; token < expression.count; ++token) {
+            std::array<std::uint32_t, 2> current{};
+            std::memcpy(current.data(),
+                        definition.data() + expression.dataOffset
+                            + token * kExpressionTokenSize,
+                        sizeof current);
+            if (current[0] == kFlagOpcode
+                && current[1] < state::build_data::items::kDefinitionCapacity
+                && !append_flag(output.completion, static_cast<std::uint16_t>(current[1]))) {
+                mark_ambiguous(output);
+                return;
+            }
+            if (current[0] != kValueOpcode
+                || token + kValueExpressionTokenCount > expression.count) {
+                continue;
+            }
+            std::array<std::uint32_t, kValueExpressionTokenCount * 2> tokens{};
+            std::memcpy(tokens.data(),
+                        definition.data() + expression.dataOffset
+                            + token * kExpressionTokenSize,
+                        sizeof tokens);
+            if (tokens[2] != kLiteralOpcode || tokens[4] != kGreaterEqualOpcode
+                || tokens[5] != UINT32_MAX
+                || tokens[1] >= catalysts::kUnavailableCompletionValueIndex || tokens[3] == 0
+                || tokens[3]
+                       > static_cast<std::uint32_t>(
+                           (std::numeric_limits<std::int32_t>::max)())) {
+                continue;
+            }
+            if (!upsert_value(output.completion,
+                              static_cast<std::uint16_t>(tokens[1]),
+                              static_cast<std::int32_t>(tokens[3]))) {
+                mark_ambiguous(output);
+                return;
+            }
+        }
+    }
+    std::sort(output.completion.flags.begin(),
+              output.completion.flags.begin() + output.completion.flagCount);
+    std::sort(output.completion.values.begin(),
+              output.completion.values.begin() + output.completion.valueCount,
+              [](const catalysts::CompletionValue& left,
+                 const catalysts::CompletionValue& right) { return left.index < right.index; });
+    if (output.completion.flagCount != 0 || output.completion.valueCount != 0
+        || output.objectiveDefinitionIndex != catalysts::kUnavailableObjectiveIndex) {
+        output.state = catalysts::CompletionConditionState::present;
+    }
+}
+
+} // namespace sunrise::client::content::items::packages

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

@@ -12,6 +12,7 @@
 #include "../../spawn_sets/spawn_set_build.h"
 #include "../../spawn_sets/spawn_set_build.h"
 #include "build.h"
 #include "build.h"
 #include "internal.h"
 #include "internal.h"
+#include "package_socket_plug_build.h"
 
 
 namespace sunrise::client::content::items::packages {
 namespace sunrise::client::content::items::packages {
 namespace {
 namespace {
@@ -31,7 +32,8 @@ namespace {
            && state::build_data::record_definitions_ready()
            && state::build_data::record_definitions_ready()
            && state::build_data::node_definitions_ready()
            && state::build_data::node_definitions_ready()
            && state::build_data::sobjects::count() != 0
            && state::build_data::sobjects::count() != 0
-           && state::build_data::investment_constants_ready();
+           && state::build_data::investment_constants_ready()
+           && state::build_data::exotic_catalysts_ready();
 }
 }
 
 
 } // namespace
 } // namespace
@@ -117,6 +119,22 @@ bool build() noexcept {
                     continue;
                     continue;
                 }
                 }
             }
             }
+            if (!state::build_data::exotic_catalysts_ready()) {
+                reason = "catalyst_gates";
+                if (!read_catalyst_acquisition_gates(source,
+                                                     storage.scratch,
+                                                     std::span<const std::byte>{storage.root},
+                                                     storage.child,
+                                                     storage.catalystAcquisitionGates)
+                    || !read_catalyst_objective_values(
+                        source,
+                        storage.scratch,
+                        std::span<const std::byte>{storage.root},
+                        storage.child,
+                        storage.catalystObjectiveValues)) {
+                    continue;
+                }
+            }
             reason = "buckets";
             reason = "buckets";
             if (!build_buckets(source, storage, std::span<const std::byte>{storage.root})) {
             if (!build_buckets(source, storage, std::span<const std::byte>{storage.root})) {
                 continue;
                 continue;

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

@@ -1,7 +1,7 @@
 #include <array>
 #include <array>
 #include <span>
 #include <span>
-#include <vector>
 
 
+#include "../../../../state/build_data/items/catalysts/exotic_catalyst_builder.h"
 #include "../../../../state/build_data/items/details/item_detail_catalog.h"
 #include "../../../../state/build_data/items/details/item_detail_catalog.h"
 #include "../../../../state/build_data/runtime.h"
 #include "../../../../state/build_data/runtime.h"
 #include "internal.h"
 #include "internal.h"
@@ -43,16 +43,28 @@ bool build_item_rows(const reader::Source& source,
     const bool needDefinitions = !state::build_data::item_definitions_ready();
     const bool needDefinitions = !state::build_data::item_definitions_ready();
     const bool needDetails = !state::build_data::configured_item_details_ready();
     const bool needDetails = !state::build_data::configured_item_details_ready();
     const bool needSocketPlugs = !state::build_data::socket_plug_rules_ready();
     const bool needSocketPlugs = !state::build_data::socket_plug_rules_ready();
+    const bool needCatalysts = !state::build_data::exotic_catalysts_ready();
     const bool needBuckets = !state::build_data::inventory_bucket_descriptors_ready();
     const bool needBuckets = !state::build_data::inventory_bucket_descriptors_ready();
-    const bool needDetailRows = needDetails || needSocketPlugs;
+    const bool retainDetails = needDetails || needCatalysts;
+    const bool needSocketRows = needSocketPlugs || needCatalysts;
+    const bool needDetailRows = needDetails || needSocketRows;
     // Bucket equipment slots are derived from this same complete item walk, so a partial retry
     // Bucket equipment slots are derived from this same complete item walk, so a partial retry
     // must still revisit the table even when definitions and detail domains already published.
     // must still revisit the table even when definitions and detail domains already published.
     const bool needRows = needDefinitions || needDetailRows || needBuckets;
     const bool needRows = needDefinitions || needDetailRows || needBuckets;
     bool published = !needRows;
     bool published = !needRows;
-    if (needDetails && storage.details.size() != kDetailCapacity) {
+    if (retainDetails && storage.details.size() != kDetailCapacity) {
         storage.details.assign(kDetailCapacity, build_details::Definition{});
         storage.details.assign(kDetailCapacity, build_details::Definition{});
     }
     }
-    const bool detailStorageReady = !needDetails || storage.details.size() == kDetailCapacity;
+    if (needCatalysts) {
+        storage.catalystCompletionConditions.assign(
+            static_cast<std::size_t>(table.count),
+            state::build_data::items::catalysts::CompletionCondition{});
+        for (std::size_t item = 0; item < storage.catalystCompletionConditions.size(); ++item) {
+            storage.catalystCompletionConditions[item].itemDefinitionIndex =
+                static_cast<std::uint16_t>(item);
+        }
+    }
+    const bool detailStorageReady = !retainDetails || storage.details.size() == kDetailCapacity;
     const std::span<const std::byte> container{storage.child};
     const std::span<const std::byte> container{storage.child};
     reason = "rows";
     reason = "rows";
     // The detail closure is gathered during this one walk. Collections can name any installed
     // The detail closure is gathered during this one walk. Collections can name any installed
@@ -90,7 +102,7 @@ bool build_item_rows(const reader::Source& source,
                                                  plugCategoryHash,
                                                  plugCategoryHash,
                                                  item.rollSetIndex,
                                                  item.rollSetIndex,
                                                  item.linkedPlugIndex};
                                                  item.linkedPlugIndex};
-        if (needSocketPlugs) {
+        if (needSocketRows) {
             storage.specialPlugCategories[item.definitionIndex] =
             storage.specialPlugCategories[item.definitionIndex] =
                 special_plug_category(plugCategoryHash);
                 special_plug_category(plugCategoryHash);
         }
         }
@@ -121,7 +133,7 @@ bool build_item_rows(const reader::Source& source,
     }
     }
     SocketPlugBuild socketPlugBuild;
     SocketPlugBuild socketPlugBuild;
     const bool socketStorageReady =
     const bool socketStorageReady =
-        !needSocketPlugs
+        !needSocketRows
         || socketPlugBuild.prepare(storage.specialPlugCategories,
         || socketPlugBuild.prepare(storage.specialPlugCategories,
                                    std::span(storage.rows).first(rowCount));
                                    std::span(storage.rows).first(rowCount));
     if (published && !socketStorageReady) {
     if (published && !socketStorageReady) {
@@ -143,10 +155,17 @@ bool build_item_rows(const reader::Source& source,
                 report_detail_failure(slot, storage.requestedDetailIndices[slot]);
                 report_detail_failure(slot, storage.requestedDetailIndices[slot]);
                 continue;
                 continue;
             }
             }
-            if (needDetails) {
+            if (retainDetails) {
                 storage.details[builtDetailCount++] = detail;
                 storage.details[builtDetailCount++] = detail;
             }
             }
-            if (needSocketPlugs) {
+            if (needCatalysts
+                && detail.definitionIndex < storage.catalystCompletionConditions.size()) {
+                read_catalyst_completion_condition(
+                    std::span<const std::byte>{storage.definition},
+                    detail.definitionIndex,
+                    storage.catalystCompletionConditions[detail.definitionIndex]);
+            }
+            if (needSocketRows) {
                 (void)socketPlugBuild.append(item,
                 (void)socketPlugBuild.append(item,
                                              std::span<const std::byte>{storage.definition},
                                              std::span<const std::byte>{storage.definition},
                                              std::span<const std::byte>{storage.plugSetTable},
                                              std::span<const std::byte>{storage.plugSetTable},
@@ -158,6 +177,30 @@ bool build_item_rows(const reader::Source& source,
                 std::span<build_details::Definition>{storage.details}.first(builtDetailCount));
                 std::span<build_details::Definition>{storage.details}.first(builtDetailCount));
             report_detail_count(detailCount, builtDetailCount);
             report_detail_count(detailCount, builtDetailCount);
         }
         }
+        std::array<state::build_data::items::catalysts::Definition,
+                   state::build_data::items::catalysts::kDefinitionCapacity>
+            catalystRows{};
+        std::size_t catalystCount = 0;
+        state::build_data::items::catalysts::Report catalystReport{};
+        const state::build_data::items::catalysts::Source catalystSource{
+            {},
+            std::span(storage.rows).first(rowCount),
+            std::span(storage.details).first(builtDetailCount),
+            socketPlugBuild.rules(),
+            socketPlugBuild.pools(),
+            socketPlugBuild.members(),
+            storage.catalystCompletionConditions,
+            storage.catalystAcquisitionGates,
+            storage.catalystObjectiveValues,
+        };
+        bool catalystBuilt = false;
+        if (published && needCatalysts) {
+            reason = "exotic_catalysts";
+            catalystBuilt = state::build_data::derive_exotic_catalysts(
+                catalystSource, catalystRows, catalystCount, catalystReport);
+            report_catalyst_catalog(catalystReport, catalystBuilt);
+            published = catalystBuilt;
+        }
         if (published && needSocketPlugs) {
         if (published && needSocketPlugs) {
             const std::size_t rules = socketPlugBuild.rule_count();
             const std::size_t rules = socketPlugBuild.rule_count();
             const std::size_t pools = socketPlugBuild.pool_count();
             const std::size_t pools = socketPlugBuild.pool_count();
@@ -169,6 +212,11 @@ bool build_item_rows(const reader::Source& source,
                 report_socket_plug_count(rules, pools, members, skipped);
                 report_socket_plug_count(rules, pools, members, skipped);
             }
             }
         }
         }
+        if (published && needCatalysts && catalystBuilt) {
+            reason = "exotic_catalysts";
+            published = state::build_data::publish_exotic_catalysts(
+                catalystSource, std::span(catalystRows).first(catalystCount));
+        }
     }
     }
     // Ability buckets read the socket entry list table again and depend on the detail domain, so
     // Ability buckets read the socket entry list table again and depend on the detail domain, so
     // they run last. The entry-bucket table never joins the on-disk cache, so a warm boot still
     // they run last. The entry-bucket table never joins the on-disk cache, so a warm boot still
@@ -201,6 +249,7 @@ bool build_item_rows(const reader::Source& source,
     return published && state::build_data::item_definitions_ready()
     return published && state::build_data::item_definitions_ready()
            && state::build_data::configured_item_details_ready()
            && state::build_data::configured_item_details_ready()
            && state::build_data::socket_plug_rules_ready()
            && state::build_data::socket_plug_rules_ready()
+           && state::build_data::exotic_catalysts_ready()
            && state::build_data::ability_buckets_ready();
            && state::build_data::ability_buckets_ready();
 }
 }
 
 

+ 125 - 9
Sunrise/src/client/content/items/packages/package_socket_plug_build.cpp

@@ -1,8 +1,10 @@
 #include "package_socket_plug_build.h"
 #include "package_socket_plug_build.h"
 
 
 #include <algorithm>
 #include <algorithm>
+#include <cstring>
 #include <limits>
 #include <limits>
 
 
+#include "../../../../middleware/content/packages/tables/definition_index_table.h"
 #include "../../../../state/build_data/runtime.h"
 #include "../../../../state/build_data/runtime.h"
 
 
 namespace sunrise::client::content::items::packages {
 namespace sunrise::client::content::items::packages {
@@ -36,6 +38,20 @@ constexpr std::uint16_t kTrackerSocketType = 518;
 /** FNV-1a constants make pool fingerprints stable and cheap. */
 /** FNV-1a constants make pool fingerprints stable and cheap. */
 constexpr std::uint64_t kHashOffsetBasis = 14695981039346656037ULL;
 constexpr std::uint64_t kHashOffsetBasis = 14695981039346656037ULL;
 constexpr std::uint64_t kHashPrime = 1099511628211ULL;
 constexpr std::uint64_t kHashPrime = 1099511628211ULL;
+/** One acquired-state rule starts with kind 1 and then its item-definition index. */
+constexpr std::uint32_t kAcquisitionRuleKind = 1;
+constexpr std::size_t kAcquisitionRuleSize = 8;
+
+/** Reads one trivially copied scalar from a bounded package blob. */
+template <typename Value>
+[[nodiscard]] bool
+read(std::span<const std::byte> blob, std::size_t offset, Value& value) noexcept {
+    if (offset > blob.size() || blob.size() - offset < sizeof value) {
+        return false;
+    }
+    std::memcpy(&value, blob.data() + offset, sizeof value);
+    return true;
+}
 
 
 /** Visitor adapter that appends one list member to a bounded lane candidate. */
 /** Visitor adapter that appends one list member to a bounded lane candidate. */
 struct VisitorContext {
 struct VisitorContext {
@@ -43,7 +59,11 @@ struct VisitorContext {
     std::size_t itemDefinitionCount{};
     std::size_t itemDefinitionCount{};
 };
 };
 
 
-/** @return Whether the package-provided member was accepted into bounded scratch. */
+/**
+ * @param opaque Visitor context supplied by the socket build.
+ * @param itemDefinitionIndex Package-provided plug index.
+ * @return True when the plug was accepted into bounded scratch.
+ */
 [[nodiscard]] bool visit_member(void* opaque, std::uint32_t itemDefinitionIndex) noexcept {
 [[nodiscard]] bool visit_member(void* opaque, std::uint32_t itemDefinitionIndex) noexcept {
     auto& context = *static_cast<VisitorContext*>(opaque);
     auto& context = *static_cast<VisitorContext*>(opaque);
     return context.build != nullptr
     return context.build != nullptr
@@ -52,6 +72,92 @@ struct VisitorContext {
 
 
 } // namespace
 } // namespace
 
 
+bool read_catalyst_acquisition_gates(const reader::Source& source,
+                                     reader::Scratch& scratch,
+                                     std::span<const std::byte> root,
+                                     std::vector<std::byte>& blob,
+                                     std::vector<catalysts::AcquisitionGate>& output) noexcept {
+    output.clear();
+    std::uint32_t tableTag = 0;
+    tables::Array table{};
+    if (!tables::slot_tag(root, tables::kSocketTypeTableSlot, tableTag) || tableTag == 0
+        || !reader::read_tag(source, scratch, tableTag, blob)
+        || !tables::find_array_at(blob, tables::kTableArrayDescriptor, table)
+        || table.elementClass != tables::kSocketTypeTableClass || table.count == 0
+        || table.count > (std::numeric_limits<std::uint16_t>::max)()) {
+        return false;
+    }
+    const std::uint64_t tableSize = table.count * tables::kSocketTypeRowStride;
+    if (tableSize > blob.size() || table.dataOffset > blob.size() - tableSize) {
+        return false;
+    }
+
+    output.resize(static_cast<std::size_t>(table.count));
+    const std::span<const std::byte> bytes{blob};
+    for (std::size_t index = 0; index < output.size(); ++index) {
+        catalysts::AcquisitionGate& gate = output[index];
+        gate.socketType = static_cast<std::uint16_t>(index);
+        const std::size_t row = table.dataOffset + index * tables::kSocketTypeRowStride;
+        tables::Array rules{};
+        if (!tables::find_array_at(bytes, row + tables::kSocketTypeAcquisitionDescriptor, rules)) {
+            continue;
+        }
+        if (rules.count != 1 || rules.elementClass != tables::kInvestmentExpressionRowClass
+            || rules.dataOffset > bytes.size()
+            || bytes.size() - rules.dataOffset < kAcquisitionRuleSize) {
+            gate.state = catalysts::AcquisitionState::ambiguous;
+            continue;
+        }
+        std::uint32_t kind = 0;
+        std::uint32_t definitionIndex = 0;
+        if (!read(bytes, rules.dataOffset, kind)
+            || !read(bytes, rules.dataOffset + sizeof kind, definitionIndex)
+            || kind != kAcquisitionRuleKind
+            || definitionIndex >= state::build_data::items::kDefinitionCapacity) {
+            gate.state = catalysts::AcquisitionState::ambiguous;
+            continue;
+        }
+        gate.definitionIndex = static_cast<std::uint16_t>(definitionIndex);
+        gate.state = catalysts::AcquisitionState::present;
+    }
+    return true;
+}
+
+bool read_catalyst_objective_values(const reader::Source& source,
+                                    reader::Scratch& scratch,
+                                    std::span<const std::byte> root,
+                                    std::vector<std::byte>& blob,
+                                    std::vector<std::int32_t>& output) noexcept {
+    output.clear();
+    std::uint32_t tableTag = 0;
+    std::uint32_t tableClass = 0;
+    tables::Array table{};
+    if (!tables::slot_tag(root, tables::kObjectiveTableSlot, tableTag) || tableTag == 0
+        || !reader::read_tag(source, scratch, tableTag, blob, tableClass)
+        || tableClass != tables::kObjectiveTableClass
+        || !tables::find_array_at(blob, tables::kTableArrayDescriptor, table)
+        || table.elementClass != tables::kObjectiveRowClass || table.count == 0
+        || table.count > catalysts::kUnavailableObjectiveIndex) {
+        return false;
+    }
+    const std::uint64_t tableSize = table.count * tables::kObjectiveRowStride;
+    if (tableSize > blob.size() || table.dataOffset > blob.size() - tableSize) {
+        return false;
+    }
+
+    output.resize(static_cast<std::size_t>(table.count));
+    const std::span<const std::byte> bytes{blob};
+    for (std::size_t index = 0; index < output.size(); ++index) {
+        const std::size_t offset = table.dataOffset + index * tables::kObjectiveRowStride
+                                   + tables::kObjectiveCompletionValueOffset;
+        if (!read(bytes, offset, output[index])) {
+            output.clear();
+            return false;
+        }
+    }
+    return true;
+}
+
 /** Returns the compact 1-based code of one native category-expansion family. */
 /** Returns the compact 1-based code of one native category-expansion family. */
 std::uint8_t special_plug_category(std::uint32_t categoryHash) noexcept {
 std::uint8_t special_plug_category(std::uint32_t categoryHash) noexcept {
     for (std::size_t index = 0; index < kExpandableCategories.size(); ++index) {
     for (std::size_t index = 0; index < kExpandableCategories.size(); ++index) {
@@ -282,15 +388,13 @@ bool SocketPlugBuild::append(const tables::items::Row& item,
     return complete;
     return complete;
 }
 }
 
 
-/** Publishes the bounded relation and releases all transient interning memory. */
+/** Publishes the bounded relation and retains its rows for dependent package builders. */
 bool SocketPlugBuild::publish() noexcept {
 bool SocketPlugBuild::publish() noexcept {
-    const bool published =
-        !rules_.empty() && !pools_.empty() && !members_.empty()
-        && state::build_data::publish_socket_plug_rules(std::span(rules_.data(), ruleCount_),
-                                                        std::span(pools_.data(), poolCount_),
-                                                        std::span(members_.data(), memberCount_));
-    release();
-    return published;
+    return !rules_.empty() && !pools_.empty() && !members_.empty()
+           && state::build_data::publish_socket_plug_rules(
+               std::span(rules_.data(), ruleCount_),
+               std::span(pools_.data(), poolCount_),
+               std::span(members_.data(), memberCount_));
 }
 }
 
 
 /** Reports how many lanes failed closed during extraction. */
 /** Reports how many lanes failed closed during extraction. */
@@ -310,6 +414,18 @@ std::size_t SocketPlugBuild::member_count() const noexcept {
     return memberCount_;
     return memberCount_;
 }
 }
 
 
+std::span<const socket_plugs::Rule> SocketPlugBuild::rules() const noexcept {
+    return std::span(rules_).first(ruleCount_);
+}
+
+std::span<const socket_plugs::Pool> SocketPlugBuild::pools() const noexcept {
+    return std::span(pools_).first(poolCount_);
+}
+
+std::span<const socket_plugs::Member> SocketPlugBuild::members() const noexcept {
+    return std::span(members_).first(memberCount_);
+}
+
 /** Drops all heap-backed extraction scratch and resets every count. */
 /** Drops all heap-backed extraction scratch and resets every count. */
 void SocketPlugBuild::release() noexcept {
 void SocketPlugBuild::release() noexcept {
     rules_.clear();
     rules_.clear();

+ 63 - 2
Sunrise/src/client/content/items/packages/package_socket_plug_build.h

@@ -6,14 +6,61 @@
 #include <span>
 #include <span>
 #include <vector>
 #include <vector>
 
 
+#include "../../../../middleware/content/packages/reader/reader.h"
 #include "../../../../middleware/content/packages/tables/items.h"
 #include "../../../../middleware/content/packages/tables/items.h"
+#include "../../../../state/build_data/items/catalysts/definition.h"
 #include "../../../../state/build_data/items/item_catalog.h"
 #include "../../../../state/build_data/items/item_catalog.h"
 #include "../../../../state/build_data/items/socket_plugs/definition.h"
 #include "../../../../state/build_data/items/socket_plugs/definition.h"
 
 
 namespace sunrise::client::content::items::packages {
 namespace sunrise::client::content::items::packages {
 
 
+namespace reader = middleware::content::packages::reader;
 namespace tables = middleware::content::packages::tables;
 namespace tables = middleware::content::packages::tables;
 namespace socket_plugs = state::build_data::items::socket_plugs;
 namespace socket_plugs = state::build_data::items::socket_plugs;
+namespace catalysts = state::build_data::items::catalysts;
+
+/**
+ * Reads the dense socket-type table and extracts each type's acquired-state gate.
+ * @param source Installed package source.
+ * @param scratch Shared package reader scratch.
+ * @param root Investment root bytes.
+ * @param blob Scratch storage for the socket-type table.
+ * @param output Receives one row per native socket type.
+ * @return True when the fixed table shape and every row extent are valid.
+ */
+[[nodiscard]] bool
+read_catalyst_acquisition_gates(const reader::Source& source,
+                                reader::Scratch& scratch,
+                                std::span<const std::byte> root,
+                                std::vector<std::byte>& blob,
+                                std::vector<catalysts::AcquisitionGate>& output) noexcept;
+
+/**
+ * Reads the dense objective table's build-defined completion values.
+ * @param source Installed package source.
+ * @param scratch Shared package reader scratch.
+ * @param root Investment root bytes.
+ * @param blob Scratch storage for the objective table.
+ * @param output Receives one completion value per native objective index.
+ * @return True when the table class, row class, count, and fixed rows are valid.
+ */
+[[nodiscard]] bool
+read_catalyst_objective_values(const reader::Source& source,
+                               reader::Scratch& scratch,
+                               std::span<const std::byte> root,
+                               std::vector<std::byte>& blob,
+                               std::vector<std::int32_t>& output) noexcept;
+
+/**
+ * Finds every positive flag and `value >= literal` term plus one objective reference.
+ * Duplicate terms are folded. Conflicting objectives or fixed-capacity overflow fail closed.
+ * @param definition Complete installed definition of the catalyst effect item.
+ * @param itemDefinitionIndex Native index of the catalyst effect item.
+ * @param output Receives the unique condition or its absent or ambiguous state.
+ */
+void read_catalyst_completion_condition(std::span<const std::byte> definition,
+                                        std::uint16_t itemDefinitionIndex,
+                                        catalysts::CompletionCondition& output) noexcept;
 
 
 /** Fixed-size, heap-backed interning state for one installed package pass. */
 /** Fixed-size, heap-backed interning state for one installed package pass. */
 class SocketPlugBuild final {
 class SocketPlugBuild final {
@@ -23,7 +70,12 @@ public:
     SocketPlugBuild(const SocketPlugBuild&) = delete;
     SocketPlugBuild(const SocketPlugBuild&) = delete;
     SocketPlugBuild& operator=(const SocketPlugBuild&) = delete;
     SocketPlugBuild& operator=(const SocketPlugBuild&) = delete;
 
 
-    /** Allocates bounded scratch and indexes the three native expandable plug categories. */
+    /**
+     * Allocates bounded scratch and indexes the three native expandable plug categories.
+     * @param specialCategories Category code for each native item index.
+     * @param itemDefinitions Complete installed item table.
+     * @return True when every bounded scratch bank is ready.
+     */
     [[nodiscard]] bool
     [[nodiscard]] bool
     prepare(std::span<const std::uint8_t> specialCategories,
     prepare(std::span<const std::uint8_t> specialCategories,
             std::span<const state::build_data::items::Definition> itemDefinitions) noexcept;
             std::span<const state::build_data::items::Definition> itemDefinitions) noexcept;
@@ -34,14 +86,23 @@ public:
                               std::span<const std::byte> plugSetTable,
                               std::span<const std::byte> plugSetTable,
                               std::size_t itemDefinitionCount) noexcept;
                               std::size_t itemDefinitionCount) noexcept;
 
 
-    /** Publishes the completed exact relation, then releases its transient scratch. */
+    /** Publishes the completed exact relation. The object retains source rows until destruction. */
     [[nodiscard]] bool publish() noexcept;
     [[nodiscard]] bool publish() noexcept;
 
 
     /** @return Socket lanes skipped because their package lists were malformed or over capacity. */
     /** @return Socket lanes skipped because their package lists were malformed or over capacity. */
     [[nodiscard]] std::size_t skipped() const noexcept;
     [[nodiscard]] std::size_t skipped() const noexcept;
+    /** @return Number of extracted socket rules. */
     [[nodiscard]] std::size_t rule_count() const noexcept;
     [[nodiscard]] std::size_t rule_count() const noexcept;
+    /** @return Number of interned socket pools. */
     [[nodiscard]] std::size_t pool_count() const noexcept;
     [[nodiscard]] std::size_t pool_count() const noexcept;
+    /** @return Number of members in all interned socket pools. */
     [[nodiscard]] std::size_t member_count() const noexcept;
     [[nodiscard]] std::size_t member_count() const noexcept;
+    /** @return Extracted socket rules, valid until this object changes or is destroyed. */
+    [[nodiscard]] std::span<const socket_plugs::Rule> rules() const noexcept;
+    /** @return Interned socket pools, valid until this object changes or is destroyed. */
+    [[nodiscard]] std::span<const socket_plugs::Pool> pools() const noexcept;
+    /** @return Interned socket members, valid until this object changes or is destroyed. */
+    [[nodiscard]] std::span<const socket_plugs::Member> members() const noexcept;
 
 
     /** Package-list visitor entry point; accepts only an in-range bounded native index. */
     /** Package-list visitor entry point; accepts only an in-range bounded native index. */
     [[nodiscard]] bool add(std::uint32_t itemDefinitionIndex,
     [[nodiscard]] bool add(std::uint32_t itemDefinitionIndex,

+ 30 - 0
Sunrise/src/client/content/scenarios/internal.h

@@ -58,6 +58,35 @@ struct RosterStorage {
     std::size_t cursor{};
     std::size_t cursor{};
     /** Tag reads spent in the current call, which is what bounds how long it blocks. */
     /** Tag reads spent in the current call, which is what bounds how long it blocks. */
     std::size_t reads{};
     std::size_t reads{};
+    /**
+     * Destination whose scenario is being walked, for diagnostics only.
+     * Several scenarios share one map and walk the same bubbles, so a per-object trace without
+     * this cannot say which destination reached an object and is easy to misread.
+     */
+    std::uint32_t destinationTag{};
+    /**
+     * Why the descriptor walk of the object being resolved fell short, counted per exit.
+     * A group is refused when its found slots miss its declared ones, and the summary says only
+     * how many were refused. These say which step lost them, which is what picks the fix.
+     * Cleared with the slot list, so every count belongs to one object.
+     */
+    struct WalkExits {
+        /** Handles enumerated across the object's per-bubble sub-blocks. */
+        std::size_t handles{};
+        /** Descriptor blobs reached, which is where a slot can still be recorded. */
+        std::size_t blobs{};
+        /** A bubble entry did not decode, which abandons every bubble after it. */
+        std::size_t bubbleAborts{};
+        /** A placed handle did not decode, which abandons the rest of the walk. */
+        std::size_t handleAborts{};
+        /** One handle's chain reached a tag that would not read. */
+        std::size_t readFailures{};
+        /** One handle's chain reached a class with no next tag. */
+        std::size_t chainEnds{};
+        /** One handle's chain was still unresolved at the depth limit. */
+        std::size_t depthExhausted{};
+    };
+    WalkExits exits{};
 };
 };
 
 
 /** Tag-read budget bounds one process-freeze interval and keeps worker shutdown responsive. */
 /** Tag-read budget bounds one process-freeze interval and keeps worker shutdown responsive. */
@@ -246,6 +275,7 @@ void publish_groups(Walk& walk, layouts::Definition& row) noexcept;
                                   reader::Scratch& scratch,
                                   reader::Scratch& scratch,
                                   RosterStorage& storage,
                                   RosterStorage& storage,
                                   std::uint32_t objectTag,
                                   std::uint32_t objectTag,
+                                  std::uint32_t sliceSetIndex,
                                   std::uint16_t& group) noexcept;
                                   std::uint16_t& group) noexcept;
 
 
 /**
 /**

+ 2 - 1
Sunrise/src/client/content/scenarios/scenario_roster_build.cpp

@@ -107,7 +107,7 @@ void note_candidate(Walk& walk,
                 return false;
                 return false;
             }
             }
             std::uint16_t group = kNotARosterGroup;
             std::uint16_t group = kNotARosterGroup;
-            if (!resolve_object(source, scratch, storage, objectTag, group)) {
+            if (!resolve_object(source, scratch, storage, objectTag, sliceSetIndex, group)) {
                 return false;
                 return false;
             }
             }
             if (group == kNotARosterGroup) {
             if (group == kNotARosterGroup) {
@@ -197,6 +197,7 @@ bool build_rosters(const reader::Source& source,
         }
         }
         layouts::Definition& row = rows[storage.cursor];
         layouts::Definition& row = rows[storage.cursor];
         ++storage.cursor;
         ++storage.cursor;
+        storage.destinationTag = row.tag;
         row.rosterGroupCount = 0;
         row.rosterGroupCount = 0;
         row.rosterGroups = {};
         row.rosterGroups = {};
         row.bubbleGroupCount = 0;
         row.bubbleGroupCount = 0;

+ 152 - 0
Sunrise/src/client/content/scenarios/scenario_roster_groups.cpp

@@ -1,3 +1,9 @@
+#include <array>
+#include <atomic>
+#include <cstdio>
+#include <span>
+
+#include "../../../core/logging/log.h"
 #include "../../../middleware/content/packages/tables/roster_intersection.h"
 #include "../../../middleware/content/packages/tables/roster_intersection.h"
 #include "../../../middleware/content/packages/tables/scenario_reader.h"
 #include "../../../middleware/content/packages/tables/scenario_reader.h"
 #include "../../../middleware/content/packages/tables/slot_descriptor_reader.h"
 #include "../../../middleware/content/packages/tables/slot_descriptor_reader.h"
@@ -34,6 +40,7 @@ struct ChainReadContext {
     auto& chain = *static_cast<ChainReadContext*>(context);
     auto& chain = *static_cast<ChainReadContext*>(context);
     ++chain.storage->reads;
     ++chain.storage->reads;
     if (!reader::read_tag(*chain.source, *chain.scratch, tag, chain.storage->chain, classId)) {
     if (!reader::read_tag(*chain.source, *chain.scratch, tag, chain.storage->chain, classId)) {
+        ++chain.storage->exits.readFailures;
         blob = {};
         blob = {};
         return false;
         return false;
     }
     }
@@ -83,13 +90,16 @@ struct ChainReadContext {
     for (std::uint64_t index = 0; index < bubbles.count; ++index) {
     for (std::uint64_t index = 0; index < bubbles.count; ++index) {
         tables::ObjectBubble bubble{};
         tables::ObjectBubble bubble{};
         if (!tables::object_bubble_at(objectBlob, bubbles, index, bubble)) {
         if (!tables::object_bubble_at(objectBlob, bubbles, index, bubble)) {
+            ++storage.exits.bubbleAborts;
             return false;
             return false;
         }
         }
         for (std::uint64_t slot = 0; slot < bubble.handleCount; ++slot) {
         for (std::uint64_t slot = 0; slot < bubble.handleCount; ++slot) {
             std::uint32_t handle = 0;
             std::uint32_t handle = 0;
             if (!tables::object_placed_handle_at(objectBlob, bubble, slot, handle)) {
             if (!tables::object_placed_handle_at(objectBlob, bubble, slot, handle)) {
+                ++storage.exits.handleAborts;
                 return false;
                 return false;
             }
             }
+            ++storage.exits.handles;
             if (!follow_handle(source, scratch, storage, handle, registryKey)) {
             if (!follow_handle(source, scratch, storage, handle, registryKey)) {
                 return false;
                 return false;
             }
             }
@@ -98,6 +108,136 @@ struct ChainReadContext {
     return true;
     return true;
 }
 }
 
 
+/**
+ * Group objects reported per run. The measured drop count is 59, so this shows every one and
+ * still bounds a content tree that drops far more.
+ */
+constexpr std::size_t kMaxUnresolvedReports = 128;
+/** Size of one line, set by its tag, key and the per-exit counts that follow them. */
+constexpr std::size_t kUnresolvedLineCapacity = 256;
+
+/** Lines already spent, so a long walk cannot flood the sink. */
+std::atomic_size_t g_unresolvedReports{0};
+
+/**
+ * Names one group object the descriptor walk could not fill.
+ * The domain summary counts these but names none, so a walk that drops most of what it finds
+ * reads the same as one that found little. The gap between declared and found is what says
+ * whether the chain stopped early or the classification refused what it reached.
+ * @param objectTag Tag of the object being resolved.
+ * @param registryKey Registry key the object declares.
+ * @param declaredSlotCount Slots the object's own slot array declares.
+ * @param storage Working storage holding what the walk recovered.
+ */
+void report_unresolved(std::uint32_t objectTag,
+                       std::uint32_t registryKey,
+                       std::uint64_t declaredSlotCount,
+                       const RosterStorage& storage) noexcept {
+    // One atomic claim per line, so a concurrent walk cannot reuse a budget slot.
+    if (g_unresolvedReports.fetch_add(1, std::memory_order_relaxed) >= kMaxUnresolvedReports) {
+        return;
+    }
+    std::array<char, kUnresolvedLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=build_data stage=roster result=unresolved tag=0x%08X "
+                                      "key=0x%08X declared=%llu found=%zu overflow=%u "
+                                      "handles=%zu blobs=%zu bubble_abort=%zu handle_abort=%zu "
+                                      "read_fail=%zu chain_end=%zu depth=%zu",
+                                      objectTag,
+                                      registryKey,
+                                      static_cast<unsigned long long>(declaredSlotCount),
+                                      storage.slotCount,
+                                      storage.slotsOverflowed ? 1U : 0U,
+                                      storage.exits.handles,
+                                      storage.exits.blobs,
+                                      storage.exits.bubbleAborts,
+                                      storage.exits.handleAborts,
+                                      storage.exits.readFailures,
+                                      storage.exits.chainEnds,
+                                      storage.exits.depthExhausted);
+    if (written > 0) {
+        core::log::write(core::log::Channel::state,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Objects named per run by the placement trace.
+ * The installed tree holds 5,991 placed objects and each is traced once, so this shows every one.
+ */
+constexpr std::size_t kMaxPlacementReports = 8192;
+/** Slot types listed per line. No installed object declares more than this many. */
+constexpr std::size_t kTracedSlotTypes = 24;
+/** Size of one line: the fixed fields plus up to `kTracedSlotTypes` short decimal numbers. */
+constexpr std::size_t kPlacementLineCapacity = 256;
+
+/** Lines already spent, so a full content walk cannot flood the sink. */
+std::atomic_size_t g_placementReports{0};
+
+/**
+ * Names one placed object and every slot type it declares, before any filter has judged it.
+ * `carries_roster_slot` admits an object only when it declares one of `kRosterSlotTypes`, and an
+ * object it refuses leaves no trace anywhere: it is not counted, not published, and not reported.
+ * So a bubble whose objects all declare some other type is indistinguishable from a bubble with no
+ * objects at all, which is exactly the ambiguity that has to be settled before that list is
+ * widened. Widening it blind is not safe — `kRosterKeyCapacity` overflow costs a destination every
+ * group it publishes today, so this reports what the filter sees rather than changing it.
+ * @param sliceSetIndex Slice set whose registry named this object.
+ * @param objectTag Tag of the placed object.
+ * @param object Whole placed-object bytes.
+ * @param admitted Whether `carries_roster_slot` accepted it.
+ */
+void report_placement(std::uint32_t destinationTag,
+                      std::uint32_t sliceSetIndex,
+                      std::uint32_t objectTag,
+                      std::span<const std::byte> object,
+                      bool admitted) noexcept {
+    if (!core::log::accepts(core::log::Channel::state, core::log::Level::debug)) {
+        return;
+    }
+    // One atomic claim per line, so a concurrent walk cannot reuse a budget slot.
+    if (g_placementReports.fetch_add(1, std::memory_order_relaxed) >= kMaxPlacementReports) {
+        return;
+    }
+    std::uint32_t key = 0;
+    (void)tables::object_key(object, key);
+    tables::Array slots{};
+    const bool hasSlots = tables::object_slots(object, slots);
+    std::array<char, kPlacementLineCapacity> line{};
+    int written = std::snprintf(line.data(),
+                                line.size(),
+                                "ev=build_data stage=placement dest=0x%08X slice=%u bubble=%u "
+                                "tag=0x%08X key=0x%08X admitted=%u slots=%llu types=",
+                                destinationTag,
+                                sliceSetIndex,
+                                sliceSetIndex / tables::kSliceSetIndexFactor,
+                                objectTag,
+                                key,
+                                admitted ? 1U : 0U,
+                                hasSlots ? static_cast<unsigned long long>(slots.count) : 0ULL);
+    if (written <= 0) {
+        return;
+    }
+    auto used = static_cast<std::size_t>(written);
+    const std::uint64_t listed =
+        hasSlots && slots.count < kTracedSlotTypes ? slots.count : kTracedSlotTypes;
+    for (std::uint64_t index = 0; hasSlots && index < listed && used < line.size(); ++index) {
+        tables::Slot slot{};
+        if (!tables::object_slot_at(object, slots, index, slot)) {
+            break;
+        }
+        written = std::snprintf(
+            line.data() + used, line.size() - used, index == 0 ? "%u" : ",%u", slot.type);
+        if (written <= 0) {
+            break;
+        }
+        used += static_cast<std::size_t>(written);
+    }
+    core::log::write(core::log::Channel::state, core::log::Level::debug, {line.data(), used});
+}
+
 /** @param storage Working storage. @param tag Object tag. @return Its memo slot, or capacity. */
 /** @param storage Working storage. @param tag Object tag. @return Its memo slot, or capacity. */
 [[nodiscard]] std::size_t memo_slot(const RosterStorage& storage, std::uint32_t tag) noexcept {
 [[nodiscard]] std::size_t memo_slot(const RosterStorage& storage, std::uint32_t tag) noexcept {
     std::size_t probe = tag % kObjectMemoCapacity;
     std::size_t probe = tag % kObjectMemoCapacity;
@@ -125,6 +265,7 @@ bool resolve_object(const reader::Source& source,
                     reader::Scratch& scratch,
                     reader::Scratch& scratch,
                     RosterStorage& storage,
                     RosterStorage& storage,
                     std::uint32_t objectTag,
                     std::uint32_t objectTag,
+                    std::uint32_t sliceSetIndex,
                     std::uint16_t& group) noexcept {
                     std::uint16_t& group) noexcept {
     group = kNotARosterGroup;
     group = kNotARosterGroup;
     const std::size_t slot = memo_slot(storage, objectTag);
     const std::size_t slot = memo_slot(storage, objectTag);
@@ -132,6 +273,9 @@ bool resolve_object(const reader::Source& source,
         return false;
         return false;
     }
     }
     if (storage.memo[slot].tag == objectTag) {
     if (storage.memo[slot].tag == objectTag) {
+        // The memo spans the whole pass, so an object first seen under another destination is
+        // answered from here and never re-traced. A destination's own trace is therefore its
+        // first sighting of each object, not every registry that names it.
         group = storage.memo[slot].group;
         group = storage.memo[slot].group;
         return true;
         return true;
     }
     }
@@ -142,6 +286,12 @@ bool resolve_object(const reader::Source& source,
         return true;
         return true;
     }
     }
 
 
+    report_placement(storage.destinationTag,
+                     sliceSetIndex,
+                     objectTag,
+                     storage.object,
+                     tables::carries_roster_slot(storage.object));
+
     layouts::RosterGroup candidate{};
     layouts::RosterGroup candidate{};
     tables::Array declared{};
     tables::Array declared{};
     if (!tables::object_key(storage.object, candidate.registryKey) || candidate.registryKey == 0
     if (!tables::object_key(storage.object, candidate.registryKey) || candidate.registryKey == 0
@@ -152,8 +302,10 @@ bool resolve_object(const reader::Source& source,
     }
     }
     storage.slotCount = 0;
     storage.slotCount = 0;
     storage.slotsOverflowed = false;
     storage.slotsOverflowed = false;
+    storage.exits = {};
     if (!collect_descriptors(source, scratch, storage, storage.object, candidate.registryKey)
     if (!collect_descriptors(source, scratch, storage, storage.object, candidate.registryKey)
         || !fill_slots(storage, declared.count, candidate)) {
         || !fill_slots(storage, declared.count, candidate)) {
+        report_unresolved(objectTag, candidate.registryKey, declared.count, storage);
         // A completed walk may prove that some declared slots have no descriptor. A failed walk
         // A completed walk may prove that some declared slots have no descriptor. A failed walk
         // cannot distinguish that absence from unread content, so it refuses the whole group.
         // cannot distinguish that absence from unread content, so it refuses the whole group.
         ++storage.unresolvedGroups;
         ++storage.unresolvedGroups;

+ 69 - 0
Sunrise/src/client/content/scenarios/scenario_roster_publish.cpp

@@ -1,5 +1,9 @@
 #include <algorithm>
 #include <algorithm>
+#include <array>
 #include <cstddef>
 #include <cstddef>
+#include <cstdio>
+
+#include "../../../core/logging/log.h"
 
 
 #include "../../../middleware/content/packages/tables/roster_intersection.h"
 #include "../../../middleware/content/packages/tables/roster_intersection.h"
 #include "internal.h"
 #include "internal.h"
@@ -9,6 +13,9 @@ namespace {
 
 
 namespace tables = middleware::content::packages::tables;
 namespace tables = middleware::content::packages::tables;
 
 
+/** Size of one publish line: the fixed fields plus the hex values that follow them. */
+constexpr std::size_t kPublishLineCapacity = 192;
+
 /**
 /**
  * Orders the safe groups the way the destination publishes them.
  * Orders the safe groups the way the destination publishes them.
  * A group that binds the player or reports the lifetime comes first, then one reached through the
  * A group that binds the player or reports the lifetime comes first, then one reached through the
@@ -99,6 +106,67 @@ void publish_per_bubble(Walk& walk, layouts::Definition& row) noexcept {
 
 
 } // namespace
 } // namespace
 
 
+/**
+ * Names every candidate and every intersection key one destination reached, and what became of it.
+ * A candidate that is admitted by the slot filter and then lost in the split leaves no trace: the
+ * row simply publishes fewer groups, which reads the same as a destination that never had them.
+ * raid_beanstalk admits objects in bubbles 8 through 13 and 15 but publishes per-bubble groups for
+ * only two of them, and nothing today says which step drops the rest.
+ * @param walk Accumulator for one destination, before the split consumes it.
+ * @param row Destination row being published into.
+ */
+void report_publish(const Walk& walk, const layouts::Definition& row) noexcept {
+    if (!core::log::accepts(core::log::Channel::state, core::log::Level::debug)) {
+        return;
+    }
+    const tables::RosterIntersection& seen = walk.intersection;
+    std::array<char, kPublishLineCapacity> line{};
+    int written = std::snprintf(line.data(),
+                                line.size(),
+                                "ev=build_data stage=publish tag=0x%08X keys=%zu candidates=%zu "
+                                "overflow=%u unresolved_set=%u observed=0x%llX top=%u bubble=%u",
+                                row.tag,
+                                seen.keyCount,
+                                walk.candidateCount,
+                                seen.overflowed ? 1U : 0U,
+                                seen.unresolvedSet ? 1U : 0U,
+                                static_cast<unsigned long long>(seen.observedSets),
+                                static_cast<unsigned>(row.rosterGroupCount),
+                                static_cast<unsigned>(row.bubbleGroupCount));
+    if (written > 0) {
+        core::log::write(core::log::Channel::state,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+    // One line per key, because the split is decided per key: a mask equal to `observed` is
+    // top-level, a partial mask is per-bubble, and zero is dropped.
+    for (std::size_t index = 0; index < seen.keyCount; ++index) {
+        const std::uint64_t mask = seen.masks[index];
+        const char* fate = mask == 0                 ? "none"
+                           : mask == seen.observedSets ? "all"
+                                                       : "partial";
+        // A key with no candidate cannot publish: the split matches candidates against keys.
+        bool paired = false;
+        for (std::size_t candidate = 0; candidate < walk.candidateCount; ++candidate) {
+            paired = paired || walk.candidates[candidate].key == seen.keys[index];
+        }
+        written = std::snprintf(line.data(),
+                                line.size(),
+                                "ev=build_data stage=publish_key tag=0x%08X key=0x%08X "
+                                "mask=0x%llX fate=%s paired=%u",
+                                row.tag,
+                                seen.keys[index],
+                                static_cast<unsigned long long>(mask),
+                                fate,
+                                paired ? 1U : 0U);
+        if (written > 0) {
+            core::log::write(core::log::Channel::state,
+                             core::log::Level::debug,
+                             {line.data(), static_cast<std::size_t>(written)});
+        }
+    }
+}
+
 /** Splits the candidates between the destination row's two lists. */
 /** Splits the candidates between the destination row's two lists. */
 void publish_groups(Walk& walk, layouts::Definition& row) noexcept {
 void publish_groups(Walk& walk, layouts::Definition& row) noexcept {
     row.rosterGroupCount = 0;
     row.rosterGroupCount = 0;
@@ -110,6 +178,7 @@ void publish_groups(Walk& walk, layouts::Definition& row) noexcept {
     // The per-bubble half is independent of the top-level one: its keys register through the
     // The per-bubble half is independent of the top-level one: its keys register through the
     // delta's own field 1, and a destination may reach one half and not the other.
     // delta's own field 1, and a destination may reach one half and not the other.
     publish_per_bubble(walk, row);
     publish_per_bubble(walk, row);
+    report_publish(walk, row);
 }
 }
 
 
 } // namespace sunrise::client::content::scenarios
 } // namespace sunrise::client::content::scenarios

+ 1119 - 0
Sunrise/src/client/diagnostics/entity_create_probe.cpp

@@ -0,0 +1,1119 @@
+#include "entity_create_probe.h"
+
+#include <Windows.h>
+#include <intrin.h>
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <span>
+#include <string_view>
+
+#include "../../core/logging/log.h"
+#include "../hooking/detour.h"
+#include "../patterns/image_scan.h"
+#include "../patterns/signature_text.h"
+
+namespace sunrise::client::diagnostics {
+namespace {
+
+namespace patterns = client::patterns;
+namespace detour = client::hooking::detour;
+
+/**
+ * The index allocator the entity creator calls first.
+ * Recovered from the mapped-image dump. Its body is unmistakable: it stores -1 into the caller's
+ * out-parameter, then asks a pool at `+0xC118` sized `0x2000` for a free index. The frame size is
+ * wildcarded so the match carries no position-dependent byte.
+ */
+constexpr std::string_view kIndexAllocatorText =
+    "48 89 5C 24 08 48 89 74 24 10 57 48 83 EC ? 48 8B DA C7 02 FF FF FF FF 48 8B F9 "
+    "BA 00 20 00 00";
+/** Compiled pattern bytes of the signature text above. */
+constexpr auto kIndexAllocator =
+    patterns::signature<patterns::signature_length(kIndexAllocatorText)>(kIndexAllocatorText);
+
+/** The allocator answers this in its out-parameter when it has no index to give. */
+constexpr std::int32_t kNoIndex = -1;
+/**
+ * Byte offset of the free-slot bitmap inside the manager the allocator is handed.
+ * Read out of the allocator's body: it calls the bitmap search with `rcx = manager + 0xC118` and
+ * a width of `0x2000`, then clears the bit it was given. A set bit is therefore a FREE slot, and
+ * the search answers -1 only when every word is zero.
+ */
+constexpr std::size_t kFreeBitmapOffset = 0xC118;
+/** Slots the bitmap covers, from the width the allocator passes. */
+constexpr std::size_t kFreeBitmapBits = 0x2000;
+/** Words in that bitmap. */
+constexpr std::size_t kFreeBitmapWords = kFreeBitmapBits / 32;
+
+/**
+ * Counts the free slots the manager currently holds.
+ * The exhaustion line alone cannot separate "the host never gave the client any slots" from
+ * "the client used everything it was given", and those need opposite fixes.
+ * @param pool Manager the allocator was handed.
+ * @return Set bits in its free bitmap, or -1 when the bitmap cannot be read.
+ */
+[[nodiscard]] std::int64_t free_slot_count(const void* pool) noexcept {
+    if (pool == nullptr) {
+        return -1;
+    }
+    std::int64_t free = 0;
+    __try {
+        const auto* words = reinterpret_cast<const std::uint32_t*>(
+            static_cast<const std::byte*>(pool) + kFreeBitmapOffset);
+        for (std::size_t word = 0; word < kFreeBitmapWords; ++word) {
+            free += static_cast<std::int64_t>(__popcnt(words[word]));
+        }
+    } __except (EXCEPTION_EXECUTE_HANDLER) {
+        return -1;
+    }
+    return free;
+}
+/** Outcomes reported per run, so a per-frame failure cannot fill the log. */
+constexpr LONG kMaxReports = 200;
+/**
+ * Stack frames captured above this probe on each allocation.
+ * The allocator itself is generic — one function serves every entity in the game — so its own
+ * address says nothing about what is being built. The callers above it are what differ, and six
+ * frames is enough to separate "the world is placing an object" from "a weapon spawned a
+ * projectile" without unwinding the whole fiber stack.
+ */
+constexpr ULONG kTraceFrames = 6;
+/**
+ * Allocation traces per run.
+ * A raid load builds a few hundred entities, so this holds several bubble loads while still
+ * bounding what a long firefight can write.
+ */
+constexpr LONG kMaxTraces = 4096;
+/** Traces already spent. */
+volatile LONG g_traces{};
+/**
+ * Image offset of the pointer to the game's entity record table.
+ * Recovered from the creation path itself, which indexes it as `base + (handle & 0x1FFF) * stride`
+ * at `0x4D71F7`: `imul ebx, [rip -> 0x1F93430]` then `add rbx, [rip -> 0x1F93428]`. The mask is the
+ * same 13 bits the allocator's bitmap covers, so a record addresses exactly one allocated index.
+ */
+constexpr std::uintptr_t kEntityTableBaseRva = 0x1F93428;
+/** Image offset of the record stride that pairs with the table above. */
+constexpr std::uintptr_t kEntityTableStrideRva = 0x1F93430;
+/** Stride the dump reports. Checked at runtime, because a wrong one would read foreign memory. */
+constexpr std::uint32_t kExpectedRecordStride = 224;
+/** Bytes of each record dumped. The whole record, so the type field can be found by comparison. */
+constexpr std::size_t kRecordDumpBytes = kExpectedRecordStride;
+/** Records dumped per run, bounded so a long session cannot fill the sink. */
+constexpr LONG kMaxRecords = 512;
+/** Records already dumped. */
+volatile LONG g_records{};
+/**
+ * Record class every live entity carries at `+0x64`.
+ * Constant across all 57 records of a run, so it marks a slot the game has actually built rather
+ * than one holding whatever the last entity left behind.
+ */
+constexpr std::uint32_t kRecordClass = 0x80809783;
+/** Offset of the record class within a record. */
+constexpr std::size_t kRecordClassOffset = 0x64;
+/** Offset of the object's definition hash. Varies per object kind; `0xFFFFFFFF` where absent. */
+constexpr std::size_t kRecordDefinitionOffset = 0x88;
+/** Offset of the instance ordinal that counts copies of one definition. */
+constexpr std::size_t kRecordOrdinalOffset = 0x8C;
+/** Offset of the transform block, which is still unset when a record is first dumped. */
+constexpr std::size_t kRecordTransformOffset = 0xA0;
+/** Dwords of the transform block reported, covering the orientation and position quads. */
+constexpr std::size_t kRecordTransformDwords = 8;
+/** Seconds between censuses. Short enough to catch a bubble soon after it settles. */
+constexpr DWORD kCensusIntervalMs = 15'000;
+/**
+ * Most recent manager the allocator was handed.
+ * The census needs the free bitmap to tell a live record from one an entity left behind, and the
+ * allocator is the only place the manager pointer is known.
+ */
+void* volatile g_lastPool{};
+/** Entries one census reports, so a fully populated table cannot fill the sink. */
+constexpr LONG kCensusEntryBudget = 2'048;
+/**
+ * Distinct record classes counted per census.
+ * The census filtered on one class, `kRecordClass`, and so never reported an index above ~1019.
+ * An interaction incident then named entity **3539** as its target while the player stood on the
+ * Wall of Wishes activation plate -- an object that works -- and the twenty panels that do not
+ * work sit at 749..768. Whatever separates them is not visible while the walk only ever admits
+ * one class, so every class is counted and sampled now.
+ */
+constexpr std::size_t kClassCapacity = 24;
+/**
+ * Records dumped per distinct class, so a large class cannot crowd out a small one.
+ * Set at 48 this hid the very thing it was built to find: one class holds every real record, so
+ * only indices 0..47 were ever dumped and the Wall of Wishes panels at 749..768 fell outside the
+ * log entirely. That absence then read as "the player never reached the wall", which was wrong.
+ * The share only needs to stop one class starving another, so it sits at the whole budget.
+ */
+constexpr LONG kPerClassDump = 2'048;
+/** Cleared to stop the census thread. */
+volatile LONG g_censusRunning{};
+/** Census thread handle. */
+HANDLE g_censusThread{};
+
+/**
+ * Index whose record has not been dumped yet.
+ * The record is empty when the allocator hands the index out — the creator fills it afterwards — so
+ * each index is read one allocation late, when whatever built it has finished.
+ */
+volatile LONG g_pendingIndex{-1};
+
+/**
+ * Dumps one entity record so the entity can be named rather than counted.
+ * Counting proved the pool works and says nothing about what is in it. The record is the only place
+ * the client keeps an entity's identity, and every entity in the run shares one creation path, so
+ * the bytes here are what separate a wall panel from a projectile.
+ * @param index Index whose record to read.
+ */
+void report_record(std::int32_t index) noexcept {
+    if (index < 0 || static_cast<std::size_t>(index) >= kFreeBitmapBits
+        || !core::log::accepts(core::log::Channel::client, core::log::Level::debug)
+        || InterlockedIncrement(&g_records) > kMaxRecords) {
+        return;
+    }
+    const auto base = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
+    if (base == 0) {
+        return;
+    }
+    std::array<char, core::log::kLineCapacity> line{};
+    int written = 0;
+    __try {
+        const auto table = *reinterpret_cast<const std::byte* const*>(base + kEntityTableBaseRva);
+        const auto stride = *reinterpret_cast<const std::uint32_t*>(base + kEntityTableStrideRva);
+        // A stride that has moved means this offset no longer names the table, and reading through
+        // it would dump unrelated memory as if it were an entity.
+        if (table == nullptr || stride != kExpectedRecordStride) {
+            return;
+        }
+        const auto* const record = table + static_cast<std::size_t>(index) * stride;
+        written = std::snprintf(line.data(),
+                                line.size(),
+                                "ev=entity_create stage=record idx=%d hex=",
+                                static_cast<int>(index));
+        for (std::size_t offset = 0; offset < kRecordDumpBytes && written > 0
+                                     && static_cast<std::size_t>(written) + 3 < line.size();
+             ++offset) {
+            const int more = std::snprintf(line.data() + written,
+                                           line.size() - static_cast<std::size_t>(written),
+                                           "%02X",
+                                           std::to_integer<unsigned char>(record[offset]));
+            if (more <= 0) {
+                break;
+            }
+            written += more;
+        }
+    } __except (EXCEPTION_EXECUTE_HANDLER) {
+        return;
+    }
+    if (written <= 0) {
+        return;
+    }
+    const auto length = static_cast<std::size_t>(written) < line.size()
+                            ? static_cast<std::size_t>(written)
+                            : line.size() - 1;
+    core::log::write(core::log::Channel::client, core::log::Level::debug, {line.data(), length});
+}
+/** Resolved `RtlCaptureStackBackTrace`, or null when ntdll would not give it up. */
+USHORT(NTAPI* g_captureBacktrace)(ULONG, ULONG, PVOID*, PULONG){};
+/** Allocations between pool samples. Frequent enough to shape the drain, rare enough to be free. */
+constexpr LONG kSampleInterval = 16;
+/** Bytes in the bitmap, from the width the client's own stocking path passes to its fill. */
+constexpr std::size_t kFreeBitmapBytes = kFreeBitmapBits / 8;
+/**
+ * High slots the host keeps for its own entities and never leases to the client.
+ * The join grant is `kSlotCount - kDefaultServerReserve` = 7936, so the top 256 indices are the
+ * host's. The client's own initialiser frees the whole bitmap because in its intended world it
+ * owns every slot; here it does not, and handing it the reserve would let it allocate an index
+ * the host also considers its own.
+ */
+constexpr std::size_t kServerReserveSlots = 256;
+/** Bytes of the bitmap that stay clear, covering the reserve at the top of the index space. */
+constexpr std::size_t kReserveBytes = kServerReserveSlots / 8;
+/** Bytes of the bitmap that are freed to the client. */
+constexpr std::size_t kClientBytes = kFreeBitmapBytes - kReserveBytes;
+/** Words of the bitmap covering the client's half. The split lands on a word boundary. */
+constexpr std::size_t kClientWords = kClientBytes / sizeof(std::uint32_t);
+static_assert(kClientBytes % sizeof(std::uint32_t) == 0,
+              "the client half must end on a word so a refill never touches the reserve");
+/** Bits per bitmap word. */
+constexpr std::size_t kBitsPerWord = 32;
+/**
+ * Address span treated as belonging to the game's image.
+ * The dump reports an image size of 0x8A5EA00, so this clears it with room for a larger build while
+ * still rejecting a frame that landed in Sunrise's own module or on a foreign allocation.
+ */
+constexpr std::uintptr_t kImageSpan = 0x10000000;
+
+/**
+ * The allocator's real shape, read from its body rather than guessed.
+ * It uses exactly two arguments: `rcx` is the manager whose free-slot bitmap sits at `+0xC118`,
+ * and `rdx` is the out-parameter it fills with the allocated index. It returns `rdx` unchanged.
+ */
+using IndexAllocator = void*(__fastcall*)(void*, std::int32_t*) noexcept;
+
+detour::Handle g_allocator{};
+volatile LONG g_reports{};
+/** Successful allocations seen, used only to space the samples. */
+volatile LONG g_allocations{};
+/**
+ * One manager's record of the indices this probe has watched the allocator hand out.
+ *
+ * A blanket `memset(bitmap, 0xFF, ...)` is what made the very first stocking work and what made
+ * every later one lethal. It frees index 0 upward, and by the time a pool has drained, index 0
+ * belongs to a live entity. The allocator picks the lowest set bit, so the next creation lands on
+ * top of a live entity and the world stops being a consistent list of them. That is the crash on
+ * respawn, the crash on Worldline Zero's ability, and the mainloop stall that ends a Shuro Chi run
+ * a few seconds after the room loads.
+ *
+ * Keeping the set of indices already handed out turns the refill from "free everything" into
+ * "free what was never taken", which is the only form of it that is safe to run on a live pool.
+ */
+struct PoolRecord {
+    /** Manager this record belongs to, or null while the slot is unused. */
+    void* pool;
+    /** Set bit per index the allocator gave out and the client has not since handed back. */
+    std::array<volatile LONG, kFreeBitmapWords> live;
+    /** Whether this pool has been refilled at least once. */
+    volatile LONG stocked;
+};
+
+/** Managers tracked at once. A world change builds a new one, so several are live per run. */
+constexpr std::size_t kTrackedPoolCapacity = 16;
+/** Per-manager occupancy records, claimed on first sight. */
+std::array<PoolRecord, kTrackedPoolCapacity> g_pools{};
+
+/**
+ * Finds the record for one manager, claiming a free slot on first sight.
+ * @param pool Manager the allocator was handed.
+ * @return Its record, or null when the table is full.
+ */
+[[nodiscard]] PoolRecord* find_pool(void* pool) noexcept {
+    for (auto& record : g_pools) {
+        if (record.pool == pool) {
+            return &record;
+        }
+    }
+    for (auto& record : g_pools) {
+        auto* const slot = reinterpret_cast<void* volatile*>(&record.pool);
+        if (InterlockedCompareExchangePointer(slot, pool, nullptr) == nullptr
+            || record.pool == pool) {
+            return &record;
+        }
+    }
+    // Past capacity nothing is tracked, so nothing is refilled either. A missed refill costs this
+    // world's entities; an untracked one corrupts a live pool.
+    return nullptr;
+}
+
+/**
+ * Records that one index is now owned by an entity.
+ * @param record Manager record, or null when the manager is untracked.
+ * @param index Index the allocator produced.
+ */
+void mark_live(PoolRecord* record, std::int32_t index) noexcept {
+    if (record == nullptr || index < 0 || static_cast<std::size_t>(index) >= kFreeBitmapBits) {
+        return;
+    }
+    const auto slot = static_cast<std::size_t>(index);
+    (void)InterlockedOr(&record->live[slot / kBitsPerWord],
+                        static_cast<LONG>(1u << (slot % kBitsPerWord)));
+}
+/** Off leaves the probe reporting only, which is what it did before it could write. */
+bool g_stockUnstockedPool{};
+/** Refill a drained pool as well as an unstocked one. Safe now that the refill spares live slots. */
+bool g_restockAlways{};
+
+/**
+ * Reports one probe outcome, up to the per-run budget.
+ * @param stage Which half answered.
+ * @param outcome What it answered.
+ * @param detail Free slots left in the pool, or -1 when the bitmap could not be read.
+ */
+void report_pair(const char* stage,
+                 const char* outcome,
+                 std::int64_t detail,
+                 std::int64_t allocations) noexcept {
+    if (!core::log::accepts(core::log::Channel::client, core::log::Level::debug)
+        || InterlockedIncrement(&g_reports) > kMaxReports) {
+        return;
+    }
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=entity_create stage=%s result=%s free=%lld allocs=%lld",
+                                      stage,
+                                      outcome,
+                                      static_cast<long long>(detail),
+                                      static_cast<long long>(allocations));
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Reports one refill, naming how many slots it actually handed back.
+ * @param outcome Whether the pool answered after the refill.
+ * @param free Free slots the bitmap holds now.
+ * @param freed Slots this refill put back.
+ * @param allocations Successful allocations seen so far.
+ */
+void report_stock(const char* outcome,
+                  std::int64_t free,
+                  std::int64_t freed,
+                  std::int64_t allocations) noexcept {
+    if (!core::log::accepts(core::log::Channel::client, core::log::Level::debug)
+        || InterlockedIncrement(&g_reports) > kMaxReports) {
+        return;
+    }
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written =
+        std::snprintf(line.data(),
+                      line.size(),
+                      "ev=entity_create stage=allocate result=%s free=%lld freed=%lld allocs=%lld",
+                      outcome,
+                      static_cast<long long>(free),
+                      static_cast<long long>(freed),
+                      static_cast<long long>(allocations));
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Names one allocation and the call sites that asked for it.
+ *
+ * Counting allocations proved the pool works; it cannot say what is being built, and that is the
+ * question a missing Wall of Wishes actually poses. Its panels are one repeated object, so a burst
+ * of identical traces landing on consecutive indices as a bubble loads is the wall being created,
+ * and the absence of such a burst is the wall never being asked for. Those two need opposite fixes.
+ *
+ * Addresses are image-relative because the game is rebased every run; an RVA maps straight into the
+ * mapped-image dump, where file offset equals RVA.
+ * @param pool Manager the index came from, so per-type managers would show as distinct pointers.
+ * @param index Index the allocator produced.
+ * @param sequence Allocation ordinal within the run.
+ */
+void report_allocation(const void* pool, std::int32_t index, LONG sequence) noexcept {
+    if (!core::log::accepts(core::log::Channel::client, core::log::Level::debug)
+        || InterlockedIncrement(&g_traces) > kMaxTraces) {
+        return;
+    }
+    const auto base = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
+    std::array<char, core::log::kLineCapacity> line{};
+    int written = std::snprintf(line.data(),
+                               line.size(),
+                               "ev=entity_create stage=alloc n=%ld idx=%d pool=0x%llX sites=",
+                               static_cast<long>(sequence),
+                               static_cast<int>(index),
+                               static_cast<unsigned long long>(reinterpret_cast<std::uintptr_t>(pool)));
+    if (written <= 0) {
+        return;
+    }
+    std::array<void*, kTraceFrames> frames{};
+    // Frame 0 is this probe, which is never interesting, so the capture starts one above it.
+    const USHORT captured = g_captureBacktrace == nullptr
+                                ? 0
+                                : g_captureBacktrace(1, kTraceFrames, frames.data(), nullptr);
+    for (USHORT frame = 0; frame < captured && written > 0
+                           && static_cast<std::size_t>(written) < line.size();
+         ++frame) {
+        const auto site = reinterpret_cast<std::uintptr_t>(frames[frame]);
+        // A frame inside Sunrise's own module is noise here; only the game's code is addressable
+        // in the dump, so anything outside it is printed as a gap rather than a misleading offset.
+        const bool inImage = base != 0 && site >= base && (site - base) < kImageSpan;
+        const int more =
+            std::snprintf(line.data() + written,
+                          line.size() - static_cast<std::size_t>(written),
+                          inImage ? "%s0x%llX" : "%s-",
+                          frame == 0 ? "" : ",",
+                          static_cast<unsigned long long>(inImage ? site - base : 0));
+        if (more <= 0) {
+            break;
+        }
+        written += more;
+    }
+    const auto length = static_cast<std::size_t>(written) < line.size()
+                            ? static_cast<std::size_t>(written)
+                            : line.size() - 1;
+    core::log::write(core::log::Channel::client, core::log::Level::debug, {line.data(), length});
+}
+
+void report(const char* stage, const char* outcome, std::int64_t detail) noexcept {
+    if (!core::log::accepts(core::log::Channel::client, core::log::Level::debug)
+        || InterlockedIncrement(&g_reports) > kMaxReports) {
+        return;
+    }
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=entity_create stage=%s result=%s free=%lld",
+                                      stage,
+                                      outcome,
+                                      static_cast<long long>(detail));
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Mirrors the index allocator and reports whether it produced an index.
+ * The out-parameter is the answer: the original writes -1 into it before doing anything, and
+ * overwrites it only on success.
+ */
+/**
+ * Reads the game's entity record table, or reports that it cannot be addressed.
+ * @param table Receives the table base.
+ * @param stride Receives the record stride.
+ * @return True when both were read and the stride still matches this build.
+ */
+[[nodiscard]] bool entity_table(const std::byte*& table, std::uint32_t& stride) noexcept {
+    const auto base = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
+    if (base == 0) {
+        return false;
+    }
+    __try {
+        table = *reinterpret_cast<const std::byte* const*>(base + kEntityTableBaseRva);
+        stride = *reinterpret_cast<const std::uint32_t*>(base + kEntityTableStrideRva);
+    } __except (EXCEPTION_EXECUTE_HANDLER) {
+        return false;
+    }
+    return table != nullptr && stride == kExpectedRecordStride;
+}
+
+/**
+ * Reports which of one word's 32 indices already hold an entity.
+ *
+ * The probe's own record of handed-out indices covers only what came through the hooked allocator,
+ * and a census measured that as 58 of 830 — the world's placed objects reach the table by some
+ * other path entirely. Trusting that record alone therefore freed 7936 slots while 42 entities
+ * were sitting in them, and the client then allocated straight over the top. The game's own record
+ * table is the authority on which slots are taken, so occupancy is read from there instead.
+ * @param table Entity record table base.
+ * @param stride Record stride.
+ * @param word Word of the free bitmap being refilled.
+ * @return Set bit per index in that word whose record is live.
+ */
+[[nodiscard]] LONG occupied_mask(const std::byte* table, std::uint32_t stride, std::size_t word) noexcept {
+    std::uint32_t mask = 0;
+    for (std::size_t bit = 0; bit < kBitsPerWord; ++bit) {
+        const std::size_t index = word * kBitsPerWord + bit;
+        __try {
+            if (*reinterpret_cast<const std::uint32_t*>(table + index * stride
+                                                        + kRecordClassOffset)
+                == kRecordClass) {
+                mask |= 1u << bit;
+            }
+        } __except (EXCEPTION_EXECUTE_HANDLER) {
+            // An unreadable record is treated as taken, which costs a slot rather than an entity.
+            mask |= 1u << bit;
+        }
+    }
+    return static_cast<LONG>(mask);
+}
+
+/**
+ * Frees every client slot that no entity holds, leaving the ones that do alone.
+ *
+ * The client's own initialiser at `0x7FF71DDADB20` fills this bitmap with `0xFF` — every slot free
+ * — but only when a role global reads zero; here it reads 3, so the fill never runs and the bitmap
+ * is all-zero from the first frame. Every entity creation then fails, which is why no enemy, plate,
+ * door or banner ever appeared and why an encounter bubble kicked to orbit. Writing those bytes
+ * ourselves is right exactly once, on a pool that is still empty. On a pool that has drained it is
+ * catastrophic, because the slots the client is using read as clear too and become free again.
+ *
+ * So the refill is driven by `record->live` instead of by a constant. A slot is freed only when the
+ * bitmap says it is taken AND this probe never watched the allocator hand it out. Two passes,
+ * because another thread may claim a slot while the first one runs: the second re-clears anything
+ * that became live in between, so no index is ever offered twice.
+ * @param pool Manager the allocator was handed.
+ * @param record Occupancy record for that manager.
+ * @param failure Receives a Windows error, or 1 for a null pool and 2 for a faulting write.
+ * @return Slots freed, or -1 when the bitmap could not be written.
+ */
+[[nodiscard]] std::int64_t stock_pool(void* pool,
+                                      PoolRecord* record,
+                                      std::uint32_t& failure) noexcept {
+    failure = 0;
+    if (pool == nullptr || record == nullptr) {
+        failure = 1;
+        return -1;
+    }
+    auto* const bitmap = static_cast<std::byte*>(pool) + kFreeBitmapOffset;
+    // The bitmap sits in the game's own allocation, so it carries whatever protection that
+    // allocation was given. Reading it worked, which does not prove it is writable.
+    DWORD previous = 0;
+    if (VirtualProtect(bitmap, kFreeBitmapBytes, PAGE_READWRITE, &previous) == FALSE) {
+        failure = GetLastError();
+        return -1;
+    }
+    const std::byte* table = nullptr;
+    std::uint32_t stride = 0;
+    const bool hasTable = entity_table(table, stride);
+    std::int64_t freed = 0;
+    __try {
+        auto* const words = reinterpret_cast<volatile LONG*>(bitmap);
+        for (std::size_t word = 0; word < kClientWords; ++word) {
+            const LONG available = words[word];
+            // A slot the client has put back is no longer live, so it returns to the pool with the
+            // rest. Without this the record would only ever grow and the refill would fade to a
+            // no-op over a long session.
+            const LONG live = InterlockedAnd(&record->live[word], ~available) & ~available;
+            // The record table is the authority; the probe's own list is kept as a second opinion
+            // for anything created in the window before its record is filled in.
+            const LONG occupied = hasTable ? occupied_mask(table, stride, word) : 0;
+            const LONG missing =
+                static_cast<LONG>(~static_cast<std::uint32_t>(live | available | occupied));
+            if (missing != 0) {
+                (void)InterlockedOr(&words[word], missing);
+                freed += __popcnt(static_cast<unsigned int>(missing));
+            }
+        }
+        for (std::size_t word = 0; word < kClientWords; ++word) {
+            const LONG live = record->live[word];
+            if (live != 0) {
+                (void)InterlockedAnd(&words[word], ~live);
+            }
+        }
+        // The host's reserve at the top of the space stays clear so the client cannot allocate an
+        // index the host also considers its own.
+        for (std::size_t word = kClientWords; word < kFreeBitmapWords; ++word) {
+            (void)InterlockedAnd(&words[word], 0);
+        }
+    } __except (EXCEPTION_EXECUTE_HANDLER) {
+        failure = 2;
+        freed = -1;
+    }
+    DWORD restored = 0;
+    (void)VirtualProtect(bitmap, kFreeBitmapBytes, previous, &restored);
+    if (freed >= 0) {
+        (void)InterlockedExchange(&record->stocked, 1);
+        if (!hasTable) {
+            // Worth saying out loud: without the table the refill is back to trusting a list that
+            // has been measured as 7% complete, which is how live entities got overwritten.
+            report("allocate", "stock_without_table", freed);
+        }
+    }
+    return freed;
+}
+
+void* __fastcall allocator_body(void* pool, std::int32_t* index) noexcept {
+    const auto call = reinterpret_cast<IndexAllocator>(g_allocator.original);
+    if (call == nullptr) {
+        return nullptr;
+    }
+    void* result = call(pool, index);
+    InterlockedExchangePointer(&g_lastPool, pool);
+    PoolRecord* const record = find_pool(pool);
+    if (index == nullptr || *index != kNoIndex) {
+        // Every index the client takes is recorded before anything else can act on it, because a
+        // refill that does not know about it would offer the same index to a second entity.
+        if (index != nullptr) {
+            mark_live(record, *index);
+        }
+        // Sample the pool as it is spent. A steadily falling count means indices are allocated and
+        // never returned; a count that rises again means the client's own free path does work and
+        // the drain is simply the world being large. Those need opposite fixes, and the exhaustion
+        // line alone cannot tell them apart because it only ever fires at zero.
+        const LONG seen = InterlockedIncrement(&g_allocations);
+        report_allocation(pool, index == nullptr ? kNoIndex : *index, seen);
+        // One allocation behind, so the creator has had time to fill the record being read.
+        report_record(InterlockedExchange(&g_pendingIndex, index == nullptr ? -1 : *index));
+        if ((seen % kSampleInterval) == 0) {
+            // The count is reported beside the free total: if the pool empties while this barely
+            // moves, the bitmap is being cleared by something other than allocation.
+            report_pair("allocate", "sample", free_slot_count(pool), seen);
+        }
+        return result;
+    }
+    const std::int64_t free = free_slot_count(pool);
+    // A pool is refilled the first time it is seen empty, and again on every later drain when the
+    // knob is on. Both are safe now: the refill spares the indices already handed out, so it can
+    // no longer hand one index to two entities the way the old blanket fill did.
+    const bool allowed = g_stockUnstockedPool && record != nullptr
+                         && (g_restockAlways || record->stocked == 0);
+    if (free != 0 || !allowed) {
+        report_pair("allocate", "exhausted", free, g_allocations);
+        return result;
+    }
+    std::uint32_t failure = 0;
+    const std::int64_t freed = stock_pool(pool, record, failure);
+    if (freed < 0) {
+        // Naming the reason matters: a refused write and a faulting page need different fixes.
+        report("allocate", "stock_failed", static_cast<std::int64_t>(failure));
+        return result;
+    }
+    result = call(pool, index);
+    // `freed` is the number that matters. It should fall well short of the whole client half: the
+    // gap is the live entities the old fill used to trample.
+    report_stock(*index == kNoIndex ? "stocked_still_empty" : "stocked",
+                 free_slot_count(pool),
+                 freed,
+                 g_allocations);
+    if (index != nullptr) {
+        mark_live(record, *index);
+    }
+    return result;
+}
+
+/**
+ * Image offset of the pointer that reaches the game's entity pool descriptors.
+ * From the creation path at `0x4D71B5`: `mov rcx, [rip -> 0x2439C70]` then `add rdx, [rcx]` with
+ * the pool ordinal already shifted left by six, so descriptors are 64 bytes apart and their array
+ * base is one further dereference in. Within a descriptor, `+0x08` is the pool base and `+0x30`
+ * its element size -- `imul eax, [rdx + 0x30]` then `add rcx, [rdx + 8]`.
+ */
+constexpr std::uintptr_t kPoolDirectoryRva = 0x2439C70;
+/** Bytes between pool descriptors. */
+constexpr std::size_t kPoolDescriptorStride = 64;
+/** Descriptors probed. The ordinal comes from a handle's high bits, which are six wide. */
+constexpr std::size_t kPoolDescriptorCount = 64;
+/** Offset of a pool's base pointer within its descriptor. */
+constexpr std::size_t kPoolBaseOffset = 0x08;
+/** Offset of a pool's element size within its descriptor. */
+constexpr std::size_t kPoolElementSizeOffset = 0x30;
+/** An element size outside this is not a record, so the descriptor is not one either. */
+constexpr std::uint32_t kMaximumElementSize = 4096;
+/**
+ * Pools whose elements match the entity record stride, walked by the census.
+ * The directory holds TWO 224-byte pools, ordinals 33 and 35, at stable and distinct bases. The
+ * census has only ever read whichever one `kEntityTableBaseRva` points at, so half the records of
+ * this shape were never looked at -- and the activation plate that works, entity 3539, is not in
+ * the half that was.
+ */
+constexpr std::size_t kRecordPoolCapacity = 4;
+/** Bases of the record-shaped pools found in the directory. */
+std::array<const std::byte*, kRecordPoolCapacity> g_recordPools{};
+/** Ordinals of those pools, in the same order. */
+std::array<std::size_t, kRecordPoolCapacity> g_recordPoolOrdinals{};
+/** Record-shaped pools found. */
+std::size_t g_recordPoolCount{};
+
+/**
+ * Reports every entity pool the game keeps, not just the one the census walks.
+ *
+ * The class tally proved the 224-byte table holds exactly one class and 830 records, and that
+ * everything read above them is out-of-bounds noise. So the Wall of Wishes activation plate, which
+ * an interaction incident named as entity 3539 and which visibly works, cannot be in that table at
+ * all -- while the twenty panels that do not work are. Handles carry a pool ordinal in their high
+ * bits, which is why one table was never the whole picture.
+ */
+void report_pools() noexcept {
+    if (!core::log::accepts(core::log::Channel::client, core::log::Level::debug)) {
+        return;
+    }
+    const auto image = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
+    if (image == 0) {
+        return;
+    }
+    for (std::size_t ordinal = 0; ordinal < kPoolDescriptorCount; ++ordinal) {
+        const std::byte* poolBase = nullptr;
+        std::uint32_t elementSize = 0;
+        __try {
+            const auto* const directory =
+                *reinterpret_cast<const std::byte* const*>(image + kPoolDirectoryRva);
+            if (directory == nullptr) {
+                return;
+            }
+            const auto* const descriptors = *reinterpret_cast<const std::byte* const*>(directory);
+            if (descriptors == nullptr) {
+                return;
+            }
+            const auto* const descriptor = descriptors + ordinal * kPoolDescriptorStride;
+            poolBase = *reinterpret_cast<const std::byte* const*>(descriptor + kPoolBaseOffset);
+            elementSize =
+                *reinterpret_cast<const std::uint32_t*>(descriptor + kPoolElementSizeOffset);
+        } __except (EXCEPTION_EXECUTE_HANDLER) {
+            continue;
+        }
+        if (poolBase == nullptr || elementSize == 0 || elementSize > kMaximumElementSize) {
+            continue;
+        }
+        if (elementSize == kExpectedRecordStride && g_recordPoolCount < kRecordPoolCapacity) {
+            g_recordPoolOrdinals[g_recordPoolCount] = ordinal;
+            g_recordPools[g_recordPoolCount++] = poolBase;
+        }
+        std::array<char, core::log::kLineCapacity> line{};
+        const int written =
+            std::snprintf(line.data(),
+                          line.size(),
+                          "ev=entity_census stage=pool ordinal=%zu base=0x%llX element=%u",
+                          ordinal,
+                          static_cast<unsigned long long>(
+                              reinterpret_cast<std::uintptr_t>(poolBase)),
+                          elementSize);
+        if (written > 0) {
+            core::log::write(core::log::Channel::client,
+                             core::log::Level::debug,
+                             {line.data(), static_cast<std::size_t>(written)});
+        }
+    }
+}
+
+/**
+ * Reports the built records of one record-shaped pool other than the cached one.
+ *
+ * The cached pointer at `kEntityTableBaseRva` names a single pool, and the directory shows two of
+ * this shape. An object that works and an object that does not may simply live in different pools,
+ * and that is not visible while only one is read.
+ * @param poolBase Base of the pool to walk.
+ * @param stride Record stride, the same for every pool of this shape.
+ * @param ordinal Directory ordinal, reported so the two can be told apart.
+ */
+void walk_pool(const std::byte* poolBase, std::uint32_t stride, std::size_t ordinal) noexcept {
+    LONG reported = 0;
+    for (std::size_t index = 0; index < kFreeBitmapBits && reported < kCensusEntryBudget; ++index) {
+        std::array<char, core::log::kLineCapacity> line{};
+        int written = 0;
+        __try {
+            const auto* const record = poolBase + index * stride;
+            const auto recordClass =
+                *reinterpret_cast<const std::uint32_t*>(record + kRecordClassOffset);
+            if (recordClass != kRecordClass) {
+                continue;
+            }
+            written = std::snprintf(
+                line.data(),
+                line.size(),
+                "ev=entity_census stage=entry pool=%zu idx=%zu cls=0x%08X def=0x%08X ord=%u rec=",
+                ordinal,
+                index,
+                recordClass,
+                *reinterpret_cast<const std::uint32_t*>(record + kRecordDefinitionOffset),
+                *reinterpret_cast<const std::uint32_t*>(record + kRecordOrdinalOffset));
+            for (std::size_t offset = 0; offset < kRecordDumpBytes && written > 0
+                                         && static_cast<std::size_t>(written) + 3 < line.size();
+                 ++offset) {
+                const int more = std::snprintf(line.data() + written,
+                                               line.size() - static_cast<std::size_t>(written),
+                                               "%02X",
+                                               std::to_integer<unsigned char>(record[offset]));
+                if (more <= 0) {
+                    break;
+                }
+                written += more;
+            }
+        } __except (EXCEPTION_EXECUTE_HANDLER) {
+            continue;
+        }
+        if (written <= 0) {
+            continue;
+        }
+        ++reported;
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+    std::array<char, core::log::kLineCapacity> tail{};
+    const int written = std::snprintf(tail.data(),
+                                      tail.size(),
+                                      "ev=entity_census stage=pool_end ordinal=%zu records=%ld",
+                                      ordinal,
+                                      static_cast<long>(reported));
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::debug,
+                         {tail.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Walks the whole entity table and reports every slot the game has built.
+ *
+ * The per-allocation dump reads a record one allocation after it is handed out, which is early
+ * enough that the transform is still its default — every instance of one definition reported the
+ * same placement, which cannot be true. A census taken well after a bubble has settled reads the
+ * finished records instead, and placement is the field that matters here: a grid of identical
+ * co-planar objects is a wall of shootable panels and nothing else is, so this can identify the
+ * Wall of Wishes without knowing the game's own name for it.
+ */
+void run_census() noexcept {
+    if (!core::log::accepts(core::log::Channel::client, core::log::Level::debug)) {
+        return;
+    }
+    const auto base = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
+    if (base == 0) {
+        return;
+    }
+    const std::byte* table = nullptr;
+    std::uint32_t stride = 0;
+    __try {
+        table = *reinterpret_cast<const std::byte* const*>(base + kEntityTableBaseRva);
+        stride = *reinterpret_cast<const std::uint32_t*>(base + kEntityTableStrideRva);
+    } __except (EXCEPTION_EXECUTE_HANDLER) {
+        return;
+    }
+    if (table == nullptr || stride != kExpectedRecordStride) {
+        return;
+    }
+    // A record keeps its class marker after the entity is gone, so the marker alone cannot
+    // distinguish a live entity from a slot one left behind. The free bitmap can: a slot the
+    // allocator would hand out is not holding anything, whatever its record still says.
+    const auto* freeWords = static_cast<const std::uint32_t*>(nullptr);
+    if (void* const pool = g_lastPool; pool != nullptr) {
+        freeWords = reinterpret_cast<const std::uint32_t*>(static_cast<std::byte*>(pool)
+                                                           + kFreeBitmapOffset);
+    }
+    // First pass counts every class present. A record whose class word is zero or all ones has
+    // never been built, so those are the only two values treated as empty.
+    std::array<std::uint32_t, kClassCapacity> classes{};
+    std::array<LONG, kClassCapacity> classCounts{};
+    std::array<LONG, kClassCapacity> classDumped{};
+    std::size_t classCount = 0;
+    for (std::size_t index = 0; index < kFreeBitmapBits; ++index) {
+        std::uint32_t value = 0;
+        __try {
+            value = *reinterpret_cast<const std::uint32_t*>(table + index * stride
+                                                            + kRecordClassOffset);
+        } __except (EXCEPTION_EXECUTE_HANDLER) {
+            continue;
+        }
+        if (value == 0 || value == 0xFFFFFFFFU) {
+            continue;
+        }
+        std::size_t slot = 0;
+        while (slot < classCount && classes[slot] != value) {
+            ++slot;
+        }
+        if (slot == classCount) {
+            if (classCount == kClassCapacity) {
+                continue;
+            }
+            classes[classCount++] = value;
+        }
+        ++classCounts[slot];
+    }
+    for (std::size_t slot = 0; slot < classCount; ++slot) {
+        std::array<char, core::log::kLineCapacity> head{};
+        const int headWritten = std::snprintf(head.data(),
+                                              head.size(),
+                                              "ev=entity_census stage=class value=0x%08X count=%ld",
+                                              classes[slot],
+                                              static_cast<long>(classCounts[slot]));
+        if (headWritten > 0) {
+            core::log::write(core::log::Channel::client,
+                             core::log::Level::debug,
+                             {head.data(), static_cast<std::size_t>(headWritten)});
+        }
+    }
+    g_recordPoolCount = 0;
+    report_pools();
+    // Name the pool the census has been reading all along, so its ordinal can be matched against
+    // the directory rather than assumed.
+    {
+        std::array<char, core::log::kLineCapacity> line{};
+        const int written = std::snprintf(
+            line.data(),
+            line.size(),
+            "ev=entity_census stage=table base=0x%llX stride=%u pools=%zu",
+            static_cast<unsigned long long>(reinterpret_cast<std::uintptr_t>(table)),
+            stride,
+            g_recordPoolCount);
+        if (written > 0) {
+            core::log::write(core::log::Channel::client,
+                             core::log::Level::debug,
+                             {line.data(), static_cast<std::size_t>(written)});
+        }
+    }
+    // Every record-shaped pool, not just the cached one. A pool the cached pointer already names
+    // is not walked twice.
+    for (std::size_t slot = 0; slot < g_recordPoolCount; ++slot) {
+        if (g_recordPools[slot] == table) {
+            continue;
+        }
+        walk_pool(g_recordPools[slot], stride, g_recordPoolOrdinals[slot]);
+    }
+    LONG live = 0;
+    LONG stale = 0;
+    // Per pass, not per run: a shared budget truncated the one census that mattered.
+    LONG entries = 0;
+    for (std::size_t index = 0; index < kFreeBitmapBits; ++index) {
+        std::array<char, core::log::kLineCapacity> line{};
+        int written = 0;
+        __try {
+            const auto* const record = table + index * stride;
+            const auto recordClass =
+                *reinterpret_cast<const std::uint32_t*>(record + kRecordClassOffset);
+            if (recordClass == 0 || recordClass == 0xFFFFFFFFU) {
+                continue;
+            }
+            std::size_t slot = 0;
+            while (slot < classCount && classes[slot] != recordClass) {
+                ++slot;
+            }
+            const bool spent = slot == classCount || classDumped[slot] >= kPerClassDump;
+            if (!spent) {
+                ++classDumped[slot];
+            }
+            unsigned slotFree = 0;
+            if (freeWords != nullptr
+                && (freeWords[index / kBitsPerWord] & (1u << (index % kBitsPerWord))) != 0) {
+                slotFree = 1;
+                ++stale;
+            } else {
+                ++live;
+            }
+            // The tally above counts every record; only the dump is rationed.
+            if (spent || entries >= kCensusEntryBudget) {
+                continue;
+            }
+            written = std::snprintf(
+                line.data(),
+                line.size(),
+                "ev=entity_census stage=entry idx=%zu cls=0x%08X def=0x%08X ord=%u free=%u rec=",
+                index,
+                recordClass,
+                *reinterpret_cast<const std::uint32_t*>(record + kRecordDefinitionOffset),
+                *reinterpret_cast<const std::uint32_t*>(record + kRecordOrdinalOffset),
+                slotFree);
+            // The whole record, not just the transform block. The block at `+0xA0` decodes as a
+            // clean quaternion but the four dwords after it are not the position — as floats they
+            // are denormals and values in the trillions. Somewhere in these 224 bytes there are
+            // three coordinates, and the way to find them is to scan every aligned offset across a
+            // group for one that varies plausibly. A 5x5 grid of co-planar panels is the Wall of
+            // Wishes and nothing else in the room is shaped like that, so placement identifies it
+            // where counting has not.
+            for (std::size_t offset = 0; offset < kRecordDumpBytes && written > 0
+                                         && static_cast<std::size_t>(written) + 3 < line.size();
+                 ++offset) {
+                const int more = std::snprintf(line.data() + written,
+                                               line.size() - static_cast<std::size_t>(written),
+                                               "%02X",
+                                               std::to_integer<unsigned char>(record[offset]));
+                if (more <= 0) {
+                    break;
+                }
+                written += more;
+            }
+        } __except (EXCEPTION_EXECUTE_HANDLER) {
+            continue;
+        }
+        if (written <= 0) {
+            continue;
+        }
+        ++entries;
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+    std::array<char, core::log::kLineCapacity> tail{};
+    const int written = std::snprintf(tail.data(),
+                                      tail.size(),
+                                      "ev=entity_census stage=end live=%ld stale=%ld allocs=%ld",
+                                      static_cast<long>(live),
+                                      static_cast<long>(stale),
+                                      static_cast<long>(g_allocations));
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::debug,
+                         {tail.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Runs a census on its own thread so it does not sit inside the game's allocation path.
+ * @param unused Thread parameter, unused.
+ * @return Always zero.
+ */
+DWORD WINAPI census_thread(LPVOID unused) noexcept {
+    (void)unused;
+    while (g_censusRunning != 0) {
+        Sleep(kCensusIntervalMs);
+        if (g_censusRunning == 0) {
+            break;
+        }
+        run_census();
+    }
+    return 0;
+}
+
+/**
+ * Attaches one probe, reporting its own outcome.
+ * @param signature Pattern to find.
+ * @param name Reported name.
+ * @param replacement Probe body.
+ * @param handle Receives the trampoline.
+ * @return True when the target was found and the detour attached.
+ */
+[[nodiscard]] bool attach(std::span<const patterns::PatternByte> signature,
+                          const char* name,
+                          void* replacement,
+                          detour::Handle& handle) noexcept {
+    std::byte* const target = patterns::scan_main_image_unique(signature, name);
+    std::array<char, core::log::kLineCapacity> line{};
+    if (target == nullptr) {
+        const int written = std::snprintf(line.data(),
+                                          line.size(),
+                                          "ev=entity_create stage=attach name=%s result=fail",
+                                          name);
+        if (written > 0) {
+            core::log::write(core::log::Channel::client,
+                             core::log::Level::warn,
+                             {line.data(), static_cast<std::size_t>(written)});
+        }
+        return false;
+    }
+    const detour::Spec spec{target, replacement};
+    const bool attached = detour::install(spec, handle);
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=entity_create stage=attach name=%s result=%s",
+                                      name,
+                                      attached ? "ok" : "fail");
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         attached ? core::log::Level::info : core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+    return attached;
+}
+
+} // namespace
+
+/** Reports which half of the client's entity creation refuses. */
+bool install_entity_create_probe(bool stockUnstockedPool, bool restockAlways) noexcept {
+    g_stockUnstockedPool = stockUnstockedPool;
+    g_restockAlways = restockAlways;
+    // Resolved rather than linked: the trace is a diagnostic, and a missing export should cost the
+    // call sites in the log, not the probe that stocks the pool.
+    if (HMODULE const ntdll = GetModuleHandleW(L"ntdll.dll"); ntdll != nullptr) {
+        g_captureBacktrace = reinterpret_cast<decltype(g_captureBacktrace)>(
+            reinterpret_cast<void*>(GetProcAddress(ntdll, "RtlCaptureStackBackTrace")));
+    }
+    const bool allocator = attach(kIndexAllocator,
+                                  "entity_index_allocator",
+                                  reinterpret_cast<void*>(&allocator_body),
+                                  g_allocator);
+    if (allocator) {
+        InterlockedExchange(&g_censusRunning, 1);
+        g_censusThread = CreateThread(nullptr, 0, &census_thread, nullptr, 0, nullptr);
+    }
+    // The initialiser is deliberately NOT hooked. Its fifth argument is passed on the stack
+    // (`mov dword [var_20h], eax` before the call), and a four-argument replacement got that
+    // wrong and black-screened the load. It does not need hooking anyway: the allocator alone
+    // answers the question, because the initialiser only runs when the allocator succeeded.
+    return allocator;
+}
+
+/** Detaches the entity-creation probes. */
+void uninstall_entity_create_probe() noexcept {
+    InterlockedExchange(&g_censusRunning, 0);
+    if (g_censusThread != nullptr) {
+        // The census only reads, so a shutdown that beats it costs a census, never the process.
+        (void)CloseHandle(g_censusThread);
+        g_censusThread = nullptr;
+    }
+    if (g_allocator.attached) {
+        (void)detour::uninstall(g_allocator);
+    }
+}
+
+} // namespace sunrise::client::diagnostics

+ 34 - 0
Sunrise/src/client/diagnostics/entity_create_probe.h

@@ -0,0 +1,34 @@
+#pragma once
+
+namespace sunrise::client::diagnostics {
+
+/**
+ * Reports which half of the client's entity creation refuses.
+ * The client logs `failed to create '<type>' entity` and nothing else, and Sunrise's own note at
+ * `server/bap/encrypted/transactions/service_outcome_commit.cpp` reads that as "it has no free
+ * index". That reading is an assumption, and acting on it once already cost a build-and-run cycle:
+ * a lease top-up landed and changed nothing.
+ *
+ * The creator calls two things in order — an index allocator that answers -1 when it has nothing
+ * to give, then an initialiser that answers false when it refuses the entity it was handed. Both
+ * end at the same log line, so the line cannot tell them apart. These two detours can: each
+ * reports its own outcome, so one run says which half is failing and the guessing stops.
+ *
+ * Diagnostic only. Neither replacement changes an argument or a result, and both are found with
+ * an independent scan rather than through the shared target registry, so a signature that no
+ * longer matches this build costs the probe and nothing else.
+ * @param stockUnstockedPool Refill a bitmap that is entirely unstocked, which is what the
+ *        client's own initialiser would have done had its role global read zero.
+ * @param restockAlways Also refill a pool that has drained, not only one never stocked. Needed to
+ *        get past an encounter bubble the drained pool would otherwise refuse. Safe: the refill
+ *        spares every index the probe watched the allocator hand out, so it cannot re-free one that
+ *        is still owned the way the earlier blanket fill did.
+ * @return True when the probe attached.
+ */
+[[nodiscard]] bool install_entity_create_probe(bool stockUnstockedPool,
+                                               bool restockAlways) noexcept;
+
+/** Detaches the entity-creation probes. */
+void uninstall_entity_create_probe() noexcept;
+
+} // namespace sunrise::client::diagnostics

+ 278 - 0
Sunrise/src/client/diagnostics/image_dump.cpp

@@ -0,0 +1,278 @@
+#include "image_dump.h"
+
+#include <Windows.h>
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <string_view>
+
+#include "../../core/filesystem/path.h"
+#include "../../core/logging/log.h"
+
+namespace sunrise::client::diagnostics {
+namespace {
+
+/** Dumps are isolated below the shared generated-artifact directory, beside the logs. */
+constexpr std::wstring_view kDumpDirectorySuffix = L"\\dumps";
+/** One stable name, so a second diagnostic run replaces the first rather than filling the disk. */
+constexpr std::wstring_view kImageFileSuffix = L"\\game_image.bin";
+/** The manifest carries the load base, without which the dump's addresses mean nothing. */
+constexpr std::wstring_view kManifestFileSuffix = L"\\game_image.txt";
+/**
+ * Bytes moved per read.
+ * Reads are page-granular in effect, so this only bounds the staging buffer and the cost of one
+ * failed read. 64 KiB keeps the buffer off the stack-sized path while staying one allocation.
+ */
+constexpr std::size_t kChunkBytes = 64 * 1024;
+/** A mapped image larger than this is not one this build can be looking at. */
+constexpr std::size_t kMaximumImageBytes = 1024ULL * 1024ULL * 1024ULL;
+
+/**
+ * Creates one directory, tolerating an existing one.
+ * @param path Full directory path.
+ * @return True when the directory exists afterwards.
+ */
+[[nodiscard]] bool ensure_directory(const core::path::Buffer& path) noexcept {
+    if (CreateDirectoryW(path.chars.data(), nullptr) != FALSE) {
+        return true;
+    }
+    if (GetLastError() != ERROR_ALREADY_EXISTS) {
+        return false;
+    }
+    // ERROR_ALREADY_EXISTS also covers files, so verify the existing object is a directory.
+    const DWORD attributes = GetFileAttributesW(path.chars.data());
+    return attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
+}
+
+/**
+ * Opens one file for writing, replacing anything already there.
+ * @param path Full file path.
+ * @return Open handle, or INVALID_HANDLE_VALUE.
+ */
+[[nodiscard]] HANDLE create_file(const core::path::Buffer& path) noexcept {
+    return CreateFileW(path.chars.data(),
+                       GENERIC_WRITE,
+                       0,
+                       nullptr,
+                       CREATE_ALWAYS,
+                       FILE_ATTRIBUTE_NORMAL,
+                       nullptr);
+}
+
+/**
+ * Writes one whole buffer.
+ * @param file Open file handle.
+ * @param data First byte.
+ * @param size Byte count.
+ * @return True when every byte reached the file.
+ */
+[[nodiscard]] bool write_all(HANDLE file, const void* data, std::size_t size) noexcept {
+    const auto* cursor = static_cast<const std::byte*>(data);
+    std::size_t remaining = size;
+    while (remaining != 0) {
+        const DWORD wanted =
+            static_cast<DWORD>(remaining < kChunkBytes ? remaining : kChunkBytes);
+        DWORD written = 0;
+        if (WriteFile(file, cursor, wanted, &written, nullptr) == FALSE || written == 0) {
+            return false;
+        }
+        cursor += written;
+        remaining -= written;
+    }
+    return true;
+}
+
+/** Header fields the dump is described by, read once from the mapped image. */
+struct ImageHeader {
+    std::byte* base{};
+    std::size_t imageSize{};
+    std::uint16_t sectionCount{};
+    std::size_t sectionOffset{};
+};
+
+/**
+ * Reads the mapped PE headers of the main module.
+ * @param output Receives the load base and image span.
+ * @return True when the headers are a usable 64-bit PE.
+ */
+[[nodiscard]] bool read_header(ImageHeader& output) noexcept {
+    output = {};
+    auto* base = reinterpret_cast<std::byte*>(GetModuleHandleW(nullptr));
+    if (base == nullptr) {
+        return false;
+    }
+    const auto& dos = *reinterpret_cast<const IMAGE_DOS_HEADER*>(base);
+    if (dos.e_magic != IMAGE_DOS_SIGNATURE || dos.e_lfanew <= 0) {
+        return false;
+    }
+    const auto& nt = *reinterpret_cast<const IMAGE_NT_HEADERS64*>(base + dos.e_lfanew);
+    if (nt.Signature != IMAGE_NT_SIGNATURE
+        || nt.OptionalHeader.Magic != IMAGE_NT_OPTIONAL_HDR64_MAGIC) {
+        return false;
+    }
+    const std::size_t imageSize = nt.OptionalHeader.SizeOfImage;
+    if (imageSize == 0 || imageSize > kMaximumImageBytes) {
+        return false;
+    }
+    output.base = base;
+    output.imageSize = imageSize;
+    output.sectionCount = nt.FileHeader.NumberOfSections;
+    output.sectionOffset = static_cast<std::size_t>(dos.e_lfanew) + sizeof(DWORD)
+                           + sizeof(IMAGE_FILE_HEADER) + nt.FileHeader.SizeOfOptionalHeader;
+    return true;
+}
+
+/**
+ * Writes the flat image span, substituting zeroes for pages the process will not read.
+ * @param file Open destination.
+ * @param header Mapped image description.
+ * @param unreadable Receives the byte count that had to be zero-filled.
+ * @return True when the whole span was written.
+ */
+[[nodiscard]] bool
+write_image(HANDLE file, const ImageHeader& header, std::size_t& unreadable) noexcept {
+    unreadable = 0;
+    static std::array<std::byte, kChunkBytes> chunk{};
+    for (std::size_t offset = 0; offset < header.imageSize; offset += kChunkBytes) {
+        const std::size_t remaining = header.imageSize - offset;
+        const std::size_t wanted = remaining < kChunkBytes ? remaining : kChunkBytes;
+        SIZE_T copied = 0;
+        // ReadProcessMemory rather than memcpy: a guard or no-access page inside the image is
+        // normal for a packed binary and must not fault the game we are dumping from.
+        if (ReadProcessMemory(
+                GetCurrentProcess(), header.base + offset, chunk.data(), wanted, &copied)
+                == FALSE
+            || copied != wanted) {
+            chunk.fill(std::byte{});
+            unreadable += wanted;
+        }
+        if (!write_all(file, chunk.data(), wanted)) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/**
+ * Writes the manifest naming the load base and every section.
+ * @param file Open destination.
+ * @param header Mapped image description.
+ * @param unreadable Bytes the image pass had to zero-fill.
+ * @return True when the manifest was written.
+ */
+[[nodiscard]] bool
+write_manifest(HANDLE file, const ImageHeader& header, std::size_t unreadable) noexcept {
+    std::array<char, 512> line{};
+    int written = std::snprintf(line.data(),
+                                line.size(),
+                                "# Sunrise mapped-image dump of the running game.\r\n"
+                                "# Load with the base below, e.g.  r2 -B 0x%llX game_image.bin\r\n"
+                                "base=0x%llX\r\n"
+                                "image_size=0x%zX\r\n"
+                                "unreadable_bytes=%zu\r\n"
+                                "sections=%u\r\n",
+                                static_cast<unsigned long long>(
+                                    reinterpret_cast<std::uintptr_t>(header.base)),
+                                static_cast<unsigned long long>(
+                                    reinterpret_cast<std::uintptr_t>(header.base)),
+                                header.imageSize,
+                                unreadable,
+                                static_cast<unsigned>(header.sectionCount));
+    if (written <= 0 || !write_all(file, line.data(), static_cast<std::size_t>(written))) {
+        return false;
+    }
+    for (std::uint16_t index = 0; index < header.sectionCount; ++index) {
+        const auto& section = *reinterpret_cast<const IMAGE_SECTION_HEADER*>(
+            header.base + header.sectionOffset + index * sizeof(IMAGE_SECTION_HEADER));
+        // The name field is not guaranteed to be null-terminated at 8 characters.
+        std::array<char, IMAGE_SIZEOF_SHORT_NAME + 1> name{};
+        for (std::size_t character = 0; character < IMAGE_SIZEOF_SHORT_NAME; ++character) {
+            name[character] = static_cast<char>(section.Name[character]);
+        }
+        written = std::snprintf(line.data(),
+                                line.size(),
+                                "section name=%-8s va=0x%08lX size=0x%08lX flags=0x%08lX\r\n",
+                                name.data(),
+                                static_cast<unsigned long>(section.VirtualAddress),
+                                static_cast<unsigned long>(section.Misc.VirtualSize),
+                                static_cast<unsigned long>(section.Characteristics));
+        if (written <= 0 || !write_all(file, line.data(), static_cast<std::size_t>(written))) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/**
+ * Reports the outcome of one dump attempt.
+ * @param stage Step that decided the outcome.
+ * @param succeeded Whether the dump completed.
+ * @param bytes Image bytes written, or zero.
+ * @param unreadable Bytes zero-filled because the page would not read.
+ */
+void report(const char* stage,
+            bool succeeded,
+            std::size_t bytes,
+            std::size_t unreadable) noexcept {
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=image_dump stage=%s result=%s bytes=%zu unreadable=%zu",
+                                      stage,
+                                      succeeded ? "ok" : "fail",
+                                      bytes,
+                                      unreadable);
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         succeeded ? core::log::Level::info : core::log::Level::error,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+} // namespace
+
+/** Writes the game's mapped image to disk so it can be disassembled offline. */
+bool dump_game_image(void* module) noexcept {
+    ImageHeader header{};
+    if (!read_header(header)) {
+        report("header", false, 0, 0);
+        return false;
+    }
+    core::path::Buffer directory{};
+    if (!core::path::artifact_directory(module, directory)
+        || !core::path::append(directory, kDumpDirectorySuffix) || !ensure_directory(directory)) {
+        report("path", false, 0, 0);
+        return false;
+    }
+
+    core::path::Buffer imagePath = directory;
+    HANDLE file = core::path::append(imagePath, kImageFileSuffix) ? create_file(imagePath)
+                                                                  : INVALID_HANDLE_VALUE;
+    if (file == INVALID_HANDLE_VALUE) {
+        report("create", false, 0, 0);
+        return false;
+    }
+    std::size_t unreadable = 0;
+    const bool wrote = write_image(file, header, unreadable);
+    CloseHandle(file);
+    if (!wrote) {
+        report("write", false, 0, unreadable);
+        return false;
+    }
+
+    core::path::Buffer manifestPath = directory;
+    file = core::path::append(manifestPath, kManifestFileSuffix) ? create_file(manifestPath)
+                                                                 : INVALID_HANDLE_VALUE;
+    if (file == INVALID_HANDLE_VALUE) {
+        report("manifest", false, header.imageSize, unreadable);
+        return false;
+    }
+    const bool described = write_manifest(file, header, unreadable);
+    CloseHandle(file);
+    report(described ? "complete" : "manifest", described, header.imageSize, unreadable);
+    return described;
+}
+
+} // namespace sunrise::client::diagnostics

+ 26 - 0
Sunrise/src/client/diagnostics/image_dump.h

@@ -0,0 +1,26 @@
+#pragma once
+
+namespace sunrise::client::diagnostics {
+
+/**
+ * Writes the game's mapped image to disk so it can be disassembled offline.
+ * `destiny2.exe` is VMProtect-packed: on disk its `.text` is fully encrypted, the retail log
+ * strings are absent, and the byte signatures in `patterns/game_signatures.cpp` match nothing.
+ * They match at runtime because every scan runs against the mapped image the packer has already
+ * decrypted, so that mapped image is the only readable copy of the code and the only thing a
+ * disassembler can be pointed at.
+ *
+ * The dump is one flat file covering the whole `SizeOfImage` span, so a file offset is the image
+ * offset and a virtual address is the load base plus that offset. A page the process will not let
+ * us read is written as zeroes rather than abandoning the dump, because an unreadable page is
+ * normal in a packed image and losing the rest of the file to it helps nobody.
+ *
+ * Off unless `client.dump_game_image` is set. The file is large — the whole image, about 140 MB —
+ * and writing it costs a second or two of boot, so it is a deliberate diagnostic run rather than
+ * something every start pays for.
+ * @param module Sunrise's own loaded module, used to resolve the artifact directory.
+ * @return True when the whole image was written and the manifest beside it was too.
+ */
+[[nodiscard]] bool dump_game_image(void* module) noexcept;
+
+} // namespace sunrise::client::diagnostics

+ 169 - 30
Sunrise/src/client/hooking/detour/transaction/detour_thread_transaction.cpp

@@ -13,6 +13,46 @@ namespace {
 /** 4 protected functions per hook bound the fixed range storage, so no heap is used. */
 /** 4 protected functions per hook bound the fixed range storage, so no heap is used. */
 constexpr std::size_t kProtectedCodeLimit = 64;
 constexpr std::size_t kProtectedCodeLimit = 64;
 
 
+/** Access an enlisted thread is opened with. Detours reads and rewrites its context. */
+constexpr DWORD kEnlistAccess =
+    THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT | THREAD_SET_CONTEXT;
+/** Access the walk needs of a thread it only names. Asking for less refuses fewer threads. */
+constexpr DWORD kWalkAccess = THREAD_QUERY_LIMITED_INFORMATION;
+/** The walk is over. NtGetNextThread reports it as a failure status, so it is checked by value. */
+constexpr LONG kStatusNoMoreEntries = static_cast<LONG>(0x8000001AL);
+
+/**
+ * Hands back the next thread of one process, in an order fixed for the length of the walk.
+ * Passing a null cursor starts it. The returned handle carries the requested access.
+ */
+using NextThread = LONG(NTAPI*)(HANDLE process,
+                                HANDLE cursor,
+                                ACCESS_MASK access,
+                                ULONG attributes,
+                                ULONG flags,
+                                HANDLE* next) noexcept;
+
+/**
+ * Finds ntdll's own thread walk, once.
+ * The documented walk is a Toolhelp snapshot, which enumerates every thread on the system to
+ * reach this process's fifty: it costs about 25 ms a pass, twice a transaction, and a boot holds
+ * one transaction per hook. This walk stays inside the process and costs about 0.08 ms. It is
+ * not a documented export, so a build that does not have it keeps the snapshot instead.
+ * @return The entry point, or null when ntdll does not export it.
+ */
+[[nodiscard]] NextThread next_thread_entry() noexcept {
+    static const NextThread entry = [] {
+        const HMODULE ntdll = GetModuleHandleW(L"ntdll.dll");
+        if (ntdll == nullptr) {
+            return static_cast<NextThread>(nullptr);
+        }
+        // The cast is through a void function pointer because GetProcAddress returns FARPROC.
+        return reinterpret_cast<NextThread>(
+            reinterpret_cast<void*>(GetProcAddress(ntdll, "NtGetNextThread")));
+    }();
+    return entry;
+}
+
 /** Exact executable range described by one x64 unwind record. */
 /** Exact executable range described by one x64 unwind record. */
 struct CodeRange {
 struct CodeRange {
     DWORD64 begin{};
     DWORD64 begin{};
@@ -45,6 +85,109 @@ void close_threads(Threads& threads) noexcept {
     return false;
     return false;
 }
 }
 
 
+/** How far one enlistment pass got. */
+enum class PassResult {
+    /** Every thread of the process was seen and taken. */
+    complete,
+    /** The walk stopped early without handing Detours anything, so another pass may still run. */
+    enumerationFailed,
+    /** Detours refused a thread. Nothing can continue this transaction. */
+    transactionFailed,
+};
+
+/**
+ * Enlists one process thread by id, unless this transaction already holds it.
+ * The handle comes from OpenThread rather than from whatever named the id. A thread Windows will
+ * not open here is one the transaction must leave alone: handing Detours a thread it cannot
+ * suspend sets a transaction-wide pending error that fails every later attach and that nothing
+ * can clear. The set of enlisted threads therefore stays exactly what a snapshot pass would take,
+ * whichever walk found them.
+ * @param threads Receives the handle, which stays suspended until the transaction ends.
+ * @param threadId Candidate process thread id.
+ * @param currentThreadId The calling thread, which the transaction enlists separately.
+ * @param foundUnseen Set when the thread was new to this transaction.
+ * @return False when Detours refused the thread and the transaction is spent.
+ */
+[[nodiscard]] bool enlist_thread_id(Threads& threads,
+                                    DWORD threadId,
+                                    DWORD currentThreadId,
+                                    bool& foundUnseen) noexcept {
+    if (threadId == 0 || threadId == currentThreadId || contains(threads, threadId)) {
+        return true;
+    }
+    foundUnseen = true;
+    if (threads.count == threads.handles.size()) {
+        return false;
+    }
+    const HANDLE thread = OpenThread(kEnlistAccess, FALSE, threadId);
+    if (thread == nullptr) {
+        // A disappearing thread is absent from the next stable pass.
+        return GetLastError() == ERROR_INVALID_PARAMETER;
+    }
+    if (DetourUpdateThread(thread) != NO_ERROR) {
+        CloseHandle(thread);
+        return false;
+    }
+    threads.handles[threads.count] = thread;
+    threads.ids[threads.count] = threadId;
+    ++threads.count;
+    return true;
+}
+
+/**
+ * Says whether a thread is still running.
+ * The walk reaches threads that have already exited: their objects outlive them for as long as
+ * something holds a handle, and the kernel thread list still carries them. A snapshot never
+ * reports one. Detours suspends a thread the moment it is handed over, suspending an exited
+ * thread fails, and that failure is a transaction-wide error that nothing can clear, so an exited
+ * thread has to be dropped before it is offered.
+ * @param thread Handle opened with at least THREAD_QUERY_LIMITED_INFORMATION.
+ * @return True only when the thread is confirmed running.
+ */
+[[nodiscard]] bool thread_is_running(HANDLE thread) noexcept {
+    DWORD exitCode = 0;
+    return GetExitCodeThread(thread, &exitCode) != FALSE && exitCode == STILL_ACTIVE;
+}
+
+/**
+ * Enlists every unseen live thread of this process using ntdll's own walk.
+ * The walk names and vets each thread; enlisting it then runs on the shared path.
+ * @param threads Receives handles that stay suspended until the transaction ends.
+ * @param foundUnseen Receives true when this pass saw any new thread.
+ * @return How far the pass got.
+ */
+[[nodiscard]] PassResult enlist_process_walk(Threads& threads, bool& foundUnseen) noexcept {
+    const NextThread nextThread = next_thread_entry();
+    if (nextThread == nullptr) {
+        return PassResult::enumerationFailed;
+    }
+    const DWORD currentThreadId = GetCurrentThreadId();
+    HANDLE cursor = nullptr;
+    for (;;) {
+        HANDLE next = nullptr;
+        const LONG status = nextThread(GetCurrentProcess(), cursor, kWalkAccess, 0, 0, &next);
+        // The cursor is only a position in the walk; the transaction never holds it.
+        if (cursor != nullptr) {
+            CloseHandle(cursor);
+        }
+        cursor = nullptr;
+        if (status == kStatusNoMoreEntries) {
+            return PassResult::complete;
+        }
+        if (status < 0 || next == nullptr) {
+            return PassResult::enumerationFailed;
+        }
+        // The walk's own handle answers both questions, so the enlist handle is only opened for
+        // a thread that is going to be offered.
+        const DWORD threadId = thread_is_running(next) ? GetThreadId(next) : 0;
+        if (!enlist_thread_id(threads, threadId, currentThreadId, foundUnseen)) {
+            CloseHandle(next);
+            return PassResult::transactionFailed;
+        }
+        cursor = next;
+    }
+}
+
 /**
 /**
  * Enlists every unseen thread present in one process-wide snapshot.
  * Enlists every unseen thread present in one process-wide snapshot.
  * @param threads Receives handles that stay suspended until the transaction ends.
  * @param threads Receives handles that stay suspended until the transaction ends.
@@ -52,7 +195,6 @@ void close_threads(Threads& threads) noexcept {
  * @return True when the whole snapshot was inspected without a hard failure.
  * @return True when the whole snapshot was inspected without a hard failure.
  */
  */
 [[nodiscard]] bool enlist_snapshot(Threads& threads, bool& foundUnseen) noexcept {
 [[nodiscard]] bool enlist_snapshot(Threads& threads, bool& foundUnseen) noexcept {
-    foundUnseen = false;
     const HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
     const HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
     if (snapshot == INVALID_HANDLE_VALUE) {
     if (snapshot == INVALID_HANDLE_VALUE) {
         return false;
         return false;
@@ -65,33 +207,8 @@ void close_threads(Threads& threads) noexcept {
     const DWORD currentThreadId = GetCurrentThreadId();
     const DWORD currentThreadId = GetCurrentThreadId();
     bool succeeded = true;
     bool succeeded = true;
     while (available != FALSE && succeeded) {
     while (available != FALSE && succeeded) {
-        const bool belongsToProcess = entry.th32OwnerProcessID == processId;
-        const bool needsEnlistment =
-            entry.th32ThreadID != currentThreadId && !contains(threads, entry.th32ThreadID);
-        if (belongsToProcess && needsEnlistment) {
-            foundUnseen = true;
-            if (threads.count == threads.handles.size()) {
-                succeeded = false;
-                break;
-            }
-
-            const HANDLE thread =
-                OpenThread(THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT | THREAD_SET_CONTEXT,
-                           FALSE,
-                           entry.th32ThreadID);
-            if (thread == nullptr) {
-                // A disappearing thread is absent from the next stable snapshot.
-                if (GetLastError() != ERROR_INVALID_PARAMETER) {
-                    succeeded = false;
-                }
-            } else if (DetourUpdateThread(thread) != NO_ERROR) {
-                CloseHandle(thread);
-                succeeded = false;
-            } else {
-                threads.handles[threads.count] = thread;
-                threads.ids[threads.count] = entry.th32ThreadID;
-                ++threads.count;
-            }
+        if (entry.th32OwnerProcessID == processId) {
+            succeeded = enlist_thread_id(threads, entry.th32ThreadID, currentThreadId, foundUnseen);
         }
         }
         available = Thread32Next(snapshot, &entry);
         available = Thread32Next(snapshot, &entry);
     }
     }
@@ -104,14 +221,36 @@ void close_threads(Threads& threads) noexcept {
 }
 }
 
 
 /**
 /**
- * Enlists new process threads until a full snapshot finds no unseen thread id.
+ * Enlists every unseen process thread in one pass, by whichever walk this build has.
+ * A partly finished process walk leaves its handles enlisted and the snapshot completes the pass:
+ * both dedupe on the thread id, so the fallback cannot enlist a thread twice.
+ * @param threads Receives handles that stay suspended until the transaction ends.
+ * @param foundUnseen Receives true when this pass saw any new thread.
+ * @return True when the pass completed without a hard failure.
+ */
+[[nodiscard]] bool enlist_pass(Threads& threads, bool& foundUnseen) noexcept {
+    foundUnseen = false;
+    const PassResult walked = enlist_process_walk(threads, foundUnseen);
+    if (walked == PassResult::complete) {
+        return true;
+    }
+    // A refused thread has already spent the transaction, so no second walk can rescue it. Only
+    // a walk that stopped before Detours was told anything falls through to the snapshot.
+    if (walked == PassResult::transactionFailed) {
+        return false;
+    }
+    return enlist_snapshot(threads, foundUnseen);
+}
+
+/**
+ * Enlists new process threads until a full pass finds no unseen thread id.
  * @param threads Receives every handle the transaction holds.
  * @param threads Receives every handle the transaction holds.
  * @return True when a full pass found no new thread.
  * @return True when a full pass found no new thread.
  */
  */
 [[nodiscard]] bool enlist_until_stable(Threads& threads) noexcept {
 [[nodiscard]] bool enlist_until_stable(Threads& threads) noexcept {
     bool foundUnseen{};
     bool foundUnseen{};
     do {
     do {
-        if (!enlist_snapshot(threads, foundUnseen)) {
+        if (!enlist_pass(threads, foundUnseen)) {
             return false;
             return false;
         }
         }
         // Earlier handles stay suspended while a later pass finds newly created threads.
         // Earlier handles stay suspended while a later pass finds newly created threads.

+ 4 - 3
Sunrise/src/client/hooks/assert_handler/assert_handler_observer.cpp

@@ -7,10 +7,12 @@
 #include <cstdint>
 #include <cstdint>
 #include <cstdio>
 #include <cstdio>
 #include <cstring>
 #include <cstring>
+#include <mutex>
 
 
 #include "../../../core/logging/log.h"
 #include "../../../core/logging/log.h"
 #include "../../targets/game/assert_handler.h"
 #include "../../targets/game/assert_handler.h"
 #include "../net_tick_probe/net_tick_probe.h"
 #include "../net_tick_probe/net_tick_probe.h"
+#include "core/threading/srw_lock.h"
 
 
 namespace sunrise::client::hooks::assert_handler {
 namespace sunrise::client::hooks::assert_handler {
 namespace {
 namespace {
@@ -35,7 +37,7 @@ constexpr int kGraphicsHaltCategory = 6;
 /** The handler the game installed, called with the same printf-style arguments the sites use. */
 /** The handler the game installed, called with the same printf-style arguments the sites use. */
 using NativeHandler = void(__cdecl*)(int, const char*, ...);
 using NativeHandler = void(__cdecl*)(int, const char*, ...);
 
 
-SRWLOCK g_lock{SRWLOCK_INIT};
+core::threading::SrwLock g_lock{};
 /** Last message seen, so a message that repeats every frame is counted rather than written. */
 /** Last message seen, so a message that repeats every frame is counted rather than written. */
 std::array<char, kTextCapacity> g_lastText{};
 std::array<char, kTextCapacity> g_lastText{};
 std::uint32_t g_repeats{};
 std::uint32_t g_repeats{};
@@ -54,7 +56,7 @@ std::uint32_t g_seen{};
  * @return True when the caller writes a log line.
  * @return True when the caller writes a log line.
  */
  */
 [[nodiscard]] bool admit(const char* text, std::uint32_t& seen, std::uint32_t& repeats) noexcept {
 [[nodiscard]] bool admit(const char* text, std::uint32_t& seen, std::uint32_t& repeats) noexcept {
-    AcquireSRWLockExclusive(&g_lock);
+    const std::lock_guard lock(g_lock);
     ++g_seen;
     ++g_seen;
     if (std::strcmp(g_lastText.data(), text) == 0) {
     if (std::strcmp(g_lastText.data(), text) == 0) {
         ++g_repeats;
         ++g_repeats;
@@ -67,7 +69,6 @@ std::uint32_t g_seen{};
     }
     }
     seen = g_seen;
     seen = g_seen;
     repeats = g_repeats;
     repeats = g_repeats;
-    ReleaseSRWLockExclusive(&g_lock);
     return repeats <= kRepeatHead || repeats % kRepeatStride == 0;
     return repeats <= kRepeatHead || repeats % kRepeatStride == 0;
 }
 }
 
 

+ 0 - 3
Sunrise/src/client/hooks/assert_handler/assert_handler_observer.h

@@ -4,9 +4,6 @@
 
 
 namespace sunrise::client::hooks::assert_handler {
 namespace sunrise::client::hooks::assert_handler {
 
 
-extern SRWLOCK g_lock;
-extern bool g_installed;
-
 /** @return Address of the internal assert handler body. */
 /** @return Address of the internal assert handler body. */
 [[nodiscard]] void* handler_entry_point() noexcept;
 [[nodiscard]] void* handler_entry_point() noexcept;
 
 

+ 86 - 12
Sunrise/src/client/hooks/bootflow/bootflow_hook_lifecycle.cpp

@@ -1,6 +1,9 @@
 #include "bootflow_hook_lifecycle.h"
 #include "bootflow_hook_lifecycle.h"
 
 
+#include <array>
 #include <atomic>
 #include <atomic>
+#include <cstddef>
+#include <span>
 
 
 #include "internal.h"
 #include "internal.h"
 
 
@@ -9,29 +12,100 @@ namespace {
 
 
 std::atomic_bool g_installed{false};
 std::atomic_bool g_installed{false};
 
 
+/** One boot-step fix that attaches a detour, in the order the group installs them. */
+struct Fix {
+    StageResult (*stage)(hooking::detour::Spec&) noexcept;
+    void (*publish)(const hooking::detour::Handle&) noexcept;
+};
+
+/**
+ * Every fix that attaches a detour. `world_step` and `fade_release` are absent: they only find
+ * addresses to call, so they open no transaction and cost the group nothing.
+ */
+constexpr std::array kFixes{
+    Fix{&stage_character_select_hold, &publish_character_select_hold},
+    Fix{&stage_orbit_slice_set, &publish_orbit_slice_set},
+    Fix{&stage_profile_setup_skip, &publish_profile_setup_skip},
+    Fix{&stage_composition_check, &publish_composition_check},
+    Fix{&stage_orbit_handoff, &publish_orbit_handoff},
+    Fix{&stage_owner_activity_slot, &publish_owner_activity_slot},
+    Fix{&stage_region_private, &publish_region_private},
+    Fix{&stage_spawn_hold, &publish_spawn_hold},
+};
+
+/** Marks a fix that staged nothing, so no handle is ever published to it. */
+constexpr std::size_t kNotStaged = kFixes.size();
+
+/** One fix's place in the batch, and what it already was before staging. */
+struct Placement {
+    std::size_t slot{kNotStaged};
+    StageResult result{StageResult::unavailable};
+};
+
 } // namespace
 } // namespace
 
 
 /**
 /**
  * Attaches the boot-step fixes that carry sign-in through to orbit.
  * Attaches the boot-step fixes that carry sign-in through to orbit.
  * Each fix stands alone at one site, so a miss on one is reported and the others still attach.
  * Each fix stands alone at one site, so a miss on one is reported and the others still attach.
+ *
+ * Every resolved fix attaches in one transaction rather than one each. A transaction enlists the
+ * threads it must suspend by walking every thread on the system, which is far more work than the
+ * attach itself, so nine transactions cost nine of those walks and one costs one. A fix whose
+ * target is missing simply is not in the batch, which is what keeps one miss off the others. If
+ * the batch itself fails the fixes are retried one at a time, so a single target Detours refuses
+ * cannot take the rest of the group down with it.
  * @return True when every fix attached.
  * @return True when every fix attached.
  */
  */
 bool install() noexcept {
 bool install() noexcept {
-    const bool hold = install_character_select_hold();
-    const bool sliceSet = install_orbit_slice_set();
-    const bool skip = install_profile_setup_skip();
-    const bool composition = install_composition_check();
-    const bool handoff = install_orbit_handoff();
-    const bool ownerSlot = install_owner_activity_slot();
-    const bool regionPrivate = install_region_private();
+    std::array<hooking::detour::Spec, kFixes.size()> specs{};
+    std::array<hooking::detour::Handle, kFixes.size()> handles{};
+    std::array<Placement, kFixes.size()> placement{};
+    std::size_t staged = 0;
+    for (std::size_t index = 0; index < kFixes.size(); ++index) {
+        hooking::detour::Spec spec{};
+        const StageResult result = kFixes[index].stage(spec);
+        placement[index].result = result;
+        if (result != StageResult::staged) {
+            continue;
+        }
+        specs[staged] = spec;
+        placement[index].slot = staged;
+        ++staged;
+    }
+
+    if (staged != 0
+        && !hooking::detour::install(std::span(specs).first(staged),
+                                     std::span(handles).first(staged))) {
+        // One refused target must not cost the others their fix, so the slow path stands them up
+        // separately. It runs only when the whole batch failed, which no supported build does.
+        for (std::size_t slot = 0; slot < staged; ++slot) {
+            handles[slot] = {};
+            (void)hooking::detour::install(specs[slot], handles[slot]);
+        }
+    }
+
+    bool anyFix = false;
+    bool everyFix = true;
+    for (std::size_t index = 0; index < kFixes.size(); ++index) {
+        const Placement& place = placement[index];
+        if (place.slot == kNotStaged) {
+            // An already-attached fix stays attached; only a missing target is a failure.
+            anyFix = anyFix || place.result == StageResult::attached;
+            everyFix = everyFix && place.result == StageResult::attached;
+            continue;
+        }
+        const hooking::detour::Handle& handle = handles[place.slot];
+        kFixes[index].publish(handle);
+        anyFix = anyFix || handle.attached;
+        everyFix = everyFix && handle.attached;
+    }
+
+    // Neither of these attaches anything, so they stay outside the transaction.
     const bool worldStep = install_world_step();
     const bool worldStep = install_world_step();
-    const bool spawn = install_spawn_hold();
     const bool fade = install_fade_release();
     const bool fade = install_fade_release();
-    const bool anyFix = hold || sliceSet || skip || composition || handoff || ownerSlot
-                        || regionPrivate || worldStep || spawn || fade;
+    anyFix = anyFix || worldStep || fade;
     g_installed.store(anyFix, std::memory_order_release);
     g_installed.store(anyFix, std::memory_order_release);
-    return hold && sliceSet && skip && composition && handoff && ownerSlot && regionPrivate
-           && worldStep && spawn && fade;
+    return everyFix && worldStep && fade;
 }
 }
 
 
 /** Detaches every boot-step fix, in the reverse order of install. */
 /** Detaches every boot-step fix, in the reverse order of install. */

+ 15 - 9
Sunrise/src/client/hooks/bootflow/character_select_hold.cpp

@@ -65,32 +65,38 @@ __declspec(noinline) void __fastcall enter_handler(std::byte* step) noexcept {
 } // namespace
 } // namespace
 
 
 /**
 /**
- * Attaches the character-select hold.
- * @return True when the target is found and the detour attaches.
+ * Stages the character-select hold.
+ * @param spec Receives the target and replacement.
+ * @return True when the target is found and the fix wants attaching.
  */
  */
-bool install_character_select_hold() noexcept {
+StageResult stage_character_select_hold(hooking::detour::Spec& spec) noexcept {
     if (g_handle.attached) {
     if (g_handle.attached) {
-        return true;
+        return StageResult::attached;
     }
     }
     std::byte* const target = scan_main_image_unique(kEnterSignature, "character_signin_enter");
     std::byte* const target = scan_main_image_unique(kEnterSignature, "character_signin_enter");
     if (target == nullptr) {
     if (target == nullptr) {
         core::log::write(core::log::Channel::client,
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          core::log::Level::warn,
                          "ev=bootflow stage=character_select result=fail reason=target");
                          "ev=bootflow stage=character_select result=fail reason=target");
-        return false;
+        return StageResult::unavailable;
     }
     }
-    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&enter_handler)};
-    if (!hooking::detour::install(spec, g_handle)) {
+    spec = hooking::detour::Spec{target, reinterpret_cast<void*>(&enter_handler)};
+    return StageResult::staged;
+}
+
+/** Takes the character-select hold's attached handle, or a detached one. */
+void publish_character_select_hold(const hooking::detour::Handle& handle) noexcept {
+    if (!handle.attached) {
         core::log::write(core::log::Channel::client,
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          core::log::Level::warn,
                          "ev=bootflow stage=character_select result=fail reason=attach");
                          "ev=bootflow stage=character_select result=fail reason=attach");
-        return false;
+        return;
     }
     }
+    g_handle = handle;
     g_original.store(reinterpret_cast<EnterHandler>(g_handle.original), std::memory_order_release);
     g_original.store(reinterpret_cast<EnterHandler>(g_handle.original), std::memory_order_release);
     core::log::write(core::log::Channel::client,
     core::log::write(core::log::Channel::client,
                      core::log::Level::info,
                      core::log::Level::info,
                      "ev=bootflow stage=character_select result=ok");
                      "ev=bootflow stage=character_select result=ok");
-    return true;
 }
 }
 
 
 /** Detaches the character-select hold. */
 /** Detaches the character-select hold. */

+ 15 - 9
Sunrise/src/client/hooks/bootflow/composition_check.cpp

@@ -104,32 +104,38 @@ __declspec(noinline) std::int64_t __fastcall check(void* config, std::byte* prop
 } // namespace
 } // namespace
 
 
 /**
 /**
- * Attaches the solo composition fix.
- * @return True when the target is found and the detour attaches.
+ * Stages the solo composition fix.
+ * @param spec Receives the target and replacement.
+ * @return staged when the target is found, unavailable on a miss.
  */
  */
-bool install_composition_check() noexcept {
+StageResult stage_composition_check(hooking::detour::Spec& spec) noexcept {
     if (g_handle.attached) {
     if (g_handle.attached) {
-        return true;
+        return StageResult::attached;
     }
     }
     std::byte* const target = scan_main_image_unique(kCheckSignature, "matchmaking_composition");
     std::byte* const target = scan_main_image_unique(kCheckSignature, "matchmaking_composition");
     if (target == nullptr) {
     if (target == nullptr) {
         core::log::write(core::log::Channel::client,
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          core::log::Level::warn,
                          "ev=bootflow stage=composition result=fail reason=target");
                          "ev=bootflow stage=composition result=fail reason=target");
-        return false;
+        return StageResult::unavailable;
     }
     }
-    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&check)};
-    if (!hooking::detour::install(spec, g_handle)) {
+    spec = hooking::detour::Spec{target, reinterpret_cast<void*>(&check)};
+    return StageResult::staged;
+}
+
+/** Takes the solo composition fix's attached handle, or a detached one. */
+void publish_composition_check(const hooking::detour::Handle& handle) noexcept {
+    if (!handle.attached) {
         core::log::write(core::log::Channel::client,
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          core::log::Level::warn,
                          "ev=bootflow stage=composition result=fail reason=attach");
                          "ev=bootflow stage=composition result=fail reason=attach");
-        return false;
+        return;
     }
     }
+    g_handle = handle;
     g_original.store(reinterpret_cast<Check>(g_handle.original), std::memory_order_release);
     g_original.store(reinterpret_cast<Check>(g_handle.original), std::memory_order_release);
     core::log::write(core::log::Channel::client,
     core::log::write(core::log::Channel::client,
                      core::log::Level::info,
                      core::log::Level::info,
                      "ev=bootflow stage=composition result=ok");
                      "ev=bootflow stage=composition result=ok");
-    return true;
 }
 }
 
 
 /** Detaches the solo composition fix. */
 /** Detaches the solo composition fix. */

+ 75 - 24
Sunrise/src/client/hooks/bootflow/internal.h

@@ -1,5 +1,6 @@
 #pragma once
 #pragma once
 
 
+#include "../../hooking/detour.h"
 #include "../../patterns/image_scan.h"
 #include "../../patterns/image_scan.h"
 
 
 namespace sunrise::client::hooks::bootflow {
 namespace sunrise::client::hooks::bootflow {
@@ -10,66 +11,112 @@ using patterns::signature;
 using patterns::signature_length;
 using patterns::signature_length;
 
 
 /**
 /**
- * Attaches the character-select hold, which stops the sign-in step auto-selecting.
- * @return True when the target is found and the detour attaches.
+ * One boot-step fix resolves its target, then the group attaches every resolved fix together.
+ * Splitting the two halves is what lets the group hold one detour transaction instead of one per
+ * fix. A transaction enlists every thread on the system to find this process's own, which costs
+ * far more than the attach it guards, so the count of transactions is what the boot pays for.
+ *
+ * A publish call is made only for a fix that staged, and takes a detached handle when the group's
+ * attach did not happen.
  */
  */
-[[nodiscard]] bool install_character_select_hold() noexcept;
+enum class StageResult : unsigned char {
+    /** The target is missing. The fix reported that itself and staged nothing. */
+    unavailable,
+    /** An earlier install already attached this fix, so there is nothing to stage. */
+    attached,
+    /** The spec is filled and the fix wants attaching. */
+    staged,
+};
+
+/**
+ * Stages the character-select hold, which stops the sign-in step auto-selecting.
+ * @param spec Receives the target and replacement.
+ * @return staged when the target was found, unavailable on a miss.
+ */
+[[nodiscard]] StageResult stage_character_select_hold(hooking::detour::Spec& spec) noexcept;
+
+/** Takes the character-select hold's attached handle, or a detached one. */
+void publish_character_select_hold(const hooking::detour::Handle& handle) noexcept;
 
 
 /** Detaches the character-select hold. */
 /** Detaches the character-select hold. */
 void uninstall_character_select_hold() noexcept;
 void uninstall_character_select_hold() noexcept;
 
 
 /**
 /**
- * Attaches the profile-setup skip, which skips the startup setup screens.
- * @return True when the target is found and the detour attaches.
+ * Stages the profile-setup skip, which skips the startup setup screens.
+ * @param spec Receives the target and replacement.
+ * @return staged when the target was found, unavailable on a miss.
  */
  */
-[[nodiscard]] bool install_profile_setup_skip() noexcept;
+[[nodiscard]] StageResult stage_profile_setup_skip(hooking::detour::Spec& spec) noexcept;
+
+/** Takes the profile-setup skip's attached handle, or a detached one. */
+void publish_profile_setup_skip(const hooking::detour::Handle& handle) noexcept;
 
 
 /** Detaches the profile-setup skip. */
 /** Detaches the profile-setup skip. */
 void uninstall_profile_setup_skip() noexcept;
 void uninstall_profile_setup_skip() noexcept;
 
 
 /**
 /**
- * Attaches the orbit slice-set picker, so the sign-in step's map load finds its target.
- * @return True when the picker is found and the detour attaches.
+ * Stages the orbit slice-set picker, so the sign-in step's map load finds its target.
+ * @param spec Receives the target and replacement.
+ * @return staged when the picker was found, unavailable on a miss.
  */
  */
-[[nodiscard]] bool install_orbit_slice_set() noexcept;
+[[nodiscard]] StageResult stage_orbit_slice_set(hooking::detour::Spec& spec) noexcept;
+
+/** Takes the orbit slice-set picker's attached handle, or a detached one. */
+void publish_orbit_slice_set(const hooking::detour::Handle& handle) noexcept;
 
 
 /** Detaches the orbit slice-set picker. */
 /** Detaches the orbit slice-set picker. */
 void uninstall_orbit_slice_set() noexcept;
 void uninstall_orbit_slice_set() noexcept;
 
 
 /**
 /**
- * Attaches the solo composition fix, which clears the count the matchmaking check rejects.
- * @return True when the target is found and the detour attaches.
+ * Stages the solo composition fix, which clears the count the matchmaking check rejects.
+ * @param spec Receives the target and replacement.
+ * @return staged when the target was found, unavailable on a miss.
  */
  */
-[[nodiscard]] bool install_composition_check() noexcept;
+[[nodiscard]] StageResult stage_composition_check(hooking::detour::Spec& spec) noexcept;
+
+/** Takes the solo composition fix's attached handle, or a detached one. */
+void publish_composition_check(const hooking::detour::Handle& handle) noexcept;
 
 
 /** Detaches the solo composition fix. */
 /** Detaches the solo composition fix. */
 void uninstall_composition_check() noexcept;
 void uninstall_composition_check() noexcept;
 
 
 /**
 /**
- * Attaches the orbit handoff release, which stops the destination step parking.
- * @return True when the target is found and the detour attaches.
+ * Stages the orbit handoff release, which stops the destination step parking.
+ * @param spec Receives the target and replacement.
+ * @return staged when the target was found, unavailable on a miss.
  */
  */
-[[nodiscard]] bool install_orbit_handoff() noexcept;
+[[nodiscard]] StageResult stage_orbit_handoff(hooking::detour::Spec& spec) noexcept;
+
+/** Takes the orbit handoff release's attached handle, or a detached one. */
+void publish_orbit_handoff(const hooking::detour::Handle& handle) noexcept;
 
 
 /** Detaches the orbit handoff release. */
 /** Detaches the orbit handoff release. */
 void uninstall_orbit_handoff() noexcept;
 void uninstall_orbit_handoff() noexcept;
 
 
 /**
 /**
- * Attaches the owner activity slot force. It pins the participation record to the replicated
+ * Stages the owner activity slot force. It pins the participation record to the replicated
  * snapshot at `comp + 496` instead of the local one at `comp + 1256`.
  * snapshot at `comp + 496` instead of the local one at `comp + 1256`.
- * @return True when the target is found and the detour attaches.
+ * @param spec Receives the target and replacement.
+ * @return staged when the target was found, unavailable on a miss.
  */
  */
-[[nodiscard]] bool install_owner_activity_slot() noexcept;
+[[nodiscard]] StageResult stage_owner_activity_slot(hooking::detour::Spec& spec) noexcept;
+
+/** Takes the owner activity slot force's attached handle, or a detached one. */
+void publish_owner_activity_slot(const hooking::detour::Handle& handle) noexcept;
 
 
 /** Detaches the owner activity slot force. */
 /** Detaches the owner activity slot force. */
 void uninstall_owner_activity_slot() noexcept;
 void uninstall_owner_activity_slot() noexcept;
 
 
 /**
 /**
- * Attaches the private-region force, so a public region takes the path a private one takes.
+ * Stages the private-region force, so a public region takes the path a private one takes.
  * A public region otherwise holds its slice-set switch until a public activity host connects.
  * A public region otherwise holds its slice-set switch until a public activity host connects.
- * @return True when both targets are found, the call site is unique and the detour attaches.
+ * @param spec Receives the target and replacement.
+ * @return staged when both targets and the call site were found, unavailable on a miss.
  */
  */
-[[nodiscard]] bool install_region_private() noexcept;
+[[nodiscard]] StageResult stage_region_private(hooking::detour::Spec& spec) noexcept;
+
+/** Takes the private-region force's attached handle, or a detached one. */
+void publish_region_private(const hooking::detour::Handle& handle) noexcept;
 
 
 /** Detaches the private-region force. */
 /** Detaches the private-region force. */
 void uninstall_region_private() noexcept;
 void uninstall_region_private() noexcept;
@@ -91,10 +138,14 @@ void uninstall_world_step() noexcept;
 void observe_world_step() noexcept;
 void observe_world_step() noexcept;
 
 
 /**
 /**
- * Attaches the spawn hold, which puts the player spawn after the world-transition fade is armed.
- * @return True when the target is found and the detour attaches.
+ * Stages the spawn hold, which puts the player spawn after the world-transition fade is armed.
+ * @param spec Receives the target and replacement.
+ * @return staged when the target was found, unavailable on a miss.
  */
  */
-[[nodiscard]] bool install_spawn_hold() noexcept;
+[[nodiscard]] StageResult stage_spawn_hold(hooking::detour::Spec& spec) noexcept;
+
+/** Takes the spawn hold's attached handle, or a detached one. */
+void publish_spawn_hold(const hooking::detour::Handle& handle) noexcept;
 
 
 /** Detaches the spawn hold. */
 /** Detaches the spawn hold. */
 void uninstall_spawn_hold() noexcept;
 void uninstall_spawn_hold() noexcept;

+ 15 - 9
Sunrise/src/client/hooks/bootflow/orbit_handoff.cpp

@@ -65,31 +65,37 @@ __declspec(noinline) bool __fastcall destination_hold(void* stepCtx) noexcept {
 } // namespace
 } // namespace
 
 
 /**
 /**
- * Attaches the orbit handoff release.
- * @return True when the target is found and the detour attaches.
+ * Stages the orbit handoff release.
+ * @param spec Receives the target and replacement.
+ * @return True when the target is found and the fix wants attaching.
  */
  */
-bool install_orbit_handoff() noexcept {
+StageResult stage_orbit_handoff(hooking::detour::Spec& spec) noexcept {
     if (g_handle.attached) {
     if (g_handle.attached) {
-        return true;
+        return StageResult::attached;
     }
     }
     std::byte* const target = scan_main_image_unique(kHoldSignature, "orbit_destination_hold");
     std::byte* const target = scan_main_image_unique(kHoldSignature, "orbit_destination_hold");
     if (target == nullptr) {
     if (target == nullptr) {
         core::log::write(core::log::Channel::client,
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          core::log::Level::warn,
                          "ev=bootflow stage=orbit_handoff result=fail reason=target");
                          "ev=bootflow stage=orbit_handoff result=fail reason=target");
-        return false;
+        return StageResult::unavailable;
     }
     }
-    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&destination_hold)};
-    if (!hooking::detour::install(spec, g_handle)) {
+    spec = hooking::detour::Spec{target, reinterpret_cast<void*>(&destination_hold)};
+    return StageResult::staged;
+}
+
+/** Takes the orbit handoff release's attached handle, or a detached one. */
+void publish_orbit_handoff(const hooking::detour::Handle& handle) noexcept {
+    if (!handle.attached) {
         core::log::write(core::log::Channel::client,
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          core::log::Level::warn,
                          "ev=bootflow stage=orbit_handoff result=fail reason=attach");
                          "ev=bootflow stage=orbit_handoff result=fail reason=attach");
-        return false;
+        return;
     }
     }
+    g_handle = handle;
     core::log::write(core::log::Channel::client,
     core::log::write(core::log::Channel::client,
                      core::log::Level::info,
                      core::log::Level::info,
                      "ev=bootflow stage=orbit_handoff result=ok");
                      "ev=bootflow stage=orbit_handoff result=ok");
-    return true;
 }
 }
 
 
 /** Detaches the orbit handoff release. */
 /** Detaches the orbit handoff release. */

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

@@ -68,29 +68,34 @@ std::uint32_t* __fastcall pick_target(LoaderContext* context, std::uint32_t* sel
 
 
 } // namespace
 } // namespace
 
 
-/** Attaches the picker so the orbit target is found. */
-bool install_orbit_slice_set() noexcept {
+/** Stages the picker so the orbit target is found. */
+StageResult stage_orbit_slice_set(hooking::detour::Spec& spec) noexcept {
     if (g_handle.attached) {
     if (g_handle.attached) {
-        return true;
+        return StageResult::attached;
     }
     }
     std::byte* const picker = scan_main_image_unique(kPickerSignature, "slice_set_target_picker");
     std::byte* const picker = scan_main_image_unique(kPickerSignature, "slice_set_target_picker");
     if (picker == nullptr) {
     if (picker == nullptr) {
         core::log::write(core::log::Channel::client,
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          core::log::Level::warn,
                          "ev=bootflow stage=slice_set result=fail reason=target");
                          "ev=bootflow stage=slice_set result=fail reason=target");
-        return false;
+        return StageResult::unavailable;
     }
     }
-    const hooking::detour::Spec spec{picker, reinterpret_cast<void*>(&pick_target)};
-    if (!hooking::detour::install(spec, g_handle)) {
+    spec = hooking::detour::Spec{picker, reinterpret_cast<void*>(&pick_target)};
+    return StageResult::staged;
+}
+
+/** Takes the picker's attached handle, or a detached one. */
+void publish_orbit_slice_set(const hooking::detour::Handle& handle) noexcept {
+    if (!handle.attached) {
         core::log::write(core::log::Channel::client,
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          core::log::Level::warn,
                          "ev=bootflow stage=slice_set result=fail reason=attach");
                          "ev=bootflow stage=slice_set result=fail reason=attach");
-        return false;
+        return;
     }
     }
+    g_handle = handle;
     core::log::write(core::log::Channel::client,
     core::log::write(core::log::Channel::client,
                      core::log::Level::info,
                      core::log::Level::info,
                      "ev=bootflow stage=slice_set result=ok");
                      "ev=bootflow stage=slice_set result=ok");
-    return true;
 }
 }
 
 
 /** Detaches the picker. */
 /** Detaches the picker. */

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

@@ -95,30 +95,35 @@ __declspec(noinline) std::uint8_t __fastcall check(void* container,
 
 
 } // namespace
 } // namespace
 
 
-/** Attaches the owner activity slot force. */
-bool install_owner_activity_slot() noexcept {
+/** Stages the owner activity slot force. */
+StageResult stage_owner_activity_slot(hooking::detour::Spec& spec) noexcept {
     if (g_handle.attached) {
     if (g_handle.attached) {
-        return true;
+        return StageResult::attached;
     }
     }
     std::byte* const target = scan_main_image_unique(kCheckSignature, "check_activity_bubbles");
     std::byte* const target = scan_main_image_unique(kCheckSignature, "check_activity_bubbles");
     if (target == nullptr) {
     if (target == nullptr) {
         core::log::write(core::log::Channel::client,
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          core::log::Level::warn,
                          "ev=bootflow stage=owner_slot result=fail reason=target");
                          "ev=bootflow stage=owner_slot result=fail reason=target");
-        return false;
+        return StageResult::unavailable;
     }
     }
-    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&check)};
-    if (!hooking::detour::install(spec, g_handle)) {
+    spec = hooking::detour::Spec{target, reinterpret_cast<void*>(&check)};
+    return StageResult::staged;
+}
+
+/** Takes the owner activity slot force's attached handle, or a detached one. */
+void publish_owner_activity_slot(const hooking::detour::Handle& handle) noexcept {
+    if (!handle.attached) {
         core::log::write(core::log::Channel::client,
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          core::log::Level::warn,
                          "ev=bootflow stage=owner_slot result=fail reason=attach");
                          "ev=bootflow stage=owner_slot result=fail reason=attach");
-        return false;
+        return;
     }
     }
+    g_handle = handle;
     g_original.store(reinterpret_cast<CheckBubbles>(g_handle.original), std::memory_order_release);
     g_original.store(reinterpret_cast<CheckBubbles>(g_handle.original), std::memory_order_release);
     core::log::write(core::log::Channel::client,
     core::log::write(core::log::Channel::client,
                      core::log::Level::info,
                      core::log::Level::info,
                      "ev=bootflow stage=owner_slot result=ok");
                      "ev=bootflow stage=owner_slot result=ok");
-    return true;
 }
 }
 
 
 /** Detaches the owner activity slot force. */
 /** Detaches the owner activity slot force. */

+ 15 - 9
Sunrise/src/client/hooks/bootflow/profile_setup_skip.cpp

@@ -90,32 +90,38 @@ __declspec(noinline) char __fastcall update(std::byte* step) noexcept {
 } // namespace
 } // namespace
 
 
 /**
 /**
- * Attaches the profile-setup skip.
- * @return True when the target is found and the detour attaches.
+ * Stages the profile-setup skip.
+ * @param spec Receives the target and replacement.
+ * @return staged when the target is found, unavailable on a miss.
  */
  */
-bool install_profile_setup_skip() noexcept {
+StageResult stage_profile_setup_skip(hooking::detour::Spec& spec) noexcept {
     if (g_handle.attached) {
     if (g_handle.attached) {
-        return true;
+        return StageResult::attached;
     }
     }
     std::byte* const target = scan_main_image_unique(kUpdateSignature, "profile_setup_update");
     std::byte* const target = scan_main_image_unique(kUpdateSignature, "profile_setup_update");
     if (target == nullptr) {
     if (target == nullptr) {
         core::log::write(core::log::Channel::client,
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          core::log::Level::warn,
                          "ev=bootflow stage=profile_setup result=fail reason=target");
                          "ev=bootflow stage=profile_setup result=fail reason=target");
-        return false;
+        return StageResult::unavailable;
     }
     }
-    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&update)};
-    if (!hooking::detour::install(spec, g_handle)) {
+    spec = hooking::detour::Spec{target, reinterpret_cast<void*>(&update)};
+    return StageResult::staged;
+}
+
+/** Takes the profile-setup skip's attached handle, or a detached one. */
+void publish_profile_setup_skip(const hooking::detour::Handle& handle) noexcept {
+    if (!handle.attached) {
         core::log::write(core::log::Channel::client,
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          core::log::Level::warn,
                          "ev=bootflow stage=profile_setup result=fail reason=attach");
                          "ev=bootflow stage=profile_setup result=fail reason=attach");
-        return false;
+        return;
     }
     }
+    g_handle = handle;
     g_original.store(reinterpret_cast<Update>(g_handle.original), std::memory_order_release);
     g_original.store(reinterpret_cast<Update>(g_handle.original), std::memory_order_release);
     core::log::write(core::log::Channel::client,
     core::log::write(core::log::Channel::client,
                      core::log::Level::info,
                      core::log::Level::info,
                      "ev=bootflow stage=profile_setup result=ok");
                      "ev=bootflow stage=profile_setup result=ok");
-    return true;
 }
 }
 
 
 /** Detaches the profile-setup skip. */
 /** Detaches the profile-setup skip. */

+ 21 - 13
Sunrise/src/client/hooks/bootflow/region_private.cpp

@@ -135,8 +135,8 @@ __declspec(noinline) bool __fastcall reader(std::uint32_t sliceSet) noexcept {
     return !forced;
     return !forced;
 }
 }
 
 
-/** @param reason Key naming the step that failed. @return False, for a direct return. */
-[[nodiscard]] bool fail(const char* reason) noexcept {
+/** @param reason Key naming the step that failed. */
+void report_failure(const char* reason) noexcept {
     std::array<char, kLineCapacity> line{};
     std::array<char, kLineCapacity> line{};
     const int written = std::snprintf(
     const int written = std::snprintf(
         line.data(), line.size(), "ev=bootflow stage=region result=fail reason=%s", reason);
         line.data(), line.size(), "ev=bootflow stage=region result=fail reason=%s", reason);
@@ -145,39 +145,47 @@ __declspec(noinline) bool __fastcall reader(std::uint32_t sliceSet) noexcept {
                          core::log::Level::warn,
                          core::log::Level::warn,
                          {line.data(), static_cast<std::size_t>(written)});
                          {line.data(), static_cast<std::size_t>(written)});
     }
     }
-    return false;
 }
 }
 
 
 } // namespace
 } // namespace
 
 
-/** Attaches the private-region force. */
-bool install_region_private() noexcept {
+/** Stages the private-region force. */
+StageResult stage_region_private(hooking::detour::Spec& spec) noexcept {
     if (g_handle.attached) {
     if (g_handle.attached) {
-        return true;
+        return StageResult::attached;
     }
     }
     std::byte* const target = scan_main_image_unique(kReaderSignature, "slice_set_is_public");
     std::byte* const target = scan_main_image_unique(kReaderSignature, "slice_set_is_public");
     if (target == nullptr) {
     if (target == nullptr) {
-        return fail("reader");
+        report_failure("reader");
+        return StageResult::unavailable;
     }
     }
     const std::byte* const starter =
     const std::byte* const starter =
         scan_main_image_unique(kStarterSignature, "region_start_transition");
         scan_main_image_unique(kStarterSignature, "region_start_transition");
     if (starter == nullptr) {
     if (starter == nullptr) {
-        return fail("starter");
+        report_failure("starter");
+        return StageResult::unavailable;
     }
     }
     const std::byte* const returnSite = find_return_site(starter, target);
     const std::byte* const returnSite = find_return_site(starter, target);
     if (returnSite == nullptr) {
     if (returnSite == nullptr) {
-        return fail("call_site");
+        report_failure("call_site");
+        return StageResult::unavailable;
     }
     }
     // Published before the detour attaches, so the first call already has its filter.
     // Published before the detour attaches, so the first call already has its filter.
     g_returnSite.store(returnSite, std::memory_order_release);
     g_returnSite.store(returnSite, std::memory_order_release);
-    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&reader)};
-    if (!hooking::detour::install(spec, g_handle)) {
-        return fail("attach");
+    spec = hooking::detour::Spec{target, reinterpret_cast<void*>(&reader)};
+    return StageResult::staged;
+}
+
+/** Takes the private-region force's attached handle, or a detached one. */
+void publish_region_private(const hooking::detour::Handle& handle) noexcept {
+    if (!handle.attached) {
+        report_failure("attach");
+        return;
     }
     }
+    g_handle = handle;
     g_original.store(reinterpret_cast<Reader>(g_handle.original), std::memory_order_release);
     g_original.store(reinterpret_cast<Reader>(g_handle.original), std::memory_order_release);
     core::log::write(
     core::log::write(
         core::log::Channel::client, core::log::Level::info, "ev=bootflow stage=region result=ok");
         core::log::Channel::client, core::log::Level::info, "ev=bootflow stage=region result=ok");
-    return true;
 }
 }
 
 
 /** Detaches the private-region force. */
 /** Detaches the private-region force. */

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

@@ -99,36 +99,41 @@ __declspec(noinline) bool __fastcall spawn_gate(std::int32_t datum) noexcept {
 
 
 } // namespace
 } // namespace
 
 
-/** Attaches the spawn hold. */
-bool install_spawn_hold() noexcept {
+/** Stages the spawn hold. */
+StageResult stage_spawn_hold(hooking::detour::Spec& spec) noexcept {
     if (g_handle.attached) {
     if (g_handle.attached) {
-        return true;
+        return StageResult::attached;
     }
     }
     std::byte* const target = scan_main_image_unique(kSpawnGateSignature, "player_spawn_gate");
     std::byte* const target = scan_main_image_unique(kSpawnGateSignature, "player_spawn_gate");
     if (target == nullptr) {
     if (target == nullptr) {
         core::log::write(core::log::Channel::client,
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          core::log::Level::warn,
                          "ev=bootflow stage=spawn_hold result=fail reason=target");
                          "ev=bootflow stage=spawn_hold result=fail reason=target");
-        return false;
+        return StageResult::unavailable;
     }
     }
     if (!spawn::resolve(target)) {
     if (!spawn::resolve(target)) {
         core::log::write(core::log::Channel::client,
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          core::log::Level::warn,
                          "ev=bootflow stage=current_slice result=fail reason=targets");
                          "ev=bootflow stage=current_slice result=fail reason=targets");
     }
     }
-    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&spawn_gate)};
-    if (!hooking::detour::install(spec, g_handle)) {
+    spec = hooking::detour::Spec{target, reinterpret_cast<void*>(&spawn_gate)};
+    return StageResult::staged;
+}
+
+/** Takes the spawn hold's attached handle, or a detached one. */
+void publish_spawn_hold(const hooking::detour::Handle& handle) noexcept {
+    if (!handle.attached) {
         spawn::forget();
         spawn::forget();
         core::log::write(core::log::Channel::client,
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
                          core::log::Level::warn,
                          "ev=bootflow stage=spawn_hold result=fail reason=attach");
                          "ev=bootflow stage=spawn_hold result=fail reason=attach");
-        return false;
+        return;
     }
     }
+    g_handle = handle;
     g_original.store(reinterpret_cast<SpawnGate>(g_handle.original), std::memory_order_release);
     g_original.store(reinterpret_cast<SpawnGate>(g_handle.original), std::memory_order_release);
     core::log::write(core::log::Channel::client,
     core::log::write(core::log::Channel::client,
                      core::log::Level::info,
                      core::log::Level::info,
                      "ev=bootflow stage=spawn_hold result=ok");
                      "ev=bootflow stage=spawn_hold result=ok");
-    return true;
 }
 }
 
 
 /** Detaches the spawn hold. */
 /** Detaches the spawn hold. */

+ 0 - 1
Sunrise/src/client/hooks/egress/internal.h

@@ -47,7 +47,6 @@ enum class HookSlot : std::size_t {
 /** Fixed handle count covers every required and OS-optional egress entry point. */
 /** Fixed handle count covers every required and OS-optional egress entry point. */
 inline constexpr std::size_t kHookCount = static_cast<std::size_t>(HookSlot::count);
 inline constexpr std::size_t kHookCount = static_cast<std::size_t>(HookSlot::count);
 
 
-extern SRWLOCK g_lock;
 extern std::array<hooking::detour::Handle, kHookCount> g_handles;
 extern std::array<hooking::detour::Handle, kHookCount> g_handles;
 
 
 /**
 /**

+ 6 - 12
Sunrise/src/client/hooks/egress/lifecycle/egress_guard_lifecycle.cpp

@@ -1,16 +1,17 @@
 #include <algorithm>
 #include <algorithm>
 #include <array>
 #include <array>
 #include <cstdio>
 #include <cstdio>
+#include <shared_mutex>
 
 
 #include "../../../../core/logging/log.h"
 #include "../../../../core/logging/log.h"
 #include "../internal.h"
 #include "../internal.h"
-#include "../platform/abi.h"
 #include "../runtime.h"
 #include "../runtime.h"
+#include "core/threading/srw_lock.h"
 #include "internal.h"
 #include "internal.h"
 
 
 namespace sunrise::client::hooks::egress {
 namespace sunrise::client::hooks::egress {
 
 
-SRWLOCK g_lock{SRWLOCK_INIT};
+core::threading::SrwLock g_lock{};
 std::array<hooking::detour::Handle, kHookCount> g_handles{};
 std::array<hooking::detour::Handle, kHookCount> g_handles{};
 
 
 namespace {
 namespace {
@@ -64,13 +65,11 @@ std::size_t g_activeHookCount{};
 
 
 /** Installs every resolver and socket guard in one process-wide transaction. */
 /** Installs every resolver and socket guard in one process-wide transaction. */
 bool install() noexcept {
 bool install() noexcept {
-    AcquireSRWLockExclusive(&g_lock);
+    const std::lock_guard lock(g_lock);
     if (all_installed()) {
     if (all_installed()) {
-        ReleaseSRWLockExclusive(&g_lock);
         return true;
         return true;
     }
     }
     if (any_installed() || !pin_owner_module() || !lifecycle::load_modules()) {
     if (any_installed() || !pin_owner_module() || !lifecycle::load_modules()) {
-        ReleaseSRWLockExclusive(&g_lock);
         return false;
         return false;
     }
     }
 
 
@@ -82,20 +81,17 @@ bool install() noexcept {
         g_activeHookCount = 0;
         g_activeHookCount = 0;
         g_batchAttached = false;
         g_batchAttached = false;
         lifecycle::release_modules();
         lifecycle::release_modules();
-        ReleaseSRWLockExclusive(&g_lock);
         return false;
         return false;
     }
     }
     g_activeHookCount = count;
     g_activeHookCount = count;
     g_batchAttached = true;
     g_batchAttached = true;
-    ReleaseSRWLockExclusive(&g_lock);
     return true;
     return true;
 }
 }
 
 
 /** Emits one line per guarded export, then the batch outcome. */
 /** Emits one line per guarded export, then the batch outcome. */
 void report_installation() noexcept {
 void report_installation() noexcept {
-    AcquireSRWLockExclusive(&g_lock);
+    const std::lock_guard lock(g_lock);
     if (g_reported) {
     if (g_reported) {
-        ReleaseSRWLockExclusive(&g_lock);
         return;
         return;
     }
     }
     g_reported = true;
     g_reported = true;
@@ -118,7 +114,6 @@ void report_installation() noexcept {
                              {line.data(), static_cast<std::size_t>(written)});
                              {line.data(), static_cast<std::size_t>(written)});
         }
         }
     }
     }
-    ReleaseSRWLockExclusive(&g_lock);
     std::array<char, 96> summary{};
     std::array<char, 96> summary{};
     const int written = std::snprintf(summary.data(),
     const int written = std::snprintf(summary.data(),
                                       summary.size(),
                                       summary.size(),
@@ -134,9 +129,8 @@ void report_installation() noexcept {
 
 
 /** @return True only when every required guard detour is attached. */
 /** @return True only when every required guard detour is attached. */
 bool is_installed() noexcept {
 bool is_installed() noexcept {
-    AcquireSRWLockShared(&g_lock);
+    const std::shared_lock lock(g_lock);
     const bool installed = all_installed();
     const bool installed = all_installed();
-    ReleaseSRWLockShared(&g_lock);
     return installed;
     return installed;
 }
 }
 
 

+ 7 - 18
Sunrise/src/client/hooks/graphics/input/graphics_window_input.cpp

@@ -2,11 +2,13 @@
 
 
 #include <atomic>
 #include <atomic>
 #include <bit>
 #include <bit>
+#include <shared_mutex>
 
 
 #include "../../../../core/ui/layout/credits/sunrise_credits_badge.h"
 #include "../../../../core/ui/layout/credits/sunrise_credits_badge.h"
 #include "../../../../core/ui/modules/logs/logs.h"
 #include "../../../../core/ui/modules/logs/logs.h"
 #include "../../../../core/ui/runtime/ui_visibility_runtime.h"
 #include "../../../../core/ui/runtime/ui_visibility_runtime.h"
 #include "../renderer/renderer.h"
 #include "../renderer/renderer.h"
+#include "core/threading/srw_lock.h"
 #include "input.h"
 #include "input.h"
 
 
 namespace sunrise::client::hooks::graphics::input {
 namespace sunrise::client::hooks::graphics::input {
@@ -24,7 +26,7 @@ struct Binding {
 
 
 Binding g_binding{};
 Binding g_binding{};
 std::atomic_uint g_activeCallbacks{};
 std::atomic_uint g_activeCallbacks{};
-SRWLOCK g_inputLock{SRWLOCK_INIT};
+core::threading::SrwLock g_inputLock{};
 
 
 /** Counts active procedure calls so teardown can be retried before module unload. */
 /** Counts active procedure calls so teardown can be retried before module unload. */
 class CallbackGuard final {
 class CallbackGuard final {
@@ -48,9 +50,8 @@ public:
  * @return Original procedure for the matching record, or null when nothing matches.
  * @return Original procedure for the matching record, or null when nothing matches.
  */
  */
 [[nodiscard]] WNDPROC original_for(HWND window) noexcept {
 [[nodiscard]] WNDPROC original_for(HWND window) noexcept {
-    AcquireSRWLockShared(&g_inputLock);
+    const std::shared_lock lock(g_inputLock);
     const WNDPROC original = g_binding.window == window ? g_binding.original : nullptr;
     const WNDPROC original = g_binding.window == window ? g_binding.original : nullptr;
-    ReleaseSRWLockShared(&g_inputLock);
     return original;
     return original;
 }
 }
 
 
@@ -122,15 +123,13 @@ bool install(HWND window) noexcept {
     if (window == nullptr || IsWindow(window) == FALSE) {
     if (window == nullptr || IsWindow(window) == FALSE) {
         return false;
         return false;
     }
     }
-    AcquireSRWLockExclusive(&g_inputLock);
+    const std::lock_guard lock(g_inputLock);
     if (g_binding.installed) {
     if (g_binding.installed) {
         const bool sameWindow = g_binding.window == window;
         const bool sameWindow = g_binding.window == window;
-        ReleaseSRWLockExclusive(&g_inputLock);
         return sameWindow;
         return sameWindow;
     }
     }
     if (g_activeCallbacks.load(std::memory_order_acquire) != 0) {
     if (g_activeCallbacks.load(std::memory_order_acquire) != 0) {
         // A retired procedure keeps its forwarding record until every old call returns.
         // A retired procedure keeps its forwarding record until every old call returns.
-        ReleaseSRWLockExclusive(&g_inputLock);
         return false;
         return false;
     }
     }
 
 
@@ -138,26 +137,22 @@ bool install(HWND window) noexcept {
     const LONG_PTR original =
     const LONG_PTR original =
         SetWindowLongPtrW(window, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(&window_procedure));
         SetWindowLongPtrW(window, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(&window_procedure));
     if (original == 0 && GetLastError() != ERROR_SUCCESS) {
     if (original == 0 && GetLastError() != ERROR_SUCCESS) {
-        ReleaseSRWLockExclusive(&g_inputLock);
         return false;
         return false;
     }
     }
     g_binding = Binding{window, std::bit_cast<WNDPROC>(original), true};
     g_binding = Binding{window, std::bit_cast<WNDPROC>(original), true};
-    ReleaseSRWLockExclusive(&g_inputLock);
     return true;
     return true;
 }
 }
 
 
 /** Restores the original procedure only when Sunrise still owns the chain head. */
 /** Restores the original procedure only when Sunrise still owns the chain head. */
 bool uninstall() noexcept {
 bool uninstall() noexcept {
-    AcquireSRWLockExclusive(&g_inputLock);
+    const std::lock_guard lock(g_inputLock);
     if (!g_binding.installed) {
     if (!g_binding.installed) {
         const bool idle = g_activeCallbacks.load(std::memory_order_acquire) == 0;
         const bool idle = g_activeCallbacks.load(std::memory_order_acquire) == 0;
-        ReleaseSRWLockExclusive(&g_inputLock);
         return idle;
         return idle;
     }
     }
     if (IsWindow(g_binding.window) == FALSE) {
     if (IsWindow(g_binding.window) == FALSE) {
         g_binding.installed = false;
         g_binding.installed = false;
         const bool idle = g_activeCallbacks.load(std::memory_order_acquire) == 0;
         const bool idle = g_activeCallbacks.load(std::memory_order_acquire) == 0;
-        ReleaseSRWLockExclusive(&g_inputLock);
         return idle;
         return idle;
     }
     }
 
 
@@ -165,19 +160,16 @@ bool uninstall() noexcept {
     const LONG_PTR current = GetWindowLongPtrW(g_binding.window, GWLP_WNDPROC);
     const LONG_PTR current = GetWindowLongPtrW(g_binding.window, GWLP_WNDPROC);
     const LONG_PTR replacement = reinterpret_cast<LONG_PTR>(&window_procedure);
     const LONG_PTR replacement = reinterpret_cast<LONG_PTR>(&window_procedure);
     if (current == 0 && GetLastError() != ERROR_SUCCESS) {
     if (current == 0 && GetLastError() != ERROR_SUCCESS) {
-        ReleaseSRWLockExclusive(&g_inputLock);
         return false;
         return false;
     }
     }
     if (current == reinterpret_cast<LONG_PTR>(g_binding.original)) {
     if (current == reinterpret_cast<LONG_PTR>(g_binding.original)) {
         // The window owner already put our forwarding target back itself.
         // The window owner already put our forwarding target back itself.
         g_binding.installed = false;
         g_binding.installed = false;
         const bool idle = g_activeCallbacks.load(std::memory_order_acquire) == 0;
         const bool idle = g_activeCallbacks.load(std::memory_order_acquire) == 0;
-        ReleaseSRWLockExclusive(&g_inputLock);
         return idle;
         return idle;
     }
     }
     if (current != replacement) {
     if (current != replacement) {
         // A later subclass owns the chain head now, so do not overwrite it.
         // A later subclass owns the chain head now, so do not overwrite it.
-        ReleaseSRWLockExclusive(&g_inputLock);
         return false;
         return false;
     }
     }
 
 
@@ -185,19 +177,17 @@ bool uninstall() noexcept {
     const LONG_PTR replaced = SetWindowLongPtrW(
     const LONG_PTR replaced = SetWindowLongPtrW(
         g_binding.window, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(g_binding.original));
         g_binding.window, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(g_binding.original));
     if (replaced == 0 && GetLastError() != ERROR_SUCCESS) {
     if (replaced == 0 && GetLastError() != ERROR_SUCCESS) {
-        ReleaseSRWLockExclusive(&g_inputLock);
         return false;
         return false;
     }
     }
     // Keep the forwarding target until a later install replaces this retired record.
     // Keep the forwarding target until a later install replaces this retired record.
     g_binding.installed = false;
     g_binding.installed = false;
     const bool idle = g_activeCallbacks.load(std::memory_order_acquire) == 0;
     const bool idle = g_activeCallbacks.load(std::memory_order_acquire) == 0;
-    ReleaseSRWLockExclusive(&g_inputLock);
     return idle;
     return idle;
 }
 }
 
 
 /** Checks whether Sunrise is still installed, or still sits below a later subclass. */
 /** Checks whether Sunrise is still installed, or still sits below a later subclass. */
 bool active(HWND window) noexcept {
 bool active(HWND window) noexcept {
-    AcquireSRWLockShared(&g_inputLock);
+    const std::shared_lock lock(g_inputLock);
     bool installed = g_binding.installed && g_binding.window == window && IsWindow(window) != FALSE
     bool installed = g_binding.installed && g_binding.window == window && IsWindow(window) != FALSE
                      && IsWindowVisible(window) != FALSE;
                      && IsWindowVisible(window) != FALSE;
     if (installed) {
     if (installed) {
@@ -209,7 +199,6 @@ bool active(HWND window) noexcept {
             installed = current != reinterpret_cast<LONG_PTR>(g_binding.original);
             installed = current != reinterpret_cast<LONG_PTR>(g_binding.original);
         }
         }
     }
     }
-    ReleaseSRWLockShared(&g_inputLock);
     return installed;
     return installed;
 }
 }
 
 

+ 124 - 0
Sunrise/src/client/hooks/membership_probe/membership_probe.cpp

@@ -182,6 +182,129 @@ void report_bind_inputs(const std::byte* client) noexcept {
     }
     }
 }
 }
 
 
+/** Bytes per hex log line. Two characters a byte keeps a line well inside its capacity. */
+constexpr std::size_t kHexBytesPerLine = 64;
+/** Bytes at the membership header to dump, which is where the decoded member table starts. */
+constexpr std::size_t kMemberDumpBytes = 512;
+/** Most of one region record to dump. A record carrying a 128-byte descriptor still fits. */
+constexpr std::size_t kRegionDumpBytes = 320;
+/** The wire numbers regions 0, 8, 16 ... 504, so consecutive terms differ by this. */
+constexpr std::int32_t kRegionIndexStride = 8;
+/** Terms matched before a candidate is accepted as the region table. */
+constexpr std::size_t kRegionMatchTerms = 4;
+/** Largest in-memory stride between two region records that is still worth testing. */
+constexpr std::size_t kMaximumRegionStride = 8'192;
+/** End of the searchable span. The entity-slot mask sits above it and holds no region table. */
+constexpr std::size_t kScanEndOffset = kPendingMaskOffset;
+/** Clients whose region table is dumped. Later messages repeat a table that has already been read.
+ */
+constexpr std::uint32_t kRegionDumpBudget = 3;
+
+std::atomic<std::uint32_t> g_regionDumps{0};
+
+/**
+ * Emits one labelled hex run over as many lines as it needs.
+ * @param stage Log stage name.
+ * @param base Offset the run starts at, so a line names where its bytes came from.
+ * @param data First byte of the run.
+ * @param size Bytes to emit.
+ */
+void report_hex(const char* stage,
+                std::size_t base,
+                const std::byte* data,
+                std::size_t size) noexcept {
+    for (std::size_t offset = 0; offset < size; offset += kHexBytesPerLine) {
+        std::array<char, core::log::kLineCapacity> line{};
+        const std::size_t run =
+            (size - offset) < kHexBytesPerLine ? size - offset : kHexBytesPerLine;
+        int written = std::snprintf(
+            line.data(), line.size(), "ev=probe stage=%s at=%zu raw=", stage, base + offset);
+        for (std::size_t index = 0; written > 0 && index < run; ++index) {
+            const int part =
+                std::snprintf(line.data() + written,
+                              line.size() - static_cast<std::size_t>(written),
+                              "%02X",
+                              std::to_integer<unsigned>(data[offset + index]));
+            if (part <= 0) {
+                break;
+            }
+            written += part;
+        }
+        if (written > 0) {
+            core::log::write(core::log::Channel::client,
+                             core::log::Level::info,
+                             {line.data(), static_cast<std::size_t>(written)});
+        }
+    }
+}
+
+/**
+ * Reports one found region table and the two records that differ by the advertisement.
+ * @param client ActivityClient.
+ * @param offset Offset of the record holding region 8.
+ * @param stride Bytes between two consecutive records.
+ */
+void report_region_table(const std::byte* client, std::size_t offset, std::size_t stride) noexcept {
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=probe stage=regions result=found client=0x%llX "
+                                      "at=%zu stride=%zu",
+                                      address_of(client),
+                                      offset,
+                                      stride);
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+    // Region 8 is the one the advertisement rides in and region 16 never carries one, so the
+    // bytes that differ between them are exactly what the client kept of the descriptor.
+    const std::size_t dump = stride < kRegionDumpBytes ? stride : kRegionDumpBytes;
+    report_hex("members", kMembershipHeaderOffset, client + kMembershipHeaderOffset, kMemberDumpBytes);
+    report_hex("region8", offset, client + offset, dump);
+    if (offset + stride + dump <= kScanEndOffset) {
+        report_hex("region16", offset + stride, client + offset + stride, dump);
+    }
+}
+
+/**
+ * Finds the client's decoded region table with no signature and dumps two of its records.
+ * Four consecutive terms of the 8-step region sequence at one fixed stride name the table, and
+ * nothing else in the object is expected to hold that run.
+ * @param client ActivityClient the handler has just committed a body into.
+ */
+void report_regions(const std::byte* client) noexcept {
+    if (g_regionDumps.fetch_add(1, std::memory_order_relaxed) >= kRegionDumpBudget) {
+        return;
+    }
+    for (std::size_t offset = kMembershipHeaderOffset; offset + sizeof(std::int32_t) <= kScanEndOffset;
+         offset += sizeof(std::int32_t)) {
+        if (field<std::int32_t>(client, offset) != kRegionIndexStride) {
+            continue;
+        }
+        for (std::size_t stride = sizeof(std::int32_t); stride <= kMaximumRegionStride;
+             stride += sizeof(std::int32_t)) {
+            if (offset + kRegionMatchTerms * stride > kScanEndOffset) {
+                break;
+            }
+            bool matched = true;
+            for (std::size_t term = 1; matched && term < kRegionMatchTerms; ++term) {
+                matched = field<std::int32_t>(client, offset + term * stride)
+                          == kRegionIndexStride * static_cast<std::int32_t>(term + 1);
+            }
+            if (!matched) {
+                continue;
+            }
+            report_region_table(client, offset, stride);
+            return;
+        }
+    }
+    core::log::write(core::log::Channel::client,
+                     core::log::Level::info,
+                     "ev=probe stage=regions result=absent");
+}
+
 /** @param client ActivityClient. @return Entity-slot bits it holds but has not applied. */
 /** @param client ActivityClient. @return Entity-slot bits it holds but has not applied. */
 [[nodiscard]] std::size_t pending_slots(const std::byte* client) noexcept {
 [[nodiscard]] std::size_t pending_slots(const std::byte* client) noexcept {
     const auto* mask = reinterpret_cast<const std::uint8_t*>(client + kPendingMaskOffset);
     const auto* mask = reinterpret_cast<const std::uint8_t*>(client + kPendingMaskOffset);
@@ -226,6 +349,7 @@ char __fastcall receive(const std::byte* client, std::int64_t body, int size) no
     const auto after = field<std::uint16_t>(client, kStatusWordOffset);
     const auto after = field<std::uint16_t>(client, kStatusWordOffset);
     report(client, before, after);
     report(client, before, after);
     report_bind_inputs(client);
     report_bind_inputs(client);
+    report_regions(client);
     track(client, GetTickCount64());
     track(client, GetTickCount64());
     return result;
     return result;
 }
 }

+ 53 - 0
Sunrise/src/client/hooks/retail_log/retail_log_enqueue_observer.cpp

@@ -1,5 +1,7 @@
 #include "retail_log_enqueue_observer.h"
 #include "retail_log_enqueue_observer.h"
 
 
+#include <intrin.h>
+
 #include <array>
 #include <array>
 #include <cstddef>
 #include <cstddef>
 #include <cstdint>
 #include <cstdint>
@@ -96,6 +98,53 @@ void capture_line(std::int32_t siteId, const char* text) noexcept {
     core::log::write(core::log::Channel::client, core::log::Level::info, {line.data(), length});
     core::log::write(core::log::Channel::client, core::log::Level::info, {line.data(), length});
 }
 }
 
 
+/** Text whose emitting call site is worth locating in the image. */
+constexpr std::string_view kTracedText = "failed to create";
+/** Call sites named per run, so a repeating line cannot flood the sink. */
+constexpr std::size_t kMaxCallSiteReports = 64;
+
+/** Reports already spent. */
+volatile LONG g_callSiteReports{};
+
+/**
+ * Names the image offset of the code that emitted one line.
+ * The packed executable cannot be disassembled on disk, so a dump of the mapped image is the only
+ * readable copy, and an offset from the load base is what addresses it. The retail text itself
+ * carries no address, and the site id is assigned by the game's own registration rather than by
+ * position, so nothing else here says which function produced a line. `_ReturnAddress` inside the
+ * funnel is the emitting call site, which is exactly the function to disassemble.
+ * @param returnAddress Return address captured in the funnel.
+ * @param text Already-formatted native line.
+ */
+void report_call_site(const void* returnAddress, const char* text) noexcept {
+    if (returnAddress == nullptr
+        || !core::log::accepts(core::log::Channel::client, core::log::Level::debug)) {
+        return;
+    }
+    const auto base = reinterpret_cast<std::uintptr_t>(GetModuleHandleW(nullptr));
+    const auto site = reinterpret_cast<std::uintptr_t>(returnAddress);
+    if (base == 0 || site < base) {
+        return;
+    }
+    if (InterlockedIncrement(&g_callSiteReports) > static_cast<LONG>(kMaxCallSiteReports)) {
+        return;
+    }
+    std::array<char, kEventCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=retail_site stage=caller rva=0x%llX va=0x%llX text=%s",
+                                      static_cast<unsigned long long>(site - base),
+                                      static_cast<unsigned long long>(site),
+                                      text);
+    if (written > 0) {
+        const auto length = static_cast<std::size_t>(written) < line.size()
+                                ? static_cast<std::size_t>(written)
+                                : line.size() - 1;
+        core::log::write(core::log::Channel::client, core::log::Level::debug,
+                         {line.data(), length});
+    }
+}
+
 /**
 /**
  * Mirrors the single funnel every retail log line passes through.
  * Mirrors the single funnel every retail log line passes through.
  * @param siteId Registered site id.
  * @param siteId Registered site id.
@@ -112,6 +161,10 @@ __declspec(noinline) void __fastcall enqueue_body(std::int32_t siteId, const cha
     if (outer) {
     if (outer) {
         if (siteId != kUnregisteredSite && text != nullptr) {
         if (siteId != kUnregisteredSite && text != nullptr) {
             capture_line(siteId, text);
             capture_line(siteId, text);
+            // Cheap guard first: the search only runs on the handful of lines that match.
+            if (std::string_view(text).find(kTracedText) != std::string_view::npos) {
+                report_call_site(_ReturnAddress(), text);
+            }
         }
         }
         assert_verbosity();
         assert_verbosity();
         g_inObserver = false;
         g_inObserver = false;

+ 114 - 6
Sunrise/src/client/patterns/registry.cpp

@@ -1,5 +1,9 @@
 #include "registry.h"
 #include "registry.h"
 
 
+#include <Windows.h>
+
+#include <array>
+#include <cstdint>
 #include <cstring>
 #include <cstring>
 
 
 namespace sunrise::client::patterns {
 namespace sunrise::client::patterns {
@@ -7,6 +11,91 @@ namespace {
 
 
 /** Returned by next_candidate when a range holds no further anchor byte. */
 /** Returned by next_candidate when a range holds no further anchor byte. */
 constexpr std::size_t kNoCandidate = static_cast<std::size_t>(-1);
 constexpr std::size_t kNoCandidate = static_cast<std::size_t>(-1);
+/** One count per distinct byte value. */
+constexpr std::size_t kByteValueCount = 256;
+/** Most ranges one fingerprint describes. No PE image carries more sections than this. */
+constexpr std::size_t kFingerprintCapacity = 96;
+
+/** How often each byte value occurs across one set of scanned ranges. */
+struct ByteCounts {
+    std::array<std::uint64_t, kByteValueCount> values{};
+};
+
+/** Identity of the range set one histogram was built from. */
+struct Fingerprint {
+    std::array<const std::byte*, kFingerprintCapacity> data{};
+    std::array<std::size_t, kFingerprintCapacity> size{};
+    std::size_t count{};
+    /** False for a range set too large to describe, which must never match a stored print. */
+    bool valid{};
+};
+
+/**
+ * The byte histogram and the ranges it came from.
+ * Building it costs one traversal of the image. Without this cache every pattern would pay that
+ * traversal, which is the very cost the anchor choice exists to avoid.
+ */
+struct FrequencyCache {
+    SRWLOCK lock{SRWLOCK_INIT};
+    Fingerprint fingerprint{};
+    ByteCounts counts{};
+};
+
+FrequencyCache g_frequency;
+
+/** @return Fingerprint of one range set, invalid when it holds more ranges than one can describe. */
+[[nodiscard]] Fingerprint fingerprint_of(std::span<const ImageRange> image) noexcept {
+    Fingerprint print{};
+    if (image.size() > kFingerprintCapacity) {
+        return print;
+    }
+    for (std::size_t index = 0; index < image.size(); ++index) {
+        print.data[index] = image[index].bytes.data();
+        print.size[index] = image[index].bytes.size();
+    }
+    print.count = image.size();
+    print.valid = true;
+    return print;
+}
+
+/** @return True when both fingerprints name the same ranges in the same order. */
+[[nodiscard]] bool same_ranges(const Fingerprint& left, const Fingerprint& right) noexcept {
+    if (!left.valid || !right.valid || left.count != right.count) {
+        return false;
+    }
+    for (std::size_t index = 0; index < left.count; ++index) {
+        if (left.data[index] != right.data[index] || left.size[index] != right.size[index]) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** Counts every byte value across one range set. */
+void count_bytes(std::span<const ImageRange> image, ByteCounts& counts) noexcept {
+    counts = {};
+    for (const ImageRange range : image) {
+        for (const std::byte value : range.bytes) {
+            ++counts.values[std::to_integer<unsigned char>(value)];
+        }
+    }
+}
+
+/**
+ * Reads the byte histogram for one range set, building it on the first request.
+ * @param image Ranges about to be scanned.
+ * @param counts Receives a copy, so no caller holds the cache lock while it scans.
+ */
+void byte_counts(std::span<const ImageRange> image, ByteCounts& counts) noexcept {
+    const Fingerprint wanted = fingerprint_of(image);
+    AcquireSRWLockExclusive(&g_frequency.lock);
+    if (!same_ranges(g_frequency.fingerprint, wanted)) {
+        count_bytes(image, g_frequency.counts);
+        g_frequency.fingerprint = wanted;
+    }
+    counts = g_frequency.counts;
+    ReleaseSRWLockExclusive(&g_frequency.lock);
+}
 
 
 /**
 /**
  * The one exact byte a pattern's candidate search keys on.
  * The one exact byte a pattern's candidate search keys on.
@@ -23,19 +112,34 @@ struct Anchor {
 
 
 /**
 /**
  * Picks the anchor byte for one pattern.
  * Picks the anchor byte for one pattern.
+ * The candidate search keys on this byte, so the rarest exact byte is the one that lets memchr
+ * skip the most. Taking the first exact byte instead lands on a REX prefix for most function
+ * prologues, and those are among the most common bytes there are in compiled x64: the sweep then
+ * stops to verify millions of times per pattern.
  * @param pattern Pattern name, bytes, and exact-byte mask.
  * @param pattern Pattern name, bytes, and exact-byte mask.
+ * @param counts How often each byte value occurs in the ranges about to be scanned.
  * @return A valid anchor when the pattern has a name, bytes, and at least one exact byte.
  * @return A valid anchor when the pattern has a name, bytes, and at least one exact byte.
  */
  */
-[[nodiscard]] Anchor anchor_of(const Pattern& pattern) noexcept {
+[[nodiscard]] Anchor anchor_of(const Pattern& pattern, const ByteCounts& counts) noexcept {
     if (pattern.name.empty() || pattern.bytes.empty()) {
     if (pattern.name.empty() || pattern.bytes.empty()) {
         return {};
         return {};
     }
     }
+    Anchor best{};
+    std::uint64_t bestCount = 0;
     for (std::size_t index = 0; index < pattern.bytes.size(); ++index) {
     for (std::size_t index = 0; index < pattern.bytes.size(); ++index) {
-        if (pattern.bytes[index].exact) {
-            return Anchor{index, std::to_integer<unsigned char>(pattern.bytes[index].value), true};
+        if (!pattern.bytes[index].exact) {
+            continue;
+        }
+        const auto value = std::to_integer<unsigned char>(pattern.bytes[index].value);
+        const std::uint64_t occurrences = counts.values[value];
+        // The earliest byte wins a tie, so one image always picks the same anchor.
+        if (best.valid && occurrences >= bestCount) {
+            continue;
         }
         }
+        best = Anchor{index, value, true};
+        bestCount = occurrences;
     }
     }
-    return {};
+    return best;
 }
 }
 
 
 /**
 /**
@@ -109,8 +213,10 @@ bool resolve_all(std::span<const ImageRange> image,
         return false;
         return false;
     }
     }
 
 
+    ByteCounts counts;
+    byte_counts(image, counts);
     for (std::size_t index = 0; index < patterns.size(); ++index) {
     for (std::size_t index = 0; index < patterns.size(); ++index) {
-        const Anchor anchor = anchor_of(patterns[index]);
+        const Anchor anchor = anchor_of(patterns[index], counts);
         matches[index] = anchor.valid ? Match{MatchStatus::missing, nullptr} : Match{};
         matches[index] = anchor.valid ? Match{MatchStatus::missing, nullptr} : Match{};
         if (!anchor.valid) {
         if (!anchor.valid) {
             continue;
             continue;
@@ -146,7 +252,9 @@ bool resolve_all(std::span<const ImageRange> image,
 std::size_t collect_matches(std::span<const ImageRange> image,
 std::size_t collect_matches(std::span<const ImageRange> image,
                             const Pattern& pattern,
                             const Pattern& pattern,
                             std::span<std::byte*> output) noexcept {
                             std::span<std::byte*> output) noexcept {
-    const Anchor anchor = anchor_of(pattern);
+    ByteCounts counts;
+    byte_counts(image, counts);
+    const Anchor anchor = anchor_of(pattern, counts);
     if (!anchor.valid || output.empty()) {
     if (!anchor.valid || output.empty()) {
         return 0;
         return 0;
     }
     }

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

@@ -7,11 +7,14 @@
 #include <string_view>
 #include <string_view>
 
 
 #include "../../core/logging/log.h"
 #include "../../core/logging/log.h"
+#include "../../core/settings/settings.h"
 #include "../../core/ui/busy/busy.h"
 #include "../../core/ui/busy/busy.h"
 #include "../../core/ui/notice/ui_notice_overlay.h"
 #include "../../core/ui/notice/ui_notice_overlay.h"
 #include "../content/activity/scriptable_catalog_worker.h"
 #include "../content/activity/scriptable_catalog_worker.h"
 #include "../content/bootstrap/bootstrap_token_publish.h"
 #include "../content/bootstrap/bootstrap_token_publish.h"
 #include "../content/investment/worker.h"
 #include "../content/investment/worker.h"
+#include "../diagnostics/entity_create_probe.h"
+#include "../diagnostics/image_dump.h"
 #include "../executable/image.h"
 #include "../executable/image.h"
 #include "../hooks/assert_handler/assert_handler_lifecycle.h"
 #include "../hooks/assert_handler/assert_handler_lifecycle.h"
 #include "../hooks/async_io/async_io_lifetime_guard.h"
 #include "../hooks/async_io/async_io_lifetime_guard.h"
@@ -49,6 +52,7 @@ StageState g_mainStage{StageState::pending};
 StageState g_graphicsStage{StageState::pending};
 StageState g_graphicsStage{StageState::pending};
 StageState g_platformStage{StageState::pending};
 StageState g_platformStage{StageState::pending};
 HMODULE g_platformModule{};
 HMODULE g_platformModule{};
+void* g_sunriseModule{};
 
 
 namespace {
 namespace {
 
 
@@ -134,6 +138,11 @@ void clear_game_targets() noexcept {
         clear_game_targets();
         clear_game_targets();
         return false;
         return false;
     }
     }
+    // The inspection above proves the packer has finished: these spans are the decrypted code the
+    // signatures match. That makes this the first point at which a dump is worth taking.
+    if (core::settings::get().client.dumpGameImage) {
+        (void)diagnostics::dump_game_image(g_sunriseModule);
+    }
     const std::span<patterns::ImageRange> imageRanges = ranges(gameImage);
     const std::span<patterns::ImageRange> imageRanges = ranges(gameImage);
     if (!targets::game::resolution::resolve(imageRanges)) {
     if (!targets::game::resolution::resolve(imageRanges)) {
         report_resolve_failure();
         report_resolve_failure();
@@ -172,6 +181,12 @@ void clear_game_targets() noexcept {
                      packageKeys ? "ev=activate stage=package_keys result=ok"
                      packageKeys ? "ev=activate stage=package_keys result=ok"
                                  : "ev=activate stage=package_keys result=fail");
                                  : "ev=activate stage=package_keys result=fail");
     // Diagnostic capture reports its own outcome and never demotes this stage.
     // Diagnostic capture reports its own outcome and never demotes this stage.
+    // The probe hooks only the index allocator, whose two-argument shape was read out of its own
+    // body. The initialiser beside it is left alone: its fifth argument is passed on the stack,
+    // and a four-argument replacement black-screened the load on 2026-08-25.
+    (void)diagnostics::install_entity_create_probe(
+        core::settings::get().client.stockEntityPool,
+        core::settings::get().client.restockDrainedEntityPool);
     (void)hooks::retail_log::install();
     (void)hooks::retail_log::install();
     (void)hooks::assert_handler::install();
     (void)hooks::assert_handler::install();
     // Read-only. At a hitch it dumps every in-flight job record from the watchdog snapshot,
     // Read-only. At a hitch it dumps every in-flight job record from the watchdog snapshot,

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

@@ -12,6 +12,7 @@
 #include "../hooks/graphics/graphics_hook_lifecycle.h"
 #include "../hooks/graphics/graphics_hook_lifecycle.h"
 #include "../hooks/inactivity/inactivity_override.h"
 #include "../hooks/inactivity/inactivity_override.h"
 #include "../hooks/infinite_ammo/infinite_ammo.h"
 #include "../hooks/infinite_ammo/infinite_ammo.h"
+#include "../hooks/membership_probe/membership_probe.h"
 #include "../hooks/network/runtime.h"
 #include "../hooks/network/runtime.h"
 #include "../hooks/noclip/runtime.h"
 #include "../hooks/noclip/runtime.h"
 #include "../hooks/package_trust/package_trust_bypass.h"
 #include "../hooks/package_trust/package_trust_bypass.h"
@@ -38,6 +39,8 @@ bool initialize(void* module) noexcept {
         core::settings::get().activitySdkGeneration;
         core::settings::get().activitySdkGeneration;
     content::activity::sdk_generation::initialize(module,
     content::activity::sdk_generation::initialize(module,
                                                   {generation.enabled, generation.luaDeclarations});
                                                   {generation.enabled, generation.luaDeclarations});
+    // Kept for activation, which resolves the artifact directory from Sunrise's own module.
+    runtime::g_sunriseModule = module;
     // Loaded before the pages register, so each page draws saved values on its first frame.
     // Loaded before the pages register, so each page draws saved values on its first frame.
     movement::initialize(module);
     movement::initialize(module);
     player::initialize(module);
     player::initialize(module);
@@ -80,6 +83,15 @@ bool shutdown() noexcept {
         ReleaseSRWLockExclusive(&runtime::g_lock);
         ReleaseSRWLockExclusive(&runtime::g_lock);
         return false;
         return false;
     }
     }
+    // Attached last, so it detaches first. The probe reads through a detour, so one left in
+    // place is a branch into code a later unload unmaps.
+    if (!hooks::membership_probe::uninstall()) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::error,
+                         "ev=shutdown stage=membership_probe result=fail");
+        ReleaseSRWLockExclusive(&runtime::g_lock);
+        return false;
+    }
     hooks::bitmap::uninstall();
     hooks::bitmap::uninstall();
     hooks::bootflow::uninstall();
     hooks::bootflow::uninstall();
     hooks::infinite_ammo::uninstall();
     hooks::infinite_ammo::uninstall();

+ 2 - 0
Sunrise/src/client/runtime/internal.h

@@ -18,5 +18,7 @@ extern StageState g_mainStage;
 extern StageState g_graphicsStage;
 extern StageState g_graphicsStage;
 extern StageState g_platformStage;
 extern StageState g_platformStage;
 extern HMODULE g_platformModule;
 extern HMODULE g_platformModule;
+/** Sunrise's own module, kept so activation can resolve the artifact directory. */
+extern void* g_sunriseModule;
 
 
 } // namespace sunrise::client::runtime
 } // namespace sunrise::client::runtime

+ 9 - 10
Sunrise/src/core/logging/log.cpp

@@ -7,8 +7,11 @@
 #include <atomic>
 #include <atomic>
 #include <cstdio>
 #include <cstdio>
 #include <cstring>
 #include <cstring>
+#include <mutex>
+#include <shared_mutex>
 
 
 #include "../filesystem/path.h"
 #include "../filesystem/path.h"
+#include "core/threading/srw_lock.h"
 #include "snapshot/internal.h"
 #include "snapshot/internal.h"
 
 
 namespace sunrise::core::log {
 namespace sunrise::core::log {
@@ -42,7 +45,7 @@ constexpr std::size_t kEventTextCapacity =
 constexpr std::size_t kStampCapacity = 32;
 constexpr std::size_t kStampCapacity = 32;
 
 
 struct LogState {
 struct LogState {
-    SRWLOCK lock{SRWLOCK_INIT};
+    threading::SrwLock lock{};
     std::array<std::atomic<Level>, static_cast<std::size_t>(Channel::count)> levels{};
     std::array<std::atomic<Level>, static_cast<std::size_t>(Channel::count)> levels{};
     HANDLE file{INVALID_HANDLE_VALUE};
     HANDLE file{INVALID_HANDLE_VALUE};
     /** Tick the sinks opened on. Every line carries its offset from this, so stalls are visible. */
     /** Tick the sinks opened on. Every line carries its offset from this, so stalls are visible. */
@@ -135,7 +138,7 @@ Settings defaults() noexcept {
 
 
 /** Applies log thresholds and opens the optional file sink. */
 /** Applies log thresholds and opens the optional file sink. */
 bool initialize(void* module, const Settings& settings) noexcept {
 bool initialize(void* module, const Settings& settings) noexcept {
-    AcquireSRWLockExclusive(&g_log.lock);
+    const std::lock_guard lock(g_log.lock);
     // Resetting under the lifetime lock prevents an admitted writer from repopulating stale view.
     // Resetting under the lifetime lock prevents an admitted writer from repopulating stale view.
     snapshot::internal::reset();
     snapshot::internal::reset();
     if (g_log.file != INVALID_HANDLE_VALUE) {
     if (g_log.file != INVALID_HANDLE_VALUE) {
@@ -159,13 +162,13 @@ bool initialize(void* module, const Settings& settings) noexcept {
             level.store(Level::off, std::memory_order_relaxed);
             level.store(Level::off, std::memory_order_relaxed);
         }
         }
     }
     }
-    ReleaseSRWLockExclusive(&g_log.lock);
     return ready;
     return ready;
 }
 }
 
 
 /** Closes the optional sink and clears the bounded in-memory view. */
 /** Closes the optional sink and clears the bounded in-memory view. */
 void shutdown() noexcept {
 void shutdown() noexcept {
-    AcquireSRWLockExclusive(&g_log.lock);
+    const std::lock_guard lock(g_log.lock);
+
     g_log.initialized = false;
     g_log.initialized = false;
     for (std::atomic<Level>& level : g_log.levels) {
     for (std::atomic<Level>& level : g_log.levels) {
         level.store(Level::off, std::memory_order_relaxed);
         level.store(Level::off, std::memory_order_relaxed);
@@ -177,7 +180,6 @@ void shutdown() noexcept {
     }
     }
     // The same lifetime lock excludes writers until both sinks and retained entries are empty.
     // The same lifetime lock excludes writers until both sinks and retained entries are empty.
     snapshot::internal::reset();
     snapshot::internal::reset();
-    ReleaseSRWLockExclusive(&g_log.lock);
 }
 }
 
 
 /** Writes one line straight to the debugger, bypassing the sinks and every threshold. */
 /** Writes one line straight to the debugger, bypassing the sinks and every threshold. */
@@ -194,9 +196,8 @@ void early(std::string_view event) noexcept {
 
 
 /** Reports whether an event would be emitted, so callers can skip the cost of building one. */
 /** Reports whether an event would be emitted, so callers can skip the cost of building one. */
 bool accepts(Channel channel, Level level) noexcept {
 bool accepts(Channel channel, Level level) noexcept {
-    AcquireSRWLockShared(&g_log.lock);
+    const std::shared_lock lock(g_log.lock);
     const bool admitted = g_log.initialized && enabled(channel, level);
     const bool admitted = g_log.initialized && enabled(channel, level);
-    ReleaseSRWLockShared(&g_log.lock);
     return admitted;
     return admitted;
 }
 }
 
 
@@ -208,9 +209,8 @@ void write(Channel channel, Level level, std::string_view event) noexcept {
         return;
         return;
     }
     }
 
 
-    AcquireSRWLockShared(&g_log.lock);
+    const std::shared_lock lock(g_log.lock);
     if (!g_log.initialized || !enabled(channel, level)) {
     if (!g_log.initialized || !enabled(channel, level)) {
-        ReleaseSRWLockShared(&g_log.lock);
         return;
         return;
     }
     }
 
 
@@ -248,7 +248,6 @@ void write(Channel channel, Level level, std::string_view event) noexcept {
     g_writers.fetch_sub(1, std::memory_order_acq_rel);
     g_writers.fetch_sub(1, std::memory_order_acq_rel);
     // Record after sink writes while the shared lifetime lock still excludes shutdown reset.
     // Record after sink writes while the shared lifetime lock still excludes shutdown reset.
     snapshot::internal::record(channel, level, std::string_view(line.data(), snapshotLength));
     snapshot::internal::record(channel, level, std::string_view(line.data(), snapshotLength));
-    ReleaseSRWLockShared(&g_log.lock);
 }
 }
 
 
 /** Formats and emits one debug event carrying a duration in the ms field. */
 /** Formats and emits one debug event carrying a duration in the ms field. */

+ 34 - 34
Sunrise/src/core/logging/snapshot/log_snapshot_ring.cpp

@@ -4,6 +4,7 @@
 #include <array>
 #include <array>
 #include <limits>
 #include <limits>
 
 
+#include "core/threading/data_mutex.h"
 #include "internal.h"
 #include "internal.h"
 
 
 namespace sunrise::core::log::snapshot {
 namespace sunrise::core::log::snapshot {
@@ -13,14 +14,13 @@ namespace {
 constexpr std::size_t kTextTerminatorBytes = 1;
 constexpr std::size_t kTextTerminatorBytes = 1;
 
 
 struct RingState {
 struct RingState {
-    SRWLOCK lock{SRWLOCK_INIT};
     std::array<Entry, kEntryCapacity> entries{};
     std::array<Entry, kEntryCapacity> entries{};
     std::size_t nextIndex{};
     std::size_t nextIndex{};
     std::size_t count{};
     std::size_t count{};
     std::uint64_t overwrittenCount{};
     std::uint64_t overwrittenCount{};
 };
 };
 
 
-RingState g_ring;
+threading::SharedDataMutex<RingState> g_ring;
 
 
 /** @param channel Value to inspect. @return True for a defined log channel. */
 /** @param channel Value to inspect. @return True for a defined log channel. */
 [[nodiscard]] bool valid_channel(Channel channel) noexcept {
 [[nodiscard]] bool valid_channel(Channel channel) noexcept {
@@ -62,15 +62,15 @@ std::uint64_t Snapshot::overwritten_count() const noexcept {
 /** @return A value-owned chronological copy of all retained events. */
 /** @return A value-owned chronological copy of all retained events. */
 Snapshot take() noexcept {
 Snapshot take() noexcept {
     Snapshot result;
     Snapshot result;
-    AcquireSRWLockShared(&g_ring.lock);
-    result.count_ = g_ring.count;
-    result.overwrittenCount_ = g_ring.overwrittenCount;
-    const std::size_t firstIndex =
-        (g_ring.nextIndex + kEntryCapacity - g_ring.count) % kEntryCapacity;
-    for (std::size_t index = 0; index < g_ring.count; ++index) {
-        result.entries_[index] = g_ring.entries[(firstIndex + index) % kEntryCapacity];
-    }
-    ReleaseSRWLockShared(&g_ring.lock);
+    g_ring.lock_read([&result](const RingState& ring) {
+        result.count_ = ring.count;
+        result.overwrittenCount_ = ring.overwrittenCount;
+        const std::size_t firstIndex =
+            (ring.nextIndex + kEntryCapacity - ring.count) % kEntryCapacity;
+        for (std::size_t index = 0; index < ring.count; ++index) {
+            result.entries_[index] = ring.entries[(firstIndex + index) % kEntryCapacity];
+        }
+    });
     return result;
     return result;
 }
 }
 
 
@@ -78,12 +78,12 @@ namespace internal {
 
 
 /** Clears retained events before a new logger lifecycle starts. */
 /** Clears retained events before a new logger lifecycle starts. */
 void reset() noexcept {
 void reset() noexcept {
-    AcquireSRWLockExclusive(&g_ring.lock);
-    g_ring.entries = {};
-    g_ring.nextIndex = 0;
-    g_ring.count = 0;
-    g_ring.overwrittenCount = 0;
-    ReleaseSRWLockExclusive(&g_ring.lock);
+    g_ring.lock_write([](RingState& ring) {
+        ring.entries = {};
+        ring.nextIndex = 0;
+        ring.count = 0;
+        ring.overwrittenCount = 0;
+    });
 }
 }
 
 
 /**
 /**
@@ -97,23 +97,23 @@ void record(Channel channel, Level level, std::string_view text) noexcept {
         return;
         return;
     }
     }
 
 
-    AcquireSRWLockExclusive(&g_ring.lock);
-    Entry& entry = g_ring.entries[g_ring.nextIndex];
-    entry = {};
-    entry.channel_ = channel;
-    entry.level_ = level;
-    const std::size_t maximumText = entry.text_.size() - kTextTerminatorBytes;
-    entry.textLength_ = (std::min)(text.size(), maximumText);
-    if (entry.textLength_ != 0) {
-        std::copy_n(text.data(), entry.textLength_, entry.text_.data());
-    }
-    g_ring.nextIndex = (g_ring.nextIndex + 1) % kEntryCapacity;
-    if (g_ring.count < kEntryCapacity) {
-        ++g_ring.count;
-    } else if (g_ring.overwrittenCount != (std::numeric_limits<std::uint64_t>::max)()) {
-        ++g_ring.overwrittenCount;
-    }
-    ReleaseSRWLockExclusive(&g_ring.lock);
+    g_ring.lock_write([channel, level, text](RingState& ring) {
+        Entry& entry = ring.entries[ring.nextIndex];
+        entry = {};
+        entry.channel_ = channel;
+        entry.level_ = level;
+        const std::size_t maximumText = entry.text_.size() - kTextTerminatorBytes;
+        entry.textLength_ = (std::min)(text.size(), maximumText);
+        if (entry.textLength_ != 0) {
+            std::copy_n(text.data(), entry.textLength_, entry.text_.data());
+        }
+        ring.nextIndex = (ring.nextIndex + 1) % kEntryCapacity;
+        if (ring.count < kEntryCapacity) {
+            ++ring.count;
+        } else if (ring.overwrittenCount != (std::numeric_limits<std::uint64_t>::max)()) {
+            ++ring.overwrittenCount;
+        }
+    });
 }
 }
 
 
 } // namespace internal
 } // namespace internal

+ 5 - 10
Sunrise/src/core/runtime/core_runtime.cpp

@@ -7,6 +7,7 @@
 #include <atomic>
 #include <atomic>
 #include <cstdint>
 #include <cstdint>
 #include <cstdio>
 #include <cstdio>
+#include <mutex>
 #include <string_view>
 #include <string_view>
 
 
 #include "../../client/runtime/host/game_host_classification.h"
 #include "../../client/runtime/host/game_host_classification.h"
@@ -26,12 +27,13 @@
 #include "../ui/modules/logs/logs.h"
 #include "../ui/modules/logs/logs.h"
 #include "../ui/modules/registry/ui_module_registry.h"
 #include "../ui/modules/registry/ui_module_registry.h"
 #include "../ui/runtime/ui_visibility_runtime.h"
 #include "../ui/runtime/ui_visibility_runtime.h"
+#include "core/threading/srw_lock.h"
 
 
 namespace sunrise::core {
 namespace sunrise::core {
 namespace {
 namespace {
 
 
 std::atomic_bool g_initialized{false};
 std::atomic_bool g_initialized{false};
-SRWLOCK g_runtimeLock{SRWLOCK_INIT};
+threading::SrwLock g_runtimeLock{};
 
 
 /** The installed public package headers live beside the game executable. */
 /** The installed public package headers live beside the game executable. */
 constexpr std::wstring_view kInstalledPackagesDirectory = L"packages";
 constexpr std::wstring_view kInstalledPackagesDirectory = L"packages";
@@ -136,9 +138,8 @@ void report_stage_failure(const char* stage) noexcept {
 
 
 /** Initializes every runtime layer in dependency order. */
 /** Initializes every runtime layer in dependency order. */
 bool initialize(void* module) noexcept {
 bool initialize(void* module) noexcept {
-    AcquireSRWLockExclusive(&g_runtimeLock);
+    const std::lock_guard lock(g_runtimeLock);
     if (g_initialized.load(std::memory_order_relaxed)) {
     if (g_initialized.load(std::memory_order_relaxed)) {
-        ReleaseSRWLockExclusive(&g_runtimeLock);
         return true;
         return true;
     }
     }
     // Taken before the first stage, so the reported duration covers settings and the sinks too.
     // Taken before the first stage, so the reported duration covers settings and the sinks too.
@@ -146,7 +147,6 @@ bool initialize(void* module) noexcept {
 
 
     if (!settings::initialize(module)) {
     if (!settings::initialize(module)) {
         // Settings name their own failure; the sinks do not exist yet to carry a second line.
         // Settings name their own failure; the sinks do not exist yet to carry a second line.
-        ReleaseSRWLockExclusive(&g_runtimeLock);
         return false;
         return false;
     }
     }
     state::unlocks::publish(settings::get().initialUnlocks);
     state::unlocks::publish(settings::get().initialUnlocks);
@@ -200,26 +200,22 @@ bool initialize(void* module) noexcept {
         state::unlocks::clear();
         state::unlocks::clear();
         log::shutdown();
         log::shutdown();
         settings::shutdown();
         settings::shutdown();
-        ReleaseSRWLockExclusive(&g_runtimeLock);
         return false;
         return false;
     }
     }
     g_initialized.store(true, std::memory_order_release);
     g_initialized.store(true, std::memory_order_release);
     log::write(log::Channel::core, log::Level::info, "ev=initialize result=ok");
     log::write(log::Channel::core, log::Level::info, "ev=initialize result=ok");
     log::write_elapsed(log::Channel::core, "ev=initialize phase=complete", startedTick, "ok");
     log::write_elapsed(log::Channel::core, "ev=initialize phase=complete", startedTick, "ok");
-    ReleaseSRWLockExclusive(&g_runtimeLock);
     return true;
     return true;
 }
 }
 
 
 /** Stops every runtime layer in reverse dependency order. */
 /** Stops every runtime layer in reverse dependency order. */
 bool shutdown() noexcept {
 bool shutdown() noexcept {
-    AcquireSRWLockExclusive(&g_runtimeLock);
+    const std::lock_guard lock(g_runtimeLock);
     if (!g_initialized.load(std::memory_order_acquire)) {
     if (!g_initialized.load(std::memory_order_acquire)) {
-        ReleaseSRWLockExclusive(&g_runtimeLock);
         return true;
         return true;
     }
     }
     if (!client::shutdown()) {
     if (!client::shutdown()) {
         // Server and State must remain valid while any Client hook is attached.
         // Server and State must remain valid while any Client hook is attached.
-        ReleaseSRWLockExclusive(&g_runtimeLock);
         return false;
         return false;
     }
     }
     g_initialized.store(false, std::memory_order_release);
     g_initialized.store(false, std::memory_order_release);
@@ -237,7 +233,6 @@ bool shutdown() noexcept {
     state::unlocks::clear();
     state::unlocks::clear();
     log::shutdown();
     log::shutdown();
     settings::shutdown();
     settings::shutdown();
-    ReleaseSRWLockExclusive(&g_runtimeLock);
     return true;
     return true;
 }
 }
 
 

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

@@ -17,6 +17,9 @@ bool Parser::client_settings(client::Settings& output) noexcept {
     bool hasPinReplicatedRecord = false;
     bool hasPinReplicatedRecord = false;
     bool hasHoldSpawn = false;
     bool hasHoldSpawn = false;
     bool hasSpawnHoldMs = false;
     bool hasSpawnHoldMs = false;
+    bool hasDumpGameImage = false;
+    bool hasStockEntityPool = false;
+    bool hasRestockDrained = false;
     if (consume('}')) {
     if (consume('}')) {
         return true;
         return true;
     }
     }
@@ -73,6 +76,21 @@ bool Parser::client_settings(client::Settings& output) noexcept {
             }
             }
             candidate.spawnHoldMs = value;
             candidate.spawnHoldMs = value;
             hasSpawnHoldMs = true;
             hasSpawnHoldMs = true;
+        } else if (key == "dump_game_image") {
+            if (hasDumpGameImage || !boolean(candidate.dumpGameImage)) {
+                return false;
+            }
+            hasDumpGameImage = true;
+        } else if (key == "stock_entity_pool") {
+            if (hasStockEntityPool || !boolean(candidate.stockEntityPool)) {
+                return false;
+            }
+            hasStockEntityPool = true;
+        } else if (key == "restock_drained_entity_pool") {
+            if (hasRestockDrained || !boolean(candidate.restockDrainedEntityPool)) {
+                return false;
+            }
+            hasRestockDrained = true;
         } else if (!skip_value(0)) {
         } else if (!skip_value(0)) {
             return false;
             return false;
         }
         }

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

@@ -56,6 +56,28 @@ struct Settings {
     bool holdSpawn{true};
     bool holdSpawn{true};
     /** How long the spawn waits for a load. `hold_spawn` decides whether it waits at all. */
     /** How long the spawn waits for a load. `hold_spawn` decides whether it waits at all. */
     std::uint64_t spawnHoldMs{kDefaultSpawnHoldMs};
     std::uint64_t spawnHoldMs{kDefaultSpawnHoldMs};
+    /**
+     * Writes the game's decrypted mapped image to `Sunrise\dumps` during activation.
+     * The packed executable on disk cannot be disassembled, so this is the only way to read the
+     * code that decodes the activity wire format. Off by default: the file is the whole image and
+     * writing it costs a second or two of every boot that enables it.
+     */
+    bool dumpGameImage{false};
+    /**
+     * Stock the client's entity free-slot bitmap when it is left entirely unstocked.
+     * The client fills that bitmap itself only when a role global reads zero; here it reads 3, so
+     * the fill never runs and every entity creation fails from the first frame. On, Sunrise writes
+     * the same bytes the client would have. Off restores the previous behaviour with no rebuild.
+     */
+    bool stockEntityPool{true};
+    /**
+     * Also refill the entity pool once it has drained, not only when it was never stocked.
+     * The pool empties from 7935 free to zero inside a minute, and a drained pool makes an
+     * encounter bubble kick to orbit again. Refilling anyway gets past that, but it re-frees
+     * indices that are still owned, so one index can reach two entities — that crashed a respawn.
+     * Off by default: the kick is recoverable, the corruption is not.
+     */
+    bool restockDrainedEntityPool{false};
 };
 };
 
 
 } // namespace sunrise::core::settings::client
 } // namespace sunrise::core::settings::client

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

@@ -42,6 +42,19 @@ inline constexpr std::uint16_t kClientLeaseMinimum = 4096;
 inline constexpr std::uint16_t kDefaultClientJoinGrant = 8'192;
 inline constexpr std::uint16_t kDefaultClientJoinGrant = 8'192;
 /** Below this a join cannot cover the client's own low water mark of 400. */
 /** Below this a join cannot cover the client's own low water mark of 400. */
 inline constexpr std::uint16_t kMinimumClientJoinGrant = 400;
 inline constexpr std::uint16_t kMinimumClientJoinGrant = 400;
+/**
+ * Lease the client is topped up to whenever it asks for more slots. Zero disables the top-up.
+ * Disabled by default because a run on 2026-08-25 measured it as useless: the top-up landed
+ * (`held=2048`) and the client returned the surplus 32 ms later (`kind=release picked=1840`,
+ * back to `held=208`), then failed to create the same three `sobject` entities it always fails
+ * on. The client manages its own lease tightly and will not hold slots it has not asked for.
+ * Capacity was never the constraint either — it failed with 208 slots held while needing 3
+ * entities, and the bubble-14 switch succeeded holding only 151. Kept as a knob because it is
+ * the cheapest way to re-run that experiment, not because a value above zero is expected to help.
+ */
+inline constexpr std::uint16_t kDefaultClientLeaseHighWater = 0;
+/** Below the client's own 400 low water mark a top-up would not change what it can create. */
+inline constexpr std::uint16_t kMinimumClientLeaseHighWater = 400;
 
 
 /**
 /**
  * Gameplay endpoint topology and the entity-slot split it implies.
  * Gameplay endpoint topology and the entity-slot split it implies.
@@ -69,6 +82,19 @@ struct Settings {
      * refuses; a family-4 completion at arrival frees it. Off by default.
      * refuses; a family-4 completion at arrival frees it. Off by default.
      */
      */
     bool holdLaunchCinematic{false};
     bool holdLaunchCinematic{false};
+    /** Lease one grant tops the client up to, so the next slice set is covered before it asks. */
+    std::uint16_t clientLeaseHighWater{kDefaultClientLeaseHighWater};
+    /**
+     * Ignore the slot mask the client sends on message 21 instead of shrinking its lease by it.
+     * Measured 2026-08-25: the client "releases" 7785 of the 7936 slots its join was granted, and
+     * 1840 of every later top-up, always within 32 ms and always leaving exactly what it had asked
+     * for. A client handing back 98% of a lease it never used is not plausible; a mask that names
+     * the slots it is KEEPING, read as the ones it is giving up, produces precisely this. The
+     * consequence is real: the client reconciles its own entity bitmap to the host's mask, so the
+     * shrunken lease starves entity creation and an encounter bubble kicks to orbit.
+     * On, the release is still framed and reported, only the lease is left alone.
+     */
+    bool ignoreClientSlotRelease{false};
 };
 };
 
 
 /**
 /**
@@ -95,4 +121,12 @@ struct Settings {
  */
  */
 [[nodiscard]] std::size_t join_grant(const Settings& settings) noexcept;
 [[nodiscard]] std::size_t join_grant(const Settings& settings) noexcept;
 
 
+/**
+ * Reports the lease one grant tops the client up to.
+ * A disabled channel reserves nothing, so the high water is bounded by the whole slot space.
+ * @param settings Active gameplay settings.
+ * @return Configured high water, capped at what the reserve leaves free.
+ */
+[[nodiscard]] std::size_t lease_high_water(const Settings& settings) noexcept;
+
 } // namespace sunrise::core::settings::server::gameplay
 } // namespace sunrise::core::settings::server::gameplay

+ 15 - 0
Sunrise/src/core/settings/server/gameplay/gameplay_settings_parser.cpp

@@ -52,6 +52,8 @@ bool Parser::gameplay_settings(gameplay::Settings& output) noexcept {
     bool hasReserve = false;
     bool hasReserve = false;
     bool hasJoinGrant = false;
     bool hasJoinGrant = false;
     bool hasHoldLaunchCinematic = false;
     bool hasHoldLaunchCinematic = false;
+    bool hasLeaseHighWater = false;
+    bool hasIgnoreRelease = false;
     for (;;) {
     for (;;) {
         std::string_view key;
         std::string_view key;
         if (!string(key) || !consume(':')) {
         if (!string(key) || !consume(':')) {
@@ -112,6 +114,19 @@ bool Parser::gameplay_settings(gameplay::Settings& output) noexcept {
                 return false;
                 return false;
             }
             }
             hasHoldLaunchCinematic = true;
             hasHoldLaunchCinematic = true;
+        } else if (key == "client_lease_high_water") {
+            std::uint64_t value = 0;
+            if (hasLeaseHighWater || !unsigned_integer(value)
+                || value > (std::numeric_limits<std::uint16_t>::max)()) {
+                return false;
+            }
+            candidate.clientLeaseHighWater = static_cast<std::uint16_t>(value);
+            hasLeaseHighWater = true;
+        } else if (key == "ignore_client_slot_release") {
+            if (hasIgnoreRelease || !boolean(candidate.ignoreClientSlotRelease)) {
+                return false;
+            }
+            hasIgnoreRelease = true;
         } else if (!skip_value(0)) {
         } else if (!skip_value(0)) {
             return false;
             return false;
         }
         }

+ 15 - 0
Sunrise/src/core/settings/server/gameplay/gameplay_settings_validation.cpp

@@ -46,6 +46,12 @@ bool valid(const Settings& settings) noexcept {
     // leaves. Requiring it to fit beside the reserve here would refuse the default, which asks
     // leaves. Requiring it to fit beside the reserve here would refuse the default, which asks
     // for the whole space on purpose.
     // for the whole space on purpose.
     constexpr std::size_t kSlotCount = state::activity::entity_slots::kSlotCount;
     constexpr std::size_t kSlotCount = state::activity::entity_slots::kSlotCount;
+    // Zero disables the top-up. Any other value must still clear the client's own low water.
+    if (settings.clientLeaseHighWater != 0
+        && (settings.clientLeaseHighWater < kMinimumClientLeaseHighWater
+            || static_cast<std::size_t>(settings.clientLeaseHighWater) > kSlotCount)) {
+        return false;
+    }
     return settings.clientJoinGrantCount >= kMinimumClientJoinGrant
     return settings.clientJoinGrantCount >= kMinimumClientJoinGrant
            && static_cast<std::size_t>(settings.clientJoinGrantCount) <= kSlotCount;
            && static_cast<std::size_t>(settings.clientJoinGrantCount) <= kSlotCount;
 }
 }
@@ -64,4 +70,13 @@ std::size_t join_grant(const Settings& settings) noexcept {
     return wanted < free ? wanted : free;
     return wanted < free ? wanted : free;
 }
 }
 
 
+/** Reports the lease one grant tops the client up to. */
+std::size_t lease_high_water(const Settings& settings) noexcept {
+    // One activity session owns exactly this many entity-slot lease bits.
+    constexpr std::size_t kSlotCount = state::activity::entity_slots::kSlotCount;
+    const std::size_t free = kSlotCount - static_cast<std::size_t>(effective_reserve(settings));
+    const std::size_t wanted = settings.clientLeaseHighWater;
+    return wanted < free ? wanted : free;
+}
+
 } // namespace sunrise::core::settings::server::gameplay
 } // namespace sunrise::core::settings::server::gameplay

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

@@ -35,6 +35,8 @@ struct Settings {
      * every file written before versioning. Checked against kSettingsVersion at load.
      * every file written before versioning. Checked against kSettingsVersion at load.
      */
      */
     std::uint32_t version{};
     std::uint32_t version{};
+    /** Completes released exotic weapon catalysts while resolving client item state. */
+    bool completeExoticCatalysts{true};
     /** Core-owned sink and channel policy. */
     /** Core-owned sink and channel policy. */
     log::Settings logging;
     log::Settings logging;
     /** Core-owned boot gate for activity SDK generation. */
     /** Core-owned boot gate for activity SDK generation. */

+ 7 - 0
Sunrise/src/core/settings/settings_parser.cpp

@@ -18,6 +18,7 @@ bool Parser::parse_root(Settings& output) noexcept {
         return at_end();
         return at_end();
     }
     }
     bool hasVersion = false;
     bool hasVersion = false;
+    bool hasCompleteExoticCatalysts = false;
     bool hasCore = false;
     bool hasCore = false;
     bool hasClient = false;
     bool hasClient = false;
     bool hasServer = false;
     bool hasServer = false;
@@ -36,6 +37,11 @@ bool Parser::parse_root(Settings& output) noexcept {
             }
             }
             output.version = static_cast<std::uint32_t>(value);
             output.version = static_cast<std::uint32_t>(value);
             hasVersion = true;
             hasVersion = true;
+        } else if (key == "complete_exotic_catalysts") {
+            if (hasCompleteExoticCatalysts || !boolean(output.completeExoticCatalysts)) {
+                return false;
+            }
+            hasCompleteExoticCatalysts = true;
         } else if (key == "core") {
         } else if (key == "core") {
             if (hasCore || !core(output)) {
             if (hasCore || !core(output)) {
                 return false;
                 return false;
@@ -230,6 +236,7 @@ Settings defaults() noexcept {
     // Named members, so adding one to Settings cannot silently shift the rest.
     // Named members, so adding one to Settings cannot silently shift the rest.
     return Settings{
     return Settings{
         .version = kSettingsVersion,
         .version = kSettingsVersion,
+        .completeExoticCatalysts = true,
         .logging = log::defaults(),
         .logging = log::defaults(),
         .activitySdkGeneration = {},
         .activitySdkGeneration = {},
         .server = server::Settings{state::entitlements::authored()},
         .server = server::Settings{state::entitlements::authored()},

+ 18 - 0
Sunrise/src/core/settings/state/activity_default_destination_parser.cpp

@@ -62,6 +62,9 @@ bool Parser::activity_settings(state::activity::defaults::ActivityDefaults& outp
     bool hasDefaultDestination = false;
     bool hasDefaultDestination = false;
     bool hasArrivalOverrides = false;
     bool hasArrivalOverrides = false;
     bool hasRosterKeyFromIdentity = false;
     bool hasRosterKeyFromIdentity = false;
+    bool hasRosterKeyOnAllSlots = false;
+    bool hasAuthorDirectorBodies = false;
+    bool hasAuthorWideRecordBodies = false;
     if (consume('}')) {
     if (consume('}')) {
         return true;
         return true;
     }
     }
@@ -85,6 +88,21 @@ bool Parser::activity_settings(state::activity::defaults::ActivityDefaults& outp
                 return false;
                 return false;
             }
             }
             hasRosterKeyFromIdentity = true;
             hasRosterKeyFromIdentity = true;
+        } else if (key == "roster_key_on_all_slots") {
+            if (hasRosterKeyOnAllSlots || !boolean(output.rosterKeyOnAllSlots)) {
+                return false;
+            }
+            hasRosterKeyOnAllSlots = true;
+        } else if (key == "author_director_bodies") {
+            if (hasAuthorDirectorBodies || !boolean(output.authorDirectorBodies)) {
+                return false;
+            }
+            hasAuthorDirectorBodies = true;
+        } else if (key == "author_wide_record_bodies") {
+            if (hasAuthorWideRecordBodies || !boolean(output.authorWideRecordBodies)) {
+                return false;
+            }
+            hasAuthorWideRecordBodies = true;
         } else if (!skip_value(0)) {
         } else if (!skip_value(0)) {
             return false;
             return false;
         }
         }

+ 2 - 4
Sunrise/src/core/settings/state/inventory_parser.cpp

@@ -20,9 +20,6 @@ enum class ItemField : std::size_t {
     count,
     count,
 };
 };
 
 
-/** The supported client exposes Locked and Tracked/Favorite as accumulated item-state bits. */
-constexpr std::uint32_t kSupportedItemStateMask = 0x3U;
-
 } // namespace
 } // namespace
 
 
 /** Parses the optional equipment object with its fixed named slots. */
 /** Parses the optional equipment object with its fixed named slots. */
@@ -153,7 +150,8 @@ bool Parser::equipment_item(authored_inventory::Item& output) noexcept {
         } else if (key == "flags") {
         } else if (key == "flags") {
             std::uint64_t flags = 0;
             std::uint64_t flags = 0;
             if (!mark(ItemField::flags) || !unsigned_value(flags)
             if (!mark(ItemField::flags) || !unsigned_value(flags)
-                || flags > kSupportedItemStateMask) {
+                || flags > (std::numeric_limits<std::uint32_t>::max)()
+                || !authored_inventory::valid_item_state(static_cast<std::uint32_t>(flags))) {
                 return false;
                 return false;
             }
             }
             parsed.flags = static_cast<std::uint32_t>(flags);
             parsed.flags = static_cast<std::uint32_t>(flags);

+ 25 - 23
Sunrise/src/core/settings/state/key_bindings_parser.cpp

@@ -4,7 +4,6 @@
 #include "../parser.h"
 #include "../parser.h"
 
 
 namespace sunrise::core::settings::parser {
 namespace sunrise::core::settings::parser {
-namespace {
 
 
 namespace bindings = state::account::settings::bindings;
 namespace bindings = state::account::settings::bindings;
 
 
@@ -77,7 +76,7 @@ constexpr std::array<std::string_view, bindings::kActionCount> kActionNames{
  * @param name Borrowed JSON property name.
  * @param name Borrowed JSON property name.
  * @return Fixed State index, or the action count when the name is unknown.
  * @return Fixed State index, or the action count when the name is unknown.
  */
  */
-[[nodiscard]] std::size_t action_index(std::string_view name) noexcept {
+[[nodiscard]] static std::size_t action_index(std::string_view name) noexcept {
     const auto found = std::find(kActionNames.begin(), kActionNames.end(), name);
     const auto found = std::find(kActionNames.begin(), kActionNames.end(), name);
     return static_cast<std::size_t>(found - kActionNames.begin());
     return static_cast<std::size_t>(found - kActionNames.begin());
 }
 }
@@ -209,18 +208,13 @@ constexpr std::array<InputName, 121> kInputNames{{
     {"extra mouse button 2", 113},
     {"extra mouse button 2", 113},
     {"mouse wheel up", 114},
     {"mouse wheel up", 114},
     {"mouse wheel down", 115},
     {"mouse wheel down", 115},
-    {"unused", 116},
+    {"unused", bindings::kUnboundInputCode},
     {"ctrl", 106},
     {"ctrl", 106},
     {"left ctrl", 69},
     {"left ctrl", 69},
     {"right ctrl", 76},
     {"right ctrl", 76},
     {"\\\\", 43},
     {"\\\\", 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. */
 /** One code that may prefix another key, and the flag it sets there. */
 struct ModifierName {
 struct ModifierName {
     std::uint16_t code;
     std::uint16_t code;
@@ -229,19 +223,19 @@ struct ModifierName {
 
 
 /** Both sides of a modifier fold onto the same flag, as they do in the Client. */
 /** Both sides of a modifier fold onto the same flag, as they do in the Client. */
 constexpr std::array<ModifierName, 9> kModifiers{{
 constexpr std::array<ModifierName, 9> kModifiers{{
-    {57, kShiftFlag},
-    {68, kShiftFlag},
-    {105, kShiftFlag},
-    {69, kControlFlag},
-    {76, kControlFlag},
-    {106, kControlFlag},
-    {71, kAltFlag},
-    {73, kAltFlag},
-    {108, kAltFlag},
+    {57, bindings::kShiftModifierFlag},
+    {68, bindings::kShiftModifierFlag},
+    {105, bindings::kShiftModifierFlag},
+    {69, bindings::kControlModifierFlag},
+    {76, bindings::kControlModifierFlag},
+    {106, bindings::kControlModifierFlag},
+    {71, bindings::kAltModifierFlag},
+    {73, bindings::kAltModifierFlag},
+    {108, bindings::kAltModifierFlag},
 }};
 }};
 
 
 /** @return The name without leading and trailing ASCII blanks. */
 /** @return The name without leading and trailing ASCII blanks. */
-[[nodiscard]] constexpr std::string_view trim(std::string_view text) noexcept {
+[[nodiscard]] static constexpr std::string_view trim(std::string_view text) noexcept {
     while (!text.empty() && (text.front() == ' ' || text.front() == '\t')) {
     while (!text.empty() && (text.front() == ' ' || text.front() == '\t')) {
         text.remove_prefix(1);
         text.remove_prefix(1);
     }
     }
@@ -252,7 +246,8 @@ constexpr std::array<ModifierName, 9> kModifiers{{
 }
 }
 
 
 /** @return True when the two names match with ASCII case folded, as the Client compares them. */
 /** @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 {
+[[nodiscard]] static constexpr bool same_name(std::string_view left,
+                                              std::string_view right) noexcept {
     if (left.size() != right.size()) {
     if (left.size() != right.size()) {
         return false;
         return false;
     }
     }
@@ -274,7 +269,7 @@ constexpr std::array<ModifierName, 9> kModifiers{{
  * @param output Receives the code the table gives that name.
  * @param output Receives the code the table gives that name.
  * @return True when the name is in the table.
  * @return True when the name is in the table.
  */
  */
-[[nodiscard]] bool named_code(std::string_view name, std::uint16_t& output) noexcept {
+[[nodiscard]] static bool named_code(std::string_view name, std::uint16_t& output) noexcept {
     for (const InputName& entry : kInputNames) {
     for (const InputName& entry : kInputNames) {
         if (same_name(entry.name, name)) {
         if (same_name(entry.name, name)) {
             output = entry.code;
             output = entry.code;
@@ -289,7 +284,7 @@ constexpr std::array<ModifierName, 9> kModifiers{{
  * @param output Receives the flag that code sets on the key it prefixes.
  * @param output Receives the flag that code sets on the key it prefixes.
  * @return True when the code is a modifier.
  * @return True when the code is a modifier.
  */
  */
-[[nodiscard]] bool modifier_flag(std::uint16_t code, std::uint16_t& output) noexcept {
+[[nodiscard]] static bool modifier_flag(std::uint16_t code, std::uint16_t& output) noexcept {
     for (const ModifierName& entry : kModifiers) {
     for (const ModifierName& entry : kModifiers) {
         if (entry.code == code) {
         if (entry.code == code) {
             output = entry.flag;
             output = entry.flag;
@@ -299,8 +294,6 @@ constexpr std::array<ModifierName, 9> kModifiers{{
     return false;
     return false;
 }
 }
 
 
-} // namespace
-
 /** Parses the whole fixed action table under named JSON properties. */
 /** Parses the whole fixed action table under named JSON properties. */
 bool Parser::key_bindings(bindings::KeyBindings& output) noexcept {
 bool Parser::key_bindings(bindings::KeyBindings& output) noexcept {
     output = {};
     output = {};
@@ -388,6 +381,15 @@ bool Parser::optional_input_code(std::optional<std::uint16_t>& output) noexcept
     if (!string(name) || !input_code_value(name, code)) {
     if (!string(name) || !input_code_value(name, code)) {
         return false;
         return false;
     }
     }
+    if (code == bindings::kUnboundInputCode) {
+        // The Client's "unused" table row is the wire sentinel, not a bindable input.
+        output.reset();
+        return true;
+    }
+    if ((code & bindings::kInputCodeMask) == bindings::kUnboundInputCode) {
+        // A modifier cannot turn the unbound sentinel into a real key.
+        return false;
+    }
     output = code;
     output = code;
     return true;
     return true;
 }
 }

+ 81 - 0
Sunrise/src/core/threading/data_mutex.h

@@ -0,0 +1,81 @@
+#pragma once
+
+#include <concepts>
+#include <mutex>
+#include <shared_mutex>
+#include <utility>
+
+#include "sendable.h"
+#include "srw_lock.h"
+
+namespace sunrise::core::threading {
+
+/** A combination of Mutex + Data. This allows Data types to be written as if they're single
+ * threaded as you'll only have access when the mutex is locked. */
+template <typename Data, typename Mutex = SrwLock> class DataMutex {
+public:
+    explicit DataMutex() noexcept
+        requires std::default_initializable<Data>
+    = default;
+
+    template <typename... Args>
+        requires std::constructible_from<Data, Args...>
+    explicit DataMutex(std::in_place_t, Args&&... args) : data_(std::forward<Args>(args)...) {}
+
+    /** Locks the mutex and calls the given Func */
+    template <std::invocable<Data&> Func, Sendable Return = std::invoke_result_t<Func, Data&>>
+    [[nodiscard]] Return lock(Func&& func) noexcept {
+        const std::lock_guard lock(mutex_);
+        return std::invoke(std::forward<Func>(func), data_);
+    }
+
+    /** Tries to loc the mutex, only calls the given Func if successful */
+    template <std::invocable<Data&> Func> void try_lock(Func&& func) noexcept {
+        std::unique_lock lock(mutex_, std::try_to_lock);
+
+        if (lock.owns_lock()) {
+            std::invoke(std::forward<Func>(func), data_);
+        }
+    }
+
+private:
+    mutable Mutex mutex_;
+    Data data_;
+};
+
+/** Similar to the above but also allows for multple readers. Readers are passed a const Data&,
+ * making accidental writes impossible */
+template <typename Data, typename SharedMutex = SrwLock> class SharedDataMutex {
+public:
+    explicit SharedDataMutex() noexcept
+        requires std::default_initializable<Data>
+    = default;
+
+    template <typename... Args>
+        requires std::constructible_from<Data, Args...>
+    explicit SharedDataMutex(std::in_place_t, Args&&... args)
+        : data_(std::forward<Args>(args)...) {}
+
+    /** Locks the mutex for reading and calls the given Func. Multiple readers can be active at
+     * once */
+    template <std::invocable<const Data&> Func,
+              Sendable Return = std::invoke_result_t<Func, const Data&>>
+    [[nodiscard]] Return lock_read(Func&& func) const noexcept {
+        const std::shared_lock lock(mutex_);
+        return std::invoke(std::forward<Func>(func), data_);
+    }
+
+    /** Locks the mutex for writing and calls the given Func. This is an exclusive lock and
+     * guarantees there are no other readers or writers */
+    template <std::invocable<Data&> Func, Sendable Return = std::invoke_result_t<Func, Data&>>
+    [[nodiscard]] Return lock_write(Func&& func) noexcept {
+        const std::lock_guard lock(mutex_);
+        return std::invoke(std::forward<Func>(func), data_);
+    }
+
+private:
+    mutable SharedMutex mutex_{};
+    Data data_{};
+};
+
+} // namespace sunrise::core::threading

+ 17 - 0
Sunrise/src/core/threading/sendable.h

@@ -0,0 +1,17 @@
+#pragma once
+
+#include <concepts>
+
+namespace sunrise::core::threading {
+
+/** An specializable struct that indicates a type can be sent across thread boundaries */
+template <typename T> struct IsSendable : std::false_type {};
+
+/** Indicates a specific type can be sent across thread boundaries. Integral, loating point, and
+ * void types are always allowed since they're easily copyable. Custom types can be marked as
+ * `Sendable` by specializing `IsSendable` above */
+template <typename T>
+concept Sendable =
+    std::integral<T> || std::floating_point<T> || std::is_void_v<T> || IsSendable<T>::value;
+
+} // namespace sunrise::core::threading

+ 47 - 0
Sunrise/src/core/threading/srw_lock.h

@@ -0,0 +1,47 @@
+#pragma once
+
+#include <WinSock2.h>
+
+namespace sunrise::core::threading {
+
+/** Wrapper to enable std::lock_guard and std::shared_lock for SRWLOCK */
+class SrwLock final {
+public:
+    constexpr explicit SrwLock() noexcept = default;
+
+    SrwLock(const SrwLock&) = delete;
+    SrwLock(SrwLock&&) = delete;
+    SrwLock& operator=(const SrwLock&) = delete;
+    SrwLock& operator=(SrwLock&&) = delete;
+
+    // stl Lockable
+    void lock() noexcept {
+        AcquireSRWLockExclusive(&lock_);
+    }
+
+    [[nodiscard]] bool try_lock() noexcept {
+        return TryAcquireSRWLockExclusive(&lock_);
+    }
+
+    void unlock() noexcept {
+        ReleaseSRWLockExclusive(&lock_);
+    }
+
+    // stl SharedLockable
+    void lock_shared() noexcept {
+        AcquireSRWLockShared(&lock_);
+    }
+
+    [[nodiscard]] bool try_lock_shared() noexcept {
+        return TryAcquireSRWLockShared(&lock_);
+    }
+
+    void unlock_shared() noexcept {
+        ReleaseSRWLockShared(&lock_);
+    }
+
+private:
+    SRWLOCK lock_{SRWLOCK_INIT};
+};
+
+} // namespace sunrise::core::threading

+ 195 - 3
Sunrise/src/middleware/bap/activity_message/activity_sensor_auth_bodies.cpp

@@ -1,5 +1,11 @@
 #include "sensor_auth_update.h"
 #include "sensor_auth_update.h"
 
 
+#include <array>
+#include <atomic>
+#include <cstdio>
+
+#include "../../../core/logging/log.h"
+
 namespace sunrise::middleware::bap::activity_message::sensor_auth_update {
 namespace sunrise::middleware::bap::activity_message::sensor_auth_update {
 namespace {
 namespace {
 
 
@@ -12,6 +18,25 @@ constexpr std::uint8_t kSlotTypeConfiguration = 8;
 constexpr std::uint8_t kSlotTypePackage = 16;
 constexpr std::uint8_t kSlotTypePackage = 16;
 constexpr std::uint8_t kSlotTypeQueues = 41;
 constexpr std::uint8_t kSlotTypeQueues = 41;
 constexpr std::uint8_t kSlotTypeSpawnKeys = 67;
 constexpr std::uint8_t kSlotTypeSpawnKeys = 67;
+/**
+ * The one published slot this host still announces without a body.
+ * A run measured every published width: 13 carries 224 bits, 16 carries 7, 17 carries 520, 18
+ * carries 386, 35 carries 359, and 37 carries **zero**. Its 1750-bit width was recovered from the
+ * client's own field tables alongside the type-35 and type-18 ones, and never written. The block
+ * ships on every region -- the roster body is byte-identical at region 8, 112 and 144 -- so it is
+ * in the stream while the player stands at the Wall of Wishes.
+ */
+constexpr std::uint8_t kSlotTypeWideRecord = 37;
+/**
+ * The slot the bubble-14 roster group brought in, and the second one found shipping bodyless.
+ * Publishing the Wall of Wishes room's object added type 30 to the stream and a measured run
+ * reported it at `bits=0`.
+ */
+constexpr std::uint8_t kSlotTypeRegionRecord = 30;
+/** The mission director. Its body is what an encounter bubble's script objects come from. */
+constexpr std::uint8_t kSlotTypeDirector = 35;
+/** The activity script runtime, which ships beside the director in the same group. */
+constexpr std::uint8_t kSlotTypeScriptRuntime = 18;
 
 
 /** Body widths, each checked against the writer after the body is written. */
 /** Body widths, each checked against the writer after the body is written. */
 constexpr std::size_t kParticipationBits = 192;
 constexpr std::size_t kParticipationBits = 192;
@@ -21,6 +46,48 @@ constexpr std::size_t kConfigurationBits = 35;
 constexpr std::size_t kPackageBits = 7;
 constexpr std::size_t kPackageBits = 7;
 constexpr std::size_t kQueueBits = 12;
 constexpr std::size_t kQueueBits = 12;
 constexpr std::size_t kSpawnKeyBits = 32 * 32 + 1 + 32;
 constexpr std::size_t kSpawnKeyBits = 32 * 32 + 1 + 32;
+/**
+ * Width of the empty type-37 body, derived from the client's schema tree rather than recalled.
+ *
+ * Slot 37 is schema `0x80805007` -> `0x80805008`, which holds two `0x8080500B` records and one
+ * `0x80805009`. `0x8080500B` is 32 + 8 + `0x8080500F` (four groups of i8,i8,u32,bool = 196) + 7 + 1
+ * + 32 + 32 + five biased i32 + `0x8080500D`; `0x80805009` is 32 + 8 + 8. `0x8080500D` is a 7-bit
+ * COUNT followed by that many 16-bit elements, so **this body is variable width** -- a fixed number
+ * cannot be right for it in general, and a zero count is the well-formed empty form.
+ *
+ * 2 x 475 + 48 = 998. An earlier note recorded 1750, which no whole element count produces
+ * (23 gives 1734, 24 gives 1766); it was never verified on the wire the way the type-35 and
+ * type-18 widths were, and it is not used.
+ */
+constexpr std::size_t kWideRecordBits = 998;
+/**
+ * Width of the type-30 body, from the client's field tables.
+ * Slot 30 is schema `0x80809532`: a nested `0x80809C42` of {u32, 7-bit biased +1, 16-bit biased
+ * +0x8000} followed by a 32-bit field biased +2^31. Fixed width, no presence bit and no array, so
+ * there is exactly one legal length and the width check below is exact -- the same shape as the
+ * type-35 and type-18 bodies, which this same decode reproduces at 359 and 386 exactly.
+ */
+constexpr std::size_t kRegionRecordBits = 32 + 7 + 16 + 32;
+/**
+ * The record shared by the director and the script runtime, class `0x808099C4`.
+ * One bool, five raw 64-bit words and a raw 32-bit word. Every field is unbiased, so a zero body
+ * decodes to zeroes rather than to a sentinel.
+ */
+constexpr std::size_t kSharedDirectorRecordBits = 1 + 5 * 64 + 32;
+/** Words in that shared record. */
+constexpr std::size_t kSharedDirectorWords = 5;
+/** Director body: two bools, two bias-1 selectors, then the shared record. */
+constexpr std::size_t kDirectorBits = 1 + 1 + 2 + 2 + kSharedDirectorRecordBits;
+/** Script-runtime body: the shared record, a bool, then one biased signed word. */
+constexpr std::size_t kScriptRuntimeBits = kSharedDirectorRecordBits + 1 + 32;
+/** Width of the director's two selectors, each stored as a signed byte biased by one. */
+constexpr std::uint8_t kDirectorSelectorWidth = 2;
+/** Wire value those selectors need for zero. Zero would decode to -1, the none sentinel. */
+constexpr std::uint32_t kDirectorSelectorZero = 1;
+/** Type-30's 7-bit field carries a bias of one, so this wire value decodes to a literal zero. */
+constexpr std::uint32_t kRegionSelectorZero = 1;
+/** Type-30's 16-bit field carries a bias of 0x8000, so this wire value decodes to zero. */
+constexpr std::uint32_t kUnsignedShortZero = 0x8000;
 
 
 /** Signed fields in these bodies carry a -2^31 bias, so this wire value stores zero. */
 /** Signed fields in these bodies carry a -2^31 bias, so this wire value stores zero. */
 constexpr std::uint32_t kSignedZero = 0x80000000;
 constexpr std::uint32_t kSignedZero = 0x80000000;
@@ -103,6 +170,56 @@ constexpr std::size_t kSpawnKeyCount = 32;
            && writer.write(0, 3);
            && writer.write(0, 3);
 }
 }
 
 
+/**
+ * Writes the record shared by the director and the script-runtime bodies, class `0x808099C4`.
+ * Recovered from the client's own static field table, whose walker reads a 1-bit bool, five
+ * unbiased 64-bit words and one unbiased raw 32-bit word. Unbiased means a zero wire value stores
+ * a literal zero, so this is the neutral, fully-constructed form of the record rather than one
+ * that decodes to a sentinel.
+ * @param writer Body writer.
+ * @return True when the record fits.
+ */
+[[nodiscard]] bool write_shared_director_record(bits::Writer& writer) noexcept {
+    bool encoded = writer.write(0, kPresenceWidth);
+    for (std::size_t word = 0; encoded && word < kSharedDirectorWords; ++word) {
+        encoded = writer.write(0, 64);
+    }
+    return encoded && writer.write(0, 32);
+}
+
+/**
+ * Writes the mission-director body, class `0x808099BF`.
+ * The director is the slot an encounter bubble's script objects are authored from, and until this
+ * existed `auth_body_bits` returned zero for it, so the block went out with a header and no body.
+ * The body is fixed width: the client's field table declares no presence bit, no array and no
+ * variant field, so there is exactly one legal length and the width check below is exact.
+ *
+ * The two selectors are the only fields that are not zero-safe. Their descriptors carry a bias of
+ * one, so a zero wire value decodes to -1 — the engine's none sentinel, the same shape as the
+ * lifetime's `+1` and the bias-one spawn-override index whose zero disables the override.
+ * @param writer Body writer.
+ * @return True when the body fits.
+ */
+[[nodiscard]] bool write_director(bits::Writer& writer) noexcept {
+    return writer.write(0, kPresenceWidth) && writer.write(0, kPresenceWidth)
+           && writer.write(kDirectorSelectorZero, kDirectorSelectorWidth)
+           && writer.write(kDirectorSelectorZero, kDirectorSelectorWidth)
+           && write_shared_director_record(writer);
+}
+
+/**
+ * Writes the activity-script-runtime body, class `0x80809919`.
+ * The shared record comes first here, then this slot's own bool and signed word. The trailing word
+ * rides the same `+2^31` bias as every other signed field in these bodies, so it carries the bias
+ * rather than a plain zero.
+ * @param writer Body writer.
+ * @return True when the body fits.
+ */
+[[nodiscard]] bool write_script_runtime(bits::Writer& writer) noexcept {
+    return write_shared_director_record(writer) && writer.write(0, kPresenceWidth)
+           && writer.write(kSignedZero, 32);
+}
+
 /**
 /**
  * Writes the spawn-key body, which maps the 32 ordinals to themselves.
  * Writes the spawn-key body, which maps the 32 ordinals to themselves.
  * @param writer Body writer.
  * @param writer Body writer.
@@ -118,9 +235,10 @@ constexpr std::size_t kSpawnKeyCount = 32;
 
 
 } // namespace
 } // namespace
 
 
-/** Reports how many bits of auth body one slot carries. */
-std::size_t
-auth_body_bits(const Snapshot& snapshot, std::uint8_t slotType, bool carriesPlayerKey) noexcept {
+/** Reports how many bits of auth body one slot carries, without reporting it. */
+std::size_t auth_body_bits_of(const Snapshot& snapshot,
+                              std::uint8_t slotType,
+                              bool carriesPlayerKey) noexcept {
     if (slotType == kSlotTypeParticipation) {
     if (slotType == kSlotTypeParticipation) {
         return carriesPlayerKey
         return carriesPlayerKey
                    ? kParticipationBits + (snapshot.hasRegion ? kParticipationRegionBits : 0)
                    ? kParticipationBits + (snapshot.hasRegion ? kParticipationRegionBits : 0)
@@ -141,9 +259,68 @@ auth_body_bits(const Snapshot& snapshot, std::uint8_t slotType, bool carriesPlay
     if (slotType == kSlotTypeSpawnKeys) {
     if (slotType == kSlotTypeSpawnKeys) {
         return kSpawnKeyBits;
         return kSpawnKeyBits;
     }
     }
+    // Both are settings-gated: a body of the wrong width does not fail this host's own width
+    // check, it desynchronises the client's parse of every block after it in the same phase-2
+    // stream, which would cost the player their spawn. Off, they go out bodyless as before.
+    if (slotType == kSlotTypeDirector) {
+        return snapshot.authorDirectorBodies ? kDirectorBits : 0;
+    }
+    if (slotType == kSlotTypeScriptRuntime) {
+        return snapshot.authorDirectorBodies ? kScriptRuntimeBits : 0;
+    }
+    if (slotType == kSlotTypeWideRecord) {
+        return snapshot.authorWideRecordBodies ? kWideRecordBits : 0;
+    }
+    if (slotType == kSlotTypeRegionRecord) {
+        return snapshot.authorWideRecordBodies ? kRegionRecordBits : 0;
+    }
     return 0;
     return 0;
 }
 }
 
 
+/**
+ * Names each published slot type and the body width it goes out with, once per distinct pair.
+ *
+ * A slot whose width is zero is announced to the client and then described with nothing -- the
+ * exact shape of the gap that types 35 and 18 had before their bodies were written. Types 21 and
+ * 37 are admitted by `kRosterSlotTypes` and still fall through to `return 0` here, and type 37's
+ * body was measured at 1750 bits and never implemented. Printing the pairs says which published
+ * slots are actually bodyless on this destination instead of inferring it from the filter.
+ * @param slotType Slot type being sized.
+ * @param bits Body width it will carry.
+ */
+void report_slot_width(std::uint8_t slotType, std::size_t bits) noexcept {
+    static std::atomic<std::uint64_t> reported{};
+    if (slotType >= 64 || !core::log::accepts(core::log::Channel::middleware,
+                                              core::log::Level::debug)) {
+        return;
+    }
+    const std::uint64_t bit = 1ULL << slotType;
+    if ((reported.fetch_or(bit, std::memory_order_relaxed) & bit) != 0) {
+        return;
+    }
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=activity stage=slot_width type=%u bits=%zu%s",
+                                      static_cast<unsigned>(slotType),
+                                      bits,
+                                      bits == 0 ? " result=bodyless" : "");
+    if (written > 0) {
+        core::log::write(core::log::Channel::middleware,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/** Reports how many bits of auth body one slot carries. */
+std::size_t
+auth_body_bits(const Snapshot& snapshot, std::uint8_t slotType, bool carriesPlayerKey) noexcept {
+    const std::size_t width = auth_body_bits_of(snapshot, slotType, carriesPlayerKey);
+    report_slot_width(slotType, width);
+    return width;
+}
+
+
 /** Writes one slot's auth body. */
 /** Writes one slot's auth body. */
 bool write_auth_body(bits::Writer& writer,
 bool write_auth_body(bits::Writer& writer,
                      const Snapshot& snapshot,
                      const Snapshot& snapshot,
@@ -167,6 +344,21 @@ bool write_auth_body(bits::Writer& writer,
         encoded = writer.write(0, 7) && writer.write(0, 5);
         encoded = writer.write(0, 7) && writer.write(0, 5);
     } else if (slotType == kSlotTypeSpawnKeys) {
     } else if (slotType == kSlotTypeSpawnKeys) {
         encoded = write_spawn_keys(writer);
         encoded = write_spawn_keys(writer);
+    } else if (slotType == kSlotTypeDirector && snapshot.authorDirectorBodies) {
+        encoded = write_director(writer);
+    } else if (slotType == kSlotTypeScriptRuntime && snapshot.authorDirectorBodies) {
+        encoded = write_script_runtime(writer);
+    } else if (slotType == kSlotTypeRegionRecord && snapshot.authorWideRecordBodies) {
+        // Zero is NOT the constructed state here: three of the four fields carry a bias, so a
+        // neutral body writes each bias rather than a zero. Writing zeros would decode to -1 in
+        // the 7-bit field and to large negatives in the other two.
+        encoded = writer.write(0, 32) && writer.write(kRegionSelectorZero, 7)
+                  && writer.write(kUnsignedShortZero, 16) && writer.write(kSignedZero, 32);
+    } else if (slotType == kSlotTypeWideRecord && snapshot.authorWideRecordBodies) {
+        // Zeroes are the empty form here rather than merely a neutral one: the two element
+        // counts inside `0x8080500D` read zero, so the body declares two empty arrays and every
+        // other field at its unbiased zero.
+        encoded = pad_bits(writer, kWideRecordBits);
     }
     }
     return encoded && writer.bit_count() == start + expected;
     return encoded && writer.bit_count() == start + expected;
 }
 }

+ 17 - 0
Sunrise/src/middleware/bap/activity_message/sensor_auth_update.h

@@ -171,6 +171,23 @@ struct Snapshot final {
     bool awaitClientSync{};
     bool awaitClientSync{};
     /** Register the groups and seed no object. Separates no components from no auth state. */
     /** Register the groups and seed no object. Separates no components from no auth state. */
     bool phaseOneOnly{};
     bool phaseOneOnly{};
+    /**
+     * Fill the type-35 mission-director and type-18 script-runtime bodies.
+     * These two are what an encounter bubble's script objects are authored from, and they shipped
+     * bodyless until their layouts were recovered from the client's own field tables. Gated
+     * because a wrong width does not fail this host's width check: it shifts every block after it
+     * in the same phase-2 stream, which costs the player their spawn rather than just the
+     * encounter. Off restores the previous bodyless behaviour with no rebuild.
+     */
+    bool authorDirectorBodies{};
+    /**
+     * Fill the type-37 body, the last published slot that still ships bodyless.
+     * A measured run showed every other published slot carrying a body (13:224, 16:7, 17:520,
+     * 18:386, 35:359) and 37 carrying zero. Same gate rationale as the director bodies: a wrong
+     * width shifts every block after it in the same phase-2 stream, so off restores the bodyless
+     * behaviour with no rebuild.
+     */
+    bool authorWideRecordBodies{};
     /**
     /**
      * Fill the participation body on every type-13 slot, not only the group's first.
      * Fill the participation body on every type-13 slot, not only the group's first.
      * The gate reads the record of the object the player datum names. Only one type-13 slot
      * The gate reads the record of the object the player datum names. Only one type-13 slot

+ 16 - 12
Sunrise/src/middleware/content/packages/reader/package_handle_cache.cpp

@@ -4,6 +4,7 @@
 #include <cwchar>
 #include <cwchar>
 #include <limits>
 #include <limits>
 
 
+#include "core/threading/data_mutex.h"
 #include "handle_cache.h"
 #include "handle_cache.h"
 
 
 namespace sunrise::middleware::content::packages::reader::handle_cache {
 namespace sunrise::middleware::content::packages::reader::handle_cache {
@@ -16,9 +17,12 @@ constexpr std::uint64_t kHashBasis = 14695981039346656037ULL;
 /** Standard 64-bit FNV-1a prime mixes each path character. */
 /** Standard 64-bit FNV-1a prime mixes each path character. */
 constexpr std::uint64_t kHashPrime = 1099511628211ULL;
 constexpr std::uint64_t kHashPrime = 1099511628211ULL;
 
 
-SRWLOCK g_lock{SRWLOCK_INIT};
-std::array<FileSlot, kSharedSlots> g_slots{};
-std::uint64_t g_useCounter{};
+struct CacheState {
+    std::array<FileSlot, kSharedSlots> slots{};
+    std::uint64_t useCounter{};
+};
+
+core::threading::DataMutex<CacheState> g_cache{};
 
 
 /** @param path Full package path. @return Its key. */
 /** @param path Full package path. @return Its key. */
 [[nodiscard]] std::uint64_t path_hash(const Path& path) noexcept {
 [[nodiscard]] std::uint64_t path_hash(const Path& path) noexcept {
@@ -123,11 +127,11 @@ bool read(const Path& path, std::uint64_t offset, std::span<std::byte> output) n
     }
     }
     // One lock covers the lookup and the read. The read is positioned on a file the next
     // One lock covers the lookup and the read. The read is positioned on a file the next
     // caller may replace.
     // caller may replace.
-    AcquireSRWLockExclusive(&g_lock);
-    const HANDLE file = acquire(g_slots, g_useCounter, path);
-    const bool complete = file != nullptr && read_positioned(file, offset, output);
-    ReleaseSRWLockExclusive(&g_lock);
-    return complete;
+    return g_cache.lock([&path, offset, output](CacheState& cache) {
+        const HANDLE file = acquire(cache.slots, cache.useCounter, path);
+        const bool complete = file != nullptr && read_positioned(file, offset, output);
+        return complete;
+    });
 }
 }
 
 
 /** Reads an exact byte range through the files one reader keeps open. */
 /** Reads an exact byte range through the files one reader keeps open. */
@@ -145,10 +149,10 @@ bool read(Scratch& scratch,
 
 
 /** Closes the shared files, which the build passes do when they finish. */
 /** Closes the shared files, which the build passes do when they finish. */
 void release() noexcept {
 void release() noexcept {
-    AcquireSRWLockExclusive(&g_lock);
-    close_slots(g_slots);
-    g_useCounter = 0;
-    ReleaseSRWLockExclusive(&g_lock);
+    g_cache.lock([](CacheState& cache) {
+        close_slots(cache.slots);
+        cache.useCounter = 0;
+    });
 }
 }
 
 
 /** @param scratch Reader whose own files are closed. */
 /** @param scratch Reader whose own files are closed. */

+ 69 - 0
Sunrise/src/middleware/content/packages/tables/bubble_state_reader.cpp

@@ -1,8 +1,13 @@
 #include "bubble_state_reader.h"
 #include "bubble_state_reader.h"
 
 
 #include <algorithm>
 #include <algorithm>
+#include <array>
+#include <atomic>
+#include <cstdio>
 
 
+#include "../../../../core/logging/log.h"
 #include "component_container_reader.h"
 #include "component_container_reader.h"
+#include "internal.h"
 
 
 namespace sunrise::middleware::content::packages::tables {
 namespace sunrise::middleware::content::packages::tables {
 namespace {
 namespace {
@@ -24,6 +29,60 @@ void add_package(BubbleStates& output, std::uint16_t packageId) noexcept {
     output.packages[output.packageCount++] = packageId;
     output.packages[output.packageCount++] = packageId;
 }
 }
 
 
+/**
+ * Slice-state rows reported per run, so a full package sweep cannot fill the sink.
+ * 359 destinations are walked and most declare tens of bubbles, so this is a sample, not a census.
+ */
+constexpr std::size_t kMaxStateReports = 4096;
+/** Rows already spent. */
+std::atomic<std::size_t> g_stateReports{};
+
+/**
+ * Dumps one slice-set state whole, so its map-global bubble index can be located rather than
+ * assumed.
+ *
+ * `kStateMapBubbleIndexOffset` is an inference, and the values it produces do not behave like a
+ * map-global index: within one destination most bubbles resolve to zero, and the field is what a
+ * container's bubble mask is keyed by, so a wrong offset collapses every bubble's spawn sets and
+ * components onto whichever bubble reads zero. The bubble name hash is the same for one bubble
+ * whichever destination walks it, so grouping these rows by hash decides the offset outright: the
+ * right one agrees across every destination that names the bubble and differs between bubbles.
+ * @param ordinal Bubble ordinal within its scenario.
+ * @param nameHash The bubble's own name hash, stable across destinations.
+ * @param state Raw inline bytes of slice-set state zero.
+ */
+void report_state(std::uint64_t ordinal,
+                  std::uint32_t nameHash,
+                  std::span<const std::byte> state) noexcept {
+    if (!core::log::accepts(core::log::Channel::state, core::log::Level::debug)
+        || g_stateReports.fetch_add(1, std::memory_order_relaxed) >= kMaxStateReports) {
+        return;
+    }
+    std::array<char, core::log::kLineCapacity> line{};
+    int written = std::snprintf(line.data(),
+                                line.size(),
+                                "ev=build_data stage=slice_state bubble=%llu hash=0x%08X raw=",
+                                static_cast<unsigned long long>(ordinal),
+                                nameHash);
+    for (std::size_t offset = 0; offset < state.size() && written > 0
+                                 && static_cast<std::size_t>(written) + 3 < line.size();
+         ++offset) {
+        const int more = std::snprintf(line.data() + written,
+                                       line.size() - static_cast<std::size_t>(written),
+                                       "%02X",
+                                       std::to_integer<unsigned char>(state[offset]));
+        if (more <= 0) {
+            break;
+        }
+        written += more;
+    }
+    if (written > 0) {
+        core::log::write(core::log::Channel::state,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
 } // namespace
 } // namespace
 
 
 /** Builds one scenario's per-bubble state array. */
 /** Builds one scenario's per-bubble state array. */
@@ -46,6 +105,16 @@ bool bubble_states(std::span<const std::byte> scenario, BubbleStates& output) no
         std::uint8_t value = kBubbleDisabledByte;
         std::uint8_t value = kBubbleDisabledByte;
         SliceState state{};
         SliceState state{};
         std::uint16_t mapIndex = kAbsentMapBubbleIndex;
         std::uint16_t mapIndex = kAbsentMapBubbleIndex;
+        std::size_t stateOffset = 0;
+        if (bubble.stateCount != 0
+            && element_offset(
+                bubble.stateDataOffset, bubble.stateCount, kSliceStateStride, 0, stateOffset)
+            && stateOffset + kSliceStateStride <= scenario.size()) {
+            report_state(index,
+                         bubble.nameHash,
+                         scenario.subspan(static_cast<std::size_t>(stateOffset),
+                                          kSliceStateStride));
+        }
         if (bubble.stateCount != 0 && slice_state_at(scenario, bubble, 0, state)) {
         if (bubble.stateCount != 0 && slice_state_at(scenario, bubble, 0, state)) {
             value = state.enabled ? kBubbleEnabledByte : kBubbleDisabledByte;
             value = state.enabled ? kBubbleEnabledByte : kBubbleDisabledByte;
             // An index no container mask can name is absent, because nothing could match it.
             // An index no container mask can name is absent, because nothing could match it.

+ 19 - 0
Sunrise/src/middleware/content/packages/tables/definition_index_table.h

@@ -272,6 +272,25 @@ slot_tag(std::span<const std::byte> blob, std::size_t index, std::uint32_t& tag)
 inline constexpr std::size_t kItemTableSlot = 48;
 inline constexpr std::size_t kItemTableSlot = 48;
 /** The investment root holds the shared reusable/randomized plug-set table at this slot. */
 /** The investment root holds the shared reusable/randomized plug-set table at this slot. */
 inline constexpr std::size_t kPlugSetTableSlot = 51;
 inline constexpr std::size_t kPlugSetTableSlot = 51;
+/** The investment root holds the dense ordinary socket-type table at this slot. */
+inline constexpr std::size_t kSocketTypeTableSlot = 94;
+/** Element class and row size of the installed ordinary socket-type table. */
+inline constexpr std::uint32_t kSocketTypeTableClass = 0x80807ABBU;
+inline constexpr std::size_t kSocketTypeRowStride = 96;
+/** One socket type points to its acquired-state rule array from this row offset. */
+inline constexpr std::size_t kSocketTypeAcquisitionDescriptor = 16;
+/** Acquired-state and item-completion arrays carry this postfix expression-row class. */
+inline constexpr std::uint32_t kInvestmentExpressionRowClass = 0x80807D31U;
+/** Catalyst progress items reference objective rows through this one-row array. */
+inline constexpr std::uint32_t kObjectiveReferenceArrayClass = 0x808087B1U;
+inline constexpr std::uint32_t kObjectiveReferenceRowClass = 0x808077E3U;
+/** The investment root holds the dense objective definition table at this slot. */
+inline constexpr std::size_t kObjectiveTableSlot = 58;
+/** Installed objective table and row layout. */
+inline constexpr std::uint32_t kObjectiveTableClass = 0x8080775BU;
+inline constexpr std::uint32_t kObjectiveRowClass = 0x8080775FU;
+inline constexpr std::size_t kObjectiveRowStride = 0xA0;
+inline constexpr std::size_t kObjectiveCompletionValueOffset = 0x30;
 /** The investment root holds the socket entry list table at this slot. */
 /** The investment root holds the socket entry list table at this slot. */
 inline constexpr std::size_t kSocketEntryListTableSlot = 97;
 inline constexpr std::size_t kSocketEntryListTableSlot = 97;
 /** The globals container names the investment root as its first child. */
 /** The globals container names the investment root as its first child. */

+ 9 - 0
Sunrise/src/middleware/content/packages/tables/roster_intersection.cpp

@@ -33,6 +33,15 @@ bool carries_roster_slot(std::span<const std::byte> object) noexcept {
     if (!object_slots(object, slots)) {
     if (!object_slots(object, slots)) {
         return false;
         return false;
     }
     }
+    // A named key is admitted on its own account, before the type test it would fail.
+    std::uint32_t key = 0;
+    if (object_key(object, key)) {
+        for (const std::uint32_t forced : kForcedRosterKeys) {
+            if (key == forced) {
+                return true;
+            }
+        }
+    }
     for (std::uint64_t index = 0; index < slots.count; ++index) {
     for (std::uint64_t index = 0; index < slots.count; ++index) {
         Slot slot{};
         Slot slot{};
         if (!object_slot_at(object, slots, index, slot)) {
         if (!object_slot_at(object, slots, index, slot)) {

+ 21 - 0
Sunrise/src/middleware/content/packages/tables/roster_intersection.h

@@ -70,6 +70,27 @@ void observe_unresolved_slice_set(RosterIntersection& state) noexcept;
  * Only 56 installed objects declare any of them, and the key limit above holds only for that
  * Only 56 installed objects declare any of them, and the key limit above holds only for that
  * filtered set. Feeding every placed object instead overflows most destinations.
  * filtered set. Feeding every placed object instead overflows most destinations.
  */
  */
+/**
+ * Object keys admitted whatever slot types they declare.
+ *
+ * The slot-type filter below is what decides which placed objects become roster groups, and a
+ * placement trace shows it admitting **68 of 5986** objects overall and **1 of 32** across the
+ * whole raid. Bubble 14 -- the Wall of Wishes room -- has exactly two objects, `0x101DECCF`
+ * (785 slots) and `0x432A36E6` (21 slots), and neither declares an admitted type, so the host
+ * sends no per-object data for that bubble at all while the client builds its twenty panels
+ * locally and never shows them.
+ *
+ * Widening the type list is not the way to test that: only 56 installed objects declare any of the
+ * nine types, the key limit holds only for that filtered set, and admitting common types overflows
+ * `kRosterKeyCapacity` on most destinations, which makes a destination publish ZERO groups. Naming
+ * one key instead adds a single group to one destination -- 3 keys become 4 of 16, and 21 slots sit
+ * well inside `kRosterSlotCapacity` -- so the experiment is bounded and reversible.
+ *
+ * The 785-slot container is deliberately NOT listed: its slots would each carry a header and the
+ * roster body is already 976 bytes, so it risks the message size rather than testing the idea.
+ */
+inline constexpr std::array<std::uint32_t, 1> kForcedRosterKeys = {0x432A36E6U};
+
 inline constexpr std::array<std::uint16_t, 9> kRosterSlotTypes = {
 inline constexpr std::array<std::uint16_t, 9> kRosterSlotTypes = {
     8, 13, 16, 17, 21, 35, 37, 41, 67};
     8, 13, 16, 17, 21, 35, 37, 41, 67};
 
 

+ 4 - 3
Sunrise/src/middleware/datagen/character_record/appearance/character_appearance_banks.cpp

@@ -93,7 +93,7 @@ void append_item_perks(const Equipped& equipped,
                        std::size_t& count) noexcept {
                        std::size_t& count) noexcept {
     append_perks(equipped.definitionIndex, bank, count);
     append_perks(equipped.definitionIndex, bank, count);
     for (std::size_t lane = 0; lane < equipped.laneCount; ++lane) {
     for (std::size_t lane = 0; lane < equipped.laneCount; ++lane) {
-        append_perks(equipped.plugs[lane], bank, count);
+        append_perks(resolve_effective_plug(equipped, lane), bank, count);
     }
     }
 }
 }
 
 
@@ -118,9 +118,10 @@ void apply_overflow_hashes(const family4::loadout::ResolvedInstances& instances,
         }
         }
         for (std::size_t lane = 0; lane < equipped.laneCount && rankedCount < ranked.size();
         for (std::size_t lane = 0; lane < equipped.laneCount && rankedCount < ranked.size();
              ++lane) {
              ++lane) {
+            const std::uint16_t effectivePlug = resolve_effective_plug(equipped, lane);
             details::Definition plug{};
             details::Definition plug{};
-            if (equipped.plugs[lane] == details::kUnavailableItemIndex
-                || !state::build_data::find_configured_item_detail(equipped.plugs[lane], plug)) {
+            if (effectivePlug == details::kUnavailableItemIndex
+                || !state::build_data::find_configured_item_detail(effectivePlug, plug)) {
                 continue;
                 continue;
             }
             }
             ranked[rankedCount++] = {priority_of(detail.socketTypes[lane]),
             ranked[rankedCount++] = {priority_of(detail.socketTypes[lane]),

+ 14 - 0
Sunrise/src/middleware/datagen/character_record/appearance/character_appearance_render.cpp

@@ -152,6 +152,20 @@ bool resolve_equipped(const family4::loadout::SlottedInstance& slotted,
     return true;
     return true;
 }
 }
 
 
+/**
+ * Resolves one socketed plug to the item row that supplies its perks and stats.
+ * @param equipped Equipped base item and its visible socket plugs.
+ * @param lane Zero-based ordinary socket lane.
+ * @return Effective plug definition, or the unavailable sentinel for an invalid lane.
+ */
+std::uint16_t resolve_effective_plug(const Equipped& equipped, std::size_t lane) noexcept {
+    if (lane >= equipped.laneCount || lane >= equipped.plugs.size()) {
+        return details::kUnavailableItemIndex;
+    }
+    return state::build_data::resolve_exotic_catalyst_effect(
+        equipped.definitionIndex, static_cast<std::uint8_t>(lane), equipped.plugs[lane]);
+}
+
 /** Fills each equipped render row with its instance, definition, art and material pairs. */
 /** Fills each equipped render row with its instance, definition, art and material pairs. */
 bool apply_render(const family4::loadout::ResolvedInstances& instances,
 bool apply_render(const family4::loadout::ResolvedInstances& instances,
                   state::CharacterClass characterClass,
                   state::CharacterClass characterClass,

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

@@ -48,7 +48,7 @@ namespace seasonal = state::progression::seasonal_experience;
 [[nodiscard]] std::int32_t item_total(const Equipped& equipped, std::uint8_t row) noexcept {
 [[nodiscard]] std::int32_t item_total(const Equipped& equipped, std::uint8_t row) noexcept {
     std::int32_t total = definition_total(equipped.definitionIndex, row);
     std::int32_t total = definition_total(equipped.definitionIndex, row);
     for (std::size_t lane = 0; lane < equipped.laneCount; ++lane) {
     for (std::size_t lane = 0; lane < equipped.laneCount; ++lane) {
-        total += definition_total(equipped.plugs[lane], row);
+        total += definition_total(resolve_effective_plug(equipped, lane), row);
     }
     }
     return total;
     return total;
 }
 }
@@ -65,7 +65,7 @@ collect_rows(const Equipped& equipped, std::span<std::uint8_t> rows, std::size_t
     const std::size_t lanes = equipped.laneCount + 1;
     const std::size_t lanes = equipped.laneCount + 1;
     for (std::size_t source = 0; source < lanes; ++source) {
     for (std::size_t source = 0; source < lanes; ++source) {
         const std::uint16_t definitionIndex =
         const std::uint16_t definitionIndex =
-            source == 0 ? equipped.definitionIndex : equipped.plugs[source - 1];
+            source == 0 ? equipped.definitionIndex : resolve_effective_plug(equipped, source - 1);
         details::Definition detail{};
         details::Definition detail{};
         if (definitionIndex == details::kUnavailableItemIndex
         if (definitionIndex == details::kUnavailableItemIndex
             || !state::build_data::find_configured_item_detail(definitionIndex, detail)) {
             || !state::build_data::find_configured_item_detail(definitionIndex, detail)) {

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

@@ -53,6 +53,15 @@ select_art_arrangement(const details::Definition& detail,
                                     details::Definition& detail,
                                     details::Definition& detail,
                                     Equipped& equipped) noexcept;
                                     Equipped& equipped) noexcept;
 
 
+/**
+ * Resolves one socket lane to the definition that supplies its native perks and stat changes.
+ * @param equipped Equipped base item and its visible socket plugs.
+ * @param lane Zero-based ordinary socket lane.
+ * @return Effective plug definition, or the unavailable sentinel for an invalid lane.
+ */
+[[nodiscard]] std::uint16_t resolve_effective_plug(const Equipped& equipped,
+                                                   std::size_t lane) noexcept;
+
 /** Fills every empty-valued field with the sentinel its reader tests for. */
 /** Fills every empty-valued field with the sentinel its reader tests for. */
 void apply_sentinels(layout::Appearance& appearance) noexcept;
 void apply_sentinels(layout::Appearance& appearance) noexcept;
 
 

+ 18 - 1
Sunrise/src/middleware/datagen/definitions.h

@@ -1,5 +1,6 @@
 #pragma once
 #pragma once
 
 
+#include <cstddef>
 #include <cstdint>
 #include <cstdint>
 
 
 namespace sunrise::middleware::datagen {
 namespace sunrise::middleware::datagen {
@@ -19,13 +20,25 @@ inline constexpr std::uint32_t kCharacterObjectId = 0xE5E86992U;
 /** Object id for the family-four item-instance slot. */
 /** Object id for the family-four item-instance slot. */
 inline constexpr std::uint32_t kItemInstanceObjectId = 0x6CFBA3ABU;
 inline constexpr std::uint32_t kItemInstanceObjectId = 0x6CFBA3ABU;
 
 
+/** Object id for the family-two social roster directory slot. */
+inline constexpr std::uint32_t kSocialRosterDirectoryObjectId = 0xDA277CE4U;
+/** Object id for the family-two social roster member slot. */
+inline constexpr std::uint32_t kSocialRosterMemberObjectId = 0x811115CEU;
+
+/** Sizes the family-two slot descriptors declare, in bytes. */
+inline constexpr std::size_t kSocialRosterDirectorySize = 96;
+inline constexpr std::size_t kSocialRosterMemberSize = 80;
+
 /** Families that carry a generated object. */
 /** Families that carry a generated object. */
 inline constexpr std::uint32_t kBannerFamily = 0;
 inline constexpr std::uint32_t kBannerFamily = 0;
+inline constexpr std::uint32_t kSocialRosterFamily = 2;
 inline constexpr std::uint32_t kRosterFamily = 3;
 inline constexpr std::uint32_t kRosterFamily = 3;
 inline constexpr std::uint32_t kAccountFamily = 4;
 inline constexpr std::uint32_t kAccountFamily = 4;
 
 
 /** Slots those objects occupy. */
 /** Slots those objects occupy. */
 inline constexpr std::uint32_t kRosterSlot = 0;
 inline constexpr std::uint32_t kRosterSlot = 0;
+inline constexpr std::uint32_t kSocialRosterDirectorySlot = 0;
+inline constexpr std::uint32_t kSocialRosterMemberSlot = 1;
 inline constexpr std::uint32_t kAccountSlot = 0;
 inline constexpr std::uint32_t kAccountSlot = 0;
 inline constexpr std::uint32_t kCharacterSlot = 1;
 inline constexpr std::uint32_t kCharacterSlot = 1;
 inline constexpr std::uint32_t kItemInstanceSlot = 3;
 inline constexpr std::uint32_t kItemInstanceSlot = 3;
@@ -40,7 +53,11 @@ inline constexpr std::uint32_t kItemInstanceSlot = 3;
 [[nodiscard]] constexpr bool
 [[nodiscard]] constexpr bool
 object_id(std::uint32_t familyType, std::uint32_t slotIndex, std::uint32_t& objectId) noexcept {
 object_id(std::uint32_t familyType, std::uint32_t slotIndex, std::uint32_t& objectId) noexcept {
     objectId = 0;
     objectId = 0;
-    if (familyType == kRosterFamily && slotIndex == kRosterSlot) {
+    if (familyType == kSocialRosterFamily && slotIndex == kSocialRosterDirectorySlot) {
+        objectId = kSocialRosterDirectoryObjectId;
+    } else if (familyType == kSocialRosterFamily && slotIndex == kSocialRosterMemberSlot) {
+        objectId = kSocialRosterMemberObjectId;
+    } else if (familyType == kRosterFamily && slotIndex == kRosterSlot) {
         objectId = kRosterObjectId;
         objectId = kRosterObjectId;
     } else if (familyType == kAccountFamily && slotIndex == kAccountSlot) {
     } else if (familyType == kAccountFamily && slotIndex == kAccountSlot) {
         objectId = kAccountObjectId;
         objectId = kAccountObjectId;

+ 3 - 0
Sunrise/src/middleware/datagen/family4/account/account_encoder.cpp

@@ -96,6 +96,9 @@ bool encode(const state::AccountState& state,
     object.acquiredFlags = unlocks.accountFlags;
     object.acquiredFlags = unlocks.accountFlags;
     object.profileUnlockFlags = unlocks.profileFlags;
     object.profileUnlockFlags = unlocks.profileFlags;
     object.objectiveValues = unlocks.objectiveValues;
     object.objectiveValues = unlocks.objectiveValues;
+    if (!state::build_data::complete_exotic_catalyst_objectives(object.objectiveValues)) {
+        return false;
+    }
     // Season claims map to account flags; pending claims overlay the same response.
     // Season claims map to account flags; pending claims overlay the same response.
     if (!seasonal::apply_reward_claims(object.acquiredFlags)) {
     if (!seasonal::apply_reward_claims(object.acquiredFlags)) {
         return false;
         return false;

+ 0 - 96
Sunrise/src/middleware/datagen/family4/account/preferences/native_key_binding_map.h

@@ -1,96 +0,0 @@
-#pragma once
-
-#include <array>
-#include <cstddef>
-
-#include "../../../../../state/account/settings/key_bindings.h"
-
-namespace sunrise::middleware::datagen::family4::account::preferences {
-
-using state::account::settings::bindings::Action;
-
-/** Array position is the fixed native account slot for the named semantic State action. */
-inline constexpr std::array<Action, state::account::settings::bindings::kActionCount>
-    kActionsBySlot{
-        Action::fire,
-        Action::toggleZoom,
-        Action::holdZoom,
-        Action::melee,
-        Action::grenade,
-        Action::super,
-        Action::reload,
-        Action::lightAttack,
-        Action::heavyAttack,
-        Action::block,
-        Action::switchWeapons,
-        Action::nextWeapon,
-        Action::previousWeapon,
-        Action::primaryWeapon,
-        Action::specialWeapon,
-        Action::heavyWeapon,
-        Action::moveForward,
-        Action::moveBackward,
-        Action::moveLeft,
-        Action::moveRight,
-        Action::jump,
-        Action::toggleCrouch,
-        Action::holdCrouch,
-        Action::toggleSprint,
-        Action::holdSprint,
-        Action::vehicleBoost,
-        Action::vehicleBrake,
-        Action::vehicleZoom,
-        Action::vehicleFirePrimary,
-        Action::vehicleFireSecondary,
-        Action::vehicleExit,
-        Action::interact,
-        Action::highlightPlayer,
-        Action::emoteOne,
-        Action::emoteTwo,
-        Action::emoteThree,
-        Action::emoteFour,
-        Action::airMove,
-        Action::classAbility,
-        Action::deathCameraZoomIn,
-        Action::deathCameraZoomOut,
-        Action::pushToTalk,
-        Action::uiGamepadButtonBack,
-        Action::uiOpenDirector,
-        Action::uiOpenDirectorStoreTab,
-        Action::uiOpenDirectorPursuitsTab,
-        Action::uiOpenDirectorMapTab,
-        Action::uiOpenDirectorDestinationsTab,
-        Action::uiOpenDirectorRosterTab,
-        Action::uiOpenDirectorSeasonsTab,
-        Action::uiOpenStartMenuAlternative,
-        Action::uiOpenStartMenuRecordsTab,
-        Action::uiOpenStartMenuCollectionsTab,
-        Action::uiOpenStartMenuClanTab,
-        Action::uiOpenStartMenuInventoryTab,
-        Action::uiOpenStartMenuSettingsTab,
-        Action::uiOpenExitDialogConfirm,
-        Action::uiAbortActivity,
-        Action::uiTextChatToggleState,
-        Action::screenshot,
-    };
-
-/**
- * Verifies that the native slot table contains every semantic action once.
- * @return True when the table is a complete one-to-one mapping.
- */
-[[nodiscard]] consteval bool complete() noexcept {
-    std::array<bool, state::account::settings::bindings::kActionCount> seen{};
-    for (const Action action : kActionsBySlot) {
-        const std::size_t stateIndex = static_cast<std::size_t>(action);
-        // Every State action must own exactly one native account slot.
-        if (stateIndex >= seen.size() || seen[stateIndex]) {
-            return false;
-        }
-        seen[stateIndex] = true;
-    }
-    return true;
-}
-
-static_assert(complete());
-
-} // namespace sunrise::middleware::datagen::family4::account::preferences

+ 6 - 3
Sunrise/src/middleware/datagen/family4/account/preferences/preferences_encoder.cpp

@@ -1,10 +1,12 @@
 #include "preferences_encoder.h"
 #include "preferences_encoder.h"
 
 
-#include "native_key_binding_map.h"
+#include "../../../../../state/account/settings/native_key_binding_map.h"
 
 
 namespace sunrise::middleware::datagen::family4::account::preferences {
 namespace sunrise::middleware::datagen::family4::account::preferences {
 namespace {
 namespace {
 
 
+namespace bindings = state::account::settings::bindings;
+
 /** Native keybinding halves use input code 0x74 as the unbound sentinel. */
 /** Native keybinding halves use input code 0x74 as the unbound sentinel. */
 constexpr std::uint16_t kUnboundInputCode = 0x0074;
 constexpr std::uint16_t kUnboundInputCode = 0x0074;
 /** Seed version 0 lets the client set up local mirrors once after sign-in. */
 /** Seed version 0 lets the client set up local mirrors once after sign-in. */
@@ -128,8 +130,9 @@ bool encode(const state::account::settings::AccountSettings& settings,
     record.chatAutoHideMode = social.chatAutoHideMode;
     record.chatAutoHideMode = social.chatAutoHideMode;
     bindingsRecord.voiceChatMirror = native_boolean(social.voiceChatEnabled);
     bindingsRecord.voiceChatMirror = native_boolean(social.voiceChatEnabled);
 
 
-    for (std::size_t nativeSlot = 0; nativeSlot < kActionsBySlot.size(); ++nativeSlot) {
-        const auto action = kActionsBySlot[nativeSlot];
+    for (std::size_t nativeSlot = 0; nativeSlot < bindings::kActionsByNativeSlot.size();
+         ++nativeSlot) {
+        const auto action = bindings::kActionsByNativeSlot[nativeSlot];
         const std::size_t stateIndex = static_cast<std::size_t>(action);
         const std::size_t stateIndex = static_cast<std::size_t>(action);
         // Native ABI order stays independent from the semantic State enum order.
         // Native ABI order stays independent from the semantic State enum order.
         bindingsRecord.keyBindings[nativeSlot] =
         bindingsRecord.keyBindings[nativeSlot] =

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

@@ -3,6 +3,7 @@
 #include <algorithm>
 #include <algorithm>
 #include <cstring>
 #include <cstring>
 
 
+#include "../../../../state/build_data/runtime.h"
 #include "abi.h"
 #include "abi.h"
 #include "layout.h"
 #include "layout.h"
 
 
@@ -150,6 +151,16 @@ bool encode(const ResolvedInstance& input, std::span<std::byte> output) noexcept
             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()) {
                 object.ordinarySockets.sockets[index].plugDefinitionIndex = *plug;
                 object.ordinarySockets.sockets[index].plugDefinitionIndex = *plug;
+                // There is no universally safe constant for the two auxiliary hashes; a wrong
+                // one silently blanks the socket's render instead of failing loudly. Each
+                // socket's own plug definition hash is what the client expects here. Falls back
+                // to the zero fill from initialize_empty_fields if the hash cannot be resolved,
+                // so this cannot regress a working socket into a worse state than before.
+                state::build_data::items::Definition plugDefinition{};
+                if (state::build_data::find_item_definition_index(*plug, plugDefinition)) {
+                    object.ordinarySockets.sockets[index].auxiliaryHashes.fill(
+                        plugDefinition.definitionHash);
+                }
             }
             }
         }
         }
     }
     }

+ 16 - 6
Sunrise/src/middleware/datagen/family4/loadout/loadout_item_resolver.cpp

@@ -136,6 +136,7 @@ bool resolve_item(const authored_inventory::Item& authored,
     build_details::Definition itemDetail{};
     build_details::Definition itemDetail{};
     build_buckets::Descriptor bucket{};
     build_buckets::Descriptor bucket{};
     build_socket_lists::Definition socketList{};
     build_socket_lists::Definition socketList{};
+    std::uint8_t nativeEquipmentSlot = 0;
     if (!state::build_data::find_item_definition_hash(authored.definitionHash, itemDefinition)
     if (!state::build_data::find_item_definition_hash(authored.definitionHash, itemDefinition)
         || !state::build_data::find_configured_item_detail(itemDefinition.definitionIndex,
         || !state::build_data::find_configured_item_detail(itemDefinition.definitionIndex,
                                                            itemDetail)
                                                            itemDetail)
@@ -151,15 +152,16 @@ bool resolve_item(const authored_inventory::Item& authored,
 
 
     Candidate candidate{};
     Candidate candidate{};
     candidate.bucket = bucket;
     candidate.bucket = bucket;
-    if (itemDetail.equipmentSlot.has_value()) {
-        if (*itemDetail.equipmentSlot < 0) {
+    if (authored_inventory::resolve_native_equipment_slot(
+            authored.definitionHash, itemDetail.equipmentSlot, nativeEquipmentSlot)) {
+        if (static_cast<std::size_t>(nativeEquipmentSlot) >= build_details::kEquipmentSlotCount) {
             return false;
             return false;
         }
         }
-        candidate.item.equipmentSlot = static_cast<std::uint8_t>(*itemDetail.equipmentSlot);
+        candidate.item.equipmentSlot = nativeEquipmentSlot;
+    } else if (itemDetail.equipmentSlot.has_value()
+               || bucket.equipmentSlot != build_buckets::kUnavailableEquipmentSlot) {
+        return false;
     } else {
     } else {
-        if (bucket.equipmentSlot != build_buckets::kUnavailableEquipmentSlot) {
-            return false;
-        }
         candidate.item.equipmentSlot = kUnavailableEquipmentSlot;
         candidate.item.equipmentSlot = kUnavailableEquipmentSlot;
     }
     }
     candidate.item.mutationSerial = authored.mutationSerial;
     candidate.item.mutationSerial = authored.mutationSerial;
@@ -171,6 +173,14 @@ bool resolve_item(const authored_inventory::Item& authored,
                                      candidate.item.instance.ordinarySockets)) {
                                      candidate.item.instance.ordinarySockets)) {
         return false;
         return false;
     }
     }
+    std::uint32_t completedFlags = candidate.item.flags;
+    auto completedPlugs = candidate.item.instance.ordinarySockets.plugs;
+    if (state::build_data::complete_exotic_catalyst(
+            itemDefinition.definitionIndex, completedFlags, completedPlugs)
+        == state::build_data::items::catalysts::ApplyResult::completed) {
+        candidate.item.flags = completedFlags;
+        candidate.item.instance.ordinarySockets.plugs = completedPlugs;
+    }
 
 
     candidate.item.instance.instanceSoid = authored.instanceSoid;
     candidate.item.instance.instanceSoid = authored.instanceSoid;
     candidate.item.instance.bounds.itemDefinitionCount =
     candidate.item.instance.bounds.itemDefinitionCount =

+ 1 - 1
Sunrise/src/middleware/web_service/messages/opcode406.h

@@ -19,7 +19,7 @@ struct Request {
 
 
 /**
 /**
  * Parses the exact reflected opcode-406 item-state descriptor.
  * Parses the exact reflected opcode-406 item-state descriptor.
- * The state value is biased from INT32_MIN, and only the two supported state bits are accepted.
+ * The state value is biased from INT32_MIN, and only the three supported state bits are accepted.
  * Fields are filled as far as the parse reaches, so a refused request still describes itself.
  * Fields are filled as far as the parse reaches, so a refused request still describes itself.
  * @param message Parsed Web Service envelope.
  * @param message Parsed Web Service envelope.
  * @param request Receives the instance, definition row, and unbiased state bits.
  * @param request Receives the instance, definition row, and unbiased state bits.

+ 3 - 3
Sunrise/src/middleware/web_service/messages/opcode406_codec.cpp

@@ -1,5 +1,6 @@
 #include <cstddef>
 #include <cstddef>
 
 
+#include "../../../state/account/inventory/item_state.h"
 #include "../../encoding/bit_reader.h"
 #include "../../encoding/bit_reader.h"
 #include "opcode406.h"
 #include "opcode406.h"
 
 
@@ -16,8 +17,6 @@ constexpr std::uint8_t kValueWidth = 32;
 constexpr std::uint8_t kPaddingWidth = 7;
 constexpr std::uint8_t kPaddingWidth = 7;
 /** Nonnegative signed 32-bit values have this bit set after native descriptor biasing. */
 /** Nonnegative signed 32-bit values have this bit set after native descriptor biasing. */
 constexpr std::uint64_t kValueBias = 0x80000000ULL;
 constexpr std::uint64_t kValueBias = 0x80000000ULL;
-/** Only the two lowest state bits are supported by this build. */
-constexpr std::uint64_t kSupportedStateBits = 0x3U;
 
 
 } // namespace
 } // namespace
 
 
@@ -49,7 +48,8 @@ bool parse_request(const Message& message, Request& request) noexcept {
 
 
     return read && instancePresent != 0 && instanceSoid != 0 && definitionPresent != 0
     return read && instancePresent != 0 && instanceSoid != 0 && definitionPresent != 0
            && encodedFlags >= kValueBias && padding == 0
            && encodedFlags >= kValueBias && padding == 0
-           && encodedFlags - kValueBias <= kSupportedStateBits;
+           && state::account::inventory::valid_item_state(
+               static_cast<std::uint32_t>(encodedFlags - kValueBias));
 }
 }
 
 
 } // namespace sunrise::middleware::web_service::messages::opcode406
 } // namespace sunrise::middleware::web_service::messages::opcode406

+ 875 - 0
Sunrise/src/middleware/web_service/messages/opcode701/opcode701_codec.cpp

@@ -0,0 +1,875 @@
+#include "opcode701_codec.h"
+
+#include <array>
+#include <bit>
+#include <cstddef>
+#include <cstdint>
+#include <limits>
+
+#include "../../../../state/account/settings/native_key_binding_map.h"
+#include "../../../../state/account/settings/settings_state.h"
+#include "../../../encoding/bit_reader.h"
+
+namespace sunrise::middleware::web_service::messages::opcode701 {
+namespace {
+
+using encoding::bits::Reader;
+namespace settings = state::account::settings;
+
+/**
+ * Schema 0x80807603 is a presence-driven reflected object. Every `optional` node starts with one
+ * presence bit; an absent node consumes no body bits. No field is byte-aligned.
+ *
+ * Implicit root (there is no root presence bit)
+ * |-- 0.0? client metadata
+ * |   |-- 0.0.0? [128] optional 64-bit publicity expiries
+ * |   `-- 0.0.1? [13] required 32-bit seen-message values
+ * `-- 0.1? account data
+ *     |-- 0.1.0? [2] optional vectors, each with two required real32 values
+ *     |-- 0.1.1? preference record
+ *     |   |-- 0.1.1.0-.61: 62 optional scalar preferences
+ *     |   `-- 0.1.1.62? [3][50]: 150 cells, each with its own presence bit and int32
+ *     |-- 0.1.2? seed, three local mirrors, source, and optional 60-word binding table
+ *     |-- 0.1.3? four required 16-bit values
+ *     |-- 0.1.4? mixed known-width record, semantic meaning unknown
+ *     |-- 0.1.5? 22 required 32-bit values
+ *     |-- 0.1.6? optional-region record
+ *     |   |-- 0.1.6.0? [100] optional int16 values, then two required int32 words
+ *     |   `-- 0.1.6.1? one int16 value
+ *     |-- 0.1.7? bool
+ *     |-- 0.1.8? bool
+ *     |-- 0.1.9? bool
+ *     |-- 0.1.10? 8-bit scalar
+ *     |-- 0.1.11? 32-bit scalar
+ *     |-- 0.1.12? 30 required int16 values, then two required int32 words
+ *     `-- 0.1.13? one 32-bit value
+ *
+ * Two optional length-prefixed blobs follow the reflected object. The final partial byte, if any,
+ * is zero padding. Traversal must therefore follow every presence flag even for unsupported data;
+ * a fixed wire offset would become invalid as soon as any earlier optional node is absent.
+ */
+
+/** Wire primitive widths used by schema 0x80807603. */
+constexpr std::uint8_t kPresenceWidthBits = 1;
+constexpr std::uint8_t kBooleanWidthBits = 1;
+constexpr std::uint8_t kTwoWidthBits = 2;
+constexpr std::uint8_t kThreeWidthBits = 3;
+constexpr std::uint8_t kFourWidthBits = 4;
+constexpr std::uint8_t kFiveWidthBits = 5;
+constexpr std::uint8_t kSixWidthBits = 6;
+constexpr std::uint8_t kByteWidthBits = 8;
+constexpr std::uint8_t kScalar16WidthBits = 16;
+constexpr std::uint8_t kScalar32WidthBits = 32;
+constexpr std::uint8_t kScalar64WidthBits = 64;
+
+/** Schema array dimensions, kept separate from scalar widths. */
+constexpr std::size_t kPublicityExpiryCount = 128;
+constexpr std::size_t kSeenMessageCount = 13;
+constexpr std::size_t kCalibrationVectorCount = 2;
+constexpr std::size_t kCalibrationValuesPerVector = 2;
+constexpr std::size_t kPreferenceMatrixRowCount = 3;
+constexpr std::size_t kPreferenceMatrixColumnCount = 50;
+constexpr std::size_t kGroup_0_1_4OptionalFieldCount = 8;
+constexpr std::size_t kGroup_0_1_3ValueCount = 4;
+constexpr std::size_t kGroup_0_1_5ValueCount = 22;
+constexpr std::size_t kGroup_0_1_6EntryCount = 100;
+constexpr std::size_t kGroup_0_1_6FixedWordCount = 2;
+constexpr std::size_t kGroup_0_1_12ValueCount = 30;
+constexpr std::size_t kGroup_0_1_12FixedWordCount = 2;
+constexpr std::size_t kOuterBlobCount = 2;
+
+/** Catalog invariants used to detect accidental table/schema drift at compile time. */
+constexpr std::size_t kCatalogPreferenceFieldCount = 62;
+constexpr std::size_t kCatalogKeyBindingCount = 60;
+constexpr std::size_t kCatalogRootMetadataMaximumBits = 8'739;
+constexpr std::size_t kCatalogPreferencesMaximumBits = 5'300;
+constexpr std::size_t kCatalogBindingsMaximumBits = 1'996;
+constexpr std::size_t kCatalogAccountBranchMaximumBits = 10'956;
+constexpr std::size_t kCatalogMaximumBits = 19'695;
+
+/** A packed binding half with this value represents no assigned input. */
+constexpr std::uint16_t kUnboundInputCode = settings::bindings::kUnboundInputCode;
+/** Each packed binding word stores one primary half followed by one secondary half. */
+constexpr unsigned kBindingHalfWidthBits = kScalar16WidthBits;
+
+/** Outer blobs encode their byte length in one unsigned 16-bit field. */
+constexpr std::uint8_t kOuterBlobLengthWidthBits = kScalar16WidthBits;
+/** A whole unread byte is data, while fewer than eight final bits may be terminal padding. */
+constexpr std::size_t kTerminalPaddingLimitBits = kByteWidthBits;
+
+/** Returns the bit count of a required fixed-width array. */
+[[nodiscard]] constexpr std::size_t fixed_array_width_bits(std::size_t count,
+                                                           std::size_t widthBits) noexcept {
+    return count * widthBits;
+}
+
+/** One scalar descriptor's stored width, destination width, and modular wire bias. */
+struct ScalarEncoding {
+    std::uint8_t wireWidthBits;
+    std::uint8_t nativeWidthBits;
+    std::uint64_t bias;
+};
+
+/** Compact signed selectors store the destination value plus one. */
+constexpr std::uint64_t kCompactIntegerBias = 1;
+/** Reflected signed 32-bit values store the destination bit pattern plus INT32_MIN. */
+constexpr std::uint64_t kSigned32Bias = 0x80000000ULL;
+
+constexpr ScalarEncoding kBoolEncoding{kBooleanWidthBits, kBooleanWidthBits, 0};
+constexpr ScalarEncoding kInt8TwoBitEncoding{kTwoWidthBits, kByteWidthBits, kCompactIntegerBias};
+constexpr ScalarEncoding kInt8ThreeBitEncoding{
+    kThreeWidthBits, kByteWidthBits, kCompactIntegerBias};
+constexpr ScalarEncoding kInt8FourBitEncoding{kFourWidthBits, kByteWidthBits, kCompactIntegerBias};
+constexpr ScalarEncoding kInt32Encoding{kScalar32WidthBits, kScalar32WidthBits, kSigned32Bias};
+constexpr ScalarEncoding kReal32Encoding{kScalar32WidthBits, kScalar32WidthBits, 0};
+
+constexpr std::size_t kPreferenceFieldCount = kCatalogPreferenceFieldCount;
+constexpr std::size_t kKeyBindingCount = settings::bindings::kActionCount;
+
+static_assert(kKeyBindingCount == kCatalogKeyBindingCount);
+
+/** Reinterprets an already-unbiased byte pattern as its signed destination value. */
+[[nodiscard]] constexpr std::int8_t as_int8(std::uint64_t value) noexcept {
+    return std::bit_cast<std::int8_t>(static_cast<std::uint8_t>(value));
+}
+
+/** Reinterprets an already-unbiased 32-bit pattern as its signed destination value. */
+[[nodiscard]] constexpr std::int32_t as_int32(std::uint64_t value) noexcept {
+    return std::bit_cast<std::int32_t>(static_cast<std::uint32_t>(value));
+}
+
+/** Reinterprets a raw IEEE-754 32-bit pattern without applying an integer conversion. */
+[[nodiscard]] constexpr float as_real32(std::uint64_t value) noexcept {
+    return std::bit_cast<float>(static_cast<std::uint32_t>(value));
+}
+
+/** Typed assignment adapters let each schema descriptor name its exact nested delta member. */
+template <auto GroupMember, auto FieldMember>
+void assign_bool(std::uint64_t value, settings::SettingsDelta& delta) noexcept {
+    (delta.*GroupMember).*FieldMember = value != 0;
+}
+
+template <auto GroupMember, auto FieldMember>
+void assign_int8(std::uint64_t value, settings::SettingsDelta& delta) noexcept {
+    (delta.*GroupMember).*FieldMember = as_int8(value);
+}
+
+template <auto GroupMember, auto FieldMember>
+void assign_int32(std::uint64_t value, settings::SettingsDelta& delta) noexcept {
+    (delta.*GroupMember).*FieldMember = as_int32(value);
+}
+
+template <auto GroupMember, auto FieldMember>
+void assign_real32(std::uint64_t value, settings::SettingsDelta& delta) noexcept {
+    (delta.*GroupMember).*FieldMember = as_real32(value);
+}
+
+/** A plain function pointer keeps the descriptor table constexpr and allocation-free. */
+using PreferenceAssignment = void (*)(std::uint64_t, settings::SettingsDelta&) noexcept;
+
+/** One schema path's index, wire decoding rule, and optional semantic State destination. */
+struct PreferenceDescriptor {
+    std::size_t schemaIndex;
+    ScalarEncoding encoding;
+    PreferenceAssignment assign;
+};
+
+/**
+ * Single source of truth for preference paths 0.1.1.0 through 0.1.1.61.
+ * A null assignment marks a structurally known field that is intentionally traversal-only.
+ */
+constexpr std::array<PreferenceDescriptor, kPreferenceFieldCount> kPreferenceDescriptors{
+    PreferenceDescriptor{0, kBoolEncoding, nullptr},  // profile setup marker
+    PreferenceDescriptor{1, kInt32Encoding, nullptr}, // post-processing seed version
+    PreferenceDescriptor{
+        2,
+        kInt8FourBitEncoding,
+        assign_int8<&settings::SettingsDelta::controls, &settings::ControlsDelta::buttonLayout>},
+    PreferenceDescriptor{
+        3,
+        kInt8ThreeBitEncoding,
+        assign_int8<&settings::SettingsDelta::controls, &settings::ControlsDelta::movementMode>},
+    PreferenceDescriptor{4,
+                         kInt8FourBitEncoding,
+                         assign_int8<&settings::SettingsDelta::controls,
+                                     &settings::ControlsDelta::controllerLookSensitivity>},
+    PreferenceDescriptor{5,
+                         kInt8ThreeBitEncoding,
+                         assign_int8<&settings::SettingsDelta::controls,
+                                     &settings::ControlsDelta::doublePressDelay>},
+    PreferenceDescriptor{6,
+                         kInt32Encoding,
+                         assign_int32<&settings::SettingsDelta::controls,
+                                      &settings::ControlsDelta::mouseLookSensitivity>},
+    PreferenceDescriptor{7,
+                         kReal32Encoding,
+                         assign_real32<&settings::SettingsDelta::controls,
+                                       &settings::ControlsDelta::adsSensitivityModifier>},
+    PreferenceDescriptor{
+        8,
+        kInt8TwoBitEncoding,
+        assign_int8<&settings::SettingsDelta::interface, &settings::InterfaceDelta::subtitlesMode>},
+    PreferenceDescriptor{
+        9,
+        kInt8FourBitEncoding,
+        assign_int8<&settings::SettingsDelta::interface, &settings::InterfaceDelta::textSize>},
+    PreferenceDescriptor{
+        10,
+        kInt8FourBitEncoding,
+        assign_int8<&settings::SettingsDelta::interface, &settings::InterfaceDelta::textColor>},
+    PreferenceDescriptor{11,
+                         kInt8FourBitEncoding,
+                         assign_int8<&settings::SettingsDelta::interface,
+                                     &settings::InterfaceDelta::textBackgroundStyle>},
+    PreferenceDescriptor{12,
+                         kInt8FourBitEncoding,
+                         assign_int8<&settings::SettingsDelta::interface,
+                                     &settings::InterfaceDelta::textBackgroundOpacity>},
+    PreferenceDescriptor{13,
+                         kInt8FourBitEncoding,
+                         assign_int8<&settings::SettingsDelta::interface,
+                                     &settings::InterfaceDelta::reservedTextMode>},
+    PreferenceDescriptor{14,
+                         kInt8FourBitEncoding,
+                         assign_int8<&settings::SettingsDelta::interface,
+                                     &settings::InterfaceDelta::subtitleOptionsEntry>},
+    PreferenceDescriptor{
+        15,
+        kInt8TwoBitEncoding,
+        assign_int8<&settings::SettingsDelta::audio, &settings::AudioDelta::voiceOutputMode>},
+    PreferenceDescriptor{
+        16,
+        kInt8TwoBitEncoding,
+        assign_int8<&settings::SettingsDelta::audio, &settings::AudioDelta::teamVoiceChannel>},
+    PreferenceDescriptor{
+        17,
+        kInt8ThreeBitEncoding,
+        assign_int8<&settings::SettingsDelta::display, &settings::DisplayDelta::brightness>},
+    PreferenceDescriptor{
+        18,
+        kInt8TwoBitEncoding,
+        assign_int8<&settings::SettingsDelta::interface, &settings::InterfaceDelta::helmetMode>},
+    PreferenceDescriptor{19,
+                         kInt8ThreeBitEncoding,
+                         assign_int8<&settings::SettingsDelta::interface,
+                                     &settings::InterfaceDelta::colorblindMode>},
+    PreferenceDescriptor{
+        20,
+        kInt8ThreeBitEncoding,
+        assign_int8<&settings::SettingsDelta::interface, &settings::InterfaceDelta::reticleColor>},
+    PreferenceDescriptor{
+        21,
+        kInt8TwoBitEncoding,
+        assign_int8<&settings::SettingsDelta::audio, &settings::AudioDelta::reservedMode>},
+    PreferenceDescriptor{22, kInt8ThreeBitEncoding, nullptr}, // unmapped audio-padding field
+    PreferenceDescriptor{
+        23,
+        kInt8FourBitEncoding,
+        assign_int8<&settings::SettingsDelta::audio, &settings::AudioDelta::migrationVersion>},
+    PreferenceDescriptor{
+        24,
+        kInt8FourBitEncoding,
+        assign_int8<&settings::SettingsDelta::audio, &settings::AudioDelta::soundEffectsVolume>},
+    PreferenceDescriptor{
+        25,
+        kInt8FourBitEncoding,
+        assign_int8<&settings::SettingsDelta::audio, &settings::AudioDelta::dialogueVolume>},
+    PreferenceDescriptor{
+        26,
+        kInt8FourBitEncoding,
+        assign_int8<&settings::SettingsDelta::audio, &settings::AudioDelta::musicVolume>},
+    PreferenceDescriptor{
+        27,
+        kInt8FourBitEncoding,
+        assign_int8<&settings::SettingsDelta::audio, &settings::AudioDelta::chatVolume>},
+    PreferenceDescriptor{
+        28,
+        kBoolEncoding,
+        assign_bool<&settings::SettingsDelta::audio, &settings::AudioDelta::muteWhenUnfocused>},
+    PreferenceDescriptor{29,
+                         kBoolEncoding,
+                         assign_bool<&settings::SettingsDelta::controls,
+                                     &settings::ControlsDelta::controllerInvertVertical>},
+    PreferenceDescriptor{30,
+                         kBoolEncoding,
+                         assign_bool<&settings::SettingsDelta::controls,
+                                     &settings::ControlsDelta::controllerInvertHorizontal>},
+    PreferenceDescriptor{31,
+                         kBoolEncoding,
+                         assign_bool<&settings::SettingsDelta::controls,
+                                     &settings::ControlsDelta::mouseInvertVertical>},
+    PreferenceDescriptor{32,
+                         kBoolEncoding,
+                         assign_bool<&settings::SettingsDelta::controls,
+                                     &settings::ControlsDelta::mouseInvertHorizontal>},
+    PreferenceDescriptor{33,
+                         kBoolEncoding,
+                         assign_bool<&settings::SettingsDelta::controls,
+                                     &settings::ControlsDelta::controllerAutoLookCentering>},
+    PreferenceDescriptor{34,
+                         kBoolEncoding,
+                         assign_bool<&settings::SettingsDelta::social,
+                                     &settings::SocialDelta::preferGoodConnection>},
+    PreferenceDescriptor{35,
+                         kBoolEncoding,
+                         assign_bool<&settings::SettingsDelta::controls,
+                                     &settings::ControlsDelta::controllerVibration>},
+    PreferenceDescriptor{36,
+                         kBoolEncoding,
+                         assign_bool<&settings::SettingsDelta::controls,
+                                     &settings::ControlsDelta::unidentifiedToggle>},
+    PreferenceDescriptor{37,
+                         kBoolEncoding,
+                         assign_bool<&settings::SettingsDelta::controls,
+                                     &settings::ControlsDelta::mouseAimSmoothing>},
+    PreferenceDescriptor{38,
+                         kBoolEncoding,
+                         assign_bool<&settings::SettingsDelta::controls,
+                                     &settings::ControlsDelta::controllerSwapShoulders>},
+    PreferenceDescriptor{39, kBoolEncoding, nullptr}, // first unmapped identity-padding field
+    PreferenceDescriptor{40, kBoolEncoding, nullptr}, // second unmapped identity-padding field
+    PreferenceDescriptor{
+        41,
+        kBoolEncoding,
+        assign_bool<&settings::SettingsDelta::social, &settings::SocialDelta::showRealNames>},
+    PreferenceDescriptor{
+        42,
+        kBoolEncoding,
+        assign_bool<&settings::SettingsDelta::interface, &settings::InterfaceDelta::displayHints>},
+    PreferenceDescriptor{
+        43,
+        kBoolEncoding,
+        assign_bool<&settings::SettingsDelta::display, &settings::DisplayDelta::showFps>},
+    PreferenceDescriptor{44,
+                         kInt8TwoBitEncoding,
+                         assign_int8<&settings::SettingsDelta::interface,
+                                     &settings::InterfaceDelta::reticleLocation>},
+    PreferenceDescriptor{45,
+                         kBoolEncoding,
+                         assign_bool<&settings::SettingsDelta::social,
+                                     &settings::SocialDelta::clanInviteNotifications>},
+    PreferenceDescriptor{
+        46,
+        kBoolEncoding,
+        assign_bool<&settings::SettingsDelta::social, &settings::SocialDelta::profanityFilter>},
+    PreferenceDescriptor{47,
+                         kInt8ThreeBitEncoding,
+                         assign_int8<&settings::SettingsDelta::interface,
+                                     &settings::InterfaceDelta::backgroundOpacity>},
+    PreferenceDescriptor{
+        48,
+        kInt8ThreeBitEncoding,
+        assign_int8<&settings::SettingsDelta::interface, &settings::InterfaceDelta::hudOpacity>},
+    PreferenceDescriptor{
+        49,
+        kBoolEncoding,
+        assign_bool<&settings::SettingsDelta::social, &settings::SocialDelta::voiceChatEnabled>},
+    PreferenceDescriptor{
+        50,
+        kInt8TwoBitEncoding,
+        assign_int8<&settings::SettingsDelta::social, &settings::SocialDelta::whisperChatMode>},
+    PreferenceDescriptor{
+        51,
+        kInt8TwoBitEncoding,
+        assign_int8<&settings::SettingsDelta::social, &settings::SocialDelta::teamChatJoinMode>},
+    PreferenceDescriptor{
+        52,
+        kInt8TwoBitEncoding,
+        assign_int8<&settings::SettingsDelta::social, &settings::SocialDelta::localChatJoinMode>},
+    PreferenceDescriptor{
+        53,
+        kInt8TwoBitEncoding,
+        assign_int8<&settings::SettingsDelta::social, &settings::SocialDelta::clanChatJoinMode>},
+    PreferenceDescriptor{
+        54,
+        kInt8TwoBitEncoding,
+        assign_int8<&settings::SettingsDelta::display, &settings::DisplayDelta::hdrMode>},
+    PreferenceDescriptor{55,
+                         kReal32Encoding,
+                         assign_real32<&settings::SettingsDelta::display,
+                                       &settings::DisplayDelta::calibrationPrimary>},
+    PreferenceDescriptor{56,
+                         kReal32Encoding,
+                         assign_real32<&settings::SettingsDelta::display,
+                                       &settings::DisplayDelta::calibrationAlpha>},
+    PreferenceDescriptor{
+        57,
+        kInt8ThreeBitEncoding,
+        assign_int8<&settings::SettingsDelta::social, &settings::SocialDelta::textChatMode>},
+    PreferenceDescriptor{
+        58,
+        kInt8TwoBitEncoding,
+        assign_int8<&settings::SettingsDelta::social, &settings::SocialDelta::chatAutoHideMode>},
+    PreferenceDescriptor{59, kBoolEncoding, nullptr}, // motion-blur mirror
+    PreferenceDescriptor{60, kBoolEncoding, nullptr}, // film-grain mirror
+    PreferenceDescriptor{61, kBoolEncoding, nullptr}, // chromatic-aberration mirror
+};
+
+/** Ensures explicit schema indices stay aligned with descriptor array positions. */
+[[nodiscard]] consteval bool valid_preference_descriptors() noexcept {
+    for (std::size_t index = 0; index < kPreferenceDescriptors.size(); ++index) {
+        const PreferenceDescriptor& descriptor = kPreferenceDescriptors[index];
+        if (descriptor.schemaIndex != index || descriptor.encoding.wireWidthBits == 0
+            || descriptor.encoding.wireWidthBits > kScalar64WidthBits
+            || descriptor.encoding.nativeWidthBits == 0
+            || descriptor.encoding.nativeWidthBits > kScalar64WidthBits) {
+            return false;
+        }
+    }
+    return true;
+}
+
+static_assert(valid_preference_descriptors());
+
+/** Optional widths for unknown mixed record path 0.1.4 fields 0 through 7. */
+constexpr std::array<std::uint8_t, kGroup_0_1_4OptionalFieldCount> kGroup_0_1_4OptionalWidths{
+    kScalar64WidthBits,
+    kScalar64WidthBits,
+    kScalar64WidthBits,
+    kTwoWidthBits,
+    kScalar64WidthBits,
+    kTwoWidthBits,
+    kSixWidthBits,
+    kFiveWidthBits,
+};
+
+/** Builds a low-bit mask without evaluating the invalid expression `1 << 64`. */
+[[nodiscard]] constexpr std::uint64_t width_mask(std::uint8_t nativeWidthBits) noexcept {
+    return nativeWidthBits == kScalar64WidthBits ? (std::numeric_limits<std::uint64_t>::max)()
+                                                 : (std::uint64_t{1} << nativeWidthBits) - 1U;
+}
+
+/** Reads the one-bit flag that precedes every optional schema node. */
+[[nodiscard]] bool read_presence(Reader& reader, bool& present) noexcept {
+    std::uint64_t value = 0;
+    if (!reader.read(kPresenceWidthBits, value)) {
+        return false;
+    }
+    present = value != 0;
+    return true;
+}
+
+/** Reads one stored scalar and removes its bias modulo the destination type width. */
+[[nodiscard]] bool
+read_scalar(Reader& reader, const ScalarEncoding& encoding, std::uint64_t& value) noexcept {
+    std::uint64_t stored = 0;
+    if (!reader.read(encoding.wireWidthBits, stored)) {
+        return false;
+    }
+    const std::uint64_t mask = width_mask(encoding.nativeWidthBits);
+    value = (stored - (encoding.bias & mask)) & mask;
+    return true;
+}
+
+/** Reads an optional scalar while preserving absent versus present-zero semantics. */
+[[nodiscard]] bool read_optional_scalar(Reader& reader,
+                                        const ScalarEncoding& encoding,
+                                        bool& present,
+                                        std::uint64_t& value) noexcept {
+    present = false;
+    value = 0;
+    return read_presence(reader, present) && (!present || read_scalar(reader, encoding, value));
+}
+
+/** Consumes one optional field whose value is deliberately not retained. */
+[[nodiscard]] bool skip_optional_bits(Reader& reader, std::size_t wireWidthBits) noexcept {
+    bool present = false;
+    return read_presence(reader, present) && (!present || reader.skip(wireWidthBits));
+}
+
+/** Consumes one optional scalar by its declared encoding without retaining its value. */
+[[nodiscard]] bool skip_optional_scalar(Reader& reader, const ScalarEncoding& encoding) noexcept {
+    return skip_optional_bits(reader, encoding.wireWidthBits);
+}
+
+/** Reads one optional group and delegates its body only when the group is present. */
+template <typename ReadBody>
+[[nodiscard]] bool read_optional_group(Reader& reader, ReadBody readBody) noexcept {
+    bool present = false;
+    return read_presence(reader, present) && (!present || readBody(reader));
+}
+
+/** Consumes the body of optional publicity-expiry bank path 0.0.0. */
+[[nodiscard]] bool skip_publicity_expiry_bank(Reader& reader) noexcept {
+    for (std::size_t index = 0; index < kPublicityExpiryCount; ++index) {
+        if (!skip_optional_bits(reader, kScalar64WidthBits)) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** Consumes the fixed seen-message bank at path 0.0.1. */
+[[nodiscard]] bool skip_seen_message_bank(Reader& reader) noexcept {
+    return reader.skip(fixed_array_width_bits(kSeenMessageCount, kScalar32WidthBits));
+}
+
+/** Consumes optional root branch 0.0 in child descriptor order. */
+[[nodiscard]] bool skip_publicity_and_seen_messages(Reader& reader) noexcept {
+    return read_optional_group(reader, skip_publicity_expiry_bank)
+           && read_optional_group(reader, skip_seen_message_bank);
+}
+
+/** Consumes one present two-scalar element under calibration path 0.1.0. */
+[[nodiscard]] bool skip_calibration_vector(Reader& reader) noexcept {
+    return reader.skip(fixed_array_width_bits(kCalibrationValuesPerVector, kScalar32WidthBits));
+}
+
+/** Consumes present group 0.1.0, including each element's own presence bit. */
+[[nodiscard]] bool skip_group_0_1_0(Reader& reader) noexcept {
+    for (std::size_t index = 0; index < kCalibrationVectorCount; ++index) {
+        if (!read_optional_group(reader, skip_calibration_vector)) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** Consumes present preference matrix path 0.1.1.62 in row-major descriptor order. */
+[[nodiscard]] bool skip_preference_matrix(Reader& reader) noexcept {
+    for (std::size_t row = 0; row < kPreferenceMatrixRowCount; ++row) {
+        for (std::size_t column = 0; column < kPreferenceMatrixColumnCount; ++column) {
+            if (!skip_optional_bits(reader, kScalar32WidthBits)) {
+                return false;
+            }
+        }
+    }
+    return true;
+}
+
+/** Decodes present preference group 0.1.1 and consumes its optional opaque matrix. */
+[[nodiscard]] bool read_preference_record(Reader& reader, settings::SettingsDelta& delta) noexcept {
+    for (const PreferenceDescriptor& descriptor : kPreferenceDescriptors) {
+        bool present = false;
+        std::uint64_t value = 0;
+        if (!read_optional_scalar(reader, descriptor.encoding, present, value)) {
+            return false;
+        }
+        if (present && descriptor.assign != nullptr) {
+            descriptor.assign(value, delta);
+        }
+    }
+    return read_optional_group(reader, skip_preference_matrix);
+}
+
+/** Decodes the body of present fixed keybinding table path 0.1.2.5 atomically. */
+[[nodiscard]] bool read_key_binding_table(Reader& reader, settings::SettingsDelta& delta) noexcept {
+    settings::bindings::KeyBindings staged{};
+    for (std::size_t nativeSlot = 0; nativeSlot < settings::bindings::kActionsByNativeSlot.size();
+         ++nativeSlot) {
+        std::uint64_t value = 0;
+        if (!read_scalar(reader, kInt32Encoding, value)) {
+            return false;
+        }
+
+        // After bias removal, bits 0-15 are primary and bits 16-31 are secondary. The value
+        // 0x0074 in either half is the protocol's unbound sentinel, not a bindable input.
+        const std::uint32_t packed = static_cast<std::uint32_t>(value);
+        const std::uint16_t primary = static_cast<std::uint16_t>(packed);
+        const std::uint16_t secondary = static_cast<std::uint16_t>(packed >> kBindingHalfWidthBits);
+        const auto action = settings::bindings::kActionsByNativeSlot[nativeSlot];
+        auto& binding = staged.values[static_cast<std::size_t>(action)];
+        if (primary != kUnboundInputCode) {
+            binding.primary = primary;
+        }
+        if (secondary != kUnboundInputCode) {
+            binding.secondary = secondary;
+        }
+    }
+    staged.configured = true;
+    delta.keyBindings = staged;
+    return true;
+}
+
+/** Decodes present binding record 0.1.2 in exact child descriptor order. */
+[[nodiscard]] bool read_binding_record(Reader& reader, settings::SettingsDelta& delta) noexcept {
+    // 0.1.2.0: seed/version marker; structurally consumed but not authoritative.
+    if (!skip_optional_scalar(reader, kInt32Encoding)) {
+        return false;
+    }
+    // 0.1.2.1: client-local one-bit mirror; traversal-only.
+    if (!skip_optional_scalar(reader, kBoolEncoding)) {
+        return false;
+    }
+    // 0.1.2.2: client-local VSync mirror.
+    if (!skip_optional_scalar(reader, kInt8ThreeBitEncoding)) {
+        return false;
+    }
+    // 0.1.2.3: client-local FOV mirror.
+    if (!skip_optional_scalar(reader, kInt32Encoding)) {
+        return false;
+    }
+
+    // 0.1.2.4: authored keybinding source and routing input for the optional table.
+    bool sourceSelectorPresent = false;
+    std::uint64_t sourceSelector = 0;
+    if (!read_optional_scalar(reader, kBoolEncoding, sourceSelectorPresent, sourceSelector)) {
+        return false;
+    }
+    if (sourceSelectorPresent) {
+        delta.keyBindingSource = sourceSelector != 0 ? settings::KeyBindingSource::computer
+                                                     : settings::KeyBindingSource::account;
+    }
+
+    // 0.1.2.5: one presence bit covers the complete 60-entry table.
+    return read_optional_group(reader, [&delta](Reader& tableReader) noexcept {
+        return read_key_binding_table(tableReader, delta);
+    });
+}
+
+/** Consumes present fixed-width group 0.1.3. */
+[[nodiscard]] bool skip_group_0_1_3(Reader& reader) noexcept {
+    return reader.skip(fixed_array_width_bits(kGroup_0_1_3ValueCount, kScalar16WidthBits));
+}
+
+/** Consumes present mixed-width group 0.1.4 without assigning unknown semantics. */
+[[nodiscard]] bool skip_group_0_1_4(Reader& reader) noexcept {
+    for (const std::uint8_t width : kGroup_0_1_4OptionalWidths) {
+        if (!skip_optional_bits(reader, width)) {
+            return false;
+        }
+    }
+    // Field 8 is required u64, field 9 is optional 3-bit, and field 10 is required bool.
+    return reader.skip(kScalar64WidthBits) && skip_optional_bits(reader, kThreeWidthBits)
+           && reader.skip(kBooleanWidthBits);
+}
+
+/** Consumes present fixed-width group 0.1.5. */
+[[nodiscard]] bool skip_group_0_1_5(Reader& reader) noexcept {
+    return reader.skip(fixed_array_width_bits(kGroup_0_1_5ValueCount, kScalar32WidthBits));
+}
+
+/** Consumes present nested entry array 0.1.6.0. */
+[[nodiscard]] bool skip_group_0_1_6_0(Reader& reader) noexcept {
+    for (std::size_t index = 0; index < kGroup_0_1_6EntryCount; ++index) {
+        if (!skip_optional_bits(reader, kScalar16WidthBits)) {
+            return false;
+        }
+    }
+    return reader.skip(fixed_array_width_bits(kGroup_0_1_6FixedWordCount, kScalar32WidthBits));
+}
+
+/** Consumes present group 0.1.6, including optional children 0 and 1. */
+[[nodiscard]] bool skip_group_0_1_6(Reader& reader) noexcept {
+    return read_optional_group(reader, skip_group_0_1_6_0)
+           && skip_optional_bits(reader, kScalar16WidthBits);
+}
+
+/** Consumes present fixed-tail group 0.1.12. */
+[[nodiscard]] bool skip_group_0_1_12(Reader& reader) noexcept {
+    return reader.skip(fixed_array_width_bits(kGroup_0_1_12ValueCount, kScalar16WidthBits))
+           && reader.skip(fixed_array_width_bits(kGroup_0_1_12FixedWordCount, kScalar32WidthBits));
+}
+
+/** Traverses every child of present account branch 0.1 in descriptor order. */
+[[nodiscard]] bool read_account_branch(Reader& reader, settings::SettingsDelta& delta) noexcept {
+    // 0.1.0: calibration vectors.
+    if (!read_optional_group(reader, skip_group_0_1_0)) {
+        return false;
+    }
+
+    // 0.1.1: preference scalars and the optional 3-by-50 matrix.
+    if (!read_optional_group(reader, [&delta](Reader& groupReader) noexcept {
+            return read_preference_record(groupReader, delta);
+        })) {
+        return false;
+    }
+
+    // 0.1.2: local mirrors, keybinding source, and packed binding table.
+    if (!read_optional_group(reader, [&delta](Reader& groupReader) noexcept {
+            return read_binding_record(groupReader, delta);
+        })) {
+        return false;
+    }
+
+    // 0.1.3: four required 16-bit values.
+    if (!read_optional_group(reader, skip_group_0_1_3)) {
+        return false;
+    }
+
+    // 0.1.4: mixed-width record with unknown semantics.
+    if (!read_optional_group(reader, skip_group_0_1_4)) {
+        return false;
+    }
+
+    // 0.1.5: 22 required 32-bit values.
+    if (!read_optional_group(reader, skip_group_0_1_5)) {
+        return false;
+    }
+
+    // 0.1.6: optional 100-entry region, two required words, and optional 16-bit tail.
+    if (!read_optional_group(reader, skip_group_0_1_6)) {
+        return false;
+    }
+
+    // 0.1.7: optional Boolean.
+    if (!skip_optional_bits(reader, kBooleanWidthBits)) {
+        return false;
+    }
+
+    // 0.1.8: optional Boolean.
+    if (!skip_optional_bits(reader, kBooleanWidthBits)) {
+        return false;
+    }
+
+    // 0.1.9: optional Boolean.
+    if (!skip_optional_bits(reader, kBooleanWidthBits)) {
+        return false;
+    }
+
+    // 0.1.10: optional 8-bit scalar.
+    if (!skip_optional_bits(reader, kByteWidthBits)) {
+        return false;
+    }
+
+    // 0.1.11: optional 32-bit scalar.
+    if (!skip_optional_bits(reader, kScalar32WidthBits)) {
+        return false;
+    }
+
+    // 0.1.12: 30 required 16-bit values followed by two required 32-bit words.
+    if (!read_optional_group(reader, skip_group_0_1_12)) {
+        return false;
+    }
+
+    // 0.1.13: optional 32-bit scalar.
+    return skip_optional_bits(reader, kScalar32WidthBits);
+}
+
+/** Consumes both optional length-prefixed blobs following the reflected object. */
+[[nodiscard]] bool skip_outer_blobs(Reader& reader) noexcept {
+    for (std::size_t index = 0; index < kOuterBlobCount; ++index) {
+        bool present = false;
+        std::uint64_t byteCount = 0;
+        if (!read_presence(reader, present)) {
+            return false;
+        }
+        if (present
+            && (!reader.read(kOuterBlobLengthWidthBits, byteCount)
+                || !reader.skip(static_cast<std::size_t>(byteCount) * kByteWidthBits))) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** Requires any final partial byte to contain only zero padding. */
+[[nodiscard]] bool finish_padding(Reader& reader) noexcept {
+    const std::size_t remaining = reader.remaining_bits();
+    if (remaining >= kTerminalPaddingLimitBits) {
+        return false;
+    }
+    std::uint64_t padding = 0;
+    return reader.read(static_cast<std::uint8_t>(remaining), padding) && padding == 0
+           && reader.remaining_bits() == 0;
+}
+
+/** Compile-time proof that the declared traversal still matches the catalog's maximum form. */
+namespace schema_size_proof {
+
+[[nodiscard]] consteval std::size_t optional_scalar(std::size_t widthBits) noexcept {
+    return kPresenceWidthBits + widthBits;
+}
+
+[[nodiscard]] consteval std::size_t optional_group(std::size_t bodyBits) noexcept {
+    return kPresenceWidthBits + bodyBits;
+}
+
+[[nodiscard]] consteval std::size_t optional_scalar_array(std::size_t count,
+                                                          std::size_t widthBits) noexcept {
+    return count * optional_scalar(widthBits);
+}
+
+[[nodiscard]] consteval std::size_t preference_fields() noexcept {
+    std::size_t total = 0;
+    for (const PreferenceDescriptor& descriptor : kPreferenceDescriptors) {
+        total += optional_scalar(descriptor.encoding.wireWidthBits);
+    }
+    return total;
+}
+
+[[nodiscard]] consteval std::size_t group_0_1_4_body() noexcept {
+    std::size_t total = 0;
+    for (const std::uint8_t widthBits : kGroup_0_1_4OptionalWidths) {
+        total += optional_scalar(widthBits);
+    }
+    // Field 8 is required u64, field 9 is optional 3-bit, and field 10 is required bool.
+    return total + kScalar64WidthBits + optional_scalar(kThreeWidthBits) + kBooleanWidthBits;
+}
+
+constexpr std::size_t kPublicityExpiryBankBits =
+    optional_group(optional_scalar_array(kPublicityExpiryCount, kScalar64WidthBits));
+constexpr std::size_t kSeenMessageBankBits =
+    optional_group(fixed_array_width_bits(kSeenMessageCount, kScalar32WidthBits));
+constexpr std::size_t kRootMetadataBits =
+    optional_group(kPublicityExpiryBankBits + kSeenMessageBankBits);
+
+constexpr std::size_t kCalibrationGroupBits = optional_group(
+    kCalibrationVectorCount
+    * optional_group(fixed_array_width_bits(kCalibrationValuesPerVector, kScalar32WidthBits)));
+constexpr std::size_t kPreferenceMatrixBits = optional_group(optional_scalar_array(
+    kPreferenceMatrixRowCount * kPreferenceMatrixColumnCount, kScalar32WidthBits));
+constexpr std::size_t kPreferencesBits =
+    optional_group(preference_fields() + kPreferenceMatrixBits);
+constexpr std::size_t kBindingsBits =
+    optional_group(optional_scalar(kScalar32WidthBits) + optional_scalar(kBooleanWidthBits)
+                   + optional_scalar(kThreeWidthBits) + optional_scalar(kScalar32WidthBits)
+                   + optional_scalar(kBooleanWidthBits)
+                   + optional_group(fixed_array_width_bits(kKeyBindingCount, kScalar32WidthBits)));
+constexpr std::size_t kGroup_0_1_3Bits =
+    optional_group(fixed_array_width_bits(kGroup_0_1_3ValueCount, kScalar16WidthBits));
+constexpr std::size_t kGroup_0_1_4Bits = optional_group(group_0_1_4_body());
+constexpr std::size_t kGroup_0_1_5Bits =
+    optional_group(fixed_array_width_bits(kGroup_0_1_5ValueCount, kScalar32WidthBits));
+constexpr std::size_t kGroup_0_1_6Bits = optional_group(
+    optional_group(optional_scalar_array(kGroup_0_1_6EntryCount, kScalar16WidthBits)
+                   + fixed_array_width_bits(kGroup_0_1_6FixedWordCount, kScalar32WidthBits))
+    + optional_scalar(kScalar16WidthBits));
+constexpr std::size_t kAccountTailBits =
+    optional_scalar(kBooleanWidthBits) + optional_scalar(kBooleanWidthBits)
+    + optional_scalar(kBooleanWidthBits) + optional_scalar(kByteWidthBits)
+    + optional_scalar(kScalar32WidthBits);
+constexpr std::size_t kGroup_0_1_12Bits =
+    optional_group(fixed_array_width_bits(kGroup_0_1_12ValueCount, kScalar16WidthBits)
+                   + fixed_array_width_bits(kGroup_0_1_12FixedWordCount, kScalar32WidthBits));
+constexpr std::size_t kGroup_0_1_13Bits = optional_scalar(kScalar32WidthBits);
+
+constexpr std::size_t kAccountBranchBits =
+    optional_group(kCalibrationGroupBits + kPreferencesBits + kBindingsBits + kGroup_0_1_3Bits
+                   + kGroup_0_1_4Bits + kGroup_0_1_5Bits + kGroup_0_1_6Bits + kAccountTailBits
+                   + kGroup_0_1_12Bits + kGroup_0_1_13Bits);
+constexpr std::size_t kSchemaBits = kRootMetadataBits + kAccountBranchBits;
+
+static_assert(kRootMetadataBits == kCatalogRootMetadataMaximumBits);
+static_assert(kPreferencesBits == kCatalogPreferencesMaximumBits);
+static_assert(kBindingsBits == kCatalogBindingsMaximumBits);
+static_assert(kAccountBranchBits == kCatalogAccountBranchMaximumBits);
+static_assert(kSchemaBits == kCatalogMaximumBits);
+
+} // namespace schema_size_proof
+
+} // namespace
+
+/** Decodes the complete schema-0x80807603 request without touching authoritative State. */
+bool parse_request(const Message& message, Request& output) noexcept {
+    output = {};
+    if (message.opcode != kOpcode) {
+        return false;
+    }
+
+    Reader reader(message.payload);
+    Request candidate{};
+    if (!read_optional_group(reader, skip_publicity_and_seen_messages)
+        || !read_optional_group(reader,
+                                [&candidate](Reader& groupReader) noexcept {
+                                    return read_account_branch(groupReader, candidate.settings);
+                                })
+        || !skip_outer_blobs(reader) || !finish_padding(reader)) {
+        return false;
+    }
+
+    output = candidate;
+    return true;
+}
+
+} // namespace sunrise::middleware::web_service::messages::opcode701

+ 31 - 0
Sunrise/src/middleware/web_service/messages/opcode701/opcode701_codec.h

@@ -0,0 +1,31 @@
+#pragma once
+
+#include <cstdint>
+
+#include "../../../../state/account/settings/settings_delta.h"
+#include "../../web_service_envelope.h"
+
+namespace sunrise::middleware::web_service::messages::opcode701 {
+
+/** Web Service opcode used by the Client's account-settings writeback. */
+inline constexpr std::uint16_t kOpcode = 701;
+
+/** Semantic result decoded from one schema-0x80807603 request. */
+struct Request {
+    state::account::settings::SettingsDelta settings;
+};
+
+/**
+ * Decodes the complete presence-driven opcode-701 request body.
+ *
+ * Unsupported schema branches are still traversed so every later field is read at its actual
+ * wire position. Output is cleared on entry; decoded values replace it only after the entire
+ * schema, optional outer blobs, and zero terminal padding validate.
+ *
+ * @param message Parsed Web Service envelope whose payload begins at schema bit zero.
+ * @param output Receives supported fields, the authored binding source, and the atomic table.
+ * @return True only when opcode and complete request encoding are valid.
+ */
+[[nodiscard]] bool parse_request(const Message& message, Request& output) noexcept;
+
+} // namespace sunrise::middleware::web_service::messages::opcode701

+ 41 - 73
Sunrise/src/server/bap/bap_route.cpp

@@ -3,6 +3,8 @@
 #include <algorithm>
 #include <algorithm>
 #include <array>
 #include <array>
 #include <limits>
 #include <limits>
+#include <mutex>
+#include <shared_mutex>
 #include <string_view>
 #include <string_view>
 
 
 #include "../../client/hooks/network/investment/investment_derived_rebuild.h"
 #include "../../client/hooks/network/investment/investment_derived_rebuild.h"
@@ -16,6 +18,7 @@
 #include "activity_authority_query_owner.h"
 #include "activity_authority_query_owner.h"
 #include "activity_authority_reset_owner.h"
 #include "activity_authority_reset_owner.h"
 #include "activity_mission_seed_lease.h"
 #include "activity_mission_seed_lease.h"
+#include "core/threading/srw_lock.h"
 #include "encrypted/bap_connection_publication.h"
 #include "encrypted/bap_connection_publication.h"
 #include "encrypted/push/activity/internal.h"
 #include "encrypted/push/activity/internal.h"
 #include "internal.h"
 #include "internal.h"
@@ -29,7 +32,7 @@ namespace layouts = state::build_data::scenarios;
 namespace roster_message = middleware::bap::activity_message::sensor_auth_update;
 namespace roster_message = middleware::bap::activity_message::sensor_auth_update;
 namespace tables = middleware::content::packages::tables;
 namespace tables = middleware::content::packages::tables;
 
 
-SRWLOCK g_lock{SRWLOCK_INIT};
+core::threading::SrwLock g_lock{};
 std::array<Session, kSessionCount> g_sessions{};
 std::array<Session, kSessionCount> g_sessions{};
 Scratch g_scratch{};
 Scratch g_scratch{};
 std::array<WorldRewardRequest, kWorldRewardQueueCapacity> g_worldRewards{};
 std::array<WorldRewardRequest, kWorldRewardQueueCapacity> g_worldRewards{};
@@ -851,8 +854,9 @@ void clear_session(Session& session) noexcept {
     // The purchase response carries the Family-4 ownership rows. Refresh Family 5 only after the
     // The purchase response carries the Family-4 ownership rows. Refresh Family 5 only after the
     // client has consumed that response, so derived artifact state never mixes adjacent purchases.
     // client has consumed that response, so derived artifact state never mixes adjacent purchases.
     if (session->artifactRefreshArmed) {
     if (session->artifactRefreshArmed) {
-        const state::Family5State family = state::investment_snapshot().family5;
-        if (client::hooks::network::investment::publish_live_family5(family)) {
+        state::InvestmentState investment{};
+        if (state::investment_snapshot(investment)
+            && client::hooks::network::investment::publish_live_family5(investment.family5)) {
             session->artifactRefreshArmed = false;
             session->artifactRefreshArmed = false;
         }
         }
     }
     }
@@ -990,7 +994,7 @@ std::size_t activity_link_count_locked(const state::activity::SessionBinding& bi
 bool consume(const client::network::BapRequest& request,
 bool consume(const client::network::BapRequest& request,
              client::network::BapResponse& response) noexcept {
              client::network::BapResponse& response) noexcept {
     response = {};
     response = {};
-    AcquireSRWLockExclusive(&g_lock);
+    const std::lock_guard lock(g_lock);
     bool success = false;
     bool success = false;
     // Polls report whether they reached scratch.
     // Polls report whether they reached scratch.
     bool touchesScratch = request.event != client::network::BapEvent::poll;
     bool touchesScratch = request.event != client::network::BapEvent::poll;
@@ -1013,15 +1017,13 @@ bool consume(const client::network::BapRequest& request,
     if (touchesScratch) {
     if (touchesScratch) {
         SecureZeroMemory(&g_scratch, sizeof g_scratch);
         SecureZeroMemory(&g_scratch, sizeof g_scratch);
     }
     }
-    ReleaseSRWLockExclusive(&g_lock);
     return success;
     return success;
 }
 }
 
 
 /** Counts authenticated BAP links that currently own one exact activity generation. */
 /** Counts authenticated BAP links that currently own one exact activity generation. */
 std::size_t activity_link_count(const state::activity::SessionBinding& binding) noexcept {
 std::size_t activity_link_count(const state::activity::SessionBinding& binding) noexcept {
-    AcquireSRWLockShared(&g_lock);
+    const std::shared_lock lock(g_lock);
     const std::size_t count = activity_link_count_locked(binding);
     const std::size_t count = activity_link_count_locked(binding);
-    ReleaseSRWLockShared(&g_lock);
     return count;
     return count;
 }
 }
 
 
@@ -1029,7 +1031,7 @@ std::size_t activity_link_count(const state::activity::SessionBinding& binding)
 bool activity_link_view(const state::activity::SessionBinding& binding,
 bool activity_link_view(const state::activity::SessionBinding& binding,
                         ActivityLinkView& output) noexcept {
                         ActivityLinkView& output) noexcept {
     output = {};
     output = {};
-    AcquireSRWLockShared(&g_lock);
+    const std::shared_lock lock(g_lock);
     const Session* const session = unique_activity_link_locked(binding, output.matchingLinks);
     const Session* const session = unique_activity_link_locked(binding, output.matchingLinks);
     if (session != nullptr) {
     if (session != nullptr) {
         const auto region = selected_region_locked(*session);
         const auto region = selected_region_locked(*session);
@@ -1045,7 +1047,6 @@ bool activity_link_view(const state::activity::SessionBinding& binding,
         output.rosterReason = session->activityRosterReason;
         output.rosterReason = session->activityRosterReason;
         output.playerKey = encrypted::push::activity::published_player_key(*session);
         output.playerKey = encrypted::push::activity::published_player_key(*session);
     }
     }
-    ReleaseSRWLockShared(&g_lock);
     return session != nullptr;
     return session != nullptr;
 }
 }
 
 
@@ -1054,7 +1055,7 @@ ActivityMissionSeedLeaseStatus
 activity_mission_seed_available(const state::activity::SessionBinding& binding,
 activity_mission_seed_available(const state::activity::SessionBinding& binding,
                                 std::uint32_t scenarioRow,
                                 std::uint32_t scenarioRow,
                                 std::uint64_t expectedGeneration) noexcept {
                                 std::uint64_t expectedGeneration) noexcept {
-    AcquireSRWLockExclusive(&g_lock);
+    const std::lock_guard lock(g_lock);
     Session* session = nullptr;
     Session* session = nullptr;
     std::size_t matchingLinks = 0;
     std::size_t matchingLinks = 0;
     ActivityMissionSeedLeaseStatus status =
     ActivityMissionSeedLeaseStatus status =
@@ -1062,7 +1063,6 @@ activity_mission_seed_available(const state::activity::SessionBinding& binding,
     if (status == ActivityMissionSeedLeaseStatus::ready && session->activityRosterStaged.staged) {
     if (status == ActivityMissionSeedLeaseStatus::ready && session->activityRosterStaged.staged) {
         status = ActivityMissionSeedLeaseStatus::outputBusy;
         status = ActivityMissionSeedLeaseStatus::outputBusy;
     }
     }
-    ReleaseSRWLockExclusive(&g_lock);
     return status;
     return status;
 }
 }
 
 
@@ -1073,7 +1073,7 @@ activity_mission_seed_lease(const state::activity::SessionBinding& binding,
                             std::uint64_t expectedGeneration,
                             std::uint64_t expectedGeneration,
                             ActivityMissionSeedLeaseView& output) noexcept {
                             ActivityMissionSeedLeaseView& output) noexcept {
     output = {};
     output = {};
-    AcquireSRWLockExclusive(&g_lock);
+    const std::lock_guard lock(g_lock);
     Session* session = nullptr;
     Session* session = nullptr;
     ActivityMissionSeedLeaseStatus status = mission_seed_link_locked(
     ActivityMissionSeedLeaseStatus status = mission_seed_link_locked(
         binding, scenarioRow, expectedGeneration, session, output.matchingLinks);
         binding, scenarioRow, expectedGeneration, session, output.matchingLinks);
@@ -1083,7 +1083,6 @@ activity_mission_seed_lease(const state::activity::SessionBinding& binding,
     if (status == ActivityMissionSeedLeaseStatus::ready) {
     if (status == ActivityMissionSeedLeaseStatus::ready) {
         read_mission_seed_lease(*session, output.matchingLinks, output);
         read_mission_seed_lease(*session, output.matchingLinks, output);
     }
     }
-    ReleaseSRWLockExclusive(&g_lock);
     return status;
     return status;
 }
 }
 
 
@@ -1092,7 +1091,7 @@ ActivityMissionSeedLeaseStatus
 select_activity_mission_seed(const state::activity::SessionBinding& binding,
 select_activity_mission_seed(const state::activity::SessionBinding& binding,
                              const ActivityMissionSeedPlan& plan,
                              const ActivityMissionSeedPlan& plan,
                              std::uint64_t expectedGeneration) noexcept {
                              std::uint64_t expectedGeneration) noexcept {
-    AcquireSRWLockExclusive(&g_lock);
+    const std::lock_guard lock(g_lock);
     Session* session = nullptr;
     Session* session = nullptr;
     std::size_t matchingLinks = 0;
     std::size_t matchingLinks = 0;
     ActivityMissionSeedLeaseStatus status = mission_seed_link_locked(
     ActivityMissionSeedLeaseStatus status = mission_seed_link_locked(
@@ -1106,7 +1105,6 @@ select_activity_mission_seed(const state::activity::SessionBinding& binding,
         if (lease.configured && same_mission_seed_plan(lease.plan, plan)) {
         if (lease.configured && same_mission_seed_plan(lease.plan, plan)) {
             // The script may select the plan the roster adopted by default. That is a selection.
             // The script may select the plan the roster adopted by default. That is a selection.
             lease.scriptSelected = true;
             lease.scriptSelected = true;
-            ReleaseSRWLockExclusive(&g_lock);
             return ActivityMissionSeedLeaseStatus::ready;
             return ActivityMissionSeedLeaseStatus::ready;
         }
         }
         if (lease.configured && lease.revision == (std::numeric_limits<std::uint64_t>::max)()) {
         if (lease.configured && lease.revision == (std::numeric_limits<std::uint64_t>::max)()) {
@@ -1126,7 +1124,6 @@ select_activity_mission_seed(const state::activity::SessionBinding& binding,
             }
             }
             if (!regionKnown) {
             if (!regionKnown) {
                 if (lease.registeredRegionCount >= lease.registeredRegions.size()) {
                 if (lease.registeredRegionCount >= lease.registeredRegions.size()) {
-                    ReleaseSRWLockExclusive(&g_lock);
                     return ActivityMissionSeedLeaseStatus::refused;
                     return ActivityMissionSeedLeaseStatus::refused;
                 }
                 }
                 lease.registeredRegions[lease.registeredRegionCount++] = plan.effectiveRegion;
                 lease.registeredRegions[lease.registeredRegionCount++] = plan.effectiveRegion;
@@ -1145,7 +1142,6 @@ select_activity_mission_seed(const state::activity::SessionBinding& binding,
             lease.scriptSelected = true;
             lease.scriptSelected = true;
         }
         }
     }
     }
-    ReleaseSRWLockExclusive(&g_lock);
     return status;
     return status;
 }
 }
 
 
@@ -1154,13 +1150,12 @@ bool activity_type23_override_available(const state::activity::SessionBinding& b
                                         const activity::host::ScriptableTarget& target,
                                         const activity::host::ScriptableTarget& target,
                                         std::int32_t expectedRegion,
                                         std::int32_t expectedRegion,
                                         std::uint64_t expectedGeneration) noexcept {
                                         std::uint64_t expectedGeneration) noexcept {
-    AcquireSRWLockShared(&g_lock);
+    const std::shared_lock lock(g_lock);
     std::size_t linkCount = 0;
     std::size_t linkCount = 0;
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     const bool available =
     const bool available =
         session != nullptr
         session != nullptr
         && canonical_type23_available_locked(*session, target, expectedRegion, expectedGeneration);
         && canonical_type23_available_locked(*session, target, expectedRegion, expectedGeneration);
-    ReleaseSRWLockShared(&g_lock);
     return available;
     return available;
 }
 }
 
 
@@ -1173,7 +1168,7 @@ bool current_activity_link_view(std::int32_t localSliceSet,
     const Session* coherent = nullptr;
     const Session* coherent = nullptr;
     const Session* privateCurrent = nullptr;
     const Session* privateCurrent = nullptr;
     bool oneDestination = true;
     bool oneDestination = true;
-    AcquireSRWLockShared(&g_lock);
+    const std::shared_lock lock(g_lock);
     for (const Session& session : g_sessions) {
     for (const Session& session : g_sessions) {
         if (session.id == 0 || !session.authenticated
         if (session.id == 0 || !session.authenticated
             || session.activity.role == ActivityClientRole::none
             || session.activity.role == ActivityClientRole::none
@@ -1233,7 +1228,6 @@ bool current_activity_link_view(std::int32_t localSliceSet,
         output.effectiveRegion = selected_region_locked(*selected).index;
         output.effectiveRegion = selected_region_locked(*selected).index;
         output.publicTarget = selected->activity.role == ActivityClientRole::publicTarget;
         output.publicTarget = selected->activity.role == ActivityClientRole::publicTarget;
     }
     }
-    ReleaseSRWLockShared(&g_lock);
     return selected != nullptr;
     return selected != nullptr;
 }
 }
 
 
@@ -1241,7 +1235,7 @@ bool current_activity_link_view(std::int32_t localSliceSet,
 bool activity_replication_view(const state::activity::SessionBinding& binding,
 bool activity_replication_view(const state::activity::SessionBinding& binding,
                                ActivityReplicationView& output) noexcept {
                                ActivityReplicationView& output) noexcept {
     output = {};
     output = {};
-    AcquireSRWLockShared(&g_lock);
+    const std::shared_lock lock(g_lock);
     std::size_t count = 0;
     std::size_t count = 0;
     const Session* const session = unique_activity_link_locked(binding, count);
     const Session* const session = unique_activity_link_locked(binding, count);
     const bool ready =
     const bool ready =
@@ -1255,7 +1249,6 @@ bool activity_replication_view(const state::activity::SessionBinding& binding,
         output.memberId = session->activityMemberKey;
         output.memberId = session->activityMemberKey;
         output.replicationEpoch = session->activity.replicationEpoch;
         output.replicationEpoch = session->activity.replicationEpoch;
     }
     }
-    ReleaseSRWLockShared(&g_lock);
     return ready;
     return ready;
 }
 }
 
 
@@ -1266,7 +1259,7 @@ bool activity_replication_view_for_session(std::uint64_t activitySessionId,
     if (activitySessionId == 0) {
     if (activitySessionId == 0) {
         return false;
         return false;
     }
     }
-    AcquireSRWLockShared(&g_lock);
+    const std::shared_lock lock(g_lock);
     const Session* selected = nullptr;
     const Session* selected = nullptr;
     std::size_t count = 0;
     std::size_t count = 0;
     for (const Session& session : g_sessions) {
     for (const Session& session : g_sessions) {
@@ -1290,7 +1283,6 @@ bool activity_replication_view_for_session(std::uint64_t activitySessionId,
         output.memberId = selected->activityMemberKey;
         output.memberId = selected->activityMemberKey;
         output.replicationEpoch = selected->activity.replicationEpoch;
         output.replicationEpoch = selected->activity.replicationEpoch;
     }
     }
-    ReleaseSRWLockShared(&g_lock);
     return count == 1;
     return count == 1;
 }
 }
 
 
@@ -1301,7 +1293,7 @@ bool activity_replication_view_for_group(std::uint64_t groupSessionId,
     if (groupSessionId == 0) {
     if (groupSessionId == 0) {
         return false;
         return false;
     }
     }
-    AcquireSRWLockShared(&g_lock);
+    const std::shared_lock lock(g_lock);
     const Session* selected = nullptr;
     const Session* selected = nullptr;
     std::size_t count = 0;
     std::size_t count = 0;
     for (const Session& session : g_sessions) {
     for (const Session& session : g_sessions) {
@@ -1323,7 +1315,6 @@ bool activity_replication_view_for_group(std::uint64_t groupSessionId,
         output.memberId = selected->activityMemberKey;
         output.memberId = selected->activityMemberKey;
         output.replicationEpoch = selected->activity.replicationEpoch;
         output.replicationEpoch = selected->activity.replicationEpoch;
     }
     }
-    ReleaseSRWLockShared(&g_lock);
     return count == 1;
     return count == 1;
 }
 }
 
 
@@ -1331,7 +1322,7 @@ bool activity_replication_view_for_group(std::uint64_t groupSessionId,
 bool request_replication_epoch(const state::activity::SessionBinding& binding,
 bool request_replication_epoch(const state::activity::SessionBinding& binding,
                                std::uint64_t expectedGeneration,
                                std::uint64_t expectedGeneration,
                                std::uint8_t generation) noexcept {
                                std::uint8_t generation) noexcept {
-    AcquireSRWLockExclusive(&g_lock);
+    const std::lock_guard lock(g_lock);
     std::size_t count = 0;
     std::size_t count = 0;
     Session* const session = unique_mutable_activity_link_locked(binding, count);
     Session* const session = unique_mutable_activity_link_locked(binding, count);
     bool queued = session != nullptr && expectedGeneration != 0
     bool queued = session != nullptr && expectedGeneration != 0
@@ -1349,7 +1340,6 @@ bool request_replication_epoch(const state::activity::SessionBinding& binding,
             session->activityKeepaliveDueTick = 0;
             session->activityKeepaliveDueTick = 0;
         }
         }
     }
     }
-    ReleaseSRWLockExclusive(&g_lock);
     return queued;
     return queued;
 }
 }
 
 
@@ -1359,7 +1349,7 @@ request_activity_authority_query(const state::activity::SessionBinding& binding,
                                  std::uint64_t expectedGeneration,
                                  std::uint64_t expectedGeneration,
                                  std::int32_t& correlation) noexcept {
                                  std::int32_t& correlation) noexcept {
     correlation = -1;
     correlation = -1;
-    AcquireSRWLockExclusive(&g_lock);
+    const std::lock_guard lock(g_lock);
     std::size_t linkCount = 0;
     std::size_t linkCount = 0;
     Session* const session = unique_mutable_activity_link_locked(binding, linkCount);
     Session* const session = unique_mutable_activity_link_locked(binding, linkCount);
     ActivityAuthorityQueryStatus status = ActivityAuthorityQueryStatus::noActivityLink;
     ActivityAuthorityQueryStatus status = ActivityAuthorityQueryStatus::noActivityLink;
@@ -1369,7 +1359,6 @@ request_activity_authority_query(const state::activity::SessionBinding& binding,
                            session->activityAuthorityQuery, expectedGeneration, correlation)
                            session->activityAuthorityQuery, expectedGeneration, correlation)
                      : ActivityAuthorityQueryStatus::staleActivityClient;
                      : ActivityAuthorityQueryStatus::staleActivityClient;
     }
     }
-    ReleaseSRWLockExclusive(&g_lock);
     return status;
     return status;
 }
 }
 
 
@@ -1379,7 +1368,7 @@ activity_authority_query_snapshot(const state::activity::SessionBinding& binding
                                   std::uint64_t expectedGeneration,
                                   std::uint64_t expectedGeneration,
                                   ActivityAuthorityQuerySnapshot& output) noexcept {
                                   ActivityAuthorityQuerySnapshot& output) noexcept {
     output = {};
     output = {};
-    AcquireSRWLockShared(&g_lock);
+    const std::shared_lock lock(g_lock);
     std::size_t linkCount = 0;
     std::size_t linkCount = 0;
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     ActivityAuthorityQueryStatus status = ActivityAuthorityQueryStatus::noActivityLink;
     ActivityAuthorityQueryStatus status = ActivityAuthorityQueryStatus::noActivityLink;
@@ -1389,7 +1378,6 @@ activity_authority_query_snapshot(const state::activity::SessionBinding& binding
                            session->activityAuthorityQuery, expectedGeneration, output)
                            session->activityAuthorityQuery, expectedGeneration, output)
                      : ActivityAuthorityQueryStatus::staleActivityClient;
                      : ActivityAuthorityQueryStatus::staleActivityClient;
     }
     }
-    ReleaseSRWLockShared(&g_lock);
     return status;
     return status;
 }
 }
 
 
@@ -1399,7 +1387,7 @@ request_activity_authority_reset(const state::activity::SessionBinding& binding,
                                  std::uint64_t expectedGeneration,
                                  std::uint64_t expectedGeneration,
                                  std::int32_t& correlation) noexcept {
                                  std::int32_t& correlation) noexcept {
     correlation = -1;
     correlation = -1;
-    AcquireSRWLockExclusive(&g_lock);
+    const std::lock_guard lock(g_lock);
     std::size_t linkCount = 0;
     std::size_t linkCount = 0;
     Session* const session = unique_mutable_activity_link_locked(binding, linkCount);
     Session* const session = unique_mutable_activity_link_locked(binding, linkCount);
     ActivityAuthorityResetStatus status = ActivityAuthorityResetStatus::noActivityLink;
     ActivityAuthorityResetStatus status = ActivityAuthorityResetStatus::noActivityLink;
@@ -1409,7 +1397,6 @@ request_activity_authority_reset(const state::activity::SessionBinding& binding,
                            session->activityAuthorityReset, expectedGeneration, correlation)
                            session->activityAuthorityReset, expectedGeneration, correlation)
                      : ActivityAuthorityResetStatus::staleActivityClient;
                      : ActivityAuthorityResetStatus::staleActivityClient;
     }
     }
-    ReleaseSRWLockExclusive(&g_lock);
     return status;
     return status;
 }
 }
 
 
@@ -1419,7 +1406,7 @@ activity_authority_reset_snapshot(const state::activity::SessionBinding& binding
                                   std::uint64_t expectedGeneration,
                                   std::uint64_t expectedGeneration,
                                   ActivityAuthorityResetSnapshot& output) noexcept {
                                   ActivityAuthorityResetSnapshot& output) noexcept {
     output = {};
     output = {};
-    AcquireSRWLockShared(&g_lock);
+    const std::shared_lock lock(g_lock);
     std::size_t linkCount = 0;
     std::size_t linkCount = 0;
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     ActivityAuthorityResetStatus status = ActivityAuthorityResetStatus::noActivityLink;
     ActivityAuthorityResetStatus status = ActivityAuthorityResetStatus::noActivityLink;
@@ -1429,7 +1416,6 @@ activity_authority_reset_snapshot(const state::activity::SessionBinding& binding
                            session->activityAuthorityReset, expectedGeneration, output)
                            session->activityAuthorityReset, expectedGeneration, output)
                      : ActivityAuthorityResetStatus::staleActivityClient;
                      : ActivityAuthorityResetStatus::staleActivityClient;
     }
     }
-    ReleaseSRWLockShared(&g_lock);
     return status;
     return status;
 }
 }
 
 
@@ -1443,7 +1429,7 @@ bool request_activity_type23_override(
     std::int32_t expectedRegion,
     std::int32_t expectedRegion,
     std::uint64_t expectedGeneration,
     std::uint64_t expectedGeneration,
     const activity::host::ScriptableOutputReservation* reservation) noexcept {
     const activity::host::ScriptableOutputReservation* reservation) noexcept {
-    AcquireSRWLockExclusive(&g_lock);
+    const std::lock_guard lock(g_lock);
     std::size_t linkCount = 0;
     std::size_t linkCount = 0;
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     const bool queued =
     const bool queued =
@@ -1451,7 +1437,6 @@ bool request_activity_type23_override(
         && canonical_type23_available_locked(*session, target, expectedRegion, expectedGeneration)
         && canonical_type23_available_locked(*session, target, expectedRegion, expectedGeneration)
         && activity::host::request_type23_override(
         && activity::host::request_type23_override(
             binding, target, channel, value, snap, expectedGeneration, reservation);
             binding, target, channel, value, snap, expectedGeneration, reservation);
-    ReleaseSRWLockExclusive(&g_lock);
     return queued;
     return queued;
 }
 }
 
 
@@ -1462,14 +1447,13 @@ bool request_activity_lifetime_override(
     std::int32_t expectedRegion,
     std::int32_t expectedRegion,
     std::uint64_t expectedGeneration,
     std::uint64_t expectedGeneration,
     const activity::host::ScriptableOutputReservation* reservation) noexcept {
     const activity::host::ScriptableOutputReservation* reservation) noexcept {
-    AcquireSRWLockExclusive(&g_lock);
+    const std::lock_guard lock(g_lock);
     std::size_t linkCount = 0;
     std::size_t linkCount = 0;
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     const bool queued = session != nullptr
     const bool queued = session != nullptr
                         && lifetime_available_locked(*session, expectedRegion, expectedGeneration)
                         && lifetime_available_locked(*session, expectedRegion, expectedGeneration)
                         && activity::host::request_lifetime_override(
                         && activity::host::request_lifetime_override(
                             binding, lifetimeState, expectedGeneration, reservation);
                             binding, lifetimeState, expectedGeneration, reservation);
-    ReleaseSRWLockExclusive(&g_lock);
     return queued;
     return queued;
 }
 }
 
 
@@ -1486,7 +1470,7 @@ bool request_activity_state_local_type23_override(
     std::uint32_t scenarioRow,
     std::uint32_t scenarioRow,
     std::uint32_t stateRow,
     std::uint32_t stateRow,
     const activity::host::ScriptableOutputReservation* reservation) noexcept {
     const activity::host::ScriptableOutputReservation* reservation) noexcept {
-    AcquireSRWLockExclusive(&g_lock);
+    const std::lock_guard lock(g_lock);
     std::size_t linkCount = 0;
     std::size_t linkCount = 0;
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     const bool queued =
     const bool queued =
@@ -1506,7 +1490,6 @@ bool request_activity_state_local_type23_override(
                                                                snap,
                                                                snap,
                                                                expectedGeneration,
                                                                expectedGeneration,
                                                                reservation);
                                                                reservation);
-    ReleaseSRWLockExclusive(&g_lock);
     return queued;
     return queued;
 }
 }
 
 
@@ -1522,7 +1505,7 @@ bool request_activity_sdk_auth_override(
     std::uint32_t scenarioRow,
     std::uint32_t scenarioRow,
     std::uint32_t stateRow,
     std::uint32_t stateRow,
     const activity::host::ScriptableOutputReservation* reservation) noexcept {
     const activity::host::ScriptableOutputReservation* reservation) noexcept {
-    AcquireSRWLockExclusive(&g_lock);
+    const std::lock_guard lock(g_lock);
     std::size_t linkCount = 0;
     std::size_t linkCount = 0;
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     const bool available =
     const bool available =
@@ -1547,7 +1530,6 @@ bool request_activity_sdk_auth_override(
                                                                      bitCount,
                                                                      bitCount,
                                                                      expectedGeneration,
                                                                      expectedGeneration,
                                                                      reservation);
                                                                      reservation);
-    ReleaseSRWLockExclusive(&g_lock);
     return queued;
     return queued;
 }
 }
 
 
@@ -1557,13 +1539,12 @@ bool request_activity_type31_override(
     const activity::host::ScriptableTarget& target,
     const activity::host::ScriptableTarget& target,
     std::int32_t expectedRegion,
     std::int32_t expectedRegion,
     const activity::host::ScriptableOutputReservation* reservation) noexcept {
     const activity::host::ScriptableOutputReservation* reservation) noexcept {
-    AcquireSRWLockExclusive(&g_lock);
+    const std::lock_guard lock(g_lock);
     std::size_t linkCount = 0;
     std::size_t linkCount = 0;
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     const bool queued = expectedRegion >= 0 && session != nullptr
     const bool queued = expectedRegion >= 0 && session != nullptr
                         && selected_region_locked(*session).index == expectedRegion
                         && selected_region_locked(*session).index == expectedRegion
                         && activity::host::request_type31_override(binding, target, reservation);
                         && activity::host::request_type31_override(binding, target, reservation);
-    ReleaseSRWLockExclusive(&g_lock);
     return queued;
     return queued;
 }
 }
 
 
@@ -1577,7 +1558,7 @@ bool request_activity_state_local_type31_override(
     std::uint32_t,
     std::uint32_t,
     std::uint32_t,
     std::uint32_t,
     const activity::host::ScriptableOutputReservation* reservation) noexcept {
     const activity::host::ScriptableOutputReservation* reservation) noexcept {
-    AcquireSRWLockExclusive(&g_lock);
+    const std::lock_guard lock(g_lock);
     std::size_t linkCount = 0;
     std::size_t linkCount = 0;
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     const encrypted::push::activity::EffectiveRegion region =
     const encrypted::push::activity::EffectiveRegion region =
@@ -1591,7 +1572,6 @@ bool request_activity_state_local_type31_override(
         && valid_state_local_type31_target(target, stateLocalRosterGroup)
         && valid_state_local_type31_target(target, stateLocalRosterGroup)
         && activity::host::request_state_local_type31_override(
         && activity::host::request_state_local_type31_override(
             binding, target, stateLocalRosterGroup, expectedGeneration, reservation);
             binding, target, stateLocalRosterGroup, expectedGeneration, reservation);
-    ReleaseSRWLockExclusive(&g_lock);
     return queued;
     return queued;
 }
 }
 
 
@@ -1605,7 +1585,7 @@ bool request_activity_state_local_sequence_override(
     std::uint32_t,
     std::uint32_t,
     std::uint32_t,
     std::uint32_t,
     const activity::host::ScriptableOutputReservation* reservation) noexcept {
     const activity::host::ScriptableOutputReservation* reservation) noexcept {
-    AcquireSRWLockExclusive(&g_lock);
+    const std::lock_guard lock(g_lock);
     std::size_t linkCount = 0;
     std::size_t linkCount = 0;
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     const encrypted::push::activity::EffectiveRegion region =
     const encrypted::push::activity::EffectiveRegion region =
@@ -1621,7 +1601,6 @@ bool request_activity_state_local_sequence_override(
         && target.authSchema == middleware::bap::activity_message::scriptable_auth::kType5Schema
         && target.authSchema == middleware::bap::activity_message::scriptable_auth::kType5Schema
         && activity::host::request_state_local_sequence_override(
         && activity::host::request_state_local_sequence_override(
             binding, target, stateLocalRosterGroup, expectedGeneration, reservation);
             binding, target, stateLocalRosterGroup, expectedGeneration, reservation);
-    ReleaseSRWLockExclusive(&g_lock);
     return queued;
     return queued;
 }
 }
 
 
@@ -1636,7 +1615,7 @@ bool request_activity_state_local_cinematic_override(
     std::uint32_t,
     std::uint32_t,
     std::uint32_t,
     std::uint32_t,
     const activity::host::ScriptableOutputReservation* reservation) noexcept {
     const activity::host::ScriptableOutputReservation* reservation) noexcept {
-    AcquireSRWLockExclusive(&g_lock);
+    const std::lock_guard lock(g_lock);
     std::size_t linkCount = 0;
     std::size_t linkCount = 0;
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     const encrypted::push::activity::EffectiveRegion region =
     const encrypted::push::activity::EffectiveRegion region =
@@ -1652,7 +1631,6 @@ bool request_activity_state_local_cinematic_override(
         && target.authSchema == middleware::bap::activity_message::scriptable_auth::kType6Schema
         && target.authSchema == middleware::bap::activity_message::scriptable_auth::kType6Schema
         && activity::host::request_state_local_cinematic_override(
         && activity::host::request_state_local_cinematic_override(
             binding, target, stateLocalRosterGroup, active, expectedGeneration, reservation);
             binding, target, stateLocalRosterGroup, active, expectedGeneration, reservation);
-    ReleaseSRWLockExclusive(&g_lock);
     return queued;
     return queued;
 }
 }
 
 
@@ -1667,7 +1645,7 @@ bool request_activity_state_local_performance_override(
     std::uint32_t,
     std::uint32_t,
     std::uint32_t,
     std::uint32_t,
     const activity::host::ScriptableOutputReservation* reservation) noexcept {
     const activity::host::ScriptableOutputReservation* reservation) noexcept {
-    AcquireSRWLockExclusive(&g_lock);
+    const std::lock_guard lock(g_lock);
     std::size_t linkCount = 0;
     std::size_t linkCount = 0;
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     const encrypted::push::activity::EffectiveRegion region =
     const encrypted::push::activity::EffectiveRegion region =
@@ -1683,7 +1661,6 @@ bool request_activity_state_local_performance_override(
         && target.authSchema == middleware::bap::activity_message::scriptable_auth::kType42Schema
         && target.authSchema == middleware::bap::activity_message::scriptable_auth::kType42Schema
         && activity::host::request_state_local_performance_override(
         && activity::host::request_state_local_performance_override(
             binding, target, stateLocalRosterGroup, stateNameHash, expectedGeneration, reservation);
             binding, target, stateLocalRosterGroup, stateNameHash, expectedGeneration, reservation);
-    ReleaseSRWLockExclusive(&g_lock);
     return queued;
     return queued;
 }
 }
 
 
@@ -1697,7 +1674,7 @@ bool request_activity_state_local_authored_scene_override(
     std::uint32_t,
     std::uint32_t,
     std::uint32_t,
     std::uint32_t,
     const activity::host::ScriptableOutputReservation* reservation) noexcept {
     const activity::host::ScriptableOutputReservation* reservation) noexcept {
-    AcquireSRWLockExclusive(&g_lock);
+    const std::lock_guard lock(g_lock);
     std::size_t linkCount = 0;
     std::size_t linkCount = 0;
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     const encrypted::push::activity::EffectiveRegion region =
     const encrypted::push::activity::EffectiveRegion region =
@@ -1711,7 +1688,6 @@ bool request_activity_state_local_authored_scene_override(
         && valid_state_local_authored_scene_target(target, stateLocalRosterGroup)
         && valid_state_local_authored_scene_target(target, stateLocalRosterGroup)
         && activity::host::request_state_local_authored_scene_override(
         && activity::host::request_state_local_authored_scene_override(
             binding, target, stateLocalRosterGroup, expectedGeneration, reservation);
             binding, target, stateLocalRosterGroup, expectedGeneration, reservation);
-    ReleaseSRWLockExclusive(&g_lock);
     return queued;
     return queued;
 }
 }
 
 
@@ -1727,7 +1703,7 @@ bool request_activity_state_local_dialogue_override(
     std::uint32_t,
     std::uint32_t,
     std::uint32_t,
     std::uint32_t,
     const activity::host::ScriptableOutputReservation* reservation) noexcept {
     const activity::host::ScriptableOutputReservation* reservation) noexcept {
-    AcquireSRWLockExclusive(&g_lock);
+    const std::lock_guard lock(g_lock);
     std::size_t linkCount = 0;
     std::size_t linkCount = 0;
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     const encrypted::push::activity::EffectiveRegion region =
     const encrypted::push::activity::EffectiveRegion region =
@@ -1748,7 +1724,6 @@ bool request_activity_state_local_dialogue_override(
                                                                  authoredCueCount,
                                                                  authoredCueCount,
                                                                  expectedGeneration,
                                                                  expectedGeneration,
                                                                  reservation);
                                                                  reservation);
-    ReleaseSRWLockExclusive(&g_lock);
     return queued;
     return queued;
 }
 }
 
 
@@ -1762,7 +1737,7 @@ bool request_activity_state_local_objective_reset(
     std::uint32_t,
     std::uint32_t,
     std::uint32_t,
     std::uint32_t,
     const activity::host::ScriptableOutputReservation* reservation) noexcept {
     const activity::host::ScriptableOutputReservation* reservation) noexcept {
-    AcquireSRWLockExclusive(&g_lock);
+    const std::lock_guard lock(g_lock);
     std::size_t linkCount = 0;
     std::size_t linkCount = 0;
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     const encrypted::push::activity::EffectiveRegion region =
     const encrypted::push::activity::EffectiveRegion region =
@@ -1778,7 +1753,6 @@ bool request_activity_state_local_objective_reset(
         && target.authSchema == middleware::bap::activity_message::scriptable_auth::kType3Schema
         && target.authSchema == middleware::bap::activity_message::scriptable_auth::kType3Schema
         && activity::host::request_state_local_objective_reset(
         && activity::host::request_state_local_objective_reset(
             binding, target, stateLocalRosterGroup, expectedGeneration, reservation);
             binding, target, stateLocalRosterGroup, expectedGeneration, reservation);
-    ReleaseSRWLockExclusive(&g_lock);
     return queued;
     return queued;
 }
 }
 
 
@@ -1792,7 +1766,7 @@ bool request_activity_state_local_task_override(
     std::uint32_t,
     std::uint32_t,
     std::uint32_t,
     std::uint32_t,
     const activity::host::ScriptableOutputReservation* reservation) noexcept {
     const activity::host::ScriptableOutputReservation* reservation) noexcept {
-    AcquireSRWLockExclusive(&g_lock);
+    const std::lock_guard lock(g_lock);
     std::size_t linkCount = 0;
     std::size_t linkCount = 0;
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     const encrypted::push::activity::EffectiveRegion region =
     const encrypted::push::activity::EffectiveRegion region =
@@ -1808,7 +1782,6 @@ bool request_activity_state_local_task_override(
         && target.authSchema == middleware::bap::activity_message::scriptable_auth::kType38Schema
         && target.authSchema == middleware::bap::activity_message::scriptable_auth::kType38Schema
         && activity::host::request_state_local_task_override(
         && activity::host::request_state_local_task_override(
             binding, target, stateLocalRosterGroup, expectedGeneration, reservation);
             binding, target, stateLocalRosterGroup, expectedGeneration, reservation);
-    ReleaseSRWLockExclusive(&g_lock);
     return queued;
     return queued;
 }
 }
 
 
@@ -1824,7 +1797,7 @@ bool request_activity_squad_override(
     std::uint64_t expectedGeneration,
     std::uint64_t expectedGeneration,
     const activity::host::ScriptableOutputReservation* reservation,
     const activity::host::ScriptableOutputReservation* reservation,
     std::array<std::int8_t, 4> authoredProfile) noexcept {
     std::array<std::int8_t, 4> authoredProfile) noexcept {
-    AcquireSRWLockExclusive(&g_lock);
+    const std::lock_guard lock(g_lock);
     std::size_t linkCount = 0;
     std::size_t linkCount = 0;
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     const Session* const session = unique_activity_link_locked(binding, linkCount);
     const encrypted::push::activity::EffectiveRegion region =
     const encrypted::push::activity::EffectiveRegion region =
@@ -1844,25 +1817,22 @@ bool request_activity_squad_override(
                                                                   nameHash,
                                                                   nameHash,
                                                                   reservation,
                                                                   reservation,
                                                                   authoredProfile);
                                                                   authoredProfile);
-    ReleaseSRWLockExclusive(&g_lock);
     return queued;
     return queued;
 }
 }
 
 
 /** Cancels one exact typed override revision while excluding activity-link publication. */
 /** Cancels one exact typed override revision while excluding activity-link publication. */
 bool cancel_activity_scriptable_override(const state::activity::SessionBinding& binding,
 bool cancel_activity_scriptable_override(const state::activity::SessionBinding& binding,
                                          std::uint64_t expectedRevision) noexcept {
                                          std::uint64_t expectedRevision) noexcept {
-    AcquireSRWLockExclusive(&g_lock);
+    const std::lock_guard lock(g_lock);
     const bool canceled =
     const bool canceled =
         activity::host::cancel_pending_scriptable_override(binding, expectedRevision);
         activity::host::cancel_pending_scriptable_override(binding, expectedRevision);
-    ReleaseSRWLockExclusive(&g_lock);
     return canceled;
     return canceled;
 }
 }
 
 
 /** Cancels a pending raw incident while excluding activity-link publication. */
 /** Cancels a pending raw incident while excluding activity-link publication. */
 bool cancel_activity_host_incident(const state::activity::SessionBinding& binding) noexcept {
 bool cancel_activity_host_incident(const state::activity::SessionBinding& binding) noexcept {
-    AcquireSRWLockExclusive(&g_lock);
+    const std::lock_guard lock(g_lock);
     const bool canceled = server::activity::host::cancel_pending_incident(binding);
     const bool canceled = server::activity::host::cancel_pending_incident(binding);
-    ReleaseSRWLockExclusive(&g_lock);
     return canceled;
     return canceled;
 }
 }
 
 
@@ -1871,21 +1841,20 @@ bool cancel_activity_host_incident(const state::activity::SessionBinding& bindin
 bool session_channel(std::uint32_t connectionId,
 bool session_channel(std::uint32_t connectionId,
                      std::array<std::byte, state::kBapNonceSize>& sendNonce,
                      std::array<std::byte, state::kBapNonceSize>& sendNonce,
                      std::array<std::byte, state::kAesKeySize>& sessionKey) noexcept {
                      std::array<std::byte, state::kAesKeySize>& sessionKey) noexcept {
-    AcquireSRWLockShared(&g_lock);
+    const std::shared_lock lock(g_lock);
     const Session* const session = session_for(connectionId);
     const Session* const session = session_for(connectionId);
     const bool armed = session != nullptr && session->authenticated;
     const bool armed = session != nullptr && session->authenticated;
     if (armed) {
     if (armed) {
         sendNonce = session->sendNonce;
         sendNonce = session->sendNonce;
         sessionKey = session->sessionKey;
         sessionKey = session->sessionKey;
     }
     }
-    ReleaseSRWLockShared(&g_lock);
     return armed;
     return armed;
 }
 }
 #endif
 #endif
 
 
 /** Securely erases every connection-owned nonce and transform buffer. */
 /** Securely erases every connection-owned nonce and transform buffer. */
 void shutdown() noexcept {
 void shutdown() noexcept {
-    AcquireSRWLockExclusive(&g_lock);
+    const std::lock_guard lock(g_lock);
     while (g_worldRewardCount != 0) {
     while (g_worldRewardCount != 0) {
         if (!commit_world_reward(g_worldRewards[g_worldRewardHead])) {
         if (!commit_world_reward(g_worldRewards[g_worldRewardHead])) {
             core::log::write(core::log::Channel::server,
             core::log::write(core::log::Channel::server,
@@ -1909,7 +1878,6 @@ void shutdown() noexcept {
     g_worldRewardHead = 0;
     g_worldRewardHead = 0;
     g_worldRewardCount = 0;
     g_worldRewardCount = 0;
     SecureZeroMemory(&g_scratch, sizeof g_scratch);
     SecureZeroMemory(&g_scratch, sizeof g_scratch);
-    ReleaseSRWLockExclusive(&g_lock);
 }
 }
 
 
 } // namespace sunrise::server::bap
 } // namespace sunrise::server::bap

+ 117 - 5
Sunrise/src/server/bap/encrypted/activity_message/activity_message_route.cpp

@@ -30,6 +30,8 @@
 #include "../../../../middleware/crypto/hmac.h"
 #include "../../../../middleware/crypto/hmac.h"
 #include "../../../../middleware/crypto/random_bytes.h"
 #include "../../../../middleware/crypto/random_bytes.h"
 #include "../../../../middleware/encoding/byte_order.h"
 #include "../../../../middleware/encoding/byte_order.h"
+#include "../../../../state/activity/bubble_authority/runtime.h"
+#include "../../../../state/activity/membership/activity_membership_query.h"
 #include "../../../../state/activity/receipts/activity_receipts.h"
 #include "../../../../state/activity/receipts/activity_receipts.h"
 #include "../../../../state/activity/runtime.h"
 #include "../../../../state/activity/runtime.h"
 #include "../../../../state/activity_sdk/runtime.h"
 #include "../../../../state/activity_sdk/runtime.h"
@@ -46,6 +48,7 @@ namespace sunrise::server::bap::encrypted::activity_message {
 namespace {
 namespace {
 
 
 namespace service = middleware::bap::activity_message;
 namespace service = middleware::bap::activity_message;
+namespace authority = middleware::bap::activity_message::entity_authority;
 namespace store = state::activity::receipts;
 namespace store = state::activity::receipts;
 namespace wire_schema = middleware::bap::activity_message::wire_schema;
 namespace wire_schema = middleware::bap::activity_message::wire_schema;
 namespace communication = wire_schema::communication;
 namespace communication = wire_schema::communication;
@@ -439,6 +442,12 @@ void report_message(std::uint32_t messageType,
 
 
 /**
 /**
  * Prepares only currently free slots for one positive client request.
  * Prepares only currently free slots for one positive client request.
+ * The ask is a floor, not the amount. The client requests the slots one slice set needs only once
+ * it has begun creating that slice set's entities, so a grant sized to the ask arrives after the
+ * creates it was meant to cover have already failed. Topping the lease up to a standing high
+ * water instead leaves the slots held before the next switch starts. `prepare_grant` picks from
+ * the free complement and documents that an ask above the slot count degrades to every remaining
+ * free slot, so an over-large top-up cannot fail a request that would otherwise have succeeded.
  * @param request Validated owned svc8 envelope.
  * @param request Validated owned svc8 envelope.
  * @param plan Cleared, then receives the chosen lease mask.
  * @param plan Cleared, then receives the chosen lease mask.
  * @return True for a valid positive request, including an exhausted zero-mask grant.
  * @return True for a valid positive request, including an exhausted zero-mask grant.
@@ -446,11 +455,40 @@ void report_message(std::uint32_t messageType,
 [[nodiscard]] bool prepare_grant(const service::Request& request, ActivityPlan& plan) noexcept {
 [[nodiscard]] bool prepare_grant(const service::Request& request, ActivityPlan& plan) noexcept {
     std::int32_t requested = 0;
     std::int32_t requested = 0;
     if (!service::entity_slot_request::parse_entity_slot_request(request.payload, requested)
     if (!service::entity_slot_request::parse_entity_slot_request(request.payload, requested)
-        || requested <= 0
-        || !state::activity::entity_slots::prepare_grant(
-            request.sessionId, static_cast<std::size_t>(requested), plan.entitySlotMutation)) {
+        || requested <= 0) {
         return false;
         return false;
     }
     }
+    std::size_t wanted = static_cast<std::size_t>(requested);
+    const std::size_t highWater = core::settings::server::gameplay::lease_high_water(
+        core::settings::get().server.gameplay);
+    std::size_t held = 0;
+    std::size_t reserved = 0;
+    // A session with no readable lease keeps the client's own ask, which is today's behaviour.
+    if (state::activity::entity_slots::lease_counts(request.sessionId, held, reserved)
+        && held < highWater) {
+        wanted = (std::max)(wanted, highWater - held);
+    }
+    if (!state::activity::entity_slots::prepare_grant(
+            request.sessionId, wanted, plan.entitySlotMutation)) {
+        return false;
+    }
+    // The lease line downstream reports the topped-up count, so without this the size of the
+    // client's own ask — the thing that says which slice set it is about to build — is lost.
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=activity stage=lease_topup soid=0x%llX asked=%d "
+                                      "wanted=%zu held=%zu high_water=%zu",
+                                      static_cast<unsigned long long>(request.sessionId),
+                                      requested,
+                                      wanted,
+                                      held,
+                                      highWater);
+    if (written > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
     plan.sessionId = request.sessionId;
     plan.sessionId = request.sessionId;
     plan.entitySlotsRequested.requestedCount = requested;
     plan.entitySlotsRequested.requestedCount = requested;
     plan.delivery = Delivery::entitySlotNotification;
     plan.delivery = Delivery::entitySlotNotification;
@@ -469,6 +507,15 @@ void report_message(std::uint32_t messageType,
     if (!service::entity_slots::decode_entity_slots(request.payload, decoded)) {
     if (!service::entity_slots::decode_entity_slots(request.payload, decoded)) {
         return false;
         return false;
     }
     }
+    if (core::settings::get().server.gameplay.ignoreClientSlotRelease) {
+        // Framed and receipted as before; only the lease is left standing. The mask's meaning is
+        // unproven, and reading it the wrong way round shrinks the lease the client's own entity
+        // bitmap mirrors, which starves entity creation.
+        plan.sessionId = request.sessionId;
+        plan.delivery = Delivery::none;
+        plan.mutationDomain = MutationDomain::none;
+        return true;
+    }
     state::activity::entity_slots::LeaseMask returned{};
     state::activity::entity_slots::LeaseMask returned{};
     std::copy(decoded.begin(), decoded.end(), returned.begin());
     std::copy(decoded.begin(), decoded.end(), returned.begin());
     if (!state::activity::entity_slots::prepare_release(
     if (!state::activity::entity_slots::prepare_release(
@@ -487,17 +534,81 @@ struct FramingRoute {
     receipts::Framed (*frame)(const service::Request&) noexcept;
     receipts::Framed (*frame)(const service::Request&) noexcept;
 };
 };
 
 
+/**
+ * Drops the recorded grant for the bubble one release names, but only once the client has left it.
+ * The receipts module reports without touching State by design, so the State change a hand-back
+ * implies is made here. Without it the bubble stays recorded as granted for the rest of the
+ * session and re-entering it — which is what every wipe, retry and backtrack does — runs with no
+ * authority, because `select_grant` only ever grants a bubble whose token is zero.
+ *
+ * The occupancy test is what makes this safe on the common path. Msg 26 is documented as the
+ * bubble exit, but msg 33 gives up *a set of slots* and the client sends it without leaving.
+ * Clearing the token while the player is still inside would let the next roster push — one every
+ * second during the load burst — re-grant the occupied bubble under a new token, on every
+ * destination rather than only this raid. Comparing the selector against the region the client
+ * last reported keeps the release to a real exit; a session that has reported no region yet
+ * cannot be judged, so it is left alone.
+ *
+ * The test is deliberately fail-safe rather than exact. `reported_region` lags a boundary
+ * crossing, so a release sent the instant the client leaves can still name the region it is
+ * leaving and be skipped. That loses a re-arm, which is the behaviour before this change; it
+ * never clears a bubble the player occupies, which would be worse than that behaviour.
+ * The selector is the raw bubble index, not a biased field: the captured releases carry 1, 14
+ * and 12, matching dream_shore, raid_larceny_staging and raid_larceny_alarm — the three bubbles
+ * that run actually visited.
+ * @param request Validated owned activity envelope carrying the release.
+ * @param expectReason True for abandon, which trails a reason after the mask.
+ */
+void release_named_bubble(const service::Request& request, bool expectReason) noexcept {
+    authority::Release decoded{};
+    const bool parsed = expectReason ? authority::parse_abandon(request.payload, decoded)
+                                     : authority::parse_abdicate(request.payload, decoded);
+    if (!parsed || decoded.selector >= state::activity::bubble_authority::kFallbackBubble) {
+        return;
+    }
+    const std::int32_t region =
+        state::activity::membership::reported_region(request.sessionId);
+    if (region < 0) {
+        return;
+    }
+    const auto occupied = static_cast<std::uint8_t>(
+        region >> state::activity::bubble_authority::kSliceSetToBubbleShift);
+    if (decoded.selector == occupied) {
+        return;
+    }
+    state::activity::bubble_authority::release_grant(request.sessionId, decoded.selector);
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=activity stage=authority result=released type=%u "
+                                      "selector=%u occupied=%u",
+                                      request.messageType,
+                                      static_cast<unsigned>(decoded.selector),
+                                      static_cast<unsigned>(occupied));
+    if (written > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
 /** Frames one abandon, which trails a reason after the mask. */
 /** Frames one abandon, which trails a reason after the mask. */
 [[nodiscard]] receipts::Framed frame_abandon(const service::Request& request) noexcept {
 [[nodiscard]] receipts::Framed frame_abandon(const service::Request& request) noexcept {
+    release_named_bubble(request, true);
     return receipts::frame_authority_release(request, true);
     return receipts::frame_authority_release(request, true);
 }
 }
 
 
 /** Frames one abdicate, which carries no reason. */
 /** Frames one abdicate, which carries no reason. */
 [[nodiscard]] receipts::Framed frame_abdicate(const service::Request& request) noexcept {
 [[nodiscard]] receipts::Framed frame_abdicate(const service::Request& request) noexcept {
+    release_named_bubble(request, false);
     return receipts::frame_authority_release(request, false);
     return receipts::frame_authority_release(request, false);
 }
 }
 
 
-/** Every adapter this route frames and records without changing State. */
+/**
+ * Every adapter this route frames and records.
+ * All of these are read-only except the two authority releases, which drop the grant token for a
+ * bubble the client has left so it can be granted again on re-entry.
+ */
 constexpr std::array<FramingRoute, 19> kFramingRoutes{{
 constexpr std::array<FramingRoute, 19> kFramingRoutes{{
     {IngressAdapter::routeMisuseReceipt, receipts::frame_route_misuse},
     {IngressAdapter::routeMisuseReceipt, receipts::frame_route_misuse},
     {IngressAdapter::reservationRequest, receipts::frame_reservation_request},
     {IngressAdapter::reservationRequest, receipts::frame_reservation_request},
@@ -521,7 +632,8 @@ constexpr std::array<FramingRoute, 19> kFramingRoutes{{
 }};
 }};
 
 
 /**
 /**
- * Frames one message that changes no State and records its receipt.
+ * Frames one message and records its receipt.
+ * Read-only except for the two authority releases, which clear a departed bubble's grant token.
  * @param request Validated envelope.
  * @param request Validated envelope.
  * @return Always true: a framing-only message can never fail the transport frame.
  * @return Always true: a framing-only message can never fail the transport frame.
  */
  */

+ 15 - 1
Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_message_receipts.cpp

@@ -1,4 +1,4 @@
-/**
+/**
  * Framing handlers for every activity message that changes no State.
  * Framing handlers for every activity message that changes no State.
  * Each one reads as much of its body as the known grammar reaches, and reports what it saw.
  * Each one reads as much of its body as the known grammar reaches, and reports what it saw.
  * It returns how completely the body was read, so the caller can record one arrival receipt.
  * It returns how completely the body was read, so the caller can record one arrival receipt.
@@ -8,6 +8,7 @@
 #include "activity_message_receipts.h"
 #include "activity_message_receipts.h"
 
 
 #include <array>
 #include <array>
+#include <bit>
 #include <cstdarg>
 #include <cstdarg>
 #include <cstddef>
 #include <cstddef>
 #include <cstdint>
 #include <cstdint>
@@ -1002,6 +1003,19 @@ Framed frame_authority_release(const message::Request& request, bool expectReaso
     if (!parsed) {
     if (!parsed) {
         return {report_malformed("authority", request), 0};
         return {report_malformed("authority", request), 0};
     }
     }
+    // The mask says how much lease the client believes it is handing back with the bubble. A
+    // release Sunrise records as authority-only while the client counts it as slots returned is
+    // how the two ledgers drift apart, and nothing else on this path reports the size.
+    std::size_t returning = 0;
+    for (const std::byte byte : decoded.mask) {
+        returning += static_cast<std::size_t>(std::popcount(std::to_integer<unsigned char>(byte)));
+    }
+    report(core::log::Level::debug,
+           "ev=activity stage=authority result=noted type=%u selector=%u reason=%d slots=%zu",
+           request.messageType,
+           static_cast<unsigned>(decoded.selector),
+           decoded.hasReason ? decoded.reason : 0,
+           returning);
     return {Verdict::framed, payload_bits(request)};
     return {Verdict::framed, payload_bits(request)};
 }
 }
 
 

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

@@ -17,6 +17,14 @@ namespace {
 constexpr std::uint64_t kFamily4RepushDelayMs = 400;
 constexpr std::uint64_t kFamily4RepushDelayMs = 400;
 /** The banner pair lands the same unsolicited way and hits the same record-state race. */
 /** The banner pair lands the same unsolicited way and hits the same record-state race. */
 constexpr std::uint64_t kBannerRepushDelayMs = 400;
 constexpr std::uint64_t kBannerRepushDelayMs = 400;
+/**
+ * Delay before the family-two re-push owed by an emblem equip.
+ *
+ * Matched to the two above, which are the measured-working value for the same record-state race:
+ * a snapshot answered too soon reaches the record before it writes its new state and is refused
+ * silently. If a re-push ever fails to land, this constant is the one guess in the mechanism.
+ */
+constexpr std::uint64_t kSocialRosterRepushDelayMs = 400;
 /**
 /**
  * Delay before the ability-icon re-derivation owed by a subclass selection.
  * Delay before the ability-icon re-derivation owed by a subclass selection.
  * The Client content-extraction pump that rebuilds the invalidated ability buckets runs on the
  * The Client content-extraction pump that rebuilds the invalidated ability buckets runs on the
@@ -261,6 +269,19 @@ void arm_repushes(Session& session, const queuez::StagedPublication& queuezPubli
         session.bannerRepushRoot = queuezPublication.bannerRepushRoot;
         session.bannerRepushRoot = queuezPublication.bannerRepushRoot;
         session.bannerRepushArmed = true;
         session.bannerRepushArmed = true;
     }
     }
+    // Recorded whenever a family-two subscribe was answered, so a later equip has a root to
+    // publish against. A peer that never subscribed to family two keeps root zero and is left
+    // alone below, which is the correct no-op for it.
+    if (queuezPublication.socialRosterRepushRoot != 0) {
+        session.socialRosterRepushRoot = queuezPublication.socialRosterRepushRoot;
+    }
+    // Its own arm on its own signal, for the reason recorded above: the banner arm was once
+    // driven from the wrong family's and took the connection down. Re-arming is idempotent and
+    // coalescing, so a burst of equips owes one delayed send rather than one each.
+    if (queuezPublication.rearmsSocialRosterRepush && session.socialRosterRepushRoot != 0) {
+        session.socialRosterRepushDueTick = now + kSocialRosterRepushDelayMs;
+        session.socialRosterRepushArmed = true;
+    }
 }
 }
 
 
 } // namespace sunrise::server::bap::encrypted
 } // namespace sunrise::server::bap::encrypted

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

@@ -307,6 +307,15 @@ bool process(const ServiceRoute& route,
             transaction->pending =
             transaction->pending =
                 web_service::take_mutation<state::PendingArtifactPurchase>(webOutcome);
                 web_service::take_mutation<state::PendingArtifactPurchase>(webOutcome);
         }
         }
+        const auto* settingsUpdate =
+            web_service::mutation_if<state::PendingSettingsUpdate>(webOutcome);
+        if (settingsUpdate != nullptr) {
+            // WS-701 promises no immediate QueueZ after-image, so State alone is delayed.
+            if (emplace_transaction<state::PendingSettingsUpdate>(outcome, *settingsUpdate)
+                == nullptr) {
+                return refuse_web_action(message, output, written);
+            }
+        }
         if (equipmentSwap != nullptr) {
         if (equipmentSwap != nullptr) {
             // Promise the Family-4 revision carrying this optimistic equip.
             // Promise the Family-4 revision carrying this optimistic equip.
             auto* transaction = emplace_transaction<EquipmentSwapTransaction>(outcome);
             auto* transaction = emplace_transaction<EquipmentSwapTransaction>(outcome);

+ 2 - 1
Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp

@@ -405,7 +405,8 @@ bool consume(Session& session,
         || transaction_if<ProfileItemAcquisitionTransaction>(outcome) != nullptr
         || transaction_if<ProfileItemAcquisitionTransaction>(outcome) != nullptr
         || transaction_if<ItemDismantleTransaction>(outcome) != nullptr
         || transaction_if<ItemDismantleTransaction>(outcome) != nullptr
         || transaction_if<RecordRewardGrantTransaction>(outcome) != nullptr
         || transaction_if<RecordRewardGrantTransaction>(outcome) != nullptr
-        || transaction_if<SeasonPassRewardTransaction>(outcome) != nullptr;
+        || transaction_if<SeasonPassRewardTransaction>(outcome) != nullptr
+        || transaction_if<state::PendingSettingsUpdate>(outcome) != nullptr;
     const bool presentsAcquisition =
     const bool presentsAcquisition =
         transaction_if<ItemAcquisitionTransaction>(outcome) != nullptr
         transaction_if<ItemAcquisitionTransaction>(outcome) != nullptr
         || transaction_if<ProfileItemAcquisitionTransaction>(outcome) != nullptr
         || transaction_if<ProfileItemAcquisitionTransaction>(outcome) != nullptr

+ 14 - 1
Sunrise/src/server/bap/encrypted/internal.h

@@ -138,7 +138,8 @@ struct ServiceOutcome {
                                      std::unique_ptr<ProfileItemAcquisitionTransaction>,
                                      std::unique_ptr<ProfileItemAcquisitionTransaction>,
                                      std::unique_ptr<ItemDismantleTransaction>,
                                      std::unique_ptr<ItemDismantleTransaction>,
                                      std::unique_ptr<RecordRewardGrantTransaction>,
                                      std::unique_ptr<RecordRewardGrantTransaction>,
-                                     std::unique_ptr<SeasonPassRewardTransaction>>;
+                                     std::unique_ptr<SeasonPassRewardTransaction>,
+                                     std::unique_ptr<state::PendingSettingsUpdate>>;
     Transaction transaction{};
     Transaction transaction{};
 };
 };
 
 
@@ -269,6 +270,18 @@ namespace body {
 /** Owns server-initiated encrypted frames appended after correlated replies. */
 /** Owns server-initiated encrypted frames appended after correlated replies. */
 namespace push {
 namespace push {
 
 
+/**
+ * Canonicalizes the account ahead of the family-specific snapshot dispatch.
+ * Families 0, 3 and 4 each take their own account snapshot, and the roster is built before the
+ * account companion, so a migration performed inside one family's builder would leave the others
+ * describing a different account: a Family-3 character record naming an emote instance the
+ * Family-4 manifest has already replaced, with no correction published afterwards. Running it
+ * ahead of every builder is what keeps the three images talking about one account.
+ * Idempotent, and one relaxed load once the answer can no longer change, so calling it from every
+ * entry point that reaches a builder costs nothing.
+ */
+void ensure_account_canonical() noexcept;
+
 /**
 /**
  * Appends the queuez snapshots one subscription needs, including the Family-4 companion.
  * Appends the queuez snapshots one subscription needs, including the Family-4 companion.
  * A snapshot that cannot be built is reported and skipped. The subscribe is answered either way,
  * A snapshot that cannot be built is reported and skipped. The subscribe is answered either way,

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

@@ -1380,6 +1380,8 @@ build_roster_snapshot(Session& session,
     // them. So every participation record carries the same player key. Selecting the first slot
     // them. So every participation record carries the same player key. Selecting the first slot
     // leaves the authored cinematic participant unbound whenever it names another record.
     // leaves the authored cinematic participant unbound whenever it names another record.
     snapshot.keyOnEveryParticipationSlot = true;
     snapshot.keyOnEveryParticipationSlot = true;
+    snapshot.authorDirectorBodies = defaults.authorDirectorBodies;
+    snapshot.authorWideRecordBodies = defaults.authorWideRecordBodies;
     // The participation record's `+0` latches only when the region index is known.
     // The participation record's `+0` latches only when the region index is known.
     snapshot.region = static_cast<std::uint32_t>(region.index);
     snapshot.region = static_cast<std::uint32_t>(region.index);
     snapshot.hasRegion = true;
     snapshot.hasRegion = true;

+ 64 - 0
Sunrise/src/server/bap/encrypted/push/queuez/queuez_account_preflight.cpp

@@ -0,0 +1,64 @@
+#include <array>
+#include <atomic>
+#include <cstdio>
+
+#include "../../../../../core/logging/log.h"
+#include "../../../../../state/runtime/runtime.h"
+#include "../../internal.h"
+
+namespace sunrise::server::bap::encrypted::push {
+namespace {
+
+/**
+ * Set once the answer can no longer change within this process, so the common path costs one
+ * relaxed load rather than a lock and a whole account copy on every pushed frame.
+ */
+std::atomic<bool> g_settled{false};
+
+/**
+ * Reports a preflight that left the account uncanonical, naming which of the two reasons it was.
+ * Silence here would be indistinguishable from a migration that ran, which is the confusion this
+ * whole preflight exists to remove.
+ */
+void report(const char* reason) noexcept {
+    std::array<char, 96> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=queuez stage=account_preflight result=skip reason=%s",
+                                      reason);
+    if (written > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+} // namespace
+
+/** Canonicalizes the account before any family image is allowed to read it. */
+void ensure_account_canonical() noexcept {
+    if (g_settled.load(std::memory_order_acquire)) {
+        return;
+    }
+    switch (state::ensure_character_emote_collection()) {
+    case state::EmoteCollectionOutcome::ready:
+        g_settled.store(true, std::memory_order_release);
+        break;
+    case state::EmoteCollectionOutcome::unsupported:
+        // The installed content decides this one and cannot change under a running process, so
+        // the verdict is final. Reported once rather than on every frame that follows.
+        g_settled.store(true, std::memory_order_release);
+        report("unsupported");
+        break;
+    case state::EmoteCollectionOutcome::notReady:
+        // Content extraction or account setup has not finished. Every family reads the same
+        // un-migrated account meanwhile, so they still agree with each other.
+        report("not_ready");
+        break;
+    case state::EmoteCollectionOutcome::failed:
+        report("failed");
+        break;
+    }
+}
+
+} // namespace sunrise::server::bap::encrypted::push

+ 18 - 11
Sunrise/src/server/bap/encrypted/push/queuez/queuez_banner_push.cpp

@@ -162,6 +162,9 @@ bool append_banner_notification(Scratch& scratch,
                                 std::size_t& written,
                                 std::size_t& written,
                                 queuez::SessionState& after) noexcept {
                                 queuez::SessionState& after) noexcept {
     after = before;
     after = before;
+    // Before the account is read, so this pair cannot describe a different account than the
+    // family-three roster or the family-four manifest.
+    ensure_account_canonical();
     // The pair names the first character when none is picked yet. The client's family-zero record
     // The pair names the first character when none is picked yet. The client's family-zero record
     // accepts a snapshot for about ten seconds, then clears the family and refuses every later
     // accepts a snapshot for about ten seconds, then clears the family and refuses every later
     // one, so holding the pair for the pick spends that window and the subscription times out.
     // one, so holding the pair for the pick spends that window and the subscription times out.
@@ -228,6 +231,7 @@ bool append_banner_move_notification(Scratch& scratch,
     bool publish = false;
     bool publish = false;
     bool incremental = false;
     bool incremental = false;
     after = before;
     after = before;
+    ensure_account_canonical();
     // A family zero with no first delivery yet has no ladder to move, and no root to name it with.
     // A family zero with no first delivery yet has no ladder to move, and no root to name it with.
     const char* reason = nullptr;
     const char* reason = nullptr;
     if (!queuez::stage_family0_subscription(
     if (!queuez::stage_family0_subscription(
@@ -330,24 +334,25 @@ bool append_socket_appearance_refresh_notification(
         return false;
         return false;
     }
     }
     state::build_data::items::details::Definition detail{};
     state::build_data::items::details::Definition detail{};
+    std::uint8_t nativeEquipmentSlot = 0;
     if (!state::build_data::find_configured_item_detail(mutation.targetDefinitionIndex, detail)
     if (!state::build_data::find_configured_item_detail(mutation.targetDefinitionIndex, detail)
         || detail.definitionIndex != mutation.targetDefinitionIndex
         || detail.definitionIndex != mutation.targetDefinitionIndex
         || detail.definitionHash != mutation.targetDefinitionHash
         || detail.definitionHash != mutation.targetDefinitionHash
-        || detail.bucketId != mutation.targetBucketId || !detail.equipmentSlot.has_value()
-        || *detail.equipmentSlot < 0
-        || static_cast<std::size_t>(*detail.equipmentSlot)
+        || detail.bucketId != mutation.targetBucketId
+        || !state::account::inventory::resolve_native_equipment_slot(
+            mutation.targetDefinitionHash, detail.equipmentSlot, nativeEquipmentSlot)
+        || static_cast<std::size_t>(nativeEquipmentSlot)
                >= state::build_data::items::details::kEquipmentSlotCount) {
                >= state::build_data::items::details::kEquipmentSlotCount) {
         return false;
         return false;
     }
     }
     snapshot::Prepared prepared{};
     snapshot::Prepared prepared{};
-    if (!snapshot::prepare_character_appearance_refresh(
-            scratch,
-            refresh,
-            mutation.afterCharacter,
-            mutation.characterIndex,
-            static_cast<std::uint8_t>(*detail.equipmentSlot),
-            true,
-            prepared)) {
+    if (!snapshot::prepare_character_appearance_refresh(scratch,
+                                                        refresh,
+                                                        mutation.afterCharacter,
+                                                        mutation.characterIndex,
+                                                        nativeEquipmentSlot,
+                                                        true,
+                                                        prepared)) {
         return false;
         return false;
     }
     }
     return append_appearance_frame(
     return append_appearance_frame(
@@ -485,6 +490,7 @@ bool append_account_resync_appearance_notification(
     std::size_t& written,
     std::size_t& written,
     queuez::SessionState& after) noexcept {
     queuez::SessionState& after) noexcept {
     after = before;
     after = before;
+    ensure_account_canonical();
     if (!before.family0Active) {
     if (!before.family0Active) {
         return true;
         return true;
     }
     }
@@ -527,6 +533,7 @@ bool append_account_resync_roster_notification(Scratch& scratch,
                                                std::size_t& written,
                                                std::size_t& written,
                                                queuez::SessionState& after) noexcept {
                                                queuez::SessionState& after) noexcept {
     after = before;
     after = before;
+    ensure_account_canonical();
     if (!before.family3Active) {
     if (!before.family3Active) {
         return true;
         return true;
     }
     }

+ 5 - 0
Sunrise/src/server/bap/encrypted/push/queuez/queuez_subscription.cpp

@@ -84,6 +84,7 @@ bool append_account_resync_notification(
     std::size_t& written,
     std::size_t& written,
     queuez::SessionState& after) noexcept {
     queuez::SessionState& after) noexcept {
     after = before;
     after = before;
+    ensure_account_canonical();
     if (!queuez::valid(before) || !before.family4Active || before.family4RootSoid == 0
     if (!queuez::valid(before) || !before.family4Active || before.family4RootSoid == 0
         || before.family4Version == (std::numeric_limits<std::int32_t>::max)()) {
         || before.family4Version == (std::numeric_limits<std::int32_t>::max)()) {
         return false;
         return false;
@@ -139,6 +140,10 @@ void append_queuez_notification(Scratch& scratch,
     after = before;
     after = before;
     armsRepush = false;
     armsRepush = false;
     armsBannerRepush = false;
     armsBannerRepush = false;
+    // Ahead of the dispatch below, not inside one family's builder: family zero reads the account
+    // directly and family three is built before the family-four companion, so a migration run any
+    // later would leave the three images describing different accounts.
+    ensure_account_canonical();
     if (subscription.familyType == queuez::kAccountFamilyType && before.family4Active
     if (subscription.familyType == queuez::kAccountFamilyType && before.family4Active
         && before.family4Version != queuez::kInitialFamilyVersion) {
         && before.family4Version != queuez::kInitialFamilyVersion) {
         // Our mirror of the Client's records is an observation, not an authority on what may be
         // Our mirror of the Client's records is an observation, not an authority on what may be

+ 4 - 0
Sunrise/src/server/bap/encrypted/push/snapshot/family4_snapshot_preparer.cpp

@@ -69,6 +69,10 @@ bool prepare(Scratch& scratch,
     if (!state::ensure_profile_item_identities()) {
     if (!state::ensure_profile_item_identities()) {
         return report_failure("profile_identities");
         return report_failure("profile_identities");
     }
     }
+    // The emote-collection canonicalization deliberately does not live here. Family zero and
+    // family three build their own images from the same account and neither passes through this
+    // function, so it runs in the shared preflight ahead of the whole dispatch instead
+    // (push::ensure_account_canonical).
     const state::AccountState account = state::account_snapshot();
     const state::AccountState account = state::account_snapshot();
     if (!state::account::valid(account)) {
     if (!state::account::valid(account)) {
         return report_failure("account_state");
         return report_failure("account_state");

+ 5 - 1
Sunrise/src/server/bap/encrypted/push/snapshot/initial_snapshot.cpp

@@ -26,6 +26,8 @@ bool prepare_initial(
     Prepared staged{};
     Prepared staged{};
     staged.rawClearSize = reservation.rawClearSize;
     staged.rawClearSize = reservation.rawClearSize;
     staged.compressedClearSize = reservation.compressedClearSize;
     staged.compressedClearSize = reservation.compressedClearSize;
+    // Family two's directory and the family-three roster object both live in slot zero, so one
+    // expression covers every family that reaches here.
     const std::uint32_t slotIndex = subscription.familyType == kAccountFamilyType
     const std::uint32_t slotIndex = subscription.familyType == kAccountFamilyType
                                         ? kAccountDefinitionSlotIndex
                                         ? kAccountDefinitionSlotIndex
                                         : kRosterDefinitionSlotIndex;
                                         : kRosterDefinitionSlotIndex;
@@ -33,7 +35,9 @@ bool prepare_initial(
     const bool hasDefinition =
     const bool hasDefinition =
         middleware::datagen::object_id(subscription.familyType, slotIndex, objectId);
         middleware::datagen::object_id(subscription.familyType, slotIndex, objectId);
     bool success = false;
     bool success = false;
-    if (subscription.familyType == kRosterFamilyType && hasDefinition) {
+    if (subscription.familyType == kSocialRosterFamilyType && hasDefinition) {
+        success = prepare_social_roster(scratch, subscription, objectId, reservation, staged);
+    } else if (subscription.familyType == kRosterFamilyType && hasDefinition) {
         success = prepare_roster(scratch, subscription, objectId, reservation, staged);
         success = prepare_roster(scratch, subscription, objectId, reservation, staged);
     } else if (subscription.familyType == kAccountFamilyType && hasDefinition) {
     } else if (subscription.familyType == kAccountFamilyType && hasDefinition) {
         success = prepare(
         success = prepare(

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

@@ -15,6 +15,8 @@ namespace sunrise::server::bap::encrypted::push::snapshot {
 
 
 /** Initial family snapshots start at version zero. */
 /** Initial family snapshots start at version zero. */
 inline constexpr std::int32_t kInitialFamilyVersion = 0;
 inline constexpr std::int32_t kInitialFamilyVersion = 0;
+/** Family two carries the social roster the Roster and Fireteam panels draw. */
+inline constexpr std::uint32_t kSocialRosterFamilyType = 2;
 /** Family three carries the account roster selected by Web Service subscription. */
 /** Family three carries the account roster selected by Web Service subscription. */
 inline constexpr std::uint32_t kRosterFamilyType = 3;
 inline constexpr std::uint32_t kRosterFamilyType = 3;
 /** Family four carries account and selected-character investment state. */
 /** Family four carries account and selected-character investment state. */
@@ -61,6 +63,24 @@ inline constexpr std::size_t kFirstItemObjectIndexUnselected = kAccountObjectInd
                                   const Reservation& reservation,
                                   const Reservation& reservation,
                                   Prepared& prepared) noexcept;
                                   Prepared& prepared) noexcept;
 
 
+/**
+ * Builds the family-two social roster snapshot.
+ *
+ * Both slots go out together: the row resolves the emblem by reading the link at directory +8
+ * and looking the member record up by it, and a full snapshot prunes whatever it does not name.
+ * @param scratch Object storage owned by the lock.
+ * @param subscription Family id the Client picked.
+ * @param objectId Unused. Both slot ids are resolved by the builder.
+ * @param reservation Prior payload prefixes that staging must keep.
+ * @param prepared Gets both descriptors and the scratch clear extents.
+ * @return True when an account is signed in and both objects fit.
+ */
+[[nodiscard]] bool prepare_social_roster(Scratch& scratch,
+                                         const middleware::queuez::Subscription& subscription,
+                                         std::uint32_t objectId,
+                                         const Reservation& reservation,
+                                         Prepared& prepared) noexcept;
+
 /**
 /**
  * Compresses one encoded family-four object into the next sealed scratch segment.
  * Compresses one encoded family-four object into the next sealed scratch segment.
  * @param scratch Raw and compressed snapshot storage owned by the lock.
  * @param scratch Raw and compressed snapshot storage owned by the lock.

+ 210 - 0
Sunrise/src/server/bap/encrypted/push/snapshot/social_roster_snapshot.cpp

@@ -0,0 +1,210 @@
+/**
+ * Family-two social roster snapshot: the directory and the member record it links to.
+ *
+ * The Roster and Fireteam panels draw a name and a blank emblem because family two is answered
+ * with an empty snapshot. The panel row resolves the emblem with two lookups, not one, and both
+ * objects have to be resident at the same time for the pair to resolve:
+ *
+ *     lookup 1: slot 0, keyed by the account soid, gives the directory
+ *     lookup 2: slot 1, keyed by the qword at directory +8, gives the member record
+ *     then the emblem definition index is read from member +36 and its variant from member +38
+ *
+ * A full snapshot prunes every object it does not name, so publishing one slot per message can
+ * never satisfy that chain whichever slot is chosen. Both go out in one message.
+ */
+
+#include <algorithm>
+#include <array>
+#include <cstdio>
+#include <cstring>
+#include <span>
+
+#include "../../../../../core/logging/log.h"
+#include "../../../../../middleware/datagen/definitions.h"
+#include "../../../../../state/account/inventory/inventory_state.h"
+#include "../../../../../state/build_data/items/item_catalog.h"
+#include "../../../../../state/runtime/runtime.h"
+#include "internal.h"
+#include "snapshot_storage.h"
+
+namespace sunrise::server::bap::encrypted::push::snapshot {
+namespace {
+
+/** One line carries the soid, the object count and the encoded size. */
+constexpr std::size_t kReportCapacity = 160;
+
+/** Where the member record carries the emblem the panel row reads. */
+constexpr std::size_t kEmblemDefinitionOffset = 36;
+constexpr std::size_t kEmblemVariantOffset = 38;
+
+/**
+ * A missing definition index is every bit set, and the variant is always sent empty.
+ *
+ * The reader tries the variant first and falls back to the definition index when the variant is
+ * the empty sentinel. Sending a real number there resolves art against a bogus variant entry: a
+ * light value written to +38 drew a grey placeholder, and a large value stalled the client outright
+ * because the field indexes a table.
+ */
+constexpr std::uint16_t kEmptyDefinitionIndex = 0xFFFFU;
+
+/**
+ * Resolves the selected character's equipped emblem to a native definition index.
+ *
+ * This has to track the live loadout rather than publish a constant. The client resolves this
+ * account-keyed object as the account's emblem rather than as roster decoration, so a fixed index
+ * here pins the emblem globally: character select, inventory and orbit all stop reflecting an equip
+ * while the equip itself keeps succeeding. Publishing what the player actually has on makes that
+ * harmless.
+ *
+ * @param account Account snapshot, already read under the lock by the caller.
+ * @param index Receives the native definition index of the equipped emblem.
+ * @return False when nothing is selected, the emblem slot is empty, or the hash is unknown. Every
+ *         one of those cases publishes the empty sentinel rather than a guess.
+ */
+[[nodiscard]] bool selected_emblem_definition_index(const state::AccountState& account,
+                                                    std::uint16_t& index,
+                                                    std::uint32_t& definitionHash) noexcept {
+    for (const state::CharacterState& character : account.characters) {
+        if (!character.selected) {
+            continue;
+        }
+        const auto& slot =
+            character.equipment
+                .slots[static_cast<std::size_t>(state::account::inventory::EquipmentSlot::emblem)];
+        if (!slot.has_value()) {
+            return false;
+        }
+        state::build_data::items::Definition definition{};
+        if (!state::build_data::items::find_hash(slot->definitionHash, definition)) {
+            return false;
+        }
+        index = definition.definitionIndex;
+        definitionHash = slot->definitionHash;
+        return true;
+    }
+    return false;
+}
+
+} // namespace
+
+/** Builds the family-two snapshot carrying the social roster directory and member record. */
+bool prepare_social_roster(Scratch& scratch,
+                           const middleware::queuez::Subscription& subscription,
+                           std::uint32_t objectId,
+                           const Reservation& reservation,
+                           Prepared& prepared) noexcept {
+    // Both slot ids are resolved here, so the caller's single id is not used.
+    (void)objectId;
+    const state::AccountState account = state::account_snapshot();
+    if (account.primarySoid == 0 || reservation.rawWriteOffset > scratch.plaintext.size()) {
+        return report_failure("social_roster_state");
+    }
+    const auto destination = std::span(scratch.plaintext).subspan(reservation.rawWriteOffset);
+    constexpr std::size_t kTotal = middleware::datagen::kSocialRosterDirectorySize
+                                   + middleware::datagen::kSocialRosterMemberSize;
+    if (destination.size() < kTotal) {
+        return report_failure("social_roster_storage");
+    }
+
+    std::uint16_t emblem = kEmptyDefinitionIndex;
+    std::uint32_t emblemHash = 0;
+    if (!selected_emblem_definition_index(account, emblem, emblemHash)) {
+        emblem = kEmptyDefinitionIndex;
+    }
+
+    Prepared staged{};
+    std::size_t objectCount = 0;
+    std::size_t compressedExtent = reservation.compressedWriteOffset;
+    std::size_t rawUsed = 0;
+
+    /**
+     * Writes one object and stages it.
+     *
+     * The two bodies are not interchangeable, because both lookups match on the object's first
+     * qword. The directory leads with the account soid the row searches by and carries the link at
+     * +8; the member record leads with that same link so the second lookup finds it. The account
+     * soid serves as the link because it is already proven to route.
+     *
+     * Only the member record carries the emblem. The directory is read for two flag bits and
+     * nothing else, so a copy of the pair there changes nothing.
+     */
+    const auto emit = [&](std::size_t size, std::uint32_t id, bool directory) noexcept {
+        if (objectCount >= staged.objects.size() || size < kEmblemVariantOffset + sizeof emblem) {
+            return false;
+        }
+        const auto body = destination.subspan(rawUsed, size);
+        std::fill(body.begin(), body.end(), std::byte{});
+        if (directory) {
+            std::memcpy(body.data(), &account.primarySoid, sizeof account.primarySoid);
+            std::memcpy(body.data() + sizeof account.primarySoid,
+                        &account.primarySoid,
+                        sizeof account.primarySoid);
+        } else {
+            std::memcpy(body.data(), &account.primarySoid, sizeof account.primarySoid);
+            std::memcpy(body.data() + kEmblemDefinitionOffset, &emblem, sizeof emblem);
+            std::memcpy(body.data() + kEmblemVariantOffset,
+                        &kEmptyDefinitionIndex,
+                        sizeof kEmptyDefinitionIndex);
+        }
+        std::size_t compressedSize = 0;
+        if (!compress_object(scratch,
+                             body,
+                             id,
+                             account.primarySoid,
+                             compressedExtent,
+                             staged.objects[objectCount],
+                             compressedSize)) {
+            return false;
+        }
+        compressedExtent += compressedSize;
+        rawUsed += size;
+        ++objectCount;
+        return true;
+    };
+
+    // The directory goes first so a partial land reads as the directory surviving without a member
+    // record, rather than as an unexplained miss.
+    if (!emit(middleware::datagen::kSocialRosterDirectorySize,
+              middleware::datagen::kSocialRosterDirectoryObjectId,
+              true)) {
+        return report_failure("social_roster_directory");
+    }
+    if (!emit(middleware::datagen::kSocialRosterMemberSize,
+              middleware::datagen::kSocialRosterMemberObjectId,
+              false)) {
+        return report_failure("social_roster_member");
+    }
+
+    staged.rawClearSize =
+        (std::max)(reservation.rawClearSize, reservation.rawWriteOffset + rawUsed);
+    staged.compressedClearSize = (std::max)(reservation.compressedClearSize, compressedExtent);
+    staged.family = middleware::queuez::Family{
+        subscription.familyType,
+        subscription.familyRootSoid,
+        kInitialFamilyVersion,
+        middleware::queuez::kFullSnapshotFlag,
+        std::span(staged.objects).first(objectCount),
+    };
+    if (!commit(staged, prepared)) {
+        return report_failure("social_roster_commit");
+    }
+
+    std::array<char, kReportCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=queuez stage=social_roster result=ok soid=0x%016llX"
+                                      " objects=%zu bytes=%zu emblem=%u hash=0x%08X",
+                                      static_cast<unsigned long long>(account.primarySoid),
+                                      objectCount,
+                                      rawUsed,
+                                      static_cast<unsigned>(emblem),
+                                      static_cast<unsigned>(emblemHash));
+    if (written > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+    return true;
+}
+
+} // namespace sunrise::server::bap::encrypted::push::snapshot

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

@@ -11,6 +11,8 @@ namespace sunrise::server::bap::encrypted::queuez {
 
 
 /** Family zero carries the banner anchor and the record for the character it names. */
 /** Family zero carries the banner anchor and the record for the character it names. */
 inline constexpr std::uint32_t kBannerFamilyType = 0;
 inline constexpr std::uint32_t kBannerFamilyType = 0;
+/** Family two carries the social roster the Roster and Fireteam panels draw. */
+inline constexpr std::uint32_t kSocialRosterFamilyType = 2;
 /** Family three carries the account character roster. */
 /** Family three carries the account character roster. */
 inline constexpr std::uint32_t kRosterFamilyType = 3;
 inline constexpr std::uint32_t kRosterFamilyType = 3;
 /** Family four carries account, character, and item state. */
 /** Family four carries account, character, and item state. */
@@ -213,6 +215,25 @@ struct StagedPublication {
     bool armsBannerRepush{};
     bool armsBannerRepush{};
     /** Root that copy must use. */
     /** Root that copy must use. */
     std::uint64_t bannerRepushRoot{};
     std::uint64_t bannerRepushRoot{};
+    /**
+     * Root a family-two subscribe was answered against, or zero when this frame answered none.
+     *
+     * A subscribe is the only moment a family-two root arrives. The connection keeps the last one
+     * so a later re-push can reuse it rather than deriving a value the peer never named.
+     */
+    std::uint64_t socialRosterRepushRoot{};
+    /**
+     * An emblem equip left the published family-two object stale and it owes a fresh copy.
+     *
+     * The family-two snapshot is built when the peer subscribes, so the emblem it carries is only
+     * correct as of that moment; the Client resolves that account-keyed object as *the* account
+     * emblem, so a stale one pins the display for the rest of the session while the equip itself
+     * keeps succeeding.
+     *
+     * Its own flag on its own signal. The banner arm is deliberately not reused: the consumer
+     * records that arming a re-push from another family's signal took the connection down.
+     */
+    bool rearmsSocialRosterRepush{};
     /** A subclass selection just staged and owes a delayed ability-icon refresh. */
     /** A subclass selection just staged and owes a delayed ability-icon refresh. */
     bool armsAbilityRefresh{};
     bool armsAbilityRefresh{};
     /** Complete row-identity overlay to retain after this equipment transaction commits. */
     /** Complete row-identity overlay to retain after this equipment transaction commits. */

+ 71 - 1
Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp

@@ -375,6 +375,75 @@ selected_character(const state::AccountState& account) noexcept {
     return true;
     return true;
 }
 }
 
 
+/**
+ * Sends the owed family-two re-push once its delay has passed.
+ *
+ * The family-two snapshot is built when the peer subscribes, so the emblem it carries is only
+ * correct as of that moment. An equip into the emblem slot leaves it stale, and the Client
+ * resolves that account-keyed object as *the* account emblem -- so the display stays pinned to
+ * whatever was worn at subscribe time while the equip itself keeps succeeding. This republishes
+ * the live value against the root the subscribe was answered with.
+ *
+ * **One attempt, spent whether or not it lands.** The arm is cleared before the frame is built, so
+ * a refusal cannot leave this re-arming every tick; this file records that a boot-shaped replay
+ * repeated after the ladder has moved took the connection down.
+ *
+ * @param session Auth, nonce and queuez state owned by the connection.
+ * @param scratch Transform buffers owned by the lock.
+ * @param response Whole-frame storage owned by the caller.
+ * @param written Gets the encoded notification size in bytes.
+ * @param touchesScratch Set before any scratch buffer is used.
+ * @return True when a whole family-two notification is published.
+ */
+[[nodiscard]] bool consume_social_roster_repush(Session& session,
+                                                Scratch& scratch,
+                                                std::span<std::byte> response,
+                                                std::size_t& written,
+                                                bool& touchesScratch) noexcept {
+    if (!session.socialRosterRepushArmed || session.socialRosterRepushRoot == 0
+        || GetTickCount64() < session.socialRosterRepushDueTick) {
+        return false;
+    }
+    // Spent up front, so no path below can leave it owed.
+    session.socialRosterRepushArmed = false;
+    touchesScratch = true;
+
+    // The same body the subscribe answer builds, rebuilt against current State so the emblem it
+    // carries is the one now worn.
+    middleware::queuez::Subscription subscription{};
+    subscription.familyType = queuez::kSocialRosterFamilyType;
+    subscription.familyRootSoid = session.socialRosterRepushRoot;
+
+    auto nextSendNonce = session.sendNonce;
+    std::size_t framedSize = 0;
+    queuez::SessionState rosterAfter{};
+    bool armsRepush = false;
+    bool armsBannerRepush = false;
+    push::append_queuez_notification(scratch,
+                                     session.queuez,
+                                     subscription,
+                                     session.sessionKey,
+                                     nextSendNonce,
+                                     scratch.framed,
+                                     framedSize,
+                                     rosterAfter,
+                                     armsRepush,
+                                     armsBannerRepush);
+    if (framedSize == 0 || framedSize > response.size()) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::warn,
+                         "ev=queuez stage=social_roster_repush result=fail");
+        return false;
+    }
+    std::copy_n(scratch.framed.begin(), framedSize, response.begin());
+    written = framedSize;
+    session.sendNonce = nextSendNonce;
+    if (valid(rosterAfter)) {
+        session.queuez = rosterAfter;
+    }
+    return true;
+}
+
 /** Refreshes appearance and roster after an asynchronous ability-bucket rebuild. */
 /** Refreshes appearance and roster after an asynchronous ability-bucket rebuild. */
 [[nodiscard]] bool consume_ability_refresh(Session& session,
 [[nodiscard]] bool consume_ability_refresh(Session& session,
                                            Scratch& scratch,
                                            Scratch& scratch,
@@ -590,7 +659,8 @@ bool consume_deferred(Session& session,
     if (!session.family4RepushArmed || session.family4RepushRoot == 0
     if (!session.family4RepushArmed || session.family4RepushRoot == 0
         || GetTickCount64() < session.family4RepushDueTick
         || GetTickCount64() < session.family4RepushDueTick
         || GetTickCount64() < session.acquisitionPresentationUntilTick) {
         || GetTickCount64() < session.acquisitionPresentationUntilTick) {
-        return consume_banner_repush(session, scratch, response, written, touchesScratch)
+        return consume_social_roster_repush(session, scratch, response, written, touchesScratch)
+               || consume_banner_repush(session, scratch, response, written, touchesScratch)
                || push::activity::consume_activity_keepalive(
                || push::activity::consume_activity_keepalive(
                    session, scratch, response, written, touchesScratch);
                    session, scratch, response, written, touchesScratch);
     }
     }

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

@@ -185,6 +185,27 @@ stage_profile_item_acquisition_push(Scratch& scratch,
     return true;
     return true;
 }
 }
 
 
+/**
+ * Says whether equipping into one slot changes what the family-two member record publishes.
+ *
+ * That record carries two fields fed by different slots. The emblem comes from the emblem slot,
+ * and the light is the mean of the eight gear slots -- the three weapons and the five armour
+ * pieces -- so an armour swap moves the record just as surely as an emblem swap does. Gating on
+ * the emblem alone would leave the roster row holding a stale light until the next subscribe.
+ *
+ * Everything else is excluded because it moves neither field: a ghost, sparrow, ship, subclass,
+ * clan banner, emote or finisher carries no Power in this season and is not the emblem, so a
+ * swap there would spend a re-push republishing an unchanged object.
+ *
+ * @param equipmentSlotIndex Authored semantic slot the equip targeted.
+ * @return True when the slot feeds the emblem or the light the member record carries.
+ */
+[[nodiscard]] constexpr bool moves_social_roster(std::size_t equipmentSlotIndex) noexcept {
+    namespace inventory = state::account::inventory;
+    return equipmentSlotIndex <= static_cast<std::size_t>(inventory::EquipmentSlot::classItem)
+           || equipmentSlotIndex == static_cast<std::size_t>(inventory::EquipmentSlot::emblem);
+}
+
 } // namespace
 } // namespace
 
 
 /** Stages queuez subscription, unsubscription, or character-move output for one peer. */
 /** Stages queuez subscription, unsubscription, or character-move output for one peer. */
@@ -203,6 +224,7 @@ bool stage_service_outcome(Scratch& scratch,
     bool armsRepush = false;
     bool armsRepush = false;
     bool armsBannerRepush = false;
     bool armsBannerRepush = false;
     std::uint64_t bannerRoot = 0;
     std::uint64_t bannerRoot = 0;
+    std::uint64_t socialRosterRoot = 0;
     bool armsAbilityRefresh = false;
     bool armsAbilityRefresh = false;
     const auto* changeCharacter = transaction_if<ChangeCharacter>(outcome);
     const auto* changeCharacter = transaction_if<ChangeCharacter>(outcome);
     const auto* selectCharacter = transaction_if<SelectCharacter>(outcome);
     const auto* selectCharacter = transaction_if<SelectCharacter>(outcome);
@@ -220,6 +242,12 @@ bool stage_service_outcome(Scratch& scratch,
     const auto presentationRows = preserveAcquisitionPresentation
     const auto presentationRows = preserveAcquisitionPresentation
                                       ? acquisitionPresentationRows
                                       ? acquisitionPresentationRows
                                       : std::span<const AcquisitionPresentationRow>{};
                                       : std::span<const AcquisitionPresentationRow>{};
+    // Set before the branch chain rather than inside the equipment arm. That arm returns early
+    // when the staged after-image fails validation, and the equip has already moved State by
+    // then -- so the published record is stale on exactly the path the arm never finishes.
+    publication.rearmsSocialRosterRepush =
+        equipment != nullptr && equipment->pending != nullptr
+        && moves_social_roster(equipment->pending->equipmentSlotIndex);
     if (outcome.hasSubscription) {
     if (outcome.hasSubscription) {
         push::append_queuez_notification(scratch,
         push::append_queuez_notification(scratch,
                                          before,
                                          before,
@@ -232,6 +260,12 @@ bool stage_service_outcome(Scratch& scratch,
                                          armsRepush,
                                          armsRepush,
                                          armsBannerRepush);
                                          armsBannerRepush);
         bannerRoot = outcome.subscription.familyRootSoid;
         bannerRoot = outcome.subscription.familyRootSoid;
+        // The subscribe is the only moment a family-two root arrives. Recorded rather than acted
+        // on: the inline answer to this subscribe lands, so nothing is owed until an equip makes
+        // what it published stale.
+        if (outcome.subscription.familyType == kSocialRosterFamilyType) {
+            socialRosterRoot = outcome.subscription.familyRootSoid;
+        }
     } else if (outcome.hasUnsubscription) {
     } else if (outcome.hasUnsubscription) {
         stage_unsubscription(before,
         stage_unsubscription(before,
                              outcome.unsubscription.familyType,
                              outcome.unsubscription.familyType,
@@ -801,6 +835,7 @@ bool stage_service_outcome(Scratch& scratch,
     publication.family4RepushRoot = armsRepush ? outcome.subscription.familyRootSoid : 0;
     publication.family4RepushRoot = armsRepush ? outcome.subscription.familyRootSoid : 0;
     publication.armsBannerRepush = armsBannerRepush && bannerRoot != 0;
     publication.armsBannerRepush = armsBannerRepush && bannerRoot != 0;
     publication.bannerRepushRoot = publication.armsBannerRepush ? bannerRoot : 0;
     publication.bannerRepushRoot = publication.armsBannerRepush ? bannerRoot : 0;
+    publication.socialRosterRepushRoot = socialRosterRoot;
     publication.armsAbilityRefresh = armsAbilityRefresh;
     publication.armsAbilityRefresh = armsAbilityRefresh;
     return true;
     return true;
 }
 }

+ 22 - 12
Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp

@@ -1,4 +1,4 @@
-#include "service_outcome_commit.h"
+#include "service_outcome_commit.h"
 
 
 #include <array>
 #include <array>
 #include <cstdio>
 #include <cstdio>
@@ -13,15 +13,15 @@
 #include "../internal.h"
 #include "../internal.h"
 
 
 namespace sunrise::server::bap::encrypted::transactions {
 namespace sunrise::server::bap::encrypted::transactions {
-namespace {
 
 
 namespace slots = state::activity::entity_slots;
 namespace slots = state::activity::entity_slots;
 
 
 /** Log names for each lease operation, in the enum's own order. */
 /** Log names for each lease operation, in the enum's own order. */
-constexpr std::array<const char*, 4> kLeaseKinds = {"none", "join", "grant", "release"};
+static constexpr std::array<const char*, 4> kLeaseKinds = {"none", "join", "grant", "release"};
 
 
 /** Retains one newly committed private ActivityClient generation for its BAP link. */
 /** Retains one newly committed private ActivityClient generation for its BAP link. */
-[[nodiscard]] bool retain_private(std::uint64_t sessionId, Publication& publication) noexcept {
+[[nodiscard]] static bool retain_private(std::uint64_t sessionId,
+                                         Publication& publication) noexcept {
     state::activity::SessionBinding binding{};
     state::activity::SessionBinding binding{};
     if (!state::activity::snapshot_binding(sessionId, binding)
     if (!state::activity::snapshot_binding(sessionId, binding)
         || !state::activity::retain_binding(binding)) {
         || !state::activity::retain_binding(binding)) {
@@ -37,8 +37,8 @@ constexpr std::array<const char*, 4> kLeaseKinds = {"none", "join", "grant", "re
 }
 }
 
 
 /** Retains one exact advertised public target before its join mutation commits. */
 /** Retains one exact advertised public target before its join mutation commits. */
-[[nodiscard]] bool retain_public(const activity_message::ActivityPlan& plan,
-                                 Publication& publication) noexcept {
+[[nodiscard]] static bool retain_public(const activity_message::ActivityPlan& plan,
+                                        Publication& publication) noexcept {
     server::gameplay::group::HostSessionBinding current{};
     server::gameplay::group::HostSessionBinding current{};
     if (!server::gameplay::group::host_session_for_activity(plan.sessionId, current)
     if (!server::gameplay::group::host_session_for_activity(plan.sessionId, current)
         || current.generation != plan.publicHost.generation
         || current.generation != plan.publicHost.generation
@@ -70,7 +70,7 @@ constexpr std::array<const char*, 4> kLeaseKinds = {"none", "join", "grant", "re
 }
 }
 
 
 /** Releases provisional activity owners when the following State commit fails. */
 /** Releases provisional activity owners when the following State commit fails. */
-void discard_activity_publication(Publication& publication) noexcept {
+static void discard_activity_publication(Publication& publication) noexcept {
     if (publication.activity.hostGeneration != 0) {
     if (publication.activity.hostGeneration != 0) {
         server::gameplay::group::release_host_session(publication.activity.hostGeneration);
         server::gameplay::group::release_host_session(publication.activity.hostGeneration);
     }
     }
@@ -87,7 +87,7 @@ void discard_activity_publication(Publication& publication) noexcept {
  * @param mutation Plan as it was before the commit consumed it.
  * @param mutation Plan as it was before the commit consumed it.
  * @param committed Whether the commit succeeded.
  * @param committed Whether the commit succeeded.
  */
  */
-void report_lease(const slots::PendingMutation& mutation, bool committed) noexcept {
+static void report_lease(const slots::PendingMutation& mutation, bool committed) noexcept {
     if (committed) {
     if (committed) {
         return;
         return;
     }
     }
@@ -100,13 +100,17 @@ void report_lease(const slots::PendingMutation& mutation, bool committed) noexce
         std::snprintf(line.data(),
         std::snprintf(line.data(),
                       line.size(),
                       line.size(),
                       "ev=activity stage=entity_slots result=fail kind=%s soid=0x%llX "
                       "ev=activity stage=entity_slots result=fail kind=%s soid=0x%llX "
-                      "requested=%zu picked=%zu held=%zu reserved=%zu known=%u",
+                      "requested=%zu picked=%zu held=%zu reserved=%zu returned=%zu known=%u",
                       kind < kLeaseKinds.size() ? kLeaseKinds[kind] : "bad",
                       kind < kLeaseKinds.size() ? kLeaseKinds[kind] : "bad",
                       static_cast<unsigned long long>(mutation.sessionId),
                       static_cast<unsigned long long>(mutation.sessionId),
                       mutation.requestedCount,
                       mutation.requestedCount,
                       slots::slot_count(mutation.mask),
                       slots::slot_count(mutation.mask),
                       held,
                       held,
                       reserved,
                       reserved,
+                      // Only a release carries one, so every other kind reports zero. A release
+                      // whose returned set and picked set disagree means the two ledgers have
+                      // diverged, which nothing else on this path would show.
+                      slots::slot_count(mutation.returnedMask),
                       known ? 1U : 0U);
                       known ? 1U : 0U);
     if (written > 0) {
     if (written > 0) {
         core::log::write(core::log::Channel::server,
         core::log::write(core::log::Channel::server,
@@ -115,15 +119,13 @@ void report_lease(const slots::PendingMutation& mutation, bool committed) noexce
     }
     }
 }
 }
 
 
-[[nodiscard]] bool report_commit(bool committed, const char* failure) noexcept {
+[[nodiscard]] static bool report_commit(bool committed, const char* failure) noexcept {
     if (!committed) {
     if (!committed) {
         core::log::write(core::log::Channel::server, core::log::Level::warn, failure);
         core::log::write(core::log::Channel::server, core::log::Level::warn, failure);
     }
     }
     return committed;
     return committed;
 }
 }
 
 
-} // namespace
-
 /**
 /**
  * Commits at most one delayed State transaction.
  * Commits at most one delayed State transaction.
  * @param outcome Checked service result whose pending transaction is used up.
  * @param outcome Checked service result whose pending transaction is used up.
@@ -215,6 +217,14 @@ bool commit(ServiceOutcome& outcome, Publication& publication, const char*& reas
         reason = "matchmaking";
         reason = "matchmaking";
         return state::matchmaking::commit(*mutation);
         return state::matchmaking::commit(*mutation);
     }
     }
+    if (auto* mutation = transaction_if<state::PendingSettingsUpdate>(outcome)) {
+        const bool committed = state::commit_settings_update(*mutation);
+        core::log::write(core::log::Channel::server,
+                         committed ? core::log::Level::debug : core::log::Level::warn,
+                         committed ? "ev=ws701 stage=transaction_commit result=ok"
+                                   : "ev=ws701 stage=transaction_commit result=fail");
+        return committed;
+    }
     if (auto* transaction = transaction_if<EquipmentSwapTransaction>(outcome)) {
     if (auto* transaction = transaction_if<EquipmentSwapTransaction>(outcome)) {
         if (transaction->pending == nullptr) {
         if (transaction->pending == nullptr) {
             return false;
             return false;

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

@@ -432,6 +432,12 @@ struct Session {
     std::uint64_t bannerRepushRoot{};
     std::uint64_t bannerRepushRoot{};
     /** True while one banner re-push is still owed to this peer. */
     /** True while one banner re-push is still owed to this peer. */
     bool bannerRepushArmed{};
     bool bannerRepushArmed{};
+    /** Tick count after which the owed social-roster re-push may go out. */
+    std::uint64_t socialRosterRepushDueTick{};
+    /** Root the last family-two subscribe was answered against, and the re-push must reuse. */
+    std::uint64_t socialRosterRepushRoot{};
+    /** True while one family-two re-push is still owed to this peer. */
+    bool socialRosterRepushArmed{};
     /** Latest shared-account generation this peer has received. */
     /** Latest shared-account generation this peer has received. */
     std::uint64_t accountGeneration{};
     std::uint64_t accountGeneration{};
     /** Newest shared-account generation owed as a full cross-peer refresh. */
     /** Newest shared-account generation owed as a full cross-peer refresh. */

Некоторые файлы не были показаны из-за большого количества измененных файлов