فهرست منبع

physics host, activity stuff, getting ready for scripting

stan 3 هفته پیش
والد
کامیت
b8ccfb9b80
100فایلهای تغییر یافته به همراه6315 افزوده شده و 952 حذف شده
  1. 1 0
      README.md
  2. 183 1
      Sunrise/Sunrise.vcxproj
  3. 8 1
      Sunrise/resources/default_settings.json
  4. 71 10
      Sunrise/src/client/content/scenarios/internal.h
  5. 2 1
      Sunrise/src/client/content/scenarios/scenario_roster_build.cpp
  6. 30 77
      Sunrise/src/client/content/scenarios/scenario_roster_groups.cpp
  7. 50 7
      Sunrise/src/client/content/scenarios/scenario_roster_publish.cpp
  8. 66 0
      Sunrise/src/client/content/scenarios/scenario_slot_classification.cpp
  9. 316 0
      Sunrise/src/client/hooks/membership_probe/membership_probe.cpp
  10. 28 0
      Sunrise/src/client/hooks/membership_probe/membership_probe.h
  11. 5 0
      Sunrise/src/client/runtime/client_hook_activation.cpp
  12. 6 0
      Sunrise/src/core/settings/parser.h
  13. 65 0
      Sunrise/src/core/settings/server/activation/activation_settings_parser.cpp
  14. 49 0
      Sunrise/src/core/settings/server/activation/definition.h
  15. 3 0
      Sunrise/src/core/settings/server/definition.h
  16. 17 0
      Sunrise/src/core/settings/server/gameplay/definition.h
  17. 9 0
      Sunrise/src/core/settings/server/gameplay/gameplay_settings_parser.cpp
  18. 18 1
      Sunrise/src/core/settings/server/gameplay/gameplay_settings_validation.cpp
  19. 6 0
      Sunrise/src/core/settings/server/server_settings_parser.cpp
  20. 14 1
      Sunrise/src/core/settings/settings_runtime.cpp
  21. 42 11
      Sunrise/src/core/ui/hud/overlays/ui_hud_session_overlay.cpp
  22. 72 0
      Sunrise/src/middleware/bap/activity_message/activity_incident_encoder.cpp
  23. 39 14
      Sunrise/src/middleware/bap/activity_message/activity_incident_parser.cpp
  24. 130 0
      Sunrise/src/middleware/bap/activity_message/activity_peer_ledger_parser.cpp
  25. 17 0
      Sunrise/src/middleware/bap/activity_message/activity_replication_epoch_encoder.cpp
  26. 20 0
      Sunrise/src/middleware/bap/activity_message/activity_replication_epoch_encoder.h
  27. 41 0
      Sunrise/src/middleware/bap/activity_message/activity_sense_update_parser.cpp
  28. 77 3
      Sunrise/src/middleware/bap/activity_message/activity_sensor_auth_blocks.cpp
  29. 48 8
      Sunrise/src/middleware/bap/activity_message/activity_sensor_auth_encoder.cpp
  30. 94 0
      Sunrise/src/middleware/bap/activity_message/activity_start_activity_parser.cpp
  31. 100 0
      Sunrise/src/middleware/bap/activity_message/activity_telemetry_parser.cpp
  32. 32 6
      Sunrise/src/middleware/bap/activity_message/incident.h
  33. 107 0
      Sunrise/src/middleware/bap/activity_message/peer_ledger.h
  34. 46 0
      Sunrise/src/middleware/bap/activity_message/sense_update.h
  35. 77 6
      Sunrise/src/middleware/bap/activity_message/sensor_auth_update.h
  36. 63 0
      Sunrise/src/middleware/bap/activity_message/start_activity.h
  37. 108 0
      Sunrise/src/middleware/bap/activity_message/telemetry.h
  38. 36 0
      Sunrise/src/middleware/content/packages/tables/roster_intersection.cpp
  39. 15 0
      Sunrise/src/middleware/content/packages/tables/roster_intersection.h
  40. 22 0
      Sunrise/src/middleware/gameplay/dtls/replay_high_water.cpp
  41. 35 0
      Sunrise/src/middleware/gameplay/dtls/replay_high_water.h
  42. 51 0
      Sunrise/src/middleware/gameplay/external/common_state.cpp
  43. 35 0
      Sunrise/src/middleware/gameplay/external/common_state.h
  44. 77 0
      Sunrise/src/middleware/gameplay/external/external_empty_profile.cpp
  45. 32 0
      Sunrise/src/middleware/gameplay/external/external_empty_profile.h
  46. 563 0
      Sunrise/src/middleware/gameplay/external/external_entity_codec.cpp
  47. 144 0
      Sunrise/src/middleware/gameplay/external/external_entity_codec.h
  48. 35 0
      Sunrise/src/middleware/gameplay/group/member_messages.cpp
  49. 44 0
      Sunrise/src/middleware/gameplay/group/member_messages.h
  50. 166 0
      Sunrise/src/middleware/gameplay/group/migration_messages.cpp
  51. 144 0
      Sunrise/src/middleware/gameplay/group/migration_messages.h
  52. 103 0
      Sunrise/src/middleware/gameplay/group/notice_messages.cpp
  53. 97 0
      Sunrise/src/middleware/gameplay/group/notice_messages.h
  54. 62 0
      Sunrise/src/middleware/gameplay/group/parameter_messages.cpp
  55. 28 1
      Sunrise/src/middleware/gameplay/group/parameter_messages.h
  56. 64 0
      Sunrise/src/middleware/gameplay/peer/connect_messages.cpp
  57. 64 0
      Sunrise/src/middleware/gameplay/peer/connect_messages.h
  58. 33 0
      Sunrise/src/middleware/gameplay/peer/established_packet.cpp
  59. 21 0
      Sunrise/src/middleware/gameplay/peer/established_packet.h
  60. 12 0
      Sunrise/src/server/bap/bap_route.cpp
  61. 194 220
      Sunrise/src/server/bap/encrypted/activity_message/activity_message_route.cpp
  62. 3 2
      Sunrise/src/server/bap/encrypted/activity_message/activity_message_route.h
  63. 13 0
      Sunrise/src/server/bap/encrypted/activity_message/definition.h
  64. 49 0
      Sunrise/src/server/bap/encrypted/activity_message/membership/activity_membership_route.cpp
  65. 11 0
      Sunrise/src/server/bap/encrypted/activity_message/membership/activity_membership_route.h
  66. 361 0
      Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_message_receipts.cpp
  67. 81 0
      Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_message_receipts.h
  68. 20 13
      Sunrise/src/server/bap/encrypted/activity_transaction/activity_transaction_notifications.cpp
  69. 100 10
      Sunrise/src/server/bap/encrypted/bap_connection_publication.cpp
  70. 15 0
      Sunrise/src/server/bap/encrypted/bap_connection_publication.h
  71. 3 3
      Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp
  72. 8 1
      Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp
  73. 2 2
      Sunrise/src/server/bap/encrypted/internal.h
  74. 6 4
      Sunrise/src/server/bap/encrypted/push/activity/activity_arrival.h
  75. 9 9
      Sunrise/src/server/bap/encrypted/push/activity/activity_global_state_push.cpp
  76. 5 4
      Sunrise/src/server/bap/encrypted/push/activity/activity_global_state_push.h
  77. 158 56
      Sunrise/src/server/bap/encrypted/push/activity/activity_keepalive_push.cpp
  78. 7 33
      Sunrise/src/server/bap/encrypted/push/activity/activity_keepalive_seed.cpp
  79. 27 11
      Sunrise/src/server/bap/encrypted/push/activity/activity_membership_push.cpp
  80. 1 0
      Sunrise/src/server/bap/encrypted/push/activity/activity_membership_push.h
  81. 1 1
      Sunrise/src/server/bap/encrypted/push/activity/activity_message_push.cpp
  82. 18 11
      Sunrise/src/server/bap/encrypted/push/activity/activity_roster_push.cpp
  83. 15 5
      Sunrise/src/server/bap/encrypted/push/activity/activity_roster_report.cpp
  84. 114 39
      Sunrise/src/server/bap/encrypted/push/activity/activity_roster_snapshot.cpp
  85. 9 12
      Sunrise/src/server/bap/encrypted/push/activity/internal.h
  86. 5 3
      Sunrise/src/server/bap/encrypted/transactions/definition.h
  87. 138 12
      Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp
  88. 67 19
      Sunrise/src/server/bap/internal.h
  89. 1 1
      Sunrise/src/server/bap/plaintext.cpp
  90. 20 35
      Sunrise/src/server/gameplay/dtls/dtls_host.cpp
  91. 75 71
      Sunrise/src/server/gameplay/gameplay_advertisement.cpp
  92. 36 20
      Sunrise/src/server/gameplay/gameplay_advertisement.h
  93. 15 1
      Sunrise/src/server/gameplay/gameplay_runtime.cpp
  94. 174 27
      Sunrise/src/server/gameplay/group/group_host.cpp
  95. 6 28
      Sunrise/src/server/gameplay/group/group_host.h
  96. 250 141
      Sunrise/src/server/gameplay/group/group_host_sessions.cpp
  97. 53 10
      Sunrise/src/server/gameplay/group/group_host_sessions.h
  98. 194 0
      Sunrise/src/server/gameplay/group/group_migration_receipts.cpp
  99. 19 0
      Sunrise/src/server/gameplay/group/group_migration_receipts.h
  100. 89 0
      Sunrise/src/server/gameplay/peer/external_shadow.cpp

+ 1 - 0
README.md

@@ -103,6 +103,7 @@ Pull Requests are welcome. Please follow these rules:
 - https://github.com/nblockbuster/tachyscope
 - https://github.com/cohaereo/destinydocs
 - https://github.com/MontagueM/DestinyUnpacker
+- https://github.com/nblockbuster/bungie-lua-decompiler
 
 ### Other:
 

+ 183 - 1
Sunrise/Sunrise.vcxproj

@@ -1,4 +1,4 @@
-<?xml version="1.0" encoding="utf-8"?>
+<?xml version="1.0" encoding="utf-8"?>
 <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
   <ItemGroup Label="ProjectConfigurations">
     <ProjectConfiguration Include="Debug|x64">
@@ -237,6 +237,7 @@
     <ClCompile Include="src\client\hooks\network\investment\investment_family5_rearm.cpp" />
     <ClCompile Include="src\client\hooks\package_trust\package_trust_bypass.cpp" />
     <ClCompile Include="src\client\hooks\bitmap\bitmap_hook_lifecycle.cpp" />
+    <ClCompile Include="src\client\hooks\membership_probe\membership_probe.cpp" />
     <ClCompile Include="src\client\hooks\bitmap\bitmap_ref_guard.cpp" />
     <ClCompile Include="src\client\hooks\external_server\external_server_route.cpp" />
     <ClCompile Include="src\client\hooks\external_server\external_server_setopt_guard.cpp" />
@@ -389,6 +390,8 @@
     <ClCompile Include="src\state\activity\transactions\activity_session_release.cpp" />
     <ClCompile Include="src\state\activity\entity_slots\transactions\activity_entity_slot_prepare.cpp" />
     <ClCompile Include="src\state\activity\entity_slots\transactions\activity_entity_slot_commit.cpp" />
+    <ClCompile Include="src\state\gameplay\physics\physics_identity.cpp" />
+    <ClCompile Include="src\state\gameplay\physics\physics_state_runtime.cpp" />
     <ClCompile Include="src\state\activity\bubble_authority\transactions\activity_bubble_authority_grant.cpp" />
     <ClCompile Include="src\state\activity\membership\transactions\activity_membership_prepare_base.cpp" />
     <ClCompile Include="src\state\activity\membership\transactions\activity_membership_authoritative_prepare.cpp" />
@@ -442,6 +445,7 @@
     <ClCompile Include="src\client\content\spawn_sets\spawn_set_catalog_builder.cpp" />
     <ClCompile Include="src\client\content\scenarios\scenario_roster_build.cpp" />
     <ClCompile Include="src\client\content\scenarios\scenario_roster_groups.cpp" />
+    <ClCompile Include="src\client\content\scenarios\scenario_slot_classification.cpp" />
     <ClCompile Include="src\client\content\scenarios\scenario_roster_publish.cpp" />
     <ClCompile Include="src\state\build_data\cache\write\cache_file_writer.cpp" />
     <ClCompile Include="src\state\build_data\cache\write\temporary\temporary_cache_file.cpp" />
@@ -562,6 +566,7 @@
     <ClCompile Include="src\middleware\bap\activity_message\activity_membership_acknowledgement_parser.cpp" />
     <ClCompile Include="src\middleware\bap\activity_message\activity_global_state_encoder.cpp" />
     <ClCompile Include="src\middleware\bap\activity_message\activity_patch_epoch_parser.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_replication_epoch_encoder.cpp" />
     <ClCompile Include="src\middleware\bap\activity_message\activity_membership_member_writer.cpp" />
     <ClCompile Include="src\middleware\bap\activity_message\activity_membership_region_writer.cpp" />
     <ClCompile Include="src\middleware\bap\activity_message\activity_replicate_membership_encoder.cpp" />
@@ -570,6 +575,7 @@
     <ClCompile Include="src\middleware\bap\activity_message\activity_entity_slot_request_parser.cpp" />
     <ClCompile Include="src\middleware\bap\activity_message\activity_entity_slots_decoder.cpp" />
     <ClCompile Include="src\middleware\bap\activity_message\activity_entity_authority_parser.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_incident_encoder.cpp" />
     <ClCompile Include="src\middleware\bap\activity_message\activity_incident_parser.cpp" />
     <ClCompile Include="src\middleware\bap\activity_message\activity_entity_slots_encoder.cpp" />
     <ClCompile Include="src\middleware\bap\certificate.cpp" />
@@ -682,11 +688,106 @@
     <ClCompile Include="src\middleware\gameplay\peer\peer_container.cpp" />
     <ClCompile Include="src\middleware\gameplay\peer\connect_messages.cpp" />
     <ClCompile Include="src\middleware\gameplay\peer\established_packet.cpp" />
+    <ClCompile Include="src\middleware\gameplay\external\common_state.cpp" />
+    <ClCompile Include="src\middleware\gameplay\external\external_entity_codec.cpp" />
+    <ClCompile Include="src\middleware\gameplay\external\external_empty_profile.cpp" />
     <ClCompile Include="src\server\gameplay\gameplay_log.cpp" />
     <ClCompile Include="src\server\gameplay\gameplay_runtime.cpp" />
     <ClCompile Include="src\server\gameplay\endpoint\gameplay_endpoint.cpp" />
     <ClCompile Include="src\server\gameplay\association\association_host.cpp" />
     <ClCompile Include="src\server\gameplay\peer\peer_transport.cpp" />
+    <ClCompile Include="src\server\gameplay\peer\external_shadow.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\backend\navigation_backend.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\backend\navigation_backend_queries.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\backend\physics_backend_contacts.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\backend\physics_backend_determinism.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\backend\physics_backend_intersection.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\backend\physics_backend_lifecycle.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\backend\physics_backend_math.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\backend\physics_backend_motion.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\backend\physics_backend_queries.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\backend\physics_backend_step.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\common\common_sync.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\controller\actor_controller_commands.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\controller\actor_controller_lifecycle.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\controller\actor_controller_path_command.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\controller\actor_controller_path_step.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\controller\actor_controller_snapshot.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\controller\actor_controller_step.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\controller\controller_math.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\host\bubble_host_builtin_controller.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\host\bubble_host_combat.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\host\bubble_host_command_state.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\host\bubble_host_command_validation.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\host\bubble_host_commands.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\host\bubble_host_coordination.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\host\bubble_host_core.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\host\bubble_host_damage.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\host\bubble_host_ledgers.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\host\bubble_host_lifecycle.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\host\bubble_host_navigation.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\host\bubble_host_outputs.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\host\bubble_host_physics.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\host\bubble_host_profiles.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\host\bubble_host_recovery.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\host\bubble_host_registry.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\host\bubble_host_services.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\host\bubble_host_setup.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\host\bubble_host_tick.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\host\bubble_host_transaction.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\host\bubble_host_trigger_setup.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\host\bubble_host_triggers.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\host\fallback_policy.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\host\physics_session.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\host\runtime.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\interest\interest_manager.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\interest\interest_ordering.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\mechanics\combat_kernel.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\mechanics\combat_kernel_damage.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\mechanics\combat_kernel_internal.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\mechanics\combat_kernel_respawn.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\mechanics\combat_kernel_snapshot.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\mechanics\command_deduplicator.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\mechanics\objective_ledger.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\mechanics\objective_ledger_snapshot.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\mechanics\participant_credit_ledger.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\mechanics\participant_credit_snapshot.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\mechanics\tick_timer.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\mechanics\trigger_system.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\mechanics\trigger_system_snapshot.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\authority\motion_validator.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\persistence\checkpoint_store.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\persistence\checkpoint_builder.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\persistence\command_journal.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\persistence\checkpoint_hash.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\persistence\checkpoint_hash_combat.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\persistence\checkpoint_hash_mechanics.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\persistence\checkpoint_validation.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\persistence\policy_blob.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\replication\replication_outcomes.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\replication\external_plan.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\replication\replication_planner.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\replication\world_coordinator.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\replication\world_coordinator_frames.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\replication\world_coordinator_peers.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\replication\world_projection.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\world\activity_policy.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\world\actor_store.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\world\actor_store_pose.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\world\actor_store_restore.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\world\actor_store_generations.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\world\authority_manager.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\world\command_queue.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\world\host_command.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\world\world_runner.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\world\world_runner_acl.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\world\world_runner_commands.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\world\world_runner_events.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\world\world_runner_executor.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\world\world_runner_hash.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\world\world_runner_restore.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\world\world_runner_transaction.cpp" />
+    <ClCompile Include="src\server\gameplay\physics\world\world_types.cpp" />
     <ClCompile Include="src\middleware\gameplay\descriptor\join_descriptor.cpp" />
     <ClCompile Include="src\server\gameplay\gameplay_advertisement.cpp" />
     <ClCompile Include="src\middleware\web_service\messages\opcode901\opcode901_codec.cpp" />
@@ -704,7 +805,18 @@
     <ClCompile Include="src\middleware\gameplay\dtls\dtls_messages.cpp" />
     <ClCompile Include="src\middleware\gameplay\dtls\association_keys.cpp" />
     <ClCompile Include="src\middleware\gameplay\dtls\record.cpp" />
+    <ClCompile Include="src\middleware\gameplay\dtls\replay_high_water.cpp" />
     <ClCompile Include="src\server\gameplay\dtls\dtls_host.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_peer_ledger_parser.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_telemetry_parser.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_start_activity_parser.cpp" />
+    <ClCompile Include="src\middleware\bap\activity_message\activity_sense_update_parser.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\activity_message\receipts\activity_message_receipts.cpp" />
+    <ClCompile Include="src\state\activity\receipts\activity_receipts.cpp" />
+    <ClCompile Include="src\core\settings\server\activation\activation_settings_parser.cpp" />
+    <ClCompile Include="src\middleware\gameplay\group\migration_messages.cpp" />
+    <ClCompile Include="src\middleware\gameplay\group\notice_messages.cpp" />
+    <ClCompile Include="src\server\gameplay\group\group_migration_receipts.cpp" />
   </ItemGroup>
   <ItemGroup Condition="'$(SunriseRunClangTidy)'=='true'">
     <ClCompile Remove="vendor\detours\detours.cpp" />
@@ -1030,6 +1142,7 @@
     <ClInclude Include="src\middleware\bap\activity_message\activity_state_refresh_parser.h" />
     <ClInclude Include="src\middleware\bap\activity_message\activity_membership_acknowledgement_parser.h" />
     <ClInclude Include="src\middleware\bap\activity_message\activity_patch_epoch_parser.h" />
+    <ClInclude Include="src\middleware\bap\activity_message\activity_replication_epoch_encoder.h" />
     <ClInclude Include="src\middleware\bap\activity_message\replicate_membership.h" />
     <ClInclude Include="src\middleware\bap\activity_message\activity_message_notification_encoder.h" />
     <ClInclude Include="src\middleware\bap\activity_message\activity_join_result_encoder.h" />
@@ -1118,6 +1231,7 @@
     <ClInclude Include="src\client\hooks\assert_handler\assert_handler_lifecycle.h" />
     <ClInclude Include="src\client\hooks\bitmap\bitmap_ref_guard.h" />
     <ClInclude Include="src\client\hooks\bitmap\bitmap_hook_lifecycle.h" />
+    <ClInclude Include="src\client\hooks\membership_probe\membership_probe.h" />
     <ClInclude Include="src\client\hooks\bootflow\internal.h" />
     <ClInclude Include="src\client\hooks\bootflow\bootflow_hook_lifecycle.h" />
     <ClInclude Include="src\client\hooks\bootflow\spawn\spawn_gate_record_dump.h" />
@@ -1204,12 +1318,68 @@
     <ClInclude Include="src\middleware\gameplay\peer\peer_container.h" />
     <ClInclude Include="src\middleware\gameplay\peer\connect_messages.h" />
     <ClInclude Include="src\middleware\gameplay\peer\established_packet.h" />
+    <ClInclude Include="src\middleware\gameplay\external\common_state.h" />
+    <ClInclude Include="src\middleware\gameplay\external\external_entity_codec.h" />
+    <ClInclude Include="src\middleware\gameplay\external\external_empty_profile.h" />
     <ClInclude Include="src\state\gameplay\definition.h" />
+    <ClInclude Include="src\state\gameplay\external\definition.h" />
+    <ClInclude Include="src\state\gameplay\physics\definition.h" />
+    <ClInclude Include="src\state\gameplay\physics\physics_identity.h" />
+    <ClInclude Include="src\state\gameplay\physics\runtime.h" />
     <ClInclude Include="src\server\gameplay\gameplay_log.h" />
     <ClInclude Include="src\server\gameplay\gameplay_runtime.h" />
     <ClInclude Include="src\server\gameplay\endpoint\gameplay_endpoint.h" />
     <ClInclude Include="src\server\gameplay\association\association_host.h" />
     <ClInclude Include="src\server\gameplay\peer\peer_transport.h" />
+    <ClInclude Include="src\server\gameplay\peer\external_shadow.h" />
+    <ClInclude Include="src\server\gameplay\physics\backend\navigation_backend.h" />
+    <ClInclude Include="src\server\gameplay\physics\backend\navigation_backend_internal.h" />
+    <ClInclude Include="src\server\gameplay\physics\backend\physics_backend.h" />
+    <ClInclude Include="src\server\gameplay\physics\backend\physics_backend_internal.h" />
+    <ClInclude Include="src\server\gameplay\physics\backend\types.h" />
+    <ClInclude Include="src\server\gameplay\physics\common\common_sync.h" />
+    <ClInclude Include="src\server\gameplay\physics\controller\actor_controller_service.h" />
+    <ClInclude Include="src\server\gameplay\physics\controller\controller_internal.h" />
+    <ClInclude Include="src\server\gameplay\physics\controller\controller_types.h" />
+    <ClInclude Include="src\server\gameplay\physics\host\bubble_host.h" />
+    <ClInclude Include="src\server\gameplay\physics\host\command_effects.h" />
+    <ClInclude Include="src\server\gameplay\physics\host\command_validation_state.h" />
+    <ClInclude Include="src\server\gameplay\physics\host\fallback_policy.h" />
+    <ClInclude Include="src\server\gameplay\physics\host\internal.h" />
+    <ClInclude Include="src\server\gameplay\physics\host\physics_session.h" />
+    <ClInclude Include="src\server\gameplay\physics\host\runtime.h" />
+    <ClInclude Include="src\server\gameplay\physics\host\tick_runtime.h" />
+    <ClInclude Include="src\server\gameplay\physics\host\types.h" />
+    <ClInclude Include="src\server\gameplay\physics\interest\interest_manager.h" />
+    <ClInclude Include="src\server\gameplay\physics\mechanics\combat_kernel.h" />
+    <ClInclude Include="src\server\gameplay\physics\mechanics\combat_kernel_internal.h" />
+    <ClInclude Include="src\server\gameplay\physics\mechanics\command_deduplicator.h" />
+    <ClInclude Include="src\server\gameplay\physics\mechanics\host_mechanics.h" />
+    <ClInclude Include="src\server\gameplay\physics\mechanics\mechanics_types.h" />
+    <ClInclude Include="src\server\gameplay\physics\mechanics\objective_ledger.h" />
+    <ClInclude Include="src\server\gameplay\physics\mechanics\participant_credit_ledger.h" />
+    <ClInclude Include="src\server\gameplay\physics\mechanics\tick_timer.h" />
+    <ClInclude Include="src\server\gameplay\physics\mechanics\trigger_system.h" />
+    <ClInclude Include="src\server\gameplay\physics\authority\motion_validator.h" />
+    <ClInclude Include="src\server\gameplay\physics\persistence\checkpoint.h" />
+    <ClInclude Include="src\server\gameplay\physics\persistence\checkpoint_builder.h" />
+    <ClInclude Include="src\server\gameplay\physics\persistence\command_journal.h" />
+    <ClInclude Include="src\server\gameplay\physics\persistence\checkpoint_hash_internal.h" />
+    <ClInclude Include="src\server\gameplay\physics\replication\definition.h" />
+    <ClInclude Include="src\server\gameplay\physics\replication\external_plan.h" />
+    <ClInclude Include="src\server\gameplay\physics\replication\internal.h" />
+    <ClInclude Include="src\server\gameplay\physics\replication\runtime.h" />
+    <ClInclude Include="src\server\gameplay\physics\replication\world_coordinator.h" />
+    <ClInclude Include="src\server\gameplay\physics\replication\world_coordinator_internal.h" />
+    <ClInclude Include="src\server\gameplay\physics\replication\world_projection.h" />
+    <ClInclude Include="src\server\gameplay\physics\world\activity_policy.h" />
+    <ClInclude Include="src\server\gameplay\physics\world\actor_store.h" />
+    <ClInclude Include="src\server\gameplay\physics\world\authority_manager.h" />
+    <ClInclude Include="src\server\gameplay\physics\world\command_queue.h" />
+    <ClInclude Include="src\server\gameplay\physics\world\host_command.h" />
+    <ClInclude Include="src\server\gameplay\physics\world\host_command_executor.h" />
+    <ClInclude Include="src\server\gameplay\physics\world\world_runner.h" />
+    <ClInclude Include="src\server\gameplay\physics\world\world_types.h" />
     <ClInclude Include="src\middleware\gameplay\descriptor\join_descriptor.h" />
     <ClInclude Include="src\server\gameplay\gameplay_advertisement.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode901\opcode901_codec.h" />
@@ -1227,7 +1397,19 @@
     <ClInclude Include="src\middleware\gameplay\dtls\dtls_messages.h" />
     <ClInclude Include="src\middleware\gameplay\dtls\association_keys.h" />
     <ClInclude Include="src\middleware\gameplay\dtls\record.h" />
+    <ClInclude Include="src\middleware\gameplay\dtls\replay_high_water.h" />
     <ClInclude Include="src\server\gameplay\dtls\dtls_host.h" />
+    <ClInclude Include="src\middleware\bap\activity_message\peer_ledger.h" />
+    <ClInclude Include="src\middleware\bap\activity_message\telemetry.h" />
+    <ClInclude Include="src\middleware\bap\activity_message\start_activity.h" />
+    <ClInclude Include="src\middleware\bap\activity_message\sense_update.h" />
+    <ClInclude Include="src\server\bap\encrypted\activity_message\receipts\activity_message_receipts.h" />
+    <ClInclude Include="src\state\activity\receipts\definition.h" />
+    <ClInclude Include="src\state\activity\receipts\activity_receipts.h" />
+    <ClInclude Include="src\core\settings\server\activation\definition.h" />
+    <ClInclude Include="src\middleware\gameplay\group\migration_messages.h" />
+    <ClInclude Include="src\middleware\gameplay\group\notice_messages.h" />
+    <ClInclude Include="src\server\gameplay\group\group_migration_receipts.h" />
   </ItemGroup>
   <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
 </Project>

+ 8 - 1
Sunrise/resources/default_settings.json

@@ -45,7 +45,14 @@
       "advertised_address": "127.0.0.1",
       "transport_address": "127.0.0.1",
       "port": 30976,
-      "server_reserve_count": 256
+      "server_reserve_count": 256,
+      "client_join_grant_count": 8192
+    },
+    "activation": {
+      "default_client_activation": true,
+      "activity_compatibility_mirror": true,
+      "gameplay_external_body": false,
+      "server_default_entity": false
     },
     "entitlements": [
       { "name": "1085660", "owned": "handle" },

+ 71 - 10
Sunrise/src/client/content/scenarios/internal.h

@@ -8,6 +8,7 @@
 
 #include "../../../middleware/content/packages/reader/reader.h"
 #include "../../../middleware/content/packages/tables/roster_intersection.h"
+#include "../../../middleware/content/packages/tables/slot_descriptor_reader.h"
 #include "../../../state/build_data/scenarios/definition.h"
 
 namespace sunrise::client::content::scenarios {
@@ -22,8 +23,6 @@ namespace reader = middleware::content::packages::reader;
 inline constexpr std::size_t kObjectMemoCapacity = 16'384;
 /** Memo value for an object that declares no roster slot type. */
 inline constexpr std::uint16_t kNotARosterGroup = 0xFFFF;
-/** Slot types run from 1 through the widest the packages declare. */
-inline constexpr std::size_t kSlotTypeSpan = layouts::kMaximumSlotType + 1;
 
 /** One memo row: a placed-object tag and the roster group it produced. */
 struct ObjectMemo {
@@ -31,6 +30,13 @@ struct ObjectMemo {
     std::uint16_t group{kNotARosterGroup};
 };
 
+/** One slot, as its own descriptor declares it. */
+struct SlotRecord {
+    std::uint16_t index{};
+    std::uint8_t type{};
+    std::uint8_t flags{};
+};
+
 /** Fixed working storage for one roster pass, kept off the caller stack. */
 struct RosterStorage {
     std::vector<std::byte> scenario;
@@ -41,10 +47,12 @@ struct RosterStorage {
     std::array<ObjectMemo, kObjectMemoCapacity> memo{};
     std::array<layouts::RosterGroup, layouts::kRosterGroupCapacity> groups{};
     std::size_t groupCount{};
-    /** Slot flags per slot type, read from a group object's descriptor chain. */
-    std::array<std::uint8_t, kSlotTypeSpan> slotFlags{};
-    std::array<std::uint8_t, kSlotTypeSpan> slotFlagsKnown{};
-    /** Group objects whose descriptor chain did not give every slot type they declare. */
+    /** Descriptors found on the object being resolved, one per slot it can publish. */
+    std::array<SlotRecord, layouts::kRosterSlotCapacity> slots{};
+    std::size_t slotCount{};
+    /** Set when the object declared more descriptors than storage holds, which refuses it. */
+    bool slotsOverflowed{};
+    /** Group objects whose descriptor walk yielded no publishable slot. */
     std::size_t unresolvedGroups{};
     /** Destinations walked so far. The walk resumes here on the next call. */
     std::size_t cursor{};
@@ -134,6 +142,50 @@ void compact_rows(Storage& storage) noexcept;
  */
 void rearm_resolve(Storage& storage) noexcept;
 
+/**
+ * Reduces one descriptor's two schemas to the slot flag bits the wire body carries.
+ * A slot claiming an auth schema is one this host holds authority over, so its object reaches the
+ * world through the bubble grant. A slot claiming neither is placed from content alone.
+ * @param authSchema Auth schema the descriptor declares, or the absent sentinel.
+ * @param senseSchema Sense schema the descriptor declares, or the absent sentinel.
+ * @return The flag bits for that slot type.
+ */
+[[nodiscard]] constexpr std::uint8_t slot_flags(std::uint32_t authSchema,
+                                                std::uint32_t senseSchema) noexcept {
+    namespace wire = middleware::content::packages::tables;
+    std::uint8_t flags = 0;
+    if (authSchema != wire::kAbsentSchema) {
+        flags |= layouts::kSlotAuthFlag;
+    }
+    if (senseSchema != wire::kAbsentSchema) {
+        flags |= layouts::kSlotSenseFlag;
+    }
+    return flags;
+}
+
+/**
+ * Records one descriptor as a slot of the object being resolved.
+ * A repeated index keeps the first descriptor, because the blobs hosting two sibling slots offer
+ * a second structurally valid hit for the same position.
+ * @param storage Working storage receiving the slot.
+ * @param descriptor Descriptor read from a placed-object blob.
+ */
+void record_slot(RosterStorage& storage,
+                 const middleware::content::packages::tables::SlotDescriptor& descriptor) noexcept;
+
+/**
+ * Fills one candidate group from the descriptors the walk found, in slot-index order.
+ * One slot is one descriptor, indexed by the descriptor rather than by its position.
+ * A group short of one descriptor is dropped, never published short.
+ * @param storage Working storage holding the descriptors.
+ * @param declaredSlotCount Slots the object's own slot array declares.
+ * @param group Receives the slot types, flags and indices.
+ * @return True when every declared slot has a descriptor and nothing overflowed.
+ */
+[[nodiscard]] bool fill_slots(RosterStorage& storage,
+                              std::size_t declaredSlotCount,
+                              layouts::RosterGroup& group) noexcept;
+
 /** One candidate group of one destination, with what its publish order is sorted on. */
 struct Candidate {
     std::uint16_t group{};
@@ -143,6 +195,12 @@ struct Candidate {
     bool primaryRegistry{};
 };
 
+/** One group a destination publishes per bubble, and the bubbles it is published in. */
+struct BubbleCandidate {
+    std::uint16_t group{};
+    std::uint64_t mask{};
+};
+
 /** @return True when both groups carry the same registry key and full wire slot layout. */
 [[nodiscard]] constexpr bool same_group_layout(const layouts::RosterGroup& left,
                                                const layouts::RosterGroup& right) noexcept {
@@ -151,7 +209,8 @@ struct Candidate {
     }
     for (std::size_t slot = 0; slot < left.slotCount; ++slot) {
         if (left.slotTypes[slot] != right.slotTypes[slot]
-            || left.slotFlags[slot] != right.slotFlags[slot]) {
+            || left.slotFlags[slot] != right.slotFlags[slot]
+            || left.slotIndices[slot] != right.slotIndices[slot]) {
             return false;
         }
     }
@@ -166,11 +225,13 @@ struct Walk {
 };
 
 /**
- * Keeps the candidates whose key is in every slice set and writes them into the destination row.
+ * Splits the candidates between the destination row's two lists.
+ * A key in every slice set goes in the top-level list. A key in some and not all goes in the
+ * per-bubble list with the bubbles that hold it. A key in none is dropped.
  * @param walk Accumulator for one destination.
- * @param row Destination row receiving its group indices.
+ * @param row Destination row receiving both sets of group indices.
  */
-void publish_safe(Walk& walk, layouts::Definition& row) noexcept;
+void publish_groups(Walk& walk, layouts::Definition& row) noexcept;
 
 /**
  * Finds the roster group of one placed object, reading it only the first time it is seen.

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

@@ -199,6 +199,7 @@ bool build_rosters(const reader::Source& source,
         ++storage.cursor;
         row.rosterGroupCount = 0;
         row.rosterGroups = {};
+        row.bubbleGroupCount = 0;
         ++storage.reads;
         if (!reader::read_tag(source, scratch, row.tag, storage.scenario)) {
             continue;
@@ -207,7 +208,7 @@ bool build_rosters(const reader::Source& source,
         if (!walk_destination(source, scratch, storage, walk)) {
             continue;
         }
-        publish_safe(walk, row);
+        publish_groups(walk, row);
     }
     return storage.cursor >= rows.size();
 }

+ 30 - 77
Sunrise/src/client/content/scenarios/scenario_roster_groups.cpp

@@ -12,25 +12,13 @@ namespace tables = middleware::content::packages::tables;
 constexpr std::size_t kChainDepthLimit = 8;
 
 /**
- * Records one descriptor's schemas against its slot type.
+ * Records one descriptor as a slot of the object being resolved.
  * @param context Roster storage.
  * @param descriptor Descriptor read from a placed-object blob.
- * @return Always true, because a descriptor of an unknown type is ordinary.
+ * @return Always true, because a descriptor this pass cannot use is ordinary.
  */
-bool record_flags(void* context, const tables::SlotDescriptor& descriptor) noexcept {
-    auto& storage = *static_cast<RosterStorage*>(context);
-    if (descriptor.slotType >= kSlotTypeSpan) {
-        return true;
-    }
-    std::uint8_t flags = 0;
-    if (descriptor.authSchema != tables::kAbsentSchema) {
-        flags |= layouts::kSlotAuthFlag;
-    }
-    if (descriptor.senseSchema != tables::kAbsentSchema) {
-        flags |= layouts::kSlotSenseFlag;
-    }
-    storage.slotFlags[descriptor.slotType] = flags;
-    storage.slotFlagsKnown[descriptor.slotType] = 1;
+bool collect_slot(void* context, const tables::SlotDescriptor& descriptor) noexcept {
+    record_slot(*static_cast<RosterStorage*>(context), descriptor);
     return true;
 }
 
@@ -56,7 +44,7 @@ void follow_handle(const reader::Source& source,
         }
         if (classId == tables::kPlacedObjectClass) {
             (void)tables::visit_slot_descriptors(
-                storage.chain, tag, registryKey, &record_flags, &storage);
+                storage.chain, tag, registryKey, &collect_slot, &storage);
             return;
         }
         std::uint32_t next = 0;
@@ -67,32 +55,23 @@ void follow_handle(const reader::Source& source,
     }
 }
 
-/** @param group Candidate group. @return True when every slot type is known. */
-[[nodiscard]] bool flags_complete(const RosterStorage& storage,
-                                  const layouts::RosterGroup& group) noexcept {
-    for (std::size_t slot = 0; slot < group.slotCount; ++slot) {
-        if (storage.slotFlagsKnown[group.slotTypes[slot]] == 0) {
-            return false;
-        }
-    }
-    return true;
-}
-
 /**
- * Reads the slot flags one group object declares, stopping once every type is known.
+ * Collects every descriptor one group object declares, over all of its per-bubble sub-blocks.
+ * Every leaf is followed: one leaf is one slot, so stopping early would drop slots rather than
+ * merely leave a slot type unresolved.
  * @param source Package directory and borrowed block keys.
  * @param scratch Lock-owned block storage.
- * @param storage Working storage for this pass.
+ * @param storage Working storage receiving the descriptors.
  * @param objectBlob Whole placed-object bytes.
- * @param group Candidate group whose slot types are already filled.
+ * @param registryKey Registry key the descriptors must name.
  */
-void resolve_flags(const reader::Source& source,
-                   reader::Scratch& scratch,
-                   RosterStorage& storage,
-                   std::span<const std::byte> objectBlob,
-                   const layouts::RosterGroup& group) noexcept {
+void collect_descriptors(const reader::Source& source,
+                         reader::Scratch& scratch,
+                         RosterStorage& storage,
+                         std::span<const std::byte> objectBlob,
+                         std::uint32_t registryKey) noexcept {
     tables::Array bubbles{};
-    if (flags_complete(storage, group) || !tables::object_bubbles(objectBlob, bubbles)) {
+    if (!tables::object_bubbles(objectBlob, bubbles)) {
         return;
     }
     for (std::uint64_t index = 0; index < bubbles.count; ++index) {
@@ -105,37 +84,9 @@ void resolve_flags(const reader::Source& source,
             if (!tables::object_placed_handle_at(objectBlob, bubble, slot, handle)) {
                 return;
             }
-            follow_handle(source, scratch, storage, handle, group.registryKey);
-            if (flags_complete(storage, group)) {
-                return;
-            }
-        }
-    }
-}
-
-/**
- * Fills one candidate group's slot types from the object's own slot array.
- * @param objectBlob Whole placed-object bytes.
- * @param group Receives the key and slot types.
- * @return True when the object declares a usable slot array.
- */
-[[nodiscard]] bool fill_slots(std::span<const std::byte> objectBlob,
-                              layouts::RosterGroup& group) noexcept {
-    tables::Array slots{};
-    if (!tables::object_slots(objectBlob, slots) || slots.count == 0
-        || slots.count > layouts::kRosterSlotCapacity) {
-        return false;
-    }
-    for (std::uint64_t index = 0; index < slots.count; ++index) {
-        tables::Slot slot{};
-        if (!tables::object_slot_at(objectBlob, slots, index, slot) || slot.type == 0
-            || slot.type > layouts::kMaximumSlotType) {
-            return false;
+            follow_handle(source, scratch, storage, handle, registryKey);
         }
-        group.slotTypes[index] = static_cast<std::uint8_t>(slot.type);
     }
-    group.slotCount = static_cast<std::uint16_t>(slots.count);
-    return true;
 }
 
 /** @param storage Working storage. @param tag Object tag. @return Its memo slot, or capacity. */
@@ -178,27 +129,29 @@ bool resolve_object(const reader::Source& source,
     storage.memo[slot].tag = objectTag;
     storage.memo[slot].group = kNotARosterGroup;
     ++storage.reads;
-    if (!reader::read_tag(source, scratch, objectTag, storage.object)
-        || !tables::carries_roster_slot(storage.object)) {
+    if (!reader::read_tag(source, scratch, objectTag, storage.object)) {
         return true;
     }
 
     layouts::RosterGroup candidate{};
+    tables::Array declared{};
     if (!tables::object_key(storage.object, candidate.registryKey) || candidate.registryKey == 0
-        || !fill_slots(storage.object, candidate)) {
+        || !tables::carries_roster_slot(storage.object)
+        || !tables::object_slots(storage.object, declared) || declared.count == 0
+        || declared.count > layouts::kRosterSlotCapacity) {
         return true;
     }
-    candidate.objectTag = objectTag;
-    resolve_flags(source, scratch, storage, storage.object, candidate);
-    if (!flags_complete(storage, candidate)) {
-        // A slot whose flags are unknown would be encoded with the wrong reset bits, and phase 2
-        // has no resync point, so the whole group is dropped instead.
+    storage.slotCount = 0;
+    storage.slotsOverflowed = false;
+    collect_descriptors(source, scratch, storage, storage.object, candidate.registryKey);
+    if (!fill_slots(storage, declared.count, candidate)) {
+        // The client registers a record per slot the object declares and refuses its whole apply
+        // while any record in the current bubble is unseeded, so a group missing one descriptor is
+        // dropped rather than published short.
         ++storage.unresolvedGroups;
         return true;
     }
-    for (std::size_t index = 0; index < candidate.slotCount; ++index) {
-        candidate.slotFlags[index] = storage.slotFlags[candidate.slotTypes[index]];
-    }
+    candidate.objectTag = objectTag;
     // One key may carry different layouts in different activities, so only exact layouts reuse.
     for (std::size_t index = 0; index < storage.groupCount; ++index) {
         if (same_group_layout(storage.groups[index], candidate)) {

+ 50 - 7
Sunrise/src/client/content/scenarios/scenario_roster_publish.cpp

@@ -27,16 +27,12 @@ namespace tables = middleware::content::packages::tables;
     return left.key < right.key;
 }
 
-} // namespace
-
 /**
- * Keeps the candidates whose key is in every slice set and writes them into the destination row.
+ * Writes the top-level half: the candidates whose key is in every slice set.
  * @param walk Accumulator for one destination.
  * @param row Destination row receiving its group indices.
  */
-void publish_safe(Walk& walk, layouts::Definition& row) noexcept {
-    row.rosterGroupCount = 0;
-    row.rosterGroups = {};
+void publish_top_level(Walk& walk, layouts::Definition& row) noexcept {
     std::array<std::uint32_t, tables::kRosterKeyCapacity> safe{};
     std::size_t safeCount = 0;
     if (!tables::safe_roster_keys(walk.intersection, safe, safeCount) || safeCount == 0) {
@@ -47,7 +43,8 @@ void publish_safe(Walk& walk, layouts::Definition& row) noexcept {
     for (std::size_t index = 0; index < walk.candidateCount; ++index) {
         const Candidate& candidate = walk.candidates[index];
         const auto last = safe.begin() + static_cast<std::ptrdiff_t>(safeCount);
-        if (std::find(safe.begin(), last, candidate.key) != last && keptCount < kept.size()) {
+        const bool keep = std::find(safe.begin(), last, candidate.key) != last;
+        if (keep && keptCount < kept.size()) {
             kept[keptCount++] = candidate;
         }
     }
@@ -69,4 +66,50 @@ void publish_safe(Walk& walk, layouts::Definition& row) noexcept {
     row.rosterGroupCount = static_cast<std::uint8_t>(published);
 }
 
+/**
+ * Writes the per-bubble half: the candidates whose key is in some slice sets and not all.
+ * @param walk Accumulator for one destination.
+ * @param row Destination row receiving its per-bubble group indices and their bubbles.
+ */
+void publish_per_bubble(Walk& walk, layouts::Definition& row) noexcept {
+    std::array<std::uint32_t, tables::kRosterKeyCapacity> keys{};
+    std::array<std::uint64_t, tables::kRosterKeyCapacity> masks{};
+    std::size_t partialCount = 0;
+    if (!tables::partial_roster_keys(walk.intersection, keys, masks, partialCount)
+        || partialCount == 0) {
+        return;
+    }
+    std::size_t published = 0;
+    for (std::size_t index = 0;
+         index < walk.candidateCount && published < layouts::kDestinationBubbleGroupCapacity;
+         ++index) {
+        const Candidate& candidate = walk.candidates[index];
+        for (std::size_t partial = 0; partial < partialCount; ++partial) {
+            if (keys[partial] != candidate.key) {
+                continue;
+            }
+            row.bubbleGroups[published] = candidate.group;
+            row.bubbleGroupMasks[published] = masks[partial];
+            ++published;
+            break;
+        }
+    }
+    row.bubbleGroupCount = static_cast<std::uint8_t>(published);
+}
+
+} // namespace
+
+/** Splits the candidates between the destination row's two lists. */
+void publish_groups(Walk& walk, layouts::Definition& row) noexcept {
+    row.rosterGroupCount = 0;
+    row.rosterGroups = {};
+    row.bubbleGroupCount = 0;
+    row.bubbleGroups = {};
+    row.bubbleGroupMasks = {};
+    publish_top_level(walk, row);
+    // 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.
+    publish_per_bubble(walk, row);
+}
+
 } // namespace sunrise::client::content::scenarios

+ 66 - 0
Sunrise/src/client/content/scenarios/scenario_slot_classification.cpp

@@ -0,0 +1,66 @@
+/**
+ * Turns the descriptors one placed object declares into the slots activity message 5 publishes.
+ * Nothing here reads a package: the walk that follows a descriptor chain owns that, and this owns
+ * what the descriptors already say. Keeping the two apart is what lets the classification be
+ * checked without an installed content tree.
+ */
+
+#include <algorithm>
+
+#include "internal.h"
+
+namespace sunrise::client::content::scenarios {
+namespace {
+
+namespace tables = middleware::content::packages::tables;
+
+} // namespace
+
+/** Records one descriptor as a slot of the object being resolved. */
+void record_slot(RosterStorage& storage, const tables::SlotDescriptor& descriptor) noexcept {
+    if (descriptor.slotType == 0 || descriptor.slotType > layouts::kMaximumSlotType
+        || descriptor.slotIndex >= layouts::kRosterSlotCapacity) {
+        return;
+    }
+    for (std::size_t slot = 0; slot < storage.slotCount; ++slot) {
+        if (storage.slots[slot].index == descriptor.slotIndex) {
+            return;
+        }
+    }
+    if (storage.slotCount == storage.slots.size()) {
+        storage.slotsOverflowed = true;
+        return;
+    }
+    storage.slots[storage.slotCount] = {descriptor.slotIndex,
+                                        static_cast<std::uint8_t>(descriptor.slotType),
+                                        slot_flags(descriptor.authSchema, descriptor.senseSchema)};
+    ++storage.slotCount;
+}
+
+/** Fills one candidate group from the descriptors the walk found, in slot-index order. */
+bool fill_slots(RosterStorage& storage,
+                std::size_t declaredSlotCount,
+                layouts::RosterGroup& group) noexcept {
+    // A short group is refused, not trimmed. The client registers a record per declared slot and
+    // holds its whole apply back while any record in the current bubble is unseeded, so publishing
+    // a group this host cannot seed in full stalls that bubble with nothing reported.
+    if (storage.slotsOverflowed || storage.slotCount == 0
+        || storage.slotCount != declaredSlotCount) {
+        return false;
+    }
+    const auto last = storage.slots.begin() + static_cast<std::ptrdiff_t>(storage.slotCount);
+    // The client reads each block independently, but ascending order is what the captured bodies
+    // carry and it keeps a body diffable against them.
+    std::sort(storage.slots.begin(), last, [](const SlotRecord& first, const SlotRecord& second) {
+        return first.index < second.index;
+    });
+    for (std::size_t slot = 0; slot < storage.slotCount; ++slot) {
+        group.slotTypes[slot] = storage.slots[slot].type;
+        group.slotFlags[slot] = storage.slots[slot].flags;
+        group.slotIndices[slot] = storage.slots[slot].index;
+    }
+    group.slotCount = static_cast<std::uint16_t>(storage.slotCount);
+    return true;
+}
+
+} // namespace sunrise::client::content::scenarios

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

@@ -0,0 +1,316 @@
+/**
+ * A read-only probe on the client's activity msg 12 handler.
+ * Two runs of a public-target membership body ended on a black screen, and the two explanations
+ * left standing contradict each other: the client either processed the body and its world
+ * container still failed to bind, or it never processed it at all. The status word the handler
+ * writes separates them, and nothing else in reach reports it.
+ */
+
+#include "membership_probe.h"
+
+#include <Windows.h>
+
+#include <array>
+#include <atomic>
+#include <bit>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#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::hooks::membership_probe {
+namespace {
+
+using patterns::scan_main_image_unique;
+using patterns::signature;
+using patterns::signature_length;
+
+/**
+ * `ActivityMsg12_ReplicateMembership_Recv` @ `0x7FF7421B7240`.
+ * Its first argument is the ActivityClient. It commits the membership block at `+27696`, then
+ * sets bit `0x100` of the status word at `+304` unconditionally, before returning 1.
+ */
+constexpr std::string_view kReceiveText = "40 55 53 41 56 41 57 48 8D AC 24 ? ? ? ? B8 C8 96 05 00";
+constexpr auto kReceive = signature<signature_length(kReceiveText)>(kReceiveText);
+
+/** Status word the handler writes. `RE/25 "The +304 status word, mapped"` owns its bits. */
+constexpr std::size_t kStatusWordOffset = 304;
+/** Membership header. Its leading qword is the member key the client matches itself by. */
+constexpr std::size_t kMembershipHeaderOffset = 27696;
+/**
+ * The two slot axes and the printable label the constructor builds from them.
+ * Axis 1 is PRIVATE or PUBLIC, axis 2 is CURRENT or TARGET, and the slot map indexes them as
+ * `axis1 + 2 * axis2`. A TARGET slot is index 2 or 3, which the public-first current-slot pick
+ * never reads -- so the label says outright which readers can ever see this client.
+ */
+constexpr std::size_t kSlotAxisOneOffset = 24;
+constexpr std::size_t kSlotAxisTwoOffset = 28;
+constexpr std::size_t kSlotLabelOffset = 32;
+/** The label is NUL-terminated; "PRIVATE CURRENT" is the longest form. */
+constexpr std::size_t kSlotLabelCapacity = 32;
+/**
+ * Established session id, returned by the client's own vtable slot 1.
+ * The rebind skips a slot outright when this is zero, with no log and no other symptom. Msg 4's
+ * accept arm is its only writer, from `join_request` field 1 inside the `join_result` we send.
+ */
+constexpr std::size_t kEstablishedSessionOffset = 16352;
+/**
+ * Slot record this client belongs to. The map's target, laid out as `mgr + 720 * index + 10752`,
+ * so the difference between two clients' values divided by 720 is their slot-record index delta.
+ */
+constexpr std::size_t kSlotRecordOffset = 27672;
+/** The slot's roster container. The public-first current-slot pick requires it non-null. */
+constexpr std::size_t kRosterContainerOffset = 27680;
+/**
+ * Sticky bind receipt. Zero from construction, one the moment a world container binds, and back
+ * to zero only on a session reset. The grant dirty byte is cleared within a tick, so a sample can
+ * miss it; this one cannot be missed.
+ */
+constexpr std::size_t kBindReceiptOffset = 27689;
+/** Bit the handler sets, which the world-container bind and the player watcher both read. */
+constexpr std::uint16_t kMembershipFlag = 0x100;
+/** Entity-slot grant the client has taken but not yet applied to a view. 8192 bits. */
+constexpr std::size_t kPendingMaskOffset = 392856;
+constexpr std::size_t kPendingMaskSize = 1024;
+/** Set by a world-container bind to re-post a grant that arrived before the bind. */
+constexpr std::size_t kGrantDirtyOffset = 393880;
+/** How long after a message a client is still sampled. The bind lands well inside this. */
+constexpr std::uint64_t kSampleWindowMs = 30'000;
+/** Sampling cadence. The bind is a tick, not a timer, so this only has to be finer than the wait.
+ */
+constexpr std::uint64_t kSampleIntervalMs = 2'000;
+/** Clients the probe tracks at once. One private and one public target is the live shape. */
+constexpr std::size_t kTrackedCapacity = 4;
+
+using Receive = char(__fastcall*)(std::int64_t, std::int64_t, int);
+
+/** One ActivityClient seen carrying a membership body, sampled until its window closes. */
+struct Tracked {
+    std::int64_t client{};
+    std::uint64_t expiresAt{};
+    std::uint64_t nextSample{};
+    bool occupied{};
+};
+
+hooking::detour::Handle g_handle{};
+std::atomic_bool g_installed{false};
+/** The detour runs on the client's network thread and the sampler on the callback pump. */
+SRWLOCK g_lock{SRWLOCK_INIT};
+std::array<Tracked, kTrackedCapacity> g_tracked{};
+
+/**
+ * Reports one msg 12 the client actually decoded.
+ * @param client ActivityClient the handler was called on.
+ * @param before Status word before the call.
+ * @param after Status word after it.
+ */
+void report(std::int64_t client, std::uint16_t before, std::uint16_t after) noexcept {
+    std::array<char, core::log::kLineCapacity> line{};
+    const std::uint64_t memberKey =
+        *reinterpret_cast<const std::uint64_t*>(client + kMembershipHeaderOffset);
+    const auto axisOne = *reinterpret_cast<const std::uint32_t*>(client + kSlotAxisOneOffset);
+    const auto axisTwo = *reinterpret_cast<const std::uint32_t*>(client + kSlotAxisTwoOffset);
+    const auto* label = reinterpret_cast<const char*>(client + kSlotLabelOffset);
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=probe stage=msg12 result=received client=0x%llX "
+                                      "slot=%u label=%.*s member=0x%016llX "
+                                      "status=0x%04X->0x%04X flag=%u",
+                                      static_cast<unsigned long long>(client),
+                                      axisOne + 2U * axisTwo,
+                                      static_cast<int>(kSlotLabelCapacity),
+                                      label,
+                                      static_cast<unsigned long long>(memberKey),
+                                      static_cast<unsigned>(before),
+                                      static_cast<unsigned>(after),
+                                      (after & kMembershipFlag) != 0 ? 1U : 0U);
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Reports the four bind inputs the rebind reads, none of which needs a call.
+ * A zero established id skips the slot silently, and a null roster container keeps the slot out
+ * of the public-first pick, so between them they name which reader can ever see this client.
+ * @param client ActivityClient.
+ */
+void report_bind_inputs(std::int64_t client) noexcept {
+    std::array<char, core::log::kLineCapacity> line{};
+    const auto established =
+        *reinterpret_cast<const std::uint64_t*>(client + kEstablishedSessionOffset);
+    const auto slotRecord = *reinterpret_cast<const std::uint64_t*>(client + kSlotRecordOffset);
+    const auto rosterContainer =
+        *reinterpret_cast<const std::uint64_t*>(client + kRosterContainerOffset);
+    const auto receipt = *reinterpret_cast<const std::uint8_t*>(client + kBindReceiptOffset);
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=probe stage=bind client=0x%llX established=0x%016llX "
+                                      "slotrec=0x%llX roster=0x%llX receipt=%u",
+                                      static_cast<unsigned long long>(client),
+                                      static_cast<unsigned long long>(established),
+                                      static_cast<unsigned long long>(slotRecord),
+                                      static_cast<unsigned long long>(rosterContainer),
+                                      static_cast<unsigned>(receipt));
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/** @param client ActivityClient. @return Entity-slot bits it holds but has not applied. */
+[[nodiscard]] std::size_t pending_slots(std::int64_t client) noexcept {
+    const auto* mask = reinterpret_cast<const std::uint8_t*>(client + kPendingMaskOffset);
+    std::size_t count = 0;
+    for (std::size_t index = 0; index < kPendingMaskSize; ++index) {
+        count += static_cast<std::size_t>(std::popcount(mask[index]));
+    }
+    return count;
+}
+
+/** Opens or refreshes the sampling window for one client. */
+void track(std::int64_t client, std::uint64_t now) noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    Tracked* free = nullptr;
+    for (Tracked& entry : g_tracked) {
+        if (entry.occupied && entry.client == client) {
+            entry.expiresAt = now + kSampleWindowMs;
+            ReleaseSRWLockExclusive(&g_lock);
+            return;
+        }
+        if (free == nullptr && !entry.occupied) {
+            free = &entry;
+        }
+    }
+    if (free != nullptr) {
+        *free = {client, now + kSampleWindowMs, now, true};
+    }
+    ReleaseSRWLockExclusive(&g_lock);
+}
+
+/** Reads the status word, defers to the original, then reads it again. */
+char __fastcall receive(std::int64_t client, std::int64_t body, int size) noexcept {
+    const auto* original = reinterpret_cast<Receive>(g_handle.original);
+    if (original == nullptr || client == 0) {
+        return original != nullptr ? original(client, body, size) : 0;
+    }
+    const auto before = *reinterpret_cast<const std::uint16_t*>(client + kStatusWordOffset);
+    const char result = original(client, body, size);
+    const auto after = *reinterpret_cast<const std::uint16_t*>(client + kStatusWordOffset);
+    report(client, before, after);
+    report_bind_inputs(client);
+    track(client, GetTickCount64());
+    return result;
+}
+
+/**
+ * Reports what one client did with its grant after the message.
+ * @param client ActivityClient.
+ */
+void sample(std::int64_t client) noexcept {
+    std::array<char, core::log::kLineCapacity> line{};
+    const auto status = *reinterpret_cast<const std::uint16_t*>(client + kStatusWordOffset);
+    const auto dirty = *reinterpret_cast<const std::uint8_t*>(client + kGrantDirtyOffset);
+    const int written = std::snprintf(
+        line.data(),
+        line.size(),
+        "ev=probe stage=grant client=0x%llX status=0x%04X "
+        "pending=%zu dirty=%u receipt=%u",
+        static_cast<unsigned long long>(client),
+        static_cast<unsigned>(status),
+        pending_slots(client),
+        static_cast<unsigned>(dirty),
+        static_cast<unsigned>(*reinterpret_cast<const std::uint8_t*>(client + kBindReceiptOffset)));
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/** @param reason Short name of the step that failed. @return Always true: a probe never blocks. */
+[[nodiscard]] bool fail(const char* reason) noexcept {
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(
+        line.data(), line.size(), "ev=probe stage=msg12 result=fail reason=%s", reason);
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+    return true;
+}
+
+} // namespace
+
+/** Attaches the read-only probe on the client's activity msg 12 handler. */
+bool install() noexcept {
+    if (g_installed.load(std::memory_order_acquire)) {
+        return true;
+    }
+    std::byte* const target = scan_main_image_unique(kReceive, "membership_probe_msg12");
+    if (target == nullptr) {
+        return fail("target");
+    }
+    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&receive)};
+    if (!hooking::detour::install(spec, g_handle)) {
+        return fail("attach");
+    }
+    g_installed.store(true, std::memory_order_release);
+    core::log::write(core::log::Channel::client,
+                     core::log::Level::info,
+                     "ev=probe stage=msg12 result=installed");
+    return true;
+}
+
+/** Samples every ActivityClient the probe has seen recently. */
+void service(std::uint64_t now) noexcept {
+    if (!g_installed.load(std::memory_order_acquire)) {
+        return;
+    }
+    // Copied under the lock, sampled outside it: a read walks 1024 bytes and must not hold a lock
+    // the detour needs on the client's own thread.
+    std::array<std::int64_t, kTrackedCapacity> due{};
+    std::size_t count = 0;
+    AcquireSRWLockExclusive(&g_lock);
+    for (Tracked& entry : g_tracked) {
+        if (!entry.occupied) {
+            continue;
+        }
+        if (now >= entry.expiresAt) {
+            // The window closes before an activity can plausibly tear down, so the pointer is
+            // never read once it could be stale.
+            entry = {};
+            continue;
+        }
+        if (now >= entry.nextSample) {
+            entry.nextSample = now + kSampleIntervalMs;
+            due[count] = entry.client;
+            ++count;
+        }
+    }
+    ReleaseSRWLockExclusive(&g_lock);
+    for (std::size_t index = 0; index < count; ++index) {
+        sample(due[index]);
+    }
+}
+
+/** Detaches the probe so a later unload cannot leave a detour into unmapped code. */
+bool uninstall() noexcept {
+    if (!g_installed.load(std::memory_order_acquire)) {
+        return true;
+    }
+    const bool detached = hooking::detour::uninstall(g_handle);
+    g_installed.store(!detached, std::memory_order_release);
+    return detached;
+}
+
+} // namespace sunrise::client::hooks::membership_probe

+ 28 - 0
Sunrise/src/client/hooks/membership_probe/membership_probe.h

@@ -0,0 +1,28 @@
+#pragma once
+
+#include <cstdint>
+
+namespace sunrise::client::hooks::membership_probe {
+
+/**
+ * Attaches the read-only probe on the client's activity msg 12 handler.
+ * It answers one question: does a membership body we push on a public-target link reach the
+ * client at all, and what identity does that link's ActivityClient hold. Nothing is changed --
+ * the detour logs and calls through.
+ * @return True when the probe is attached, or was already.
+ */
+[[nodiscard]] bool install() noexcept;
+
+/**
+ * Samples every ActivityClient the probe has seen recently.
+ * The container bind happens on a later tick than the message, so the message handler is too
+ * early to see its effect. Sampling stops a bounded time after the last message on a client, so
+ * a torn-down activity is not read through a stale pointer.
+ * @param now Monotonic tick count in milliseconds.
+ */
+void service(std::uint64_t now) noexcept;
+
+/** Detaches the probe so a later unload cannot leave a detour into unmapped code. */
+[[nodiscard]] bool uninstall() noexcept;
+
+} // namespace sunrise::client::hooks::membership_probe

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

@@ -19,6 +19,7 @@
 #include "../hooks/cursor/runtime.h"
 #include "../hooks/graphics/graphics_hook_lifecycle.h"
 #include "../hooks/infinite_ammo/infinite_ammo.h"
+#include "../hooks/membership_probe/membership_probe.h"
 #include "../hooks/network/runtime.h"
 #include "../hooks/noclip/runtime.h"
 #include "../hooks/package_trust/package_trust_bypass.h"
@@ -177,6 +178,10 @@ void clear_game_targets() noexcept {
     // The bitmap reference guard puts the none sentinel in place of a reference outside tag
     // space. Without it the widget's stored-reference reader faults.
     (void)hooks::bitmap::install();
+    // Read-only. It reports the status word the activity msg 12 handler writes, which is the one
+    // thing that separates "the client never saw our membership body" from "it saw it and the
+    // world container still did not bind".
+    (void)hooks::membership_probe::install();
     content::investment::worker::activate();
     return true;
 }

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

@@ -55,6 +55,12 @@ private:
      * @return True when the object is valid JSON; unknown future options are skipped.
      */
     [[nodiscard]] bool server_settings(server::Settings& output) noexcept;
+    /**
+     * Parses the activation gate block on top of the fixed defaults.
+     * @param output Receives the block only after every key in it is valid.
+     * @return True when every supported key carries a boolean; unknown keys are skipped.
+     */
+    [[nodiscard]] bool activation_settings(server::activation::Settings& output) noexcept;
     /**
      * Parses the gameplay endpoint block on top of the fixed defaults.
      * @param output Receives the block only after the whole object is consistent.

+ 65 - 0
Sunrise/src/core/settings/server/activation/activation_settings_parser.cpp

@@ -0,0 +1,65 @@
+#include "../../parser.h"
+
+namespace sunrise::core::settings::parser {
+
+/** Parses the activation gate block on top of the fixed defaults. */
+bool Parser::activation_settings(server::activation::Settings& output) noexcept {
+    output = {};
+    if (!consume('{')) {
+        return false;
+    }
+    if (consume('}')) {
+        return true;
+    }
+    server::activation::Settings candidate{};
+    for (;;) {
+        std::string_view key;
+        if (!string(key) || !consume(':')) {
+            return false;
+        }
+        bool value = false;
+        if (key == "default_client_activation") {
+            if (!boolean(value)) {
+                return false;
+            }
+            candidate.defaultClientActivation = value;
+        } else if (key == "activity_compatibility_mirror") {
+            if (!boolean(value)) {
+                return false;
+            }
+            candidate.activityCompatibilityMirror = value;
+        } else if (key == "gameplay_external_body") {
+            if (!boolean(value)) {
+                return false;
+            }
+            candidate.gameplayExternalBody = value;
+        } else if (key == "server_default_entity") {
+            if (!boolean(value)) {
+                return false;
+            }
+            candidate.serverDefaultEntity = value;
+        } else if (key == "physics_host_session") {
+            if (!boolean(value)) {
+                return false;
+            }
+            candidate.physicsHostSession = value;
+        } else if (key == "activity_public_membership") {
+            if (!boolean(value)) {
+                return false;
+            }
+            candidate.activityPublicMembership = value;
+        } else if (!skip_value(0)) {
+            return false;
+        }
+        if (consume('}')) {
+            // The whole block is taken at once, so a later invalid key cannot leave half of it.
+            output = candidate;
+            return true;
+        }
+        if (!consume(',')) {
+            return false;
+        }
+    }
+}
+
+} // namespace sunrise::core::settings::parser

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

@@ -0,0 +1,49 @@
+#pragma once
+
+namespace sunrise::core::settings::server::activation {
+
+/**
+ * Gates for the default client-activation work, one per bounded domain.
+ * Each domain finishes on its own, so partial work in one cannot make another read as ready.
+ * With every gate off the host sends the same bytes it did before the domain existed.
+ */
+struct Settings {
+    /**
+     * The activation coordinator: the transition policy the host applies to a client request to
+     * start a different activity. Off, such a request is framed and recorded and answered with
+     * nothing.
+     */
+    bool defaultClientActivation{true};
+    /**
+     * Bounded receipt, mirror, and response handling for the activity compatibility traffic.
+     * Off, an activity message that changes no state is framed and reported but not recorded, so
+     * the arrival registry stays empty.
+     */
+    bool activityCompatibilityMirror{true};
+    /**
+     * The external common and channel wrapper on the gameplay packet.
+     * TODO: nothing reads this gate. The codec and receive shadow exist; the packet-outcome
+     * binding does not. Wire it to the established writer, not before.
+     */
+    bool gameplayExternalBody{false};
+    /**
+     * One allowlisted server-owned default entity.
+     * TODO: nothing reads this gate. The allocator and channel-2 codec exist offline; a visible
+     * class chain and a live outcome path do not. Wire it with those, not before.
+     */
+    bool serverDefaultEntity{false};
+    /**
+     * The physics-host bridge: one world opened, ticked and closed per admitted gameplay peer.
+     * Off by default because the bridge runs on the game's own render thread, so its cost is a
+     * frame stall. It produces no wire output either way.
+     */
+    bool physicsHostSession{false};
+    /**
+     * Membership on a public-target link, so the client binds a world container to it.
+     * Off by default. Sending it hands the client's player create and destroy source to that
+     * link's membership block, and a body that does not carry the local player destroys it.
+     */
+    bool activityPublicMembership{false};
+};
+
+} // namespace sunrise::core::settings::server::activation

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

@@ -1,6 +1,7 @@
 #pragma once
 
 #include "../../../state/entitlements/definition.h"
+#include "activation/definition.h"
 #include "gameplay/definition.h"
 
 namespace sunrise::core::settings::server {
@@ -14,6 +15,8 @@ struct Settings {
     state::entitlements::Table entitlements{};
     /** Gameplay UDP endpoint topology. Disabled leaves the channel unpublished. */
     gameplay::Settings gameplay{};
+    /** Per-domain gates for the default client-activation work. */
+    activation::Settings activation{};
     /** BAP port. The listener binds it and SignOn publishes it. Zero is the no-relay sentinel. */
     std::uint16_t bapPort{kDefaultBapPort};
 };

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

@@ -32,6 +32,13 @@ inline constexpr std::uint16_t kDefaultServerReserve = 256;
 inline constexpr std::uint16_t kMinimumServerReserve = 8;
 /** The client keeps at least this many lease bits after the reserve is subtracted. */
 inline constexpr std::uint16_t kClientLeaseMinimum = 4096;
+/**
+ * Entity slots the join hands the client before it asks for any.
+ * The whole slot space, capped at what the reserve leaves, which is the measured behaviour.
+ */
+inline constexpr std::uint16_t kDefaultClientJoinGrant = 8'192;
+/** Below this a join cannot cover the client's own low water mark of 400. */
+inline constexpr std::uint16_t kMinimumClientJoinGrant = 400;
 
 /**
  * Gameplay endpoint topology and the entity-slot split it implies.
@@ -51,6 +58,8 @@ struct Settings {
     std::uint16_t port{kDefaultPort};
     /** Entity indices held back from the client lease for server-authored entities. */
     std::uint16_t serverReserveCount{kDefaultServerReserve};
+    /** Entity indices the join grants. The rest stay free for the client to request. */
+    std::uint16_t clientJoinGrantCount{kDefaultClientJoinGrant};
 };
 
 /**
@@ -69,4 +78,12 @@ struct Settings {
  */
 [[nodiscard]] std::uint16_t effective_reserve(const Settings& settings) noexcept;
 
+/**
+ * Reports the entity slots one join grants.
+ * A disabled channel reserves nothing, so the grant is bounded by the whole slot space instead.
+ * @param settings Active gameplay settings.
+ * @return Configured grant, capped at what the reserve leaves free.
+ */
+[[nodiscard]] std::size_t join_grant(const Settings& settings) noexcept;
+
 } // namespace sunrise::core::settings::server::gameplay

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

@@ -50,6 +50,7 @@ bool Parser::gameplay_settings(gameplay::Settings& output) noexcept {
     bool hasTransport = false;
     bool hasPort = false;
     bool hasReserve = false;
+    bool hasJoinGrant = false;
     for (;;) {
         std::string_view key;
         if (!string(key) || !consume(':')) {
@@ -97,6 +98,14 @@ bool Parser::gameplay_settings(gameplay::Settings& output) noexcept {
             }
             candidate.serverReserveCount = static_cast<std::uint16_t>(value);
             hasReserve = true;
+        } else if (key == "client_join_grant_count") {
+            std::uint64_t value = 0;
+            if (hasJoinGrant || !unsigned_integer(value)
+                || value > (std::numeric_limits<std::uint16_t>::max)()) {
+                return false;
+            }
+            candidate.clientJoinGrantCount = static_cast<std::uint16_t>(value);
+            hasJoinGrant = true;
         } else if (!skip_value(0)) {
             return false;
         }

+ 18 - 1
Sunrise/src/core/settings/server/gameplay/gameplay_settings_validation.cpp

@@ -28,7 +28,15 @@ bool valid(const Settings& settings) noexcept {
     if (settings.port == kDiscoveryPortLow || settings.port == kDiscoveryPortHigh) {
         return false;
     }
-    return reserve_fits(settings.serverReserveCount);
+    if (!reserve_fits(settings.serverReserveCount)) {
+        return false;
+    }
+    // The grant only has to name a real slot count. It is capped at whatever the reserve leaves
+    // when the join reads it, so requiring it to fit beside the reserve here would refuse the
+    // default, which asks for the whole space on purpose.
+    constexpr std::size_t kSlotCount = state::activity::entity_slots::kSlotCount;
+    return settings.clientJoinGrantCount >= kMinimumClientJoinGrant
+           && static_cast<std::size_t>(settings.clientJoinGrantCount) <= kSlotCount;
 }
 
 /** Reports the slots actually held back from the client lease. */
@@ -36,4 +44,13 @@ std::uint16_t effective_reserve(const Settings& settings) noexcept {
     return settings.topology == Topology::disabled ? 0 : settings.serverReserveCount;
 }
 
+/** Reports the entity slots one join grants. */
+std::size_t join_grant(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.clientJoinGrantCount;
+    return wanted < free ? wanted : free;
+}
+
 } // namespace sunrise::core::settings::server::gameplay

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

@@ -18,6 +18,7 @@ bool Parser::server_settings(server::Settings& output) noexcept {
     bool hasEntitlements = false;
     bool hasBapPort = false;
     bool hasGameplay = false;
+    bool hasActivation = false;
     for (;;) {
         std::string_view key;
         if (!string(key) || !consume(':')) {
@@ -41,6 +42,11 @@ bool Parser::server_settings(server::Settings& output) noexcept {
                 return false;
             }
             hasGameplay = true;
+        } else if (key == "activation") {
+            if (hasActivation || !activation_settings(output.activation)) {
+                return false;
+            }
+            hasActivation = true;
         } else if (!skip_value(0)) {
             return false;
         }

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

@@ -172,6 +172,19 @@ void report_upgrade(bool stored) noexcept {
     }
 }
 
+/**
+ * Drops a leading UTF-8 byte order mark.
+ * Notepad and PowerShell's `Set-Content -Encoding utf8` both write one, and the parser reads it
+ * as a stray token. That made an unparsable file, and an unparsable file kills startup before
+ * the log opens, so the failure arrives with nothing to read.
+ * @param document Whole settings text as read from disk.
+ * @return The same text with any BOM removed.
+ */
+[[nodiscard]] std::string_view without_byte_order_mark(std::string_view document) noexcept {
+    constexpr std::string_view kMark = "\xEF\xBB\xBF";
+    return document.starts_with(kMark) ? document.substr(kMark.size()) : document;
+}
+
 } // namespace
 
 /** Loads the settings file from the owned folder, or creates the default one. */
@@ -232,7 +245,7 @@ bool initialize(void* module) noexcept {
     if (!readOk || !closed) {
         return fail("read");
     }
-    std::string_view document(buffer.data(), read);
+    std::string_view document = without_byte_order_mark(std::string_view(buffer.data(), read));
     static std::array<char, kConfigCapacity> upgradedBuffer{};
     const bool upgrading = upgrade::needed(document);
     if (upgrading) {

+ 42 - 11
Sunrise/src/core/ui/hud/overlays/ui_hud_session_overlay.cpp

@@ -1,6 +1,7 @@
 /**
  * The session overlay. One row per instance the player is in.
- * A row names the region, its group session, its activity host, its channel and its join.
+ * A row names the region, its group session, its activity host, its channel, how far its join has
+ * got and whether its player row has published.
  */
 
 #include "ui_hud_session_overlay.h"
@@ -28,7 +29,7 @@ constexpr std::size_t kRowCapacity = 8;
 /** Admitted peers one snapshot reads. */
 constexpr std::size_t kAdmittedCapacity = 8;
 /** Columns of the instance table, in draw order. */
-constexpr int kColumnCount = 6;
+constexpr int kColumnCount = 7;
 /** Shown while this host serves no instance. */
 constexpr char kNoInstance[] = "no session instances";
 /** Shown for a row no advertisement gave a region. */
@@ -44,22 +45,48 @@ constexpr std::array<const char*, 6> kChannelStages{
     return index < kChannelStages.size() ? kChannelStages[index] : kChannelStages[0];
 }
 
-/** Names how far one join has got. @param count Admitted rows in use. @return The join word. */
-[[nodiscard]] const char*
-join_name(const std::array<group::AdmittedRow, kAdmittedCapacity>& admitted,
-          std::size_t count,
-          std::uint64_t sessionId) noexcept {
+/**
+ * The two state words for one instance, in the order the join reaches them.
+ * `hosted` is the end of the join ladder: the peer builds no activity client until it holds the
+ * activity-host parameter, so a row short of it is still waiting on this host.
+ */
+struct InstanceState {
+    const char* join{"unjoined"};
+    const char* player{"-"};
+};
+
+/**
+ * Reads how far one instance has got.
+ * @param admitted Admitted rows from the snapshot.
+ * @param count Admitted rows in use.
+ * @param sessionId Group session the row names.
+ * @return Both state words, or the absent pair when no record holds that session.
+ */
+[[nodiscard]] InstanceState
+instance_state(const std::array<group::AdmittedRow, kAdmittedCapacity>& admitted,
+               std::size_t count,
+               std::uint64_t sessionId) noexcept {
     for (std::size_t index = 0; index < count; ++index) {
         const group::AdmittedRow& row = admitted[index];
         if (row.sessionId != sessionId) {
             continue;
         }
+        InstanceState state{};
         if (!row.joinComplete) {
-            return "joining";
+            state.join = "joining";
+        } else {
+            state.join = row.activityHostPublished ? "hosted" : "admitted";
+        }
+        // A peer that has asked for no player owes nothing, which is not the same as one whose
+        // player row the reliable queue has refused so far.
+        if (!row.hasPlayer) {
+            state.player = "none";
+        } else {
+            state.player = row.playerPublished ? "published" : "owed";
         }
-        return row.activityHostPublished ? "ready" : "joined";
+        return state;
     }
-    return "unjoined";
+    return {};
 }
 
 /** Draws one cell of hexadecimal identity. @param value Session id, or zero when there is none. */
@@ -118,6 +145,7 @@ void draw() noexcept {
     ImGui::TableSetupColumn("activity host");
     ImGui::TableSetupColumn("channel");
     ImGui::TableSetupColumn("join");
+    ImGui::TableSetupColumn("player");
     ImGui::TableHeadersRow();
     for (std::size_t index = 0; index < live; ++index) {
         const group::HostSessionRow& row = rows[index];
@@ -130,8 +158,11 @@ void draw() noexcept {
         draw_session_cell(row.hostSessionId);
         ImGui::TableNextColumn();
         ImGui::TextUnformatted(channel_name(stages[index]));
+        const InstanceState state = instance_state(admitted, admittedCount, row.groupSessionId);
+        ImGui::TableNextColumn();
+        ImGui::TextUnformatted(state.join);
         ImGui::TableNextColumn();
-        ImGui::TextUnformatted(join_name(admitted, admittedCount, row.groupSessionId));
+        ImGui::TextUnformatted(state.player);
     }
     ImGui::EndTable();
 }

+ 72 - 0
Sunrise/src/middleware/bap/activity_message/activity_incident_encoder.cpp

@@ -0,0 +1,72 @@
+#include <algorithm>
+#include <climits>
+
+#include "incident.h"
+
+namespace sunrise::middleware::bap::activity_message::incident {
+namespace {
+
+[[nodiscard]] bool target_allowed(std::uint32_t target) noexcept {
+    return target <= kTargetMaximum
+           && std::find(kPoisonTargets.begin(), kPoisonTargets.end(), target)
+                  == kPoisonTargets.end();
+}
+
+/** @return True when every bounded incident field can be represented on the wire. */
+[[nodiscard]] bool valid(const Incident& incident) noexcept {
+    if (!target_allowed(incident.primaryTarget) || incident.extraTargetCount > kExtraTargetMaximum
+        || incident.selectorLength > kSelectorMaximum || incident.payloadLength > kPayloadMaximum
+        || (!incident.hasCompressedSelector && incident.selectorLength != 0)
+        || (!incident.hasOptionalBlock
+            && (incident.optionalWordA != 0 || incident.optionalWordB != 0))) {
+        return false;
+    }
+    for (std::uint32_t index = 0; index < incident.extraTargetCount; ++index) {
+        if (!target_allowed(incident.extraTargets[index])) {
+            return false;
+        }
+    }
+    return true;
+}
+
+[[nodiscard]] bool write_bytes(encoding::bits::Writer& writer,
+                               std::span<const std::byte> bytes) noexcept {
+    for (const std::byte value : bytes) {
+        if (!writer.write(std::to_integer<std::uint8_t>(value), CHAR_BIT)) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** Writes fields after the caller has established semantic validity. */
+[[nodiscard]] bool write_fields(encoding::bits::Writer& writer, const Incident& incident) noexcept {
+    bool written = writer.write(incident.primaryTarget, kTargetWidth)
+                   && writer.write(incident.extraTargetCount, kExtraCountWidth);
+    for (std::uint32_t index = 0; written && index < incident.extraTargetCount; ++index) {
+        written = writer.write(incident.extraTargets[index], kTargetWidth);
+    }
+    written =
+        written && writer.write(incident.hasCompressedSelector ? 1U : 0U, kSelectorPresenceWidth);
+    if (written && incident.hasCompressedSelector) {
+        written =
+            writer.write(incident.selectorLength, kSelectorLengthWidth)
+            && write_bytes(writer, std::span(incident.selector).first(incident.selectorLength));
+    }
+    written = written && writer.write(incident.hasOptionalBlock ? 1U : 0U, kOptionalPresenceWidth);
+    if (written && incident.hasOptionalBlock) {
+        written = writer.write(incident.optionalWordA, kOptionalWordWidth)
+                  && writer.write(incident.optionalWordB, kOptionalWordWidth);
+    }
+    return written && writer.write(incident.payloadLength, kPayloadLengthWidth)
+           && write_bytes(writer, std::span(incident.payload).first(incident.payloadLength));
+}
+
+} // namespace
+
+/** Writes one bounded incident body after a complete semantic preflight. */
+bool write(encoding::bits::Writer& writer, const Incident& incident) noexcept {
+    return valid(incident) && write_fields(writer, incident);
+}
+
+} // namespace sunrise::middleware::bap::activity_message::incident

+ 39 - 14
Sunrise/src/middleware/bap/activity_message/activity_incident_parser.cpp

@@ -1,15 +1,16 @@
 /**
- * Msg 19 targets index a 7,763-record table that the Client reads without a bound check, so a bad
- * index is a crash and not a decode error. Rows 795, 4690 and 5375 hold type code -1 and are the
- * same risk. This validator rejects both before anything acts on the body.
- * A compressed target selector ends decoding: its wire length is not recoverable from this build,
- * so the fields behind one cannot be located.
+ * Incident targets index a 7,763-record table that the Client reads without a bound check, so a
+ * bad index is a crash and not a decode error. Rows 795, 4690 and 5375 hold type code -1 and are
+ * the same risk. This validator rejects both before anything acts on the body.
+ * The compressed target selector carries its own 9-bit byte length, so the fields behind it are
+ * located and the whole body is framed.
  */
 
 #include <algorithm>
 #include <climits>
 
 #include "../../encoding/bit_reader.h"
+#include "../../encoding/byte_order.h"
 #include "incident.h"
 
 namespace sunrise::middleware::bap::activity_message::incident {
@@ -45,11 +46,13 @@ const char* verdict_name(Verdict verdict) noexcept {
         return "too_many_targets";
     case Verdict::payloadTooLong:
         return "payload_too_long";
+    case Verdict::selectorTooLong:
+        return "selector_too_long";
     }
     return "unknown";
 }
 
-/** Validates one msg-19 body as far as its wire shape allows. */
+/** Validates one incident body from its first target to the end of its payload. */
 Verdict validate(std::span<const std::byte> payload, Incident& parsed) noexcept {
     parsed = {};
     encoding::bits::Reader reader(payload);
@@ -84,17 +87,34 @@ Verdict validate(std::span<const std::byte> payload, Incident& parsed) noexcept
     if (!reader.read(kSelectorPresenceWidth, field)) {
         return Verdict::truncated;
     }
-    if (field != 0) {
-        // Every target is checked by now, which is the part that can crash the Client.
-        parsed.hasCompressedSelector = true;
-        return Verdict::accepted;
+    parsed.hasCompressedSelector = field != 0;
+    if (parsed.hasCompressedSelector) {
+        if (!reader.read(kSelectorLengthWidth, field)) {
+            return Verdict::truncated;
+        }
+        parsed.selectorLength = static_cast<std::uint32_t>(field);
+        if (parsed.selectorLength > kSelectorMaximum) {
+            return Verdict::selectorTooLong;
+        }
+        for (std::uint32_t index = 0; index < parsed.selectorLength; ++index) {
+            if (!reader.read(CHAR_BIT, field)) {
+                return Verdict::truncated;
+            }
+            parsed.selector[index] = static_cast<std::byte>(field);
+        }
     }
 
     if (!reader.read(kOptionalPresenceWidth, field)) {
         return Verdict::truncated;
     }
-    if (field != 0 && !reader.skip(kOptionalFieldWidth)) {
-        return Verdict::truncated;
+    parsed.hasOptionalBlock = field != 0;
+    if (parsed.hasOptionalBlock) {
+        std::uint64_t wordB = 0;
+        if (!reader.read(kOptionalWordWidth, field) || !reader.read(kOptionalWordWidth, wordB)) {
+            return Verdict::truncated;
+        }
+        parsed.optionalWordA = static_cast<std::uint32_t>(field);
+        parsed.optionalWordB = static_cast<std::uint32_t>(wordB);
     }
 
     if (!reader.read(kPayloadLengthWidth, field)) {
@@ -104,10 +124,15 @@ Verdict validate(std::span<const std::byte> payload, Incident& parsed) noexcept
     if (parsed.payloadLength > kPayloadMaximum) {
         return Verdict::payloadTooLong;
     }
-    if (reader.remaining_bits() < static_cast<std::size_t>(parsed.payloadLength) * CHAR_BIT) {
-        return Verdict::truncated;
+    for (std::uint32_t index = 0; index < parsed.payloadLength; ++index) {
+        if (!reader.read(CHAR_BIT, field)) {
+            return Verdict::truncated;
+        }
+        parsed.payload[index] = static_cast<std::byte>(field);
     }
     parsed.hasPayload = true;
+    parsed.consumedBits = static_cast<std::uint32_t>(payload.size() * encoding::kBitsPerByte
+                                                     - reader.remaining_bits());
     return Verdict::accepted;
 }
 

+ 130 - 0
Sunrise/src/middleware/bap/activity_message/activity_peer_ledger_parser.cpp

@@ -0,0 +1,130 @@
+/**
+ * Fixed peer-ledger bodies: reservation release, peer leave, connectivity failure, and a
+ * speculative migration proposal. Each one names a peer key that has to resolve inside the bound
+ * session before anything acts on it, so every parser returns the key rather than a decision.
+ */
+
+#include <bit>
+
+#include "../../encoding/bit_reader.h"
+#include "../../encoding/byte_order.h"
+#include "peer_ledger.h"
+
+namespace sunrise::middleware::bap::activity_message::peer_ledger {
+namespace {
+
+/** The migration scalar is biased by the midpoint of the unsigned 32-bit range. */
+constexpr std::uint32_t kMigrationScalarBias = 0x80000000U;
+
+/** Layout shared by the release body and the leave body that extends it. */
+struct LedgerLayout final {
+    /** The first opaque scalar starts the body. */
+    static constexpr std::size_t scalarA = 0;
+    /** The second opaque scalar follows the first. */
+    static constexpr std::size_t scalarB = scalarA + encoding::kU32Size;
+    /** Eight key bytes, low byte first, in the same order the join request writes its member key.
+     */
+    static constexpr std::size_t peerKey = scalarB + encoding::kU32Size;
+    /** Only the leave body carries this trailing scalar. */
+    static constexpr std::size_t scalarC = peerKey + encoding::kU64Size;
+};
+
+/** Layout of the migration proposal. */
+struct MigrationLayout final {
+    /** The biased opaque scalar starts the body. */
+    static constexpr std::size_t scalar = 0;
+    /** Eight key bytes, low byte first, follow the scalar. */
+    static constexpr std::size_t peerKey = scalar + encoding::kU32Size;
+};
+
+/**
+ * Fills the two scalars and the peer key shared by the release and leave bodies.
+ * @param input Payload holding at least the shared prefix.
+ * @param scalarA Receives the first scalar.
+ * @param scalarB Receives the second scalar.
+ * @param peerKey Receives the peer key.
+ */
+void read_shared_prefix(std::span<const std::byte> input,
+                        std::uint32_t& scalarA,
+                        std::uint32_t& scalarB,
+                        std::uint64_t& peerKey) noexcept {
+    scalarA = encoding::read_u32_be(input.subspan<LedgerLayout::scalarA, encoding::kU32Size>());
+    scalarB = encoding::read_u32_be(input.subspan<LedgerLayout::scalarB, encoding::kU32Size>());
+    peerKey = encoding::read_u64_le(input.subspan<LedgerLayout::peerKey, encoding::kU64Size>());
+}
+
+} // namespace
+
+/** Parses a reservation release. */
+bool parse_release(std::span<const std::byte> input,
+                   ReservationRelease& release,
+                   std::size_t& consumedBits) noexcept {
+    release = {};
+    consumedBits = 0;
+    if (input.size() < kReleaseSize) {
+        return false;
+    }
+    read_shared_prefix(input, release.scalarA, release.scalarB, release.peerKey);
+    consumedBits = kReleaseSize * encoding::kBitsPerByte;
+    return true;
+}
+
+/** Parses a peer leave notice. */
+bool parse_leave(std::span<const std::byte> input,
+                 PeerLeave& leave,
+                 std::size_t& consumedBits) noexcept {
+    leave = {};
+    consumedBits = 0;
+    if (input.size() < kLeaveSize) {
+        return false;
+    }
+    read_shared_prefix(input, leave.scalarA, leave.scalarB, leave.peerKey);
+    leave.scalarC =
+        encoding::read_u32_be(input.subspan<LedgerLayout::scalarC, encoding::kU32Size>());
+    consumedBits = kLeaveSize * encoding::kBitsPerByte;
+    return true;
+}
+
+/** Parses a connectivity failure report. */
+bool parse_connectivity_failure(std::span<const std::byte> input,
+                                ConnectivityFailure& failure,
+                                std::size_t& consumedBits) noexcept {
+    failure = {};
+    consumedBits = 0;
+    if (input.size() < kConnectivityFailureSize) {
+        return false;
+    }
+    // The key is raw bits here rather than a byte field, so the reason that follows it is not
+    // byte aligned and the whole body has to be walked as a bitstream.
+    encoding::bits::Reader reader(input);
+    std::uint64_t key = 0;
+    std::uint64_t reason = 0;
+    if (!reader.read(encoding::kU64Size * encoding::kBitsPerByte, key)
+        || !reader.read(kFailureReasonWidth, reason)) {
+        return false;
+    }
+    failure.peerKey = key;
+    failure.rawReason = static_cast<std::uint8_t>(reason);
+    consumedBits = input.size() * encoding::kBitsPerByte - reader.remaining_bits();
+    return true;
+}
+
+/** Parses a speculative migration proposal. */
+bool parse_migration(std::span<const std::byte> input,
+                     MigrationProposal& proposal,
+                     std::size_t& consumedBits) noexcept {
+    proposal = {};
+    consumedBits = 0;
+    if (input.size() < kMigrationSize) {
+        return false;
+    }
+    const std::uint32_t raw =
+        encoding::read_u32_be(input.subspan<MigrationLayout::scalar, encoding::kU32Size>());
+    proposal.scalar = std::bit_cast<std::int32_t>(raw - kMigrationScalarBias);
+    proposal.peerKey =
+        encoding::read_u64_le(input.subspan<MigrationLayout::peerKey, encoding::kU64Size>());
+    consumedBits = kMigrationSize * encoding::kBitsPerByte;
+    return true;
+}
+
+} // namespace sunrise::middleware::bap::activity_message::peer_ledger

+ 17 - 0
Sunrise/src/middleware/bap/activity_message/activity_replication_epoch_encoder.cpp

@@ -0,0 +1,17 @@
+#include "activity_replication_epoch_encoder.h"
+
+namespace sunrise::middleware::bap::activity_message::replication_epoch {
+
+/** Encodes activity message 44 with the give-up latch byte forced to zero. */
+bool encode(std::uint8_t generation, std::span<std::byte> output, std::size_t& written) noexcept {
+    written = 0;
+    if (output.size() < kEncodedSize) {
+        return false;
+    }
+    output[0] = static_cast<std::byte>(generation);
+    output[1] = std::byte{};
+    written = kEncodedSize;
+    return true;
+}
+
+} // namespace sunrise::middleware::bap::activity_message::replication_epoch

+ 20 - 0
Sunrise/src/middleware/bap/activity_message/activity_replication_epoch_encoder.h

@@ -0,0 +1,20 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+namespace sunrise::middleware::bap::activity_message::replication_epoch {
+
+/** Activity message 44 carries one generation byte and one zero latch byte. */
+inline constexpr std::size_t kEncodedSize = 2;
+
+/**
+ * Encodes activity message 44 with the give-up latch byte forced to zero.
+ * A set latch byte permanently kills the client's roster path, so nothing else may build this body.
+ * TODO: no sender yet. The common-generation barrier has to be measured before this is called.
+ */
+[[nodiscard]] bool
+encode(std::uint8_t generation, std::span<std::byte> output, std::size_t& written) noexcept;
+
+} // namespace sunrise::middleware::bap::activity_message::replication_epoch

+ 41 - 0
Sunrise/src/middleware/bap/activity_message/activity_sense_update_parser.cpp

@@ -0,0 +1,41 @@
+/**
+ * The sensor sense update. Its first 129 bits are the patch epoch and one literal zero, which is
+ * enough to tell whether the client still agrees with the epoch the roster update published. The
+ * sense delta behind them has an unresolved width, so the rest of the body stays a bounded tail.
+ */
+
+#include "../../encoding/bit_reader.h"
+#include "../../encoding/byte_order.h"
+#include "sense_update.h"
+
+namespace sunrise::middleware::bap::activity_message::sense_update {
+
+/** Parses a sensor sense update as far as its recovered grammar reaches. */
+bool parse_sense_update(std::span<const std::byte> input,
+                        SenseUpdate& update,
+                        std::size_t& consumedBits) noexcept {
+    update = {};
+    consumedBits = 0;
+    if (input.size() * encoding::kBitsPerByte > kOuterBitCapacity) {
+        return false;
+    }
+    encoding::bits::Reader reader(input);
+    std::uint64_t literal = 0;
+    if (!reader.read(kEpochFieldWidth, update.epoch.first)
+        || !reader.read(kEpochFieldWidth, update.epoch.second)
+        || !reader.read(kLiteralZeroWidth, literal)) {
+        update = {};
+        return false;
+    }
+    consumedBits = input.size() * encoding::kBitsPerByte - reader.remaining_bits();
+    if (literal != 0) {
+        // The bit is a schema literal, so a set bit means this body is not the shape above and no
+        // field read from it can be trusted.
+        update = {};
+        return false;
+    }
+    update.tailBits = static_cast<std::uint32_t>(reader.remaining_bits());
+    return true;
+}
+
+} // namespace sunrise::middleware::bap::activity_message::sense_update

+ 77 - 3
Sunrise/src/middleware/bap/activity_message/activity_sensor_auth_blocks.cpp

@@ -44,12 +44,80 @@ bool write_bubble_block(bits::Writer& writer, const Grant& grant) noexcept {
     return encoded;
 }
 
+namespace {
+
+/**
+ * Writes one sub-block's key presence mask, low bit first.
+ * A key whose bit is clear is dropped in silence, so the mask has to match the key count exactly.
+ * @param writer Body writer.
+ * @param keyCount Keys the sub-block carries.
+ * @return True when all three words fit.
+ */
+[[nodiscard]] bool write_key_mask(bits::Writer& writer, std::size_t keyCount) noexcept {
+    bool encoded = true;
+    for (std::size_t word = 0; encoded && word < kBubbleMaskWords; ++word) {
+        const std::size_t low = word * kChunkWidth;
+        const std::size_t set = keyCount > low ? keyCount - low : 0;
+        const std::size_t bits = set > kChunkWidth ? kChunkWidth : set;
+        encoded = writer.write((std::uint64_t{1} << bits) - 1, kChunkWidth);
+    }
+    return encoded;
+}
+
+/**
+ * Writes one per-bubble sub-block: the bubble it belongs to, then its keys with their mask bits
+ * and state bytes. ClientRoster_ApplyDelta applies only the sub-block whose key equals the current
+ * bubble index, which is what makes these keys bubble-local.
+ * @param writer Body writer.
+ * @param block Bubble and keys to publish.
+ * @param stateSequence Value each state byte carries, so a re-send can force a re-add.
+ * @return True when the whole sub-block fits.
+ */
+[[nodiscard]] bool write_bubble_sub_block(bits::Writer& writer,
+                                          const BubbleSubBlock& block,
+                                          std::uint8_t stateSequence) noexcept {
+    const std::size_t keyCount = block.keys.size();
+    const auto count = static_cast<std::uint32_t>(keyCount);
+    bool encoded = writer.write(1, kPresenceWidth)
+                   && writer.write(kBubbleKeyBias + block.bubble, kKeyWidth)
+                   && writer.write(1, kPresenceWidth) && writer.write(1, kPresenceWidth)
+                   && writer.write(count, kBubbleCountWidth);
+    for (std::size_t index = 0; encoded && index < keyCount; ++index) {
+        encoded = writer.write(block.keys[index], kKeyWidth);
+    }
+    encoded = encoded && writer.write(1, kPresenceWidth) && write_key_mask(writer, keyCount)
+              && writer.write(1, kPresenceWidth) && writer.write(count, kBubbleCountWidth);
+    for (std::size_t index = 0; encoded && index < keyCount; ++index) {
+        encoded = writer.write(kStateByteBias + stateSequence, 8);
+    }
+    return encoded;
+}
+
+/**
+ * Writes the delta's field-1 half: the sub-block count, then one element each.
+ * @param writer Body writer positioned after the field's presence bit.
+ * @param subBlocks Sub-blocks to publish, in bubble order.
+ * @param stateSequence Value each state byte carries.
+ * @return True when every sub-block fits.
+ */
+[[nodiscard]] bool write_bubble_sub_blocks(bits::Writer& writer,
+                                           std::span<const BubbleSubBlock> subBlocks,
+                                           std::uint8_t stateSequence) noexcept {
+    bool encoded = writer.write(static_cast<std::uint32_t>(subBlocks.size()), kBubbleCountWidth);
+    for (std::size_t index = 0; encoded && index < subBlocks.size(); ++index) {
+        encoded = write_bubble_sub_block(writer, subBlocks[index], stateSequence);
+    }
+    return encoded;
+}
+
+} // namespace
+
 /** Writes the phase-1 roster delta, which registers the group keys. */
 bool write_roster_delta(bits::Writer& writer,
                         const Roster& roster,
                         std::uint8_t stateSequence) noexcept {
     const std::size_t root = writer.bit_count();
-    const std::size_t keyCount = roster.groupCount;
+    const std::size_t keyCount = roster.topLevelGroupCount;
     // Clearing the root presence bit means nothing below it is read.
     bool encoded = writer.write(1, kPresenceWidth) && writer.write(1, kPresenceWidth)
                    && writer.write(1, kPresenceWidth)
@@ -71,8 +139,14 @@ bool write_roster_delta(bits::Writer& writer,
     for (std::size_t group = 0; encoded && group < keyCount; ++group) {
         encoded = writer.write(kStateByteBias + stateSequence, 8);
     }
-    return encoded && writer.write(0, kPresenceWidth)
-           && writer.bit_count() == root + delta_bits(keyCount);
+    // Field 1 is the per-bubble sub-block half. Absent, it is one zero bit and 32 KB of the
+    // client's roster struct stays untouched.
+    const std::span<const BubbleSubBlock> subBlocks = roster.bubbleSubBlocks;
+    encoded = encoded && writer.write(subBlocks.empty() ? 0U : 1U, kPresenceWidth);
+    if (encoded && !subBlocks.empty()) {
+        encoded = write_bubble_sub_blocks(writer, subBlocks, stateSequence);
+    }
+    return encoded && writer.bit_count() == root + delta_bits(keyCount, subBlocks);
 }
 
 /** Writes one per-object state block. */

+ 48 - 8
Sunrise/src/middleware/bap/activity_message/activity_sensor_auth_encoder.cpp

@@ -12,6 +12,34 @@ constexpr std::uint8_t kSlotTypeParticipation = 13;
 /** The participation region rides a signed field, so this is the widest index it accepts. */
 constexpr std::uint32_t kMaximumRegion = 0x7FFFFFFF;
 
+/**
+ * Checks the per-bubble sub-blocks against what the client's own arrays hold.
+ * An empty sub-block would publish a zero count and a zero mask, which registers nothing and
+ * spends a slot, so it is refused rather than encoded.
+ * @param subBlocks Sub-blocks the body would carry.
+ * @return True when every sub-block names a usable bubble and a bounded key set.
+ */
+[[nodiscard]] bool valid_sub_blocks(std::span<const BubbleSubBlock> subBlocks) noexcept {
+    if (subBlocks.size() > kBubbleSubBlockCapacity) {
+        return false;
+    }
+    for (std::size_t index = 0; index < subBlocks.size(); ++index) {
+        const BubbleSubBlock& block = subBlocks[index];
+        if (block.bubble > kMaximumSubBlockBubble || block.keys.empty()
+            || block.keys.size() > kBubbleKeyCapacity) {
+            return false;
+        }
+        // The client's array holds one element per bubble, and its sweep walks every element that
+        // matches. A repeated bubble would register the same keys twice in one apply.
+        for (std::size_t earlier = 0; earlier < index; ++earlier) {
+            if (subBlocks[earlier].bubble == block.bubble) {
+                return false;
+            }
+        }
+    }
+    return true;
+}
+
 /**
  * Checks the scalars whose out-of-range values would encode with no complaint.
  * @param snapshot Message input.
@@ -37,22 +65,31 @@ constexpr std::uint32_t kMaximumRegion = 0x7FFFFFFF;
             || snapshot.grant.token < kMinimumGrantToken)) {
         return false;
     }
-    if (snapshot.roster.groupCount > kGroupCapacity) {
+    if (snapshot.roster.groupCount > kGroupCapacity
+        || snapshot.roster.topLevelGroupCount > snapshot.roster.groupCount) {
         return false;
     }
     for (std::size_t group = 0; group < snapshot.roster.groupCount; ++group) {
         const Group& row = snapshot.roster.groups[group];
-        if (row.slotTypes.size() != row.slotFlags.size() || row.slotTypes.empty()) {
+        if (row.slotTypes.size() != row.slotFlags.size()
+            || row.slotTypes.size() != row.slotIndices.size() || row.slotTypes.empty()) {
             return false;
         }
+        // An index past the field's range wraps into another slot's, which seeds the wrong
+        // object rather than refusing.
+        for (const std::uint16_t index : row.slotIndices) {
+            if (index > kMaximumSlotIndex) {
+                return false;
+            }
+        }
     }
-    return true;
+    return valid_sub_blocks(snapshot.roster.bubbleSubBlocks);
 }
 
 /**
- * Writes every group's object blocks, in publish order. Every registered object must be seeded
- * before any auth state applies, because the client's gate walks the whole sync-record pool. A
- * partial message seeds nothing that applies.
+ * Writes every group's object blocks, in publish order, per-bubble groups included.
+ * Every registered object must be seeded before any auth state applies, because the client's gate
+ * walks the whole sync-record pool. A partial message seeds nothing that applies.
  * @param writer Body writer sitting after the phase-1 delta.
  * @param snapshot Message input.
  * @return True when every block fits.
@@ -76,7 +113,7 @@ constexpr std::uint32_t kMaximumRegion = 0x7FFFFFFF;
                                          snapshot,
                                          row.key,
                                          slotType,
-                                         static_cast<std::uint16_t>(slot),
+                                         row.slotIndices[slot],
                                          row.slotFlags[slot],
                                          carriesPlayerKey);
         }
@@ -106,7 +143,10 @@ constexpr std::uint32_t kMaximumRegion = 0x7FFFFFFF;
     // The enable latch is not sticky, so it goes on every message.
     encoded = encoded && writer.write(1, kPresenceWidth)
               && write_roster_delta(writer, snapshot.roster, snapshot.stateSequence)
-              && writer.bit_count() == latchBit + 1 + delta_bits(snapshot.roster.groupCount);
+              && writer.bit_count()
+                     == latchBit + 1
+                            + delta_bits(snapshot.roster.topLevelGroupCount,
+                                         snapshot.roster.bubbleSubBlocks);
     if (encoded && !snapshot.phaseOneOnly) {
         encoded = write_phase_two(writer, snapshot);
     }

+ 94 - 0
Sunrise/src/middleware/bap/activity_message/activity_start_activity_parser.cpp

@@ -0,0 +1,94 @@
+/**
+ * The start-new-activity request. The body opens with a selector and the two activity indices the
+ * move is between, then three optional identity fields, then a nested selection record whose shape
+ * is not recovered. Parsing stops at that record and reports the rest as a bounded tail.
+ */
+
+#include <bit>
+
+#include "../../encoding/bit_reader.h"
+#include "../../encoding/byte_order.h"
+#include "start_activity.h"
+
+namespace sunrise::middleware::bap::activity_message::start_activity {
+namespace {
+
+/**
+ * Reads one biased index field.
+ * @param reader Reader positioned at the field.
+ * @param width Field width in bits.
+ * @param value Receives the decoded value.
+ * @return True when the field was present.
+ */
+[[nodiscard]] bool
+read_index(encoding::bits::Reader& reader, std::uint8_t width, std::int32_t& value) noexcept {
+    std::uint64_t raw = 0;
+    if (!reader.read(width, raw)) {
+        return false;
+    }
+    value = static_cast<std::int32_t>(raw) - kIndexBias;
+    return true;
+}
+
+/**
+ * Reads one presence bit and, when it is set, the field behind it.
+ * @param reader Reader positioned at the presence bit.
+ * @param width Width of the field behind the bit.
+ * @param value Receives the raw field value when it is present.
+ * @param present Receives whether the field was there.
+ * @return True when the presence bit and any field behind it were both available.
+ */
+[[nodiscard]] bool read_optional(encoding::bits::Reader& reader,
+                                 std::uint8_t width,
+                                 std::uint64_t& value,
+                                 bool& present) noexcept {
+    std::uint64_t flag = 0;
+    if (!reader.read(kPresenceWidth, flag)) {
+        return false;
+    }
+    present = flag != 0;
+    if (!present) {
+        value = 0;
+        return true;
+    }
+    return reader.read(width, value);
+}
+
+} // namespace
+
+/** Parses a start-new-activity request as far as its recovered grammar reaches. */
+bool parse_start_activity(std::span<const std::byte> input,
+                          StartActivity& request,
+                          std::size_t& consumedBits) noexcept {
+    request = {};
+    consumedBits = 0;
+    encoding::bits::Reader reader(input);
+    if (!read_index(reader, kSelectorWidth, request.selector)
+        || !read_index(reader, kActivityIndexWidth, request.sourceActivityIndex)
+        || !read_index(reader, kActivityIndexWidth, request.destinationActivityIndex)) {
+        return false;
+    }
+
+    std::uint64_t field = 0;
+    if (!read_optional(reader, kElementWidth, field, request.hasElement)) {
+        return false;
+    }
+    request.element =
+        request.hasElement ? static_cast<std::int32_t>(field) - kIndexBias : kAbsentIndex;
+    if (!read_optional(reader, kIdentityWidth, field, request.hasIdentity)) {
+        return false;
+    }
+    request.identity = std::bit_cast<std::int64_t>(field);
+    if (!read_optional(
+            reader, kIdentityWidth, request.selectionIdentity, request.hasSelectionIdentity)) {
+        return false;
+    }
+
+    // The nested selection record starts here. Its field widths are unresolved, so the rest of the
+    // body is one bounded region rather than a sequence this parser can claim to have read.
+    request.tailBits = static_cast<std::uint32_t>(reader.remaining_bits());
+    consumedBits = input.size() * encoding::kBitsPerByte - reader.remaining_bits();
+    return true;
+}
+
+} // namespace sunrise::middleware::bap::activity_message::start_activity

+ 100 - 0
Sunrise/src/middleware/bap/activity_message/activity_telemetry_parser.cpp

@@ -0,0 +1,100 @@
+/**
+ * Telemetry and reservation bodies. Each one is framed only as far as its recovered grammar
+ * reaches, and the caller is told how many bits that was, so a body that is retained rather than
+ * understood is never counted as fully read.
+ */
+
+#include <bit>
+
+#include "../../encoding/bit_reader.h"
+#include "../../encoding/byte_order.h"
+#include "telemetry.h"
+
+namespace sunrise::middleware::bap::activity_message::telemetry {
+namespace {
+
+/** Opaque scalar bodies are biased by the midpoint of the unsigned 32-bit range. */
+constexpr std::uint32_t kScalarBias = 0x80000000U;
+/** Every high-water word is read most significant bit first, as the rest of the body is. */
+constexpr std::uint8_t kNarrowWidth = 32;
+/** See kNarrowWidth. */
+constexpr std::uint8_t kWideWidth = 64;
+
+} // namespace
+
+/** Parses a peer-reservation request. */
+bool parse_reservation_request(std::span<const std::byte> input,
+                               ReservationRequest& request,
+                               std::size_t& consumedBits) noexcept {
+    request = {};
+    consumedBits = 0;
+    if (input.size() < kReservationRevisionSize) {
+        return false;
+    }
+    request.revision = encoding::read_u32_be(input.first<encoding::kU32Size>());
+    request.recordBytes = static_cast<std::uint32_t>(input.size() - kReservationRevisionSize);
+    consumedBits = kReservationRevisionSize * encoding::kBitsPerByte;
+    return true;
+}
+
+/** Parses a lag-switch report as far as its record count. */
+bool parse_lag_switch(std::span<const std::byte> input,
+                      LagSwitchReport& report,
+                      std::size_t& consumedBits) noexcept {
+    report = {};
+    consumedBits = 0;
+    encoding::bits::Reader reader(input);
+    std::uint64_t count = 0;
+    if (!reader.read(kLagRecordCountWidth, count)) {
+        return false;
+    }
+    report.recordCount = static_cast<std::uint8_t>(count);
+    report.aboveSupported = report.recordCount > kLagRecordSupported;
+    report.recordBits = static_cast<std::uint32_t>(reader.remaining_bits());
+    consumedBits = kLagRecordCountWidth;
+    return true;
+}
+
+/** Parses the fixed high-water telemetry block. */
+bool parse_high_water(std::span<const std::byte> input,
+                      HighWater& block,
+                      std::size_t& consumedBits) noexcept {
+    block = {};
+    consumedBits = 0;
+    if (input.size() < kHighWaterSize) {
+        return false;
+    }
+    encoding::bits::Reader reader(input);
+    std::uint64_t field = 0;
+    for (std::uint32_t& word : block.narrow) {
+        if (!reader.read(kNarrowWidth, field)) {
+            return false;
+        }
+        word = static_cast<std::uint32_t>(field);
+    }
+    for (std::uint64_t& word : block.wide) {
+        if (!reader.read(kWideWidth, field)) {
+            return false;
+        }
+        word = field;
+    }
+    consumedBits = input.size() * encoding::kBitsPerByte - reader.remaining_bits();
+    return true;
+}
+
+/** Parses one biased signed scalar body. */
+bool parse_opaque_scalar(std::span<const std::byte> input,
+                         std::int32_t& value,
+                         std::size_t& consumedBits) noexcept {
+    value = 0;
+    consumedBits = 0;
+    if (input.size() < kScalarSize) {
+        return false;
+    }
+    const std::uint32_t raw = encoding::read_u32_be(input.first<encoding::kU32Size>());
+    value = std::bit_cast<std::int32_t>(raw - kScalarBias);
+    consumedBits = kScalarSize * encoding::kBitsPerByte;
+    return true;
+}
+
+} // namespace sunrise::middleware::bap::activity_message::telemetry

+ 32 - 6
Sunrise/src/middleware/bap/activity_message/incident.h

@@ -5,6 +5,8 @@
 #include <cstdint>
 #include <span>
 
+#include "../../encoding/bit_writer.h"
+
 namespace sunrise::middleware::bap::activity_message::incident {
 
 /** Activity message type 19 carries one incident. Both sides can send it. */
@@ -23,10 +25,16 @@ inline constexpr std::uint8_t kExtraCountWidth = 5;
 inline constexpr std::uint32_t kExtraTargetMaximum = 25;
 /** One bit says whether a compressed target selector follows. */
 inline constexpr std::uint8_t kSelectorPresenceWidth = 1;
+/** The selector byte length is 9 bits, so the wire can ask for more than the limit allows. */
+inline constexpr std::uint8_t kSelectorLengthWidth = 9;
+/** At most 260 selector bytes follow. Their meaning stays opaque. */
+inline constexpr std::uint32_t kSelectorMaximum = 260;
 /** One bit says whether optional field K follows. */
 inline constexpr std::uint8_t kOptionalPresenceWidth = 1;
 /** Optional field K is two 32-bit words. */
 inline constexpr std::uint8_t kOptionalFieldWidth = 64;
+/** Each optional word is a full 32-bit field. */
+inline constexpr std::uint8_t kOptionalWordWidth = 32;
 /** The payload byte length is 9 bits, so the wire can ask for more than the limit allows. */
 inline constexpr std::uint8_t kPayloadLengthWidth = 9;
 /** At most 500 payload bytes follow. */
@@ -49,16 +57,28 @@ enum class Verdict : std::uint8_t {
     tooManyTargets,
     /** More than 500 payload bytes were declared. */
     payloadTooLong,
+    /** More than 260 selector bytes were declared. */
+    selectorTooLong,
 };
 
-/** One validated incident. Fields after a compressed selector are not decoded. */
+/** One validated incident, framed to the end of its payload. */
 struct Incident {
+    std::array<std::byte, kSelectorMaximum> selector{};
+    std::array<std::byte, kPayloadMaximum> payload{};
     std::uint32_t primaryTarget{};
     std::uint32_t extraTargets[kExtraTargetMaximum]{};
     std::uint32_t extraTargetCount{};
+    std::uint32_t selectorLength{};
     std::uint32_t payloadLength{};
-    /** Set when a compressed selector follows, which ends decoding for this body. */
+    /** Both optional words, read only when the optional block is present. */
+    std::uint32_t optionalWordA{};
+    std::uint32_t optionalWordB{};
+    /** Bits the body used. Below the payload's own bit count means trailing padding. */
+    std::uint32_t consumedBits{};
+    /** Set when a compressed selector is present. Its bytes stay opaque. */
     bool hasCompressedSelector{};
+    /** Set when the two optional words are present. */
+    bool hasOptionalBlock{};
     /** Set when the payload length and its bytes were reached and checked. */
     bool hasPayload{};
 };
@@ -67,13 +87,19 @@ struct Incident {
 [[nodiscard]] const char* verdict_name(Verdict verdict) noexcept;
 
 /**
- * Validates one msg-19 body as far as its wire shape allows.
- * Every target index is range and poison checked. Decoding stops at a compressed target selector,
- * whose wire length is not recoverable from this artifact, so the payload behind one is not read.
- * @param payload Activity message payload after the 17-byte envelope.
+ * Validates one incident body from its first target to the end of its payload.
+ * Every target index is range and poison checked before anything else, because an out-of-range
+ * index is a crash in the consumer rather than a decode error.
+ * @param payload Activity message payload after the envelope.
  * @param parsed Cleared first. Receives every field reached before the verdict.
  * @return accepted, or the first rule the body broke.
  */
 [[nodiscard]] Verdict validate(std::span<const std::byte> payload, Incident& parsed) noexcept;
 
+/**
+ * Writes one bounded incident body after a complete semantic preflight.
+ * TODO: no sender yet. The outbound gameplay-event route has to open before this is called.
+ */
+[[nodiscard]] bool write(encoding::bits::Writer& writer, const Incident& incident) noexcept;
+
 } // namespace sunrise::middleware::bap::activity_message::incident

+ 107 - 0
Sunrise/src/middleware/bap/activity_message/peer_ledger.h

@@ -0,0 +1,107 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+namespace sunrise::middleware::bap::activity_message::peer_ledger {
+
+/** The client returns one peer reservation it no longer needs. */
+inline constexpr std::uint32_t kReleaseReservationType = 14;
+/** The client reports that one peer is leaving. */
+inline constexpr std::uint32_t kPeerLeaveType = 15;
+/** The client reports that it cannot reach one peer. Both sides can send this type. */
+inline constexpr std::uint32_t kConnectivityFailureType = 37;
+/** The client proposes a host migration. Nothing here acts on one. */
+inline constexpr std::uint32_t kSpeculativeMigrationType = 48;
+
+/** Release carries two scalars and a peer key in exactly 16 bytes. */
+inline constexpr std::size_t kReleaseSize = 16;
+/** Leave carries the release fields and one trailing scalar in exactly 20 bytes. */
+inline constexpr std::size_t kLeaveSize = 20;
+/** Connectivity failure is 66 meaningful bits, padded to 9 bytes. */
+inline constexpr std::size_t kConnectivityFailureSize = 9;
+/** Migration carries a biased value and a peer key in exactly 12 bytes. */
+inline constexpr std::size_t kMigrationSize = 12;
+
+/** The reason selector is two bits wide. Its schema bias is not recovered, so it stays raw. */
+inline constexpr std::uint8_t kFailureReasonWidth = 2;
+
+/**
+ * One returned peer reservation.
+ * The reservation context is recovered; what the two scalars mean individually is not, so they
+ * keep their wire order and no meaning is assigned to either.
+ */
+struct ReservationRelease {
+    std::uint32_t scalarA{};
+    std::uint32_t scalarB{};
+    /** Eight key bytes, low byte first, naming the peer inside the session's ledger. */
+    std::uint64_t peerKey{};
+};
+
+/** One peer leave notice. It is the release body plus one trailing scalar. */
+struct PeerLeave {
+    std::uint32_t scalarA{};
+    std::uint32_t scalarB{};
+    std::uint64_t peerKey{};
+    std::uint32_t scalarC{};
+};
+
+/** One reported failure to reach a peer. */
+struct ConnectivityFailure {
+    std::uint64_t peerKey{};
+    /** Raw two-bit selector. The schema bias is unrecovered, so this is not a decoded reason. */
+    std::uint8_t rawReason{};
+};
+
+/** One proposed host migration. Accepting one needs the group migration state machine. */
+struct MigrationProposal {
+    /** Biased signed scalar whose meaning is not recovered. */
+    std::int32_t scalar{};
+    std::uint64_t peerKey{};
+};
+
+/**
+ * Parses a reservation release.
+ * @param input Activity payload after the envelope.
+ * @param release Cleared first, then filled.
+ * @param consumedBits Receives the bits the body used, whether or not it parsed.
+ * @return True when the whole fixed body was present.
+ */
+[[nodiscard]] bool parse_release(std::span<const std::byte> input,
+                                 ReservationRelease& release,
+                                 std::size_t& consumedBits) noexcept;
+
+/**
+ * Parses a peer leave notice.
+ * @param input Activity payload after the envelope.
+ * @param leave Cleared first, then filled.
+ * @param consumedBits Receives the bits the body used, whether or not it parsed.
+ * @return True when the whole fixed body was present.
+ */
+[[nodiscard]] bool
+parse_leave(std::span<const std::byte> input, PeerLeave& leave, std::size_t& consumedBits) noexcept;
+
+/**
+ * Parses a connectivity failure report.
+ * @param input Activity payload after the envelope.
+ * @param failure Cleared first, then filled.
+ * @param consumedBits Receives the bits the body used, whether or not it parsed.
+ * @return True when the whole fixed body was present.
+ */
+[[nodiscard]] bool parse_connectivity_failure(std::span<const std::byte> input,
+                                              ConnectivityFailure& failure,
+                                              std::size_t& consumedBits) noexcept;
+
+/**
+ * Parses a speculative migration proposal.
+ * @param input Activity payload after the envelope.
+ * @param proposal Cleared first, then filled.
+ * @param consumedBits Receives the bits the body used, whether or not it parsed.
+ * @return True when the whole fixed body was present.
+ */
+[[nodiscard]] bool parse_migration(std::span<const std::byte> input,
+                                   MigrationProposal& proposal,
+                                   std::size_t& consumedBits) noexcept;
+
+} // namespace sunrise::middleware::bap::activity_message::peer_ledger

+ 46 - 0
Sunrise/src/middleware/bap/activity_message/sense_update.h

@@ -0,0 +1,46 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+#include "activity_patch_epoch_parser.h"
+
+namespace sunrise::middleware::bap::activity_message::sense_update {
+
+/** The client reports sensor sense changes. It is the client's answer to the roster update. */
+inline constexpr std::uint32_t kMessageType = 6;
+
+/** The body opens with the same 128-bit patch epoch the roster update echoes. */
+inline constexpr std::uint8_t kEpochFieldWidth = 64;
+/** One literal zero bit follows the epoch. A set bit means the body is not this shape. */
+inline constexpr std::uint8_t kLiteralZeroWidth = 1;
+/** The client's outer destination bounds the whole body. */
+inline constexpr std::size_t kOuterBitCapacity = 514'048;
+/** The client's per-group scratch bounds one group substream. */
+inline constexpr std::size_t kGroupBitCapacity = 102'400;
+
+/**
+ * The recovered prefix of a sensor sense update.
+ * The sense delta behind the literal zero has an unresolved width, so the group loop after it
+ * cannot be located and everything from the delta on is one bounded tail.
+ */
+struct SenseUpdate {
+    /** Epoch the client believes is current. It must match the one the roster update carried. */
+    patch_epoch::PatchEpoch epoch{};
+    /** Bits left after the literal zero. Their grammar is unresolved. */
+    std::uint32_t tailBits{};
+};
+
+/**
+ * Parses a sensor sense update as far as its recovered grammar reaches.
+ * @param input Activity payload after the envelope.
+ * @param update Cleared first, then filled with the epoch and the tail size.
+ * @param consumedBits Receives the bits the recovered prefix used.
+ * @return True when the epoch and the literal zero were both present and the zero read zero.
+ */
+[[nodiscard]] bool parse_sense_update(std::span<const std::byte> input,
+                                      SenseUpdate& update,
+                                      std::size_t& consumedBits) noexcept;
+
+} // namespace sunrise::middleware::bap::activity_message::sense_update

+ 77 - 6
Sunrise/src/middleware/bap/activity_message/sensor_auth_update.h

@@ -18,8 +18,12 @@ inline constexpr std::size_t kAuthoritySlotCount = 65;
 inline constexpr std::uint8_t kMaximumGrantBubble = 63;
 /** A grant token of zero equals the client's cleared mirror, so it grants nothing. */
 inline constexpr std::uint16_t kMinimumGrantToken = 1;
-/** Groups one destination may publish. No installed destination reaches more than two. */
-inline constexpr std::size_t kGroupCapacity = 4;
+/**
+ * Groups one body may carry, top-level and per-bubble together.
+ * No installed destination reaches more than two objects that go in the top-level list, and the
+ * per-bubble half adds its own. Phase 2 seeds every one of them.
+ */
+inline constexpr std::size_t kGroupCapacity = 8;
 /** The three lifetime states spawn gate G4's unbounded jump table accepts. */
 inline constexpr std::array<std::uint8_t, 3> kLifetimeStates = {3, 6, 10};
 /** Slot flag bit for a block that carries a sense reset bit. */
@@ -39,20 +43,49 @@ struct Grant final {
 
 /**
  * One roster group and its slots, in slot-index order.
- * Slot indices are contiguous from zero, so a slot's ordinal in these arrays is its index.
+ * A slot the object declares but no descriptor names is not here, so an ordinal is not an index
+ * and `slotIndices` carries each slot's own, from its descriptor.
  */
 struct Group final {
     std::uint32_t key{};
     std::span<const std::uint8_t> slotTypes{};
     std::span<const std::uint8_t> slotFlags{};
+    std::span<const std::uint16_t> slotIndices{};
+};
+
+/** Sub-blocks the delta's field 1 may carry. The wire array declares one element per bubble. */
+inline constexpr std::size_t kBubbleSubBlockCapacity = 64;
+/** Keys one sub-block may carry. Its wire key array declares 96. */
+inline constexpr std::size_t kBubbleKeyCapacity = 96;
+/** The widest bubble a sub-block may name. The client masks the index it compares to six bits. */
+inline constexpr std::uint32_t kMaximumSubBlockBubble = 63;
+
+/**
+ * One per-bubble roster sub-block, an element of the delta's field 1.
+ * Its keys register only while that bubble is current, and the slice-set sweep deactivates them
+ * through the same unchecked lookup, so a key here must be in every slice set of this bubble.
+ */
+struct BubbleSubBlock final {
+    std::uint32_t bubble{};
+    /** Keys registered while that bubble is current. Each one also needs a group to seed it. */
+    std::span<const std::uint32_t> keys{};
 };
 
 /** Which groups one destination publishes and which of them binds the player. */
 struct Roster final {
+    /**
+     * Every group this body registers, top-level first and per-bubble after.
+     * Phase 2 seeds all of them: the client applies auth state only once every object registered
+     * in the current bubble is seeded, so a group left out holds back the whole apply.
+     */
     std::array<Group, kGroupCapacity> groups{};
     std::size_t groupCount{};
+    /** Leading groups that go in the delta's top-level key list. The rest are per-bubble only. */
+    std::size_t topLevelGroupCount{};
     /** Group whose first type-13 block carries the player key. It must be one that registers. */
     std::uint32_t playerKeyGroup{};
+    /** Sub-blocks published in the delta's field 1. None leaves that field one zero bit. */
+    std::span<const BubbleSubBlock> bubbleSubBlocks{};
 };
 
 /** Everything one `sensor_auth_update` carries. */
@@ -128,6 +161,8 @@ inline constexpr std::uint8_t kSlotTypeWidth = 7;
 inline constexpr std::uint8_t kSlotIndexWidth = 16;
 inline constexpr std::uint32_t kSlotTypeBias = 1;
 inline constexpr std::uint32_t kSlotIndexBias = 32768;
+/** The widest slot index the biased 16-bit field carries. One above it wraps to zero. */
+inline constexpr std::uint16_t kMaximumSlotIndex = 32767;
 /** The per-entry state byte is stored biased, so the wire value never goes negative. */
 inline constexpr std::uint32_t kStateByteBias = 0x80;
 
@@ -141,9 +176,45 @@ inline constexpr std::uint32_t kStateByteBias = 0x80;
     return delta_mask_bit(keyCount) + 32 * kDeltaMaskWords + 1;
 }
 
-/** @param keyCount Published group count. @return Total delta size from its own root bit. */
-[[nodiscard]] constexpr std::size_t delta_bits(std::size_t keyCount) noexcept {
-    return delta_state_count_bit(keyCount) + kDeltaCountWidth + 8 * keyCount + 1;
+/** The sub-block count and each nested count are 7-bit fields with no presence bit. */
+inline constexpr std::uint8_t kBubbleCountWidth = 7;
+/** The sub-block's own key is a signed 32-bit field, so its wire value carries the -2^31 bias. */
+inline constexpr std::uint32_t kBubbleKeyBias = 0x80000000;
+/** The sub-block presence mask is three words whatever the key count. */
+inline constexpr std::size_t kBubbleMaskWords = 3;
+/**
+ * Bits one sub-block costs before its keys: the element's own presence bit, its bubble key, the
+ * four remaining flags, both counts and the presence mask. These are the 5 flags per sub-block
+ * that close the schema's 325-flag sum.
+ */
+inline constexpr std::size_t kBubbleSubBlockFixedBits =
+    kPresenceWidth + kKeyWidth + kPresenceWidth + kPresenceWidth + kBubbleCountWidth
+    + kPresenceWidth + 32 * kBubbleMaskWords + kPresenceWidth + kBubbleCountWidth;
+/** Bits one key costs inside a sub-block: the key itself, then its state byte. */
+inline constexpr std::size_t kBubbleSubBlockKeyBits = kKeyWidth + 8;
+
+/**
+ * @param subBlocks Sub-blocks the delta carries, which must not be empty.
+ * @return Bits the whole field-1 half costs, after the field's own presence bit.
+ */
+[[nodiscard]] constexpr std::size_t
+bubble_bits(std::span<const BubbleSubBlock> subBlocks) noexcept {
+    std::size_t bits = kBubbleCountWidth;
+    for (const BubbleSubBlock& block : subBlocks) {
+        bits += kBubbleSubBlockFixedBits + kBubbleSubBlockKeyBits * block.keys.size();
+    }
+    return bits;
+}
+
+/**
+ * @param keyCount Top-level group count.
+ * @param subBlocks Sub-blocks the delta carries, empty when field 1 is absent.
+ * @return Total delta size from its own root bit.
+ */
+[[nodiscard]] constexpr std::size_t delta_bits(std::size_t keyCount,
+                                               std::span<const BubbleSubBlock> subBlocks) noexcept {
+    return delta_state_count_bit(keyCount) + kDeltaCountWidth + 8 * keyCount + 1
+           + (subBlocks.empty() ? 0 : bubble_bits(subBlocks));
 }
 
 /**

+ 63 - 0
Sunrise/src/middleware/bap/activity_message/start_activity.h

@@ -0,0 +1,63 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+namespace sunrise::middleware::bap::activity_message::start_activity {
+
+/** The client asks to start a different activity from inside the world. */
+inline constexpr std::uint32_t kMessageType = 11;
+
+/** The bounded selector opens the body. */
+inline constexpr std::uint8_t kSelectorWidth = 4;
+/** Both activity indices are twelve bits wide. */
+inline constexpr std::uint8_t kActivityIndexWidth = 12;
+/** The optional activity element is nine bits wide. */
+inline constexpr std::uint8_t kElementWidth = 9;
+/** The two optional identity words are full width. */
+inline constexpr std::uint8_t kIdentityWidth = 64;
+/** Every optional field is introduced by one presence bit. */
+inline constexpr std::uint8_t kPresenceWidth = 1;
+/** The selector, both indices, and the optional element all carry a bias of one. */
+inline constexpr std::int32_t kIndexBias = 1;
+/** A biased field that decodes to this value names nothing. */
+inline constexpr std::int32_t kAbsentIndex = -1;
+
+/**
+ * The part of a start-new-activity request whose grammar is recovered.
+ * The body continues with a nested selection record of unresolved shape, so everything from that
+ * record on is retained as one bounded tail instead of being walked.
+ */
+struct StartActivity {
+    /** Bounded selector. Its value meanings are not recovered. */
+    std::int32_t selector{};
+    /** Activity index the client is leaving. */
+    std::int32_t sourceActivityIndex{};
+    /** Activity index the client is asking for. */
+    std::int32_t destinationActivityIndex{};
+    /** Optional activity element, or the absent index when the body carried none. */
+    std::int32_t element{kAbsentIndex};
+    /** Optional opaque identity. */
+    std::int64_t identity{};
+    /** Optional account or selection identity, read as unsigned. */
+    std::uint64_t selectionIdentity{};
+    /** Bits left after the last recovered field. Their grammar is unresolved. */
+    std::uint32_t tailBits{};
+    bool hasElement{};
+    bool hasIdentity{};
+    bool hasSelectionIdentity{};
+};
+
+/**
+ * Parses a start-new-activity request as far as its recovered grammar reaches.
+ * @param input Activity payload after the envelope.
+ * @param request Cleared first, then filled with every field that was reached.
+ * @param consumedBits Receives the bits the recovered fields used.
+ * @return True when every recovered field was present.
+ */
+[[nodiscard]] bool parse_start_activity(std::span<const std::byte> input,
+                                        StartActivity& request,
+                                        std::size_t& consumedBits) noexcept;
+
+} // namespace sunrise::middleware::bap::activity_message::start_activity

+ 108 - 0
Sunrise/src/middleware/bap/activity_message/telemetry.h

@@ -0,0 +1,108 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+namespace sunrise::middleware::bap::activity_message::telemetry {
+
+/** The client asks for peer reservations. The record array behind the revision is unresolved. */
+inline constexpr std::uint32_t kReservationRequestType = 13;
+/** A debug command. It is retained and never executed. */
+inline constexpr std::uint32_t kDebugCommandType = 34;
+/** A client heartbeat carrying one runtime-selected nested body. */
+inline constexpr std::uint32_t kHeartbeatType = 39;
+/** An opaque scalar report whose meaning is not recovered. */
+inline constexpr std::uint32_t kBugClawType = 43;
+/** A lag-switch report: one record count and a record array of unresolved shape. */
+inline constexpr std::uint32_t kLagSwitchType = 46;
+/** A connection-quality report holding two nested structures of unresolved leaf shape. */
+inline constexpr std::uint32_t kConnectionQualityType = 47;
+/** A fixed telemetry block sent at most once a minute. */
+inline constexpr std::uint32_t kHighWaterType = 49;
+/** An opaque scalar request whose known wrapper always sends logical zero. */
+inline constexpr std::uint32_t kRefreshInspirationsType = 50;
+
+/** The reservation revision is one unsigned 32-bit word ahead of the record array. */
+inline constexpr std::size_t kReservationRevisionSize = 4;
+/** An opaque biased scalar body is one 32-bit word. */
+inline constexpr std::size_t kScalarSize = 4;
+/** The high-water body is exactly 416 bits. */
+inline constexpr std::size_t kHighWaterSize = 52;
+/** The lag-switch record count is six bits, so the wire can declare more than is supported. */
+inline constexpr std::uint8_t kLagRecordCountWidth = 6;
+/** Records above this count have no backing evidence, so they are retained and not acted on. */
+inline constexpr std::uint8_t kLagRecordSupported = 32;
+/** The high-water body opens with nine unsigned 32-bit words. */
+inline constexpr std::size_t kHighWaterNarrowCount = 9;
+/** Two unsigned 64-bit words close the high-water body. */
+inline constexpr std::size_t kHighWaterWideCount = 2;
+
+/** One peer-reservation request. The records behind the revision are kept as a bounded region. */
+struct ReservationRequest {
+    std::uint32_t revision{};
+    /** Bytes of record data after the revision. Their grammar is unresolved. */
+    std::uint32_t recordBytes{};
+};
+
+/** One lag-switch report. The records behind the count have an unresolved grammar. */
+struct LagSwitchReport {
+    std::uint8_t recordCount{};
+    /** Bits after the count. Their grammar is unresolved. */
+    std::uint32_t recordBits{};
+    /** Set when the declared count is above what the record grammar is known to support. */
+    bool aboveSupported{};
+};
+
+/** One high-water telemetry block. Every leaf meaning is opaque. */
+struct HighWater {
+    std::array<std::uint32_t, kHighWaterNarrowCount> narrow{};
+    std::array<std::uint64_t, kHighWaterWideCount> wide{};
+};
+
+/**
+ * Parses a peer-reservation request.
+ * @param input Activity payload after the envelope.
+ * @param request Cleared first, then filled.
+ * @param consumedBits Receives the bits the revision used.
+ * @return True when the revision was present.
+ */
+[[nodiscard]] bool parse_reservation_request(std::span<const std::byte> input,
+                                             ReservationRequest& request,
+                                             std::size_t& consumedBits) noexcept;
+
+/**
+ * Parses a lag-switch report as far as its record count.
+ * @param input Activity payload after the envelope.
+ * @param report Cleared first, then filled.
+ * @param consumedBits Receives the bits the count used.
+ * @return True when the count was present.
+ */
+[[nodiscard]] bool parse_lag_switch(std::span<const std::byte> input,
+                                    LagSwitchReport& report,
+                                    std::size_t& consumedBits) noexcept;
+
+/**
+ * Parses the fixed high-water telemetry block.
+ * @param input Activity payload after the envelope.
+ * @param block Cleared first, then filled.
+ * @param consumedBits Receives the bits the block used.
+ * @return True when the whole fixed body was present.
+ */
+[[nodiscard]] bool parse_high_water(std::span<const std::byte> input,
+                                    HighWater& block,
+                                    std::size_t& consumedBits) noexcept;
+
+/**
+ * Parses one biased signed scalar body, shared by the two opaque scalar messages.
+ * @param input Activity payload after the envelope.
+ * @param value Receives the decoded scalar.
+ * @param consumedBits Receives the bits the scalar used.
+ * @return True when the whole fixed body was present.
+ */
+[[nodiscard]] bool parse_opaque_scalar(std::span<const std::byte> input,
+                                       std::int32_t& value,
+                                       std::size_t& consumedBits) noexcept;
+
+} // namespace sunrise::middleware::bap::activity_message::telemetry

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

@@ -138,4 +138,40 @@ bool safe_roster_keys(const RosterIntersection& state,
     return true;
 }
 
+/**
+ * Reports the keys present in some observed slice sets and not all, with their bubbles.
+ * @param state Accumulator for one destination.
+ * @param keys Receives the partially present keys.
+ * @param masks Receives each key's bubbles, in the same order.
+ * @param count Receives how many were written.
+ * @return True when nothing overflowed and every partial key fits the output.
+ */
+bool partial_roster_keys(const RosterIntersection& state,
+                         std::span<std::uint32_t> keys,
+                         std::span<std::uint64_t> masks,
+                         std::size_t& count) noexcept {
+    count = 0;
+    if (state.overflowed || keys.size() != masks.size()) {
+        return false;
+    }
+    // An unread slice set has no index, so no bubble can be ruled in or out and none is safe.
+    if (state.unresolvedSet) {
+        return true;
+    }
+    for (std::size_t index = 0; index < state.keyCount; ++index) {
+        const std::uint64_t mask = state.masks[index];
+        if (mask == 0 || mask == state.observedSets) {
+            continue;
+        }
+        if (count == keys.size()) {
+            count = 0;
+            return false;
+        }
+        keys[count] = state.keys[index];
+        masks[count] = mask;
+        ++count;
+    }
+    return true;
+}
+
 } // namespace sunrise::middleware::content::packages::tables

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

@@ -99,4 +99,19 @@ inline constexpr std::array<std::uint16_t, 9> kRosterSlotTypes = {
                                     std::span<std::uint32_t> output,
                                     std::size_t& count) noexcept;
 
+/**
+ * Reports the keys present in some observed slice sets and not all, with the bubbles holding them.
+ * These belong in a per-bubble sub-block: the top-level list would make the teardown sweep deref a
+ * key the current slice set cannot find. One recorded bit is one bubble.
+ * @param state Accumulator for one destination.
+ * @param keys Receives the partially present keys.
+ * @param masks Receives each key's bubbles, one bit per bubble index, in the same order.
+ * @param count Receives how many were written.
+ * @return True when nothing overflowed and every partial key fits the output.
+ */
+[[nodiscard]] bool partial_roster_keys(const RosterIntersection& state,
+                                       std::span<std::uint32_t> keys,
+                                       std::span<std::uint64_t> masks,
+                                       std::size_t& count) noexcept;
+
 } // namespace sunrise::middleware::content::packages::tables

+ 22 - 0
Sunrise/src/middleware/gameplay/dtls/replay_high_water.cpp

@@ -0,0 +1,22 @@
+#include "replay_high_water.h"
+
+namespace sunrise::middleware::gameplay::dtls {
+
+/** Applies one expanded sequence to the authenticated receive high-water mark. */
+ReplayDecision update(ReplayHighWater& state, std::uint32_t sequence) noexcept {
+    if (!state.initialized) {
+        state.lastSequence = sequence;
+        state.initialized = true;
+        return ReplayDecision::accepted;
+    }
+    if (sequence == state.lastSequence) {
+        return ReplayDecision::duplicate;
+    }
+    if (sequence < state.lastSequence) {
+        return ReplayDecision::tooOld;
+    }
+    state.lastSequence = sequence;
+    return ReplayDecision::accepted;
+}
+
+} // namespace sunrise::middleware::gameplay::dtls

+ 35 - 0
Sunrise/src/middleware/gameplay/dtls/replay_high_water.h

@@ -0,0 +1,35 @@
+#pragma once
+
+#include <cstdint>
+
+namespace sunrise::middleware::gameplay::dtls {
+
+/** Result of applying one sequence to the authenticated receive high-water mark. */
+enum class ReplayDecision : std::uint8_t {
+    accepted,
+    duplicate,
+    tooOld,
+};
+
+/**
+ * Strict authenticated receive high-water mark for one secure association.
+ * This is a high-water mark, not a sliding window: only a sequence above the last accepted one is
+ * admitted, so a reordered record is dropped rather than held.
+ */
+struct ReplayHighWater {
+    /** Highest accepted expanded sequence. */
+    std::uint32_t lastSequence{};
+    /** False until the first sequence is accepted. */
+    bool initialized{};
+};
+
+/**
+ * Applies one expanded sequence to a strict authenticated high-water mark.
+ * Gaps are accepted. Duplicates, older values, and numeric rollover are rejected.
+ * @param state Receive history for one association.
+ * @param sequence Expanded sequence to check.
+ * @return The accept or rejection reason. Rejection does not change the state.
+ */
+[[nodiscard]] ReplayDecision update(ReplayHighWater& state, std::uint32_t sequence) noexcept;
+
+} // namespace sunrise::middleware::gameplay::dtls

+ 51 - 0
Sunrise/src/middleware/gameplay/external/common_state.cpp

@@ -0,0 +1,51 @@
+#include "common_state.h"
+
+namespace sunrise::middleware::gameplay::external {
+namespace {
+
+/** Common epoch and activity values use full reflected 64-bit fields. */
+constexpr std::uint8_t kValueWidth = 64;
+/** The root count selects zero through three entries. */
+constexpr std::uint8_t kCountWidth = 2;
+/** Each activity entry ends with one reconciliation byte. */
+constexpr std::uint8_t kGenerationWidth = 8;
+
+} // namespace
+
+/** Reads one common root without changing output on failure. */
+bool read_common_state(encoding::bits::Reader& reader, CommonState& output) noexcept {
+    CommonState candidate{};
+    std::uint64_t value = 0;
+    if (!reader.read(kValueWidth, candidate.patchEpoch[0])
+        || !reader.read(kValueWidth, candidate.patchEpoch[1]) || !reader.read(kCountWidth, value)) {
+        return false;
+    }
+    candidate.entryCount = static_cast<std::uint8_t>(value);
+    for (std::size_t index = 0; index < candidate.entryCount; ++index) {
+        if (!reader.read(kValueWidth, candidate.entries[index].activitySessionId)
+            || !reader.read(kGenerationWidth, value)) {
+            return false;
+        }
+        candidate.entries[index].reconciliationGeneration = static_cast<std::uint8_t>(value);
+    }
+    output = candidate;
+    return true;
+}
+
+/** Writes one bounded common root. */
+bool write_common_state(encoding::bits::Writer& writer, const CommonState& state) noexcept {
+    if (state.entryCount > state.entries.size() || !writer.write(state.patchEpoch[0], kValueWidth)
+        || !writer.write(state.patchEpoch[1], kValueWidth)
+        || !writer.write(state.entryCount, kCountWidth)) {
+        return false;
+    }
+    for (std::size_t index = 0; index < state.entryCount; ++index) {
+        if (!writer.write(state.entries[index].activitySessionId, kValueWidth)
+            || !writer.write(state.entries[index].reconciliationGeneration, kGenerationWidth)) {
+            return false;
+        }
+    }
+    return true;
+}
+
+} // namespace sunrise::middleware::gameplay::external

+ 35 - 0
Sunrise/src/middleware/gameplay/external/common_state.h

@@ -0,0 +1,35 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+
+#include "../../encoding/bit_reader.h"
+#include "../../encoding/bit_writer.h"
+
+namespace sunrise::middleware::gameplay::external {
+
+/** A common root carries at most three activity entries. */
+inline constexpr std::size_t kCommonEntryCapacity = 3;
+
+/** One activity binding in the gameplay common root. */
+struct CommonEntry {
+    std::uint64_t activitySessionId{};
+    std::uint8_t reconciliationGeneration{};
+};
+
+/** Exact decoded gameplay common root. */
+struct CommonState {
+    std::array<std::uint64_t, 2> patchEpoch{};
+    std::array<CommonEntry, kCommonEntryCapacity> entries{};
+    std::uint8_t entryCount{};
+};
+
+/** Reads one common root without changing output on failure. */
+[[nodiscard]] bool read_common_state(encoding::bits::Reader& reader, CommonState& output) noexcept;
+
+/** Writes one bounded common root. */
+[[nodiscard]] bool write_common_state(encoding::bits::Writer& writer,
+                                      const CommonState& state) noexcept;
+
+} // namespace sunrise::middleware::gameplay::external

+ 77 - 0
Sunrise/src/middleware/gameplay/external/external_empty_profile.cpp

@@ -0,0 +1,77 @@
+#include "external_empty_profile.h"
+
+namespace sunrise::middleware::gameplay::external {
+namespace {
+
+/** Presence and empty-list fields are one bit. */
+constexpr std::uint8_t kFlagWidth = 1;
+/** A channel-2 default bubble is one byte. */
+constexpr std::uint8_t kBubbleWidth = 8;
+
+/** Reads one boolean field. */
+[[nodiscard]] bool read_flag(encoding::bits::Reader& reader, bool& value) noexcept {
+    std::uint64_t field = 0;
+    if (!reader.read(kFlagWidth, field)) {
+        return false;
+    }
+    value = field != 0;
+    return true;
+}
+
+} // namespace
+
+/** Reads the receive-only common root and empty-channel profile. */
+EmptyProfileResult read_empty_profile(encoding::bits::Reader& reader,
+                                      EmptyProfile& output) noexcept {
+    EmptyProfile candidate{};
+    if (!read_flag(reader, candidate.commonPresent)) {
+        return EmptyProfileResult::malformed;
+    }
+    if (candidate.commonPresent && !read_common_state(reader, candidate.common)) {
+        return EmptyProfileResult::malformed;
+    }
+
+    bool present = false;
+    if (!read_flag(reader, present)) {
+        return EmptyProfileResult::malformed;
+    }
+    if (present) {
+        return EmptyProfileResult::channel0Present;
+    }
+    if (!read_flag(reader, present)) {
+        return EmptyProfileResult::malformed;
+    }
+    if (present) {
+        return EmptyProfileResult::channel1Present;
+    }
+    if (!read_flag(reader, present)) {
+        return EmptyProfileResult::malformed;
+    }
+    if (present) {
+        return EmptyProfileResult::channel2Present;
+    }
+    if (!read_flag(reader, candidate.defaultBubblePresent)) {
+        return EmptyProfileResult::malformed;
+    }
+    if (candidate.defaultBubblePresent) {
+        std::uint64_t bubble = 0;
+        if (!reader.read(kBubbleWidth, bubble)) {
+            return EmptyProfileResult::malformed;
+        }
+        candidate.defaultBubble = static_cast<std::uint8_t>(bubble);
+    }
+    if (!read_flag(reader, present)) {
+        return EmptyProfileResult::malformed;
+    }
+    if (present) {
+        return EmptyProfileResult::channel3Present;
+    }
+    if (!read_flag(reader, candidate.channel3TrailingList)) {
+        return EmptyProfileResult::malformed;
+    }
+
+    output = candidate;
+    return EmptyProfileResult::accepted;
+}
+
+} // namespace sunrise::middleware::gameplay::external

+ 32 - 0
Sunrise/src/middleware/gameplay/external/external_empty_profile.h

@@ -0,0 +1,32 @@
+#pragma once
+
+#include <cstdint>
+
+#include "common_state.h"
+
+namespace sunrise::middleware::gameplay::external {
+
+/** Result of reading the receive-only empty-channel profile. */
+enum class EmptyProfileResult : std::uint8_t {
+    accepted,
+    malformed,
+    channel0Present,
+    channel1Present,
+    channel2Present,
+    channel3Present,
+};
+
+/** Common state and bounded fields retained by the empty profile. */
+struct EmptyProfile {
+    CommonState common{};
+    std::uint8_t defaultBubble{};
+    bool commonPresent{};
+    bool defaultBubblePresent{};
+    bool channel3TrailingList{};
+};
+
+/** Reads common state and four empty channels without changing output on failure. */
+[[nodiscard]] EmptyProfileResult read_empty_profile(encoding::bits::Reader& reader,
+                                                    EmptyProfile& output) noexcept;
+
+} // namespace sunrise::middleware::gameplay::external

+ 563 - 0
Sunrise/src/middleware/gameplay/external/external_entity_codec.cpp

@@ -0,0 +1,563 @@
+#include "external_entity_codec.h"
+
+#include <array>
+
+namespace sunrise::middleware::gameplay::external {
+namespace {
+
+/** Presence, count, shortcut, and trailing fields use one bit. */
+constexpr std::uint8_t kFlagWidth = 1;
+/** The wire token carries a 13-bit slot. */
+constexpr std::uint8_t kEntitySlotWidth = 13;
+/** The wire token carries a four-bit incarnation. */
+constexpr std::uint8_t kEntityIncarnationWidth = 4;
+/** A raw bubble that fits on wire uses one byte. */
+constexpr std::uint8_t kRawBubbleWidth = 8;
+/** The optional raw-bubble value is limited to one unsigned byte. */
+constexpr std::uint16_t kMaximumRawBubble = 0xFF;
+/** A strict remove declares its one-bit body in a signed 16-bit field. */
+constexpr std::uint8_t kSubrecordLengthWidth = 16;
+/** A create carries one lifecycle byte. */
+constexpr std::uint8_t kLifecycleRevisionWidth = 8;
+/** A create carries one two-bit entity type. */
+constexpr std::uint8_t kEntityTypeWidth = 2;
+/** A strict administrative remove contains only its trailing false bit. */
+constexpr std::uint16_t kStrictRemoveBodyBits = 1;
+/** All flags represented by the explicit five-bit envelope. */
+constexpr std::uint16_t kAllowedFlags =
+    entityCreate | entityUpdate | entityRemove | entityLifecycle | entityAnchor;
+/** Explicit flags are sent in this fixed order. */
+constexpr std::array<std::uint16_t, 5> kExplicitFlags{
+    entityCreate, entityUpdate, entityRemove, entityLifecycle, entityAnchor};
+
+/** Measured callback lengths used to reject a changed second pass. */
+struct PayloadPlan {
+    std::size_t baselineBits{};
+    std::size_t updateBits{};
+};
+
+/** Reads one boolean field. */
+[[nodiscard]] bool read_flag(encoding::bits::Reader& reader, bool& output) noexcept {
+    std::uint64_t value = 0;
+    if (!reader.read(kFlagWidth, value)) {
+        return false;
+    }
+    output = value != 0;
+    return true;
+}
+
+/** Writes one boolean field. */
+[[nodiscard]] bool write_flag(encoding::bits::Writer& writer, bool value) noexcept {
+    return writer.write(value ? 1U : 0U, kFlagWidth);
+}
+
+/** @return True when a token fits its 17-bit wire identity. */
+[[nodiscard]] bool valid_token(const EntityToken& token) noexcept {
+    return token.slot <= kMaximumEntitySlot && token.incarnation <= kMaximumEntityIncarnation;
+}
+
+/** @return True when a raw bubble is absent or fits the optional byte. */
+[[nodiscard]] bool valid_raw_bubble(std::uint16_t rawBubble) noexcept {
+    return rawBubble == kNoRawBubble || rawBubble <= kMaximumRawBubble;
+}
+
+/** @return True when a value is one of the four wire entity types. */
+[[nodiscard]] bool valid_type(EntityType type) noexcept {
+    return type < EntityType::count;
+}
+
+/** @return True for create/update records or the exact administrative remove form. */
+[[nodiscard]] bool valid_flags(std::uint16_t flags) noexcept {
+    if ((flags & ~kAllowedFlags) != 0) {
+        return false;
+    }
+    if (flags == entityRemove) {
+        return true;
+    }
+    return (flags & entityRemove) == 0 && (flags & (entityCreate | entityUpdate)) != 0;
+}
+
+/** @return The declared callback limit for one type-payload part. */
+[[nodiscard]] std::size_t payload_limit(const TypePayloadCodec& codec,
+                                        TypePayloadPart part) noexcept {
+    return part == TypePayloadPart::baseline ? codec.maximumBaselineBits : codec.maximumUpdateBits;
+}
+
+/** @return True when callback state and its declared bit limit are bounded. */
+[[nodiscard]] bool valid_payload(const TypePayloadCodec& codec,
+                                 TypePayloadPart part,
+                                 const TypePayload& payload) noexcept {
+    return payload.byteCount <= payload.state.size()
+           && payload_limit(codec, part) <= kMaximumTypePayloadBits;
+}
+
+/** Validates the canonical record forms accepted by the generic envelope. */
+[[nodiscard]] bool valid_record(const EntityRecord& record) noexcept {
+    if (!valid_token(record.token) || !valid_raw_bubble(record.rawBubble)
+        || !valid_flags(record.flags)) {
+        return false;
+    }
+    if (record.flags == entityRemove) {
+        return !record.anchorPresent && !record.trailingState && record.lifecycleRevision == 0
+               && record.baseline.byteCount == 0 && record.update.byteCount == 0;
+    }
+    if (!valid_type(record.type) || (record.anchorPresent && (record.flags & entityAnchor) == 0)
+        || ((record.flags & entityAnchor) != 0 && record.anchorPresent
+            && !valid_token(record.anchor))
+        || record.trailingState) {
+        return false;
+    }
+    if ((record.flags & entityCreate) == 0
+        && (record.lifecycleRevision != 0 || record.baseline.byteCount != 0)) {
+        return false;
+    }
+    return (record.flags & entityUpdate) != 0 || record.update.byteCount == 0;
+}
+
+/** Writes one token in slot-then-incarnation order. */
+[[nodiscard]] bool write_token(encoding::bits::Writer& writer, const EntityToken& token) noexcept {
+    return valid_token(token) && writer.write(token.slot, kEntitySlotWidth)
+           && writer.write(token.incarnation, kEntityIncarnationWidth);
+}
+
+/** Reads one token without changing output on failure. */
+[[nodiscard]] bool read_token(encoding::bits::Reader& reader, EntityToken& output) noexcept {
+    std::uint64_t slot = 0;
+    std::uint64_t incarnation = 0;
+    if (!reader.read(kEntitySlotWidth, slot)
+        || !reader.read(kEntityIncarnationWidth, incarnation)) {
+        return false;
+    }
+    output.slot = static_cast<std::uint16_t>(slot);
+    output.incarnation = static_cast<std::uint8_t>(incarnation);
+    return true;
+}
+
+/** Measures one pure payload callback before any envelope bits are written. */
+[[nodiscard]] bool measure_payload(const TypePayloadCodec& codec,
+                                   const EntityRecord& record,
+                                   TypePayloadPart part,
+                                   std::size_t& bitCount) noexcept {
+    const TypePayload& payload =
+        part == TypePayloadPart::baseline ? record.baseline : record.update;
+    if (codec.write == nullptr || !valid_payload(codec, part, payload)) {
+        return false;
+    }
+    encoding::bits::Writer writer = encoding::bits::Writer::measuring();
+    std::size_t ignored = 0;
+    if (!codec.write(codec.context, record.token, record.type, part, payload, writer)
+        || !writer.finish(ignored) || writer.bit_count() > payload_limit(codec, part)) {
+        return false;
+    }
+    bitCount = writer.bit_count();
+    return true;
+}
+
+/** Runs one payload writer and requires the preflight length to remain exact. */
+[[nodiscard]] bool write_payload(const TypePayloadCodec& codec,
+                                 const EntityRecord& record,
+                                 TypePayloadPart part,
+                                 std::size_t expectedBits,
+                                 encoding::bits::Writer& writer) noexcept {
+    const TypePayload& payload =
+        part == TypePayloadPart::baseline ? record.baseline : record.update;
+    const std::size_t before = writer.bit_count();
+    std::size_t ignored = 0;
+    return codec.write != nullptr
+           && codec.write(codec.context, record.token, record.type, part, payload, writer)
+           && writer.finish(ignored) && writer.bit_count() >= before
+           && writer.bit_count() - before == expectedBits;
+}
+
+/** Reads one payload through a bounded callback into temporary state. */
+[[nodiscard]] bool read_payload(const TypePayloadCodec& codec,
+                                const EntityToken& token,
+                                EntityType type,
+                                TypePayloadPart part,
+                                encoding::bits::Reader& reader,
+                                TypePayload& output) noexcept {
+    const std::size_t limit = payload_limit(codec, part);
+    if (codec.read == nullptr || limit > kMaximumTypePayloadBits) {
+        return false;
+    }
+    const std::size_t before = reader.remaining_bits();
+    TypePayload candidate{};
+    std::uint64_t ignored = 0;
+    if (!codec.read(codec.context, token, type, part, reader, candidate) || !reader.read(0, ignored)
+        || candidate.byteCount > candidate.state.size()) {
+        return false;
+    }
+    const std::size_t after = reader.remaining_bits();
+    if (after > before || before - after > limit) {
+        return false;
+    }
+    output = candidate;
+    return true;
+}
+
+/** Resolves the type for an update-only record. */
+[[nodiscard]] bool
+resolve_type(const TypePayloadCodec& codec, const EntityToken& token, EntityType& output) noexcept {
+    EntityType candidate = EntityType::count;
+    if (codec.resolveType == nullptr || !codec.resolveType(codec.context, token, candidate)
+        || !valid_type(candidate)) {
+        return false;
+    }
+    output = candidate;
+    return true;
+}
+
+/** Builds a complete payload plan before the channel-2 header is touched. */
+[[nodiscard]] bool
+prepare_batch(const TypePayloadCodec& codec, const EntityBatch& batch, PayloadPlan& plan) noexcept {
+    plan = {};
+    if (!valid_raw_bubble(batch.defaultRawBubble)) {
+        return false;
+    }
+    if (!batch.recordPresent) {
+        return true;
+    }
+    if (!valid_record(batch.record)) {
+        return false;
+    }
+    if ((batch.record.flags & entityCreate) != 0
+        && !measure_payload(codec, batch.record, TypePayloadPart::baseline, plan.baselineBits)) {
+        return false;
+    }
+    return (batch.record.flags & entityUpdate) == 0
+           || measure_payload(codec, batch.record, TypePayloadPart::update, plan.updateBits);
+}
+
+/** Writes the five explicit flag bits or the update shortcut. */
+[[nodiscard]] bool write_record_flags(encoding::bits::Writer& writer,
+                                      std::uint16_t flags) noexcept {
+    const bool shortcut = flags == entityUpdate;
+    if (!write_flag(writer, shortcut)) {
+        return false;
+    }
+    if (shortcut) {
+        return true;
+    }
+    for (const std::uint16_t flag : kExplicitFlags) {
+        if (!write_flag(writer, (flags & flag) != 0)) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** Reads the shortcut or five explicit flag bits. */
+[[nodiscard]] bool read_record_flags(encoding::bits::Reader& reader,
+                                     std::uint16_t& output) noexcept {
+    bool shortcut = false;
+    if (!read_flag(reader, shortcut)) {
+        return false;
+    }
+    if (shortcut) {
+        output = entityUpdate;
+        return true;
+    }
+    std::uint16_t flags = 0;
+    for (const std::uint16_t flag : kExplicitFlags) {
+        bool present = false;
+        if (!read_flag(reader, present)) {
+            return false;
+        }
+        if (present) {
+            flags |= flag;
+        }
+    }
+    output = flags;
+    return true;
+}
+
+/** Writes one inherited, byte-sized, or reset raw bubble. */
+[[nodiscard]] bool write_record_bubble(encoding::bits::Writer& writer,
+                                       std::uint16_t defaultRawBubble,
+                                       std::uint16_t rawBubble) noexcept {
+    const bool changed = rawBubble != defaultRawBubble;
+    if (!write_flag(writer, changed)) {
+        return false;
+    }
+    if (!changed) {
+        return true;
+    }
+    const bool fits = rawBubble <= kMaximumRawBubble;
+    return write_flag(writer, fits) && (!fits || writer.write(rawBubble, kRawBubbleWidth));
+}
+
+/** Reads one inherited, byte-sized, or reset raw bubble. */
+[[nodiscard]] bool read_record_bubble(encoding::bits::Reader& reader,
+                                      std::uint16_t defaultRawBubble,
+                                      std::uint16_t& output) noexcept {
+    bool changed = false;
+    if (!read_flag(reader, changed)) {
+        return false;
+    }
+    if (!changed) {
+        output = defaultRawBubble;
+        return true;
+    }
+    bool fits = false;
+    if (!read_flag(reader, fits)) {
+        return false;
+    }
+    if (!fits) {
+        output = kNoRawBubble;
+        return true;
+    }
+    std::uint64_t bubble = 0;
+    if (!reader.read(kRawBubbleWidth, bubble)) {
+        return false;
+    }
+    output = static_cast<std::uint16_t>(bubble);
+    return true;
+}
+
+/** Writes one validated record body after its token and batch default. */
+[[nodiscard]] bool write_record(encoding::bits::Writer& writer,
+                                const TypePayloadCodec& codec,
+                                const EntityRecord& record,
+                                std::uint16_t defaultRawBubble,
+                                const PayloadPlan& plan) noexcept {
+    if (!write_record_flags(writer, record.flags)) {
+        return false;
+    }
+    if ((record.flags & entityAnchor) != 0
+        && (!write_flag(writer, record.anchorPresent)
+            || (record.anchorPresent && !write_token(writer, record.anchor)))) {
+        return false;
+    }
+    if (!write_record_bubble(writer, defaultRawBubble, record.rawBubble)) {
+        return false;
+    }
+    if (record.flags == entityRemove
+        && !writer.write(kStrictRemoveBodyBits, kSubrecordLengthWidth)) {
+        return false;
+    }
+    if ((record.flags & entityCreate) != 0
+        && (!writer.write(record.lifecycleRevision, kLifecycleRevisionWidth)
+            || !writer.write(static_cast<std::uint8_t>(record.type), kEntityTypeWidth)
+            || !write_payload(
+                codec, record, TypePayloadPart::baseline, plan.baselineBits, writer))) {
+        return false;
+    }
+    if ((record.flags & entityUpdate) != 0
+        && !write_payload(codec, record, TypePayloadPart::update, plan.updateBits, writer)) {
+        return false;
+    }
+    return (record.flags & entityRemove) == 0 || write_flag(writer, record.trailingState);
+}
+
+/** Reads one record body into temporary state. */
+[[nodiscard]] bool read_record(encoding::bits::Reader& reader,
+                               const TypePayloadCodec& codec,
+                               std::uint16_t defaultRawBubble,
+                               EntityRecord& output) noexcept {
+    EntityRecord candidate{};
+    candidate.token = output.token;
+    if (!read_record_flags(reader, candidate.flags) || !valid_flags(candidate.flags)) {
+        return false;
+    }
+    if ((candidate.flags & entityAnchor) != 0
+        && (!read_flag(reader, candidate.anchorPresent)
+            || (candidate.anchorPresent && !read_token(reader, candidate.anchor)))) {
+        return false;
+    }
+    if (!read_record_bubble(reader, defaultRawBubble, candidate.rawBubble)) {
+        return false;
+    }
+    if ((candidate.flags & (entityCreate | entityRemove)) == entityRemove) {
+        std::uint64_t bitLength = 0;
+        if (!reader.read(kSubrecordLengthWidth, bitLength) || bitLength != kStrictRemoveBodyBits) {
+            return false;
+        }
+    }
+    if ((candidate.flags & entityCreate) != 0) {
+        std::uint64_t revision = 0;
+        std::uint64_t type = 0;
+        if (!reader.read(kLifecycleRevisionWidth, revision)
+            || !reader.read(kEntityTypeWidth, type)) {
+            return false;
+        }
+        candidate.lifecycleRevision = static_cast<std::uint8_t>(revision);
+        candidate.type = static_cast<EntityType>(type);
+        if (!valid_type(candidate.type)
+            || !read_payload(codec,
+                             candidate.token,
+                             candidate.type,
+                             TypePayloadPart::baseline,
+                             reader,
+                             candidate.baseline)) {
+            return false;
+        }
+    } else if ((candidate.flags & entityUpdate) != 0
+               && !resolve_type(codec, candidate.token, candidate.type)) {
+        return false;
+    }
+    if ((candidate.flags & entityUpdate) != 0
+        && !read_payload(codec,
+                         candidate.token,
+                         candidate.type,
+                         TypePayloadPart::update,
+                         reader,
+                         candidate.update)) {
+        return false;
+    }
+    if ((candidate.flags & entityRemove) != 0
+        && (!read_flag(reader, candidate.trailingState) || candidate.trailingState)) {
+        return false;
+    }
+    if (!valid_record(candidate)) {
+        return false;
+    }
+    output = candidate;
+    return true;
+}
+
+/** Writes a prepared channel-2 batch without repeating callback preflight. */
+[[nodiscard]] bool write_batch_fields(encoding::bits::Writer& writer,
+                                      const TypePayloadCodec& codec,
+                                      const EntityBatch& batch,
+                                      const PayloadPlan& plan) noexcept {
+    if (!write_flag(writer, batch.recordPresent)
+        || (batch.recordPresent && !write_token(writer, batch.record.token))) {
+        return false;
+    }
+    const bool defaultPresent = batch.defaultRawBubble != kNoRawBubble;
+    if (!write_flag(writer, defaultPresent)
+        || (defaultPresent && !writer.write(batch.defaultRawBubble, kRawBubbleWidth))) {
+        return false;
+    }
+    return !batch.recordPresent
+           || write_record(writer, codec, batch.record, batch.defaultRawBubble, plan);
+}
+
+/** Reads a channel-2 batch into temporary state. */
+[[nodiscard]] bool read_batch_fields(encoding::bits::Reader& reader,
+                                     const TypePayloadCodec& codec,
+                                     EntityBatch& output) noexcept {
+    EntityBatch candidate{};
+    if (!read_flag(reader, candidate.recordPresent)
+        || (candidate.recordPresent && !read_token(reader, candidate.record.token))) {
+        return false;
+    }
+    bool defaultPresent = false;
+    if (!read_flag(reader, defaultPresent)) {
+        return false;
+    }
+    if (defaultPresent) {
+        std::uint64_t bubble = 0;
+        if (!reader.read(kRawBubbleWidth, bubble)) {
+            return false;
+        }
+        candidate.defaultRawBubble = static_cast<std::uint16_t>(bubble);
+    }
+    if (candidate.recordPresent
+        && !read_record(reader, codec, candidate.defaultRawBubble, candidate.record)) {
+        return false;
+    }
+    output = candidate;
+    return true;
+}
+
+/** Writes common and all four fixed channels using one prepared entity plan. */
+[[nodiscard]] bool write_frame_fields(encoding::bits::Writer& writer,
+                                      const TypePayloadCodec& codec,
+                                      const ExternalEntityFrame& frame,
+                                      const PayloadPlan& plan) noexcept {
+    if (!write_flag(writer, frame.commonPresent)
+        || (frame.commonPresent && !write_common_state(writer, frame.common))) {
+        return false;
+    }
+    // Channels 0 and 1 are empty until their type registries have server producers.
+    if (!write_flag(writer, false) || !write_flag(writer, false)
+        || !write_batch_fields(writer, codec, frame.entities, plan)) {
+        return false;
+    }
+    // Channel 3 and its enclosing list stay absent in the generic fallback.
+    return write_flag(writer, false) && write_flag(writer, false);
+}
+
+/** Reads common and all four fixed channels into temporary state. */
+[[nodiscard]] bool read_frame_fields(encoding::bits::Reader& reader,
+                                     const TypePayloadCodec& codec,
+                                     ExternalEntityFrame& output) noexcept {
+    ExternalEntityFrame candidate{};
+    if (!read_flag(reader, candidate.commonPresent)
+        || (candidate.commonPresent && !read_common_state(reader, candidate.common))) {
+        return false;
+    }
+    bool present = false;
+    if (!read_flag(reader, present) || present || !read_flag(reader, present) || present
+        || !read_batch_fields(reader, codec, candidate.entities) || !read_flag(reader, present)
+        || present || !read_flag(reader, present) || present) {
+        return false;
+    }
+    output = candidate;
+    return true;
+}
+
+} // namespace
+
+/** Reads one channel-2 batch and commits the reader and output only on success. */
+bool read_entity_batch(encoding::bits::Reader& reader,
+                       const TypePayloadCodec& codec,
+                       EntityBatch& output) noexcept {
+    encoding::bits::Reader candidateReader = reader;
+    EntityBatch candidate{};
+    if (!read_batch_fields(candidateReader, codec, candidate)) {
+        return false;
+    }
+    reader = candidateReader;
+    output = candidate;
+    return true;
+}
+
+/** Writes one channel-2 batch after a complete fail-closed preflight. */
+bool write_entity_batch(encoding::bits::Writer& writer,
+                        const TypePayloadCodec& codec,
+                        const EntityBatch& batch) noexcept {
+    PayloadPlan plan{};
+    if (!prepare_batch(codec, batch, plan)) {
+        return false;
+    }
+    encoding::bits::Writer measuring = encoding::bits::Writer::measuring();
+    std::size_t ignored = 0;
+    if (!write_batch_fields(measuring, codec, batch, plan) || !measuring.finish(ignored)) {
+        return false;
+    }
+    return write_batch_fields(writer, codec, batch, plan);
+}
+
+/** Reads the fixed four-channel wrapper and commits no state on failure. */
+bool read_external_entity_frame(encoding::bits::Reader& reader,
+                                const TypePayloadCodec& codec,
+                                ExternalEntityFrame& output) noexcept {
+    encoding::bits::Reader candidateReader = reader;
+    ExternalEntityFrame candidate{};
+    if (!read_frame_fields(candidateReader, codec, candidate)) {
+        return false;
+    }
+    reader = candidateReader;
+    output = candidate;
+    return true;
+}
+
+/** Writes the fixed four-channel wrapper after a complete fail-closed preflight. */
+bool write_external_entity_frame(encoding::bits::Writer& writer,
+                                 const TypePayloadCodec& codec,
+                                 const ExternalEntityFrame& frame) noexcept {
+    PayloadPlan plan{};
+    if (!prepare_batch(codec, frame.entities, plan)) {
+        return false;
+    }
+    encoding::bits::Writer measuring = encoding::bits::Writer::measuring();
+    std::size_t ignored = 0;
+    if (!write_frame_fields(measuring, codec, frame, plan) || !measuring.finish(ignored)) {
+        return false;
+    }
+    return write_frame_fields(writer, codec, frame, plan);
+}
+
+} // namespace sunrise::middleware::gameplay::external

+ 144 - 0
Sunrise/src/middleware/gameplay/external/external_entity_codec.h

@@ -0,0 +1,144 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+
+#include "../../encoding/bit_reader.h"
+#include "../../encoding/bit_writer.h"
+#include "common_state.h"
+
+namespace sunrise::middleware::gameplay::external {
+
+/** The entity batch carries zero or one record. */
+inline constexpr std::size_t kEntityBatchCapacity = 1;
+/** A token slot is the low 13 bits of the 17-bit wire token. */
+inline constexpr std::uint16_t kMaximumEntitySlot = 0x1FFF;
+/** A token incarnation is the high four bits of the 17-bit wire token. */
+inline constexpr std::uint8_t kMaximumEntityIncarnation = 0x0F;
+/** An absent or non-byte raw bubble uses the 16-bit reset value. */
+inline constexpr std::uint16_t kNoRawBubble = 0xFFFF;
+/** Callback state is bounded to 256 bytes per baseline or update. */
+inline constexpr std::size_t kTypePayloadStateCapacity = 256;
+/** One callback body is capped at 16,384 bits by host policy. */
+inline constexpr std::size_t kMaximumTypePayloadBits = 16'384;
+
+/** Record flags transported by the five explicit envelope bits. */
+enum EntityRecordFlag : std::uint16_t {
+    entityCreate = 0x0001,
+    entityUpdate = 0x0002,
+    entityRemove = 0x0004,
+    entityLifecycle = 0x0008,
+    entityAnchor = 0x0040,
+};
+
+/** The two-bit entity type carried by a create record. */
+enum class EntityType : std::uint8_t {
+    sobject = 0,
+    squad = 1,
+    playerBroadcast = 2,
+    test = 3,
+    count,
+};
+
+/** Selects the type-specific body requested from a payload codec. */
+enum class TypePayloadPart : std::uint8_t {
+    baseline,
+    update,
+};
+
+/** One 17-bit entity identity. The simulation-world id stays in stream context. */
+struct EntityToken {
+    std::uint16_t slot{};
+    std::uint8_t incarnation{};
+};
+
+/** Fixed callback-owned semantic state for one type payload. */
+struct TypePayload {
+    std::array<std::byte, kTypePayloadStateCapacity> state{};
+    std::uint16_t byteCount{};
+};
+
+/** One generic channel-2 record and its callback-owned type state. */
+struct EntityRecord {
+    EntityToken token{};
+    EntityToken anchor{};
+    TypePayload baseline{};
+    TypePayload update{};
+    std::uint16_t rawBubble{kNoRawBubble};
+    std::uint16_t flags{};
+    std::uint8_t lifecycleRevision{};
+    EntityType type{EntityType::sobject};
+    bool anchorPresent{};
+    bool trailingState{};
+};
+
+/** One bounded channel-2 batch. */
+struct EntityBatch {
+    EntityRecord record{};
+    std::uint16_t defaultRawBubble{kNoRawBubble};
+    bool recordPresent{};
+};
+
+/** Resolves the existing type needed by an update-only record. */
+using ResolveEntityType = bool (*)(const void* context,
+                                   const EntityToken& token,
+                                   EntityType& output) noexcept;
+
+/** Reads one callback-owned baseline or update body. */
+using ReadTypePayload = bool (*)(const void* context,
+                                 const EntityToken& token,
+                                 EntityType type,
+                                 TypePayloadPart part,
+                                 encoding::bits::Reader& reader,
+                                 TypePayload& output) noexcept;
+
+/** Writes one callback-owned baseline or update body. */
+using WriteTypePayload = bool (*)(const void* context,
+                                  const EntityToken& token,
+                                  EntityType type,
+                                  TypePayloadPart part,
+                                  const TypePayload& payload,
+                                  encoding::bits::Writer& writer) noexcept;
+
+/** Bounded type-payload callbacks. Empty callbacks are the safe scriptless fallback. */
+struct TypePayloadCodec {
+    const void* context{};
+    ResolveEntityType resolveType{};
+    ReadTypePayload read{};
+    WriteTypePayload write{};
+    std::size_t maximumBaselineBits{};
+    std::size_t maximumUpdateBits{};
+};
+
+/** Common state plus the fixed empty channel-0, channel-1, and channel-3 profile. */
+struct ExternalEntityFrame {
+    CommonState common{};
+    EntityBatch entities{};
+    bool commonPresent{};
+};
+
+/**
+ * Reads one channel-2 batch and commits the reader and output only on success.
+ * TODO: no caller yet. The four entry points below wait on the `gameplay_external_body` gate.
+ */
+[[nodiscard]] bool read_entity_batch(encoding::bits::Reader& reader,
+                                     const TypePayloadCodec& codec,
+                                     EntityBatch& output) noexcept;
+
+/** Writes one channel-2 batch after a complete fail-closed preflight. */
+[[nodiscard]] bool write_entity_batch(encoding::bits::Writer& writer,
+                                      const TypePayloadCodec& codec,
+                                      const EntityBatch& batch) noexcept;
+
+/** Reads the fixed four-channel wrapper and commits no state on failure. */
+[[nodiscard]] bool read_external_entity_frame(encoding::bits::Reader& reader,
+                                              const TypePayloadCodec& codec,
+                                              ExternalEntityFrame& output) noexcept;
+
+/** Writes the fixed four-channel wrapper after a complete fail-closed preflight. */
+[[nodiscard]] bool write_external_entity_frame(encoding::bits::Writer& writer,
+                                               const TypePayloadCodec& codec,
+                                               const ExternalEntityFrame& frame) noexcept;
+
+} // namespace sunrise::middleware::gameplay::external

+ 35 - 0
Sunrise/src/middleware/gameplay/group/member_messages.cpp

@@ -63,4 +63,39 @@ bool read_player_add(bits::Reader& reader, PlayerAddRequest& output) noexcept {
     return true;
 }
 
+/** Reads a whole player-remove message. */
+bool read_player_remove(bits::Reader& reader, PlayerRemoveRequest& output) noexcept {
+    PlayerRemoveRequest candidate{};
+    std::uint64_t reserved = 0;
+    if (!bits::read_raw_u64(reader, candidate.sessionId)
+        || !reader.read(kReservedWidth, reserved)) {
+        return false;
+    }
+    // The consumer refuses a set reserved bit, the same as it does on an add.
+    if (reserved != 0) {
+        return false;
+    }
+    output = candidate;
+    return true;
+}
+
+/** Reads the leading fields of a player-properties message. */
+bool read_player_properties_header(bits::Reader& reader, PlayerPropertiesRequest& output) noexcept {
+    PlayerPropertiesRequest candidate{};
+    std::uint64_t reserved = 0;
+    std::uint64_t sequence = 0;
+    std::uint64_t kind = 0;
+    if (!bits::read_raw_u64(reader, candidate.sessionId) || !reader.read(kReservedWidth, reserved)
+        || !reader.read(kSequenceWidth, sequence) || !reader.read(kKindWidth, kind)) {
+        return false;
+    }
+    if (reserved != 0) {
+        return false;
+    }
+    candidate.sequence = static_cast<std::uint32_t>(sequence);
+    candidate.kind = static_cast<std::uint8_t>(kind);
+    output = candidate;
+    return true;
+}
+
 } // namespace sunrise::middleware::gameplay::group

+ 44 - 0
Sunrise/src/middleware/gameplay/group/member_messages.h

@@ -10,10 +10,18 @@ namespace sunrise::middleware::gameplay::group {
 inline constexpr std::uint8_t kPeerPropertiesId = 31;
 /** Registry id a member uses to ask the host to add one player. */
 inline constexpr std::uint8_t kPlayerAddId = 34;
+/** Registry id a member uses to ask the host to remove its player. */
+inline constexpr std::uint8_t kPlayerRemoveId = 36;
+/** Registry id a member uses to publish a sparse change to its player record. */
+inline constexpr std::uint8_t kPlayerPropertiesId = 37;
 /** Declared decoded size of a peer-properties message. */
 inline constexpr std::uint32_t kPeerPropertiesSize = 408;
 /** Declared decoded size of a player-add message. */
 inline constexpr std::uint32_t kPlayerAddSize = 288;
+/** Declared decoded size of a player-remove message. */
+inline constexpr std::uint32_t kPlayerRemoveSize = 12;
+/** Declared decoded size of a player-properties message. */
+inline constexpr std::uint32_t kPlayerPropertiesSize = 288;
 
 /**
  * Leading fields of a peer-properties message.
@@ -46,6 +54,24 @@ struct PlayerAddRequest {
 [[nodiscard]] bool read_peer_properties_header(encoding::bits::Reader& reader,
                                                PeerPropertiesHeader& output) noexcept;
 
+/**
+ * A player-remove message. It names no player: the identity comes from the bound peer state.
+ */
+struct PlayerRemoveRequest {
+    std::uint64_t sessionId{};
+};
+
+/**
+ * Leading fields of a player-properties message.
+ * The 232-byte sparse player record and its 20-byte tail after them are not decoded.
+ */
+struct PlayerPropertiesRequest {
+    std::uint64_t sessionId{};
+    std::uint32_t sequence{};
+    /** Player kind, 0 through 3. */
+    std::uint8_t kind{};
+};
+
 /**
  * Reads the identity fields of a player-add message.
  * @param reader Reader positioned at the body.
@@ -55,4 +81,22 @@ struct PlayerAddRequest {
 [[nodiscard]] bool read_player_add(encoding::bits::Reader& reader,
                                    PlayerAddRequest& output) noexcept;
 
+/**
+ * Reads a whole player-remove message.
+ * @param reader Reader positioned at the body.
+ * @param output Receives the session id.
+ * @return True when both fields were present and the reserved bit read zero.
+ */
+[[nodiscard]] bool read_player_remove(encoding::bits::Reader& reader,
+                                      PlayerRemoveRequest& output) noexcept;
+
+/**
+ * Reads the leading fields of a player-properties message.
+ * @param reader Reader positioned at the body.
+ * @param output Receives the fields.
+ * @return True when every field was present and the reserved bit read zero.
+ */
+[[nodiscard]] bool read_player_properties_header(encoding::bits::Reader& reader,
+                                                 PlayerPropertiesRequest& output) noexcept;
+
 } // namespace sunrise::middleware::gameplay::group

+ 166 - 0
Sunrise/src/middleware/gameplay/group/migration_messages.cpp

@@ -0,0 +1,166 @@
+/**
+ * Host-migration and election bodies. Each one is read into typed fields and nothing more: this
+ * host keeps a stable host, and an answer built from a half-read election can leave two peers each
+ * believing they carry the group.
+ */
+
+#include "migration_messages.h"
+
+#include "../../encoding/bit_raw.h"
+
+namespace sunrise::middleware::gameplay::group {
+
+namespace {
+
+namespace bits = encoding::bits;
+
+/** The successor member index is six bits. */
+constexpr std::uint8_t kSuccessorIndexWidth = 6;
+/** Handoff progress is seven bits, so the wire can declare more than a percentage. */
+constexpr std::uint8_t kProgressWidth = 7;
+/** The transition token is a raw 32-bit field. */
+constexpr std::size_t kTransitionTokenBytes = 4;
+/** Key material the reestablishment carries, in bytes. It is skipped, never kept. */
+constexpr std::size_t kKeyMaterialBytes = 16;
+/** The online session id the reestablishment carries, in bytes. It is skipped, never kept. */
+constexpr std::size_t kOnlineSessionBytes = 18;
+/** Every optional field is introduced by one presence bit. */
+constexpr std::uint8_t kPresenceWidth = 1;
+/** The candidate count is six bits, so the wire can declare more than the bound allows. */
+constexpr std::uint8_t kCandidateCountWidth = 6;
+/** The refuse code is four bits. */
+constexpr std::uint8_t kRefuseCodeWidth = 4;
+/** Raw byte fields are read as whole bytes. */
+constexpr std::uint8_t kBitsPerByte = 8;
+
+} // namespace
+
+/** Reads a host-handoff or peer-handoff body. */
+bool read_host_handoff(bits::Reader& reader, HostHandoff& output) noexcept {
+    HostHandoff candidate{};
+    std::uint64_t index = 0;
+    if (!bits::read_raw_u64(reader, candidate.sessionId)
+        || !bits::read_raw(reader, candidate.successorAddress)
+        || !reader.read(kSuccessorIndexWidth, index)) {
+        return false;
+    }
+    candidate.successorIndex = static_cast<std::uint8_t>(index);
+    output = candidate;
+    return true;
+}
+
+/** Reads a host-transition body. */
+bool read_host_transition(bits::Reader& reader, HostTransition& output) noexcept {
+    HostTransition candidate{};
+    std::uint64_t progress = 0;
+    std::array<std::byte, kTransitionTokenBytes> token{};
+    if (!bits::read_raw_u64(reader, candidate.sessionId) || !reader.read(kProgressWidth, progress)
+        || !bits::read_raw(reader, token)) {
+        return false;
+    }
+    if (progress > kMaximumHandoffProgress) {
+        return false;
+    }
+    candidate.progress = static_cast<std::uint8_t>(progress);
+    for (std::size_t index = 0; index < token.size(); ++index) {
+        candidate.transitionToken |= std::to_integer<std::uint32_t>(token[index])
+                                     << (static_cast<unsigned>(index) * kBitsPerByte);
+    }
+    output = candidate;
+    return true;
+}
+
+/** Reads a host-reestablish body. */
+bool read_host_reestablish(bits::Reader& reader, HostReestablish& output) noexcept {
+    HostReestablish candidate{};
+    // The key material and online session id belong to the peer's own session. They are consumed
+    // so the body is framed and are never copied out.
+    if (!bits::read_raw_u64(reader, candidate.sessionId)
+        || !bits::read_raw_u64(reader, candidate.machineId)
+        || !bits::read_raw(reader, candidate.address) || !bits::skip_raw(reader, kKeyMaterialBytes)
+        || !bits::skip_raw(reader, kOnlineSessionBytes)) {
+        return false;
+    }
+    output = candidate;
+    return true;
+}
+
+/** Reads a session-only migration body. */
+bool read_migration_session(bits::Reader& reader, std::uint64_t& sessionId) noexcept {
+    sessionId = 0;
+    return bits::read_raw_u64(reader, sessionId);
+}
+
+/** Reads a host-decline body. */
+bool read_host_decline(bits::Reader& reader, HostDecline& output) noexcept {
+    HostDecline candidate{};
+    std::uint64_t present = 0;
+    if (!bits::read_raw_u64(reader, candidate.sessionId) || !reader.read(kPresenceWidth, present)) {
+        return false;
+    }
+    candidate.hasDeclineData = present != 0;
+    if (!candidate.hasDeclineData) {
+        output = candidate;
+        return true;
+    }
+    std::uint64_t flag = 0;
+    std::uint64_t hasAddress = 0;
+    if (!reader.read(kPresenceWidth, flag) || !reader.read(kPresenceWidth, hasAddress)) {
+        return false;
+    }
+    candidate.declineFlag = flag != 0;
+    candidate.hasAddress = hasAddress != 0;
+    if (candidate.hasAddress && !bits::read_raw(reader, candidate.address)) {
+        return false;
+    }
+    output = candidate;
+    return true;
+}
+
+/** Reads an election as far as its candidate addresses. */
+bool read_election(bits::Reader& reader, Election& output) noexcept {
+    Election candidate{};
+    std::uint64_t count = 0;
+    if (!bits::read_raw_u64(reader, candidate.sessionId)
+        || !bits::read_raw(reader, candidate.previousHost)
+        || !reader.read(kCandidateCountWidth, count)) {
+        return false;
+    }
+    if (count > kMaximumCandidates) {
+        return false;
+    }
+    candidate.candidateCount = static_cast<std::uint8_t>(count);
+    for (std::uint8_t index = 0; index < candidate.candidateCount; ++index) {
+        if (!bits::skip_raw(reader, descriptor::kNetAddrSize)) {
+            return false;
+        }
+    }
+    // The per-candidate value width is unrecovered, so the three bitsets behind it cannot be
+    // located and the rest of the body stays one bounded region.
+    candidate.tailBits = static_cast<std::uint32_t>(reader.remaining_bits());
+    output = candidate;
+    return true;
+}
+
+/** Reads an election refusal. */
+bool read_election_refuse(bits::Reader& reader, ElectionRefuse& output) noexcept {
+    ElectionRefuse candidate{};
+    std::uint64_t code = 0;
+    std::uint64_t present = 0;
+    if (!bits::read_raw_u64(reader, candidate.sessionId) || !reader.read(kRefuseCodeWidth, code)
+        || !reader.read(kPresenceWidth, present)) {
+        return false;
+    }
+    if (code < kMinimumRefuseCode || code > kMaximumRefuseCode) {
+        return false;
+    }
+    candidate.refuseCode = static_cast<std::uint8_t>(code);
+    candidate.hasCandidateAddress = present != 0;
+    if (candidate.hasCandidateAddress && !bits::read_raw(reader, candidate.candidateAddress)) {
+        return false;
+    }
+    output = candidate;
+    return true;
+}
+
+} // namespace sunrise::middleware::gameplay::group

+ 144 - 0
Sunrise/src/middleware/gameplay/group/migration_messages.h

@@ -0,0 +1,144 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+
+#include "../../encoding/bit_reader.h"
+#include "../descriptor/join_descriptor.h"
+
+namespace sunrise::middleware::gameplay::group {
+
+/**
+ * Registry ids of the host-migration and election messages. Transport authority, not gameplay.
+ * A valid inbound body is still read: an unread one leaves the peer''s election state and this
+ * host''s disagreeing, and an echoed one can leave two hosts believing they won.
+ */
+enum class MigrationMessageId : std::uint8_t {
+    hostHandoff = 19,
+    peerHandoff = 20,
+    hostTransition = 21,
+    hostReestablish = 22,
+    reestablishPending = 23,
+    hostDecline = 24,
+    peerReestablish = 25,
+    election = 27,
+    electionRefuse = 28,
+};
+
+/** Declared decoded sizes the registry holds for those ids. */
+inline constexpr std::uint32_t kHostHandoffSize = 100;
+/** See kHostHandoffSize. */
+inline constexpr std::uint32_t kPeerHandoffSize = 100;
+/** See kHostHandoffSize. */
+inline constexpr std::uint32_t kHostTransitionSize = 16;
+/** See kHostHandoffSize. */
+inline constexpr std::uint32_t kHostReestablishSize = 136;
+/** See kHostHandoffSize. */
+inline constexpr std::uint32_t kReestablishPendingSize = 8;
+/** See kHostHandoffSize. */
+inline constexpr std::uint32_t kHostDeclineSize = 98;
+/** See kHostHandoffSize. */
+inline constexpr std::uint32_t kPeerReestablishSize = 8;
+/** See kHostHandoffSize. */
+inline constexpr std::uint32_t kElectionSize = 2992;
+/** See kHostHandoffSize. */
+inline constexpr std::uint32_t kElectionRefuseSize = 100;
+
+/** Handoff progress is a percentage and never exceeds this. */
+inline constexpr std::uint8_t kMaximumHandoffProgress = 100;
+/** An election names at most this many candidates. */
+inline constexpr std::uint8_t kMaximumCandidates = 32;
+/** Refuse codes run from one through ten. */
+inline constexpr std::uint8_t kMinimumRefuseCode = 1;
+/** See kMinimumRefuseCode. */
+inline constexpr std::uint8_t kMaximumRefuseCode = 10;
+
+/** Body of a host handoff and of the acknowledgement that mirrors it. */
+struct HostHandoff {
+    std::uint64_t sessionId{};
+    std::array<std::byte, descriptor::kNetAddrSize> successorAddress{};
+    /** Member index the old host nominates. */
+    std::uint8_t successorIndex{};
+};
+
+/** Body of a host transition report. */
+struct HostTransition {
+    std::uint64_t sessionId{};
+    /** Percentage through the handoff. */
+    std::uint8_t progress{};
+    std::uint32_t transitionToken{};
+};
+
+/**
+ * Body of a host reestablishment.
+ * The key material and online session id are read as opaque regions and never kept: they are the
+ * peer's own session material and this host has no use for either.
+ */
+struct HostReestablish {
+    std::uint64_t sessionId{};
+    std::uint64_t machineId{};
+    std::array<std::byte, descriptor::kNetAddrSize> address{};
+};
+
+/** Body of a host decline. Everything after the session is optional. */
+struct HostDecline {
+    std::uint64_t sessionId{};
+    std::array<std::byte, descriptor::kNetAddrSize> address{};
+    bool hasDeclineData{};
+    bool declineFlag{};
+    bool hasAddress{};
+};
+
+/**
+ * The recovered prefix of an election.
+ * The per-candidate value width is not recovered, so the fields behind the candidate addresses
+ * are one bounded tail rather than a sequence this parser can locate.
+ */
+struct Election {
+    std::uint64_t sessionId{};
+    std::array<std::byte, descriptor::kNetAddrSize> previousHost{};
+    std::uint8_t candidateCount{};
+    /** Bits left after the candidate addresses. Their grammar is unresolved. */
+    std::uint32_t tailBits{};
+};
+
+/** Body of an election refusal. */
+struct ElectionRefuse {
+    std::uint64_t sessionId{};
+    std::array<std::byte, descriptor::kNetAddrSize> candidateAddress{};
+    std::uint8_t refuseCode{};
+    bool hasCandidateAddress{};
+};
+
+/** Reads a host-handoff or peer-handoff body. @return True when every field was present. */
+[[nodiscard]] bool read_host_handoff(encoding::bits::Reader& reader, HostHandoff& output) noexcept;
+
+/** Reads a host-transition body. @return True when every field was present and in range. */
+[[nodiscard]] bool read_host_transition(encoding::bits::Reader& reader,
+                                        HostTransition& output) noexcept;
+
+/** Reads a host-reestablish body. @return True when every field was present. */
+[[nodiscard]] bool read_host_reestablish(encoding::bits::Reader& reader,
+                                         HostReestablish& output) noexcept;
+
+/** Reads a session-only migration body, shared by ids 23 and 25. */
+[[nodiscard]] bool read_migration_session(encoding::bits::Reader& reader,
+                                          std::uint64_t& sessionId) noexcept;
+
+/** Reads a host-decline body. @return True when the selected form was complete. */
+[[nodiscard]] bool read_host_decline(encoding::bits::Reader& reader, HostDecline& output) noexcept;
+
+/**
+ * Reads an election as far as its candidate addresses.
+ * @param reader Reader positioned at the body.
+ * @param output Receives the prefix and the size of the unread tail.
+ * @return True when the prefix was present and the candidate count is in range.
+ */
+[[nodiscard]] bool read_election(encoding::bits::Reader& reader, Election& output) noexcept;
+
+/** Reads an election refusal. @return True when the selected form was complete and in range. */
+[[nodiscard]] bool read_election_refuse(encoding::bits::Reader& reader,
+                                        ElectionRefuse& output) noexcept;
+
+} // namespace sunrise::middleware::gameplay::group

+ 103 - 0
Sunrise/src/middleware/gameplay/group/notice_messages.cpp

@@ -0,0 +1,103 @@
+/**
+ * Bodies a group host emits. This host reads them so a container carrying one stays readable, and
+ * acts on none of them: each one names an authority decision that belongs to whichever peer holds
+ * the group, and answering from a body alone is how two peers end up both acting as host.
+ */
+
+#include "notice_messages.h"
+
+#include "../../encoding/bit_raw.h"
+
+namespace sunrise::middleware::gameplay::group {
+
+namespace {
+
+namespace bits = encoding::bits;
+
+/** The protocol version is a 16-bit value field. */
+constexpr std::uint8_t kProtocolVersionWidth = 16;
+/** The boot kind is three bits. */
+constexpr std::uint8_t kBootKindWidth = 3;
+/** The boot reason is five bits. */
+constexpr std::uint8_t kBootReasonWidth = 5;
+/** The refusal kind is one bit. */
+constexpr std::uint8_t kRefuseKindWidth = 1;
+/** The refusal reason is six bits. */
+constexpr std::uint8_t kRefuseReasonWidth = 6;
+
+} // namespace
+
+/** Reads a peer-connect notice. */
+bool read_peer_connect(bits::Reader& reader, PeerConnectNotice& output) noexcept {
+    PeerConnectNotice candidate{};
+    std::uint64_t version = 0;
+    if (!reader.read(kProtocolVersionWidth, version)
+        || !bits::read_raw_u64(reader, candidate.machineId)
+        || !bits::read_raw_u64(reader, candidate.sessionId)) {
+        return false;
+    }
+    candidate.protocolVersion = static_cast<std::uint16_t>(version);
+    output = candidate;
+    return true;
+}
+
+/** Reads a session-boot notice. */
+bool read_session_boot(bits::Reader& reader, SessionBootNotice& output) noexcept {
+    SessionBootNotice candidate{};
+    std::uint64_t kind = 0;
+    std::uint64_t reason = 0;
+    if (!bits::read_raw_u64(reader, candidate.sessionId) || !reader.read(kBootKindWidth, kind)
+        || !reader.read(kBootReasonWidth, reason)
+        || !bits::read_raw_u64(reader, candidate.machineId)) {
+        return false;
+    }
+    candidate.kind = static_cast<std::uint8_t>(kind);
+    candidate.reason = static_cast<std::uint8_t>(reason);
+    output = candidate;
+    return true;
+}
+
+/** Reads a leadership delegation or a machine boot. */
+bool read_addressed_notice(bits::Reader& reader, bool withKind, AddressedNotice& output) noexcept {
+    AddressedNotice candidate{};
+    if (!bits::read_raw_u64(reader, candidate.sessionId)) {
+        return false;
+    }
+    if (withKind) {
+        std::uint64_t kind = 0;
+        if (!reader.read(kBootKindWidth, kind)) {
+            return false;
+        }
+        candidate.kind = static_cast<std::uint8_t>(kind);
+        candidate.hasKind = true;
+    }
+    if (!bits::read_raw(reader, candidate.address)) {
+        return false;
+    }
+    output = candidate;
+    return true;
+}
+
+/** Reads a player refusal. */
+bool read_player_refuse(bits::Reader& reader, PlayerRefuse& output) noexcept {
+    PlayerRefuse candidate{};
+    std::uint64_t kind = 0;
+    std::uint64_t reason = 0;
+    if (!bits::read_raw_u64(reader, candidate.sessionId)
+        || !bits::read_raw_u64(reader, candidate.playerId) || !reader.read(kRefuseKindWidth, kind)
+        || !reader.read(kRefuseReasonWidth, reason)) {
+        return false;
+    }
+    candidate.kindFlag = kind != 0;
+    candidate.reason = static_cast<std::uint8_t>(reason);
+    output = candidate;
+    return true;
+}
+
+/** Consumes the opaque voice registration. */
+bool read_voice_registration(bits::Reader& reader) noexcept {
+    // The checked build only probes whether the voice singleton exists and never reads the body.
+    return bits::skip_raw(reader, kVoiceRegistrationBytes);
+}
+
+} // namespace sunrise::middleware::gameplay::group

+ 97 - 0
Sunrise/src/middleware/gameplay/group/notice_messages.h

@@ -0,0 +1,97 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+
+#include "../../encoding/bit_reader.h"
+#include "../descriptor/join_descriptor.h"
+
+namespace sunrise::middleware::gameplay::group {
+
+/**
+ * Registry ids of the group messages a host normally emits.
+ * Receiving one means the peer is acting as the authority for that group. Nothing here acts on
+ * them, but each is read so the container behind it stays readable.
+ */
+enum class NoticeMessageId : std::uint8_t {
+    peerConnectNotice = 11,
+    sessionBootNotice = 18,
+    delegateLeadership = 32,
+    bootMachine = 33,
+    playerRefuse = 35,
+    voiceRegistration = 41,
+};
+
+/** Declared decoded sizes the registry holds for those ids. */
+inline constexpr std::uint32_t kPeerConnectNoticeSize = 24;
+/** See kPeerConnectNoticeSize. */
+inline constexpr std::uint32_t kDelegateLeadershipSize = 94;
+/** See kPeerConnectNoticeSize. */
+inline constexpr std::uint32_t kBootMachineSize = 100;
+/** See kPeerConnectNoticeSize. */
+inline constexpr std::uint32_t kPlayerRefuseSize = 24;
+/** See kPeerConnectNoticeSize. */
+inline constexpr std::uint32_t kVoiceRegistrationSize = 16;
+
+/** The voice registration is 128 opaque bits the checked build never reads. */
+inline constexpr std::size_t kVoiceRegistrationBytes = 16;
+
+/** Body of a peer-connect notice. */
+struct PeerConnectNotice {
+    std::uint16_t protocolVersion{};
+    std::uint64_t machineId{};
+    std::uint64_t sessionId{};
+};
+
+/** Body of a session-boot notice. */
+struct SessionBootNotice {
+    std::uint64_t sessionId{};
+    std::uint64_t machineId{};
+    std::uint8_t kind{};
+    std::uint8_t reason{};
+};
+
+/** Body of a leadership delegation or a machine boot. Only the boot carries a kind. */
+struct AddressedNotice {
+    std::uint64_t sessionId{};
+    std::array<std::byte, descriptor::kNetAddrSize> address{};
+    std::uint8_t kind{};
+    bool hasKind{};
+};
+
+/** Body of a player refusal. */
+struct PlayerRefuse {
+    std::uint64_t sessionId{};
+    std::uint64_t playerId{};
+    std::uint8_t reason{};
+    bool kindFlag{};
+};
+
+/** Reads a peer-connect notice. @return True when every field was present. */
+[[nodiscard]] bool read_peer_connect(encoding::bits::Reader& reader,
+                                     PeerConnectNotice& output) noexcept;
+
+/** Reads a session-boot notice. @return True when every field was present. */
+[[nodiscard]] bool read_session_boot(encoding::bits::Reader& reader,
+                                     SessionBootNotice& output) noexcept;
+
+/**
+ * Reads a leadership delegation or a machine boot.
+ * @param reader Reader positioned at the body.
+ * @param withKind True for the boot, which carries a kind between the session and the address.
+ * @param output Receives the fields.
+ * @return True when every field was present.
+ */
+[[nodiscard]] bool read_addressed_notice(encoding::bits::Reader& reader,
+                                         bool withKind,
+                                         AddressedNotice& output) noexcept;
+
+/** Reads a player refusal. @return True when every field was present. */
+[[nodiscard]] bool read_player_refuse(encoding::bits::Reader& reader,
+                                      PlayerRefuse& output) noexcept;
+
+/** Consumes the opaque voice registration. @return True when the whole field was present. */
+[[nodiscard]] bool read_voice_registration(encoding::bits::Reader& reader) noexcept;
+
+} // namespace sunrise::middleware::gameplay::group

+ 62 - 0
Sunrise/src/middleware/gameplay/group/parameter_messages.cpp

@@ -31,6 +31,41 @@ constexpr std::uint8_t kGroupSize = 16;
 constexpr std::uint8_t kGroupCount = 2;
 /** Only the low 25 bits of either mask name a parameter. */
 constexpr std::uint64_t kParameterMaskBits = 0x1FFFFFF;
+/** A request-body width of zero means that parameter has no recovered request codec. */
+constexpr std::uint16_t kNoRequestCodec = 0;
+
+/**
+ * Width in bits of each parameter's request body, indexed by registry index.
+ * The bodies are interleaved with no length prefix, so these widths are what locates the next one.
+ * A zero means the grammar is unrecovered and the walk stops: every later offset would be wrong.
+ */
+constexpr std::array<std::uint16_t, kParameterCount> kRequestBodyWidths{
+    23,              // 0: value6 bias 1, value9 bias 1, value8
+    34,              // 1: four value6, three bits, value5, value2
+    kNoRequestCodec, // 2: shared 140-byte schema
+    208,             // 3: raw64, value64, raw32, value32, value16
+    kNoRequestCodec, // 4: nested request root
+    kNoRequestCodec, // 5: nested request root
+    kNoRequestCodec, // 6: nested request root
+    kNoRequestCodec, // 7: activity descriptor root
+    kNoRequestCodec, // 8: activity descriptor root
+    3,               // 9: value2, bit
+    14,              // 10: value6, value5, value2, bit
+    4,               // 11: value4
+    kNoRequestCodec, // 12: bounded address record
+    kNoRequestCodec, // 13: same codec as 12
+    69,              // 14: raw64, value5
+    1,               // 15: bit
+    28,              // 16: value4, value4, value20
+    1,               // 17: bit
+    6,               // 18: value6
+    23,              // 19: value3, value6, value6, value8
+    42,              // 20: value7, value3, value32
+    kNoRequestCodec, // 21: nested request root
+    kNoRequestCodec, // 22: nested root
+    kNoRequestCodec, // 23: nested root
+    kNoRequestCodec, // 24: single-record request
+};
 
 /**
  * Reduces one parameter mask to the presence bits the encoder writes ahead of it.
@@ -134,6 +169,33 @@ bool read_parameter_request(bits::Reader& reader, ParameterRequestHeader& output
     return true;
 }
 
+/** Walks the request bodies that follow a parameter request header. */
+bool walk_parameter_request(bits::Reader& reader,
+                            std::uint64_t requestedMask,
+                            ParameterRequestWalk& walk) noexcept {
+    walk = {};
+    const std::uint64_t selected = requestedMask & kParameterMaskBits;
+    for (std::uint8_t parameter = 0; parameter < kParameterCount; ++parameter) {
+        if (((selected >> parameter) & 1ULL) == 0) {
+            continue;
+        }
+        const std::uint16_t width = kRequestBodyWidths[parameter];
+        if (width == kNoRequestCodec) {
+            walk.ambiguousParameter = parameter;
+            walk.tailBits = static_cast<std::uint32_t>(reader.remaining_bits());
+            return true;
+        }
+        if (!reader.skip(width)) {
+            walk.tailBits = static_cast<std::uint32_t>(reader.remaining_bits());
+            return false;
+        }
+        walk.walkedMask |= 1ULL << parameter;
+    }
+    walk.tailBits = static_cast<std::uint32_t>(reader.remaining_bits());
+    walk.complete = true;
+    return true;
+}
+
 /** Writes a parameter update. */
 bool write_parameter_update(bits::Writer& writer, const ParameterUpdate& body) noexcept {
     const std::uint64_t released = body.releasedMask & kParameterMaskBits;

+ 28 - 1
Sunrise/src/middleware/gameplay/group/parameter_messages.h

@@ -1,4 +1,4 @@
-#pragma once
+#pragma once
 
 #include <cstddef>
 #include <cstdint>
@@ -41,6 +41,33 @@ struct ParameterRequestHeader {
 [[nodiscard]] bool read_parameter_request(encoding::bits::Reader& reader,
                                           ParameterRequestHeader& output) noexcept;
 
+/** How far the selected request bodies of one parameter request could be located. */
+struct ParameterRequestWalk {
+    /** Parameters whose request body was located and consumed. */
+    std::uint64_t walkedMask{};
+    /** Bits left in the container once the walk stopped. */
+    std::uint32_t tailBits{};
+    /** First selected parameter with no recovered request codec, or the count when there is none.
+     */
+    std::uint8_t ambiguousParameter{kParameterCount};
+    /** Set when every selected body was located, so a later message in the container is findable.
+     */
+    bool complete{};
+};
+
+/**
+ * Walks the request bodies that follow a parameter request header.
+ * The bodies are interleaved with no per-body length, so a parameter with no recovered codec makes
+ * every later body unfindable. The walk stops there and reports the rest as one ambiguous tail.
+ * @param reader Reader positioned immediately after the request header.
+ * @param requestedMask Requested bitmap, already reduced to its meaningful bits.
+ * @param walk Cleared first, then filled with how far the walk got.
+ * @return True when every located body was complete. False means a body ran off the container.
+ */
+[[nodiscard]] bool walk_parameter_request(encoding::bits::Reader& reader,
+                                          std::uint64_t requestedMask,
+                                          ParameterRequestWalk& walk) noexcept;
+
 /**
  * @param mask Requested bitmap from one request header.
  * @param parameter Registry index, 0 through 24.

+ 64 - 0
Sunrise/src/middleware/gameplay/peer/connect_messages.cpp

@@ -14,6 +14,22 @@ constexpr std::uint8_t kSequenceWidth = 32;
 constexpr std::uint8_t kRefuseReasonWidth = 3;
 /** The close reason is five bits. */
 constexpr std::uint8_t kCloseReasonWidth = 5;
+/** The ping sequence is a 16-bit value field. */
+constexpr std::uint8_t kPingSequenceWidth = 16;
+/** The ping timestamp is a 64-bit value field. */
+constexpr std::uint8_t kPingTimestampWidth = 64;
+/** The ping's trailing flag and the pong's response kind are the only fields that differ. */
+constexpr std::uint8_t kPingFlagWidth = 1;
+/** The pong response kind is two bits. */
+constexpr std::uint8_t kPongKindWidth = 2;
+/** The discarded-packet count is five bits. */
+constexpr std::uint8_t kDiscardedWidth = 5;
+/** A mayday names its group session in raw bits. */
+constexpr std::uint8_t kMaydaySessionWidth = 64;
+/** The mayday code is nine bits and decodes to one above its wire value. */
+constexpr std::uint8_t kMaydayCodeWidth = 9;
+/** The mayday code carries a bias of one. */
+constexpr std::uint16_t kMaydayCodeBias = 1;
 
 /** Reads the two sequence fields every connection message starts with. */
 [[nodiscard]] bool
@@ -83,4 +99,52 @@ bool read_closed(bits::Reader& reader, ConnectEnd& output) noexcept {
     return true;
 }
 
+/** Reads a ping body. */
+bool read_ping(bits::Reader& reader, PingBody& output) noexcept {
+    std::uint64_t sequence = 0;
+    std::uint64_t flag = 0;
+    if (!reader.read(kPingSequenceWidth, sequence)
+        || !reader.read(kPingTimestampWidth, output.timestamp)
+        || !reader.read(kPingFlagWidth, flag)) {
+        return false;
+    }
+    output.sequence = static_cast<std::uint16_t>(sequence);
+    output.flag = flag != 0;
+    return true;
+}
+
+/** Writes a pong body. */
+bool write_pong(bits::Writer& writer, const PongBody& body) noexcept {
+    // The sender matches a pong to its ping on the echoed pair, so both lead unaltered.
+    return writer.write(body.sequence, kPingSequenceWidth)
+           && writer.write(body.timestamp, kPingTimestampWidth)
+           && writer.write(body.responseKind, kPongKindWidth);
+}
+
+/** Reads a packets-discarded diagnostic. */
+bool read_packets_discarded(bits::Reader& reader, std::uint8_t& discarded) noexcept {
+    std::uint64_t count = 0;
+    if (!reader.read(kDiscardedWidth, count)) {
+        return false;
+    }
+    discarded = static_cast<std::uint8_t>(count);
+    return true;
+}
+
+/** Reads a recovery diagnostic. */
+bool read_mayday(bits::Reader& reader, MaydayBody& output) noexcept {
+    MaydayBody candidate{};
+    std::uint64_t code = 0;
+    if (!reader.read(kMaydaySessionWidth, candidate.sessionId)
+        || !reader.read(kMaydayCodeWidth, code)) {
+        return false;
+    }
+    candidate.code = static_cast<std::uint16_t>(code) + kMaydayCodeBias;
+    if (candidate.code > kMaydayCodeMaximum) {
+        return false;
+    }
+    output = candidate;
+    return true;
+}
+
 } // namespace sunrise::middleware::gameplay::peer

+ 64 - 0
Sunrise/src/middleware/gameplay/peer/connect_messages.h

@@ -15,11 +15,16 @@ inline constexpr std::size_t kAddressBlobSize = descriptor::kNetAddrSize;
 
 /** Registry ids of the connection-control messages this host implements. */
 enum class ConnectId : std::uint8_t {
+    ping = 0,
+    pong = 1,
+    packetsDiscarded = 4,
     request = 5,
     response = 6,
     refuse = 7,
     establish = 8,
     closed = 9,
+    /** Bounded recovery diagnostic. It sits with the control ids rather than the session ones. */
+    mayday = 42,
 };
 
 /** Declared decoded sizes the registry holds for those ids. */
@@ -32,6 +37,42 @@ inline constexpr std::uint32_t kRefuseSize = 12;
 inline constexpr std::uint32_t kEstablishSize = 8;
 /** See kRequestSize. */
 inline constexpr std::uint32_t kClosedSize = 12;
+/** See kRequestSize. */
+inline constexpr std::uint32_t kPingSize = 24;
+/** See kRequestSize. */
+inline constexpr std::uint32_t kPongSize = 24;
+/** See kRequestSize. */
+inline constexpr std::uint32_t kPacketsDiscardedSize = 4;
+/** See kRequestSize. */
+inline constexpr std::uint32_t kMaydaySize = 10;
+
+/** The pong response kind is two bits wide and its largest named value is 2. */
+inline constexpr std::uint8_t kPongResponseKind = 2;
+/** A mayday code decodes to one above its wire value and may not exceed this. */
+inline constexpr std::uint16_t kMaydayCodeMaximum = 365;
+
+/** Body of a ping, and of the pong that echoes its first two fields. */
+struct PingBody {
+    std::uint16_t sequence{};
+    std::uint64_t timestamp{};
+    /** One-bit field the sender chose. The pong does not carry it. */
+    bool flag{};
+};
+
+/** Body of a pong. It echoes the ping and names the kind of response it is. */
+struct PongBody {
+    std::uint16_t sequence{};
+    std::uint64_t timestamp{};
+    /** Two-bit response kind. The recovered response uses the largest named value. */
+    std::uint8_t responseKind{kPongResponseKind};
+};
+
+/** Body of a recovery diagnostic. The session is the group it was raised for. */
+struct MaydayBody {
+    std::uint64_t sessionId{};
+    /** Decoded code, one above the wire value. */
+    std::uint16_t code{};
+};
 
 /** Body of a connect request. */
 struct ConnectRequest {
@@ -94,4 +135,27 @@ struct ConnectEnd {
 /** Reads a connect closed body. @return True when every field was present. */
 [[nodiscard]] bool read_closed(encoding::bits::Reader& reader, ConnectEnd& output) noexcept;
 
+/** Reads a ping body. @return True when every field was present. */
+[[nodiscard]] bool read_ping(encoding::bits::Reader& reader, PingBody& output) noexcept;
+
+/** Writes a pong body. @return True when every field fit. */
+[[nodiscard]] bool write_pong(encoding::bits::Writer& writer, const PongBody& body) noexcept;
+
+/**
+ * Reads a packets-discarded diagnostic.
+ * @param reader Reader positioned at the body.
+ * @param discarded Receives the five-bit count.
+ * @return True when the count was present.
+ */
+[[nodiscard]] bool read_packets_discarded(encoding::bits::Reader& reader,
+                                          std::uint8_t& discarded) noexcept;
+
+/**
+ * Reads a recovery diagnostic.
+ * @param reader Reader positioned at the body.
+ * @param output Receives the session and the decoded code.
+ * @return True when both fields were present and the code is within its bound.
+ */
+[[nodiscard]] bool read_mayday(encoding::bits::Reader& reader, MaydayBody& output) noexcept;
+
 } // namespace sunrise::middleware::gameplay::peer

+ 33 - 0
Sunrise/src/middleware/gameplay/peer/established_packet.cpp

@@ -69,6 +69,12 @@ constexpr std::uint16_t kByteBits = 8;
 constexpr std::uint8_t kMessageIdWidth = 6;
 /** The declared decoded size after it is 18 bits. */
 constexpr std::uint8_t kMessageSizeWidth = 18;
+/** The filler length is a 14-bit field. */
+constexpr std::uint8_t kFillerLengthWidth = 14;
+/** The recovered filler body limit is 9,920 bits. */
+constexpr std::uint64_t kMaximumFillerBits = 9920;
+/** Byte padding can carry at most seven bits. */
+constexpr std::size_t kMaximumPaddingBits = 7;
 
 /**
  * Reads one ternary packet status.
@@ -502,6 +508,33 @@ bool enqueue_message(state::gameplay::OutboundQueue& queue,
     return true;
 }
 
+/** Reads the bounded outer filler and requires exact zero byte padding. */
+bool read_filler_and_padding(bits::Reader& reader, FillerTrailer& output) noexcept {
+    FillerTrailer candidate{};
+    std::uint64_t present = 0;
+    if (!reader.read(kFlagWidth, present)) {
+        return false;
+    }
+    candidate.present = present != 0;
+    if (candidate.present) {
+        std::uint64_t bitCount = 0;
+        if (!reader.read(kFillerLengthWidth, bitCount) || bitCount == 0
+            || bitCount > kMaximumFillerBits || !reader.skip(static_cast<std::size_t>(bitCount))) {
+            return false;
+        }
+        candidate.bitCount = static_cast<std::size_t>(bitCount);
+    }
+
+    const std::size_t paddingBits = reader.remaining_bits();
+    std::uint64_t padding = 0;
+    if (paddingBits > kMaximumPaddingBits
+        || !reader.read(static_cast<std::uint8_t>(paddingBits), padding) || padding != 0) {
+        return false;
+    }
+    output = candidate;
+    return true;
+}
+
 /** Writes the filler trailer that ends every packet. */
 bool write_absent_filler(bits::Writer& writer) noexcept {
     // Two bits close a packet: the extended-presence bit, then the external-body present bit.

+ 21 - 0
Sunrise/src/middleware/gameplay/peer/established_packet.h

@@ -21,6 +21,17 @@ inline constexpr std::size_t kLargeFragmentBytes = 32;
 inline constexpr std::size_t kSmallFragmentBytes = 6;
 /** One packet carries at most this many queue records before it is refused. */
 inline constexpr std::size_t kMaximumRecords = 64;
+/** The two-bit connection guard is the full connection sequence modulo four. */
+inline constexpr std::uint32_t kConnectionSequenceGuardModulus = 4;
+
+/**
+ * Reduces a full connection sequence to the guard carried by an established packet.
+ * @param sequence Full connection sequence announced during connect.
+ * @return The sequence modulo four.
+ */
+[[nodiscard]] constexpr std::uint8_t connection_sequence_low2(std::uint32_t sequence) noexcept {
+    return static_cast<std::uint8_t>(sequence % kConnectionSequenceGuardModulus);
+}
 
 /** Acknowledgement state this side publishes and the other side reads. */
 struct AckState {
@@ -71,6 +82,12 @@ struct EstablishedPacket {
     bool hasExternal{};
 };
 
+/** Bounded filler trailer after the external handler. */
+struct FillerTrailer {
+    std::size_t bitCount{};
+    bool present{};
+};
+
 /**
  * Decodes one established packet up to and including both reliable queues.
  * The external handler body is not parsed here. The packet reports the bit offset it starts at.
@@ -136,6 +153,10 @@ struct EstablishedPacket {
                                    std::span<const std::byte> body,
                                    std::size_t bodyBits) noexcept;
 
+/** Reads the filler and requires exact zero byte padding. */
+[[nodiscard]] bool read_filler_and_padding(encoding::bits::Reader& reader,
+                                           FillerTrailer& output) noexcept;
+
 /**
  * Writes the filler trailer that ends every packet.
  * @param writer Writer positioned after the last handler payload.

+ 12 - 0
Sunrise/src/server/bap/bap_route.cpp

@@ -7,6 +7,7 @@
 
 #include "../../core/logging/log.h"
 #include "../../state/matchmaking/matchmaking_state.h"
+#include "encrypted/bap_connection_publication.h"
 #include "internal.h"
 #include "runtime.h"
 
@@ -63,6 +64,8 @@ void publish_account_mutation(Session& origin) noexcept {
 /** @param session Its secrets and identity are wiped. */
 void clear_session(Session& session) noexcept {
     SecureZeroMemory(&session, sizeof session);
+    // Zeroing is not the cleared state: `advertisedRegion` is -1 and zero is a real region.
+    session.activity = {};
 }
 
 /**
@@ -91,6 +94,9 @@ void clear_session(Session& session) noexcept {
     if (session.id != 0 && !release_matchmaking_context(session)) {
         return false;
     }
+    if (session.id != 0) {
+        encrypted::release_activity_connection(session);
+    }
     clear_session(session);
     session.id = id;
     return true;
@@ -105,6 +111,9 @@ void clear_session(Session& session) noexcept {
     if (session.id != 0 && !release_matchmaking_context(session)) {
         return false;
     }
+    if (session.id != 0) {
+        encrypted::release_activity_connection(session);
+    }
     clear_session(session);
     return true;
 }
@@ -215,6 +224,9 @@ void shutdown() noexcept {
             // State erases runtime descriptors before the opaque association is cleared.
             (void)state::matchmaking::release_context(session.matchmakingContext);
         }
+        if (session.id != 0) {
+            encrypted::release_activity_connection(session);
+        }
     }
     SecureZeroMemory(g_sessions.data(), sizeof g_sessions);
     SecureZeroMemory(&g_scratch, sizeof g_scratch);

+ 194 - 220
Sunrise/src/server/bap/encrypted/activity_message/activity_message_route.cpp

@@ -1,8 +1,7 @@
-#include "activity_message_route.h"
+#include "activity_message_route.h"
 
 #include <algorithm>
 #include <array>
-#include <bit>
 #include <cstdio>
 
 #include "../../../../core/logging/log.h"
@@ -18,10 +17,17 @@
 #include "../../../../middleware/bap/activity_message/entity_authority.h"
 #include "../../../../middleware/bap/activity_message/entity_slots.h"
 #include "../../../../middleware/bap/activity_message/incident.h"
+#include "../../../../middleware/bap/activity_message/peer_ledger.h"
+#include "../../../../middleware/bap/activity_message/sense_update.h"
+#include "../../../../middleware/bap/activity_message/start_activity.h"
+#include "../../../../middleware/bap/activity_message/telemetry.h"
+#include "../../../../middleware/encoding/byte_order.h"
+#include "../../../../state/activity/receipts/activity_receipts.h"
 #include "../../../../state/activity/runtime.h"
 #include "membership/activity_membership_route.h"
 #include "middleware/bap/activity_message/activity_entity_slot_request_parser.h"
 #include "patch_epoch/activity_patch_epoch_route.h"
+#include "receipts/activity_message_receipts.h"
 
 namespace sunrise::server::bap::encrypted::activity_message {
 namespace {
@@ -31,64 +37,32 @@ namespace authority = service::entity_authority;
 namespace client_keepalive = service::client_keepalive;
 namespace high_water = service::high_water;
 namespace epoch_message = service::patch_epoch;
+namespace ledger = service::peer_ledger;
+namespace telemetry = service::telemetry;
+namespace store = state::activity::receipts;
 
 /** Activity message type 3 starts the client join transaction. */
 constexpr std::uint32_t kJoinRequestMessageType = 3;
-
-/** One row per Client-sent message this route accepts but has no state to change for. */
-struct AcceptedMessage {
-    std::uint32_t type;
-    const char* name;
-};
-
 /**
- * The Client senders that carry no work for this host. Each is one-way, so accepting is the whole
- * contract. The names are the binary's own, so a log line says what arrived.
+ * Activity message type 8 is a client-local request the transport converts into its own service.
+ * On this route it is an authenticated but invalid use, never a second session allocation.
  */
-constexpr std::array<AcceptedMessage, 14> kAcceptedMessages{{
-    {6, "sensor_sense_update"},
-    {8, "request_activity_host"},
-    {11, "start_new_activity"},
-    {13, "request_peer_reservation"},
-    {14, "release_peer_reservation"},
-    {15, "peer_leave_request"},
-    {34, "process_debug_command"},
-    {37, "connectivity_failure"},
-    {39, "send_client_heartbeat"},
-    {43, "bug_claw"},
-    {46, "report_lag_switch"},
-    {47, "connection_quality_report"},
-    {48, "speculative_migration"},
-    {50, "refresh_inspirations"},
-}};
-
-/** @return The binary name for one accepted message type, or nullptr when it is not one. */
-[[nodiscard]] const char* accepted_name(std::uint32_t messageType) noexcept {
-    const auto row = std::find_if(kAcceptedMessages.begin(),
-                                  kAcceptedMessages.end(),
-                                  [messageType](const AcceptedMessage& candidate) noexcept {
-                                      return candidate.type == messageType;
-                                  });
-    return row == kAcceptedMessages.end() ? nullptr : row->name;
-}
+constexpr std::uint32_t kLocalActivityHostMessageType = 8;
 
 /**
- * Records one accepted message that changes no host state.
- * @param messageType Activity message type from the envelope.
- * @param name Binary name for that type.
- * @param payloadSize Declared payload bytes, which is the only thing that varies here.
+ * Reports one inbound activity message, whatever the route goes on to do with it.
+ * Without this line a type the client never sends reads the same as one handled in silence.
+ * Nothing else says whether the client ever asks for or returns an entity slot.
+ * @param request Parsed envelope.
  */
-void report_accepted(std::uint32_t messageType,
-                     const char* name,
-                     std::size_t payloadSize) noexcept {
+void report_arrival(const service::Request& request) noexcept {
     std::array<char, core::log::kLineCapacity> line{};
     const int written = std::snprintf(line.data(),
                                       line.size(),
-                                      "ev=activity stage=message result=accept type=%u name=%s "
-                                      "bytes=%zu",
-                                      messageType,
-                                      name,
-                                      payloadSize);
+                                      "ev=activity stage=inbound type=%u handle=0x%llX bytes=%zu",
+                                      request.messageType,
+                                      static_cast<unsigned long long>(request.accountHandle),
+                                      request.payload.size());
     if (written > 0) {
         core::log::write(core::log::Channel::server,
                          core::log::Level::debug,
@@ -96,37 +70,9 @@ void report_accepted(std::uint32_t messageType,
     }
 }
 
-/**
- * Checks one incident and reports its verdict. Nothing relays msg 19 yet, so a pass changes
- * nothing. A failure is named because a bad target index would crash the Client if it were sent on.
- * @param request Validated owned svc8 envelope.
- */
-void report_incident(const service::Request& request) noexcept {
-    namespace incident = service::incident;
-    incident::Incident parsed;
-    const incident::Verdict verdict = incident::validate(request.payload, parsed);
-    std::array<char, core::log::kLineCapacity> line{};
-    const int written = std::snprintf(line.data(),
-                                      line.size(),
-                                      "ev=activity stage=incident result=%s target=%u extra=%u "
-                                      "selector=%u payload=%u",
-                                      incident::verdict_name(verdict),
-                                      parsed.primaryTarget,
-                                      parsed.extraTargetCount,
-                                      static_cast<unsigned>(parsed.hasCompressedSelector),
-                                      parsed.payloadLength);
-    if (written <= 0) {
-        return;
-    }
-    const auto level =
-        verdict == incident::Verdict::accepted ? core::log::Level::debug : core::log::Level::warn;
-    core::log::write(
-        core::log::Channel::server, level, {line.data(), static_cast<std::size_t>(written)});
-}
-
 /**
  * Reports one activity message the route did not stage, naming its type.
- * Every inbound activity message is one-way, so nothing here can jam the Client's reply ring. An
+ * Every inbound activity message is one-way, so nothing here can jam the client's reply ring. An
  * unnamed drop is invisible, and membership waits on the identity message.
  * @param messageType Activity message type from the envelope.
  * @param accountHandle Handle the envelope carried.
@@ -150,21 +96,97 @@ void report_message(std::uint32_t messageType,
     }
 }
 
+/**
+ * Records one arrival against the message type's receipt row.
+ * Every routed message calls this, so a type that arrives and changes nothing is still counted.
+ * @param request Validated envelope.
+ * @param verdict How completely the body was framed.
+ * @param consumedBits Bits the parser used, or zero when the type has no parser.
+ */
+void record(const service::Request& request,
+            store::Verdict verdict,
+            std::size_t consumedBits) noexcept {
+    if (!core::settings::get().server.activation.activityCompatibilityMirror) {
+        // Off, the framing still runs and still reports; only the retained arrival is skipped.
+        return;
+    }
+    store::Arrival arrival{};
+    arrival.sessionId = request.accountHandle;
+    arrival.messageType = request.messageType;
+    arrival.payloadBytes = static_cast<std::uint32_t>(request.payload.size());
+    arrival.peerHeardMask = request.peerHeardMask;
+    arrival.consumedBits = static_cast<std::uint32_t>(consumedBits);
+    arrival.verdict = verdict;
+    static_cast<void>(store::record(arrival));
+}
+
+/** Tests whether a retained link binding still names its exact State and host generations. */
+[[nodiscard]] bool binding_is_current(const ActivityClientBinding& binding) noexcept {
+    if (binding.role == ActivityClientRole::privateCurrent) {
+        return binding.session.sessionId != state::activity::kAbsentSessionId
+               && binding.source.sessionId == binding.session.sessionId
+               && binding.source.createdRevision == binding.session.createdRevision
+               && state::activity::binding_matches(binding.session);
+    }
+    if (binding.role != ActivityClientRole::publicTarget
+        || !state::activity::binding_matches(binding.session)
+        || !state::activity::binding_matches(binding.source)) {
+        return false;
+    }
+    server::gameplay::group::HostSessionBinding host{};
+    return server::gameplay::group::host_session_for_activity(binding.session.sessionId, host)
+           && host.generation == binding.hostGeneration
+           && host.groupSessionId == binding.groupSessionId
+           && host.source.sessionId == binding.source.sessionId
+           && host.source.createdRevision == binding.source.createdRevision
+           && host.target.sessionId == binding.session.sessionId
+           && host.target.createdRevision == binding.session.createdRevision;
+}
+
+/**
+ * Tests whether one message may mutate the State this link owns.
+ * A mutating body names its session through the envelope handle. Join and patch-epoch messages
+ * have separate ownership rules and do not use this helper.
+ * @param binding Exact ActivityClient generation owned by this link.
+ * @param request Validated envelope.
+ * @return True when the envelope handle may drive a State mutation.
+ */
+[[nodiscard]] bool owns_session(const ActivityClientBinding& binding,
+                                const service::Request& request) noexcept {
+    return binding_is_current(binding) && request.accountHandle == binding.session.sessionId;
+}
+
 /**
  * Prepares the joined State and the whole initial lease mask as one mutation.
+ * @param binding Exact ActivityClient generation already owned by this link.
  * @param request Validated owned svc8 envelope.
  * @param plan Cleared, then receives join scalars and the chosen lease mask.
  * @return True when the fixed join payload and current State can stage together.
  */
-[[nodiscard]] bool prepare_join(const service::Request& request, ActivityPlan& plan) noexcept {
+[[nodiscard]] bool prepare_join(const ActivityClientBinding& binding,
+                                const service::Request& request,
+                                ActivityPlan& plan) noexcept {
     service::JoinRequest parsed;
-    // The client takes the low slots and the server keeps the reserve above them.
-    const std::size_t reserve =
-        core::settings::server::gameplay::effective_reserve(core::settings::get().server.gameplay);
-    const std::size_t granted = state::activity::entity_slots::kSlotCount - reserve;
     if (!service::join_request::parse_join_request(request.payload, parsed)
-        || parsed.sessionId != request.accountHandle
-        || !state::activity::entity_slots::prepare_join(
+        || parsed.sessionId != request.accountHandle) {
+        return false;
+    }
+    if (binding_is_current(binding) && parsed.sessionId == binding.session.sessionId) {
+        plan.bindingIntent = BindingIntent::preserveCurrent;
+        plan.targetBinding = binding.session;
+    } else if (server::gameplay::group::host_session_for_activity(parsed.sessionId, plan.publicHost)
+               && state::activity::binding_matches(plan.publicHost.target)) {
+        plan.bindingIntent = BindingIntent::publicTarget;
+        plan.targetBinding = plan.publicHost.target;
+    } else {
+        return false;
+    }
+    // The client takes the low slots and the server keeps the reserve above them.
+    const core::settings::server::gameplay::Settings& gameplay =
+        core::settings::get().server.gameplay;
+    const std::size_t reserve = core::settings::server::gameplay::effective_reserve(gameplay);
+    const std::size_t granted = core::settings::server::gameplay::join_grant(gameplay);
+    if (!state::activity::entity_slots::prepare_join(
             parsed.sessionId, parsed.memberKey, granted, reserve, plan.entitySlotMutation)) {
         return false;
     }
@@ -196,98 +218,6 @@ void report_message(std::uint32_t messageType,
     return true;
 }
 
-/** @return How many slots one authority mask names. */
-[[nodiscard]] std::size_t
-mask_slot_count(const service::entity_slots::EntitySlotMask& mask) noexcept {
-    std::size_t slots = 0;
-    for (const std::byte value : mask) {
-        slots += static_cast<std::size_t>(std::popcount(std::to_integer<unsigned char>(value)));
-    }
-    return slots;
-}
-
-/**
- * Reports one msg 26 or msg 33. Neither returns a lease. Msg 21 does.
- * @param request Validated owned svc8 envelope.
- * @param expectReason True for msg 26, which trails a 3-bit reason after the mask.
- * @return True when the fixed body for that message type decodes.
- */
-[[nodiscard]] bool report_authority_release(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) {
-        return false;
-    }
-    std::array<char, core::log::kLineCapacity> line{};
-    const int written = std::snprintf(line.data(),
-                                      line.size(),
-                                      "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,
-                                      mask_slot_count(decoded.mask));
-    if (written > 0) {
-        core::log::write(core::log::Channel::server,
-                         core::log::Level::debug,
-                         {line.data(), static_cast<std::size_t>(written)});
-    }
-    return true;
-}
-
-/**
- * Reports one msg 29, 31 or 32 answer. This host sends no msg 28 or msg 30, so an answer here is
- * the Client reconciling on its own. Nothing is staged.
- * @param request Validated owned svc8 envelope.
- * @return True when the body for that message type decodes.
- */
-[[nodiscard]] bool report_query_answer(const service::Request& request) noexcept {
-    namespace authority = service::entity_authority;
-    authority::QueryAnswer answer;
-    if (!authority::parse_query_answer(request.messageType, request.payload, answer)) {
-        return false;
-    }
-    std::array<char, core::log::kLineCapacity> line{};
-    const int written = std::snprintf(line.data(),
-                                      line.size(),
-                                      "ev=activity stage=authority result=ok type=%u corr=0x%08X "
-                                      "selector=%d mask=%u",
-                                      request.messageType,
-                                      answer.correlation,
-                                      answer.hasSelector ? static_cast<int>(answer.selector) : -1,
-                                      static_cast<unsigned>(answer.hasMask));
-    if (written > 0) {
-        core::log::write(core::log::Channel::server,
-                         core::log::Level::debug,
-                         {line.data(), static_cast<std::size_t>(written)});
-    }
-    return true;
-}
-
-/**
- * Reports one msg 27 purge request. The host does not answer it: the reply is msg 25, whose
- * consumer asserts unless the epoch is one above the Client's own, and nothing here tracks that.
- * @param request Validated owned svc8 envelope.
- * @return True when the fixed body is present.
- */
-[[nodiscard]] bool report_request_purge(const service::Request& request) noexcept {
-    std::int32_t reason = 0;
-    if (!service::entity_authority::parse_request_purge(request.payload, reason)) {
-        return false;
-    }
-    std::array<char, core::log::kLineCapacity> line{};
-    const int written = std::snprintf(
-        line.data(), line.size(), "ev=activity stage=purge result=noted reason=%d", reason);
-    if (written > 0) {
-        core::log::write(core::log::Channel::server,
-                         core::log::Level::debug,
-                         {line.data(), static_cast<std::size_t>(written)});
-    }
-    return true;
-}
-
 /**
  * Prepares only the slots that are both held and in the returned mask.
  * @param request Validated owned svc8 envelope.
@@ -311,10 +241,69 @@ mask_slot_count(const service::entity_slots::EntitySlotMask& mask) noexcept {
     return true;
 }
 
+/** One framing-only message type and the handler that reads its body. */
+struct FramingRoute {
+    std::uint32_t type;
+    receipts::Framed (*frame)(const service::Request&) noexcept;
+};
+
+/** Frames one abandon, which trails a reason after the mask. */
+[[nodiscard]] receipts::Framed frame_abandon(const service::Request& request) noexcept {
+    return receipts::frame_authority_release(request, true);
+}
+
+/** Frames one abdicate, which carries no reason. */
+[[nodiscard]] receipts::Framed frame_abdicate(const service::Request& request) noexcept {
+    return receipts::frame_authority_release(request, false);
+}
+
+/** Every message type this route frames and records without changing State. */
+constexpr std::array<FramingRoute, 22> kFramingRoutes{{
+    {service::sense_update::kMessageType, receipts::frame_sense_update},
+    {kLocalActivityHostMessageType, receipts::frame_route_misuse},
+    {telemetry::kReservationRequestType, receipts::frame_reservation_request},
+    {ledger::kReleaseReservationType, receipts::frame_reservation_release},
+    {ledger::kPeerLeaveType, receipts::frame_peer_leave},
+    {client_keepalive::kMessageType, receipts::frame_client_keepalive},
+    {service::incident::kMessageType, receipts::frame_incident},
+    {authority::kAbandonMessageType, frame_abandon},
+    {authority::kAbdicateMessageType, frame_abdicate},
+    {authority::kRequestPurgeMessageType, receipts::frame_request_purge},
+    {authority::kResetAcknowledgementMessageType, receipts::frame_query_answer},
+    {authority::kQueryPerBubbleMessageType, receipts::frame_query_answer},
+    {authority::kQueryResponseMessageType, receipts::frame_query_answer},
+    {telemetry::kDebugCommandType, receipts::frame_debug_command},
+    {ledger::kConnectivityFailureType, receipts::frame_connectivity_failure},
+    {telemetry::kHeartbeatType, receipts::frame_heartbeat},
+    {telemetry::kBugClawType, receipts::frame_opaque_scalar},
+    {telemetry::kRefreshInspirationsType, receipts::frame_opaque_scalar},
+    {telemetry::kLagSwitchType, receipts::frame_lag_switch},
+    {telemetry::kConnectionQualityType, receipts::frame_connection_quality},
+    {ledger::kSpeculativeMigrationType, receipts::frame_migration},
+    {high_water::kMessageType, receipts::frame_high_water},
+}};
+
+/**
+ * Frames one message that changes no State and records its receipt.
+ * @param request Validated envelope.
+ * @return Always true: a framing-only message can never fail the transport frame.
+ */
+[[nodiscard]] bool frame_only(const service::Request& request) noexcept {
+    const auto row = std::find_if(kFramingRoutes.begin(),
+                                  kFramingRoutes.end(),
+                                  [&request](const FramingRoute& candidate) noexcept {
+                                      return candidate.type == request.messageType;
+                                  });
+    const receipts::Framed framed =
+        row != kFramingRoutes.end() ? row->frame(request) : receipts::frame_unknown(request);
+    record(request, framed.verdict, framed.consumedBits);
+    return true;
+}
+
 } // namespace
 
 /** Routes one svc8 activity message and prepares any supported push transaction. */
-bool process(std::uint64_t boundSessionId,
+bool process(const ActivityClientBinding& binding,
              std::span<const std::byte> requestBody,
              ActivityPlan& plan,
              bool& hasTransaction) noexcept {
@@ -326,19 +315,23 @@ bool process(std::uint64_t boundSessionId,
         report_message(0, 0, "parse");
         return false;
     }
-    // Dispatch is on message type alone, and every handler keys off the envelope's own handle, so
-    // nothing has to be bound first. The join request carries the session in the first place, and
-    // it arrives on a link that has allocated nothing.
+    report_arrival(request);
+    // Join acquires or preserves an exact binding. Every other message, including type 52 and the
+    // receipt-only types, must name the exact session already owned by this link before it can
+    // mutate State or the receipt registry.
+    const std::uint32_t messageType = request.messageType;
+    const bool ownsMessage =
+        messageType == kJoinRequestMessageType || owns_session(binding, request);
+    if (!ownsMessage) {
+        report_message(request.messageType, request.accountHandle, "unowned");
+        record(request, store::Verdict::unowned, 0);
+        return true;
+    }
     bool prepared = false;
     if (request.messageType == epoch_message::kMessageType) {
-        // Type 52 alone carries a zero handle, so its session is the one this link allocated.
-        prepared = patch_epoch::prepare(boundSessionId, request, plan);
-    } else if (request.messageType == high_water::kMessageType
-               || request.messageType == client_keepalive::kMessageType) {
-        // Both are one-way notices with nothing to answer.
-        return true;
+        prepared = patch_epoch::prepare(request.accountHandle, request, plan);
     } else if (request.messageType == kJoinRequestMessageType) {
-        prepared = prepare_join(request, plan);
+        prepared = prepare_join(binding, request, plan);
     } else if (request.messageType == service::entity_slot_request::kMessageType) {
         prepared = prepare_grant(request, plan);
     } else if (request.messageType == service::entity_slots::kRequestMessageType) {
@@ -351,47 +344,28 @@ bool process(std::uint64_t boundSessionId,
         prepared = membership::prepare_authoritative(request, plan);
     } else if (request.messageType == service::membership_acknowledgement::kMessageType) {
         prepared = membership::prepare_acknowledgement(request, plan);
-    } else if (request.messageType == authority::kAbandonMessageType) {
-        if (!report_authority_release(request, true)) {
-            report_message(request.messageType, request.accountHandle, "parse");
-        }
-        return true;
-    } else if (request.messageType == authority::kAbdicateMessageType) {
-        if (!report_authority_release(request, false)) {
-            report_message(request.messageType, request.accountHandle, "parse");
+    } else if (request.messageType == service::start_activity::kMessageType) {
+        // Off, the request is framed and recorded but no transition policy runs on it.
+        if (!core::settings::get().server.activation.defaultClientActivation) {
+            const receipts::Framed framed = receipts::frame_start_activity(request);
+            record(request, framed.verdict, framed.consumedBits);
+            return true;
         }
-        return true;
-    } else if (request.messageType == service::incident::kMessageType) {
-        report_incident(request);
-        return true;
-    } else if (request.messageType == authority::kRequestPurgeMessageType) {
-        if (!report_request_purge(request)) {
-            report_message(request.messageType, request.accountHandle, "parse");
-        }
-        return true;
-    } else if (request.messageType == authority::kResetAcknowledgementMessageType
-               || request.messageType == authority::kQueryPerBubbleMessageType
-               || request.messageType == authority::kQueryResponseMessageType) {
-        if (!report_query_answer(request)) {
-            report_message(request.messageType, request.accountHandle, "parse");
-        }
-        return true;
-    } else if (const char* name = accepted_name(request.messageType); name != nullptr) {
-        // One-way with nothing to change here. Accepting is the whole contract.
-        report_accepted(request.messageType, name, request.payload.size());
-        return true;
+        prepared = membership::prepare_start_activity(request, plan);
     } else {
-        // Later message handlers are independent. An owned envelope is a safe no-op.
-        report_message(request.messageType, request.accountHandle, "unhandled");
-        return true;
+        return frame_only(request);
     }
     // A message that cannot be staged is reported and dropped. Failing the frame would leave the
-    // Client's pending ring jammed.
+    // client's pending ring jammed.
     if (!prepared) {
         report_message(request.messageType, request.accountHandle, "prepare");
+        record(request, store::Verdict::malformed, 0);
         plan = {};
         return true;
     }
+    record(request,
+           store::Verdict::framed,
+           request.payload.size() * middleware::encoding::kBitsPerByte);
     hasTransaction = true;
     return true;
 }

+ 3 - 2
Sunrise/src/server/bap/encrypted/activity_message/activity_message_route.h

@@ -4,6 +4,7 @@
 #include <cstdint>
 #include <span>
 
+#include "../../internal.h"
 #include "definition.h"
 
 namespace sunrise::server::bap::encrypted::activity_message {
@@ -11,13 +12,13 @@ namespace sunrise::server::bap::encrypted::activity_message {
 /**
  * Routes one svc8 activity message and prepares any supported push transaction.
  * The decrypted payload is borrowed for this call only, never kept.
- * @param boundSessionId Activity capability this link allocated, used only by message type 52.
+ * @param binding Exact ActivityClient generation owned by this link.
  * @param requestBody Whole decrypted svc8 body after the generic BAP header.
  * @param plan Cleared, then receives one deferred State transaction and optional push data.
  * @param hasTransaction Receives true only when a message stages a transaction.
  * @return True for any envelope that parses, including unhandled message types.
  */
-[[nodiscard]] bool process(std::uint64_t boundSessionId,
+[[nodiscard]] bool process(const ActivityClientBinding& binding,
                            std::span<const std::byte> requestBody,
                            ActivityPlan& plan,
                            bool& hasTransaction) noexcept;

+ 13 - 0
Sunrise/src/server/bap/encrypted/activity_message/definition.h

@@ -5,6 +5,7 @@
 #include "../../../../middleware/bap/activity_message/activity_patch_epoch_parser.h"
 #include "../../../../state/activity/membership/activity_membership_query.h"
 #include "../../../../state/activity/runtime.h"
+#include "../../../gameplay/group/group_host_sessions.h"
 
 namespace sunrise::server::bap::encrypted::activity_message {
 
@@ -36,6 +37,13 @@ enum class MutationDomain : std::uint8_t {
     patchEpoch,
 };
 
+/** Connection binding change staged by an activity join. */
+enum class BindingIntent : std::uint8_t {
+    none,
+    preserveCurrent,
+    publicTarget,
+};
+
 /** Scalar and mask data kept after the sensitive svc8 payload view expires. */
 struct ActivityPlan final {
     std::uint32_t correlation{};
@@ -43,6 +51,10 @@ struct ActivityPlan final {
     state::activity::entity_slots::PendingMutation entitySlotMutation{};
     state::activity::membership::PendingMutation membershipMutation{};
     middleware::bap::activity_message::patch_epoch::PatchEpoch patchEpoch{};
+    /** Exact target generation whose destination the staged msg1 must encode. */
+    state::activity::SessionBinding targetBinding{};
+    /** Exact advertised host row used only by a new public-target join. */
+    server::gameplay::group::HostSessionBinding publicHost{};
     /** The character the join request named, or zero when it carried none. */
     std::uint64_t joinCharacterSoid{};
     /**
@@ -58,6 +70,7 @@ struct ActivityPlan final {
     bool transitionStarted{};
     Delivery delivery{};
     MutationDomain mutationDomain{};
+    BindingIntent bindingIntent{};
 };
 
 } // namespace sunrise::server::bap::encrypted::activity_message

+ 49 - 0
Sunrise/src/server/bap/encrypted/activity_message/membership/activity_membership_route.cpp

@@ -9,6 +9,8 @@ activity_membership_acknowledgement_parser.h"
 #include "../../../../../middleware/bap/activity_message/activity_client_identity_parser.h"
 #include "../../../../../middleware/bap/activity_message/activity_state_refresh_parser.h"
 #include "../../../../../middleware/bap/activity_message/client_authoritative_data.h"
+#include "../../../../../middleware/bap/activity_message/start_activity.h"
+#include "../../../../../state/activity/destination/definition.h"
 #include "../../../../../state/activity/membership/activity_membership_query.h"
 
 namespace sunrise::server::bap::encrypted::activity_message::membership {
@@ -17,6 +19,9 @@ namespace {
 namespace service = middleware::bap::activity_message;
 namespace membership_state = state::activity::membership;
 
+/** A request with no revision of its own asks for the current one, not a numbered resend. */
+constexpr std::uint32_t kNoRequestedRevision = 0;
+
 /**
  * Maps one parsed client identity into State's protocol-neutral storage.
  * @param parsed Typed Middleware identity whose source bytes expire after routing.
@@ -151,6 +156,50 @@ bool prepare_refresh(const service::Request& request, ActivityPlan& plan) noexce
     return true;
 }
 
+/** Stages the host snapshot a start-new-activity request asks for. */
+bool prepare_start_activity(const service::Request& request, ActivityPlan& plan) noexcept {
+    service::start_activity::StartActivity parsed{};
+    std::size_t consumed = 0;
+    if (!service::start_activity::parse_start_activity(request.payload, parsed, consumed)) {
+        return false;
+    }
+    // The destination index is checked against the width its own field can carry. Which indices
+    // are installed comes from the activity definition table, which this host does not read, so
+    // an in-range index is as far as the route can be validated here.
+    const bool routed =
+        parsed.destinationActivityIndex >= 0
+        && parsed.destinationActivityIndex <= state::activity::destination::kMaximumActivityIndex;
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=activity stage=start_activity result=%s from=%d to=%d "
+                                      "tail=%u",
+                                      routed ? "accepted" : "out_of_range",
+                                      parsed.sourceActivityIndex,
+                                      parsed.destinationActivityIndex,
+                                      parsed.tailBits);
+    if (written > 0) {
+        core::log::write(core::log::Channel::server,
+                         routed ? core::log::Level::info : core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+    if (!routed) {
+        return false;
+    }
+    // The request carries no revision or bubble of its own, so the refresh guard is built from the
+    // absent pair rather than from a value the client did not send.
+    if (!membership_state::prepare_refresh(request.accountHandle,
+                                           kNoRequestedRevision,
+                                           state::activity::destination::kAbsentActivityIndex,
+                                           plan.membershipMutation)) {
+        return false;
+    }
+    plan.sessionId = request.accountHandle;
+    plan.delivery = Delivery::refreshNotifications;
+    plan.mutationDomain = MutationDomain::membership;
+    return true;
+}
+
 /** Stages a matching acknowledgement update or a transactional no-op. */
 bool prepare_acknowledgement(const service::Request& request, ActivityPlan& plan) noexcept {
     service::membership_acknowledgement::MembershipAcknowledgement parsed{};

+ 11 - 0
Sunrise/src/server/bap/encrypted/activity_message/membership/activity_membership_route.h

@@ -32,6 +32,17 @@ namespace sunrise::server::bap::encrypted::activity_message::membership {
 [[nodiscard]] bool prepare_refresh(const middleware::bap::activity_message::Request& request,
                                    ActivityPlan& plan) noexcept;
 
+/**
+ * Stages the host snapshot a start-new-activity request asks for.
+ * The request has no recovered reply of its own. The response is the same three notifications a
+ * refresh carries, so the client re-reads the state it is about to move through.
+ * @param request Validated owned svc8 envelope.
+ * @param plan Cleared, then receives the refresh transaction and its delivery.
+ * @return True when the route the request names is in range and its session is joined.
+ */
+[[nodiscard]] bool prepare_start_activity(const middleware::bap::activity_message::Request& request,
+                                          ActivityPlan& plan) noexcept;
+
 /**
  * Stages a matching acknowledgement update or a transactional no-op.
  * @param request Validated owned svc8 envelope.

+ 361 - 0
Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_message_receipts.cpp

@@ -0,0 +1,361 @@
+/**
+ * Framing handlers for every activity message that changes no State. Each one reads as much of its
+ * body as the recovered grammar reaches, reports what it saw, and returns how completely the body
+ * was read so the caller can record one arrival receipt. None of them acts on what it read.
+ */
+
+#include "activity_message_receipts.h"
+
+#include <array>
+#include <cstdarg>
+#include <cstdint>
+#include <cstdio>
+
+#include "../../../../../core/logging/log.h"
+#include "../../../../../middleware/bap/activity_message/activity_client_keepalive_validator.h"
+#include "../../../../../middleware/bap/activity_message/entity_authority.h"
+#include "../../../../../middleware/bap/activity_message/incident.h"
+#include "../../../../../middleware/bap/activity_message/peer_ledger.h"
+#include "../../../../../middleware/bap/activity_message/sense_update.h"
+#include "../../../../../middleware/bap/activity_message/start_activity.h"
+#include "../../../../../middleware/bap/activity_message/telemetry.h"
+#include "../../../../../middleware/encoding/byte_order.h"
+
+namespace sunrise::server::bap::encrypted::activity_message::receipts {
+namespace {
+
+namespace store = state::activity::receipts;
+namespace authority = message::entity_authority;
+namespace ledger = message::peer_ledger;
+namespace telemetry = message::telemetry;
+
+using store::Verdict;
+
+/**
+ * Writes one bounded key-value event on the server channel.
+ * @param level Severity, checked against the channel threshold before formatting.
+ * @param format Printf-style format holding one event line.
+ */
+void report(core::log::Level level, const char* format, ...) noexcept {
+    if (!core::log::accepts(core::log::Channel::server, level)) {
+        return;
+    }
+    std::array<char, core::log::kLineCapacity> line{};
+    va_list arguments;
+    va_start(arguments, format);
+    const int written = std::vsnprintf(line.data(), line.size(), format, arguments);
+    va_end(arguments);
+    if (written <= 0) {
+        return;
+    }
+    // vsnprintf reports the length it wanted, so a truncated line reports past the buffer.
+    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::server, level, {line.data(), length});
+}
+
+/** @return The whole payload's bit count, which is the bar a fully framed body reaches. */
+[[nodiscard]] std::size_t payload_bits(const message::Request& request) noexcept {
+    return request.payload.size() * middleware::encoding::kBitsPerByte;
+}
+
+/**
+ * Reports one body whose declared framing did not hold.
+ * @param stage Short stable stage name for the log line.
+ * @param request Validated envelope.
+ * @return Always malformed, so the caller can return it directly.
+ */
+[[nodiscard]] Verdict report_malformed(const char* stage,
+                                       const message::Request& request) noexcept {
+    report(core::log::Level::warn,
+           "ev=activity stage=%s result=malformed type=%u bytes=%zu",
+           stage,
+           request.messageType,
+           request.payload.size());
+    return Verdict::malformed;
+}
+
+} // namespace
+
+/** Frames a sensor sense update and reports its epoch. */
+Framed frame_sense_update(const message::Request& request) noexcept {
+    namespace sense = message::sense_update;
+    sense::SenseUpdate update{};
+    std::size_t consumed = 0;
+    if (!sense::parse_sense_update(request.payload, update, consumed)) {
+        return {report_malformed("sense", request), consumed};
+    }
+    report(core::log::Level::debug,
+           "ev=activity stage=sense result=framed epoch=0x%016llX%016llX groups_bits=%u",
+           static_cast<unsigned long long>(update.epoch.first),
+           static_cast<unsigned long long>(update.epoch.second),
+           update.tailBits);
+    // The group loop behind the sense delta has no recovered width, so the body is retained
+    // rather than walked.
+    return {update.tailBits == 0 ? Verdict::framed : Verdict::partial, consumed};
+}
+
+/** Records a service-8 envelope carrying the local-only activity-host request type. */
+Framed frame_route_misuse(const message::Request& request) noexcept {
+    // This type is a client-local message the transport turns into its own service. Arriving here
+    // it is an authenticated but invalid route use, and answering it would allocate a second
+    // session for one the client already has.
+    report(core::log::Level::warn,
+           "ev=activity stage=route result=misuse type=%u bytes=%zu",
+           request.messageType,
+           request.payload.size());
+    return {Verdict::quarantined, 0};
+}
+
+/** Frames a start-new-activity request without applying any transition policy to it. */
+Framed frame_start_activity(const message::Request& request) noexcept {
+    namespace start = message::start_activity;
+    start::StartActivity parsed{};
+    std::size_t consumed = 0;
+    if (!start::parse_start_activity(request.payload, parsed, consumed)) {
+        return {report_malformed("start_activity", request), consumed};
+    }
+    report(core::log::Level::info,
+           "ev=activity stage=start_activity result=read from=%d to=%d tail=%u",
+           parsed.sourceActivityIndex,
+           parsed.destinationActivityIndex,
+           parsed.tailBits);
+    return {parsed.tailBits == 0 ? Verdict::framed : Verdict::partial, consumed};
+}
+
+/** Frames a peer-reservation request as far as its revision. */
+Framed frame_reservation_request(const message::Request& request) noexcept {
+    telemetry::ReservationRequest parsed{};
+    std::size_t consumed = 0;
+    if (!telemetry::parse_reservation_request(request.payload, parsed, consumed)) {
+        return {report_malformed("reservation", request), consumed};
+    }
+    report(core::log::Level::debug,
+           "ev=activity stage=reservation result=read revision=%u records=%u",
+           parsed.revision,
+           parsed.recordBytes);
+    const std::size_t tail =
+        static_cast<std::size_t>(parsed.recordBytes) * middleware::encoding::kBitsPerByte;
+    return {tail == 0 ? Verdict::framed : Verdict::partial, consumed};
+}
+
+/** Frames a reservation release. */
+Framed frame_reservation_release(const message::Request& request) noexcept {
+    ledger::ReservationRelease release{};
+    std::size_t consumed = 0;
+    if (!ledger::parse_release(request.payload, release, consumed)) {
+        return {report_malformed("reservation_release", request), consumed};
+    }
+    report(core::log::Level::debug,
+           "ev=activity stage=reservation_release result=read peer=0x%016llX",
+           static_cast<unsigned long long>(release.peerKey));
+    return {Verdict::framed, consumed};
+}
+
+/** Frames a peer leave notice. */
+Framed frame_peer_leave(const message::Request& request) noexcept {
+    ledger::PeerLeave leave{};
+    std::size_t consumed = 0;
+    if (!ledger::parse_leave(request.payload, leave, consumed)) {
+        return {report_malformed("peer_leave", request), consumed};
+    }
+    report(core::log::Level::info,
+           "ev=activity stage=peer_leave result=read peer=0x%016llX",
+           static_cast<unsigned long long>(leave.peerKey));
+    return {Verdict::framed, consumed};
+}
+
+/** Records a debug command without reading or running it. */
+Framed frame_debug_command(const message::Request& request) noexcept {
+    // The nested command definition is runtime selected, so the body cannot be walked from the
+    // outer root alone. It is never executed, dispatched, or sent on to another client.
+    report(core::log::Level::warn,
+           "ev=activity stage=debug_command result=quarantined bytes=%zu",
+           request.payload.size());
+    return {Verdict::quarantined, 0};
+}
+
+/** Frames a connectivity failure report. */
+Framed frame_connectivity_failure(const message::Request& request) noexcept {
+    ledger::ConnectivityFailure failure{};
+    std::size_t consumed = 0;
+    if (!ledger::parse_connectivity_failure(request.payload, failure, consumed)) {
+        return {report_malformed("connectivity", request), consumed};
+    }
+    report(core::log::Level::info,
+           "ev=activity stage=connectivity result=read peer=0x%016llX reason=%u",
+           static_cast<unsigned long long>(failure.peerKey),
+           static_cast<unsigned>(failure.rawReason));
+    // The two bits are the last schema field; the rest of the ninth byte is padding.
+    return {Verdict::framed, consumed};
+}
+
+/** Records a client heartbeat as a bounded body. */
+Framed frame_heartbeat(const message::Request& request) noexcept {
+    // One runtime-selected nested definition, so the declared service length is the only bound.
+    report(core::log::Level::debug,
+           "ev=activity stage=heartbeat result=bounded bytes=%zu",
+           request.payload.size());
+    return {Verdict::partial, 0};
+}
+
+/** Frames a lag-switch report as far as its record count. */
+Framed frame_lag_switch(const message::Request& request) noexcept {
+    telemetry::LagSwitchReport parsed{};
+    std::size_t consumed = 0;
+    if (!telemetry::parse_lag_switch(request.payload, parsed, consumed)) {
+        return {report_malformed("lag_switch", request), consumed};
+    }
+    report(parsed.aboveSupported ? core::log::Level::warn : core::log::Level::debug,
+           "ev=activity stage=lag_switch result=%s records=%u tail=%u",
+           parsed.aboveSupported ? "over_supported" : "read",
+           static_cast<unsigned>(parsed.recordCount),
+           parsed.recordBits);
+    // A count above what the record grammar supports is retained and not acted on, because the
+    // records behind it have no recovered shape either way.
+    return {parsed.aboveSupported ? Verdict::quarantined : Verdict::partial, consumed};
+}
+
+/** Records a connection-quality report as a bounded body. */
+Framed frame_connection_quality(const message::Request& request) noexcept {
+    // Two nested structures whose leaf grammar is unresolved.
+    report(core::log::Level::debug,
+           "ev=activity stage=connection_quality result=bounded bytes=%zu",
+           request.payload.size());
+    return {Verdict::partial, 0};
+}
+
+/** Frames a speculative migration proposal without acting on it. */
+Framed frame_migration(const message::Request& request) noexcept {
+    ledger::MigrationProposal proposal{};
+    std::size_t consumed = 0;
+    if (!ledger::parse_migration(request.payload, proposal, consumed)) {
+        return {report_malformed("migration", request), consumed};
+    }
+    // Host ownership never moves from a proposal. Acting on one needs the group migration state
+    // machine, and a host that answers without it can split the session in two.
+    report(core::log::Level::info,
+           "ev=activity stage=migration result=noted peer=0x%016llX scalar=%d",
+           static_cast<unsigned long long>(proposal.peerKey),
+           proposal.scalar);
+    return {Verdict::framed, consumed};
+}
+
+/** Frames the fixed high-water telemetry block. */
+Framed frame_high_water(const message::Request& request) noexcept {
+    telemetry::HighWater block{};
+    std::size_t consumed = 0;
+    if (!telemetry::parse_high_water(request.payload, block, consumed)) {
+        return {report_malformed("high_water", request), consumed};
+    }
+    report(
+        core::log::Level::debug, "ev=activity stage=high_water result=framed bits=%zu", consumed);
+    return {Verdict::framed, consumed};
+}
+
+/** Frames one of the two opaque scalar messages. */
+Framed frame_opaque_scalar(const message::Request& request) noexcept {
+    std::int32_t value = 0;
+    std::size_t consumed = 0;
+    if (!telemetry::parse_opaque_scalar(request.payload, value, consumed)) {
+        return {report_malformed("scalar", request), consumed};
+    }
+    report(core::log::Level::debug,
+           "ev=activity stage=scalar result=read type=%u value=%d",
+           request.messageType,
+           value);
+    return {Verdict::framed, consumed};
+}
+
+/** Frames the one-byte activity keepalive. */
+Framed frame_client_keepalive(const message::Request& request) noexcept {
+    namespace keepalive = message::client_keepalive;
+    if (!keepalive::validate_client_keepalive(request.payload)) {
+        return {report_malformed("keepalive", request), 0};
+    }
+    // The single byte is uninitialized at the sender, so it carries no value to read.
+    const std::size_t consumed = payload_bits(request);
+    return {Verdict::framed, consumed};
+}
+
+/** Frames one incident and quarantines a poison target. */
+Framed frame_incident(const message::Request& request) noexcept {
+    namespace incident = message::incident;
+    incident::Incident parsed{};
+    const incident::Verdict verdict = incident::validate(request.payload, parsed);
+    const bool accepted = verdict == incident::Verdict::accepted;
+    report(accepted ? core::log::Level::debug : core::log::Level::warn,
+           "ev=activity stage=incident result=%s target=%u extra=%u selector=%u "
+           "optional=%u payload=%u",
+           incident::verdict_name(verdict),
+           parsed.primaryTarget,
+           parsed.extraTargetCount,
+           parsed.selectorLength,
+           static_cast<unsigned>(parsed.hasOptionalBlock),
+           parsed.payloadLength);
+    if (!accepted) {
+        // A refused target index would index the consumer's table unbounded, so the body is kept
+        // and never relayed.
+        const Verdict outcome = verdict == incident::Verdict::targetPoisoned
+                                        || verdict == incident::Verdict::targetOutOfRange
+                                    ? Verdict::quarantined
+                                    : Verdict::malformed;
+        return {outcome, parsed.consumedBits};
+    }
+    return {Verdict::framed, parsed.consumedBits};
+}
+
+/** Frames one authority release, which records authority and returns no lease. */
+Framed frame_authority_release(const message::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) {
+        return {report_malformed("authority", request), 0};
+    }
+    report(core::log::Level::debug,
+           "ev=activity stage=authority result=noted type=%u selector=%u reason=%d",
+           request.messageType,
+           static_cast<unsigned>(decoded.selector),
+           decoded.hasReason ? decoded.reason : 0);
+    return {Verdict::framed, payload_bits(request)};
+}
+
+/** Frames one purge request. Nothing answers it. */
+Framed frame_request_purge(const message::Request& request) noexcept {
+    std::int32_t reason = 0;
+    if (!authority::parse_request_purge(request.payload, reason)) {
+        return {report_malformed("purge", request), 0};
+    }
+    // The answer would have to name the exact next authority generation, which nothing here
+    // tracks, and the consumer asserts on any other value.
+    report(core::log::Level::debug, "ev=activity stage=purge result=noted reason=%d", reason);
+    return {Verdict::framed, payload_bits(request)};
+}
+
+/** Frames one authority query answer. */
+Framed frame_query_answer(const message::Request& request) noexcept {
+    authority::QueryAnswer answer{};
+    if (!authority::parse_query_answer(request.messageType, request.payload, answer)) {
+        return {report_malformed("authority_answer", request), 0};
+    }
+    // This host sends no query, so an answer is the client reconciling on its own.
+    report(core::log::Level::debug,
+           "ev=activity stage=authority result=answer type=%u corr=0x%08X selector=%d",
+           request.messageType,
+           answer.correlation,
+           answer.hasSelector ? static_cast<int>(answer.selector) : -1);
+    return {Verdict::framed, payload_bits(request)};
+}
+
+/** Records an envelope whose message type has no recovered body grammar. */
+Framed frame_unknown(const message::Request& request) noexcept {
+    report(core::log::Level::warn,
+           "ev=activity stage=unknown result=bounded type=%u bytes=%zu",
+           request.messageType,
+           request.payload.size());
+    return {Verdict::partial, 0};
+}
+
+} // namespace sunrise::server::bap::encrypted::activity_message::receipts

+ 81 - 0
Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_message_receipts.h

@@ -0,0 +1,81 @@
+#pragma once
+
+#include <cstddef>
+
+#include "../../../../../middleware/bap/activity_message/definition.h"
+#include "../../../../../state/activity/receipts/definition.h"
+
+namespace sunrise::server::bap::encrypted::activity_message::receipts {
+
+/** One message framed by the route, which changed no State. */
+struct Framed {
+    /** How completely the body was read. */
+    state::activity::receipts::Verdict verdict{state::activity::receipts::Verdict::framed};
+    /** Bits the parser consumed. Below the payload's own bit count means a tail was left. */
+    std::size_t consumedBits{};
+};
+
+namespace message = middleware::bap::activity_message;
+
+/** Frames a sensor sense update and reports its epoch. */
+[[nodiscard]] Framed frame_sense_update(const message::Request& request) noexcept;
+
+/** Records a service-8 envelope carrying the local-only activity-host request type. */
+[[nodiscard]] Framed frame_route_misuse(const message::Request& request) noexcept;
+
+/** Frames a start-new-activity request without applying any transition policy to it. */
+[[nodiscard]] Framed frame_start_activity(const message::Request& request) noexcept;
+
+/** Frames a peer-reservation request as far as its revision. */
+[[nodiscard]] Framed frame_reservation_request(const message::Request& request) noexcept;
+
+/** Frames a reservation release. */
+[[nodiscard]] Framed frame_reservation_release(const message::Request& request) noexcept;
+
+/** Frames a peer leave notice. */
+[[nodiscard]] Framed frame_peer_leave(const message::Request& request) noexcept;
+
+/** Records a debug command without reading or running it. */
+[[nodiscard]] Framed frame_debug_command(const message::Request& request) noexcept;
+
+/** Frames a connectivity failure report. */
+[[nodiscard]] Framed frame_connectivity_failure(const message::Request& request) noexcept;
+
+/** Records a client heartbeat as a bounded body. */
+[[nodiscard]] Framed frame_heartbeat(const message::Request& request) noexcept;
+
+/** Frames a lag-switch report as far as its record count. */
+[[nodiscard]] Framed frame_lag_switch(const message::Request& request) noexcept;
+
+/** Records a connection-quality report as a bounded body. */
+[[nodiscard]] Framed frame_connection_quality(const message::Request& request) noexcept;
+
+/** Frames a speculative migration proposal without acting on it. */
+[[nodiscard]] Framed frame_migration(const message::Request& request) noexcept;
+
+/** Frames the fixed high-water telemetry block. */
+[[nodiscard]] Framed frame_high_water(const message::Request& request) noexcept;
+
+/** Frames one of the two opaque scalar messages. */
+[[nodiscard]] Framed frame_opaque_scalar(const message::Request& request) noexcept;
+
+/** Frames the one-byte activity keepalive. */
+[[nodiscard]] Framed frame_client_keepalive(const message::Request& request) noexcept;
+
+/** Frames one incident and quarantines a poison target. */
+[[nodiscard]] Framed frame_incident(const message::Request& request) noexcept;
+
+/** Frames one authority release, which records authority and returns no lease. */
+[[nodiscard]] Framed frame_authority_release(const message::Request& request,
+                                             bool expectReason) noexcept;
+
+/** Frames one purge request. Nothing answers it. */
+[[nodiscard]] Framed frame_request_purge(const message::Request& request) noexcept;
+
+/** Frames one authority query answer. */
+[[nodiscard]] Framed frame_query_answer(const message::Request& request) noexcept;
+
+/** Records an envelope whose message type has no recovered body grammar. */
+[[nodiscard]] Framed frame_unknown(const message::Request& request) noexcept;
+
+} // namespace sunrise::server::bap::encrypted::activity_message::receipts

+ 20 - 13
Sunrise/src/server/bap/encrypted/activity_transaction/activity_transaction_notifications.cpp

@@ -1,4 +1,4 @@
-#include "activity_transaction_notifications.h"
+#include "activity_transaction_notifications.h"
 
 #include "../../../../core/logging/log.h"
 #include "../../../gameplay/gameplay_advertisement.h"
@@ -13,17 +13,22 @@ namespace {
 
 /**
  * Reports whether the citizen advertisement this membership body would carry is still coming.
- * The client applies one membership update per revision, so a body sent before the region's host
- * session exists spends that revision on a record no later push can fill. Holding costs one
- * keepalive.
+ * One membership update lands per revision, so a body sent before the region's host session exists
+ * spends that revision on a record no later push can fill. Holding costs one keepalive.
  * @param activity Prepared activity transaction, whose region this body publishes.
  * @return True when the push has to wait.
  */
-[[nodiscard]] bool advertisement_pending(const activity_message::ActivityPlan& activity) noexcept {
+[[nodiscard]] bool advertisement_pending(const Session& session,
+                                         const activity_message::ActivityPlan& activity) noexcept {
+    if (session.activity.role != ActivityClientRole::privateCurrent
+        || !state::activity::binding_matches(session.activity.source)) {
+        return false;
+    }
     // Take the delta's region, not the committed one. Staging runs before the commit, so the
     // committed value still names the region the player has left.
     const server::gameplay::AdvertisementState state = server::gameplay::advertisement_state(
-        push::activity::planned_region(activity.membershipMutation, activity.sessionId).index);
+        session.activity.source,
+        push::activity::planned_region(activity.membershipMutation, session.activity.source).index);
     if (state != server::gameplay::AdvertisementState::pending) {
         return false;
     }
@@ -54,10 +59,11 @@ namespace {
                                  std::span<std::byte> response,
                                  std::size_t& written) noexcept {
     bool staged = push::activity::append_global_state_notification(
-        scratch, activity.sessionId, key, nonce, response, written);
-    if (activity.membershipMutation.hasSnapshot && !advertisement_pending(activity)) {
+        scratch, session.activity.session, key, nonce, response, written);
+    if (session.activity.role == ActivityClientRole::privateCurrent
+        && activity.membershipMutation.hasSnapshot && !advertisement_pending(session, activity)) {
         staged = push::activity::append_membership_notification(
-                     scratch, activity, key, nonce, response, written)
+                     scratch, session, activity, key, nonce, response, written)
                  || staged;
     }
     return push::activity::append_roster_notification(
@@ -87,11 +93,12 @@ namespace {
                                        std::size_t& written) noexcept {
     bool staged = false;
     bool held = false;
-    if (activity.membershipMutation.hasSnapshot) {
-        held = advertisement_pending(activity);
+    if (session.activity.role == ActivityClientRole::privateCurrent
+        && activity.membershipMutation.hasSnapshot) {
+        held = advertisement_pending(session, activity);
         if (!held) {
             staged = push::activity::append_membership_notification(
-                scratch, activity, key, nonce, response, written);
+                scratch, session, activity, key, nonce, response, written);
         }
     }
     if (activity.regionMoved) {
@@ -141,7 +148,7 @@ bool stage_notifications(Session& session,
     }
     if (activity.delivery == activity_message::Delivery::membershipNotification) {
         return push::activity::append_membership_notification(
-            scratch, activity, key, nonce, response, written);
+            scratch, session, activity, key, nonce, response, written);
     }
     if (activity.delivery == activity_message::Delivery::refreshNotifications) {
         return stage_refresh(session, scratch, activity, key, nonce, response, written);

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

@@ -2,6 +2,12 @@
 
 #include <Windows.h>
 
+#include <atomic>
+#include <limits>
+
+#include "../../../state/activity/runtime.h"
+#include "../../gameplay/group/group_host_sessions.h"
+
 namespace sunrise::server::bap::encrypted {
 namespace {
 
@@ -15,8 +21,48 @@ constexpr std::uint64_t kBannerRepushDelayMs = 400;
  */
 constexpr std::uint64_t kTransitionWindowMs = 15'000;
 
+/** Process-lifetime generation that rejects delayed epochs after a BAP slot is reused. */
+std::atomic<std::uint64_t> g_nextActivityBindingGeneration{1};
+
+/** Releases one host-session retain when it is present. */
+void release_host_generation(std::uint64_t generation) noexcept {
+    if (generation != 0) {
+        server::gameplay::group::release_host_session(generation);
+    }
+}
+
+/** Clears all connection state rebuilt by a successful activity join. */
+void reset_join_state(Session& session) noexcept {
+    session.activityMemberKey = 0;
+    session.activityCharacterSoid = 0;
+    session.activityKeepaliveDueTick = 0;
+    session.activityRosterDueTick = 0;
+    session.activityTransitionUntilTick = 0;
+    session.activityPatchEpoch = {};
+    session.activityRosterGroups = 0;
+    session.activityRosterSends = 0;
+    session.activityRosterReason = 0;
+    session.activityRosterStaged = {};
+    if (session.activity.role == ActivityClientRole::privateCurrent) {
+        session.activity.advertisedRegion = -1;
+    }
+}
+
 } // namespace
 
+/** Reserves one process-lifetime ActivityClient generation without wrapping. */
+bool reserve_activity_binding_generation(std::uint64_t& generation) noexcept {
+    generation = 0;
+    std::uint64_t current = g_nextActivityBindingGeneration.load();
+    while (current != (std::numeric_limits<std::uint64_t>::max)()) {
+        if (g_nextActivityBindingGeneration.compare_exchange_weak(current, current + 1)) {
+            generation = current;
+            return true;
+        }
+    }
+    return false;
+}
+
 /** Captures the connection fields one service outcome carries. */
 ConnectionFields connection_fields(const ServiceOutcome& outcome) noexcept {
     ConnectionFields fields{};
@@ -44,22 +90,25 @@ void publish_connection_fields(Session& session,
                                const transactions::Publication& publication,
                                const ConnectionFields& fields) noexcept {
     if (publication.hasActivitySessionBinding) {
-        // Only the first binding decides the link's kind. A link that allocated its own session
-        // also joins later, and that join must not reclassify it.
-        if (session.activitySessionId == 0 && publication.activitySessionFromJoin) {
-            session.activityJoinedForeignSession = true;
+        if (!publication.preservesActivitySessionBinding) {
+            release_activity_connection(session);
+            session.activity = publication.activity;
+            reset_join_state(session);
         }
-        session.activitySessionId = publication.activitySessionId;
+        session.activity.bindingGeneration = publication.activity.bindingGeneration;
     }
-    if (fields.joinMemberKey != 0) {
+    if (fields.joinsActivity) {
+        discard_staged_advertisement(session);
+        release_host_generation(session.activityAdvertisementHostGeneration);
+        session.activityAdvertisementHostGeneration = 0;
+        reset_join_state(session);
         session.activityMemberKey = fields.joinMemberKey;
-    }
-    if (fields.joinCharacterSoid != 0) {
         session.activityCharacterSoid = fields.joinCharacterSoid;
     }
     if (fields.retainsPatchEpoch) {
-        session.activityPatchEpoch = fields.patchEpoch;
-        session.activityPatchEpochSeen = true;
+        session.activityPatchEpoch.value = fields.patchEpoch;
+        session.activityPatchEpoch.bindingGeneration = session.activity.bindingGeneration;
+        session.activityPatchEpoch.seen = session.activity.role != ActivityClientRole::none;
     }
     if (fields.opensTransitionWindow) {
         session.activityTransitionUntilTick = GetTickCount64() + kTransitionWindowMs;
@@ -73,6 +122,47 @@ void publish_connection_fields(Session& session,
     }
 }
 
+/** Stages one retained host row until the membership frame has an outcome. */
+void stage_activity_advertisement(Session& session, std::uint64_t hostGeneration) noexcept {
+    discard_staged_advertisement(session);
+    session.activityAdvertisementStaged.hostGeneration = hostGeneration;
+    session.activityAdvertisementStaged.staged = true;
+}
+
+/** Publishes one staged advertisement retain. */
+void commit_staged_advertisement(Session& session) noexcept {
+    if (!session.activityAdvertisementStaged.staged) {
+        return;
+    }
+    release_host_generation(session.activityAdvertisementHostGeneration);
+    session.activityAdvertisementHostGeneration =
+        session.activityAdvertisementStaged.hostGeneration;
+    session.activityAdvertisementStaged = {};
+}
+
+/** Releases one staged advertisement retain. */
+void discard_staged_advertisement(Session& session) noexcept {
+    if (session.activityAdvertisementStaged.staged) {
+        release_host_generation(session.activityAdvertisementStaged.hostGeneration);
+        session.activityAdvertisementStaged = {};
+    }
+}
+
+/** Releases every exact activity owner held by one BAP connection. */
+void release_activity_connection(Session& session) noexcept {
+    discard_staged_advertisement(session);
+    release_host_generation(session.activityAdvertisementHostGeneration);
+    session.activityAdvertisementHostGeneration = 0;
+    if (session.activity.hostGeneration != 0) {
+        server::gameplay::group::release_host_session(session.activity.hostGeneration);
+    }
+    if (session.activity.session.sessionId != state::activity::kAbsentSessionId) {
+        state::activity::release_binding(session.activity.session);
+    }
+    session.activity = {};
+    session.activityPatchEpoch = {};
+}
+
 /** Arms the owed Family-4 and banner re-pushes when the queuez publication asks for them. */
 void arm_repushes(Session& session, const queuez::StagedPublication& queuezPublication) noexcept {
     const std::uint64_t now = GetTickCount64();

+ 15 - 0
Sunrise/src/server/bap/encrypted/bap_connection_publication.h

@@ -23,6 +23,9 @@ struct ConnectionFields {
     bool joinsActivity{};
 };
 
+/** Reserves one process-lifetime ActivityClient generation without wrapping. */
+[[nodiscard]] bool reserve_activity_binding_generation(std::uint64_t& generation) noexcept;
+
 /**
  * Captures the connection fields one service outcome carries.
  * @param outcome Prepared outcome, still holding its uncommitted mutations.
@@ -40,6 +43,18 @@ void publish_connection_fields(Session& session,
                                const transactions::Publication& publication,
                                const ConnectionFields& fields) noexcept;
 
+/** Stages one retained host row until the membership frame is published or discarded. */
+void stage_activity_advertisement(Session& session, std::uint64_t hostGeneration) noexcept;
+
+/** Publishes the staged advertisement retain and releases the previous delivered one. */
+void commit_staged_advertisement(Session& session) noexcept;
+
+/** Releases a retained advertisement that never reached the caller. */
+void discard_staged_advertisement(Session& session) noexcept;
+
+/** Releases every exact activity binding and advertisement owned by one BAP link. */
+void release_activity_connection(Session& session) noexcept;
+
 /**
  * Arms the owed Family-4 and banner re-pushes when the queuez publication asks for them.
  * @param session Connection-owned re-push timers.

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

@@ -66,7 +66,7 @@ std::atomic<std::uint64_t> g_translatedIdentity{0};
  * Processes the body for one authenticated service route.
  * @param route Service route data found earlier.
  * @param queuezState Queuez versions and residents set up by this BAP peer.
- * @param activitySessionId Activity capability allocated through this BAP session.
+ * @param activity Exact ActivityClient generation owned by this BAP session.
  * @param matchmakingContext State-owned logical context for this BAP session.
  * @param requestBody Borrowed decrypted request body.
  * @param output Caller-owned response-body storage.
@@ -76,7 +76,7 @@ std::atomic<std::uint64_t> g_translatedIdentity{0};
  */
 bool process(const ServiceRoute& route,
              const queuez::SessionState& queuezState,
-             std::uint64_t activitySessionId,
+             const ActivityClientBinding& activity,
              state::matchmaking::ContextHandle matchmakingContext,
              std::span<const std::byte> requestBody,
              std::span<std::byte> output,
@@ -115,7 +115,7 @@ bool process(const ServiceRoute& route,
         activity_message::ActivityPlan plan{};
         bool hasTransaction = false;
         const bool processed =
-            activity_message::process(activitySessionId, requestBody, plan, hasTransaction);
+            activity_message::process(activity, requestBody, plan, hasTransaction);
         if (processed && hasTransaction) {
             outcome.transaction = plan;
         }

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

@@ -98,7 +98,7 @@ bool consume(Session& session,
     if (processesBody
         && !body::process(route,
                           session.queuez,
-                          session.activitySessionId,
+                          session.activity,
                           session.matchmakingContext,
                           frame.body,
                           scratch.responseBody,
@@ -217,6 +217,12 @@ bool consume(Session& session,
             publish_connection_fields(session, publication, connection);
             // The caller copy is done, so what the staged roster body owes is settled here.
             push::activity::commit_staged_roster(session);
+            commit_staged_advertisement(session);
+            // Any delivered activity notification resets the client's silence timer. Delay the
+            // fallback keepalive so this same request does not append a redundant second push.
+            if (activityPlan != nullptr && framedSize != 0) {
+                session.activityKeepaliveDueTick = GetTickCount64() + kActivityKeepaliveIntervalMs;
+            }
             session.accountMutationPublished = mutatesAccount;
             if (transaction_if<EquipmentSwapTransaction>(outcome) != nullptr) {
                 std::array<char, core::log::kLineCapacity> line{};
@@ -346,6 +352,7 @@ bool consume(Session& session,
     if (!handled) {
         // The staged body is dropped, so its grant and its state byte go back for the next push.
         push::activity::discard_staged_roster(session);
+        discard_staged_advertisement(session);
     }
     clear_prefix(scratch.plaintext, plaintextSize);
     clear_prefix(scratch.responseBody, responseBodySize);

+ 2 - 2
Sunrise/src/server/bap/encrypted/internal.h

@@ -167,7 +167,7 @@ namespace body {
  * Processes a request body and encodes a correlated body when the route needs one.
  * @param route Service route data found earlier.
  * @param queuezState Queuez versions and residents set up by this BAP peer.
- * @param activitySessionId Activity capability allocated through this BAP session.
+ * @param activity Exact ActivityClient generation owned by this BAP session.
  * @param matchmakingContext State-owned logical context for this BAP session.
  * @param requestBody Borrowed decrypted request body.
  * @param output Caller-owned response-body storage.
@@ -177,7 +177,7 @@ namespace body {
  */
 [[nodiscard]] bool process(const ServiceRoute& route,
                            const queuez::SessionState& queuezState,
-                           std::uint64_t activitySessionId,
+                           const ActivityClientBinding& activity,
                            state::matchmaking::ContextHandle matchmakingContext,
                            std::span<const std::byte> requestBody,
                            std::span<std::byte> output,

+ 6 - 4
Sunrise/src/server/bap/encrypted/push/activity/activity_arrival.h

@@ -4,6 +4,7 @@
 #include <string_view>
 
 #include "../../../../../state/activity/defaults/definition.h"
+#include "../../../../../state/activity/definition.h"
 #include "../../../../../state/activity/destination/definition.h"
 #include "../../../../../state/activity/membership/activity_membership_query.h"
 #include "../../../../../state/build_data/scenarios/definition.h"
@@ -40,21 +41,22 @@ struct EffectiveRegion final {
 /**
  * Resolves the one region a session publishes.
  * The client's report wins. Before the first report the destination's arrival slice set stands in.
- * @param sessionId Joined activity session.
+ * @param binding Exact joined activity-session generation.
  * @return The published region index, its source, and the destination's arrival slice set.
  */
-[[nodiscard]] EffectiveRegion effective_region(std::uint64_t sessionId) noexcept;
+[[nodiscard]] EffectiveRegion
+effective_region(const state::activity::SessionBinding& binding) noexcept;
 
 /**
  * Resolves the region one prepared membership body publishes.
  * Staging runs before the commit, so committed State still names the region just left. The body
  * must name the delta's region or its advertisement fills the wrong record.
  * @param mutation Prepared membership operation, whose sparse input may carry a new region.
- * @param sessionId Joined activity session, used when the delta names no region.
+ * @param binding Exact joined activity-session generation used when the delta names no region.
  * @return The region this body publishes, its source, and the destination's arrival slice set.
  */
 [[nodiscard]] EffectiveRegion
 planned_region(const state::activity::membership::PendingMutation& mutation,
-               std::uint64_t sessionId) noexcept;
+               const state::activity::SessionBinding& binding) noexcept;
 
 } // namespace sunrise::server::bap::encrypted::push::activity

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

@@ -10,6 +10,7 @@
 #include "../../../../../state/activity/defaults/activity_defaults_snapshot.h"
 #include "../../../../../state/activity/destination/activity_destination_snapshot.h"
 #include "../../../../../state/activity/destination/activity_destination_spawn_binding.h"
+#include "../../../../../state/activity/runtime.h"
 #include "../../../../../state/build_data/runtime.h"
 #include "activity_arrival.h"
 #include "activity_notification_frame.h"
@@ -45,20 +46,19 @@ void copy_name(const state::activity::destination::DestinationSelection& selecti
 
 /** Builds the whole message body input for one session. */
 [[nodiscard]] bool
-resolve_state(std::uint64_t sessionId,
+resolve_state(const state::activity::SessionBinding& binding,
               message::GlobalActivityState& output,
               state::activity::destination::DestinationSelection& selection) noexcept {
     output = {};
+    if (!state::activity::binding_matches(binding)) {
+        return false;
+    }
     state::activity::defaults::ActivityDefaults defaults{};
     state::activity::defaults::snapshot(defaults);
     const state::activity::defaults::FallbackPolicy& fallback =
         defaults.defaultDestination.fallback;
 
-    // The session's own destination wins. The authored default covers a session that committed
-    // before any selection was readable.
-    if (!state::activity::destination::snapshot(sessionId, selection)) {
-        selection = defaults.defaultDestination.selection;
-    }
+    selection = binding.destination;
     copy_name(selection, output);
     // The descriptor view points into caller storage that outlives the encode.
     output.descriptorBits = std::span<const std::byte>(selection.descriptorBits);
@@ -96,14 +96,14 @@ resolve_state(std::uint64_t sessionId,
 
 /** Appends one global-activity-state svc9 notification and advances its local nonce. */
 bool append_global_state_notification(Scratch& scratch,
-                                      std::uint64_t sessionId,
+                                      const state::activity::SessionBinding& binding,
                                       std::span<const std::byte, state::kAesKeySize> key,
                                       std::array<std::byte, state::kBapNonceSize>& nonce,
                                       std::span<std::byte> response,
                                       std::size_t& written) noexcept {
     message::GlobalActivityState body{};
     state::activity::destination::DestinationSelection selection{};
-    if (written > response.size() || !resolve_state(sessionId, body, selection)) {
+    if (written > response.size() || !resolve_state(binding, body, selection)) {
         return false;
     }
 
@@ -113,7 +113,7 @@ bool append_global_state_notification(Scratch& scratch,
     const bool encoded =
         message::encode_global_activity_state(body, scratch.responseBody, messageSize)
         && append_notification_frame(scratch,
-                                     sessionId,
+                                     binding.sessionId,
                                      message::kMessageType,
                                      std::span(scratch.responseBody).first(messageSize),
                                      key,

+ 5 - 4
Sunrise/src/server/bap/encrypted/push/activity/activity_global_state_push.h

@@ -6,6 +6,7 @@
 #include <span>
 
 #include "../../../../../middleware/bap/activity_message/activity_global_state_encoder.h"
+#include "../../../../../state/activity/definition.h"
 #include "../../../../../state/activity/destination/definition.h"
 #include "../../../internal.h"
 
@@ -13,13 +14,13 @@ namespace sunrise::server::bap::encrypted::push::activity {
 
 /**
  * Builds the whole message body input for one session.
- * @param sessionId Nonzero activity id.
+ * @param binding Exact activity generation whose destination is encoded.
  * @param output Cleared, then receives the destination found and the bubble policy.
  * @param selection Caller storage the body's descriptor view points into.
  * @return True when the authored defaults are present.
  */
 [[nodiscard]] bool
-resolve_state(std::uint64_t sessionId,
+resolve_state(const state::activity::SessionBinding& binding,
               middleware::bap::activity_message::global_activity_state::GlobalActivityState& output,
               state::activity::destination::DestinationSelection& selection) noexcept;
 
@@ -28,7 +29,7 @@ resolve_state(std::uint64_t sessionId,
  * The body is built from the destination committed with the session, falling back to the authored
  * default destination and its bubble policy when no scenario table is present.
  * @param scratch Lock-owned transform buffers.
- * @param sessionId Nonzero activity id echoed in the svc9 envelope.
+ * @param binding Exact activity generation echoed in the svc9 envelope.
  * @param key Active AES-GCM session key.
  * @param nonce Local send nonce advanced only after the complete notification exists.
  * @param response Lock-owned complete-frame staging storage.
@@ -37,7 +38,7 @@ resolve_state(std::uint64_t sessionId,
  */
 [[nodiscard]] bool
 append_global_state_notification(Scratch& scratch,
-                                 std::uint64_t sessionId,
+                                 const state::activity::SessionBinding& binding,
                                  std::span<const std::byte, state::kAesKeySize> key,
                                  std::array<std::byte, state::kBapNonceSize>& nonce,
                                  std::span<std::byte> response,

+ 158 - 56
Sunrise/src/server/bap/encrypted/push/activity/activity_keepalive_push.cpp

@@ -6,11 +6,13 @@
 #include <cstdio>
 
 #include "../../../../../core/logging/log.h"
+#include "../../../../../core/settings/settings.h"
 #include "../../../../../state/activity/definition.h"
 #include "../../../../../state/activity/membership/activity_membership_query.h"
 #include "../../../../../state/runtime/runtime.h"
 #include "../../../../gameplay/gameplay_advertisement.h"
 #include "../../activity_message/definition.h"
+#include "../../bap_connection_publication.h"
 #include "activity_arrival.h"
 #include "activity_global_state_push.h"
 #include "activity_membership_push.h"
@@ -20,16 +22,17 @@
 namespace sunrise::server::bap::encrypted::push::activity {
 namespace {
 
-/**
- * Keepalive cadence. The client tears the session down after 20.5 seconds of server silence, so
- * 5 seconds leaves 3 writes of margin.
- */
-constexpr std::uint64_t kKeepaliveIntervalMs = 5'000;
 /**
  * Roster burst cadence, used only while the client is loading. Outside that window the roster
  * rides the keepalive.
  */
 constexpr std::uint64_t kRosterBurstIntervalMs = 1'000;
+/**
+ * Retry cadence for a membership body held while its advertisement is still being allocated.
+ * The allocation lands in the next service slice, so this is short. Leaving the keepalive due
+ * instead would rebuild and send a whole frame on every pump.
+ */
+constexpr std::uint64_t kMembershipRetryIntervalMs = 250;
 /** -1 asks for the membership snapshot without naming a bubble. */
 constexpr std::int32_t kNoBubble = -1;
 /** A refresh re-send carries the current revision instead of asking for an older one. */
@@ -56,6 +59,7 @@ constexpr std::uint32_t kCurrentRevision = 0;
     if (!published || framedSize == 0 || framedSize > response.size()) {
         // Nothing left, so a roster staged into the discarded body is offered again next push.
         discard_staged_roster(session);
+        discard_staged_advertisement(session);
         return false;
     }
     for (std::size_t index = 0; index < framedSize; ++index) {
@@ -65,6 +69,7 @@ constexpr std::uint32_t kCurrentRevision = 0;
     session.sendNonce = nextSendNonce;
     // Settled only here: the grant and the state byte may move only on a delivered frame.
     commit_staged_roster(session);
+    commit_staged_advertisement(session);
     return true;
 }
 
@@ -80,19 +85,22 @@ bool consume_activity_keepalive(Session& session,
     const std::uint64_t now = GetTickCount64();
     // The burst runs only while the client is loading. A join or a transition-token change opens
     // that window. Outside it the roster goes out on the keepalive alone.
-    const bool burstDue = !session.activityJoinedForeignSession
-                          && now < session.activityTransitionUntilTick
-                          && now >= session.activityRosterDueTick;
+    const bool burstDue =
+        now < session.activityTransitionUntilTick && now >= session.activityRosterDueTick;
     const bool keepaliveDue = now >= session.activityKeepaliveDueTick;
     // A region change cannot wait for the keepalive. The client claims the next region almost at
     // once. Only the reported field is read here, because this runs on every pump.
+    const bool active = session.activity.role != ActivityClientRole::none
+                        && state::activity::binding_matches(session.activity.session)
+                        && state::activity::binding_matches(session.activity.source);
+    const bool isPrivate = session.activity.role == ActivityClientRole::privateCurrent;
     const std::int32_t reportedRegion =
-        session.activitySessionId == 0
-            ? -1
-            : state::activity::membership::reported_region(session.activitySessionId);
-    const bool regionChanged = !session.activityJoinedForeignSession && reportedRegion >= 0
-                               && reportedRegion != session.activityAdvertisedRegion;
-    if (session.activitySessionId == 0 || (!burstDue && !keepaliveDue && !regionChanged)) {
+        active && isPrivate
+            ? state::activity::membership::reported_region(session.activity.source.sessionId)
+            : -1;
+    const bool regionChanged =
+        isPrivate && reportedRegion >= 0 && reportedRegion != session.activity.advertisedRegion;
+    if (!active || (!burstDue && !keepaliveDue && !regionChanged)) {
         return false;
     }
     touchesScratch = true;
@@ -106,68 +114,141 @@ bool consume_activity_keepalive(Session& session,
     // The burst is what step 36 waits on. When both timers fire together the roster goes out last,
     // because the type-13 key binds to the player message 12 creates.
     if (!keepaliveDue && !regionChanged) {
-        session.activityRosterDueTick = now + kRosterBurstIntervalMs;
         published = append_roster_notification(
             session, scratch, key, nextSendNonce, scratch.framed, framedSize, true);
-        return publish_frame(
+        const bool delivered = publish_frame(
             session, scratch, response, written, framedSize, nextSendNonce, published);
+        if (delivered) {
+            session.activityRosterDueTick = now + kRosterBurstIntervalMs;
+        }
+        return delivered;
     }
-    session.activityKeepaliveDueTick = now + kKeepaliveIntervalMs;
     published = append_global_state_notification(
-        scratch, session.activitySessionId, key, nextSendNonce, scratch.framed, framedSize);
-    if (session.activityJoinedForeignSession) {
-        // This link exists only so the client's second activity instance sees traffic. A roster or
-        // membership push on it leaves the transition running with no world entered.
-        return publish_frame(
+        scratch, session.activity.session, key, nextSendNonce, scratch.framed, framedSize);
+    // Nothing may advance membership State until the first required frame proves this caller owns
+    // enough capacity to publish at least the keepalive prefix.
+    if (!published || framedSize > response.size()) {
+        static_cast<void>(publish_frame(
+            session, scratch, response, written, framedSize, nextSendNonce, published));
+        return false;
+    }
+    if (session.activity.role == ActivityClientRole::publicTarget) {
+        // The public target owns its own epoch and roster. It must not advertise another target,
+        // and the citizen advertisement inside the body is already gated on `privateCurrent`.
+        //
+        // It still owes one membership body. The client's msg 12 handler is the only writer of the
+        // flag that binds a world container to this ActivityClient, and until that bind lands the
+        // entity-slot grant sent at join has no view to reach:
+        // `RE/31 "A grant reaches a view only through a bound world container"`.
+        //
+        // Exactly one body per binding. The flag the client sets is one-way, it never acknowledges
+        // one on this link, and a link that joined a session it did not allocate never reports a
+        // region -- so neither an acknowledgement nor a revision gate can ever close.
+        state::activity::membership::PendingMutation staged{};
+        bool appended = false;
+        const bool owesMembership =
+            core::settings::get().server.activation.activityPublicMembership
+            && session.activityMembershipSentGeneration != session.activity.bindingGeneration;
+        if (owesMembership) {
+            // The body is the private link's member table, sent verbatim on this envelope.
+            //
+            // This link can author nothing of its own. Its join names the host rather than itself,
+            // so its member key is the host's id, and the client never sends its identity here so
+            // its own record stays empty. The client matches itself by the key at `client+27696`,
+            // which is its machine id and is the same on both links, so only the private snapshot
+            // carries a member the client recognises as the local player. A body carrying anything
+            // else makes the client prune the member and destroy the player it holds.
+            //
+            // Read, never committed. `prepare_refresh` captures without changing State, and this
+            // link must not move the private session's membership revision.
+            const std::uint64_t privateSessionId =
+                state::activity::membership::live_region_session(state::activity::kAbsentSessionId);
+            // Zero until the client publishes its identity. Before that the private table still
+            // holds the seed's placeholder, which is not what the client matches on either.
+            const bool identityPublished =
+                privateSessionId != state::activity::kAbsentSessionId
+                && state::activity::membership::join_identity(privateSessionId) != 0;
+            const bool hasSnapshot = identityPublished
+                                     && state::activity::membership::prepare_refresh(
+                                         privateSessionId, kCurrentRevision, kNoBubble, staged)
+                                     && staged.hasSnapshot;
+            if (hasSnapshot) {
+                activity_message::ActivityPlan plan{};
+                plan.sessionId = session.activity.session.sessionId;
+                plan.membershipMutation = staged;
+                appended = append_membership_notification(
+                    scratch, session, plan, key, nextSendNonce, scratch.framed, framedSize);
+                published = appended || published;
+                SecureZeroMemory(&plan, sizeof plan);
+            }
+        }
+        published = append_roster_notification(
+                        session, scratch, key, nextSendNonce, scratch.framed, framedSize, false)
+                    || published;
+        SecureZeroMemory(&staged, sizeof staged);
+        const bool delivered = publish_frame(
             session, scratch, response, written, framedSize, nextSendNonce, published);
+        if (delivered) {
+            // Latched here, not at encode. An encoded body the client never saw is not a send.
+            if (appended) {
+                session.activityMembershipSentGeneration = session.activity.bindingGeneration;
+            }
+            session.activityKeepaliveDueTick = now + kActivityKeepaliveIntervalMs;
+            session.activityRosterDueTick = now + kRosterBurstIntervalMs;
+        }
+        return delivered;
     }
 
     // The client applies one membership update per revision and drops repeats, so an already
     // acknowledged region change needs a new revision to land. Move it only when there is a real
     // advertisement, or an empty channel advances the revision on every poll.
-    if (regionChanged
-        && server::gameplay::advertisement_state(reportedRegion)
-               == server::gameplay::AdvertisementState::ready
-        && state::activity::membership::acknowledged(session.activitySessionId)
-        && state::activity::membership::republish(session.activitySessionId)) {
-        core::log::write(core::log::Channel::server,
-                         core::log::Level::info,
-                         "ev=gameplay stage=membership result=republished reason=region");
-    }
     // Membership only becomes publishable after the client sends its identity, so it joins the
     // keepalive instead of the join reply.
     state::activity::membership::PendingMutation refresh{};
-    bool hasMembership = state::activity::membership::prepare_refresh(
-                             session.activitySessionId, kCurrentRevision, kNoBubble, refresh)
-                         && refresh.hasSnapshot;
-    if (!hasMembership
-        && seed_identity(
-            session.activitySessionId, session.activityMemberKey, session.activityCharacterSoid)) {
-        SecureZeroMemory(&refresh, sizeof refresh);
-        hasMembership = state::activity::membership::prepare_refresh(
-                            session.activitySessionId, kCurrentRevision, kNoBubble, refresh)
-                        && refresh.hasSnapshot;
+    state::activity::membership::PendingMutation stagedMembership{};
+    const bool needsRepublish =
+        regionChanged
+        && server::gameplay::advertisement_state(session.activity.source, reportedRegion)
+               == server::gameplay::AdvertisementState::ready
+        && state::activity::membership::acknowledged(session.activity.session.sessionId);
+    bool preparedRepublish = needsRepublish
+                             && state::activity::membership::prepare_republish(
+                                 session.activity.session.sessionId, stagedMembership);
+    bool commitsMembership = preparedRepublish;
+    bool hasMembership = false;
+    if (preparedRepublish) {
+        refresh = stagedMembership;
+        hasMembership = true;
+    } else {
+        hasMembership =
+            state::activity::membership::prepare_refresh(
+                session.activity.session.sessionId, kCurrentRevision, kNoBubble, refresh)
+            && refresh.hasSnapshot;
     }
-    // A client value wins; this only fills the token when nothing has published one.
-    if (hasMembership && refresh.snapshot.transitionToken == 0
-        && seed_transition_token(session.activitySessionId)) {
-        SecureZeroMemory(&refresh, sizeof refresh);
-        hasMembership = state::activity::membership::prepare_refresh(
-                            session.activitySessionId, kCurrentRevision, kNoBubble, refresh)
-                        && refresh.hasSnapshot;
+    if (!hasMembership
+        && prepare_seed_identity(session.activity.session.sessionId,
+                                 session.activityMemberKey,
+                                 session.activityCharacterSoid,
+                                 stagedMembership)) {
+        refresh = stagedMembership;
+        hasMembership = stagedMembership.hasSnapshot;
+        commitsMembership = true;
     }
     // The citizen advertisement rides on this message. Without one more send per region the client
     // finds no ambassador in the next region, takes the role itself and matchmakes forever.
     // Re-sending a stable snapshot instead would make it rebuild every player snapshot.
     const bool publishesMembership =
         hasMembership
-        && (regionChanged || !state::activity::membership::acknowledged(session.activitySessionId));
+        && (regionChanged
+            || !state::activity::membership::acknowledged(session.activity.session.sessionId));
     // Resolved the way the body resolves it, not from the reported field. Before the first report
     // the arrival slice set stands in, and that first push carries a descriptor too.
     const server::gameplay::AdvertisementState advertisement =
-        publishesMembership ? server::gameplay::advertisement_state(
-                                  effective_region(session.activitySessionId).index)
-                            : server::gameplay::AdvertisementState::absent;
+        publishesMembership
+            ? server::gameplay::advertisement_state(session.activity.source,
+                                                    effective_region(session.activity.source).index)
+            : server::gameplay::AdvertisementState::absent;
+    bool appendedMembership = false;
     if (publishesMembership && advertisement == server::gameplay::AdvertisementState::pending) {
         // The client applies one membership update per revision, so a push made while the
         // advertisement is still being allocated spends that revision on a region record with no
@@ -177,10 +258,11 @@ bool consume_activity_keepalive(Session& session,
                          "ev=gameplay stage=membership result=held reason=no_host_session");
     } else if (publishesMembership) {
         activity_message::ActivityPlan plan{};
-        plan.sessionId = session.activitySessionId;
+        plan.sessionId = session.activity.session.sessionId;
         plan.membershipMutation = refresh;
         const bool sent = append_membership_notification(
-            scratch, plan, key, nextSendNonce, scratch.framed, framedSize);
+            scratch, session, plan, key, nextSendNonce, scratch.framed, framedSize);
+        appendedMembership = sent;
         // Only `pending` leaves the trigger armed, because only `pending` is transient. `absent`
         // means this channel advertises nothing, so re-arming there republishes on every poll.
         if (sent && reportedRegion >= 0) {
@@ -200,7 +282,6 @@ bool consume_activity_keepalive(Session& session,
     const std::uint32_t reportedRevision = refresh.snapshot.revision;
     SecureZeroMemory(&refresh, sizeof refresh);
     // The keepalive always carries the roster, in or out of a transition.
-    session.activityRosterDueTick = now + kRosterBurstIntervalMs;
     published = append_roster_notification(
                     session, scratch, key, nextSendNonce, scratch.framed, framedSize, false)
                 || published;
@@ -227,11 +308,32 @@ bool consume_activity_keepalive(Session& session,
                          core::log::Level::debug,
                          {line.data(), static_cast<std::size_t>(count)});
     }
+    const bool deferredMembership = commitsMembership && !appendedMembership;
+    if (framedSize > response.size()
+        || (commitsMembership && appendedMembership
+            && !state::activity::membership::commit(stagedMembership))) {
+        SecureZeroMemory(&stagedMembership, sizeof stagedMembership);
+        discard_staged_roster(session);
+        discard_staged_advertisement(session);
+        return false;
+    }
+    SecureZeroMemory(&stagedMembership, sizeof stagedMembership);
     const bool delivered =
         publish_frame(session, scratch, response, written, framedSize, nextSendNonce, published);
     // A body the client never saw must advertise its region again on the next poll.
     if (delivered && stagedAdvertisedRegion >= 0) {
-        session.activityAdvertisedRegion = stagedAdvertisedRegion;
+        session.activity.advertisedRegion = stagedAdvertisedRegion;
+    }
+    if (delivered) {
+        // A held membership body is owed again soon, but not on every pump.
+        session.activityKeepaliveDueTick =
+            now + (deferredMembership ? kMembershipRetryIntervalMs : kActivityKeepaliveIntervalMs);
+        session.activityRosterDueTick = now + kRosterBurstIntervalMs;
+        if (preparedRepublish && appendedMembership) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::info,
+                             "ev=gameplay stage=membership result=republished reason=region");
+        }
     }
     return delivered;
 }

+ 7 - 33
Sunrise/src/server/bap/encrypted/push/activity/activity_keepalive_seed.cpp

@@ -1,7 +1,4 @@
-#include <Windows.h>
-
 #include "../../../../../state/account/account_state.h"
-#include "../../../../../state/activity/defaults/activity_defaults_snapshot.h"
 #include "../../../../../state/activity/membership/activity_membership_query.h"
 #include "../../../../../state/runtime/runtime.h"
 #include "internal.h"
@@ -19,15 +16,14 @@ constexpr std::int32_t kMemberSkipTest = -1;
  * which is logical -1. Seeding zero instead cost the ship and the banner.
  */
 constexpr std::int32_t kUnsetOpaque = -1;
-/** Default transition token. A client value from message 22 replaces it. */
-constexpr std::uint8_t kDefaultTransitionToken = 1;
-
 } // namespace
 
-/** Seeds the membership identity from the join when no identity message has arrived. */
-bool seed_identity(std::uint64_t sessionId,
-                   std::uint64_t memberKey,
-                   std::uint64_t characterSoid) noexcept {
+/** Prepares the fallback membership identity without changing stored State. */
+bool prepare_seed_identity(std::uint64_t sessionId,
+                           std::uint64_t memberKey,
+                           std::uint64_t characterSoid,
+                           state::activity::membership::PendingMutation& mutation) noexcept {
+    mutation = {};
     if (memberKey == 0) {
         return false;
     }
@@ -42,29 +38,7 @@ bool seed_identity(std::uint64_t sessionId,
     // signed in on. The selected character is only the fallback for a join that named none.
     identity.opaqueSoid =
         characterSoid != 0 ? characterSoid : state::account::selected_character_soid(account);
-    state::activity::membership::PendingMutation mutation{};
-    const bool seeded = state::activity::membership::prepare_identity(sessionId, identity, mutation)
-                        && state::activity::membership::commit(mutation);
-    SecureZeroMemory(&mutation, sizeof mutation);
-    return seeded;
-}
-
-/** Seeds the transition token when nothing has published one. */
-bool seed_transition_token(std::uint64_t sessionId) noexcept {
-    state::activity::defaults::ActivityDefaults defaults{};
-    state::activity::defaults::snapshot(defaults);
-    state::activity::membership::AuthoritativeUpdate update{};
-    // No teleport is published here. It goes out only when the client's own authoritative-data
-    // message supplies one, because a fabricated teleport is a transition the client never asked
-    // for. The token fills every member lane of all 64 region records. Zero is never sent.
-    update.hasTransitionToken = true;
-    update.transitionToken = kDefaultTransitionToken;
-    state::activity::membership::PendingMutation mutation{};
-    const bool seeded =
-        state::activity::membership::prepare_authoritative(sessionId, update, mutation)
-        && state::activity::membership::commit(mutation);
-    SecureZeroMemory(&mutation, sizeof mutation);
-    return seeded;
+    return state::activity::membership::prepare_identity(sessionId, identity, mutation);
 }
 
 } // namespace sunrise::server::bap::encrypted::push::activity

+ 27 - 11
Sunrise/src/server/bap/encrypted/push/activity/activity_membership_push.cpp

@@ -5,6 +5,7 @@
 #include "../../../../../middleware/bap/activity_message/replicate_membership.h"
 #include "../../../../../middleware/secure_channel/runtime.h"
 #include "../../../../gameplay/gameplay_advertisement.h"
+#include "../../bap_connection_publication.h"
 #include "activity_arrival.h"
 #include "activity_notification_frame.h"
 
@@ -18,13 +19,16 @@ constexpr std::uint8_t kLocalMemberSlot = 0;
 
 /**
  * Maps a lock-consistent State snapshot into the fixed Middleware schema.
- * @param sessionId Joined activity session, used to resolve the advertised region.
+ * @param session Exact ActivityClient owner of the membership body.
  * @param mutation Prepared membership operation, whose region this body publishes.
+ * @param hostGeneration Receives the retained advertised host row, or zero.
  * @return Whole current membership encoder input.
  */
 [[nodiscard]] message::MembershipSnapshot
-make_wire_snapshot(std::uint64_t sessionId,
-                   const state::activity::membership::PendingMutation& mutation) noexcept {
+make_wire_snapshot(const Session& session,
+                   const state::activity::membership::PendingMutation& mutation,
+                   std::uint64_t& hostGeneration) noexcept {
+    hostGeneration = 0;
     const state::activity::membership::Snapshot& snapshot = mutation.snapshot;
     message::MembershipSnapshot wire{};
     wire.identity.memberKey = snapshot.identity.memberKey;
@@ -46,12 +50,18 @@ make_wire_snapshot(std::uint64_t sessionId,
     wire.transitionToken = snapshot.transitionToken;
     // The region this body is about to commit, not the one State still holds. Staging runs before
     // the commit, so the region just left would leave the pending record empty for good.
-    const EffectiveRegion region = planned_region(mutation, sessionId);
-    server::gameplay::build_advertisement(region.index,
-                                          region.reported ? server::gameplay::RegionSource::reported
-                                                          : server::gameplay::RegionSource::arrival,
-                                          kLocalMemberSlot,
-                                          wire.citizen);
+    if (session.activity.role == ActivityClientRole::privateCurrent
+        && state::activity::binding_matches(session.activity.source)) {
+        const EffectiveRegion region = planned_region(mutation, session.activity.source);
+        server::gameplay::build_advertisement(session.activity.source,
+                                              region.index,
+                                              region.reported
+                                                  ? server::gameplay::RegionSource::reported
+                                                  : server::gameplay::RegionSource::arrival,
+                                              kLocalMemberSlot,
+                                              wire.citizen,
+                                              hostGeneration);
+    }
     return wire;
 }
 
@@ -59,6 +69,7 @@ make_wire_snapshot(std::uint64_t sessionId,
 
 /** Appends one current membership svc9 notification and advances its local nonce. */
 bool append_membership_notification(Scratch& scratch,
+                                    Session& session,
                                     const activity_message::ActivityPlan& activity,
                                     std::span<const std::byte, state::kAesKeySize> key,
                                     std::array<std::byte, state::kBapNonceSize>& nonce,
@@ -71,12 +82,13 @@ bool append_membership_notification(Scratch& scratch,
     const std::size_t initialWritten = written;
     auto initialNonce = nonce;
     std::size_t messageSize = 0;
+    std::uint64_t hostGeneration = 0;
     const message::MembershipSnapshot snapshot =
-        make_wire_snapshot(activity.sessionId, activity.membershipMutation);
+        make_wire_snapshot(session, activity.membershipMutation, hostGeneration);
     const bool encoded =
         message::encode_replicate_membership(snapshot, scratch.responseBody, messageSize)
         && append_notification_frame(scratch,
-                                     activity.sessionId,
+                                     session.activity.session.sessionId,
                                      message::kMessageType,
                                      std::span(scratch.responseBody).first(messageSize),
                                      key,
@@ -85,8 +97,12 @@ bool append_membership_notification(Scratch& scratch,
                                      written);
     SecureZeroMemory(scratch.responseBody.data(), message::encoded_size(snapshot));
     if (encoded) {
+        stage_activity_advertisement(session, hostGeneration);
         middleware::secure_channel::advance_nonce(nonce);
     } else {
+        if (hostGeneration != 0) {
+            server::gameplay::group::release_host_session(hostGeneration);
+        }
         if (written > initialWritten) {
             SecureZeroMemory(response.data() + initialWritten, written - initialWritten);
         }

+ 1 - 0
Sunrise/src/server/bap/encrypted/push/activity/activity_membership_push.h

@@ -21,6 +21,7 @@ namespace sunrise::server::bap::encrypted::push::activity {
  */
 [[nodiscard]] bool
 append_membership_notification(Scratch& scratch,
+                               Session& session,
                                const activity_message::ActivityPlan& activity,
                                std::span<const std::byte, state::kAesKeySize> key,
                                std::array<std::byte, state::kBapNonceSize>& nonce,

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

@@ -80,7 +80,7 @@ bool append_join_notifications(Scratch& scratch,
     // the set with its empty host table.
     if (encoded) {
         encoded = append_global_state_notification(
-            scratch, activity.sessionId, key, nonce, response, written);
+            scratch, activity.targetBinding, key, nonce, response, written);
     }
     if (encoded) {
         encoded = append_notification_frame(scratch,

+ 18 - 11
Sunrise/src/server/bap/encrypted/push/activity/activity_roster_push.cpp

@@ -42,7 +42,8 @@ bool append_roster_notification(Session& session,
     std::array<char, kDestinationCapacity> destination{};
     std::size_t destinationLength = 0;
     RosterOutcome outcome = RosterOutcome::noEpoch;
-    if (session.activityPatchEpochSeen) {
+    if (session.activityPatchEpoch.seen
+        && session.activityPatchEpoch.bindingGeneration == session.activity.bindingGeneration) {
         outcome = build_roster_snapshot(
             session, scratch, snapshot, destination, destinationLength, burst);
     }
@@ -60,8 +61,9 @@ bool append_roster_notification(Session& session,
     // the bubble the player actually entered without authority.
     // The wire field is unsigned. A value past the signed range turns negative and the selector
     // rejects it, the same answer as its own upper bound.
-    if (state::activity::bubble_authority::select_grant(
-            session.activitySessionId, static_cast<std::int32_t>(snapshot.region), grant)) {
+    if (state::activity::bubble_authority::select_grant(session.activity.session.sessionId,
+                                                        static_cast<std::int32_t>(snapshot.region),
+                                                        grant)) {
         snapshot.hasGrant = true;
         snapshot.grant.bubble = grant.bubble;
         snapshot.grant.token = grant.token;
@@ -72,7 +74,7 @@ bool append_roster_notification(Session& session,
     std::size_t messageSize = 0;
     bool encoded = message::encode_sensor_auth_update(snapshot, scratch.responseBody, messageSize)
                    && append_notification_frame(scratch,
-                                                session.activitySessionId,
+                                                session.activity.session.sessionId,
                                                 message::kMessageType,
                                                 std::span(scratch.responseBody).first(messageSize),
                                                 key,
@@ -84,12 +86,13 @@ bool append_roster_notification(Session& session,
         // Staged, not published. The grant and the counters are one-way and this body may still be
         // discarded, so they are held here and settled by `commit_staged_roster` or
         // `discard_staged_roster`.
-        session.activityRosterStaged = {grant,
-                                        initialRosterGroups,
-                                        initialRosterSends,
-                                        initialRosterState,
-                                        snapshot.hasGrant,
-                                        true};
+        session.activityRosterStaged.grant = grant;
+        session.activityRosterStaged.bindingGeneration = session.activity.bindingGeneration;
+        session.activityRosterStaged.priorGroups = initialRosterGroups;
+        session.activityRosterStaged.priorSends = initialRosterSends;
+        session.activityRosterStaged.priorState = initialRosterState;
+        session.activityRosterStaged.hasGrant = snapshot.hasGrant;
+        session.activityRosterStaged.staged = true;
     }
     report_roster_push(session,
                        snapshot,
@@ -117,8 +120,12 @@ void commit_staged_roster(Session& session) noexcept {
     if (!session.activityRosterStaged.staged) {
         return;
     }
+    if (session.activityRosterStaged.bindingGeneration != session.activity.bindingGeneration) {
+        session.activityRosterStaged = {};
+        return;
+    }
     if (session.activityRosterStaged.hasGrant) {
-        state::activity::bubble_authority::record_grant(session.activitySessionId,
+        state::activity::bubble_authority::record_grant(session.activity.session.sessionId,
                                                         session.activityRosterStaged.grant);
     }
     session.activityRosterStaged = {};

+ 15 - 5
Sunrise/src/server/bap/encrypted/push/activity/activity_roster_report.cpp

@@ -31,19 +31,29 @@ void report_roster_push(Session& session,
     for (std::size_t index = 0; index < roster.groupCount; ++index) {
         slots += roster.groups[index].slotTypes.size();
     }
+    // The per-bubble half is reported on its own. A body carrying it and one that does not are
+    // otherwise the same line, and the group count alone cannot tell them apart.
+    std::size_t bubbleKeys = 0;
+    for (const message::BubbleSubBlock& block : roster.bubbleSubBlocks) {
+        bubbleKeys += block.keys.size();
+    }
     std::array<char, core::log::kLineCapacity> line{};
     const int written =
         std::snprintf(line.data(),
                       line.size(),
-                      "ev=activity stage=roster result=%s soid=0x%llX foreign=%u dest=%.*s "
-                      "groups=%zu objects=%zu bytes=%zu state=%u keygroup=0x%X grant=%d "
-                      "region=%u slice=%u spawn=0x%X join=0x%llX player=0x%llX",
+                      "ev=activity stage=roster result=%s soid=0x%llX public=%u dest=%.*s "
+                      "groups=%zu top=%zu sub=%zu subkeys=%zu objects=%zu bytes=%zu state=%u "
+                      "keygroup=0x%X grant=%d region=%u slice=%u spawn=0x%X join=0x%llX "
+                      "player=0x%llX",
                       kOutcomeNames[static_cast<std::size_t>(outcome)],
-                      static_cast<unsigned long long>(session.activitySessionId),
-                      session.activityJoinedForeignSession ? 1U : 0U,
+                      static_cast<unsigned long long>(session.activity.session.sessionId),
+                      session.activity.role == ActivityClientRole::publicTarget ? 1U : 0U,
                       static_cast<int>(destination.size()),
                       destination.data(),
                       roster.groupCount,
+                      roster.topLevelGroupCount,
+                      roster.bubbleSubBlocks.size(),
+                      bubbleKeys,
                       slots,
                       bytes,
                       session.activityRosterState,

+ 114 - 39
Sunrise/src/server/bap/encrypted/push/activity/activity_roster_snapshot.cpp

@@ -5,9 +5,9 @@
 
 #include "../../../../../state/account/account_state.h"
 #include "../../../../../state/activity/defaults/activity_defaults_snapshot.h"
-#include "../../../../../state/activity/destination/activity_destination_snapshot.h"
 #include "../../../../../state/activity/destination/activity_destination_spawn_binding.h"
 #include "../../../../../state/activity/membership/activity_membership_query.h"
+#include "../../../../../state/activity/runtime.h"
 #include "../../../../../state/build_data/runtime.h"
 #include "../../../../../state/runtime/runtime.h"
 #include "activity_arrival.h"
@@ -58,6 +58,65 @@ constexpr std::uint64_t kIdentityLowMask = 0xFFFFFFFFULL;
     return selected;
 }
 
+/**
+ * Copies one roster group into the encoder's fixed input.
+ * @param tableIndex Roster table index the destination row names.
+ * @param scratch Lock-owned roster group storage the spans point into.
+ * @param slot Storage and input slot to fill, which are the same ordinal.
+ * @param roster Receives the group.
+ * @return True when the named group was found.
+ */
+[[nodiscard]] bool fill_group(std::uint16_t tableIndex,
+                              Scratch& scratch,
+                              std::size_t slot,
+                              message::Roster& roster) noexcept {
+    layouts::RosterGroup& group = scratch.rosterGroups[slot];
+    if (!state::build_data::find_roster_group(tableIndex, group)) {
+        return false;
+    }
+    roster.groups[slot].key = group.registryKey;
+    roster.groups[slot].slotTypes =
+        std::span<const std::uint8_t>(group.slotTypes.data(), group.slotCount);
+    roster.groups[slot].slotFlags =
+        std::span<const std::uint8_t>(group.slotFlags.data(), group.slotCount);
+    roster.groups[slot].slotIndices =
+        std::span<const std::uint16_t>(group.slotIndices.data(), group.slotCount);
+    return true;
+}
+
+/**
+ * Builds the per-bubble sub-blocks from the destination's per-bubble groups.
+ * The row holds one bubble mask per group. The wire wants the transpose: one sub-block per
+ * bubble, carrying every key that bubble registers.
+ * @param layout Destination row carrying the groups and their bubble masks.
+ * @param scratch Lock-owned sub-block storage the spans point into.
+ * @param roster Groups already filled, whose per-bubble half starts at the top-level count.
+ * @return The sub-blocks to publish, which is empty when the destination has no per-bubble group.
+ */
+[[nodiscard]] std::span<const message::BubbleSubBlock> fill_sub_blocks(
+    const layouts::Definition& layout, Scratch& scratch, const message::Roster& roster) noexcept {
+    std::size_t published = 0;
+    for (std::size_t bubble = 0; bubble < scratch.rosterSubBlocks.size(); ++bubble) {
+        std::size_t keyCount = 0;
+        for (std::size_t index = 0; index < layout.bubbleGroupCount; ++index) {
+            if ((layout.bubbleGroupMasks[index] & (std::uint64_t{1} << bubble)) == 0) {
+                continue;
+            }
+            scratch.rosterSubBlockKeys[published][keyCount] =
+                roster.groups[roster.topLevelGroupCount + index].key;
+            ++keyCount;
+        }
+        if (keyCount == 0) {
+            continue;
+        }
+        scratch.rosterSubBlocks[published].bubble = static_cast<std::uint32_t>(bubble);
+        scratch.rosterSubBlocks[published].keys =
+            std::span<const std::uint32_t>(scratch.rosterSubBlockKeys[published].data(), keyCount);
+        ++published;
+    }
+    return std::span(scratch.rosterSubBlocks).first(published);
+}
+
 /**
  * Copies the destination's published groups into the encoder's fixed input.
  * @param layout Destination row naming its groups by roster table index.
@@ -68,23 +127,32 @@ constexpr std::uint64_t kIdentityLowMask = 0xFFFFFFFFULL;
 [[nodiscard]] bool
 fill_roster(const layouts::Definition& layout, Scratch& scratch, message::Roster& roster) noexcept {
     roster = {};
-    if (layout.rosterGroupCount == 0 || layout.rosterGroupCount > scratch.rosterGroups.size()
-        || layout.rosterGroupCount > roster.groups.size()) {
+    const std::size_t groupCount =
+        std::size_t{layout.rosterGroupCount} + std::size_t{layout.bubbleGroupCount};
+    if (layout.rosterGroupCount == 0 || groupCount > scratch.rosterGroups.size()
+        || groupCount > roster.groups.size()) {
         return false;
     }
     for (std::size_t index = 0; index < layout.rosterGroupCount; ++index) {
-        layouts::RosterGroup& group = scratch.rosterGroups[index];
-        if (!state::build_data::find_roster_group(layout.rosterGroups[index], group)) {
+        if (!fill_group(layout.rosterGroups[index], scratch, index, roster)) {
             return false;
         }
-        roster.groups[index].key = group.registryKey;
-        roster.groups[index].slotTypes =
-            std::span<const std::uint8_t>(group.slotTypes.data(), group.slotCount);
-        roster.groups[index].slotFlags =
-            std::span<const std::uint8_t>(group.slotFlags.data(), group.slotCount);
-    }
-    roster.groupCount = layout.rosterGroupCount;
-    for (std::size_t index = 0; index < roster.groupCount && roster.playerKeyGroup == 0; ++index) {
+    }
+    // The per-bubble groups follow the top-level ones in the same array, because phase 2 seeds
+    // every group the body registers and the client holds its apply back until they are all in.
+    for (std::size_t index = 0; index < layout.bubbleGroupCount; ++index) {
+        if (!fill_group(
+                layout.bubbleGroups[index], scratch, layout.rosterGroupCount + index, roster)) {
+            return false;
+        }
+    }
+    roster.topLevelGroupCount = layout.rosterGroupCount;
+    roster.groupCount = groupCount;
+    roster.bubbleSubBlocks = fill_sub_blocks(layout, scratch, roster);
+    // Only a top-level group can bind the player: its object is in every slice set, so the gate
+    // reads it wherever the player is.
+    for (std::size_t index = 0; index < roster.topLevelGroupCount && roster.playerKeyGroup == 0;
+         ++index) {
         const layouts::RosterGroup& group = scratch.rosterGroups[index];
         for (std::size_t slot = 0; slot < group.slotCount; ++slot) {
             if (group.slotTypes[slot] == kSlotTypeParticipation) {
@@ -131,24 +199,23 @@ next_state_sequence(Session& session, std::uint32_t folded, bool burst) noexcept
 } // namespace
 
 /** Resolves the one region a session publishes. */
-EffectiveRegion effective_region(std::uint64_t sessionId) noexcept {
+EffectiveRegion effective_region(const state::activity::SessionBinding& binding) noexcept {
+    EffectiveRegion region{};
+    region.index = state::activity::membership::kAbsentRegionIndex;
+    if (!state::activity::binding_matches(binding)) {
+        return region;
+    }
     state::activity::defaults::ActivityDefaults defaults{};
-    state::activity::destination::DestinationSelection selection{};
     state::activity::defaults::snapshot(defaults);
-    // The session's own destination wins. The authored default covers a session that committed
-    // before any selection was readable.
-    if (!state::activity::destination::snapshot(sessionId, selection)) {
-        selection = defaults.defaultDestination.selection;
-    }
+    const state::activity::destination::DestinationSelection& selection = binding.destination;
     const std::string_view name(reinterpret_cast<const char*>(selection.packageName.data()),
                                 selection.packageNameLength);
     // A missing layout leaves a cleared definition, and the arrival rule then returns the
     // authored fallback index.
     layouts::Definition layout{};
     static_cast<void>(state::build_data::find_scenario_layout(name, layout));
-    EffectiveRegion region{};
     region.arrival = arrival_slice_set(defaults.defaultDestination, selection, name, layout);
-    const std::int32_t reported = state::activity::membership::reported_region(sessionId);
+    const std::int32_t reported = state::activity::membership::reported_region(binding.sessionId);
     region.reported = reported >= 0;
     region.index = region.reported ? reported : static_cast<std::int32_t>(region.arrival);
     return region;
@@ -156,8 +223,8 @@ EffectiveRegion effective_region(std::uint64_t sessionId) noexcept {
 
 /** Resolves the region one prepared membership body publishes. */
 EffectiveRegion planned_region(const state::activity::membership::PendingMutation& mutation,
-                               std::uint64_t sessionId) noexcept {
-    EffectiveRegion region = effective_region(sessionId);
+                               const state::activity::SessionBinding& binding) noexcept {
+    EffectiveRegion region = effective_region(binding);
     // The same rule the State merge uses, so the body and the record it will commit agree. A
     // negative index is the unset value the client sends on its way out, not a position.
     if (mutation.authoritativeInput.hasRegion
@@ -179,20 +246,15 @@ RosterOutcome build_roster_snapshot(Session& session,
     snapshot = {};
     destinationLength = 0;
     state::activity::defaults::ActivityDefaults defaults{};
-    state::activity::destination::DestinationSelection selection{};
     state::activity::defaults::snapshot(defaults);
-    // A joined link takes its region from the link that knows where the player is, so its bubble
-    // grant names the bubble the world is in. Its roster groups stay its own: the client-reference
-    // table holds one record per key, so two containers sharing a key orphan each other's rows.
-    const std::uint64_t regionFrom =
-        session.activityJoinedForeignSession
-            ? state::activity::membership::live_region_session(session.activitySessionId)
-            : session.activitySessionId;
-    // The session's own destination wins. The authored default covers a session that committed
-    // before any selection was readable.
-    if (!state::activity::destination::snapshot(session.activitySessionId, selection)) {
-        selection = defaults.defaultDestination.selection;
+    if (session.activity.role == ActivityClientRole::none
+        || !state::activity::binding_matches(session.activity.session)
+        || !state::activity::binding_matches(session.activity.source)) {
+        return RosterOutcome::noLayout;
     }
+    // Public targets keep the destination copied from their exact advertised source generation.
+    const state::activity::destination::DestinationSelection& selection =
+        session.activity.session.destination;
     layouts::Definition layout{};
     const std::string_view name(reinterpret_cast<const char*>(selection.packageName.data()),
                                 selection.packageNameLength);
@@ -209,8 +271,21 @@ RosterOutcome build_roster_snapshot(Session& session,
         defaults.defaultDestination.fallback;
     // One resolution serves this body and the citizen advertisement in message 12. Two would let
     // the join descriptor land in a region record the client is not pending on.
-    const EffectiveRegion region = effective_region(regionFrom);
-    snapshot.patchEpoch = session.activityPatchEpoch;
+    EffectiveRegion region{};
+    region.arrival = arrival_slice_set(defaults.defaultDestination, selection, name, layout);
+    if (session.activity.role == ActivityClientRole::publicTarget) {
+        region.index = session.activity.advertisedRegion;
+        region.reported = region.index >= 0;
+    } else {
+        const std::int32_t reported =
+            state::activity::membership::reported_region(session.activity.source.sessionId);
+        region.reported = reported >= 0;
+        region.index = region.reported ? reported : static_cast<std::int32_t>(region.arrival);
+    }
+    if (region.index < 0) {
+        return RosterOutcome::noLayout;
+    }
+    snapshot.patchEpoch = session.activityPatchEpoch.value;
     // The character the join named wins, resolved to its authored SOID. The client binds its
     // player by matching this value against the object registry, and the short form the join
     // carries matches nothing.
@@ -219,7 +294,7 @@ RosterOutcome build_roster_snapshot(Session& session,
     // sends the character SOID. That field is the membership identity, so this sends it instead.
     if (defaults.rosterKeyFromIdentity) {
         const std::uint64_t identity =
-            state::activity::membership::join_identity(session.activitySessionId);
+            state::activity::membership::join_identity(session.activity.session.sessionId);
         if (identity != 0) {
             snapshot.playerKey = identity;
         }

+ 9 - 12
Sunrise/src/server/bap/encrypted/push/activity/internal.h

@@ -6,6 +6,7 @@
 #include <string_view>
 
 #include "../../../../../middleware/bap/activity_message/sensor_auth_update.h"
+#include "../../../../../state/activity/membership/activity_membership_query.h"
 #include "../../../internal.h"
 
 namespace sunrise::server::bap::encrypted::push::activity {
@@ -13,22 +14,18 @@ namespace sunrise::server::bap::encrypted::push::activity {
 namespace message = middleware::bap::activity_message::sensor_auth_update;
 
 /**
- * Seeds the membership identity from the join when no identity message has arrived.
+ * Prepares the fallback membership identity when no identity message has arrived.
  * @param sessionId Joined activity session.
  * @param memberKey Client member key captured from the join request.
  * @param characterSoid Character the client signed in on.
- * @return True when a membership snapshot exists afterwards.
+ * @param mutation Cleared, then receives the exact deferred identity operation.
+ * @return True when a membership snapshot can be staged.
  */
-[[nodiscard]] bool seed_identity(std::uint64_t sessionId,
-                                 std::uint64_t memberKey,
-                                 std::uint64_t characterSoid) noexcept;
-
-/**
- * Seeds the transition token when nothing has published one.
- * @param sessionId Joined activity session.
- * @return True when the token is published.
- */
-[[nodiscard]] bool seed_transition_token(std::uint64_t sessionId) noexcept;
+[[nodiscard]] bool
+prepare_seed_identity(std::uint64_t sessionId,
+                      std::uint64_t memberKey,
+                      std::uint64_t characterSoid,
+                      state::activity::membership::PendingMutation& mutation) noexcept;
 
 /** Why one roster push produced nothing, or that it produced a body. */
 enum class RosterOutcome : std::uint8_t {

+ 5 - 3
Sunrise/src/server/bap/encrypted/transactions/definition.h

@@ -2,14 +2,16 @@
 
 #include <cstdint>
 
+#include "../../internal.h"
+
 namespace sunrise::server::bap::encrypted::transactions {
 
 /** Connection fields published only after State commits and caller output is copied. */
 struct Publication {
-    std::uint64_t activitySessionId{};
+    ActivityClientBinding activity{};
     bool hasActivitySessionBinding{};
-    /** True when the binding came from a join rather than from this link's own allocation. */
-    bool activitySessionFromJoin{};
+    /** Rejoin keeps the exact retained private/public owner and only resets connection state. */
+    bool preservesActivitySessionBinding{};
 };
 
 } // namespace sunrise::server::bap::encrypted::transactions

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

@@ -1,13 +1,113 @@
-#include "service_outcome_commit.h"
+#include "service_outcome_commit.h"
+
+#include <array>
+#include <cstdio>
 
 #include "../../../../core/logging/log.h"
-#include "../../../../state/activity/bubble_authority/runtime.h"
 #include "../../../../state/activity/runtime.h"
 #include "../../../../state/matchmaking/matchmaking_state.h"
 #include "../../../../state/runtime/runtime.h"
+#include "../bap_connection_publication.h"
 #include "../internal.h"
 
 namespace sunrise::server::bap::encrypted::transactions {
+namespace {
+
+namespace slots = state::activity::entity_slots;
+
+/** Log names for each lease operation, in the enum's own order. */
+constexpr std::array<const char*, 4> kLeaseKinds = {"none", "join", "grant", "release"};
+
+/** Retains one newly committed private ActivityClient generation for its BAP link. */
+[[nodiscard]] bool retain_private(std::uint64_t sessionId, Publication& publication) noexcept {
+    state::activity::SessionBinding binding{};
+    if (!state::activity::snapshot_binding(sessionId, binding)
+        || !state::activity::retain_binding(binding)) {
+        return false;
+    }
+    publication.activity.session = binding;
+    publication.activity.source = binding;
+    publication.activity.role = ActivityClientRole::privateCurrent;
+    publication.hasActivitySessionBinding = true;
+    return true;
+}
+
+/** Retains one exact advertised public target before its join mutation commits. */
+[[nodiscard]] bool retain_public(const activity_message::ActivityPlan& plan,
+                                 Publication& publication) noexcept {
+    server::gameplay::group::HostSessionBinding current{};
+    if (!server::gameplay::group::host_session_for_activity(plan.sessionId, current)
+        || current.generation != plan.publicHost.generation
+        || current.groupSessionId != plan.publicHost.groupSessionId
+        || current.regionIndex != plan.publicHost.regionIndex
+        || current.source.sessionId != plan.publicHost.source.sessionId
+        || current.source.createdRevision != plan.publicHost.source.createdRevision
+        || current.target.sessionId != plan.publicHost.target.sessionId
+        || current.target.createdRevision != plan.publicHost.target.createdRevision
+        || !state::activity::binding_matches(current.source)
+        || !state::activity::binding_matches(current.target)
+        || !server::gameplay::group::retain_host_session(current.generation)) {
+        return false;
+    }
+    if (!state::activity::retain_binding(current.target)) {
+        server::gameplay::group::release_host_session(current.generation);
+        return false;
+    }
+    publication.activity.session = current.target;
+    publication.activity.source = current.source;
+    publication.activity.groupSessionId = current.groupSessionId;
+    publication.activity.hostGeneration = current.generation;
+    publication.activity.advertisedRegion = current.regionIndex;
+    publication.activity.role = ActivityClientRole::publicTarget;
+    publication.hasActivitySessionBinding = true;
+    return true;
+}
+
+/** Releases provisional activity owners when the following State commit fails. */
+void discard_activity_publication(Publication& publication) noexcept {
+    if (publication.activity.hostGeneration != 0) {
+        server::gameplay::group::release_host_session(publication.activity.hostGeneration);
+    }
+    if (publication.activity.session.sessionId != state::activity::kAbsentSessionId) {
+        state::activity::release_binding(publication.activity.session);
+    }
+    publication = {};
+}
+
+/**
+ * Reports one entity-slot lease change.
+ * The client prints only `failed to create` when it has no free index, and nothing else on this
+ * path reports the lease, so a failed create reads the same as an empty grant without this line.
+ * @param mutation Plan as it was before the commit consumed it.
+ * @param committed Whether the commit succeeded.
+ */
+void report_lease(const slots::PendingMutation& mutation, bool committed) noexcept {
+    std::size_t held = 0;
+    std::size_t reserved = 0;
+    const bool known = slots::lease_counts(mutation.sessionId, held, reserved);
+    const auto kind = static_cast<std::size_t>(mutation.kind);
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written =
+        std::snprintf(line.data(),
+                      line.size(),
+                      "ev=activity stage=entity_slots result=%s kind=%s soid=0x%llX "
+                      "requested=%zu picked=%zu held=%zu reserved=%zu known=%u",
+                      committed ? "ok" : "fail",
+                      kind < kLeaseKinds.size() ? kLeaseKinds[kind] : "bad",
+                      static_cast<unsigned long long>(mutation.sessionId),
+                      mutation.requestedCount,
+                      slots::slot_count(mutation.mask),
+                      held,
+                      reserved,
+                      known ? 1U : 0U);
+    if (written > 0) {
+        core::log::write(core::log::Channel::server,
+                         committed ? core::log::Level::debug : core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+} // namespace
 
 /**
  * Commits at most one delayed State transaction.
@@ -19,29 +119,55 @@ bool commit(ServiceOutcome& outcome, Publication& publication) noexcept {
     publication = {};
     if (auto* allocation = transaction_if<state::activity::PendingAllocation>(outcome)) {
         const std::uint64_t sessionId = allocation->sessionId;
+        std::uint64_t bindingGeneration = 0;
         if (sessionId == state::activity::kAbsentSessionId
+            || !reserve_activity_binding_generation(bindingGeneration)
             || !state::activity::commit(*allocation)) {
             return false;
         }
-        publication.activitySessionId = sessionId;
-        publication.hasActivitySessionBinding = true;
+        if (!retain_private(sessionId, publication)) {
+            static_cast<void>(state::activity::release_session(sessionId));
+            return false;
+        }
+        publication.activity.bindingGeneration = bindingGeneration;
         return true;
     }
     if (auto* plan = transaction_if<activity_message::ActivityPlan>(outcome)) {
         if (plan->mutationDomain == activity_message::MutationDomain::entitySlots) {
-            if (!state::activity::entity_slots::commit(plan->entitySlotMutation)) {
+            const bool joins = plan->delivery == activity_message::Delivery::joinNotifications;
+            const bool validJoinIntent =
+                plan->bindingIntent == activity_message::BindingIntent::preserveCurrent
+                || plan->bindingIntent == activity_message::BindingIntent::publicTarget;
+            if (joins && !validJoinIntent) {
+                return false;
+            }
+            std::uint64_t bindingGeneration = 0;
+            if (joins && !reserve_activity_binding_generation(bindingGeneration)) {
+                return false;
+            }
+            if (joins && plan->bindingIntent == activity_message::BindingIntent::publicTarget
+                && !retain_public(*plan, publication)) {
+                return false;
+            }
+            // The commit consumes the plan, so the counts are taken from a copy of it.
+            const slots::PendingMutation attempted = plan->entitySlotMutation;
+            const bool committed = slots::commit(plan->entitySlotMutation);
+            report_lease(attempted, committed);
+            if (!committed) {
+                discard_activity_publication(publication);
                 return false;
             }
             // The keepalive only finds a link that is bound to a session. A link that allocated
             // its own session carries the same id, so this rebinds it to itself.
-            if (plan->delivery == activity_message::Delivery::joinNotifications
-                && plan->sessionId != state::activity::kAbsentSessionId) {
-                publication.activitySessionId = plan->sessionId;
+            if (joins && plan->sessionId != state::activity::kAbsentSessionId) {
                 publication.hasActivitySessionBinding = true;
-                publication.activitySessionFromJoin = true;
-                // The join resets the client's roster container and the grant mirror with it.
-                // A kept grant leaves that container ungranted, which refuses its placed objects.
-                state::activity::bubble_authority::clear_grants(plan->sessionId);
+                if (plan->bindingIntent == activity_message::BindingIntent::preserveCurrent) {
+                    publication.preservesActivitySessionBinding = true;
+                } else if (plan->bindingIntent != activity_message::BindingIntent::publicTarget) {
+                    discard_activity_publication(publication);
+                    return false;
+                }
+                publication.activity.bindingGeneration = bindingGeneration;
             }
             return true;
         }

+ 67 - 19
Sunrise/src/server/bap/internal.h

@@ -7,8 +7,10 @@
 
 #include "../../client/network/consumer.h"
 #include "../../middleware/bap/activity_message/activity_patch_epoch_parser.h"
+#include "../../middleware/bap/activity_message/sensor_auth_update.h"
 #include "../../middleware/bap/frame.h"
 #include "../../state/activity/bubble_authority/definition.h"
+#include "../../state/activity/definition.h"
 #include "../../state/build_data/scenarios/definition.h"
 #include "../../state/runtime/state.h"
 #include "encrypted/queuez/definition.h"
@@ -17,6 +19,8 @@ namespace sunrise::server::bap {
 
 /** One session per transport peer slot, so a connection id indexes this array directly. */
 inline constexpr std::size_t kSessionCount = client::network::kBapConnectionCount;
+/** A delivered activity frame defers the next silence-prevention write by five seconds. */
+inline constexpr std::uint64_t kActivityKeepaliveIntervalMs = 5'000;
 
 /** Fixed scratch storage owned by the lock, kept off the Client thread's stack. */
 struct Scratch {
@@ -25,10 +29,19 @@ struct Scratch {
     std::array<std::byte, client::network::kBapFrameCapacity> responsePayload{};
     std::array<std::byte, client::network::kBapFrameCapacity> sealed{};
     std::array<std::byte, client::network::kBapFrameCapacity> framed{};
-    /** Roster groups the outbound body's slot spans point into. */
+    /** Roster groups the outbound body's slot spans point into, top-level and per-bubble alike. */
     std::array<state::build_data::scenarios::RosterGroup,
-               state::build_data::scenarios::kDestinationGroupCapacity>
+               middleware::bap::activity_message::sensor_auth_update::kGroupCapacity>
         rosterGroups{};
+    /** Per-bubble sub-blocks the outbound body's field-1 span points into. */
+    std::array<middleware::bap::activity_message::sensor_auth_update::BubbleSubBlock,
+               state::build_data::scenarios::kBubbleCapacity>
+        rosterSubBlocks{};
+    /** Keys each sub-block carries, which its own span points into. */
+    std::array<
+        std::array<std::uint32_t, state::build_data::scenarios::kDestinationBubbleGroupCapacity>,
+        state::build_data::scenarios::kBubbleCapacity>
+        rosterSubBlockKeys{};
 };
 
 /**
@@ -38,6 +51,8 @@ struct Scratch {
  */
 struct RosterPublication {
     state::activity::bubble_authority::Grant grant{};
+    /** ActivityClient generation that staged this grant and its roster counters. */
+    std::uint64_t bindingGeneration{};
     std::uint32_t priorGroups{};
     std::uint8_t priorSends{};
     std::uint8_t priorState{};
@@ -47,6 +62,41 @@ struct RosterPublication {
     bool staged{};
 };
 
+/** ActivityClient role owned by one authenticated BAP link. */
+enum class ActivityClientRole : std::uint8_t {
+    none,
+    privateCurrent,
+    publicTarget,
+};
+
+/** Exact activity-session generations owned by one BAP link. */
+struct ActivityClientBinding {
+    /** Target/current session that every activity envelope on this link names. */
+    state::activity::SessionBinding session{};
+    /** Same as session for private links; advertised source for public targets. */
+    state::activity::SessionBinding source{};
+    std::uint64_t groupSessionId{};
+    std::uint64_t hostGeneration{};
+    /** Changes on every bind and rejoin, even when the session id stays the same. */
+    std::uint64_t bindingGeneration{};
+    /** Private: last citizen region. Public: immutable region captured by the host binding. */
+    std::int32_t advertisedRegion{-1};
+    ActivityClientRole role{ActivityClientRole::none};
+};
+
+/** Patch epoch tied to the exact ActivityClient binding that received it. */
+struct BoundPatchEpoch {
+    middleware::bap::activity_message::patch_epoch::PatchEpoch value{};
+    std::uint64_t bindingGeneration{};
+    bool seen{};
+};
+
+/** Host-session retain staged by one membership body until its frame is published. */
+struct AdvertisementPublication {
+    std::uint64_t hostGeneration{};
+    bool staged{};
+};
+
 /** Mutable transport state owned by one BAP connection. */
 struct Session {
     std::uint32_t id{};
@@ -55,8 +105,8 @@ struct Session {
     std::array<std::byte, state::kBapNonceSize> receiveNonce{};
     /** Opaque State handle taken only after the server hello authenticates. */
     state::matchmaking::ContextHandle matchmakingContext{};
-    /** Activity capability allocated and published through this authenticated session. */
-    std::uint64_t activitySessionId{};
+    /** Exact private or public ActivityClient generation owned by this connection. */
+    ActivityClientBinding activity{};
     /** Tick count after which the activity link owes its next keepalive write. */
     std::uint64_t activityKeepaliveDueTick{};
     /** Client member key from the join request. It seeds the membership id. */
@@ -69,32 +119,30 @@ struct Session {
     std::uint64_t activityCharacterSoid{};
     /** Tick count after which the activity link owes its next roster update. */
     std::uint64_t activityRosterDueTick{};
+    /**
+     * Binding generation whose membership body this link has already delivered.
+     * The client sets its own membership flag once and never clears it, and never acknowledges a
+     * body on a public-target link, so a send condition has to be a one-shot per binding rather
+     * than a revision or acknowledgement gate. Latched on delivery, never on encode.
+     */
+    std::uint64_t activityMembershipSentGeneration{};
     /**
      * Tick count until which the client is loading, so the roster runs at its faster cadence.
      * A join and a transition-token change are the only two things that open it.
      */
     std::uint64_t activityTransitionUntilTick{};
-    /** The client's own patch epoch, from message 52. The roster body splices it verbatim. */
-    middleware::bap::activity_message::patch_epoch::PatchEpoch activityPatchEpoch{};
+    /** The client's own patch epoch, scoped to the binding that received message 52. */
+    BoundPatchEpoch activityPatchEpoch{};
     /** Group set the last roster update published, folded into one comparable value. */
     std::uint32_t activityRosterGroups{};
     /** Roster updates sent on this connection, capped once the warm-up bumps are spent. */
     std::uint8_t activityRosterSends{};
     /** Per-entry state byte the last roster update carried. */
     std::uint8_t activityRosterState{};
-    /** Set once message 52 has arrived, which is what makes a roster update sendable. */
-    bool activityPatchEpochSeen{};
-    /**
-     * Set when this link's first binding came from joining a session it did not allocate.
-     * Such a link carries the keepalive alone. A roster or membership push on it stalls the load.
-     */
-    bool activityJoinedForeignSession{};
-    /**
-     * Region the last delivered citizen advertisement named. -1 until one has gone out.
-     * It moves only on a frame that reached the client and carried a descriptor. Anything else
-     * leaves the region-change trigger armed for the next poll.
-     */
-    std::int32_t activityAdvertisedRegion{-1};
+    /** Host row retained by the last delivered citizen advertisement. */
+    std::uint64_t activityAdvertisementHostGeneration{};
+    /** Host row retained by a staged membership body until publication is known. */
+    AdvertisementPublication activityAdvertisementStaged{};
     /**
      * Reason code of the last logged roster outcome.
      * The push runs every second, so a refusal is logged only when the reason changes. One flag

+ 1 - 1
Sunrise/src/server/bap/plaintext.cpp

@@ -149,7 +149,7 @@ void arm_encryption(Session& session, const state::BapState& bap) noexcept {
     // pending ring, so one unanswered request jams that ring for the rest of the run.
     if (!encrypted::body::process(route,
                                   session.queuez,
-                                  session.activitySessionId,
+                                  session.activity,
                                   session.matchmakingContext,
                                   request.body,
                                   scratch.responseBody,

+ 20 - 35
Sunrise/src/server/gameplay/dtls/dtls_host.cpp

@@ -1,11 +1,9 @@
-#include "dtls_host.h"
+#include "dtls_host.h"
 
 #include <Windows.h>
 
-#include <algorithm>
 #include <array>
 #include <atomic>
-#include <string_view>
 #include <type_traits>
 
 #include "../../../middleware/crypto/ecc_p224.h"
@@ -13,6 +11,7 @@
 #include "../../../middleware/gameplay/dtls/association_keys.h"
 #include "../../../middleware/gameplay/dtls/dtls_messages.h"
 #include "../../../middleware/gameplay/dtls/record.h"
+#include "../../../middleware/gameplay/dtls/replay_high_water.h"
 #include "../endpoint/gameplay_endpoint.h"
 #include "../gameplay_log.h"
 #include "../peer/peer_transport.h"
@@ -45,6 +44,8 @@ struct Association {
     std::array<std::byte, wire::kInitAckSize> issued{};
     /** Keys and tag every record of this association uses. */
     middleware::gameplay::dtls::RecordContext record{};
+    /** Authenticated record sequences already admitted on this association. */
+    middleware::gameplay::dtls::ReplayHighWater receiveHighWater{};
     /** False until one received record names the digest that authenticates it. */
     bool authKnown{};
     /** Sequence the next sent record carries. */
@@ -72,26 +73,6 @@ std::atomic<unsigned> g_recordReported{0};
 
 std::array<Association, kAssociationCapacity> g_associations{};
 
-/**
- * Formats bytes as lowercase hex for one log line.
- * @param input Bytes to render.
- * @param output Receives the text and its terminator; it must hold two characters per byte.
- */
-void to_hex(std::span<const std::byte> input, std::span<char> output) noexcept {
-    /** Digits one nibble maps to. */
-    constexpr std::string_view kDigits{"0123456789abcdef"};
-    /** Bits in one nibble. */
-    constexpr unsigned kNibbleBits = 4;
-    /** Mask of one nibble. */
-    constexpr unsigned kNibbleMask = 0xF;
-    for (std::size_t index = 0; index < input.size(); ++index) {
-        const auto value = std::to_integer<unsigned>(input[index]);
-        output[index * 2] = kDigits[(value >> kNibbleBits) & kNibbleMask];
-        output[(index * 2) + 1] = kDigits[value & kNibbleMask];
-    }
-    output[input.size() * 2] = '\0';
-}
-
 /** @return True when both endpoints name the same address and port. */
 [[nodiscard]] bool same_endpoint(const state::gameplay::Endpoint& left,
                                  const state::gameplay::Endpoint& right) noexcept {
@@ -294,8 +275,10 @@ void on_cookie_echo(const state::gameplay::Endpoint& from,
         report(core::log::Level::warn, "ev=gameplay stage=dtls result=drop reason=key_agreement");
         return;
     }
-    if (!middleware::gameplay::dtls::derive(
-            agreement.sharedSecret, kJoinKey, association->record.keys)) {
+    const bool derived = middleware::gameplay::dtls::derive(
+        agreement.sharedSecret, kJoinKey, association->record.keys);
+    SecureZeroMemory(agreement.sharedSecret.data(), agreement.sharedSecret.size());
+    if (!derived) {
         report(core::log::Level::warn, "ev=gameplay stage=dtls result=drop reason=key_derivation");
         return;
     }
@@ -315,18 +298,10 @@ void on_cookie_echo(const state::gameplay::Endpoint& from,
         ++g_openClock;
         association->opened = g_openClock;
     }
-    // TODO: stop logging key material once the digest choice is settled. Secrets must not be
-    // written to a log.
-    std::array<char, (2 * middleware::crypto::ecc::kFieldSize) + 1> secretText{};
-    to_hex(agreement.sharedSecret, secretText);
-    std::array<char, (2 * middleware::gameplay::dtls::kCypherKeySize) + 1> cypherText{};
-    to_hex(association->record.keys.cypher, cypherText);
     report(core::log::Level::info,
-           "ev=gameplay stage=dtls result=%s step=cookie_ack peer_tag=0x%04X secret=%s cypher=%s",
+           "ev=gameplay stage=dtls result=%s step=cookie_ack peer_tag=0x%04X",
            sent ? "ok" : "send_failed",
-           static_cast<unsigned>(association->requesterTag),
-           secretText.data(),
-           cypherText.data());
+           static_cast<unsigned>(association->requesterTag));
 }
 
 /**
@@ -375,6 +350,16 @@ void on_record(const state::gameplay::Endpoint& from,
         }
         return;
     }
+    const wire::ReplayDecision replay = wire::update(association->receiveHighWater, sequence);
+    if (replay != wire::ReplayDecision::accepted) {
+        if (g_recordReported.fetch_add(1, std::memory_order_relaxed) < kMaxRecordReports) {
+            report(core::log::Level::warn,
+                   "ev=gameplay stage=dtls result=drop reason=%s seq=%u",
+                   replay == wire::ReplayDecision::duplicate ? "replay_duplicate" : "replay_old",
+                   sequence);
+        }
+        return;
+    }
     association->touched = now;
     ++g_openClock;
     association->heard = g_openClock;

+ 75 - 71
Sunrise/src/server/gameplay/gameplay_advertisement.cpp

@@ -5,10 +5,9 @@
 #include <cstdint>
 
 #include "../../middleware/gameplay/descriptor/join_descriptor.h"
-#include "../../state/activity/definition.h"
 #include "endpoint/gameplay_endpoint.h"
 #include "gameplay_log.h"
-#include "group/group_host.h"
+#include "group/group_host_sessions.h"
 
 namespace sunrise::server::gameplay {
 
@@ -23,28 +22,14 @@ constexpr std::uint8_t kQueriedMemberSlot = 0;
 /** Odd multiplier that spreads one region index across the whole 64-bit space. */
 constexpr std::uint64_t kRegionStride = 0x9E3779B97F4A7C15ULL;
 
-/**
- * Derives one region's copy of an identity field.
- * It is derived rather than allocated so every push for a region names the same value with no
- * table.
- * @param base Whole-process identity field.
- * @param regionIndex Region the record belongs to.
- * @return Nonzero value for that region.
- */
+/** @return A stable nonzero region-specific copy of one process identity field. */
 [[nodiscard]] std::uint64_t region_identity(std::uint64_t base, std::int32_t regionIndex) noexcept {
     const auto region = static_cast<std::uint64_t>(static_cast<std::uint32_t>(regionIndex));
     const std::uint64_t derived = base ^ (kRegionStride * (region + 1U));
-    // The descriptor refuses a zero machine id and a zero session id alike.
     return derived == 0 ? kRegionStride : derived;
 }
 
-/**
- * Derives the descriptor machine id one region advertises.
- * It is also the key of that region's activity host session, so a readiness query has to derive it
- * the same way the advertisement does.
- * @param regionIndex Region the record belongs to.
- * @return The region's machine id.
- */
+/** @return Group-session key carried by one region's descriptor. */
 [[nodiscard]] std::uint64_t region_machine_id(std::int32_t regionIndex) noexcept {
     return region_identity(endpoint::identity().machineId, regionIndex);
 }
@@ -58,15 +43,17 @@ constexpr unsigned kReasonShift = 40;
 /** Region source sits above the skip reason. */
 constexpr unsigned kSourceShift = 48;
 
-/** Why an advertisement was not built. Reported so a silent skip cannot look like a send. */
+/** Why an advertisement was not built. */
 enum class Skip : std::uint64_t {
     none,
     notReady,
     noRegion,
     slotRange,
+    noSource,
     descriptor,
     noHostSession,
-    hostSessionFull
+    hostSessionConflict,
+    hostSessionFull,
 };
 
 std::atomic<std::uint64_t> g_reported{kNoOutcome};
@@ -74,14 +61,7 @@ std::atomic<std::uint64_t> g_reported{kNoOutcome};
 /** Log names for RegionSource, in its declaration order. */
 constexpr const char* kSources[] = {"reported", "arrival"};
 
-/**
- * Reports one advertisement outcome, and only when it differs from the last.
- * A membership push runs every few seconds, so an unchanged outcome must stay silent.
- * @param skip Check that refused the build, or Skip::none.
- * @param regionIndex Region the outcome belongs to.
- * @param regionSource Where that index came from.
- * @param ambassadorSlot Slot the advertisement named, or zero when it did not build.
- */
+/** Reports one changed advertisement outcome. */
 void report_outcome(Skip skip,
                     std::int32_t regionIndex,
                     RegionSource regionSource,
@@ -103,13 +83,15 @@ void report_outcome(Skip skip,
                static_cast<unsigned>(ambassadorSlot));
         return;
     }
-    // Log names for Skip, in its declaration order.
+    /** Stable log labels follow the Skip enumeration's ordinal order. */
     static constexpr const char* kReasons[] = {"none",
                                                "not_ready",
                                                "no_region",
                                                "slot_range",
+                                               "no_source",
                                                "descriptor",
                                                "no_host_session",
+                                               "host_session_conflict",
                                                "host_session_full"};
     report(core::log::Level::info,
            "ev=gameplay stage=advertise result=skip reason=%s region=%d region_source=%s",
@@ -118,30 +100,19 @@ void report_outcome(Skip skip,
            source);
 }
 
-/**
- * Picks an ambassador slot that is not the joining client's own.
- * An equal slot sends the peer into the local-ambassador stage, and it never reaches the citizen
- * join.
- * @param localMemberSlot Slot the joining client occupies.
- * @return A different, encodable member slot.
- */
+/** @return Encodable ambassador slot that differs from the joining client's slot. */
 [[nodiscard]] std::uint8_t ambassador_slot(std::uint8_t localMemberSlot) noexcept {
     return localMemberSlot == 0 ? 1 : 0;
 }
 
-/**
- * Builds one region's advertisement, or names the first check that refused it.
- * Every caller runs this same body, so a readiness query and the advertisement itself can never
- * disagree about whether a descriptor is coming.
- * @param regionIndex Region the record belongs to.
- * @param localMemberSlot Member slot of the joining client.
- * @param candidate Cleared, then filled when the whole advertisement builds.
- * @return Skip::none when it built, otherwise the check that refused.
- */
-[[nodiscard]] Skip build_candidate(std::int32_t regionIndex,
+/** Builds one source-bound candidate and retains its host generation on success. */
+[[nodiscard]] Skip build_candidate(const state::activity::SessionBinding& source,
+                                   std::int32_t regionIndex,
                                    std::uint8_t localMemberSlot,
-                                   message::CitizenAdvertisement& candidate) noexcept {
+                                   message::CitizenAdvertisement& candidate,
+                                   std::uint64_t& hostGeneration) noexcept {
     candidate = {};
+    hostGeneration = 0;
     if (!endpoint::ready()) {
         return Skip::notReady;
     }
@@ -151,54 +122,71 @@ void report_outcome(Skip skip,
     if (localMemberSlot > kMaximumMemberSlot) {
         return Skip::slotRange;
     }
+
     const endpoint::Identity identity = endpoint::identity();
     const state::gameplay::Endpoint advertised = endpoint::advertised();
+    group::HostSessionBinding host{};
+    switch (
+        group::request_host_session(region_machine_id(regionIndex), source, regionIndex, host)) {
+    case group::HostSessionState::ready:
+        break;
+    case group::HostSessionState::pending:
+        return Skip::noHostSession;
+    case group::HostSessionState::conflict:
+        return Skip::hostSessionConflict;
+    case group::HostSessionState::full:
+        return Skip::hostSessionFull;
+    case group::HostSessionState::absent:
+    default:
+        return Skip::noSource;
+    }
+    if (!group::retain_host_session(host.generation)) {
+        return Skip::noHostSession;
+    }
+
     middleware::gameplay::descriptor::JoinEndpoint join{};
     join.address = advertised.address;
     join.port = advertised.port;
-    // The client keys its managed sessions by the descriptor's machine id, not by its session id.
-    // Two regions may never share one.
-    join.machineId = region_machine_id(regionIndex);
+    join.machineId = host.groupSessionId;
     join.onlineSessionId = region_identity(identity.onlineSessionId, regionIndex);
-    // The region compares this against the `activity-host` parameter and errors out with
-    // `public_activity_host_mismatch` when they disagree, so both carry the same allocated id.
-    // The key is the machine id, because that is what the peer echoes in every later message.
-    bool claimedSlot = false;
-    const std::uint64_t hostSession =
-        group::activity_host_session(join.machineId, regionIndex, claimedSlot);
-    if (hostSession == state::activity::kAbsentSessionId) {
-        // A claimed slot is filled by the next service slice. An unclaimed one never will be, so
-        // the two must not read the same to a caller deciding whether to wait.
-        return claimedSlot ? Skip::noHostSession : Skip::hostSessionFull;
-    }
     if (!middleware::gameplay::descriptor::build(join, candidate.descriptor)) {
+        group::release_host_session(host.generation);
         candidate = {};
         return Skip::descriptor;
     }
-    candidate.onlineSessionId = hostSession;
+
+    candidate.onlineSessionId = host.target.sessionId;
     candidate.regionIndex = regionIndex;
     candidate.ambassadorSlot = ambassador_slot(localMemberSlot);
     candidate.present = true;
+    hostGeneration = host.generation;
     return Skip::none;
 }
 
 } // namespace
 
-/** Builds the citizen advertisement for one region record. */
-void build_advertisement(std::int32_t regionIndex,
+/** Builds a citizen advertisement from one exact source activity generation. */
+void build_advertisement(const state::activity::SessionBinding& source,
+                         std::int32_t regionIndex,
                          RegionSource regionSource,
                          std::uint8_t localMemberSlot,
-                         message::CitizenAdvertisement& output) noexcept {
-    const Skip skip = build_candidate(regionIndex, localMemberSlot, output);
+                         message::CitizenAdvertisement& output,
+                         std::uint64_t& hostGeneration) noexcept {
+    const Skip skip = build_candidate(source, regionIndex, localMemberSlot, output, hostGeneration);
     report_outcome(skip, regionIndex, regionSource, output.ambassadorSlot);
 }
 
-/** Reports whether one region's advertisement can be built now. */
-AdvertisementState advertisement_state(std::int32_t regionIndex) noexcept {
+/** Claims a missing host row and reports whether its advertisement is ready. */
+AdvertisementState advertisement_state(const state::activity::SessionBinding& source,
+                                       std::int32_t regionIndex) noexcept {
     message::CitizenAdvertisement candidate{};
-    // The same body the advertisement runs, so it cannot promise a descriptor the build refuses.
-    // It also claims the region's host-session slot, so the service slice allocates one.
-    switch (build_candidate(regionIndex, kQueriedMemberSlot, candidate)) {
+    std::uint64_t generation = 0;
+    const Skip skip =
+        build_candidate(source, regionIndex, kQueriedMemberSlot, candidate, generation);
+    if (generation != 0) {
+        group::release_host_session(generation);
+    }
+    switch (skip) {
     case Skip::none:
         return AdvertisementState::ready;
     case Skip::noHostSession:
@@ -208,4 +196,20 @@ AdvertisementState advertisement_state(std::int32_t regionIndex) noexcept {
     }
 }
 
+/** Source-less publishers remain wire-silent and do not claim a host row. */
+void build_advertisement(std::int32_t regionIndex,
+                         RegionSource regionSource,
+                         std::uint8_t localMemberSlot,
+                         message::CitizenAdvertisement& output) noexcept {
+    static_cast<void>(localMemberSlot);
+    output = {};
+    report_outcome(Skip::noSource, regionIndex, regionSource, 0);
+}
+
+/** Source-less readiness queries remain absent and do not claim a host row. */
+AdvertisementState advertisement_state(std::int32_t regionIndex) noexcept {
+    static_cast<void>(regionIndex);
+    return AdvertisementState::absent;
+}
+
 } // namespace sunrise::server::gameplay

+ 36 - 20
Sunrise/src/server/gameplay/gameplay_advertisement.h

@@ -3,6 +3,7 @@
 #include <cstdint>
 
 #include "../../middleware/bap/activity_message/replicate_membership.h"
+#include "../../state/activity/definition.h"
 
 namespace sunrise::server::gameplay {
 
@@ -14,37 +15,52 @@ enum class RegionSource : std::uint8_t {
     arrival,
 };
 
+/** Whether one exact source binding's advertisement can be built now. */
+enum class AdvertisementState : std::uint8_t {
+    /** It builds, so a push carries the descriptor and the host session. */
+    ready,
+    /** Only the source-bound target is missing, and the next service slice fills it. */
+    pending,
+    /** The channel advertises nothing at all. */
+    absent,
+};
+
 /**
- * Builds the citizen advertisement for one region record.
- * It stays empty unless the endpoint is bound and the region index names a real record, so a
- * disabled or unbound channel publishes exactly the membership body it published before.
- * @param regionIndex Region the roster publishes for this session.
+ * Builds a citizen advertisement from one exact source activity generation.
+ * A successful call retains the returned host generation until the publisher releases it with
+ * `group::release_host_session`.
+ * @param source Exact source record whose destination the target must copy.
+ * @param regionIndex Concrete region the roster publishes for this session.
  * @param regionSource Where that index came from.
  * @param localMemberSlot Member slot of the joining client.
- * @param output Cleared, then filled when the channel can be advertised.
+ * @param output Cleared, then filled when the whole channel can be advertised.
+ * @param hostGeneration Cleared, then receives the retained row generation on success.
  */
 void build_advertisement(
+    const state::activity::SessionBinding& source,
     std::int32_t regionIndex,
     RegionSource regionSource,
     std::uint8_t localMemberSlot,
-    middleware::bap::activity_message::replicate_membership::CitizenAdvertisement& output) noexcept;
+    middleware::bap::activity_message::replicate_membership::CitizenAdvertisement& output,
+    std::uint64_t& hostGeneration) noexcept;
 
-/** Whether one region's advertisement can be built now. */
-enum class AdvertisementState : std::uint8_t {
-    /** It builds, so a push carries the descriptor and the host session. */
-    ready,
-    /** Only the region's activity host session is missing, and the next service slice fills it. */
-    pending,
-    /** The channel advertises nothing at all, so the published body is unchanged either way. */
-    absent,
-};
+/** Claims a missing host row and reports whether its advertisement is ready. */
+[[nodiscard]] AdvertisementState advertisement_state(const state::activity::SessionBinding& source,
+                                                     std::int32_t regionIndex) noexcept;
+
+/**
+ * Source-less publisher. It clears output and claims no host row, so the channel stays silent.
+ * TODO: no caller yet. It is the fail-closed answer for a publisher that holds no source binding.
+ */
+void build_advertisement(
+    std::int32_t regionIndex,
+    RegionSource regionSource,
+    std::uint8_t localMemberSlot,
+    middleware::bap::activity_message::replicate_membership::CitizenAdvertisement& output) noexcept;
 
 /**
- * Reports whether one region's advertisement can be built now.
- * The client applies one membership update per revision. Publishing during `pending` spends that
- * revision on a region record with no descriptor, so a publisher must hold instead.
- * @param regionIndex Region the roster publishes for this session.
- * @return Which of the three states the advertisement is in.
+ * Source-less readiness query. It reports absent and claims no host row.
+ * TODO: no caller yet. It is the fail-closed answer for a caller that holds no source binding.
  */
 [[nodiscard]] AdvertisementState advertisement_state(std::int32_t regionIndex) noexcept;
 

+ 15 - 1
Sunrise/src/server/gameplay/gameplay_runtime.cpp

@@ -5,6 +5,8 @@
 #include "endpoint/gameplay_endpoint.h"
 #include "group/group_host.h"
 #include "peer/peer_transport.h"
+#include "physics/host/physics_session.h"
+#include "physics/host/runtime.h"
 
 namespace sunrise::server::gameplay {
 
@@ -14,7 +16,13 @@ bool initialize() noexcept {
     dtls::reset();
     peer::reset();
     group::reset();
-    return endpoint::initialize();
+    if (!endpoint::initialize()) {
+        return false;
+    }
+    // The endpoint binds first. No transport path reaches the host yet, so a host that cannot
+    // allocate must not stop the channel from carrying everything that does not need one.
+    static_cast<void>(physics::host::runtime::initialize());
+    return true;
 }
 
 /** Runs one bounded gameplay slice. */
@@ -23,11 +31,17 @@ void service(std::uint64_t now) noexcept {
     // The retries run before the send so anything they queue leaves in this slice.
     group::service(now);
     peer::service(now);
+    // Last, because it reads the admitted set the two calls above have already settled. It emits
+    // nothing on the wire, so its position cannot delay a queued send.
+    physics::host::session::service(now);
 }
 
 /** Stops the endpoint and clears every association and peer. */
 void shutdown() noexcept {
     endpoint::shutdown();
+    // The worlds close before the host does, or their State contexts are stranded.
+    physics::host::session::reset();
+    physics::host::runtime::shutdown();
     peer::reset();
     group::reset();
     dtls::reset();

+ 174 - 27
Sunrise/src/server/gameplay/group/group_host.cpp

@@ -18,6 +18,7 @@
 #include "../gameplay_log.h"
 #include "../peer/peer_transport.h"
 #include "group_host_sessions.h"
+#include "group_migration_receipts.h"
 
 namespace sunrise::server::gameplay::group {
 
@@ -129,8 +130,16 @@ template <typename Body>
         sessionId, id, declaredSize, {body.data(), size}, writer.bit_count());
 }
 
+/** @return True when two endpoints name the same address and port. */
+[[nodiscard]] bool same_endpoint(const state::gameplay::Endpoint& left,
+                                 const state::gameplay::Endpoint& right) noexcept {
+    return left.address == right.address && left.port == right.port;
+}
+
 /**
- * Finds or claims the record for one peer.
+ * Finds or claims the record for one peer, and binds it to that peer's endpoint.
+ * Admission is what establishes ownership, so this rebinds an existing record. A client that
+ * rebuilds its channel arrives from a new port and joins the same session again.
  * @param peer Peer endpoint.
  * @param sessionId Group session the record is keyed by. Zero claims nothing.
  * @return Record for that session, or null when the table is full.
@@ -145,6 +154,7 @@ template <typename Body>
     const std::uint64_t use = g_admitClock.fetch_add(1) + 1;
     for (Admitted& entry : g_admitted) {
         if (entry.occupied && entry.sessionId == sessionId) {
+            entry.endpoint = peer;
             entry.lastUse = use;
             return &entry;
         }
@@ -161,6 +171,55 @@ template <typename Body>
     return nullptr;
 }
 
+/**
+ * Finds the record for one session and proves the sender owns it.
+ * Every later message names its own session in its body, so without this a peer could name a
+ * session another endpoint was admitted for and move that session's state.
+ * @param peer Peer endpoint the message arrived from.
+ * @param sessionId Group session the message named.
+ * @return Record for that session, or null when it is absent or owned by another endpoint.
+ */
+[[nodiscard]] Admitted* claim_owned(const state::gameplay::Endpoint& peer,
+                                    std::uint64_t sessionId) noexcept {
+    if (sessionId == 0) {
+        return nullptr;
+    }
+    const std::uint64_t use = g_admitClock.fetch_add(1) + 1;
+    for (Admitted& entry : g_admitted) {
+        if (!entry.occupied || entry.sessionId != sessionId) {
+            continue;
+        }
+        if (!same_endpoint(entry.endpoint, peer)) {
+            return nullptr;
+        }
+        entry.lastUse = use;
+        return &entry;
+    }
+    return nullptr;
+}
+
+/**
+ * Tests whether another endpoint was admitted for one session.
+ * An absent record is not a conflict: a message may name a session before this host has a record
+ * for it, and refusing that would strand the peer. A record held elsewhere is a conflict.
+ * @param peer Peer endpoint the message arrived from.
+ * @param sessionId Group session the message named.
+ * @return True when a record holds that session for a different endpoint.
+ */
+[[nodiscard]] bool owned_elsewhere(const state::gameplay::Endpoint& peer,
+                                   std::uint64_t sessionId) noexcept {
+    AcquireSRWLockShared(&g_admittedLock);
+    bool conflict = false;
+    for (const Admitted& entry : g_admitted) {
+        if (entry.occupied && entry.sessionId == sessionId) {
+            conflict = !same_endpoint(entry.endpoint, peer);
+            break;
+        }
+    }
+    ReleaseSRWLockShared(&g_admittedLock);
+    return conflict;
+}
+
 /**
  * Publishes one snapshot naming this host, one admitted peer, and that peer's player if it has
  * one. The caller holds the admitted lock.
@@ -247,15 +306,16 @@ template <typename Body>
  * The peer creates no activity client until it holds this parameter, and the public-region
  * slice-set switch waits behind that client.
  * @param body Cleared body to fill.
- * @param groupSessionId Group session whose region this parameter is published for.
+ * @param binding Retained host row used for this whole parameter body.
  */
-void fill_activity_host(wire::ActivityHostParameter& body, std::uint64_t groupSessionId) noexcept {
+void fill_activity_host(wire::ActivityHostParameter& body,
+                        const HostSessionBinding& binding) noexcept {
     // The peer's `current-activity` carries this host's empty delta, so its nonce is the
     // descriptor default and the comparand is the empty id.
     body.selectionId = 0;
     // The peer addresses its activity join request to this id, and the activity route refuses one
     // that names no committed activity session. A gameplay identity is not one.
-    body.hostId = held_host_session(groupSessionId);
+    body.hostId = binding.target.sessionId;
     // The peer tests only the bit for its own member index, and this host does not decode which
     // index that is, so every bit is set.
     body.memberMask = kAllMembers;
@@ -269,7 +329,10 @@ void fill_activity_host(wire::ActivityHostParameter& body, std::uint64_t groupSe
  * @return True when the update was queued on the peer's reliable channel.
  */
 [[nodiscard]] bool publish_activity_host(const Admitted& record) noexcept {
-    if (held_host_session(record.sessionId) == state::activity::kAbsentSessionId) {
+    // The body is built from this copy, so no retain is needed: `host_session_for_group` already
+    // returns only a ready row whose State bindings still match, and nothing below reads the table.
+    HostSessionBinding binding{};
+    if (!host_session_for_group(record.sessionId, binding)) {
         // Publishing a zero host id latches an unusable parameter on the peer, and the peer only
         // reads it once. The region's advertisement allocates and this retries.
         report(core::log::Level::debug, "ev=gameplay stage=activityhost result=nosession");
@@ -282,7 +345,7 @@ void fill_activity_host(wire::ActivityHostParameter& body, std::uint64_t groupSe
     update.carriedMask =
         (std::uint64_t{1} << static_cast<std::uint8_t>(wire::Parameter::activityHost))
         | (std::uint64_t{1} << static_cast<std::uint8_t>(wire::Parameter::currentActivity));
-    fill_activity_host(update.activityHost, record.sessionId);
+    fill_activity_host(update.activityHost, binding);
 
     const bool sent = send_reliable(
         record.sessionId,
@@ -303,19 +366,25 @@ void fill_activity_host(wire::ActivityHostParameter& body, std::uint64_t groupSe
 /**
  * Answers one view establishment by binding and echoing the peer's own signature.
  * What a host's own view should hold is unknown. Echoing is the only answer that cannot produce
- * a signature mismatch.
- * @param sessionId Group session the link carries.
+ * a signature mismatch. The binding is keyed by the link, because the body names no session.
+ * @param from Peer endpoint the view arrived from.
+ * @param sessionId Session the reply rides back on, or zero when the link carries several.
  * @param view Decoded view body.
  */
-void bind_view(std::uint64_t sessionId, const wire::ViewEstablishment& view) noexcept {
+void bind_view(const state::gameplay::Endpoint& from,
+               std::uint64_t sessionId,
+               const wire::ViewEstablishment& view) noexcept {
     state::gameplay::ViewSignature signature{};
     signature.token = view.sessionToken;
     signature.kind = view.kind;
     signature.listCount = view.listCount;
     signature.hasList = view.hasList;
     signature.list = view.list;
+    // Kept unread. Its meaning is unrecovered, and dropping it would lose a field the peer sent.
+    signature.optionalValue = view.optionalValue;
+    signature.hasOptionalValue = view.hasOptionalValue;
     signature.bound = true;
-    peer::bind_view(sessionId, signature);
+    peer::bind_view(from, signature);
 
     const bool sent = send_reliable(
         sessionId,
@@ -339,11 +408,15 @@ void bind_view(std::uint64_t sessionId, const wire::ViewEstablishment& view) noe
  */
 void answer_parameters(std::uint64_t sessionId, std::uint64_t requested) noexcept {
     std::uint64_t carried = requested & wire::kEncodableParameters;
-    // Claims the region's slot rather than only reading it, so a request arriving before the
-    // advertisement still makes the service slice allocate one.
-    if (activity_host_session(sessionId, kUnknownRegion) == state::activity::kAbsentSessionId) {
-        // See publish_activity_host: a zero host id is worse than no answer for this one.
-        carried &= ~(std::uint64_t{1} << static_cast<std::uint8_t>(wire::Parameter::activityHost));
+    const std::uint64_t activityHostMask =
+        std::uint64_t{1} << static_cast<std::uint8_t>(wire::Parameter::activityHost);
+    // The body is built from this copy, so no retain is needed. See publish_activity_host.
+    HostSessionBinding binding{};
+    const bool hasHost =
+        (carried & activityHostMask) != 0 && host_session_for_group(sessionId, binding);
+    if ((carried & activityHostMask) != 0 && !hasHost) {
+        // A zero host id is worse than no answer for this one.
+        carried &= ~activityHostMask;
     }
     if (carried == 0) {
         report(core::log::Level::debug,
@@ -357,7 +430,9 @@ void answer_parameters(std::uint64_t sessionId, std::uint64_t requested) noexcep
     update.carriedMask = carried;
     // A zero host id latches an unusable parameter on the peer, so the answer carries the same
     // body the unsolicited publish does.
-    fill_activity_host(update.activityHost, sessionId);
+    if (hasHost) {
+        fill_activity_host(update.activityHost, binding);
+    }
 
     const bool sent = send_reliable(
         sessionId,
@@ -415,8 +490,7 @@ void release_endpoint(const state::gameplay::Endpoint& endpoint) noexcept {
     std::size_t count = 0;
     AcquireSRWLockExclusive(&g_admittedLock);
     for (Admitted& entry : g_admitted) {
-        if (entry.occupied && entry.endpoint.address == endpoint.address
-            && entry.endpoint.port == endpoint.port) {
+        if (entry.occupied && same_endpoint(entry.endpoint, endpoint)) {
             ++count;
             entry = {};
         }
@@ -450,7 +524,7 @@ bool consume(const state::gameplay::Endpoint& from,
         if (!wire::read_view(reader, view)) {
             return false;
         }
-        bind_view(sessionId, view);
+        bind_view(from, sessionId, view);
         return true;
     }
     if (id == static_cast<std::uint8_t>(wire::SessionMessageId::leaveSession)) {
@@ -458,6 +532,14 @@ bool consume(const state::gameplay::Endpoint& from,
         if (!wire::read_session_only(reader, leaving)) {
             return false;
         }
+        if (owned_elsewhere(from, leaving)) {
+            // A leave tears the session's link down, so a peer must not be able to send one for a
+            // session another endpoint was admitted for.
+            report(core::log::Level::warn,
+                   "ev=gameplay stage=leave result=unowned session=0x%016llX",
+                   static_cast<unsigned long long>(leaving));
+            return true;
+        }
         const bool sent = peer::send_out_of_band(
             from,
             static_cast<std::uint8_t>(wire::SessionMessageId::leaveAcknowledge),
@@ -491,7 +573,7 @@ bool consume(const state::gameplay::Endpoint& from,
         // `established`, so the answer is a snapshot that promotes them. Keyed by the body's
         // session, not the link's: one link carries every region the client joined over it.
         AcquireSRWLockExclusive(&g_admittedLock);
-        Admitted* const record = claim(from, body.sessionId);
+        Admitted* const record = claim_owned(from, body.sessionId);
         bool queued = false;
         const bool owed = record != nullptr && !record->joinPublished;
         if (record != nullptr) {
@@ -524,6 +606,12 @@ bool consume(const state::gameplay::Endpoint& from,
         if (!wire::read_join_abort(reader, notice)) {
             return false;
         }
+        if (owned_elsewhere(from, notice.sessionId)) {
+            report(core::log::Level::warn,
+                   "ev=gameplay stage=join result=unowned_abort session=0x%016llX",
+                   static_cast<unsigned long long>(notice.sessionId));
+            return true;
+        }
         report(core::log::Level::info,
                "ev=gameplay stage=join result=abort session=0x%016llX",
                static_cast<unsigned long long>(notice.sessionId));
@@ -535,8 +623,6 @@ bool consume(const state::gameplay::Endpoint& from,
         if (!wire::read_parameter_request(reader, header)) {
             return false;
         }
-        // The selected bodies after the header have per-parameter codecs this host does not
-        // write, so their widths are unknown and this container cannot be walked further.
         const std::uint64_t mask = header.requestedMask & kParameterMaskBits;
         std::array<char, kParameterNameCapacity> names{};
         report(core::log::Level::info,
@@ -544,8 +630,26 @@ bool consume(const state::gameplay::Endpoint& from,
                static_cast<unsigned>(mask),
                static_cast<unsigned>(header.modeFlag ? 1U : 0U),
                wire::parameter_names(mask, names.data(), names.size()));
-        answer_parameters(header.sessionId, mask);
-        return false;
+        // The selected bodies are walked before the answer goes out, so nothing is answered from
+        // a request that was only read as far as its header.
+        wire::ParameterRequestWalk walk{};
+        const bool intact = wire::walk_parameter_request(reader, mask, walk);
+        report(walk.complete ? core::log::Level::debug : core::log::Level::info,
+               "ev=gameplay stage=parameters result=%s walked=0x%08X stopped=%u tail=%u",
+               walk.complete ? "framed"
+               : intact      ? "ambiguous"
+                             : "truncated",
+               static_cast<unsigned>(walk.walkedMask),
+               static_cast<unsigned>(walk.ambiguousParameter),
+               walk.tailBits);
+        // The peer builds no activity client until it holds the host parameter, so the answer goes
+        // out even when a later body could not be located. The tail above is what is unread, not
+        // the answer's own inputs. A session another endpoint holds is answered by that endpoint.
+        if (!owned_elsewhere(from, header.sessionId)) {
+            answer_parameters(header.sessionId, mask);
+        }
+        // Only a fully located request leaves the container readable behind it.
+        return walk.complete;
     }
     if (id == wire::kPeerPropertiesId) {
         wire::PeerPropertiesHeader header{};
@@ -569,7 +673,7 @@ bool consume(const state::gameplay::Endpoint& from,
         // encoder here, and the peer's clear-flag arm accepts a row without one.
         AcquireSRWLockExclusive(&g_admittedLock);
         // The body's session, for the same reason join-complete uses its own.
-        Admitted* const record = claim(from, request.sessionId);
+        Admitted* const record = claim_owned(from, request.sessionId);
         bool published = false;
         if (record != nullptr) {
             record->hasPlayer = true;
@@ -590,7 +694,48 @@ bool consume(const state::gameplay::Endpoint& from,
                static_cast<unsigned>(request.kind));
         return false;
     }
-    return false;
+    if (id == wire::kPlayerRemoveId) {
+        wire::PlayerRemoveRequest request{};
+        if (!wire::read_player_remove(reader, request)) {
+            return false;
+        }
+        // The message names no player. The one to drop is the player the bound record holds.
+        AcquireSRWLockExclusive(&g_admittedLock);
+        Admitted* const record = claim_owned(from, request.sessionId);
+        bool published = false;
+        if (record != nullptr && record->hasPlayer) {
+            record->hasPlayer = false;
+            record->playerId = 0;
+            published = publish_snapshot(*record);
+            record->playerPublished = published;
+        }
+        ReleaseSRWLockExclusive(&g_admittedLock);
+        report(core::log::Level::info,
+               "ev=gameplay stage=player result=%s session=0x%llX",
+               published           ? "removed"
+               : record == nullptr ? "fail"
+                                   : "absent",
+               static_cast<unsigned long long>(request.sessionId));
+        // The whole body is two fields, so the container stays readable behind it.
+        return true;
+    }
+    if (id == wire::kPlayerPropertiesId) {
+        wire::PlayerPropertiesRequest request{};
+        if (!wire::read_player_properties_header(reader, request)) {
+            return false;
+        }
+        // The sparse record behind the header is not decoded, so nothing is merged from it. A
+        // merge from the header alone would reset every field the record carries.
+        report(core::log::Level::info,
+               "ev=gameplay stage=player result=properties session=0x%llX seq=%u kind=%u",
+               static_cast<unsigned long long>(request.sessionId),
+               request.sequence,
+               static_cast<unsigned>(request.kind));
+        return false;
+    }
+    // Migration and election bodies are read and recorded. This host never starts a migration and
+    // never answers one, but leaving them unread would end the container at the first of them.
+    return migration::consume(id, reader);
 }
 
 /** Publishes the membership snapshot that completes one peer's join. */
@@ -715,7 +860,9 @@ void snapshot_admitted(std::span<AdmittedRow> output, std::size_t& count) noexce
                          entry.endpoint,
                          entry.joinComplete,
                          entry.activityHostPublished,
-                         entry.playerPublished};
+                         entry.hasPlayer,
+                         entry.playerPublished,
+                         entry.joinId};
         ++count;
     }
     ReleaseSRWLockShared(&g_admittedLock);

+ 6 - 28
Sunrise/src/server/gameplay/group/group_host.h

@@ -1,4 +1,4 @@
-#pragma once
+#pragma once
 
 #include <cstddef>
 #include <cstdint>
@@ -11,8 +11,7 @@ namespace sunrise::server::gameplay::group {
 
 /**
  * Consumes one group-session message.
- * True means the whole body was read and the container may continue. False means the reader is
- * left part way through the body, so the caller must stop.
+ * False leaves the reader part way through the body, so the caller must stop.
  * @param from Peer endpoint in host order.
  * @param sessionId Fallback for a message that names no session, or zero when the link carries
  *                  several. Every session-scoped message names its own and uses that instead.
@@ -40,31 +39,6 @@ namespace sunrise::server::gameplay::group {
                                       std::uint64_t peerJoinId,
                                       std::uint64_t sessionId) noexcept;
 
-/**
- * Reports one region's activity host session, allocating it on first use.
- * One id per region, because the peer routes every activity push by it alone. The id must not
- * change while the region keeps its slot, or the peer errors `public_activity_host_mismatch`.
- * @param groupSessionId Session the peer names for this region, which is the descriptor's machine
- *        id and not its session id.
- * @param regionIndex Region being advertised, or kUnknownRegion.
- * @return Allocated session id, or zero while State cannot allocate one yet.
- */
-[[nodiscard]] std::uint64_t activity_host_session(std::uint64_t groupSessionId,
-                                                  std::int32_t regionIndex) noexcept;
-
-/**
- * Reports one region's activity session and whether it holds a table slot at all.
- * A caller that waits for the session needs the difference. Without a slot nothing will ever
- * allocate one, so waiting never ends.
- * @param groupSessionId Session the peer names for this region.
- * @param regionIndex Region being advertised, or kUnknownRegion.
- * @param claimedSlot Set when a slot names this region, with or without a session in it yet.
- * @return Allocated session id, or zero while the slot has none.
- */
-[[nodiscard]] std::uint64_t activity_host_session(std::uint64_t groupSessionId,
-                                                  std::int32_t regionIndex,
-                                                  bool& claimedSlot) noexcept;
-
 /** One admitted peer's group session and how far its join has got. */
 struct AdmittedRow {
     std::uint64_t sessionId{};
@@ -73,8 +47,12 @@ struct AdmittedRow {
     bool joinComplete{};
     /** Set once the `activity-host` parameter is on the peer's reliable channel. */
     bool activityHostPublished{};
+    /** Set once the peer has asked this host to add a player. */
+    bool hasPlayer{};
     /** Set once a snapshot naming the peer's player is on that channel. */
     bool playerPublished{};
+    /** Join id the peer chose for itself. It is the only member identity this table carries. */
+    std::uint64_t joinId{};
 };
 
 /** Copies every admitted group-session record. @param count Receives the copied row count. */

+ 250 - 141
Sunrise/src/server/gameplay/group/group_host_sessions.cpp

@@ -3,239 +3,348 @@
 #include <Windows.h>
 
 #include <array>
+#include <limits>
 
 #include "../../../state/activity/runtime.h"
 #include "../gameplay_log.h"
-#include "group_host.h"
 
 namespace sunrise::server::gameplay::group {
 
 namespace {
 
-/** One region's activity host session, keyed by the group session that region advertises. */
+/** One source-bound activity-host row owned by the fixed table. */
 struct HostSession {
-    std::uint64_t groupSessionId{};
-    std::uint64_t hostSessionId{};
+    HostSessionBinding binding{};
+    HostSessionState state{HostSessionState::absent};
     std::uint64_t lastUse{};
-    /** Region the advertisement named. The interface reads it; no lookup uses it. */
-    std::int32_t regionIndex{};
+    std::uint32_t references{};
     bool occupied{};
 };
 
-/** Regions that may hold an activity host session at once. */
+/** Regions that may hold an activity-host session at once. */
 constexpr std::size_t kHostSessionCapacity = 8;
-/** Guards the host-session table. It is never held across a State allocation. */
+/** Guards host rows and their deferred-retirement queue. Never held across a State call. */
 SRWLOCK g_hostSessionLock{SRWLOCK_INIT};
-/**
- * Activity host sessions this host advertises, one per region.
- * The session id is the peer's only routing key. See `activity_host_session` in `group_host.h`.
- */
 std::array<HostSession, kHostSessionCapacity> g_hostSessions{};
-/** Rises on every lookup, so the least recently named region is the one an eviction takes. */
+std::array<HostSessionBinding, kHostSessionCapacity> g_retired{};
+std::size_t g_retiredCount = 0;
 std::uint64_t g_useStamp = 0;
-/** Sessions an eviction took the slot from. The service slice frees them. */
-std::array<std::uint64_t, kHostSessionCapacity> g_evicted{};
-std::size_t g_evictedCount = 0;
-
-/**
- * Names one region in the table, taking a slot when it holds none. The caller holds the lock.
- * @param groupSessionId Group session the region advertises.
- * @param regionIndex Region the caller is advertising, kept on the row.
- * @param held Receives the session the region already holds, or the absent id.
- * @return True when a slot names the region afterwards.
- */
-[[nodiscard]] bool
-claim_locked(std::uint64_t groupSessionId, std::int32_t regionIndex, std::uint64_t& held) noexcept {
-    held = state::activity::kAbsentSessionId;
-    for (HostSession& entry : g_hostSessions) {
-        if (entry.occupied && entry.groupSessionId == groupSessionId) {
-            entry.lastUse = ++g_useStamp;
-            // A caller with no region keeps the one the advertisement recorded.
-            if (regionIndex != kUnknownRegion) {
-                entry.regionIndex = regionIndex;
-            }
-            held = entry.hostSessionId;
-            return true;
-        }
+std::uint64_t g_generation = 0;
+
+/** @return True when two bindings name the same immutable State record generation. */
+[[nodiscard]] bool same_generation(const state::activity::SessionBinding& left,
+                                   const state::activity::SessionBinding& right) noexcept {
+    return left.sessionId == right.sessionId && left.createdRevision == right.createdRevision;
+}
+
+/** @return Next nonzero row generation, or zero after monotonic generation exhaustion. */
+[[nodiscard]] std::uint64_t next_generation_locked() noexcept {
+    if (g_generation == (std::numeric_limits<std::uint64_t>::max)()) {
+        return 0;
     }
-    for (HostSession& entry : g_hostSessions) {
-        if (!entry.occupied) {
-            entry = {
-                groupSessionId, state::activity::kAbsentSessionId, ++g_useStamp, regionIndex, true};
-            return true;
-        }
+    ++g_generation;
+    return g_generation;
+}
+
+/** Moves one unreferenced occupied row to deferred retirement. The caller holds the lock. */
+[[nodiscard]] bool retire_locked(HostSession& row) noexcept {
+    if (!row.occupied) {
+        return true;
     }
-    if (g_evictedCount == g_evicted.size()) {
+    if (row.references != 0 || g_retiredCount == g_retired.size()) {
         return false;
     }
-    // The evicted session is freed by the service slice, not here: this runs inside a staged push
-    // and the release advances the state revision that push is committing against.
-    std::size_t oldest = 0;
-    for (std::size_t index = 1; index < g_hostSessions.size(); ++index) {
-        if (g_hostSessions[index].lastUse < g_hostSessions[oldest].lastUse) {
-            oldest = index;
-        }
-    }
-    if (g_hostSessions[oldest].hostSessionId != state::activity::kAbsentSessionId) {
-        g_evicted[g_evictedCount] = g_hostSessions[oldest].hostSessionId;
-        ++g_evictedCount;
-    }
-    g_hostSessions[oldest] = {
-        groupSessionId, state::activity::kAbsentSessionId, ++g_useStamp, regionIndex, true};
+    g_retired[g_retiredCount] = row.binding;
+    ++g_retiredCount;
+    row = {};
     return true;
 }
 
-/** Frees every session an eviction took a slot from. Callers hold no lock. */
-void free_evicted_host_sessions() noexcept {
-    std::array<std::uint64_t, kHostSessionCapacity> freed{};
+/** Releases both State retains and the allocated target of one retired row. */
+void release_retired(const HostSessionBinding& binding) noexcept {
+    if (binding.target.sessionId != state::activity::kAbsentSessionId) {
+        state::activity::release_binding(binding.target);
+        const bool released = state::activity::release_session(binding.target.sessionId);
+        report(core::log::Level::info,
+               "ev=gameplay stage=activityhost result=retired session=0x%llX generation=%llu "
+               "state=%s",
+               static_cast<unsigned long long>(binding.target.sessionId),
+               static_cast<unsigned long long>(binding.generation),
+               released ? "freed" : "retained");
+    }
+    state::activity::release_binding(binding.source);
+}
+
+/** Releases every deferred row. Callers hold no lock. */
+void free_retired_host_sessions() noexcept {
+    std::array<HostSessionBinding, kHostSessionCapacity> retired{};
     std::size_t count = 0;
     AcquireSRWLockExclusive(&g_hostSessionLock);
-    freed = g_evicted;
-    count = g_evictedCount;
-    g_evicted = {};
-    g_evictedCount = 0;
+    retired = g_retired;
+    count = g_retiredCount;
+    g_retired = {};
+    g_retiredCount = 0;
     ReleaseSRWLockExclusive(&g_hostSessionLock);
     for (std::size_t index = 0; index < count; ++index) {
-        const bool released = state::activity::release_session(freed[index]);
-        report(core::log::Level::info,
-               "ev=gameplay stage=activityhost result=evicted session=0x%llX state=%s",
-               static_cast<unsigned long long>(freed[index]),
-               released ? "freed" : "absent");
+        release_retired(retired[index]);
     }
 }
 
-} // namespace
-
-/** Reports the activity host session already held for one region, without claiming a slot. */
-std::uint64_t held_host_session(std::uint64_t groupSessionId) noexcept {
-    std::uint64_t held = state::activity::kAbsentSessionId;
+/** Copies one ready row selected by a predicate, then validates both retained State bindings. */
+template <typename Predicate>
+[[nodiscard]] bool find_ready(Predicate predicate, HostSessionBinding& output) noexcept {
+    output = {};
     AcquireSRWLockShared(&g_hostSessionLock);
-    for (const HostSession& entry : g_hostSessions) {
-        if (entry.occupied && entry.groupSessionId == groupSessionId) {
-            held = entry.hostSessionId;
+    for (const HostSession& row : g_hostSessions) {
+        if (row.occupied && row.state == HostSessionState::ready && predicate(row.binding)) {
+            output = row.binding;
             break;
         }
     }
     ReleaseSRWLockShared(&g_hostSessionLock);
-    return held;
+    if (output.generation == 0 || !state::activity::binding_matches(output.source)
+        || !state::activity::binding_matches(output.target)) {
+        output = {};
+        return false;
+    }
+    return true;
 }
 
-/** Copies every occupied host-session row. */
-void snapshot_host_sessions(std::span<HostSessionRow> output, std::size_t& count) noexcept {
-    count = 0;
-    AcquireSRWLockShared(&g_hostSessionLock);
-    for (const HostSession& entry : g_hostSessions) {
-        if (!entry.occupied || count >= output.size()) {
-            continue;
+} // namespace
+
+/** Claims or finds one source-bound activity-host row. */
+HostSessionState request_host_session(std::uint64_t groupSessionId,
+                                      const state::activity::SessionBinding& source,
+                                      std::int32_t regionIndex,
+                                      HostSessionBinding& output) noexcept {
+    output = {};
+    if (groupSessionId == 0 || regionIndex < 0 || !state::activity::retain_binding(source)) {
+        return HostSessionState::absent;
+    }
+
+    HostSessionState result = HostSessionState::full;
+    bool sourceTransferred = false;
+    AcquireSRWLockExclusive(&g_hostSessionLock);
+
+    HostSession* matching = nullptr;
+    for (HostSession& row : g_hostSessions) {
+        if (row.occupied && row.binding.groupSessionId == groupSessionId) {
+            matching = &row;
+            break;
         }
-        output[count] = {entry.groupSessionId, entry.hostSessionId, entry.regionIndex};
-        ++count;
     }
-    ReleaseSRWLockShared(&g_hostSessionLock);
+    if (matching != nullptr && same_generation(matching->binding.source, source)
+        && matching->binding.regionIndex == regionIndex) {
+        matching->lastUse = ++g_useStamp;
+        output = matching->binding;
+        result = matching->state;
+    } else if (matching != nullptr && matching->references != 0) {
+        result = HostSessionState::conflict;
+    } else {
+        HostSession* target = matching;
+        if (target == nullptr) {
+            for (HostSession& row : g_hostSessions) {
+                if (!row.occupied) {
+                    target = &row;
+                    break;
+                }
+            }
+        }
+        if (target == nullptr) {
+            for (HostSession& row : g_hostSessions) {
+                if (row.references == 0 && (target == nullptr || row.lastUse < target->lastUse)) {
+                    target = &row;
+                }
+            }
+        }
+
+        const std::uint64_t generation = target == nullptr ? 0 : next_generation_locked();
+        if (target != nullptr && generation != 0 && retire_locked(*target)) {
+            target->binding.source = source;
+            target->binding.groupSessionId = groupSessionId;
+            target->binding.generation = generation;
+            target->binding.regionIndex = regionIndex;
+            target->state = HostSessionState::pending;
+            target->lastUse = ++g_useStamp;
+            target->occupied = true;
+            output = target->binding;
+            sourceTransferred = true;
+            result = HostSessionState::pending;
+        }
+    }
+    ReleaseSRWLockExclusive(&g_hostSessionLock);
+
+    if (!sourceTransferred) {
+        state::activity::release_binding(source);
+    }
+    if (result == HostSessionState::full) {
+        report(core::log::Level::warn, "ev=gameplay stage=activityhost result=full");
+    }
+    return result;
 }
 
-/** Reports one region's activity session, asking the gameplay slice to allocate a missing one. */
-std::uint64_t activity_host_session(std::uint64_t groupSessionId,
-                                    std::int32_t regionIndex) noexcept {
-    bool claimed = false;
-    return activity_host_session(groupSessionId, regionIndex, claimed);
+/** Copies a ready row by its exact group-session key. */
+bool host_session_for_group(std::uint64_t groupSessionId, HostSessionBinding& output) noexcept {
+    output = {};
+    return groupSessionId != 0
+           && find_ready(
+               [groupSessionId](const HostSessionBinding& binding) {
+                   return binding.groupSessionId == groupSessionId;
+               },
+               output);
 }
 
-/** Reports one region's activity session and whether it holds a slot at all. */
-std::uint64_t activity_host_session(std::uint64_t groupSessionId,
-                                    std::int32_t regionIndex,
-                                    bool& claimedSlot) noexcept {
-    claimedSlot = false;
-    if (groupSessionId == 0) {
-        return state::activity::kAbsentSessionId;
-    }
-    // Never allocated here. The allocation advances the state revision and would fail the guard
-    // of the push that called in. The slot is claimed now and `service` fills it for the next one.
-    std::uint64_t held = state::activity::kAbsentSessionId;
+/** Copies a ready row by its allocated target activity-session id. */
+bool host_session_for_activity(std::uint64_t hostSessionId, HostSessionBinding& output) noexcept {
+    output = {};
+    return hostSessionId != state::activity::kAbsentSessionId
+           && find_ready(
+               [hostSessionId](const HostSessionBinding& binding) {
+                   return binding.target.sessionId == hostSessionId;
+               },
+               output);
+}
+
+/** Retains one ready host-row generation against replacement or eviction. */
+bool retain_host_session(std::uint64_t generation) noexcept {
+    if (generation == 0) {
+        return false;
+    }
+    bool retained = false;
     AcquireSRWLockExclusive(&g_hostSessionLock);
-    const bool claimed = claim_locked(groupSessionId, regionIndex, held);
+    for (HostSession& row : g_hostSessions) {
+        if (row.occupied && row.state == HostSessionState::ready
+            && row.binding.generation == generation
+            && row.references != (std::numeric_limits<decltype(row.references)>::max)()) {
+            ++row.references;
+            row.lastUse = ++g_useStamp;
+            retained = true;
+            break;
+        }
+    }
     ReleaseSRWLockExclusive(&g_hostSessionLock);
-    claimedSlot = claimed;
-    if (!claimed) {
-        // No slot, so nothing will ever fill one. A caller waiting on this has to publish without
-        // it rather than hold for an allocation that is not coming.
-        report(core::log::Level::warn, "ev=gameplay stage=activityhost result=full");
+    return retained;
+}
+
+/** Releases one external retain on the exact host-row generation. */
+void release_host_session(std::uint64_t generation) noexcept {
+    if (generation == 0) {
+        return;
+    }
+    AcquireSRWLockExclusive(&g_hostSessionLock);
+    for (HostSession& row : g_hostSessions) {
+        if (row.occupied && row.binding.generation == generation && row.references != 0) {
+            --row.references;
+            break;
+        }
     }
-    return held;
+    ReleaseSRWLockExclusive(&g_hostSessionLock);
 }
 
-/** Fills every claimed host-session slot that has no session yet. */
+/** Copies every occupied host-session row. */
+void snapshot_host_sessions(std::span<HostSessionRow> output, std::size_t& count) noexcept {
+    count = 0;
+    AcquireSRWLockShared(&g_hostSessionLock);
+    for (const HostSession& row : g_hostSessions) {
+        if (!row.occupied || count >= output.size()) {
+            continue;
+        }
+        output[count] = {row.binding.groupSessionId,
+                         row.binding.target.sessionId,
+                         row.binding.regionIndex,
+                         row.binding.generation};
+        ++count;
+    }
+    ReleaseSRWLockShared(&g_hostSessionLock);
+}
+
+/** Fills every pending host-session row with a source-bound target. */
 void allocate_claimed_host_sessions() noexcept {
-    free_evicted_host_sessions();
+    free_retired_host_sessions();
     for (;;) {
-        std::uint64_t groupSessionId = 0;
+        HostSessionBinding pending{};
         AcquireSRWLockShared(&g_hostSessionLock);
-        for (const HostSession& entry : g_hostSessions) {
-            if (entry.occupied && entry.hostSessionId == state::activity::kAbsentSessionId) {
-                groupSessionId = entry.groupSessionId;
+        for (const HostSession& row : g_hostSessions) {
+            if (row.occupied && row.state == HostSessionState::pending) {
+                pending = row.binding;
                 break;
             }
         }
         ReleaseSRWLockShared(&g_hostSessionLock);
-        if (groupSessionId == 0) {
+        if (pending.generation == 0) {
             return;
         }
-        // Outside the table lock: the allocation takes the State lock and the two may not nest.
+
         std::uint64_t sessionId = state::activity::kAbsentSessionId;
         state::activity::PendingAllocation allocation{};
-        if (!state::activity::prepare_session(sessionId, allocation)
+        if (!state::activity::prepare_session(pending.source.destination, sessionId, allocation)
             || !state::activity::commit(allocation)) {
-            // The account half is not loaded yet on an early slice, so this retries next slice.
             return;
         }
+
+        state::activity::SessionBinding target{};
+        if (!state::activity::snapshot_binding(sessionId, target)
+            || !state::activity::retain_binding(target)) {
+            return;
+        }
+
         bool stored = false;
         std::size_t occupied = 0;
         AcquireSRWLockExclusive(&g_hostSessionLock);
-        for (HostSession& entry : g_hostSessions) {
-            if (!entry.occupied) {
+        for (HostSession& row : g_hostSessions) {
+            if (!row.occupied) {
                 continue;
             }
             ++occupied;
-            if (entry.groupSessionId == groupSessionId
-                && entry.hostSessionId == state::activity::kAbsentSessionId) {
-                entry.hostSessionId = sessionId;
+            if (row.state == HostSessionState::pending
+                && row.binding.generation == pending.generation) {
+                row.binding.target = target;
+                row.state = HostSessionState::ready;
+                row.lastUse = ++g_useStamp;
                 stored = true;
+                break;
             }
         }
         ReleaseSRWLockExclusive(&g_hostSessionLock);
+
         if (!stored) {
-            // The slot was evicted while this was allocating, so the session is spent.
-            static_cast<void>(state::activity::release_session(sessionId));
+            state::activity::release_binding(target);
+            static_cast<void>(state::activity::release_session(target.sessionId));
             return;
         }
         report(core::log::Level::info,
                "ev=gameplay stage=activityhost result=allocated session=0x%llX group=0x%016llX "
-               "held=%zu",
-               static_cast<unsigned long long>(sessionId),
-               static_cast<unsigned long long>(groupSessionId),
+               "generation=%llu held=%zu",
+               static_cast<unsigned long long>(target.sessionId),
+               static_cast<unsigned long long>(pending.groupSessionId),
+               static_cast<unsigned long long>(pending.generation),
                occupied);
     }
 }
 
-/** Returns every held host session to State and clears the table. */
+/** Returns every retained binding and allocated target to State, then clears the table. */
 void reset_host_sessions() noexcept {
-    // Dropping the table alone would strand its records in State, and nothing else can name them
-    // once their group session is forgotten.
-    std::array<std::uint64_t, kHostSessionCapacity> held{};
+    std::array<HostSessionBinding, kHostSessionCapacity * 2> released{};
     std::size_t count = 0;
     AcquireSRWLockExclusive(&g_hostSessionLock);
-    for (const HostSession& entry : g_hostSessions) {
-        if (entry.occupied) {
-            held[count] = entry.hostSessionId;
+    for (const HostSession& row : g_hostSessions) {
+        if (row.occupied) {
+            released[count] = row.binding;
             ++count;
         }
     }
+    for (std::size_t index = 0; index < g_retiredCount; ++index) {
+        released[count] = g_retired[index];
+        ++count;
+    }
     g_hostSessions = {};
+    g_retired = {};
+    g_retiredCount = 0;
     ReleaseSRWLockExclusive(&g_hostSessionLock);
+
     for (std::size_t index = 0; index < count; ++index) {
-        static_cast<void>(state::activity::release_session(held[index]));
+        release_retired(released[index]);
     }
-    free_evicted_host_sessions();
 }
 
 } // namespace sunrise::server::gameplay::group

+ 53 - 10
Sunrise/src/server/gameplay/group/group_host_sessions.h

@@ -4,36 +4,79 @@
 #include <cstdint>
 #include <span>
 
+#include "../../../state/activity/definition.h"
+
 namespace sunrise::server::gameplay::group {
 
-/** Region of a caller that knows none. Such a call keeps the region already on the row. */
+/** Region sentinel that cannot claim or replace a host-session row. */
 inline constexpr std::int32_t kUnknownRegion = -1;
 
-/** One region's advertised group session and the activity host session it holds. */
+/** Immutable identity of one source-bound activity-host row generation. */
+struct HostSessionBinding {
+    state::activity::SessionBinding source{};
+    state::activity::SessionBinding target{};
+    std::uint64_t groupSessionId{};
+    std::uint64_t generation{};
+    std::int32_t regionIndex{kUnknownRegion};
+};
+
+/** Result of requesting one source-bound activity-host row. */
+enum class HostSessionState : std::uint8_t {
+    absent,
+    pending,
+    ready,
+    conflict,
+    full,
+};
+
+/** One advertised group session and its currently allocated activity-host session. */
 struct HostSessionRow {
     std::uint64_t groupSessionId{};
     std::uint64_t hostSessionId{};
     std::int32_t regionIndex{};
+    /** Rises on every claim, so it separates one host binding from the next on the same region. */
+    std::uint64_t generation{};
 };
 
 /**
- * Reports the activity host session already held for one region, without claiming a slot.
- * @param groupSessionId Group session the region advertises.
- * @return The host session id, or the absent id when none is held yet.
+ * Claims or finds one host row bound to an exact source activity generation and region.
+ * An unknown region never claims a row. A conflicting referenced row is never replaced.
+ * @param groupSessionId Group session carried by the matching join descriptor.
+ * @param source Exact source activity whose destination the target must copy.
+ * @param regionIndex Concrete advertised region.
+ * @param output Cleared, then receives the pending or ready row generation.
+ * @return Current state of the requested row.
  */
-[[nodiscard]] std::uint64_t held_host_session(std::uint64_t groupSessionId) noexcept;
+[[nodiscard]] HostSessionState request_host_session(std::uint64_t groupSessionId,
+                                                    const state::activity::SessionBinding& source,
+                                                    std::int32_t regionIndex,
+                                                    HostSessionBinding& output) noexcept;
+
+/** Copies a ready row by its exact group-session key. */
+[[nodiscard]] bool host_session_for_group(std::uint64_t groupSessionId,
+                                          HostSessionBinding& output) noexcept;
+
+/** Copies a ready row by its allocated target activity-session id. */
+[[nodiscard]] bool host_session_for_activity(std::uint64_t hostSessionId,
+                                             HostSessionBinding& output) noexcept;
+
+/** Retains one ready host-row generation against replacement or eviction. */
+[[nodiscard]] bool retain_host_session(std::uint64_t generation) noexcept;
+
+/** Releases one external retain on the exact host-row generation. */
+void release_host_session(std::uint64_t generation) noexcept;
 
 /** Copies every occupied host-session row. @param count Receives the copied row count. */
 void snapshot_host_sessions(std::span<HostSessionRow> output, std::size_t& count) noexcept;
 
 /**
- * Fills every claimed host-session slot that has no session yet, and frees every evicted session.
- * The allocation advances the state revision, so it must never run inside a staged push. That
- * push would fail its own revision guard. Callers hold no lock.
+ * Allocates pending source-bound targets and releases retired rows.
+ * State calls run outside the host lock and advance State revisions, so this must not run inside a
+ * staged push.
  */
 void allocate_claimed_host_sessions() noexcept;
 
-/** Returns every held host session to State and clears the table. */
+/** Returns every retained binding and allocated target to State, then clears the table. */
 void reset_host_sessions() noexcept;
 
 } // namespace sunrise::server::gameplay::group

+ 194 - 0
Sunrise/src/server/gameplay/group/group_migration_receipts.cpp

@@ -0,0 +1,194 @@
+#include "group_migration_receipts.h"
+
+#include "../../../middleware/gameplay/group/migration_messages.h"
+#include "../../../middleware/gameplay/group/notice_messages.h"
+#include "../gameplay_log.h"
+
+namespace sunrise::server::gameplay::group::migration {
+
+namespace {
+
+namespace wire = middleware::gameplay::group;
+namespace bits = middleware::encoding::bits;
+
+/** @return True when the id names a migration body carrying nothing but a group session. */
+[[nodiscard]] bool session_only(std::uint8_t id) noexcept {
+    return id == static_cast<std::uint8_t>(wire::MigrationMessageId::reestablishPending)
+           || id == static_cast<std::uint8_t>(wire::MigrationMessageId::peerReestablish);
+}
+
+/**
+ * Reads a handoff or its acknowledgement.
+ * @param id Registry message id, which names which half of the pair this is.
+ * @param reader Reader positioned at the body.
+ * @return True when the body was completely read.
+ */
+[[nodiscard]] bool consume_handoff(std::uint8_t id, bits::Reader& reader) noexcept {
+    wire::HostHandoff body{};
+    if (!wire::read_host_handoff(reader, body)) {
+        return false;
+    }
+    report(core::log::Level::info,
+           "ev=gameplay stage=migration result=handoff id=%u session=0x%016llX successor=%u",
+           static_cast<unsigned>(id),
+           static_cast<unsigned long long>(body.sessionId),
+           static_cast<unsigned>(body.successorIndex));
+    return true;
+}
+
+/**
+ * Reads one body a group host normally emits and records what it said.
+ * @param id Registry message id.
+ * @param reader Reader positioned at the body.
+ * @return True when the id is one of these messages and its body was completely read.
+ */
+[[nodiscard]] bool consume_notice(std::uint8_t id, bits::Reader& reader) noexcept {
+    if (id == static_cast<std::uint8_t>(wire::NoticeMessageId::peerConnectNotice)) {
+        wire::PeerConnectNotice body{};
+        if (!wire::read_peer_connect(reader, body)) {
+            return false;
+        }
+        report(core::log::Level::debug,
+               "ev=gameplay stage=notice result=peer_connect session=0x%016llX machine=0x%016llX "
+               "protocol=0x%04X",
+               static_cast<unsigned long long>(body.sessionId),
+               static_cast<unsigned long long>(body.machineId),
+               static_cast<unsigned>(body.protocolVersion));
+        return true;
+    }
+    if (id == static_cast<std::uint8_t>(wire::NoticeMessageId::sessionBootNotice)) {
+        wire::SessionBootNotice body{};
+        if (!wire::read_session_boot(reader, body)) {
+            return false;
+        }
+        report(core::log::Level::warn,
+               "ev=gameplay stage=notice result=boot session=0x%016llX kind=%u reason=%u",
+               static_cast<unsigned long long>(body.sessionId),
+               static_cast<unsigned>(body.kind),
+               static_cast<unsigned>(body.reason));
+        return true;
+    }
+    const bool delegate =
+        id == static_cast<std::uint8_t>(wire::NoticeMessageId::delegateLeadership);
+    if (delegate || id == static_cast<std::uint8_t>(wire::NoticeMessageId::bootMachine)) {
+        wire::AddressedNotice body{};
+        if (!wire::read_addressed_notice(reader, !delegate, body)) {
+            return false;
+        }
+        report(core::log::Level::warn,
+               "ev=gameplay stage=notice result=%s session=0x%016llX kind=%u",
+               delegate ? "delegate" : "boot_machine",
+               static_cast<unsigned long long>(body.sessionId),
+               static_cast<unsigned>(body.kind));
+        return true;
+    }
+    if (id == static_cast<std::uint8_t>(wire::NoticeMessageId::playerRefuse)) {
+        wire::PlayerRefuse body{};
+        if (!wire::read_player_refuse(reader, body)) {
+            return false;
+        }
+        report(core::log::Level::warn,
+               "ev=gameplay stage=notice result=player_refuse session=0x%016llX player=0x%016llX "
+               "reason=%u",
+               static_cast<unsigned long long>(body.sessionId),
+               static_cast<unsigned long long>(body.playerId),
+               static_cast<unsigned>(body.reason));
+        return true;
+    }
+    if (id == static_cast<std::uint8_t>(wire::NoticeMessageId::voiceRegistration)) {
+        if (!wire::read_voice_registration(reader)) {
+            return false;
+        }
+        report(core::log::Level::debug, "ev=gameplay stage=notice result=voice");
+        return true;
+    }
+    return false;
+}
+
+} // namespace
+
+/** Reads one host-migration or election message and records what it said. */
+bool consume(std::uint8_t id, bits::Reader& reader) noexcept {
+    if (id == static_cast<std::uint8_t>(wire::MigrationMessageId::hostHandoff)
+        || id == static_cast<std::uint8_t>(wire::MigrationMessageId::peerHandoff)) {
+        return consume_handoff(id, reader);
+    }
+    if (id == static_cast<std::uint8_t>(wire::MigrationMessageId::hostTransition)) {
+        wire::HostTransition body{};
+        if (!wire::read_host_transition(reader, body)) {
+            return false;
+        }
+        report(core::log::Level::info,
+               "ev=gameplay stage=migration result=transition session=0x%016llX progress=%u "
+               "token=0x%08X",
+               static_cast<unsigned long long>(body.sessionId),
+               static_cast<unsigned>(body.progress),
+               body.transitionToken);
+        return true;
+    }
+    if (id == static_cast<std::uint8_t>(wire::MigrationMessageId::hostReestablish)) {
+        wire::HostReestablish body{};
+        if (!wire::read_host_reestablish(reader, body)) {
+            return false;
+        }
+        // The new host is recorded and not installed. Installing one from a peer's own claim is
+        // how a group ends up with two hosts.
+        report(core::log::Level::warn,
+               "ev=gameplay stage=migration result=reestablish session=0x%016llX machine=0x%016llX",
+               static_cast<unsigned long long>(body.sessionId),
+               static_cast<unsigned long long>(body.machineId));
+        return true;
+    }
+    if (session_only(id)) {
+        std::uint64_t sessionId = 0;
+        if (!wire::read_migration_session(reader, sessionId)) {
+            return false;
+        }
+        report(core::log::Level::debug,
+               "ev=gameplay stage=migration result=pending id=%u session=0x%016llX",
+               static_cast<unsigned>(id),
+               static_cast<unsigned long long>(sessionId));
+        return true;
+    }
+    if (id == static_cast<std::uint8_t>(wire::MigrationMessageId::hostDecline)) {
+        wire::HostDecline body{};
+        if (!wire::read_host_decline(reader, body)) {
+            return false;
+        }
+        report(core::log::Level::info,
+               "ev=gameplay stage=migration result=decline session=0x%016llX data=%u flag=%u",
+               static_cast<unsigned long long>(body.sessionId),
+               static_cast<unsigned>(body.hasDeclineData),
+               static_cast<unsigned>(body.declineFlag));
+        return true;
+    }
+    if (id == static_cast<std::uint8_t>(wire::MigrationMessageId::election)) {
+        wire::Election body{};
+        if (!wire::read_election(reader, body)) {
+            return false;
+        }
+        report(core::log::Level::warn,
+               "ev=gameplay stage=migration result=election session=0x%016llX candidates=%u "
+               "tail=%u",
+               static_cast<unsigned long long>(body.sessionId),
+               static_cast<unsigned>(body.candidateCount),
+               body.tailBits);
+        // The candidate value width is unrecovered, so the bitsets behind the addresses were not
+        // located and no later message in this container can be found.
+        return false;
+    }
+    if (id == static_cast<std::uint8_t>(wire::MigrationMessageId::electionRefuse)) {
+        wire::ElectionRefuse body{};
+        if (!wire::read_election_refuse(reader, body)) {
+            return false;
+        }
+        report(core::log::Level::info,
+               "ev=gameplay stage=migration result=election_refuse session=0x%016llX code=%u",
+               static_cast<unsigned long long>(body.sessionId),
+               static_cast<unsigned>(body.refuseCode));
+        return true;
+    }
+    return consume_notice(id, reader);
+}
+
+} // namespace sunrise::server::gameplay::group::migration

+ 19 - 0
Sunrise/src/server/gameplay/group/group_migration_receipts.h

@@ -0,0 +1,19 @@
+#pragma once
+
+#include <cstdint>
+
+#include "../../../middleware/encoding/bit_reader.h"
+
+namespace sunrise::server::gameplay::group::migration {
+
+/**
+ * Reads one host-migration or election message and records what it said.
+ * Nothing acts on one. An unread body leaves the container behind it unreadable, and an echoed one
+ * can leave two peers each believing they carry the group.
+ * @param id Registry message id.
+ * @param reader Reader positioned at the body.
+ * @return True when the id is one of these messages and its body was completely read.
+ */
+[[nodiscard]] bool consume(std::uint8_t id, middleware::encoding::bits::Reader& reader) noexcept;
+
+} // namespace sunrise::server::gameplay::group::migration

+ 89 - 0
Sunrise/src/server/gameplay/peer/external_shadow.cpp

@@ -0,0 +1,89 @@
+#include "external_shadow.h"
+
+namespace sunrise::server::gameplay::peer {
+namespace {
+
+namespace shadow = state::gameplay::external;
+
+/** Reports whether a context has every required process-local generation. */
+[[nodiscard]] bool valid_context(const shadow::ExternalShadowKey& key) noexcept {
+    return key.carrier.generation != 0 && key.authenticatedMemberId != 0 && key.peerGeneration != 0
+           && key.channelGeneration != 0 && key.groupSessionId != 0 && key.viewGeneration != 0
+           && key.activitySessionId != 0;
+}
+
+/** Reports whether two observations belong to the same peer and view. */
+[[nodiscard]] bool same_context(const shadow::ExternalShadowKey& left,
+                                const shadow::ExternalShadowKey& right) noexcept {
+    return left.carrier.kind == right.carrier.kind
+           && left.carrier.generation == right.carrier.generation
+           && left.authenticatedMemberId == right.authenticatedMemberId
+           && left.peerGeneration == right.peerGeneration
+           && left.channelGeneration == right.channelGeneration
+           && left.groupSessionId == right.groupSessionId
+           && left.viewGeneration == right.viewGeneration
+           && left.activitySessionId == right.activitySessionId
+           && left.remoteConnectionSequence == right.remoteConnectionSequence
+           && left.localConnectionSequence == right.localConnectionSequence;
+}
+
+/** Copies the empty-channel fields carried on every observation. */
+void copy_channel_state(const middleware::gameplay::external::EmptyProfile& decoded,
+                        shadow::ExternalShadow& output) noexcept {
+    output.defaultBubble = decoded.defaultBubble;
+    output.defaultBubblePresent = decoded.defaultBubblePresent;
+    output.channel3TrailingList = decoded.channel3TrailingList;
+}
+
+} // namespace
+
+/** Reads one complete empty-channel frame without changing output on failure. */
+middleware::gameplay::external::EmptyProfileResult
+read_empty_frame(middleware::encoding::bits::Reader& reader, EmptyFrame& output) noexcept {
+    namespace external = middleware::gameplay::external;
+    EmptyFrame candidate{};
+    const external::EmptyProfileResult profile =
+        external::read_empty_profile(reader, candidate.profile);
+    if (profile != external::EmptyProfileResult::accepted) {
+        return profile;
+    }
+    if (!middleware::gameplay::peer::read_filler_and_padding(reader, candidate.filler)
+        || reader.remaining_bits() != 0) {
+        return external::EmptyProfileResult::malformed;
+    }
+    output = candidate;
+    return external::EmptyProfileResult::accepted;
+}
+
+/** Prepares one receive-only observation without changing current state. */
+ShadowPrepareResult
+prepare_external_shadow(const shadow::ExternalShadowKey& currentContext,
+                        const middleware::gameplay::external::EmptyProfile& decoded,
+                        const shadow::ExternalShadow& current,
+                        shadow::ExternalShadow& next) noexcept {
+    if (!valid_context(currentContext)) {
+        return ShadowPrepareResult::unsupportedCommon;
+    }
+
+    shadow::ExternalShadow candidate{};
+    if (decoded.commonPresent) {
+        if (decoded.common.entryCount != 1
+            || decoded.common.entries[0].activitySessionId != currentContext.activitySessionId) {
+            return ShadowPrepareResult::unsupportedCommon;
+        }
+        candidate.key = currentContext;
+        candidate.patchEpoch = decoded.common.patchEpoch;
+        candidate.reconciliationGeneration = decoded.common.entries[0].reconciliationGeneration;
+        candidate.occupied = true;
+    } else {
+        if (!current.occupied || !same_context(current.key, currentContext)) {
+            return ShadowPrepareResult::missingCommon;
+        }
+        candidate = current;
+    }
+    copy_channel_state(decoded, candidate);
+    next = candidate;
+    return ShadowPrepareResult::ready;
+}
+
+} // namespace sunrise::server::gameplay::peer

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است