Forráskód Böngészése

basic gameplay channel and ah implementation

stan 3 hete
szülő
commit
122b8da905
100 módosított fájl, 8286 hozzáadás és 137 törlés
  1. 99 0
      Sunrise/Sunrise.vcxproj
  2. 9 0
      Sunrise/resources/default_settings.json
  3. 3 3
      Sunrise/resources/resource.h
  4. 47 0
      Sunrise/src/client/content/vendors/layout.h
  5. 331 0
      Sunrise/src/client/content/vendors/package_vendor_build.cpp
  6. 22 0
      Sunrise/src/client/content/vendors/vendor_build.h
  7. 96 16
      Sunrise/src/client/hooks/assert_handler/assert_handler_observer.cpp
  8. 12 8
      Sunrise/src/client/hooks/bootflow/region_private.cpp
  9. 5 2
      Sunrise/src/client/hooks/graphics/renderer/graphics_renderer_report.cpp
  10. 1 0
      Sunrise/src/client/hooks/graphics/renderer/graphics_renderer_report.h
  11. 7 0
      Sunrise/src/client/hooks/graphics/renderer/selection/graphics_swap_chain_selection.cpp
  12. 129 13
      Sunrise/src/client/hooks/queuez/family0/family0_source_seed.cpp
  13. 19 2
      Sunrise/src/client/hooks/queuez/family0_subscription.cpp
  14. 14 4
      Sunrise/src/client/hooks/retail_log/retail_log_enqueue_observer.cpp
  15. 18 1
      Sunrise/src/core/logging/log.cpp
  16. 65 0
      Sunrise/src/core/settings/address_text.cpp
  17. 22 0
      Sunrise/src/core/settings/address_text.h
  18. 6 0
      Sunrise/src/core/settings/client/client_settings_parser.cpp
  19. 6 0
      Sunrise/src/core/settings/client/definition.h
  20. 3 45
      Sunrise/src/core/settings/client/external/client_external_settings_parser.cpp
  21. 7 0
      Sunrise/src/core/settings/parser.h
  22. 3 0
      Sunrise/src/core/settings/server/definition.h
  23. 71 0
      Sunrise/src/core/settings/server/gameplay/definition.h
  24. 117 0
      Sunrise/src/core/settings/server/gameplay/gameplay_settings_parser.cpp
  25. 39 0
      Sunrise/src/core/settings/server/gameplay/gameplay_settings_validation.cpp
  26. 6 0
      Sunrise/src/core/settings/server/server_settings_parser.cpp
  27. 52 0
      Sunrise/src/core/settings/state_settings.cpp
  28. 2 2
      Sunrise/src/middleware/bap/activity_message/activity_entity_slot_request_parser.cpp
  29. 39 11
      Sunrise/src/middleware/bap/activity_message/activity_membership_region_writer.cpp
  30. 7 4
      Sunrise/src/middleware/bap/activity_message/activity_replicate_membership_encoder.cpp
  31. 38 0
      Sunrise/src/middleware/bap/activity_message/replicate_membership.h
  32. 144 0
      Sunrise/src/middleware/crypto/aes_cbc.cpp
  33. 38 0
      Sunrise/src/middleware/crypto/aes_cbc.h
  34. 65 0
      Sunrise/src/middleware/crypto/aes_gcm_encrypt.cpp
  35. 26 0
      Sunrise/src/middleware/crypto/aes_gcm_encrypt.h
  36. 298 0
      Sunrise/src/middleware/crypto/ecc_p224.cpp
  37. 31 0
      Sunrise/src/middleware/crypto/ecc_p224.h
  38. 143 0
      Sunrise/src/middleware/crypto/hmac.cpp
  39. 42 0
      Sunrise/src/middleware/crypto/hmac.h
  40. 135 0
      Sunrise/src/middleware/crypto/lookup3.cpp
  41. 28 0
      Sunrise/src/middleware/crypto/lookup3.h
  42. 285 0
      Sunrise/src/middleware/crypto/modular_1024.cpp
  43. 96 0
      Sunrise/src/middleware/crypto/modular_1024.h
  44. 139 0
      Sunrise/src/middleware/crypto/murmur3.cpp
  45. 26 0
      Sunrise/src/middleware/crypto/murmur3.h
  46. 22 0
      Sunrise/src/middleware/crypto/random_bytes.cpp
  47. 16 0
      Sunrise/src/middleware/crypto/random_bytes.h
  48. 78 0
      Sunrise/src/middleware/crypto/sha256.cpp
  49. 35 0
      Sunrise/src/middleware/crypto/sha256.h
  50. 436 0
      Sunrise/src/middleware/crypto/tiger192.cpp
  51. 23 0
      Sunrise/src/middleware/crypto/tiger192.h
  52. 8 5
      Sunrise/src/middleware/datagen/family4/progression/layout.h
  53. 22 1
      Sunrise/src/middleware/datagen/family4/progression/progression_bank_keys.cpp
  54. 1 1
      Sunrise/src/middleware/datagen/family4/progression/progression_bank_keys.h
  55. 67 0
      Sunrise/src/middleware/encoding/bit_raw.cpp
  56. 53 0
      Sunrise/src/middleware/encoding/bit_raw.h
  57. 152 0
      Sunrise/src/middleware/gameplay/association/control_packet.cpp
  58. 75 0
      Sunrise/src/middleware/gameplay/association/control_packet.h
  59. 187 0
      Sunrise/src/middleware/gameplay/association/protected_datagram.cpp
  60. 59 0
      Sunrise/src/middleware/gameplay/association/protected_datagram.h
  61. 138 0
      Sunrise/src/middleware/gameplay/association/srp_exchange.cpp
  62. 41 0
      Sunrise/src/middleware/gameplay/association/srp_exchange.h
  63. 106 0
      Sunrise/src/middleware/gameplay/descriptor/join_descriptor.cpp
  64. 48 0
      Sunrise/src/middleware/gameplay/descriptor/join_descriptor.h
  65. 76 0
      Sunrise/src/middleware/gameplay/dtls/association_keys.cpp
  66. 37 0
      Sunrise/src/middleware/gameplay/dtls/association_keys.h
  67. 192 0
      Sunrise/src/middleware/gameplay/dtls/dtls_messages.cpp
  68. 134 0
      Sunrise/src/middleware/gameplay/dtls/dtls_messages.h
  69. 250 0
      Sunrise/src/middleware/gameplay/dtls/record.cpp
  70. 84 0
      Sunrise/src/middleware/gameplay/dtls/record.h
  71. 66 0
      Sunrise/src/middleware/gameplay/group/member_messages.cpp
  72. 58 0
      Sunrise/src/middleware/gameplay/group/member_messages.h
  73. 161 0
      Sunrise/src/middleware/gameplay/group/parameter_messages.cpp
  74. 101 0
      Sunrise/src/middleware/gameplay/group/parameter_messages.h
  75. 84 0
      Sunrise/src/middleware/gameplay/group/parameter_registry.cpp
  76. 52 0
      Sunrise/src/middleware/gameplay/group/parameter_registry.h
  77. 369 0
      Sunrise/src/middleware/gameplay/group/session_messages.cpp
  78. 217 0
      Sunrise/src/middleware/gameplay/group/session_messages.h
  79. 208 0
      Sunrise/src/middleware/gameplay/group/session_state.cpp
  80. 32 0
      Sunrise/src/middleware/gameplay/group/session_state.h
  81. 103 0
      Sunrise/src/middleware/gameplay/group/view_message.cpp
  82. 53 0
      Sunrise/src/middleware/gameplay/group/view_message.h
  83. 25 0
      Sunrise/src/middleware/gameplay/nat/introduction.cpp
  84. 20 0
      Sunrise/src/middleware/gameplay/nat/introduction.h
  85. 86 0
      Sunrise/src/middleware/gameplay/peer/connect_messages.cpp
  86. 97 0
      Sunrise/src/middleware/gameplay/peer/connect_messages.h
  87. 512 0
      Sunrise/src/middleware/gameplay/peer/established_packet.cpp
  88. 146 0
      Sunrise/src/middleware/gameplay/peer/established_packet.h
  89. 75 0
      Sunrise/src/middleware/gameplay/peer/join_messages.cpp
  90. 85 0
      Sunrise/src/middleware/gameplay/peer/join_messages.h
  91. 63 0
      Sunrise/src/middleware/gameplay/peer/peer_container.cpp
  92. 65 0
      Sunrise/src/middleware/gameplay/peer/peer_container.h
  93. 128 0
      Sunrise/src/middleware/gameplay/peer/reliable_assembly.cpp
  94. 46 0
      Sunrise/src/middleware/gameplay/peer/reliable_assembly.h
  95. 103 0
      Sunrise/src/middleware/web_service/messages/opcode901/opcode901_codec.cpp
  96. 70 0
      Sunrise/src/middleware/web_service/messages/opcode901/opcode901_codec.h
  97. 7 4
      Sunrise/src/server/bap/encrypted/activity_message/activity_message_route.cpp
  98. 35 4
      Sunrise/src/server/bap/encrypted/activity_transaction/activity_transaction_notifications.cpp
  99. 17 9
      Sunrise/src/server/bap/encrypted/bap_connection_publication.cpp
  100. 71 2
      Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp

+ 99 - 0
Sunrise/Sunrise.vcxproj

@@ -37,6 +37,9 @@
     <IntDir>$(ProjectDir)..\build\obj\$(Platform)\$(Configuration)\</IntDir>
     <ExternalIncludePath>$(ExternalIncludePath);$(ProjectDir)vendor\detours;$(ProjectDir)vendor\imgui;$(ProjectDir)vendor\imgui\backends</ExternalIncludePath>
     <VcpkgApplocalDeps>false</VcpkgApplocalDeps>
+    <!-- clang rejects the UCRT offsetof macro in a constant expression, so point it at the
+         builtin. MSVC refuses the same define as a reserved name, so it stays on this pass. -->
+    <ClangTidyPrependExtraArgs>-D_CRT_USE_BUILTIN_OFFSETOF $(ClangTidyPrependExtraArgs)</ClangTidyPrependExtraArgs>
   </PropertyGroup>
   <ItemDefinitionGroup>
     <ClCompile>
@@ -346,6 +349,7 @@
     <ClCompile Include="src\state\activity\destination\activity_destination_validation.cpp" />
     <ClCompile Include="src\state\activity\transactions\activity_session_prepare.cpp" />
     <ClCompile Include="src\state\activity\transactions\activity_session_commit.cpp" />
+    <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\activity\bubble_authority\transactions\activity_bubble_authority_grant.cpp" />
@@ -382,6 +386,10 @@
     <ClCompile Include="src\state\build_data\cache\records\cache_record_codec.cpp" />
     <ClCompile Include="src\state\build_data\cache\records\cache_scenario_records.cpp" />
     <ClCompile Include="src\state\build_data\cache\records\cache_spawn_set_records.cpp" />
+    <ClCompile Include="src\state\build_data\cache\records\cache_vendor_records.cpp" />
+    <ClCompile Include="src\state\build_data\vendors\vendor_catalog.cpp" />
+    <ClCompile Include="src\state\build_data\vendors\vendor_expression.cpp" />
+    <ClCompile Include="src\client\content\vendors\package_vendor_build.cpp" />
     <ClCompile Include="src\state\build_data\cache\records\cache_hash_name_records.cpp" />
     <ClCompile Include="src\state\build_data\cache\records\cache_detail_links.cpp" />
     <ClCompile Include="src\state\build_data\scenarios\scenario_catalog.cpp" />
@@ -604,6 +612,49 @@
     <ClCompile Include="src\client\content\items\packages\package_item_rows.cpp" />
     <ClCompile Include="src\client\diagnostics\module_range.cpp" />
     <ClCompile Include="src\client\process\freeze\client_process_freeze.cpp" />
+    <ClCompile Include="src\core\settings\address_text.cpp" />
+    <ClCompile Include="src\core\settings\server\gameplay\gameplay_settings_validation.cpp" />
+    <ClCompile Include="src\core\settings\server\gameplay\gameplay_settings_parser.cpp" />
+    <ClCompile Include="src\middleware\crypto\random_bytes.cpp" />
+    <ClCompile Include="src\middleware\crypto\sha256.cpp" />
+    <ClCompile Include="src\middleware\crypto\ecc_p224.cpp" />
+    <ClCompile Include="src\middleware\crypto\tiger192.cpp" />
+    <ClCompile Include="src\middleware\crypto\aes_cbc.cpp" />
+    <ClCompile Include="src\middleware\crypto\hmac.cpp" />
+    <ClCompile Include="src\middleware\crypto\lookup3.cpp" />
+    <ClCompile Include="src\middleware\crypto\murmur3.cpp" />
+    <ClCompile Include="src\middleware\crypto\aes_gcm_encrypt.cpp" />
+    <ClCompile Include="src\middleware\crypto\modular_1024.cpp" />
+    <ClCompile Include="src\middleware\encoding\bit_raw.cpp" />
+    <ClCompile Include="src\middleware\gameplay\association\srp_exchange.cpp" />
+    <ClCompile Include="src\middleware\gameplay\association\control_packet.cpp" />
+    <ClCompile Include="src\middleware\gameplay\association\protected_datagram.cpp" />
+    <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\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\middleware\gameplay\descriptor\join_descriptor.cpp" />
+    <ClCompile Include="src\server\gameplay\gameplay_advertisement.cpp" />
+    <ClCompile Include="src\middleware\web_service\messages\opcode901\opcode901_codec.cpp" />
+    <ClCompile Include="src\middleware\gameplay\peer\reliable_assembly.cpp" />
+    <ClCompile Include="src\middleware\gameplay\peer\join_messages.cpp" />
+    <ClCompile Include="src\middleware\gameplay\group\session_messages.cpp" />
+    <ClCompile Include="src\middleware\gameplay\group\session_state.cpp" />
+    <ClCompile Include="src\middleware\gameplay\group\view_message.cpp" />
+    <ClCompile Include="src\middleware\gameplay\group\parameter_messages.cpp" />
+    <ClCompile Include="src\middleware\gameplay\group\parameter_registry.cpp" />
+    <ClCompile Include="src\middleware\gameplay\group\member_messages.cpp" />
+    <ClCompile Include="src\server\gameplay\group\group_host.cpp" />
+    <ClCompile Include="src\server\gameplay\group\group_host_sessions.cpp" />
+    <ClCompile Include="src\middleware\gameplay\nat\introduction.cpp" />
+    <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\server\gameplay\dtls\dtls_host.cpp" />
   </ItemGroup>
   <ItemGroup Condition="'$(SunriseRunClangTidy)'=='true'">
     <ClCompile Remove="vendor\detours\detours.cpp" />
@@ -813,6 +864,11 @@
     <ClInclude Include="src\state\build_data\hash_names\hash_name_catalog.h" />
     <ClInclude Include="src\state\build_data\hash_names\definition.h" />
     <ClInclude Include="src\state\build_data\spawn_sets\definition.h" />
+    <ClInclude Include="src\state\build_data\vendors\definition.h" />
+    <ClInclude Include="src\state\build_data\vendors\vendor_catalog.h" />
+    <ClInclude Include="src\state\build_data\vendors\vendor_expression.h" />
+    <ClInclude Include="src\client\content\vendors\layout.h" />
+    <ClInclude Include="src\client\content\vendors\vendor_build.h" />
     <ClInclude Include="src\state\build_data\scenarios\definition.h" />
     <ClInclude Include="src\state\build_data\runtime\persistence\build_data_persistence.h" />
     <ClInclude Include="src\state\build_data\runtime\persistence\publication_transaction.h" />
@@ -1047,6 +1103,49 @@
     <ClInclude Include="src\server\bap\encrypted\push\queuez\queuez_push_reporting.h" />
     <ClInclude Include="src\client\diagnostics\module_range.h" />
     <ClInclude Include="src\client\process\freeze\client_process_freeze.h" />
+    <ClInclude Include="src\core\settings\address_text.h" />
+    <ClInclude Include="src\core\settings\server\gameplay\definition.h" />
+    <ClInclude Include="src\middleware\crypto\random_bytes.h" />
+    <ClInclude Include="src\middleware\crypto\sha256.h" />
+    <ClInclude Include="src\middleware\crypto\ecc_p224.h" />
+    <ClInclude Include="src\middleware\crypto\tiger192.h" />
+    <ClInclude Include="src\middleware\crypto\aes_cbc.h" />
+    <ClInclude Include="src\middleware\crypto\hmac.h" />
+    <ClInclude Include="src\middleware\crypto\lookup3.h" />
+    <ClInclude Include="src\middleware\crypto\murmur3.h" />
+    <ClInclude Include="src\middleware\crypto\aes_gcm_encrypt.h" />
+    <ClInclude Include="src\middleware\crypto\modular_1024.h" />
+    <ClInclude Include="src\middleware\encoding\bit_raw.h" />
+    <ClInclude Include="src\middleware\gameplay\association\srp_exchange.h" />
+    <ClInclude Include="src\middleware\gameplay\association\control_packet.h" />
+    <ClInclude Include="src\middleware\gameplay\association\protected_datagram.h" />
+    <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\state\gameplay\definition.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\middleware\gameplay\descriptor\join_descriptor.h" />
+    <ClInclude Include="src\server\gameplay\gameplay_advertisement.h" />
+    <ClInclude Include="src\middleware\web_service\messages\opcode901\opcode901_codec.h" />
+    <ClInclude Include="src\middleware\gameplay\peer\reliable_assembly.h" />
+    <ClInclude Include="src\middleware\gameplay\peer\join_messages.h" />
+    <ClInclude Include="src\middleware\gameplay\group\session_messages.h" />
+    <ClInclude Include="src\middleware\gameplay\group\session_state.h" />
+    <ClInclude Include="src\middleware\gameplay\group\view_message.h" />
+    <ClInclude Include="src\middleware\gameplay\group\parameter_messages.h" />
+    <ClInclude Include="src\middleware\gameplay\group\parameter_registry.h" />
+    <ClInclude Include="src\middleware\gameplay\group\member_messages.h" />
+    <ClInclude Include="src\server\gameplay\group\group_host.h" />
+    <ClInclude Include="src\server\gameplay\group\group_host_sessions.h" />
+    <ClInclude Include="src\middleware\gameplay\nat\introduction.h" />
+    <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\server\gameplay\dtls\dtls_host.h" />
   </ItemGroup>
   <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
 </Project>

+ 9 - 0
Sunrise/resources/default_settings.json

@@ -31,12 +31,21 @@
     },
     "fade_release": true,
     "force_join_request_ready": true,
+    "region_private": true,
     "pin_replicated_record": true,
     "hold_spawn": true,
     "spawn_hold_ms": 30000
   },
   "server": {
     "bap_port": 30974,
+    "gameplay": {
+      "topology": "disabled",
+      "bind_address": "127.0.0.1",
+      "advertised_address": "127.0.0.1",
+      "transport_address": "127.0.0.1",
+      "port": 30976,
+      "server_reserve_count": 256
+    },
     "entitlements": [
       { "name": "1085660", "owned": "handle" },
       { "name": "STEAM_PAID_TIER", "owned": "handle" },

+ 3 - 3
Sunrise/resources/resource.h

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

+ 47 - 0
Sunrise/src/client/content/vendors/layout.h

@@ -0,0 +1,47 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+
+namespace sunrise::client::content::vendors {
+
+/** Tag of the installed vendor index blob, which names every vendor definition. */
+inline constexpr std::uint32_t kIndexRootTag = 0x8131931DU;
+
+/** A vendor definition holds its installed array descriptor here. */
+inline constexpr std::size_t kInstalledArrayDescriptor = 32;
+/** A vendor definition holds its sale array descriptor here. */
+inline constexpr std::size_t kSaleArrayDescriptor = 48;
+/** A vendor definition holds its unnamed third array descriptor here. */
+inline constexpr std::size_t kThirdArrayDescriptor = 80;
+/** Raw reset interval. Its unit, epoch and scope are open, so it is stored unconverted. */
+inline constexpr std::size_t kResetIntervalOffset = 20;
+/** Raw reset phase, paired with the interval. */
+inline constexpr std::size_t kResetPhaseOffset = 24;
+
+/** Sale row expression array descriptor. */
+inline constexpr std::size_t kSaleExpression8Offset = 8;
+/** Sale row nested-record array descriptor. */
+inline constexpr std::size_t kSaleNestedRecordOffset = 32;
+/** Sale row main item-definition index. */
+inline constexpr std::size_t kSaleItemIndexOffset = 70;
+/** Sale row installed/runtime table index. */
+inline constexpr std::size_t kSaleInstalledIndexOffset = 100;
+/** Sale row scalar with no closed consumer. */
+inline constexpr std::size_t kSaleRaw104Offset = 104;
+/** Sale row scalar with no closed consumer. */
+inline constexpr std::size_t kSaleRaw108Offset = 108;
+/** Sale row second expression array descriptor. */
+inline constexpr std::size_t kSaleExpression120Offset = 120;
+/** Sale row array descriptor with no closed consumer. */
+inline constexpr std::size_t kSaleCount136Offset = 136;
+/** Sale row feature branch byte. */
+inline constexpr std::size_t kSaleFeatureBranchOffset = 154;
+/** Sale row inline expression array descriptor. */
+inline constexpr std::size_t kSaleExpression160Offset = 160;
+/** Sale row scalar with no closed consumer. It is not the field the runtime selector reads. */
+inline constexpr std::size_t kSaleRaw172Offset = 172;
+/** Sale row secondary item-definition index. */
+inline constexpr std::size_t kSaleSecondaryItemOffset = 176;
+
+} // namespace sunrise::client::content::vendors

+ 331 - 0
Sunrise/src/client/content/vendors/package_vendor_build.cpp

@@ -0,0 +1,331 @@
+#include <array>
+#include <cstddef>
+#include <cstdio>
+#include <cstring>
+#include <limits>
+#include <span>
+#include <vector>
+
+#include "../../../core/logging/log.h"
+#include "../../../middleware/content/packages/tables/definition_index_table.h"
+#include "../../../state/build_data/runtime.h"
+#include "../../../state/build_data/vendors/definition.h"
+#include "layout.h"
+#include "vendor_build.h"
+
+namespace sunrise::client::content::vendors {
+namespace {
+
+namespace reader = middleware::content::packages::reader;
+namespace tables = middleware::content::packages::tables;
+namespace domain = state::build_data::vendors;
+
+/** Every extracted row, kept off the caller stack. */
+struct Storage {
+    std::vector<std::byte> blob{};
+    std::array<domain::IndexEntry, domain::kIndexCapacity> index{};
+    std::array<domain::Definition, domain::kDefinitionCapacity> definitions{};
+    std::array<domain::SaleRow, domain::kSaleRowCapacity> saleRows{};
+    std::array<domain::InstalledRow, domain::kInstalledRowCapacity> installedRows{};
+    std::size_t indexCount{};
+    std::size_t definitionCount{};
+    std::size_t saleRowCount{};
+    std::size_t installedRowCount{};
+};
+
+/** One array a definition or a sale row declares, reduced to what the catalog stores. */
+struct ArrayView {
+    std::uint32_t base{};
+    std::uint32_t classId{};
+    std::uint16_t count{};
+};
+
+/** @param blob Source bytes. @param offset Field offset. @param value Receives the field. */
+template <typename Value>
+[[nodiscard]] bool
+read(std::span<const std::byte> blob, std::size_t offset, Value& value) noexcept {
+    if (offset > blob.size() || blob.size() - offset < sizeof value) {
+        return false;
+    }
+    std::memcpy(&value, blob.data() + offset, sizeof value);
+    return true;
+}
+
+/**
+ * Reads one array descriptor and bounds it against the blob holding it.
+ * The raw count is read first, because the shared resolver reports absent and corrupt alike.
+ * @param blob Whole blob owning the descriptor.
+ * @param descriptor Descriptor offset.
+ * @param stride One row's size.
+ * @param output Receives the array, or an absent array.
+ * @return True when the array is absent, or resolves and ends inside the blob.
+ */
+[[nodiscard]] bool read_array(std::span<const std::byte> blob,
+                              std::size_t descriptor,
+                              std::size_t stride,
+                              ArrayView& output) noexcept {
+    /** Row counts are stored as unsigned 16-bit values. */
+    constexpr std::uint64_t kMaximumCount = (std::numeric_limits<std::uint16_t>::max)();
+    output = {};
+    std::uint64_t declared = 0;
+    if (!read(blob, descriptor, declared)) {
+        return false;
+    }
+    if (declared == 0) {
+        return true;
+    }
+    tables::Array array{};
+    if (!tables::find_array_at(blob, descriptor, array) || array.count > kMaximumCount) {
+        return false;
+    }
+    const std::uint64_t end = array.dataOffset + (array.count * stride);
+    if (end > blob.size()) {
+        return false;
+    }
+    output = {static_cast<std::uint32_t>(array.dataOffset),
+              array.elementClass,
+              static_cast<std::uint16_t>(array.count)};
+    return true;
+}
+
+/**
+ * Reads one array's declared count from a sale row, without resolving its header.
+ * @param blob Whole definition blob.
+ * @param descriptor Descriptor offset inside the blob.
+ * @param count Receives the declared count.
+ * @return True when the count is inside the blob and fits the stored width.
+ */
+[[nodiscard]] bool
+read_count(std::span<const std::byte> blob, std::size_t descriptor, std::uint32_t& count) noexcept {
+    /** Sale-row array counts are stored as unsigned 32-bit values. */
+    constexpr std::uint64_t kMaximumCount = (std::numeric_limits<std::uint32_t>::max)();
+    count = 0;
+    std::uint64_t declared = 0;
+    if (!read(blob, descriptor, declared) || declared > kMaximumCount) {
+        return false;
+    }
+    count = static_cast<std::uint32_t>(declared);
+    return true;
+}
+
+/**
+ * Reads the whole installed vendor index.
+ * @param source Package directory and borrowed block keys.
+ * @param scratch Lock-owned block storage.
+ * @param storage Pass storage receiving the index rows.
+ * @return True when the index blob reads and every row fits.
+ */
+[[nodiscard]] bool
+read_index(const reader::Source& source, reader::Scratch& scratch, Storage& storage) noexcept {
+    std::uint32_t classId = 0;
+    tables::Array array{};
+    if (!reader::read_tag(source, scratch, kIndexRootTag, storage.blob, classId)
+        || classId != domain::kIndexWrapperClass) {
+        return false;
+    }
+    const std::span<const std::byte> blob{storage.blob};
+    if (!tables::find_array_at(blob, tables::kTableArrayDescriptor, array)
+        || array.elementClass != domain::kIndexRowClass || array.count > domain::kIndexCapacity) {
+        return false;
+    }
+    for (std::uint64_t row = 0; row < array.count; ++row) {
+        tables::IndexRow entry{};
+        if (!tables::index_row(blob, array, row, entry)) {
+            return false;
+        }
+        storage.index[storage.indexCount] = {
+            entry.definitionHash, entry.targetTag, static_cast<std::uint16_t>(row)};
+        ++storage.indexCount;
+    }
+    return storage.indexCount != 0;
+}
+
+/**
+ * Reads every sale row of one definition into the flat bank.
+ * @param blob Whole definition blob.
+ * @param definition Definition whose sale array was already resolved.
+ * @param storage Pass storage receiving the rows.
+ * @return True when every row is inside the blob and the bank holds them all.
+ */
+[[nodiscard]] bool read_sale_rows(std::span<const std::byte> blob,
+                                  const domain::Definition& definition,
+                                  Storage& storage) noexcept {
+    if (definition.saleCount > domain::kSaleRowCapacity - storage.saleRowCount) {
+        return false;
+    }
+    for (std::size_t row = 0; row < definition.saleCount; ++row) {
+        const std::size_t at = definition.saleRowBase + (row * domain::kSaleRowStride);
+        domain::SaleRow& value = storage.saleRows[storage.saleRowCount + row];
+        value = {};
+        value.vendorIndex = definition.index;
+        value.rowIndex = static_cast<std::uint16_t>(row);
+        if (!read(blob, at + kSaleItemIndexOffset, value.itemIndex)
+            || !read(blob, at + kSaleSecondaryItemOffset, value.secondaryItemIndex)
+            || !read(blob, at + kSaleInstalledIndexOffset, value.installedIndex)
+            || !read(blob, at + kSaleRaw104Offset, value.raw104)
+            || !read(blob, at + kSaleRaw108Offset, value.raw108)
+            || !read(blob, at + kSaleRaw172Offset, value.raw172)
+            || !read(blob, at + kSaleFeatureBranchOffset, value.featureBranch)
+            || !read_count(blob, at + kSaleExpression8Offset, value.expressionCount8)
+            || !read_count(blob, at + kSaleNestedRecordOffset, value.nestedRecordCount)
+            || !read_count(blob, at + kSaleExpression120Offset, value.expressionCount120)
+            || !read_count(blob, at + kSaleCount136Offset, value.count136)
+            || !read_count(blob, at + kSaleExpression160Offset, value.expressionCount160)) {
+            return false;
+        }
+    }
+    storage.saleRowCount += definition.saleCount;
+    return true;
+}
+
+/**
+ * Reads every installed row of one definition into the flat bank, whole.
+ * @param blob Whole definition blob.
+ * @param definition Definition whose installed array was already resolved.
+ * @param storage Pass storage receiving the rows.
+ * @return True when every row is inside the blob and the bank holds them all.
+ */
+[[nodiscard]] bool read_installed_rows(std::span<const std::byte> blob,
+                                       const domain::Definition& definition,
+                                       Storage& storage) noexcept {
+    if (definition.installedCount > domain::kInstalledRowCapacity - storage.installedRowCount) {
+        return false;
+    }
+    for (std::size_t row = 0; row < definition.installedCount; ++row) {
+        const std::size_t at = definition.installedRowBase + (row * domain::kInstalledRowStride);
+        domain::InstalledRow& value = storage.installedRows[storage.installedRowCount + row];
+        value = {};
+        value.vendorIndex = definition.index;
+        value.rowIndex = static_cast<std::uint16_t>(row);
+        if (!read(blob, at, value.raw)) {
+            return false;
+        }
+    }
+    storage.installedRowCount += definition.installedCount;
+    return true;
+}
+
+/**
+ * Reads one vendor definition and both of its row arrays.
+ * @param source Package directory and borrowed block keys.
+ * @param scratch Lock-owned block storage.
+ * @param entry Index row naming the definition.
+ * @param storage Pass storage receiving the definition and its rows.
+ * @return True when the definition blob reads and every array ends inside it.
+ */
+[[nodiscard]] bool read_definition(const reader::Source& source,
+                                   reader::Scratch& scratch,
+                                   const domain::IndexEntry& entry,
+                                   Storage& storage) noexcept {
+    /** Definition sizes are stored as unsigned 32-bit values. */
+    constexpr std::size_t kMaximumSize = (std::numeric_limits<std::uint32_t>::max)();
+    std::uint32_t classId = 0;
+    if (storage.definitionCount == domain::kDefinitionCapacity
+        || !reader::read_tag(source, scratch, entry.definitionTag, storage.blob, classId)
+        || classId != domain::kDefinitionClass || storage.blob.size() > kMaximumSize) {
+        return false;
+    }
+    const std::span<const std::byte> blob{storage.blob};
+    ArrayView installed{};
+    ArrayView sale{};
+    ArrayView third{};
+    if (!read_array(blob, kInstalledArrayDescriptor, domain::kInstalledRowStride, installed)
+        || !read_array(blob, kSaleArrayDescriptor, domain::kSaleRowStride, sale)
+        || !read_array(blob, kThirdArrayDescriptor, domain::kThirdRowStride, third)) {
+        return false;
+    }
+    domain::Definition definition{};
+    definition.definitionHash = entry.definitionHash;
+    definition.definitionTag = entry.definitionTag;
+    definition.definitionClass = classId;
+    definition.definitionSize = static_cast<std::uint32_t>(blob.size());
+    definition.index = entry.index;
+    definition.installedRowBase = installed.base;
+    definition.installedRowClass = installed.classId;
+    definition.installedCount = installed.count;
+    definition.saleRowBase = sale.base;
+    definition.saleRowClass = sale.classId;
+    definition.saleCount = sale.count;
+    definition.thirdRowBase = third.base;
+    definition.thirdRowClass = third.classId;
+    definition.thirdCount = third.count;
+    definition.saleRowOffset = static_cast<std::uint32_t>(storage.saleRowCount);
+    definition.installedRowOffset = static_cast<std::uint32_t>(storage.installedRowCount);
+    if (!read(blob, kResetIntervalOffset, definition.resetIntervalRaw)
+        || !read(blob, kResetPhaseOffset, definition.resetPhaseRaw)
+        || !read_sale_rows(blob, definition, storage)
+        || !read_installed_rows(blob, definition, storage)) {
+        return false;
+    }
+    storage.definitions[storage.definitionCount] = definition;
+    ++storage.definitionCount;
+    return true;
+}
+
+/** @param hashes Requested hashes. @param hash Index row hash. @return True when requested. */
+[[nodiscard]] bool requested(std::span<const std::uint32_t> hashes, std::uint32_t hash) noexcept {
+    for (const std::uint32_t value : hashes) {
+        if (value == hash) {
+            return true;
+        }
+    }
+    return false;
+}
+
+/**
+ * Reports the pass so a boot with no vendor catalog says which step lost the rows.
+ * @param storage Pass storage holding every count.
+ * @param result Outcome text for the log line.
+ */
+void report(const Storage& storage, const char* result) noexcept {
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=build_data stage=vendors index=%zu definitions=%zu "
+                                      "sale=%zu installed=%zu result=%s",
+                                      storage.indexCount,
+                                      storage.definitionCount,
+                                      storage.saleRowCount,
+                                      storage.installedRowCount,
+                                      result);
+    if (written > 0) {
+        core::log::write(core::log::Channel::state,
+                         storage.indexCount != 0 ? core::log::Level::info : core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+} // namespace
+
+/** Extracts and publishes the vendor catalog from the installed packages. */
+bool build(const reader::Source& source,
+           reader::Scratch& scratch,
+           std::span<const std::uint32_t> definitionHashes) noexcept {
+    if (state::build_data::vendor_catalog_ready()) {
+        return true;
+    }
+    static Storage storage{};
+    storage = {};
+    if (!read_index(source, scratch, storage)) {
+        report(storage, "index");
+        return false;
+    }
+    // Walking the index in order gives the ascending definition order the catalog requires.
+    for (std::size_t row = 0; row < storage.indexCount; ++row) {
+        const domain::IndexEntry entry = storage.index[row];
+        if (requested(definitionHashes, entry.definitionHash)
+            && !read_definition(source, scratch, entry, storage)) {
+            report(storage, "definition");
+            return false;
+        }
+    }
+    const bool published = state::build_data::publish_vendor_catalog(
+        std::span(storage.index).first(storage.indexCount),
+        std::span(storage.definitions).first(storage.definitionCount),
+        std::span(storage.saleRows).first(storage.saleRowCount),
+        std::span(storage.installedRows).first(storage.installedRowCount));
+    report(storage, published ? "ok" : "publish");
+    return published;
+}
+
+} // namespace sunrise::client::content::vendors

+ 22 - 0
Sunrise/src/client/content/vendors/vendor_build.h

@@ -0,0 +1,22 @@
+#pragma once
+
+#include <cstdint>
+#include <span>
+
+#include "../../../middleware/content/packages/reader/reader.h"
+
+namespace sunrise::client::content::vendors {
+
+/**
+ * Extracts the vendor catalog from the installed packages, once.
+ * The whole index is read. A definition is read only when asked for, as each is over 100 KiB.
+ * @param source Package directory and borrowed block keys.
+ * @param scratch Lock-owned block storage shared with the other content passes.
+ * @param definitionHashes Vendor definition hashes to read definitions for.
+ * @return True when State already holds the catalog or a full pass publishes it.
+ */
+[[nodiscard]] bool build(const middleware::content::packages::reader::Source& source,
+                         middleware::content::packages::reader::Scratch& scratch,
+                         std::span<const std::uint32_t> definitionHashes) noexcept;
+
+} // namespace sunrise::client::content::vendors

+ 96 - 16
Sunrise/src/client/hooks/assert_handler/assert_handler_observer.cpp

@@ -1,12 +1,15 @@
 #include "assert_handler_observer.h"
 
+#include <Windows.h>
+
 #include <array>
-#include <atomic>
 #include <cstdarg>
+#include <cstdint>
 #include <cstdio>
 #include <cstring>
 
 #include "../../../core/logging/log.h"
+#include "../../targets/game/assert_handler.h"
 
 namespace sunrise::client::hooks::assert_handler {
 namespace {
@@ -15,26 +18,77 @@ namespace {
 constexpr std::size_t kTextCapacity = 1024;
 /** One log line carries the message plus its fixed prefix. */
 constexpr std::size_t kLineCapacity = 1152;
-/** A per-frame assert must not fill the log; later hits are counted only. */
-constexpr std::uint32_t kReportLimit = 200;
+/** Consecutive repeats of one message written in full before counting takes over. */
+constexpr std::uint32_t kRepeatHead = 8;
+/** One repeat in this many is written after that, so a stuck assert never goes silent. */
+constexpr std::uint32_t kRepeatStride = 512;
 /** Used when the game's own format string cannot be printed. */
 constexpr char kUnformattable[] = "<unformattable>";
 
-std::atomic<std::uint32_t> g_reports{};
+/**
+ * Halt category of the graphics device-loss assert. A removed device never returns, so this one
+ * must keep halting the process. Every other category stays non-fatal.
+ */
+constexpr int kGraphicsHaltCategory = 6;
+
+/** The handler the game installed, called with the same printf-style arguments the sites use. */
+using NativeHandler = void(__cdecl*)(int, const char*, ...);
+
+SRWLOCK g_lock{SRWLOCK_INIT};
+/** Last message seen, so a message that repeats every frame is counted rather than written. */
+std::array<char, kTextCapacity> g_lastText{};
+std::uint32_t g_repeats{};
+std::uint32_t g_seen{};
+
+/** @param code Halt category from the assert site. @return True when the process must still die. */
+[[nodiscard]] bool unrecoverable(int code) noexcept {
+    return code == kGraphicsHaltCategory;
+}
+
+/**
+ * Counts one message and says whether this occurrence is written.
+ * @param text Formatted assert message.
+ * @param seen Receives the total assert count.
+ * @param repeats Receives the length of the current run of this message.
+ * @return True when the caller writes a log line.
+ */
+[[nodiscard]] bool admit(const char* text, std::uint32_t& seen, std::uint32_t& repeats) noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    ++g_seen;
+    if (std::strcmp(g_lastText.data(), text) == 0) {
+        ++g_repeats;
+    } else {
+        const std::size_t length = std::strlen(text);
+        const std::size_t copied = length < kTextCapacity ? length : kTextCapacity - 1;
+        std::memcpy(g_lastText.data(), text, copied);
+        g_lastText[copied] = '\0';
+        g_repeats = 1;
+    }
+    seen = g_seen;
+    repeats = g_repeats;
+    ReleaseSRWLockExclusive(&g_lock);
+    return repeats <= kRepeatHead || repeats % kRepeatStride == 0;
+}
 
 /**
  * Records one assert without letting logging failures reach the game.
- * @param code Native first argument, always zero at the observed sites.
+ * @param code Halt category the assert site passes. Zero, one and six all occur.
  * @param text Already-formatted assert message.
  */
 void report(int code, const char* text) noexcept {
-    const std::uint32_t seen = g_reports.fetch_add(1, std::memory_order_relaxed) + 1;
-    if (seen > kReportLimit) {
+    std::uint32_t seen = 0;
+    std::uint32_t repeats = 0;
+    if (!admit(text, seen, repeats)) {
         return;
     }
     std::array<char, kLineCapacity> line{};
-    const int written = std::snprintf(
-        line.data(), line.size(), "ev=assert stage=hit n=%u arg0=%d text=%s", seen, code, text);
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=assert stage=hit n=%u repeat=%u arg0=%d text=%s",
+                                      seen,
+                                      repeats,
+                                      code,
+                                      text);
     if (written <= 0) {
         return;
     }
@@ -42,17 +96,38 @@ void report(int code, const char* text) noexcept {
                             ? static_cast<std::size_t>(written)
                             : line.size() - 1;
     core::log::write(core::log::Channel::client, core::log::Level::error, {line.data(), length});
-    if (seen == kReportLimit) {
-        core::log::write(
-            core::log::Channel::client, core::log::Level::warn, "ev=assert stage=cap result=ok");
+}
+
+/**
+ * Hands one assert back to the handler the game installed, which halts the process.
+ * @param code Halt category from the assert site.
+ * @param text Message this handler already formatted.
+ */
+void chain(int code, const char* text) noexcept {
+    if (!targets::game::assert_handler::is_resolved()) {
+        return;
+    }
+    const targets::game::assert_handler::Targets& resolved = targets::game::assert_handler::get();
+    if (resolved.original == nullptr) {
+        return;
     }
+    std::array<char, 64> line{};
+    const int written =
+        std::snprintf(line.data(), line.size(), "ev=assert stage=halt arg0=%d result=native", code);
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::error,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+    // The original is printf-style too, so the text already built is passed as its one argument.
+    reinterpret_cast<NativeHandler>(resolved.original)(code, "%s", text);
 }
 
 /**
- * Replacement assert handler. The sites call this slot as a printf-style callback, and returning
- * without calling the game's own handler is what makes the assert non-fatal: the shipped handler
- * builds a crash ticket, shows a dialog and blocks.
- * @param code Native first argument.
+ * Replacement assert handler. The sites call this slot as a printf-style callback. Returning
+ * without calling the game's own handler is what makes the assert non-fatal. That handler builds
+ * a crash ticket, shows a dialog and blocks.
+ * @param code Halt category from the assert site.
  * @param format Native printf-style format string.
  */
 void __cdecl handler_body(int code, const char* format, ...) noexcept {
@@ -77,6 +152,11 @@ void __cdecl handler_body(int code, const char* format, ...) noexcept {
     }
     inside = true;
     report(code, text.data());
+    // Swallowing an unrecoverable halt leaves the process running with nothing to present. The
+    // original handler runs so the process dies the way the game intends.
+    if (unrecoverable(code)) {
+        chain(code, text.data());
+    }
     inside = false;
 }
 

+ 12 - 8
Sunrise/src/client/hooks/bootflow/region_private.cpp

@@ -7,6 +7,7 @@
 #include <string_view>
 
 #include "../../../core/logging/log.h"
+#include "../../../core/settings/settings.h"
 #include "../../hooking/detour.h"
 #include "internal.h"
 
@@ -86,10 +87,11 @@ std::atomic<unsigned> g_forced{0};
 }
 
 /**
- * Emits one forcing event while the per-run budget lasts.
- * @param sliceSet Slice-set index whose bubble was reported private.
+ * Emits one decision event while the per-run budget lasts. Only a public bubble reaches here.
+ * @param sliceSet Slice-set index whose bubble the reader called public.
+ * @param forced True when the answer was replaced, false when the region stays public.
  */
-void report(std::uint32_t sliceSet) noexcept {
+void report(std::uint32_t sliceSet, bool forced) noexcept {
     // One atomic claim per line, so a concurrent transition cannot reuse a budget slot.
     if (g_forced.fetch_add(1, std::memory_order_relaxed) >= kMaxReports) {
         return;
@@ -97,7 +99,8 @@ void report(std::uint32_t sliceSet) noexcept {
     std::array<char, kLineCapacity> line{};
     const int written = std::snprintf(line.data(),
                                       line.size(),
-                                      "ev=bootflow stage=region result=forced slice_set=%u",
+                                      "ev=bootflow stage=region result=%s slice_set=%u",
+                                      forced ? "forced" : "public",
                                       static_cast<unsigned>(sliceSet));
     if (written > 0) {
         core::log::write(core::log::Channel::client,
@@ -108,8 +111,8 @@ void report(std::uint32_t sliceSet) noexcept {
 
 /**
  * Reports a bubble as private, for the region transition's own call only.
- * The starter stores this result in the manager's public flag. A public region then holds its
- * slice-set switch until a public activity host connects, which solo play never gets.
+ * A public region holds its slice-set switch until a public activity host connects.
+ * `client.region_private` off keeps the public answer, so the citizen join can run.
  * @return False on the starter's call, otherwise the reader's own answer.
  */
 __declspec(noinline) bool __fastcall reader(std::uint32_t sliceSet) noexcept {
@@ -125,8 +128,9 @@ __declspec(noinline) bool __fastcall reader(std::uint32_t sliceSet) noexcept {
     if (caller != g_returnSite.load(std::memory_order_acquire)) {
         return true;
     }
-    report(sliceSet);
-    return false;
+    const bool forced = core::settings::get().client.regionPrivate;
+    report(sliceSet, forced);
+    return !forced;
 }
 
 /** @param reason Key naming the step that failed. @return False, for a direct return. */

+ 5 - 2
Sunrise/src/client/hooks/graphics/renderer/graphics_renderer_report.cpp

@@ -23,8 +23,9 @@ constexpr std::array<std::string_view, 6> kStageNames{
     "shutdown",
 };
 
-/** One name per Reason value, in Reason order. */
-constexpr std::array<std::string_view, 18> kReasonNames{
+/** One name per Reason value, in Reason order. One per line to match the enum. */
+// clang-format off
+constexpr std::array<std::string_view, 19> kReasonNames{
     "none",
     "window",
     "window_thread",
@@ -32,6 +33,7 @@ constexpr std::array<std::string_view, 18> kReasonNames{
     "description",
     "device",
     "context",
+    "device_removed",
     "back_buffer",
     "view",
     "view_format",
@@ -44,6 +46,7 @@ constexpr std::array<std::string_view, 18> kReasonNames{
     "rebuild_target",
     "surface_lost",
 };
+// clang-format on
 
 /** Both tables are indexed by the enum value, so a new entry must extend them. */
 static_assert(kStageNames.size() == static_cast<std::size_t>(Stage::shutdown) + 1);

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

@@ -22,6 +22,7 @@ enum class Reason : unsigned {
     description,
     device,
     context,
+    deviceRemoved,
     // target
     backBuffer,
     view,

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

@@ -119,6 +119,13 @@ bool acquire(IDXGISwapChain* swapChain, Resources& output) noexcept {
         release_resources(staged);
         return false;
     }
+    if (FAILED(staged.device->GetDeviceRemovedReason())) {
+        // A removed device never comes back, so every later step fails against it. The rebuild
+        // is refused here rather than at the render target.
+        report::note(report::Stage::acquire, report::Reason::deviceRemoved);
+        release_resources(staged);
+        return false;
+    }
     staged.device->GetImmediateContext(&staged.context);
     if (staged.context == nullptr) {
         report::note(report::Stage::acquire, report::Reason::context);

+ 129 - 13
Sunrise/src/client/hooks/queuez/family0/family0_source_seed.cpp

@@ -1,9 +1,12 @@
 #include "family0_source_seed.h"
 
+#include <Windows.h>
+
 #include <array>
 #include <atomic>
 #include <cstddef>
 #include <cstdint>
+#include <cstdio>
 #include <cstring>
 
 #include "../../../../core/logging/log.h"
@@ -19,12 +22,22 @@ struct ManagerLayout {
 
 /** Fields of the family-zero source list. The sweep diffs the whole fixed-size record. */
 struct SourceListLayout {
-    /** Entry count, held twice. The sweep treats both as the list length. */
+    /**
+     * Members the producer counted with flags bit 0 or bit 4. Bit 4 is set by every upsert, so
+     * this moves even when nothing is emitted.
+     */
     static constexpr std::size_t countA = 0;
+    /**
+     * Entries the producer wrote with a non-zero soid. It moves once per emitted entry, so it is
+     * the exact length.
+     */
     static constexpr std::size_t countB = 8;
     /** First entry, {u64 key, u16 mask}. */
     static constexpr std::size_t firstKey = 16;
     static constexpr std::size_t firstMask = 24;
+    /** Second entry. Two entries naming one soid is what gives the family two records. */
+    static constexpr std::size_t secondKey = 32;
+    static constexpr std::size_t secondMask = 40;
 };
 
 /**
@@ -37,10 +50,109 @@ constexpr std::uint64_t kSeededCount = 1;
 /** Bytes of the source list the producer owns, rebuilt whole so no stale entry survives. */
 constexpr std::size_t kSourceListBytes = 0x210;
 
+/** Shortest gap between two seed reports. The sweep runs per frame, so this bounds the volume. */
+constexpr std::uint64_t kReportIntervalMs = 1'000;
+/** One line carries the replaced key and the rewrite count. */
+constexpr std::size_t kReportLimit = 128;
+/** One line carries both counters and the first two entries. */
+constexpr std::size_t kListReportLimit = 192;
+
+/** The producer's own output, as the seed found it before writing anything. */
+struct ListHead {
+    std::uint64_t countA{};
+    std::uint64_t countB{};
+    std::uint64_t firstKey{};
+    std::uint64_t secondKey{};
+    std::uint16_t firstMask{};
+    std::uint16_t secondMask{};
+};
+
+/** @return True when both heads hold the same values. */
+[[nodiscard]] bool equal(const ListHead& first, const ListHead& second) noexcept {
+    return first.countA == second.countA && first.countB == second.countB
+           && first.firstKey == second.firstKey && first.secondKey == second.secondKey
+           && first.firstMask == second.firstMask && first.secondMask == second.secondMask;
+}
+
 std::atomic<SourceList> g_sourceList{nullptr};
 /** The account key, read from the manager rather than written by us, so it cannot disagree. */
 std::atomic<std::uint64_t> g_accountKey{0};
-std::atomic_bool g_reported{false};
+/** Total rewrites. A rewrite means something else put its own list back. */
+std::atomic<std::uint64_t> g_rewrites{0};
+std::atomic<std::uint64_t> g_reportDueTick{0};
+/** The last head reported, so the producer's output is logged on change rather than per frame. */
+ListHead g_lastHead{};
+bool g_hasLastHead{false};
+
+/**
+ * Reports the producer's own output whenever it changes.
+ * @param head Source-list head as the seed found it.
+ */
+void report_list(const ListHead& head) noexcept {
+    if (g_hasLastHead && equal(head, g_lastHead)) {
+        return;
+    }
+    g_lastHead = head;
+    g_hasLastHead = true;
+    std::array<char, kListReportLimit> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=queuez stage=family0_list countA=%llu countB=%llu "
+                                      "first=0x%016llX/%u second=0x%016llX/%u",
+                                      static_cast<unsigned long long>(head.countA),
+                                      static_cast<unsigned long long>(head.countB),
+                                      static_cast<unsigned long long>(head.firstKey),
+                                      static_cast<unsigned>(head.firstMask),
+                                      static_cast<unsigned long long>(head.secondKey),
+                                      static_cast<unsigned>(head.secondMask));
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Copies the source-list head the producer left behind.
+ * @param list Borrowed source list.
+ * @return Its counters and first two entries.
+ */
+[[nodiscard]] ListHead read_head(const std::byte* list) noexcept {
+    ListHead head{};
+    std::memcpy(&head.countA, list + SourceListLayout::countA, sizeof head.countA);
+    std::memcpy(&head.countB, list + SourceListLayout::countB, sizeof head.countB);
+    std::memcpy(&head.firstKey, list + SourceListLayout::firstKey, sizeof head.firstKey);
+    std::memcpy(&head.firstMask, list + SourceListLayout::firstMask, sizeof head.firstMask);
+    std::memcpy(&head.secondKey, list + SourceListLayout::secondKey, sizeof head.secondKey);
+    std::memcpy(&head.secondMask, list + SourceListLayout::secondMask, sizeof head.secondMask);
+    return head;
+}
+
+/**
+ * Reports how often the seed is having to put the account key back.
+ * A steady rewrite rate means the game's own producer is rebuilding the list underneath.
+ * @param replaced Key that was in the list before this rewrite.
+ */
+void report_seed(std::uint64_t replaced) noexcept {
+    const std::uint64_t count = g_rewrites.fetch_add(1, std::memory_order_relaxed) + 1;
+    const std::uint64_t now = GetTickCount64();
+    if (now < g_reportDueTick.load(std::memory_order_relaxed)) {
+        return;
+    }
+    g_reportDueTick.store(now + kReportIntervalMs, std::memory_order_relaxed);
+    std::array<char, kReportLimit> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=queuez stage=family0 result=seeded replaced=0x%016llX "
+                                      "rewrites=%llu",
+                                      static_cast<unsigned long long>(replaced),
+                                      static_cast<unsigned long long>(count));
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
 
 } // namespace
 
@@ -72,11 +184,16 @@ void seed_source_list() noexcept {
     if (list == nullptr) {
         return;
     }
-    std::uint64_t existing = 0;
-    std::memcpy(&existing, list + SourceListLayout::firstKey, sizeof existing);
-    if (existing == key) {
-        return; // Already ours. Rewriting the same bytes would only churn the subscribe.
+    // Read before the early return, so the producer's own output is visible once it starts working.
+    const ListHead head = read_head(list);
+    report_list(head);
+    // Both counters have to agree, not just the first key. A counter mismatch arms a boot
+    // deadline in the sweep, and rewriting the whole buffer clears it.
+    if (head.firstKey == key && head.countA == kSeededCount && head.countB == kSeededCount
+        && head.secondKey == 0) {
+        return; // Already exactly ours. Rewriting the same bytes would only churn the subscribe.
     }
+    const std::uint64_t existing = head.firstKey;
     // The whole buffer is rebuilt, so an entry the producer left past the first cannot survive
     // as a key the sweep would go on to subscribe.
     std::array<std::byte, kSourceListBytes> seeded{};
@@ -85,18 +202,17 @@ void seed_source_list() noexcept {
     std::memcpy(seeded.data() + SourceListLayout::firstKey, &key, sizeof key);
     std::memcpy(seeded.data() + SourceListLayout::firstMask, &kEntryMask, sizeof kEntryMask);
     std::memcpy(list, seeded.data(), seeded.size());
-    if (!g_reported.exchange(true, std::memory_order_relaxed)) {
-        core::log::write(core::log::Channel::client,
-                         core::log::Level::info,
-                         "ev=queuez stage=family0 result=seeded");
-    }
+    report_seed(existing);
 }
 
-/** Clears the captured key, the published getter, and the one-shot report. */
+/** Clears the captured key, the published getter, and the report state. */
 void reset() noexcept {
     g_sourceList.store(nullptr, std::memory_order_release);
     g_accountKey.store(0, std::memory_order_release);
-    g_reported.store(false, std::memory_order_release);
+    g_rewrites.store(0, std::memory_order_release);
+    g_reportDueTick.store(0, std::memory_order_release);
+    g_lastHead = {};
+    g_hasLastHead = false;
 }
 
 } // namespace sunrise::client::hooks::queuez::family0

+ 19 - 2
Sunrise/src/client/hooks/queuez/family0_subscription.cpp

@@ -1,6 +1,8 @@
+#include <array>
 #include <atomic>
 #include <cstddef>
 #include <cstdint>
+#include <cstdio>
 #include <string_view>
 
 #include "../../../core/logging/log.h"
@@ -39,6 +41,8 @@ constexpr auto kFamily0SweepSignature =
 
 /** Signed displacement bytes of a near call, decoded to reach the source-list getter. */
 constexpr std::ptrdiff_t kRelativeOperandSize = 4;
+/** One line carries the two resolved addresses and nothing else. */
+constexpr std::size_t kTargetReportLimit = 96;
 
 using FamilySweep = std::int64_t(__fastcall*)(std::byte*);
 using Family0Sweep = void(__fastcall*)(std::byte*);
@@ -80,8 +84,21 @@ __declspec(noinline) std::int64_t __fastcall family_sweep(std::byte* manager) no
         return false;
     }
     std::byte* const operand = target + kFamily0SweepSignature.size();
-    family0::publish_source_list(reinterpret_cast<family0::SourceList>(
-        resolve_relative(operand, operand + kRelativeOperandSize)));
+    void* const getter = resolve_relative(operand, operand + kRelativeOperandSize);
+    family0::publish_source_list(reinterpret_cast<family0::SourceList>(getter));
+    // Two family-zero sweeps share this prologue shape, and only one subscribes and frees
+    // records. The resolved addresses say which one the scan picked.
+    std::array<char, kTargetReportLimit> line{};
+    const int count = std::snprintf(line.data(),
+                                    line.size(),
+                                    "ev=queuez stage=family0_target sweep=0x%llX getter=0x%llX",
+                                    reinterpret_cast<unsigned long long>(target),
+                                    reinterpret_cast<unsigned long long>(getter));
+    if (count > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
     const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&family0_sweep)};
     return hooking::detour::install(spec, g_family0Handle);
 }

+ 14 - 4
Sunrise/src/client/hooks/retail_log/retail_log_enqueue_observer.cpp

@@ -21,15 +21,17 @@ constexpr std::size_t kNativeTextSize = 320;
 constexpr std::int32_t kUnregisteredSite = -1;
 /** Line storage holds the cleaned text plus its fixed key prefix. */
 constexpr std::size_t kEventCapacity = kNativeTextSize + 64;
-/** A late config load can reset all category thresholds, so set them again now and then. */
-constexpr unsigned kReassertInterval = 512;
+/** A late config load resets the thresholds, so set them again on this period. A count will not
+ *  do: a closed category emits fewer lines, so it advances slower and stays closed. */
+constexpr std::uint64_t kReassertIntervalMs = 2'000;
 /** How many categories the game's own verbosity table holds. */
 constexpr std::uint32_t kCategoryCount = 26;
 /** 0 is the game's loosest category threshold. A higher value logs less. */
 constexpr std::uint32_t kMostVerbose = 0;
 
 thread_local bool g_inObserver{};
-volatile LONG g_sinceAssert{};
+/** Tick at which the next re-assert is due. Zero makes the first call assert. */
+volatile LONG64 g_nextAssertTick{};
 
 /**
  * Copies the native text into fixed storage as one printable line.
@@ -119,7 +121,15 @@ void assert_verbosity() noexcept {
     if (!core::log::accepts(core::log::Channel::client, core::log::Level::debug)) {
         return;
     }
-    if (InterlockedIncrement(&g_sinceAssert) % kReassertInterval != 1) {
+    const auto now = static_cast<LONG64>(GetTickCount64());
+    const LONG64 due = g_nextAssertTick;
+    if (now < due) {
+        return;
+    }
+    // One claim per period, so concurrent funnel threads do not all reopen the table.
+    if (InterlockedCompareExchange64(
+            &g_nextAssertTick, now + static_cast<LONG64>(kReassertIntervalMs), due)
+        != due) {
         return;
     }
     const auto setter = reinterpret_cast<SetCategoryVerbosity>(

+ 18 - 1
Sunrise/src/core/logging/log.cpp

@@ -4,6 +4,7 @@
 
 #include <array>
 #include <atomic>
+#include <cstdio>
 #include <cstring>
 
 #include "../filesystem/path.h"
@@ -30,11 +31,15 @@ constexpr std::size_t kLineTerminatorBytes = 1;
 /** Event text stops before the CRLF and the trailing null. */
 constexpr std::size_t kEventTextCapacity =
     kLineCapacity - kLineEnding.size() - kLineTerminatorBytes;
+/** Longest " t=" field: the key plus a 64-bit millisecond count. */
+constexpr std::size_t kStampCapacity = 32;
 
 struct LogState {
     SRWLOCK lock{SRWLOCK_INIT};
     std::array<std::atomic<Level>, static_cast<std::size_t>(Channel::count)> levels{};
     HANDLE file{INVALID_HANDLE_VALUE};
+    /** Tick the sinks opened on. Every line carries its offset from this, so stalls are visible. */
+    ULONGLONG startTick{};
     bool debuggerSink{};
     bool initialized{};
 };
@@ -132,6 +137,7 @@ bool initialize(void* module, const Settings& settings) noexcept {
     }
     g_log.initialized = false;
     g_log.debuggerSink = settings.debuggerSink;
+    g_log.startTick = GetTickCount64();
     for (std::size_t index = 0; index < g_log.levels.size(); ++index) {
         g_log.levels[index].store(settings.levels[index], std::memory_order_relaxed);
     }
@@ -201,11 +207,22 @@ void write(Channel channel, Level level, std::string_view event) noexcept {
         return;
     }
 
+    std::array<char, kStampCapacity> stamp{};
+    const int stamped =
+        std::snprintf(stamp.data(),
+                      stamp.size(),
+                      " t=%llu ",
+                      static_cast<unsigned long long>(GetTickCount64() - g_log.startTick));
+
     std::array<char, kLineCapacity> line{};
     std::size_t length = append(line, 0, kChannelNames[channelIndex], kEventTextCapacity);
     length = append(line, length, " level=", kEventTextCapacity);
     length = append(line, length, kLevelNames[levelIndex], kEventTextCapacity);
-    length = append(line, length, " ", kEventTextCapacity);
+    length = append(line,
+                    length,
+                    stamped > 0 ? std::string_view(stamp.data(), static_cast<std::size_t>(stamped))
+                                : std::string_view(" "),
+                    kEventTextCapacity);
     length = append(line, length, event, kEventTextCapacity);
     const std::size_t snapshotLength = length;
     std::memcpy(line.data() + length, kLineEnding.data(), kLineEnding.size());

+ 65 - 0
Sunrise/src/core/settings/address_text.cpp

@@ -0,0 +1,65 @@
+#include "address_text.h"
+
+namespace sunrise::core::settings::address {
+
+namespace {
+
+/** Largest value one IPv4 octet can hold. */
+constexpr unsigned int kMaximumOctet = 255;
+/** Longest decimal form of one octet. */
+constexpr std::size_t kMaximumDigits = 3;
+/** A dotted quad has 3 separators. */
+constexpr std::size_t kSeparators = kOctets - 1;
+/** Decimal base of the octet text. */
+constexpr unsigned int kDecimalBase = 10;
+
+/**
+ * Reads one decimal octet.
+ * @param text Complete dotted quad.
+ * @param position Advanced past the digits that were read.
+ * @param output Receives the octet value.
+ * @return True when 1 to 3 digits form a value of at most 255.
+ */
+[[nodiscard]] bool
+read_octet(std::string_view text, std::size_t& position, unsigned char& output) noexcept {
+    unsigned int value = 0;
+    std::size_t digits = 0;
+    while (position < text.size() && text[position] >= '0' && text[position] <= '9') {
+        value = value * kDecimalBase + static_cast<unsigned int>(text[position] - '0');
+        ++position;
+        ++digits;
+        if (digits > kMaximumDigits || value > kMaximumOctet) {
+            return false;
+        }
+    }
+    if (digits == 0) {
+        return false;
+    }
+    output = static_cast<unsigned char>(value);
+    return true;
+}
+
+} // namespace
+
+/** Splits an IPv4 dotted quad into octets. */
+bool parse_ipv4(std::string_view text, std::array<unsigned char, kOctets>& output) noexcept {
+    std::array<unsigned char, kOctets> octets{};
+    std::size_t position = 0;
+    for (std::size_t index = 0; index < octets.size(); ++index) {
+        if (!read_octet(text, position, octets[index])) {
+            return false;
+        }
+        const bool separator = index < kSeparators;
+        if (separator && (position >= text.size() || text[position] != '.')) {
+            return false;
+        }
+        position += separator ? 1 : 0;
+    }
+    if (position != text.size()) {
+        return false;
+    }
+    output = octets;
+    return true;
+}
+
+} // namespace sunrise::core::settings::address

+ 22 - 0
Sunrise/src/core/settings/address_text.h

@@ -0,0 +1,22 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <string_view>
+
+namespace sunrise::core::settings::address {
+
+/** Octets in one IPv4 address. */
+inline constexpr std::size_t kOctets = 4;
+
+/**
+ * Splits an IPv4 dotted quad into octets.
+ * A host name is refused because every consumer forwards this text as a numeric address.
+ * @param text Complete address text with no surrounding space.
+ * @param output Receives the octets in dotted-quad order only on success.
+ * @return True when the whole text is four decimal octets separated by dots.
+ */
+[[nodiscard]] bool parse_ipv4(std::string_view text,
+                              std::array<unsigned char, kOctets>& output) noexcept;
+
+} // namespace sunrise::core::settings::address

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

@@ -12,6 +12,7 @@ bool Parser::client_settings(client::Settings& output) noexcept {
     bool hasExternalServer = false;
     bool hasFadeRelease = false;
     bool hasForceJoinRequestReady = false;
+    bool hasRegionPrivate = false;
     bool hasPinReplicatedRecord = false;
     bool hasHoldSpawn = false;
     bool hasSpawnHoldMs = false;
@@ -43,6 +44,11 @@ bool Parser::client_settings(client::Settings& output) noexcept {
                 return false;
             }
             hasForceJoinRequestReady = true;
+        } else if (key == "region_private") {
+            if (hasRegionPrivate || !boolean(candidate.regionPrivate)) {
+                return false;
+            }
+            hasRegionPrivate = true;
         } else if (key == "pin_replicated_record") {
             if (hasPinReplicatedRecord || !boolean(candidate.pinReplicatedRecord)) {
                 return false;

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

@@ -29,6 +29,12 @@ struct Settings {
      * Two of its five terms are client flags no host message reaches, so the host cannot open it.
      */
     bool forceJoinRequestReady{true};
+    /**
+     * Reports a public region as private to the region transition.
+     * On, a public region loads solo. Off, it waits for a public activity host.
+     * That host is the only route to the citizen join and the gameplay channel.
+     */
+    bool regionPrivate{true};
     /**
      * Pins the participation record to the replicated snapshot at `comp + 496`.
      * Off, the record is the local one at `comp + 1256`, whose spawn-gate byte no wire field

+ 3 - 45
Sunrise/src/core/settings/client/external/client_external_settings_parser.cpp

@@ -1,6 +1,7 @@
 #include <cstddef>
 #include <string_view>
 
+#include "../../address_text.h"
 #include "../../parser.h"
 
 namespace sunrise::core::settings::parser {
@@ -12,10 +13,6 @@ namespace external = client::external;
 constexpr unsigned int kMinimumPrintableAscii = 0x20;
 /** Tilde is the last printable ASCII value accepted by external-server text. */
 constexpr unsigned int kMaximumPrintableAscii = 0x7E;
-/** Largest value one IPv4 octet can hold. */
-constexpr unsigned int kMaximumOctet = 255;
-/** A dotted quad has 3 separators. */
-constexpr std::size_t kAddressSeparators = external::kAddressOctets - 1;
 /** The Client compares all 37 token bytes, so a short GUID never matches. */
 constexpr std::size_t kConfigGuidLength = external::kConfigGuidCapacity - 1;
 
@@ -45,55 +42,16 @@ template <std::size_t Capacity>
 }
 
 /**
- * Reads one decimal octet ending at a separator or at the end of the text.
- * @param text Complete dotted quad.
- * @param position Advanced past the octet and any separator after it.
- * @param output Receives the octet value.
- * @return True when 1 to 3 digits form a value of at most 255.
- */
-[[nodiscard]] bool
-read_octet(std::string_view text, std::size_t& position, unsigned char& output) noexcept {
-    unsigned int value = 0;
-    std::size_t digits = 0;
-    while (position < text.size() && text[position] >= '0' && text[position] <= '9') {
-        value = value * 10 + static_cast<unsigned int>(text[position] - '0');
-        ++position;
-        ++digits;
-        if (digits > 3 || value > kMaximumOctet) {
-            return false;
-        }
-    }
-    if (digits == 0) {
-        return false;
-    }
-    output = static_cast<unsigned char>(value);
-    return true;
-}
-
-/**
- * Splits a dotted quad into octets and copies it into wide storage.
+ * Derives the octets and the wide copy from the host text.
  * A name is refused because the resolver forwards this text with AI_NUMERICHOST.
  * @param output Host settings whose text is already filled in.
  * @return True when the host text is a complete IPv4 dotted quad.
  */
 [[nodiscard]] bool derive_address(external::Settings& output) noexcept {
     const std::string_view text(output.host.data());
-    std::array<unsigned char, external::kAddressOctets> octets{};
-    std::size_t position = 0;
-    for (std::size_t index = 0; index < octets.size(); ++index) {
-        if (!read_octet(text, position, octets[index])) {
-            return false;
-        }
-        const bool separator = index < kAddressSeparators;
-        if (separator && (position >= text.size() || text[position] != '.')) {
-            return false;
-        }
-        position += separator ? 1 : 0;
-    }
-    if (position != text.size()) {
+    if (!address::parse_ipv4(text, output.address)) {
         return false;
     }
-    output.address = octets;
     output.hostWide = {};
     for (std::size_t index = 0; index < text.size(); ++index) {
         output.hostWide[index] = static_cast<wchar_t>(text[index]);

+ 7 - 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 gameplay endpoint block on top of the fixed defaults.
+     * @param output Receives the block only after the whole object is consistent.
+     * @return True when the topology, addresses, port, and slot reserve agree.
+     */
+    [[nodiscard]] bool gameplay_settings(server::gameplay::Settings& output) noexcept;
     /**
      * Parses the authored entitlement array. Array order is the handle order the Client finds
      * definitions by, so entries are kept exactly as configured.
@@ -113,6 +119,7 @@ private:
     [[nodiscard]] bool flag_runs(std::span<std::uint8_t> bank) noexcept;
     [[nodiscard]] bool flag_indices(std::span<std::uint8_t> bank) noexcept;
     [[nodiscard]] bool objective_values(std::span<std::int32_t> bank) noexcept;
+    [[nodiscard]] bool progression_values(state::unlocks::ProgressionBank& bank) noexcept;
     /**
      * Parses the optional authored family-5 override group.
      * @param output Receives both override lists; id and gate fields stay default.

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

@@ -1,6 +1,7 @@
 #pragma once
 
 #include "../../../state/entitlements/definition.h"
+#include "gameplay/definition.h"
 
 namespace sunrise::core::settings::server {
 
@@ -11,6 +12,8 @@ inline constexpr std::uint16_t kDefaultBapPort = 30974;
 struct Settings {
     /** Authored ownership policy declared by SignOn and defined by the content manifest. */
     state::entitlements::Table entitlements{};
+    /** Gameplay UDP endpoint topology. Disabled leaves the channel unpublished. */
+    gameplay::Settings gameplay{};
     /** BAP port. The listener binds it and SignOn publishes it. Zero is the no-relay sentinel. */
     std::uint16_t bapPort{kDefaultBapPort};
 };

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

@@ -0,0 +1,71 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+
+namespace sunrise::core::settings::server::gameplay {
+
+/** Process that owns the gameplay UDP endpoint for this run. */
+enum class Topology : std::uint8_t {
+    /** No endpoint is bound and no method-0 descriptor is advertised. */
+    disabled,
+    /** Sunrise binds the endpoint and hosts the peer protocol itself. */
+    embedded,
+    /** A configured process owns the endpoint. Sunrise binds nothing. */
+    external,
+};
+
+/** Octets in one IPv4 address. */
+inline constexpr std::size_t kAddressOctets = 4;
+/** The join descriptor carries an even UDP port, so an odd port is refused at load. */
+inline constexpr std::uint16_t kPortAlignment = 2;
+/** Even, and clear of both discovery ports. */
+inline constexpr std::uint16_t kDefaultPort = 30976;
+/** Discovery owns 3074 and 3075, so the gameplay endpoint may take neither. */
+inline constexpr std::uint16_t kDiscoveryPortLow = 3074;
+/** Upper discovery port. See kDiscoveryPortLow. */
+inline constexpr std::uint16_t kDiscoveryPortHigh = 3075;
+/** One carrier, the live wave, and removal headroom fit in this many reserved slots. */
+inline constexpr std::uint16_t kDefaultServerReserve = 256;
+/** A smaller reserve cannot hold one carrier plus its removal and quarantine headroom. */
+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;
+
+/**
+ * Gameplay endpoint topology and the entity-slot split it implies.
+ * The advertised address is the one message 12 publishes. The transport address is where the
+ * datagram lands after the Client's egress rewrite. Both carry the same port.
+ */
+struct Settings {
+    Topology topology{Topology::disabled};
+    /** Local interface the embedded endpoint binds. */
+    std::array<unsigned char, kAddressOctets> bindAddress{127, 0, 0, 1};
+    /** Address written into the published descriptor. */
+    std::array<unsigned char, kAddressOctets> advertisedAddress{127, 0, 0, 1};
+    /** Address the Client's egress rewrite produces. Must reach the bound endpoint. */
+    std::array<unsigned char, kAddressOctets> transportAddress{127, 0, 0, 1};
+    /** Even UDP port, shared by all three addresses because the egress hook keeps the port. */
+    std::uint16_t port{kDefaultPort};
+    /** Entity indices held back from the client lease for server-authored entities. */
+    std::uint16_t serverReserveCount{kDefaultServerReserve};
+};
+
+/**
+ * Checks one gameplay block for internal consistency.
+ * @param settings Parsed or default gameplay settings.
+ * @return True when the topology, port, and reserve can be bound and advertised together.
+ */
+[[nodiscard]] bool valid(const Settings& settings) noexcept;
+
+/**
+ * Reports the slots actually held back from the client lease.
+ * A disabled channel authors no entities, so it reserves nothing and the client keeps the
+ * whole slot space.
+ * @param settings Active gameplay settings.
+ * @return Configured reserve, or zero while the channel is off.
+ */
+[[nodiscard]] std::uint16_t effective_reserve(const Settings& settings) noexcept;
+
+} // namespace sunrise::core::settings::server::gameplay

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

@@ -0,0 +1,117 @@
+#include <cstddef>
+#include <cstdint>
+#include <limits>
+#include <string_view>
+
+#include "../../address_text.h"
+#include "../../parser.h"
+
+namespace sunrise::core::settings::parser {
+namespace {
+
+namespace gameplay = server::gameplay;
+
+/**
+ * Turns one topology token into its enum value.
+ * @param name Borrowed JSON token.
+ * @param output Receives the topology only for a known token.
+ * @return True when the token names a supported topology.
+ */
+[[nodiscard]] bool topology_value(std::string_view name, gameplay::Topology& output) noexcept {
+    if (name == "disabled") {
+        output = gameplay::Topology::disabled;
+        return true;
+    }
+    if (name == "embedded") {
+        output = gameplay::Topology::embedded;
+        return true;
+    }
+    if (name == "external") {
+        output = gameplay::Topology::external;
+        return true;
+    }
+    return false;
+}
+
+} // namespace
+
+/** Parses the gameplay endpoint block on top of the fixed defaults. */
+bool Parser::gameplay_settings(gameplay::Settings& output) noexcept {
+    if (!consume('{')) {
+        return false;
+    }
+    gameplay::Settings candidate = output;
+    if (consume('}')) {
+        return gameplay::valid(candidate);
+    }
+    bool hasTopology = false;
+    bool hasBind = false;
+    bool hasAdvertised = false;
+    bool hasTransport = false;
+    bool hasPort = false;
+    bool hasReserve = false;
+    for (;;) {
+        std::string_view key;
+        if (!string(key) || !consume(':')) {
+            return false;
+        }
+        if (key == "topology") {
+            std::string_view name;
+            if (hasTopology || !string(name) || !topology_value(name, candidate.topology)) {
+                return false;
+            }
+            hasTopology = true;
+        } else if (key == "bind_address") {
+            std::string_view text;
+            if (hasBind || !string(text) || !address::parse_ipv4(text, candidate.bindAddress)) {
+                return false;
+            }
+            hasBind = true;
+        } else if (key == "advertised_address") {
+            std::string_view text;
+            if (hasAdvertised || !string(text)
+                || !address::parse_ipv4(text, candidate.advertisedAddress)) {
+                return false;
+            }
+            hasAdvertised = true;
+        } else if (key == "transport_address") {
+            std::string_view text;
+            if (hasTransport || !string(text)
+                || !address::parse_ipv4(text, candidate.transportAddress)) {
+                return false;
+            }
+            hasTransport = true;
+        } else if (key == "port") {
+            std::uint64_t value = 0;
+            if (hasPort || !unsigned_integer(value)
+                || value > (std::numeric_limits<std::uint16_t>::max)()) {
+                return false;
+            }
+            candidate.port = static_cast<std::uint16_t>(value);
+            hasPort = true;
+        } else if (key == "server_reserve_count") {
+            std::uint64_t value = 0;
+            if (hasReserve || !unsigned_integer(value)
+                || value > (std::numeric_limits<std::uint16_t>::max)()) {
+                return false;
+            }
+            candidate.serverReserveCount = static_cast<std::uint16_t>(value);
+            hasReserve = true;
+        } else if (!skip_value(0)) {
+            return false;
+        }
+        if (consume('}')) {
+            // The block is rejected as a whole so a half-applied topology never binds.
+            if (!gameplay::valid(candidate)) {
+                return false;
+            }
+            output = candidate;
+            return true;
+        }
+        if (!consume(',')) {
+            return false;
+        }
+    }
+}
+
+} // namespace sunrise::core::settings::parser

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

@@ -0,0 +1,39 @@
+#include "../../../../state/activity/entity_slots/definition.h"
+#include "definition.h"
+
+namespace sunrise::core::settings::server::gameplay {
+
+namespace {
+
+/** @return True when the slot split leaves both sides a usable share. */
+[[nodiscard]] bool reserve_fits(std::uint16_t reserve) noexcept {
+    // One activity session owns exactly this many entity-slot lease bits.
+    constexpr std::size_t kSlotCount = state::activity::entity_slots::kSlotCount;
+    if (reserve < kMinimumServerReserve || static_cast<std::size_t>(reserve) >= kSlotCount) {
+        return false;
+    }
+    return kSlotCount - static_cast<std::size_t>(reserve) >= kClientLeaseMinimum;
+}
+
+} // namespace
+
+/** Checks one gameplay block for internal consistency. */
+bool valid(const Settings& settings) noexcept {
+    if (settings.topology == Topology::disabled) {
+        return true;
+    }
+    if (settings.port == 0 || settings.port % kPortAlignment != 0) {
+        return false;
+    }
+    if (settings.port == kDiscoveryPortLow || settings.port == kDiscoveryPortHigh) {
+        return false;
+    }
+    return reserve_fits(settings.serverReserveCount);
+}
+
+/** Reports the slots actually held back from the client lease. */
+std::uint16_t effective_reserve(const Settings& settings) noexcept {
+    return settings.topology == Topology::disabled ? 0 : settings.serverReserveCount;
+}
+
+} // namespace sunrise::core::settings::server::gameplay

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

@@ -17,6 +17,7 @@ bool Parser::server_settings(server::Settings& output) noexcept {
     }
     bool hasEntitlements = false;
     bool hasBapPort = false;
+    bool hasGameplay = false;
     for (;;) {
         std::string_view key;
         if (!string(key) || !consume(':')) {
@@ -35,6 +36,11 @@ bool Parser::server_settings(server::Settings& output) noexcept {
             }
             output.bapPort = static_cast<std::uint16_t>(value);
             hasBapPort = true;
+        } else if (key == "gameplay") {
+            if (hasGameplay || !gameplay_settings(output.gameplay)) {
+                return false;
+            }
+            hasGameplay = true;
         } else if (!skip_value(0)) {
             return false;
         }

+ 52 - 0
Sunrise/src/core/settings/state_settings.cpp

@@ -1,3 +1,6 @@
+#include <algorithm>
+#include <array>
+
 #include "../../state/activity/defaults/activity_defaults_validation.h"
 #include "parser.h"
 
@@ -91,6 +94,51 @@ bool Parser::objective_values(std::span<std::int32_t> bank) noexcept {
     }
 }
 
+/**
+ * Fills one progression bank from [definition_index, lane 0, lane 1, lane 2] rows.
+ * Repeated rows for one definition merge by lane maximum, not by last write. The Client's
+ * own definition-indexed bank merges the same way.
+ * @param bank Authored lanes addressed by definition index.
+ * @return True when every row names a possible definition and carries all 3 lanes.
+ */
+bool Parser::progression_values(state::unlocks::ProgressionBank& bank) noexcept {
+    std::array<bool, state::build_data::progressions::kDefinitionCapacity> authored{};
+    if (!consume('[')) {
+        return false;
+    }
+    if (consume(']')) {
+        return true;
+    }
+    for (;;) {
+        std::uint64_t index = 0;
+        state::unlocks::ProgressionLanes lanes{};
+        if (!consume('[') || !unsigned_integer(index) || index >= bank.size()) {
+            return false;
+        }
+        for (std::int32_t& lane : lanes) {
+            if (!consume(',') || !signed_32(lane)) {
+                return false;
+            }
+        }
+        if (!consume(']')) {
+            return false;
+        }
+        const auto slot = static_cast<std::size_t>(index);
+        state::unlocks::ProgressionLanes& row = bank[slot];
+        for (std::size_t lane = 0; lane < row.size(); ++lane) {
+            // The first row of a definition is taken whole, so a negative lane is not clamped.
+            row[lane] = authored[slot] ? (std::max)(row[lane], lanes[lane]) : lanes[lane];
+        }
+        authored[slot] = true;
+        if (consume(']')) {
+            return true;
+        }
+        if (!consume(',')) {
+            return false;
+        }
+    }
+}
+
 /**
  * Parses the four authored unlock banks.
  * @param output Receives every expanded bank.
@@ -122,6 +170,10 @@ bool Parser::unlocks(state::unlocks::Table& output) noexcept {
             parsed = flag_runs(output.characterObjectFlags);
         } else if (key == "character_objective_values") {
             parsed = objective_values(output.characterObjectValues);
+        } else if (key == "account_progressions") {
+            parsed = progression_values(output.accountProgressions);
+        } else if (key == "character_progressions") {
+            parsed = progression_values(output.characterProgressions);
         } else {
             parsed = skip_value(0);
         }

+ 2 - 2
Sunrise/src/middleware/bap/activity_message/activity_entity_slot_request_parser.cpp

@@ -19,8 +19,8 @@ bool parse_entity_slot_request(std::span<const std::byte> input, std::int32_t& v
         return false;
     }
 
-    const std::uint32_t raw =
-        encoding::read_u32_be(std::span<const std::byte, kEncodedSize>{input});
+    // A longer body is legal here, so take a prefix. A fixed-extent span needs an exact size.
+    const std::uint32_t raw = encoding::read_u32_be(input.first<kEncodedSize>());
     const std::int64_t decoded = static_cast<std::int64_t>(raw) - kSignedValueBias;
     value = static_cast<std::int32_t>(decoded);
     return true;

+ 39 - 11
Sunrise/src/middleware/bap/activity_message/activity_membership_region_writer.cpp

@@ -18,24 +18,51 @@ constexpr std::uint8_t kSliceSetBitWidth = 10;
 /** Signed host-state fields add 1 before writing their unsigned value. */
 constexpr std::int32_t kSignedFieldBias = 1;
 
+/** Ambassadorship state fields are 2 bits at bias 1, so wire 2 stores the assigned state 1. */
+constexpr std::uint64_t kAmbassadorAssigned = 2;
+/** Member-slot fields are 6 bits at bias 1. */
+constexpr std::uint8_t kSlotBitWidth = 6;
 /**
- * Writes one 451-bit state-zero region record.
+ * Ambassador slot named by a record with no advertisement, at bias 1, so wire 2 stores slot 1.
+ * It must never store the client's own slot 0. A record naming slot 0 makes the client claim the
+ * region, and the next differing slot then revokes it for good.
+ */
+constexpr std::uint64_t kUnadvertisedAmbassadorSlot = 2;
+/** The join-descriptor element count is an unclamped 8-bit field; only 128 is usable. */
+constexpr std::uint64_t kDescriptorCount = 128;
+
+/**
+ * Writes one state-zero region record.
+ * A record carrying the citizen advertisement is 1,024 bits longer than the others.
  * @param writer Fixed-buffer writer sitting at the record start.
  * @param bubble Bubble index, 0 through 63.
- * @param token Transition token copied into all member lanes.
+ * @param snapshot Transition token and optional citizen advertisement.
  * @return True when every fixed field fits.
  */
-[[nodiscard]] bool
-write_region(encoding::bits::Writer& writer, std::size_t bubble, std::uint8_t token) noexcept {
+[[nodiscard]] bool write_region(encoding::bits::Writer& writer,
+                                std::size_t bubble,
+                                const MembershipSnapshot& snapshot) noexcept {
     const std::uint32_t region = static_cast<std::uint32_t>(bubble) * kRegionIndexStride;
+    const bool advertise = snapshot.citizen.present
+                           && static_cast<std::int32_t>(region) == snapshot.citizen.regionIndex;
+    const std::uint64_t ambassadorSlot =
+        advertise ? static_cast<std::uint64_t>(snapshot.citizen.ambassadorSlot) + kSignedFieldBias
+                  : kUnadvertisedAmbassadorSlot;
     bool encoded = writer.write(kRegionIndexBias + region, 32) && writer.write(1, 2)
-                   && writer.write(0, 8) && writer.write(2, 2) && writer.write(1, 6)
-                   && writer.write(0, 1) && writer.write(0, 32) && writer.write(0, 8)
-                   && writer.write(0, 32);
+                   && writer.write(0, 8) && writer.write(kAmbassadorAssigned, 2)
+                   && writer.write(ambassadorSlot, kSlotBitWidth) && writer.write(0, 1)
+                   && writer.write(0, 32) && writer.write(0, 8) && writer.write(0, 32);
     for (std::size_t member = 0; encoded && member < kRegionTokenCount; ++member) {
-        encoded = writer.write(token, 8);
+        encoded = writer.write(snapshot.transitionToken, 8);
+    }
+    if (!advertise) {
+        return encoded && writer.write(0, 8) && writer.write(0, 64);
+    }
+    encoded = encoded && writer.write(kDescriptorCount, 8);
+    for (const std::byte value : snapshot.citizen.descriptor) {
+        encoded = encoded && writer.write(std::to_integer<std::uint64_t>(value), 8);
     }
-    return encoded && writer.write(0, 8) && writer.write(0, 64);
+    return encoded && writer.write(snapshot.citizen.onlineSessionId, 64);
 }
 
 /**
@@ -87,9 +114,10 @@ bool write_region_block(encoding::bits::Writer& writer,
                         const MembershipSnapshot& snapshot) noexcept {
     bool encoded = writer.bit_count() == kRegionBlockStartBit;
     for (std::size_t bubble = 0; encoded && bubble < kRegionCount; ++bubble) {
-        encoded = write_region(writer, bubble, snapshot.transitionToken);
+        encoded = write_region(writer, bubble, snapshot);
     }
-    return encoded && write_host_tail(writer, snapshot) && writer.bit_count() == kRegionBlockEndBit;
+    return encoded && write_host_tail(writer, snapshot)
+           && writer.bit_count() == region_block_end_bit(snapshot);
 }
 
 } // namespace sunrise::middleware::bap::activity_message::replicate_membership

+ 7 - 4
Sunrise/src/middleware/bap/activity_message/activity_replicate_membership_encoder.cpp

@@ -15,11 +15,12 @@ bool encode_replicate_membership(const MembershipSnapshot& snapshot,
                                  std::span<std::byte> output,
                                  std::size_t& written) noexcept {
     written = 0;
-    if (output.size() < kEncodedSize || !valid(snapshot)) {
+    const std::size_t size = encoded_size(snapshot);
+    if (output.size() < size || !valid(snapshot)) {
         return false;
     }
 
-    encoding::bits::Writer writer(output.first(kEncodedSize));
+    encoding::bits::Writer writer(output.first(size));
     const bool encoded = writer.write(1, 1) && writer.write(snapshot.revision, 32)
                          && writer.write(snapshot.epoch, 32)
                          && write_member_table(writer, snapshot.identity) && writer.write(1, 1)
@@ -28,8 +29,10 @@ bool encode_replicate_membership(const MembershipSnapshot& snapshot,
                          && writer.write(kLocalMemberMask, 32) && writer.write(0, 1)
                          && writer.write(0, 1) && writer.write(0, 1);
     std::size_t encodedSize = 0;
-    if (!encoded || writer.bit_count() != kMeaningfulBitCount || !writer.finish(encodedSize)
-        || encodedSize != kEncodedSize) {
+    const std::size_t meaningfulBits =
+        kMeaningfulBitCount + (snapshot.citizen.present ? kDescriptorBitCount : 0);
+    if (!encoded || writer.bit_count() != meaningfulBits || !writer.finish(encodedSize)
+        || encodedSize != size) {
         return false;
     }
 

+ 38 - 0
Sunrise/src/middleware/bap/activity_message/replicate_membership.h

@@ -5,6 +5,7 @@
 #include <span>
 
 #include "../../encoding/bit_writer.h"
+#include "../../gameplay/descriptor/join_descriptor.h"
 #include "activity_client_identity_parser.h"
 #include "client_authoritative_data.h"
 
@@ -16,12 +17,38 @@ inline constexpr std::uint32_t kMessageType = 12;
 inline constexpr std::size_t kMeaningfulBitCount = 29'968;
 /** The host-present snapshot is byte-aligned at 3,746 bytes. */
 inline constexpr std::size_t kEncodedSize = 3'746;
+/** One filled descriptor makes its record 1,024 bits longer and shifts every later field. */
+inline constexpr std::size_t kDescriptorBitCount = gameplay::descriptor::kDescriptorSize * 8U;
+/** Byte size once one record carries a descriptor. */
+inline constexpr std::size_t kCitizenEncodedSize =
+    kEncodedSize + gameplay::descriptor::kDescriptorSize;
+
+/**
+ * One remote-citizen advertisement placed in a single region record.
+ * The record is picked by region index. The client adopts only the record whose index matches
+ * its pending region.
+ */
+struct CitizenAdvertisement final {
+    std::array<std::byte, gameplay::descriptor::kDescriptorSize> descriptor{};
+    /** The ambassador's activity-host id. It is not the descriptor's own session id. */
+    std::uint64_t onlineSessionId{};
+    /** Region index of the record that carries it. */
+    std::int32_t regionIndex{};
+    /**
+     * Ambassador member slot. It must differ from the joining client's own slot. An equal slot
+     * picks the local-ambassador stage instead of the citizen stage.
+     */
+    std::uint8_t ambassadorSlot{};
+    bool present{};
+};
 
 /** Inputs for one local-player membership snapshot. */
 struct MembershipSnapshot final {
     client_identity::ClientIdentity identity{};
     client_authoritative_data::SpawnState spawn{};
     client_authoritative_data::TeleportState teleport{};
+    /** Empty unless the gameplay channel is advertising an endpoint this run. */
+    CitizenAdvertisement citizen{};
     std::uint32_t revision{};
     /** Stable session epoch; changing it clears the client's peer table. */
     std::uint32_t epoch{};
@@ -29,6 +56,11 @@ struct MembershipSnapshot final {
     std::uint8_t transitionToken{};
 };
 
+/** @return Encoded byte size for one snapshot, which grows with a citizen advertisement. */
+[[nodiscard]] constexpr std::size_t encoded_size(const MembershipSnapshot& snapshot) noexcept {
+    return snapshot.citizen.present ? kCitizenEncodedSize : kEncodedSize;
+}
+
 /**
  * Encodes one full-player membership snapshot. No allocation.
  * @param snapshot Checked identity, revision, transition, and host-echo values.
@@ -47,6 +79,12 @@ inline constexpr std::size_t kRegionBlockStartBit = 835;
 /** The host-present region block ends before top-level field four. */
 inline constexpr std::size_t kRegionBlockEndBit = 29'899;
 
+/** @return Bit at which the region block ends for one snapshot. */
+[[nodiscard]] constexpr std::size_t
+region_block_end_bit(const MembershipSnapshot& snapshot) noexcept {
+    return snapshot.citizen.present ? kRegionBlockEndBit + kDescriptorBitCount : kRegionBlockEndBit;
+}
+
 /** @return True when the teleport slice-set index fits its fixed wire field. */
 [[nodiscard]] bool valid(const MembershipSnapshot& snapshot) noexcept;
 

+ 144 - 0
Sunrise/src/middleware/crypto/aes_cbc.cpp

@@ -0,0 +1,144 @@
+#include "aes_cbc.h"
+
+#include <Windows.h>
+
+#include <algorithm>
+#include <array>
+#include <bcrypt.h>
+
+namespace sunrise::middleware::crypto::aes {
+
+namespace {
+
+/** @return True for a BCrypt status that reports success. */
+[[nodiscard]] bool succeeded(NTSTATUS status) noexcept {
+    return status >= 0;
+}
+
+/** One opened key, closed when it leaves scope. */
+class Key {
+public:
+    /** @param key Cypher key bytes. */
+    explicit Key(std::span<const std::byte> key) noexcept {
+        if (!succeeded(
+                BCryptOpenAlgorithmProvider(&m_algorithm, BCRYPT_AES_ALGORITHM, nullptr, 0))) {
+            m_algorithm = nullptr;
+            return;
+        }
+        const auto* mode = reinterpret_cast<PUCHAR>(const_cast<wchar_t*>(BCRYPT_CHAIN_MODE_CBC));
+        if (!succeeded(BCryptSetProperty(m_algorithm,
+                                         BCRYPT_CHAINING_MODE,
+                                         const_cast<PUCHAR>(mode),
+                                         sizeof(BCRYPT_CHAIN_MODE_CBC),
+                                         0))
+            || !succeeded(BCryptGenerateSymmetricKey(
+                m_algorithm,
+                &m_key,
+                nullptr,
+                0,
+                reinterpret_cast<PUCHAR>(const_cast<std::byte*>(key.data())),
+                static_cast<ULONG>(key.size()),
+                0))) {
+            m_key = nullptr;
+        }
+    }
+
+    Key(const Key&) = delete;
+    Key(Key&&) = delete;
+    Key& operator=(const Key&) = delete;
+    Key& operator=(Key&&) = delete;
+
+    /** Closes the key and the provider in the order BCrypt requires. */
+    ~Key() noexcept {
+        if (m_key != nullptr) {
+            BCryptDestroyKey(m_key);
+        }
+        if (m_algorithm != nullptr) {
+            BCryptCloseAlgorithmProvider(m_algorithm, 0);
+        }
+    }
+
+    /** @return The opened key, or null when any stage failed. */
+    [[nodiscard]] BCRYPT_KEY_HANDLE handle() const noexcept {
+        return m_key;
+    }
+
+private:
+    BCRYPT_ALG_HANDLE m_algorithm{nullptr};
+    BCRYPT_KEY_HANDLE m_key{nullptr};
+};
+
+/** @return True when the buffers are a whole number of blocks and the vector is one block. */
+[[nodiscard]] bool sized(std::span<const std::byte> iv,
+                         std::span<const std::byte> input,
+                         std::span<std::byte> output) noexcept {
+    return iv.size() == kBlockSize && !input.empty() && input.size() % kBlockSize == 0
+           && output.size() >= input.size();
+}
+
+} // namespace
+
+/** Encrypts whole blocks in cipher block chaining mode. */
+bool encrypt(std::span<const std::byte> key,
+             std::span<const std::byte> iv,
+             std::span<const std::byte> input,
+             std::span<std::byte> output) noexcept {
+    if (!sized(iv, input, output)) {
+        return false;
+    }
+    const Key opened{key};
+    if (opened.handle() == nullptr) {
+        return false;
+    }
+    // BCrypt overwrites the vector it is given, so the caller's is never passed in.
+    std::array<std::byte, kBlockSize> vector{};
+    std::copy(iv.begin(), iv.end(), vector.begin());
+    ULONG produced = 0;
+    const bool complete =
+        succeeded(BCryptEncrypt(opened.handle(),
+                                reinterpret_cast<PUCHAR>(const_cast<std::byte*>(input.data())),
+                                static_cast<ULONG>(input.size()),
+                                nullptr,
+                                reinterpret_cast<PUCHAR>(vector.data()),
+                                static_cast<ULONG>(vector.size()),
+                                reinterpret_cast<PUCHAR>(output.data()),
+                                static_cast<ULONG>(input.size()),
+                                &produced,
+                                0))
+        && produced == input.size();
+    SecureZeroMemory(vector.data(), vector.size());
+    return complete;
+}
+
+/** Decrypts whole blocks in cipher block chaining mode. */
+bool decrypt(std::span<const std::byte> key,
+             std::span<const std::byte> iv,
+             std::span<const std::byte> input,
+             std::span<std::byte> output) noexcept {
+    if (!sized(iv, input, output)) {
+        return false;
+    }
+    const Key opened{key};
+    if (opened.handle() == nullptr) {
+        return false;
+    }
+    std::array<std::byte, kBlockSize> vector{};
+    std::copy(iv.begin(), iv.end(), vector.begin());
+    ULONG produced = 0;
+    const bool complete =
+        succeeded(BCryptDecrypt(opened.handle(),
+                                reinterpret_cast<PUCHAR>(const_cast<std::byte*>(input.data())),
+                                static_cast<ULONG>(input.size()),
+                                nullptr,
+                                reinterpret_cast<PUCHAR>(vector.data()),
+                                static_cast<ULONG>(vector.size()),
+                                reinterpret_cast<PUCHAR>(output.data()),
+                                static_cast<ULONG>(input.size()),
+                                &produced,
+                                0))
+        && produced == input.size();
+    SecureZeroMemory(vector.data(), vector.size());
+    return complete;
+}
+
+} // namespace sunrise::middleware::crypto::aes

+ 38 - 0
Sunrise/src/middleware/crypto/aes_cbc.h

@@ -0,0 +1,38 @@
+#pragma once
+
+#include <cstddef>
+#include <span>
+
+namespace sunrise::middleware::crypto::aes {
+
+/** AES works on 16-byte blocks, whatever its key width. */
+inline constexpr std::size_t kBlockSize = 16;
+
+/**
+ * Encrypts whole blocks in cipher block chaining mode.
+ * No padding is added. The length must already be a whole number of blocks.
+ * @param key Cypher key, 16, 24 or 32 bytes.
+ * @param iv Initialization vector, one block.
+ * @param input Plaintext, a whole number of blocks.
+ * @param output Receives the ciphertext; it may be the same buffer as the input.
+ * @return True when Windows completed the operation.
+ */
+[[nodiscard]] bool encrypt(std::span<const std::byte> key,
+                           std::span<const std::byte> iv,
+                           std::span<const std::byte> input,
+                           std::span<std::byte> output) noexcept;
+
+/**
+ * Decrypts whole blocks in cipher block chaining mode.
+ * @param key Cypher key, 16, 24 or 32 bytes.
+ * @param iv Initialization vector, one block.
+ * @param input Ciphertext, a whole number of blocks.
+ * @param output Receives the plaintext; it may be the same buffer as the input.
+ * @return True when Windows completed the operation.
+ */
+[[nodiscard]] bool decrypt(std::span<const std::byte> key,
+                           std::span<const std::byte> iv,
+                           std::span<const std::byte> input,
+                           std::span<std::byte> output) noexcept;
+
+} // namespace sunrise::middleware::crypto::aes

+ 65 - 0
Sunrise/src/middleware/crypto/aes_gcm_encrypt.cpp

@@ -0,0 +1,65 @@
+#include "aes_gcm_encrypt.h"
+
+#include <Windows.h>
+
+#include <bcrypt.h>
+#include <limits>
+
+namespace sunrise::middleware::crypto::aes_gcm {
+
+/** Encrypts one buffer and returns its tag apart from the ciphertext. */
+bool encrypt(std::span<const std::byte, kKeySize> key,
+             std::span<const std::byte, kNonceSize> nonce,
+             std::span<const std::byte> plaintext,
+             std::span<std::byte> output,
+             std::span<std::byte, kTagSize> tag) noexcept {
+    if (plaintext.size() > (std::numeric_limits<ULONG>::max)()
+        || output.size() < plaintext.size()) {
+        return false;
+    }
+    BCRYPT_ALG_HANDLE algorithm = nullptr;
+    if (BCryptOpenAlgorithmProvider(&algorithm, BCRYPT_AES_ALGORITHM, nullptr, 0) < 0) {
+        return false;
+    }
+    BCRYPT_KEY_HANDLE symmetricKey = nullptr;
+    bool sealed = false;
+    if (BCryptSetProperty(algorithm,
+                          BCRYPT_CHAINING_MODE,
+                          reinterpret_cast<PUCHAR>(const_cast<wchar_t*>(BCRYPT_CHAIN_MODE_GCM)),
+                          sizeof(BCRYPT_CHAIN_MODE_GCM),
+                          0)
+            >= 0
+        && BCryptGenerateSymmetricKey(algorithm,
+                                      &symmetricKey,
+                                      nullptr,
+                                      0,
+                                      reinterpret_cast<PUCHAR>(const_cast<std::byte*>(key.data())),
+                                      static_cast<ULONG>(key.size()),
+                                      0)
+               >= 0) {
+        BCRYPT_AUTHENTICATED_CIPHER_MODE_INFO authentication;
+        BCRYPT_INIT_AUTH_MODE_INFO(authentication);
+        authentication.pbNonce = reinterpret_cast<PUCHAR>(const_cast<std::byte*>(nonce.data()));
+        authentication.cbNonce = static_cast<ULONG>(nonce.size());
+        authentication.pbTag = reinterpret_cast<PUCHAR>(tag.data());
+        authentication.cbTag = static_cast<ULONG>(tag.size());
+        ULONG produced = 0;
+        sealed = BCryptEncrypt(symmetricKey,
+                               reinterpret_cast<PUCHAR>(const_cast<std::byte*>(plaintext.data())),
+                               static_cast<ULONG>(plaintext.size()),
+                               &authentication,
+                               nullptr,
+                               0,
+                               reinterpret_cast<PUCHAR>(output.data()),
+                               static_cast<ULONG>(output.size()),
+                               &produced,
+                               0)
+                     >= 0
+                 && produced == plaintext.size();
+        BCryptDestroyKey(symmetricKey);
+    }
+    BCryptCloseAlgorithmProvider(algorithm, 0);
+    return sealed;
+}
+
+} // namespace sunrise::middleware::crypto::aes_gcm

+ 26 - 0
Sunrise/src/middleware/crypto/aes_gcm_encrypt.h

@@ -0,0 +1,26 @@
+#pragma once
+
+#include <cstddef>
+#include <span>
+
+#include "aes_gcm_decrypt.h"
+
+namespace sunrise::middleware::crypto::aes_gcm {
+
+/**
+ * Encrypts one buffer and returns its tag apart from the ciphertext.
+ * There is no additional authenticated data. The tag covers the ciphertext only.
+ * @param key AES-128 key.
+ * @param nonce 12-byte nonce.
+ * @param plaintext Bytes to encrypt.
+ * @param output Destination of at least the plaintext size.
+ * @param tag Receives the full 16-byte authentication tag.
+ * @return True only when the whole transform succeeds.
+ */
+[[nodiscard]] bool encrypt(std::span<const std::byte, kKeySize> key,
+                           std::span<const std::byte, kNonceSize> nonce,
+                           std::span<const std::byte> plaintext,
+                           std::span<std::byte> output,
+                           std::span<std::byte, kTagSize> tag) noexcept;
+
+} // namespace sunrise::middleware::crypto::aes_gcm

+ 298 - 0
Sunrise/src/middleware/crypto/ecc_p224.cpp

@@ -0,0 +1,298 @@
+#include "ecc_p224.h"
+
+#include <Windows.h>
+
+#include <algorithm>
+#include <bcrypt.h>
+#include <vector>
+
+namespace sunrise::middleware::crypto::ecc {
+
+namespace {
+
+/** DER tag for a SEQUENCE. */
+constexpr std::byte kTagSequence{0x30};
+/** DER tag for an INTEGER. */
+constexpr std::byte kTagInteger{0x02};
+/** DER tag for a BIT STRING. */
+constexpr std::byte kTagBitString{0x03};
+/** The key encoding opens with a one-bit flag that is set only on a private key. */
+constexpr std::array<std::byte, 4> kPublicFlagBits{
+    kTagBitString, std::byte{0x02}, std::byte{0x07}, std::byte{0x00}};
+/** A DER length below this fits in the single byte that follows the tag. */
+constexpr std::size_t kShortFormLimit = 0x80;
+/** Bits in one byte. */
+constexpr unsigned kByteBits = 8;
+/** Public key blobs name the curve rather than carry its parameters. */
+constexpr ULONG kGenericPublicMagic = 0x504B4345;
+
+/** @return True for a BCrypt status that reports success. */
+[[nodiscard]] bool succeeded(NTSTATUS status) noexcept {
+    return status >= 0;
+}
+
+/**
+ * Appends one positive integer in the minimal form DER requires.
+ * A leading zero goes in when the top bit is set, so the value never reads as negative.
+ * @param output Growing encoding.
+ * @param value Field element, high byte first.
+ */
+void append_integer(std::vector<std::byte>& output, std::span<const std::byte> value) noexcept {
+    std::size_t first = 0;
+    while (first + 1 < value.size() && value[first] == std::byte{0}) {
+        ++first;
+    }
+    const std::span<const std::byte> trimmed = value.subspan(first);
+    const bool pad = (std::to_integer<unsigned>(trimmed[0]) & 0x80U) != 0;
+    output.push_back(kTagInteger);
+    output.push_back(static_cast<std::byte>(trimmed.size() + (pad ? 1U : 0U)));
+    if (pad) {
+        output.push_back(std::byte{0});
+    }
+    output.insert(output.end(), trimmed.begin(), trimmed.end());
+}
+
+/**
+ * Encodes one public key the way the peer's importer reads it.
+ * @param x Affine x, high byte first.
+ * @param y Affine y, high byte first.
+ * @param output Receives the encoding, zero padded to the fixed field.
+ * @return True when the encoding fits the field.
+ */
+[[nodiscard]] bool encode_public_key(std::span<const std::byte> x,
+                                     std::span<const std::byte> y,
+                                     std::array<std::byte, kExportedKeySize>& output) noexcept {
+    std::vector<std::byte> body;
+    body.insert(body.end(), kPublicFlagBits.begin(), kPublicFlagBits.end());
+    // The curve is identified by its field size, encoded as a plain integer.
+    body.push_back(kTagInteger);
+    body.push_back(std::byte{1});
+    body.push_back(static_cast<std::byte>(kFieldSize));
+    append_integer(body, x);
+    append_integer(body, y);
+    if (body.size() >= kShortFormLimit || body.size() + 2 > kExportedKeySize) {
+        return false;
+    }
+    output = {};
+    output[0] = kTagSequence;
+    output[1] = static_cast<std::byte>(body.size());
+    std::copy(body.begin(), body.end(), output.begin() + 2);
+    return true;
+}
+
+/**
+ * Reads one DER element header.
+ * @param input Encoding.
+ * @param cursor Position of the tag; advanced past the header on success.
+ * @param tag Tag the element must carry.
+ * @param length Receives the content length.
+ * @return True when the element is present and its content fits the input.
+ */
+[[nodiscard]] bool read_header(std::span<const std::byte> input,
+                               std::size_t& cursor,
+                               std::byte tag,
+                               std::size_t& length) noexcept {
+    if (cursor + 2 > input.size() || input[cursor] != tag) {
+        return false;
+    }
+    const auto declared = std::to_integer<std::size_t>(input[cursor + 1]);
+    // Only the short form is accepted. Every element here is far below the long-form threshold.
+    if (declared >= kShortFormLimit || cursor + 2 + declared > input.size()) {
+        return false;
+    }
+    cursor += 2;
+    length = declared;
+    return true;
+}
+
+/**
+ * Reads one integer into a fixed field, right aligned.
+ * @param input Encoding.
+ * @param cursor Position of the tag; advanced past the element on success.
+ * @param output Receives the value, high byte first.
+ * @return True when the element is an integer no wider than the field.
+ */
+[[nodiscard]] bool read_field_integer(std::span<const std::byte> input,
+                                      std::size_t& cursor,
+                                      std::array<std::byte, kFieldSize>& output) noexcept {
+    std::size_t length = 0;
+    if (!read_header(input, cursor, kTagInteger, length) || length == 0) {
+        return false;
+    }
+    std::span<const std::byte> value = input.subspan(cursor, length);
+    if (!value.empty() && value[0] == std::byte{0}) {
+        value = value.subspan(1);
+    }
+    if (value.size() > kFieldSize) {
+        return false;
+    }
+    output = {};
+    std::copy(value.begin(), value.end(), output.end() - static_cast<std::ptrdiff_t>(value.size()));
+    cursor += length;
+    return true;
+}
+
+/**
+ * Reads the peer's exported public key.
+ * @param input Peer key, padding included.
+ * @param x Receives affine x.
+ * @param y Receives affine y.
+ * @return True when the encoding is a public key on this curve.
+ */
+[[nodiscard]] bool decode_public_key(std::span<const std::byte> input,
+                                     std::array<std::byte, kFieldSize>& x,
+                                     std::array<std::byte, kFieldSize>& y) noexcept {
+    std::size_t cursor = 0;
+    std::size_t length = 0;
+    if (!read_header(input, cursor, kTagSequence, length)) {
+        return false;
+    }
+    if (!read_header(input, cursor, kTagBitString, length)) {
+        return false;
+    }
+    cursor += length;
+    std::size_t sizeLength = 0;
+    if (!read_header(input, cursor, kTagInteger, sizeLength) || sizeLength != 1
+        || input[cursor] != static_cast<std::byte>(kFieldSize)) {
+        return false;
+    }
+    cursor += sizeLength;
+    return read_field_integer(input, cursor, x) && read_field_integer(input, cursor, y);
+}
+
+/**
+ * Opens the agreement algorithm bound to this curve.
+ * @param output Receives the provider handle only on success.
+ * @return True when Windows offers the curve.
+ */
+[[nodiscard]] bool open_provider(BCRYPT_ALG_HANDLE& output) noexcept {
+    BCRYPT_ALG_HANDLE algorithm = nullptr;
+    if (!succeeded(BCryptOpenAlgorithmProvider(&algorithm, BCRYPT_ECDH_ALGORITHM, nullptr, 0))) {
+        return false;
+    }
+    const auto* curve = reinterpret_cast<PUCHAR>(const_cast<wchar_t*>(BCRYPT_ECC_CURVE_SECP224R1));
+    if (!succeeded(BCryptSetProperty(algorithm,
+                                     BCRYPT_ECC_CURVE_NAME,
+                                     const_cast<PUCHAR>(curve),
+                                     sizeof(BCRYPT_ECC_CURVE_SECP224R1),
+                                     0))) {
+        BCryptCloseAlgorithmProvider(algorithm, 0);
+        return false;
+    }
+    output = algorithm;
+    return true;
+}
+
+/**
+ * Imports the peer's point as a public key.
+ * @param algorithm Provider bound to the curve.
+ * @param x Affine x.
+ * @param y Affine y.
+ * @param output Receives the key handle only on success.
+ * @return True when Windows accepted the point.
+ */
+[[nodiscard]] bool import_peer(BCRYPT_ALG_HANDLE algorithm,
+                               const std::array<std::byte, kFieldSize>& x,
+                               const std::array<std::byte, kFieldSize>& y,
+                               BCRYPT_KEY_HANDLE& output) noexcept {
+    std::array<std::byte, sizeof(BCRYPT_ECCKEY_BLOB) + (2 * kFieldSize)> blob{};
+    auto* header = reinterpret_cast<BCRYPT_ECCKEY_BLOB*>(blob.data());
+    header->dwMagic = kGenericPublicMagic;
+    header->cbKey = static_cast<ULONG>(kFieldSize);
+    std::copy(x.begin(), x.end(), blob.begin() + sizeof(BCRYPT_ECCKEY_BLOB));
+    std::copy(y.begin(), y.end(), blob.begin() + sizeof(BCRYPT_ECCKEY_BLOB) + kFieldSize);
+    return succeeded(BCryptImportKeyPair(algorithm,
+                                         nullptr,
+                                         BCRYPT_ECCPUBLIC_BLOB,
+                                         &output,
+                                         reinterpret_cast<PUCHAR>(blob.data()),
+                                         static_cast<ULONG>(blob.size()),
+                                         0));
+}
+
+/**
+ * Runs the agreement and takes the raw secret.
+ * @param ours Our private key.
+ * @param theirs Peer's public key.
+ * @param output Receives the x coordinate, high byte first.
+ * @return True when Windows produced a full-width secret.
+ */
+[[nodiscard]] bool raw_secret(BCRYPT_KEY_HANDLE ours,
+                              BCRYPT_KEY_HANDLE theirs,
+                              std::array<std::byte, kFieldSize>& output) noexcept {
+    BCRYPT_SECRET_HANDLE secret = nullptr;
+    if (!succeeded(BCryptSecretAgreement(ours, theirs, &secret, 0))) {
+        return false;
+    }
+    ULONG produced = 0;
+    const bool derived = succeeded(BCryptDeriveKey(secret,
+                                                   BCRYPT_KDF_RAW_SECRET,
+                                                   nullptr,
+                                                   reinterpret_cast<PUCHAR>(output.data()),
+                                                   static_cast<ULONG>(output.size()),
+                                                   &produced,
+                                                   0));
+    BCryptDestroySecret(secret);
+    if (!derived || produced != output.size()) {
+        // A short derive can still have written part of the secret.
+        SecureZeroMemory(output.data(), output.size());
+        return false;
+    }
+    // Windows hands the raw secret back low byte first; the peer reads it high byte first.
+    std::reverse(output.begin(), output.end());
+    return true;
+}
+
+} // namespace
+
+/** Generates one key pair and agrees a secret with the peer's exported public key. */
+bool agree(std::span<const std::byte> peerPublicKey, Agreement& output) noexcept {
+    std::array<std::byte, kFieldSize> peerX{};
+    std::array<std::byte, kFieldSize> peerY{};
+    if (!decode_public_key(peerPublicKey, peerX, peerY)) {
+        return false;
+    }
+    BCRYPT_ALG_HANDLE algorithm = nullptr;
+    if (!open_provider(algorithm)) {
+        return false;
+    }
+
+    BCRYPT_KEY_HANDLE ours = nullptr;
+    BCRYPT_KEY_HANDLE theirs = nullptr;
+    bool complete = false;
+    if (succeeded(BCryptGenerateKeyPair(algorithm, &ours, kFieldSize * kByteBits, 0))
+        && succeeded(BCryptFinalizeKeyPair(ours, 0))) {
+        std::array<std::byte, sizeof(BCRYPT_ECCKEY_BLOB) + (2 * kFieldSize)> blob{};
+        ULONG produced = 0;
+        if (succeeded(BCryptExportKey(ours,
+                                      nullptr,
+                                      BCRYPT_ECCPUBLIC_BLOB,
+                                      reinterpret_cast<PUCHAR>(blob.data()),
+                                      static_cast<ULONG>(blob.size()),
+                                      &produced,
+                                      0))
+            && produced == blob.size()) {
+            const std::span<const std::byte> point{blob.data() + sizeof(BCRYPT_ECCKEY_BLOB),
+                                                   2 * kFieldSize};
+            complete =
+                encode_public_key(point.first(kFieldSize), point.last(kFieldSize), output.publicKey)
+                && import_peer(algorithm, peerX, peerY, theirs)
+                && raw_secret(ours, theirs, output.sharedSecret);
+        }
+    }
+
+    if (theirs != nullptr) {
+        BCryptDestroyKey(theirs);
+    }
+    if (ours != nullptr) {
+        BCryptDestroyKey(ours);
+    }
+    BCryptCloseAlgorithmProvider(algorithm, 0);
+    if (!complete) {
+        SecureZeroMemory(output.sharedSecret.data(), output.sharedSecret.size());
+        output = {};
+    }
+    return complete;
+}
+
+} // namespace sunrise::middleware::crypto::ecc

+ 31 - 0
Sunrise/src/middleware/crypto/ecc_p224.h

@@ -0,0 +1,31 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <span>
+
+namespace sunrise::middleware::crypto::ecc {
+
+/** secp224r1 coordinates and shared secrets are 28 bytes. */
+inline constexpr std::size_t kFieldSize = 28;
+/** An exported key travels in a fixed field, zero padded past the encoding it carries. */
+inline constexpr std::size_t kExportedKeySize = 100;
+
+/** One completed agreement. */
+struct Agreement {
+    /** Our public key, encoded the way the peer's importer expects. */
+    std::array<std::byte, kExportedKeySize> publicKey{};
+    /** The x coordinate of the agreed point, high byte first. */
+    std::array<std::byte, kFieldSize> sharedSecret{};
+};
+
+/**
+ * Generates one key pair and agrees a secret with the peer's exported public key.
+ * The pair is ephemeral and is destroyed before this returns; only the agreement survives.
+ * @param peerPublicKey Peer's exported key, padding included.
+ * @param output Receives the public key and the secret only on success.
+ * @return True when the peer key parsed and Windows completed the agreement.
+ */
+[[nodiscard]] bool agree(std::span<const std::byte> peerPublicKey, Agreement& output) noexcept;
+
+} // namespace sunrise::middleware::crypto::ecc

+ 143 - 0
Sunrise/src/middleware/crypto/hmac.cpp

@@ -0,0 +1,143 @@
+#include "hmac.h"
+
+#include <Windows.h>
+
+#include <algorithm>
+#include <bcrypt.h>
+#include <limits>
+#include <vector>
+
+#include "murmur3.h"
+
+namespace sunrise::middleware::crypto::hmac {
+
+namespace {
+
+/** SHA-1 produces this many bytes. */
+constexpr std::size_t kSha1DigestSize = 20;
+/** The hand-built construction pads the key to one block. */
+constexpr std::size_t kPadBlockSize = 64;
+/** Byte the inner pad is built with. */
+constexpr std::byte kInnerPad{0x36};
+/** Byte the outer pad is built with. */
+constexpr std::byte kOuterPad{0x5C};
+
+/**
+ * Authenticates with the hand-built construction the peer uses over a one-shot digest.
+ * The key must fit one block. It is zero padded, never hashed down.
+ * @param key Authentication key.
+ * @param first Leading covered bytes.
+ * @param second Trailing covered bytes.
+ * @param output Receives the digest.
+ */
+void authenticate_murmur3(std::span<const std::byte> key,
+                          std::span<const std::byte> first,
+                          std::span<const std::byte> second,
+                          Digest& output) noexcept {
+    std::array<std::byte, kPadBlockSize> inner{};
+    std::array<std::byte, kPadBlockSize> outer{};
+    std::copy(key.begin(), key.end(), inner.begin());
+    std::copy(key.begin(), key.end(), outer.begin());
+    for (std::size_t index = 0; index < kPadBlockSize; ++index) {
+        inner[index] ^= kInnerPad;
+        outer[index] ^= kOuterPad;
+    }
+
+    std::vector<std::byte> body;
+    body.reserve(inner.size() + first.size() + second.size());
+    body.insert(body.end(), inner.begin(), inner.end());
+    body.insert(body.end(), first.begin(), first.end());
+    body.insert(body.end(), second.begin(), second.end());
+
+    murmur3::Digest digest{};
+    murmur3::hash(body, {}, digest);
+    murmur3::Digest sealed{};
+    murmur3::hash(outer, digest, sealed);
+
+    output.size = murmur3::kDigestSize;
+    std::copy(sealed.begin(), sealed.end(), output.bytes.begin());
+    // The pads and the body carry the key.
+    SecureZeroMemory(inner.data(), inner.size());
+    SecureZeroMemory(outer.data(), outer.size());
+    SecureZeroMemory(body.data(), body.size());
+}
+
+/** @return True for a BCrypt status that reports success. */
+[[nodiscard]] bool succeeded(NTSTATUS status) noexcept {
+    return status >= 0;
+}
+
+/** @param algorithm Selected digest. @return The Windows provider name. */
+[[nodiscard]] LPCWSTR provider(Algorithm algorithm) noexcept {
+    return algorithm == Algorithm::sha1 ? BCRYPT_SHA1_ALGORITHM : BCRYPT_SHA256_ALGORITHM;
+}
+
+/** @param algorithm Selected digest. @return Its digest width in bytes. */
+[[nodiscard]] std::size_t digest_size(Algorithm algorithm) noexcept {
+    return algorithm == Algorithm::sha1 ? kSha1DigestSize : kMaximumDigestSize;
+}
+
+/**
+ * Adds one buffer to an open digest.
+ * @param handle Open hash object.
+ * @param part Bytes to add; an empty part is skipped.
+ * @return True when the bytes fit one call and BCrypt accepted them.
+ */
+[[nodiscard]] bool add(BCRYPT_HASH_HANDLE handle, std::span<const std::byte> part) noexcept {
+    if (part.empty()) {
+        return true;
+    }
+    if (part.size() > (std::numeric_limits<ULONG>::max)()) {
+        return false;
+    }
+    return succeeded(BCryptHashData(handle,
+                                    reinterpret_cast<PUCHAR>(const_cast<std::byte*>(part.data())),
+                                    static_cast<ULONG>(part.size()),
+                                    0));
+}
+
+} // namespace
+
+/** Authenticates two buffers as one message. */
+bool authenticate(Algorithm algorithm,
+                  std::span<const std::byte> key,
+                  std::span<const std::byte> first,
+                  std::span<const std::byte> second,
+                  Digest& output) noexcept {
+    if (algorithm == Algorithm::murmur3) {
+        if (key.size() > kPadBlockSize) {
+            return false;
+        }
+        authenticate_murmur3(key, first, second, output);
+        return true;
+    }
+    BCRYPT_ALG_HANDLE opened = nullptr;
+    if (!succeeded(BCryptOpenAlgorithmProvider(
+            &opened, provider(algorithm), nullptr, BCRYPT_ALG_HANDLE_HMAC_FLAG))) {
+        return false;
+    }
+    output.size = digest_size(algorithm);
+    BCRYPT_HASH_HANDLE handle = nullptr;
+    bool complete = false;
+    if (succeeded(BCryptCreateHash(opened,
+                                   &handle,
+                                   nullptr,
+                                   0,
+                                   reinterpret_cast<PUCHAR>(const_cast<std::byte*>(key.data())),
+                                   static_cast<ULONG>(key.size()),
+                                   0))) {
+        complete = add(handle, first) && add(handle, second)
+                   && succeeded(BCryptFinishHash(handle,
+                                                 reinterpret_cast<PUCHAR>(output.bytes.data()),
+                                                 static_cast<ULONG>(output.size),
+                                                 0));
+        BCryptDestroyHash(handle);
+    }
+    BCryptCloseAlgorithmProvider(opened, 0);
+    if (!complete) {
+        output = {};
+    }
+    return complete;
+}
+
+} // namespace sunrise::middleware::crypto::hmac

+ 42 - 0
Sunrise/src/middleware/crypto/hmac.h

@@ -0,0 +1,42 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+namespace sunrise::middleware::crypto::hmac {
+
+/** The widest digest any algorithm here produces. */
+inline constexpr std::size_t kMaximumDigestSize = 32;
+
+/** One finished digest and the bytes of it that are meaningful. */
+struct Digest {
+    std::array<std::byte, kMaximumDigestSize> bytes{};
+    std::size_t size{};
+};
+
+/** Digest algorithms the peer can pick, numbered with the ids it picks them by. */
+enum class Algorithm : std::uint8_t {
+    sha256 = 0,
+    sha1 = 2,
+    murmur3 = 4,
+};
+
+/**
+ * Authenticates two buffers as one message.
+ * Two buffers, not one: the record skips the bytes between its header and its body.
+ * @param algorithm Digest to key.
+ * @param key Authentication key.
+ * @param first Leading covered bytes.
+ * @param second Trailing covered bytes.
+ * @param output Receives the digest only on success.
+ * @return True when Windows completed the operation.
+ */
+[[nodiscard]] bool authenticate(Algorithm algorithm,
+                                std::span<const std::byte> key,
+                                std::span<const std::byte> first,
+                                std::span<const std::byte> second,
+                                Digest& output) noexcept;
+
+} // namespace sunrise::middleware::crypto::hmac

+ 135 - 0
Sunrise/src/middleware/crypto/lookup3.cpp

@@ -0,0 +1,135 @@
+#include "lookup3.h"
+
+namespace sunrise::middleware::crypto::lookup3 {
+
+namespace {
+
+/** Words one main round consumes. */
+constexpr std::size_t kRoundWords = 3;
+/** Bytes in one word. */
+constexpr std::size_t kWordBytes = 4;
+/** Bits in one word. */
+constexpr unsigned kWordBits = 32;
+/** Bits in one byte. */
+constexpr unsigned kByteBits = 8;
+
+/** @return Value rotated left by count. */
+[[nodiscard]] constexpr std::uint32_t rotate(std::uint32_t value, unsigned count) noexcept {
+    return (value << count) | (value >> (kWordBits - count));
+}
+
+/** Mixes three accumulators reversibly. Every rotation constant is part of the algorithm. */
+constexpr void mix(std::uint32_t& a, std::uint32_t& b, std::uint32_t& c) noexcept {
+    a -= c;
+    a ^= rotate(c, 4);
+    c += b;
+    b -= a;
+    b ^= rotate(a, 6);
+    a += c;
+    c -= b;
+    c ^= rotate(b, 8);
+    b += a;
+    a -= c;
+    a ^= rotate(c, 16);
+    c += b;
+    b -= a;
+    b ^= rotate(a, 19);
+    a += c;
+    c -= b;
+    c ^= rotate(b, 4);
+    b += a;
+}
+
+/** Mixes the three accumulators one way, so only c is worth reading afterwards. */
+constexpr void finish(std::uint32_t& a, std::uint32_t& b, std::uint32_t& c) noexcept {
+    c ^= b;
+    c -= rotate(b, 14);
+    a ^= c;
+    a -= rotate(c, 11);
+    b ^= a;
+    b -= rotate(a, 25);
+    c ^= b;
+    c -= rotate(b, 16);
+    a ^= c;
+    a -= rotate(c, 4);
+    b ^= a;
+    b -= rotate(a, 14);
+    c ^= b;
+    c -= rotate(b, 24);
+}
+
+} // namespace
+
+/** Hashes a run of 32-bit words. */
+std::uint32_t hash_words(std::span<const std::uint32_t> words, std::uint32_t initial) noexcept {
+    std::uint32_t a = initial;
+    std::uint32_t b = initial;
+    std::uint32_t c = initial;
+    std::size_t index = 0;
+    // The main rounds stop with one to three words left, unless the input is empty.
+    while (words.size() - index > kRoundWords) {
+        a += words[index];
+        b += words[index + 1];
+        c += words[index + 2];
+        mix(a, b, c);
+        index += kRoundWords;
+    }
+    const std::size_t remaining = words.size() - index;
+    if (remaining == 0) {
+        return c;
+    }
+    if (remaining == kRoundWords) {
+        c += words[index + 2];
+    }
+    if (remaining >= 2) {
+        b += words[index + 1];
+    }
+    a += words[index];
+    finish(a, b, c);
+    return c;
+}
+
+/** Hashes a byte buffer as little-endian words. */
+std::uint32_t hash_bytes(std::span<const std::byte> bytes, std::uint32_t initial) noexcept {
+    if (bytes.size() % kWordBytes != 0) {
+        return 0;
+    }
+    std::uint32_t a = initial;
+    std::uint32_t b = initial;
+    std::uint32_t c = initial;
+    const std::size_t count = bytes.size() / kWordBytes;
+
+    // Bytes are assembled, not reinterpreted: the span has no alignment guarantee.
+    auto word = [bytes](std::size_t position) noexcept {
+        std::uint32_t value = 0;
+        for (std::size_t offset = 0; offset < kWordBytes; ++offset) {
+            value |= std::to_integer<std::uint32_t>(bytes[position * kWordBytes + offset])
+                     << (offset * kByteBits);
+        }
+        return value;
+    };
+
+    std::size_t index = 0;
+    while (count - index > kRoundWords) {
+        a += word(index);
+        b += word(index + 1);
+        c += word(index + 2);
+        mix(a, b, c);
+        index += kRoundWords;
+    }
+    const std::size_t remaining = count - index;
+    if (remaining == 0) {
+        return c;
+    }
+    if (remaining == kRoundWords) {
+        c += word(index + 2);
+    }
+    if (remaining >= 2) {
+        b += word(index + 1);
+    }
+    a += word(index);
+    finish(a, b, c);
+    return c;
+}
+
+} // namespace sunrise::middleware::crypto::lookup3

+ 28 - 0
Sunrise/src/middleware/crypto/lookup3.h

@@ -0,0 +1,28 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+namespace sunrise::middleware::crypto::lookup3 {
+
+/**
+ * Hashes a run of 32-bit words with Bob Jenkins' lookup3, word form.
+ * The start value is a caller literal here, not the usual length and seed.
+ * @param words Words to hash, read in the order given.
+ * @param initial Value all three accumulators start at.
+ * @return The finished hash.
+ */
+[[nodiscard]] std::uint32_t hash_words(std::span<const std::uint32_t> words,
+                                       std::uint32_t initial) noexcept;
+
+/**
+ * Hashes a byte buffer as little-endian 32-bit words.
+ * @param bytes Buffer whose length must be a multiple of four.
+ * @param initial Value all three accumulators start at.
+ * @return The finished hash, or zero when the length is not a multiple of four.
+ */
+[[nodiscard]] std::uint32_t hash_bytes(std::span<const std::byte> bytes,
+                                       std::uint32_t initial) noexcept;
+
+} // namespace sunrise::middleware::crypto::lookup3

+ 285 - 0
Sunrise/src/middleware/crypto/modular_1024.cpp

@@ -0,0 +1,285 @@
+#include "modular_1024.h"
+
+#include <Windows.h>
+
+#include <intrin.h>
+
+namespace sunrise::middleware::crypto::modular {
+
+namespace {
+
+/** Bits in one limb. */
+constexpr std::size_t kLimbBits = 64;
+/** Bits in the whole number, which is also the Montgomery radix exponent. */
+constexpr std::size_t kValueBits = kLimbBits * kLimbCount;
+/** Bytes in one limb. */
+constexpr std::size_t kLimbBytes = sizeof(std::uint64_t);
+/** Bits in one byte of the big-endian wire form. */
+constexpr std::size_t kByteBits = 8;
+/** Mask of one wire byte. */
+constexpr std::uint64_t kByteMask = 0xFF;
+/** Montgomery reduction needs two limbs of headroom above the product. */
+constexpr std::size_t kProductLimbs = kLimbCount + 2;
+/** Each Newton round doubles the correct bits, so 6 lift one bit to the full limb. */
+constexpr unsigned kInverseRounds = 6;
+
+/**
+ * Multiplies and accumulates one limb.
+ * @param left First factor.
+ * @param right Second factor.
+ * @param addend Value added to the product.
+ * @param carry Carried in, and replaced by the high half.
+ * @return Low half of left*right + addend + carry.
+ */
+[[nodiscard]] std::uint64_t multiply_accumulate(std::uint64_t left,
+                                                std::uint64_t right,
+                                                std::uint64_t addend,
+                                                std::uint64_t& carry) noexcept {
+    std::uint64_t high = 0;
+    std::uint64_t low = _umul128(left, right, &high);
+    // Two 64-bit addends cannot overflow the 128-bit product of two 64-bit factors.
+    high += _addcarry_u64(0, low, addend, &low);
+    high += _addcarry_u64(0, low, carry, &low);
+    carry = high;
+    return low;
+}
+
+/**
+ * Subtracts the modulus once.
+ * @param value Limbs reduced in place.
+ * @param modulus Modulus limbs.
+ * @return Borrow out of the most significant limb.
+ */
+[[nodiscard]] unsigned char subtract_modulus(Number& value, const Number& modulus) noexcept {
+    unsigned char borrow = 0;
+    for (std::size_t index = 0; index < kLimbCount; ++index) {
+        borrow = _subborrow_u64(borrow, value[index], modulus[index], &value[index]);
+    }
+    return borrow;
+}
+
+/**
+ * Conditionally subtracts the modulus so the result stays reduced.
+ * @param value Limbs to reduce in place.
+ * @param modulus Modulus limbs.
+ * @param overflow Carry that left the most significant limb.
+ */
+void reduce_once(Number& value, const Number& modulus, unsigned char overflow) noexcept {
+    if (overflow != 0 || !less_than(value, modulus)) {
+        Number reduced = value;
+        // The borrow is discarded: an overflowing sum is exactly one modulus too large.
+        (void)subtract_modulus(reduced, modulus);
+        value = reduced;
+        SecureZeroMemory(reduced.data(), reduced.size() * kLimbBytes);
+    }
+}
+
+/**
+ * Computes the negated inverse of one limb modulo two to the power of 64.
+ * @param limb Odd limb 0 of the modulus.
+ * @return Value whose product with the limb is the all-ones limb plus one.
+ */
+[[nodiscard]] std::uint64_t negated_inverse(std::uint64_t limb) noexcept {
+    std::uint64_t inverse = 1;
+    for (unsigned round = 0; round < kInverseRounds; ++round) {
+        inverse *= 2 - limb * inverse;
+    }
+    return 0 - inverse;
+}
+
+/**
+ * Multiplies in the Montgomery domain.
+ * @param modulus Prepared modulus.
+ * @param left Value below the modulus.
+ * @param right Value below the modulus.
+ * @param output Receives left*right divided by the radix, reduced.
+ */
+void montgomery_multiply(const Modulus& modulus,
+                         const Number& left,
+                         const Number& right,
+                         Number& output) noexcept {
+    std::array<std::uint64_t, kProductLimbs> product{};
+    for (std::size_t outer = 0; outer < kLimbCount; ++outer) {
+        std::uint64_t carry = 0;
+        for (std::size_t inner = 0; inner < kLimbCount; ++inner) {
+            product[inner] = multiply_accumulate(left[inner], right[outer], product[inner], carry);
+        }
+        const unsigned char spill =
+            _addcarry_u64(0, product[kLimbCount], carry, &product[kLimbCount]);
+        product[kLimbCount + 1] = spill;
+
+        // One reduction step clears limb 0, so the window slides down by one limb.
+        const std::uint64_t factor = product[0] * modulus.inverse;
+        carry = 0;
+        (void)multiply_accumulate(factor, modulus.value[0], product[0], carry);
+        for (std::size_t inner = 1; inner < kLimbCount; ++inner) {
+            product[inner - 1] =
+                multiply_accumulate(factor, modulus.value[inner], product[inner], carry);
+        }
+        const unsigned char last =
+            _addcarry_u64(0, product[kLimbCount], carry, &product[kLimbCount - 1]);
+        product[kLimbCount] = product[kLimbCount + 1] + last;
+    }
+    Number result{};
+    for (std::size_t index = 0; index < kLimbCount; ++index) {
+        result[index] = product[index];
+    }
+    reduce_once(result, modulus.value, static_cast<unsigned char>(product[kLimbCount] != 0));
+    output = result;
+    // Both locals hold the value, which is secret on the exponent paths.
+    SecureZeroMemory(product.data(), product.size() * kLimbBytes);
+    SecureZeroMemory(result.data(), result.size() * kLimbBytes);
+}
+
+/**
+ * Doubles a reduced value.
+ * @param modulus Prepared modulus.
+ * @param value Value below the modulus, doubled in place.
+ */
+void double_value(const Modulus& modulus, Number& value) noexcept {
+    unsigned char carry = 0;
+    for (std::size_t index = 0; index < kLimbCount; ++index) {
+        carry = _addcarry_u64(carry, value[index], value[index], &value[index]);
+    }
+    reduce_once(value, modulus.value, carry);
+}
+
+/** @return Bit at the given position, counted from the least significant. */
+[[nodiscard]] bool bit_at(const Number& value, std::size_t position) noexcept {
+    const std::uint64_t limb = value[position / kLimbBits];
+    return ((limb >> (position % kLimbBits)) & 1U) != 0;
+}
+
+} // namespace
+
+/** Reads a fixed-width big-endian integer. */
+void import_big_endian(std::span<const std::byte, kByteSize> input, Number& output) noexcept {
+    output = {};
+    for (std::size_t index = 0; index < kByteSize; ++index) {
+        const std::size_t limb = (kByteSize - 1 - index) / kLimbBytes;
+        const std::size_t shift = ((kByteSize - 1 - index) % kLimbBytes) * kByteBits;
+        output[limb] |= static_cast<std::uint64_t>(std::to_integer<unsigned char>(input[index]))
+                        << shift;
+    }
+}
+
+/** Writes a fixed-width big-endian integer. */
+void export_big_endian(const Number& value, std::span<std::byte, kByteSize> output) noexcept {
+    for (std::size_t index = 0; index < kByteSize; ++index) {
+        const std::size_t limb = (kByteSize - 1 - index) / kLimbBytes;
+        const std::size_t shift = ((kByteSize - 1 - index) % kLimbBytes) * kByteBits;
+        output[index] = static_cast<std::byte>((value[limb] >> shift) & kByteMask);
+    }
+}
+
+/** Sets one number to a small constant. */
+void set_small(Number& output, std::uint64_t value) noexcept {
+    output = {};
+    output[0] = value;
+}
+
+/** Reports an all-zero value. */
+bool is_zero(const Number& value) noexcept {
+    for (const std::uint64_t limb : value) {
+        if (limb != 0) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** Compares two values. */
+bool less_than(const Number& left, const Number& right) noexcept {
+    for (std::size_t index = kLimbCount; index > 0; --index) {
+        const std::uint64_t high = left[index - 1];
+        const std::uint64_t low = right[index - 1];
+        if (high != low) {
+            return high < low;
+        }
+    }
+    return false;
+}
+
+/** Derives the Montgomery constants for one modulus. */
+bool prepare(const Number& modulus, Modulus& output) noexcept {
+    const bool odd = (modulus[0] & 1U) != 0;
+    const bool normalized = (modulus[kLimbCount - 1] >> (kLimbBits - 1)) != 0;
+    if (!odd || !normalized) {
+        return false;
+    }
+    Modulus candidate{};
+    candidate.value = modulus;
+    candidate.inverse = negated_inverse(modulus[0]);
+
+    // The radix is below twice a normalized modulus, so its residue is one subtraction away.
+    Number residue{};
+    unsigned char borrow = 0;
+    for (std::size_t index = 0; index < kLimbCount; ++index) {
+        borrow = _subborrow_u64(borrow, 0, modulus[index], &residue[index]);
+    }
+    // Doubling the radix residue once per bit of the radix yields the squared factor.
+    for (std::size_t round = 0; round < kValueBits; ++round) {
+        double_value(candidate, residue);
+    }
+    candidate.montgomeryFactor = residue;
+    candidate.ready = true;
+    output = candidate;
+    return true;
+}
+
+/** Adds two reduced values. */
+void add(const Modulus& modulus, const Number& left, const Number& right, Number& output) noexcept {
+    Number result{};
+    unsigned char carry = 0;
+    for (std::size_t index = 0; index < kLimbCount; ++index) {
+        carry = _addcarry_u64(carry, left[index], right[index], &result[index]);
+    }
+    reduce_once(result, modulus.value, carry);
+    output = result;
+    SecureZeroMemory(result.data(), result.size() * kLimbBytes);
+}
+
+/** Multiplies two reduced values. */
+void multiply(const Modulus& modulus,
+              const Number& left,
+              const Number& right,
+              Number& output) noexcept {
+    Number product{};
+    montgomery_multiply(modulus, left, right, product);
+    montgomery_multiply(modulus, product, modulus.montgomeryFactor, output);
+    SecureZeroMemory(product.data(), product.size() * kLimbBytes);
+}
+
+/** Raises a reduced value to a power. */
+void power(const Modulus& modulus,
+           const Number& base,
+           const Number& exponent,
+           Number& output) noexcept {
+    Number one{};
+    set_small(one, 1);
+    Number accumulator{};
+    montgomery_multiply(modulus, one, modulus.montgomeryFactor, accumulator);
+    Number factor{};
+    montgomery_multiply(modulus, base, modulus.montgomeryFactor, factor);
+
+    std::size_t highest = 0;
+    for (std::size_t position = kValueBits; position > 0; --position) {
+        if (bit_at(exponent, position - 1)) {
+            highest = position;
+            break;
+        }
+    }
+    for (std::size_t position = highest; position > 0; --position) {
+        montgomery_multiply(modulus, accumulator, accumulator, accumulator);
+        if (bit_at(exponent, position - 1)) {
+            montgomery_multiply(modulus, accumulator, factor, accumulator);
+        }
+    }
+    Number one_montgomery{};
+    set_small(one_montgomery, 1);
+    montgomery_multiply(modulus, accumulator, one_montgomery, output);
+    SecureZeroMemory(accumulator.data(), accumulator.size() * kLimbBytes);
+    SecureZeroMemory(factor.data(), factor.size() * kLimbBytes);
+}
+
+} // namespace sunrise::middleware::crypto::modular

+ 96 - 0
Sunrise/src/middleware/crypto/modular_1024.h

@@ -0,0 +1,96 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+namespace sunrise::middleware::crypto::modular {
+
+/** Direct-association integers are 1024 bits and travel as 128 big-endian bytes. */
+inline constexpr std::size_t kByteSize = 128;
+/** The value is held in 64-bit limbs, least significant first. */
+inline constexpr std::size_t kLimbCount = kByteSize / sizeof(std::uint64_t);
+
+/** One 1024-bit unsigned value, limb 0 least significant. */
+using Number = std::array<std::uint64_t, kLimbCount>;
+
+/**
+ * One odd modulus and the Montgomery constants derived from it.
+ * Build it once per process: preparing it costs far more than a single multiply.
+ */
+struct Modulus {
+    Number value{};
+    /** R squared modulo the value, where R is two to the power of 1024. */
+    Number montgomeryFactor{};
+    /** Negated inverse of limb 0 modulo two to the power of 64. */
+    std::uint64_t inverse{};
+    bool ready{};
+};
+
+/**
+ * Reads a fixed-width big-endian integer.
+ * @param input Exactly 128 bytes, left zero padded as the wire carries them.
+ * @param output Receives the limbs.
+ */
+void import_big_endian(std::span<const std::byte, kByteSize> input, Number& output) noexcept;
+
+/**
+ * Writes a fixed-width big-endian integer.
+ * @param value Number to write.
+ * @param output Receives exactly 128 bytes, left zero padded.
+ */
+void export_big_endian(const Number& value, std::span<std::byte, kByteSize> output) noexcept;
+
+/** Sets one number to a small constant. */
+void set_small(Number& output, std::uint64_t value) noexcept;
+
+/** @return True when every limb is zero. */
+[[nodiscard]] bool is_zero(const Number& value) noexcept;
+
+/** @return True when left is strictly below right. */
+[[nodiscard]] bool less_than(const Number& left, const Number& right) noexcept;
+
+/**
+ * Derives the Montgomery constants for one modulus.
+ * @param modulus Odd modulus with its most significant bit set.
+ * @param output Receives the prepared modulus only on success.
+ * @return True when the modulus is odd and normalized.
+ */
+[[nodiscard]] bool prepare(const Number& modulus, Modulus& output) noexcept;
+
+/**
+ * Adds two reduced values.
+ * @param modulus Prepared modulus.
+ * @param left Value below the modulus.
+ * @param right Value below the modulus.
+ * @param output Receives the reduced sum. Aliasing an input is allowed.
+ */
+void add(const Modulus& modulus, const Number& left, const Number& right, Number& output) noexcept;
+
+/**
+ * Multiplies two reduced values.
+ * @param modulus Prepared modulus.
+ * @param left Value below the modulus.
+ * @param right Value below the modulus.
+ * @param output Receives the reduced product. Aliasing an input is allowed.
+ */
+void multiply(const Modulus& modulus,
+              const Number& left,
+              const Number& right,
+              Number& output) noexcept;
+
+/**
+ * Raises a reduced value to a power.
+ * The exponent is read as a plain integer, so leading zero limbs cost nothing.
+ * @param modulus Prepared modulus.
+ * @param base Value below the modulus.
+ * @param exponent Exponent, not reduced.
+ * @param output Receives the reduced power. Aliasing an input is allowed.
+ */
+void power(const Modulus& modulus,
+           const Number& base,
+           const Number& exponent,
+           Number& output) noexcept;
+
+} // namespace sunrise::middleware::crypto::modular

+ 139 - 0
Sunrise/src/middleware/crypto/murmur3.cpp

@@ -0,0 +1,139 @@
+#include "murmur3.h"
+
+#include <cstdint>
+#include <vector>
+
+namespace sunrise::middleware::crypto::murmur3 {
+
+namespace {
+
+/** Bits in one byte. */
+constexpr unsigned kByteBits = 8;
+/** The variant consumes the message in 16-byte blocks. */
+constexpr std::size_t kBlockSize = 16;
+/** First block multiplier. */
+constexpr std::uint64_t kFirstMultiplier = 0x87C37B91114253D5ULL;
+/** Second block multiplier. */
+constexpr std::uint64_t kSecondMultiplier = 0x4CF5AD432745937FULL;
+/** First finalization multiplier. */
+constexpr std::uint64_t kFirstFinal = 0xFF51AFD7ED558CCDULL;
+/** Second finalization multiplier. */
+constexpr std::uint64_t kSecondFinal = 0xC4CEB9FE1A85EC53ULL;
+/** Additive constant of the first lane. */
+constexpr std::uint64_t kFirstAddend = 0x52DCE729ULL;
+/** Additive constant of the second lane. */
+constexpr std::uint64_t kSecondAddend = 0x38495AB5ULL;
+/** Lane mixing multiplier. */
+constexpr std::uint64_t kLaneMultiplier = 5;
+/** Shift the finalizer folds by. */
+constexpr unsigned kFinalShift = 33;
+
+/** @param value Input. @param count Bit count. @return The value rotated left. */
+[[nodiscard]] std::uint64_t rotate(std::uint64_t value, unsigned count) noexcept {
+    return (value << count) | (value >> (64U - count));
+}
+
+/** @param value Input. @return The avalanche of one lane. */
+[[nodiscard]] std::uint64_t finalize(std::uint64_t value) noexcept {
+    std::uint64_t mixed = value;
+    mixed ^= mixed >> kFinalShift;
+    mixed *= kFirstFinal;
+    mixed ^= mixed >> kFinalShift;
+    mixed *= kSecondFinal;
+    mixed ^= mixed >> kFinalShift;
+    return mixed;
+}
+
+/**
+ * Reads one 64-bit word, low byte first.
+ * @param input Message bytes.
+ * @param offset First byte of the word.
+ * @return Host-order value.
+ */
+[[nodiscard]] std::uint64_t read_word(std::span<const std::byte> input,
+                                      std::size_t offset) noexcept {
+    std::uint64_t value = 0;
+    for (std::size_t index = 0; index < sizeof(std::uint64_t); ++index) {
+        value |= std::to_integer<std::uint64_t>(input[offset + index]) << (index * kByteBits);
+    }
+    return value;
+}
+
+} // namespace
+
+/** Hashes two buffers as one message with the 64-bit 128-wide variant, seed zero. */
+void hash(std::span<const std::byte> first,
+          std::span<const std::byte> second,
+          Digest& output) noexcept {
+    // The variant has no streaming form, so the two parts are joined before it runs.
+    std::vector<std::byte> message;
+    message.reserve(first.size() + second.size());
+    message.insert(message.end(), first.begin(), first.end());
+    message.insert(message.end(), second.begin(), second.end());
+    const std::span<const std::byte> input{message};
+
+    std::uint64_t lowLane = 0;
+    std::uint64_t highLane = 0;
+    const std::size_t blocks = input.size() / kBlockSize;
+    for (std::size_t block = 0; block < blocks; ++block) {
+        std::uint64_t low = read_word(input, block * kBlockSize);
+        std::uint64_t high = read_word(input, (block * kBlockSize) + sizeof(std::uint64_t));
+        low *= kFirstMultiplier;
+        low = rotate(low, 31);
+        low *= kSecondMultiplier;
+        lowLane ^= low;
+        lowLane = rotate(lowLane, 27);
+        lowLane += highLane;
+        lowLane = (lowLane * kLaneMultiplier) + kFirstAddend;
+        high *= kSecondMultiplier;
+        high = rotate(high, 33);
+        high *= kFirstMultiplier;
+        highLane ^= high;
+        highLane = rotate(highLane, 31);
+        highLane += lowLane;
+        highLane = (highLane * kLaneMultiplier) + kSecondAddend;
+    }
+
+    std::uint64_t lowTail = 0;
+    std::uint64_t highTail = 0;
+    const std::size_t consumed = blocks * kBlockSize;
+    const std::size_t remainder = input.size() - consumed;
+    for (std::size_t index = remainder; index > 0; --index) {
+        const auto value = std::to_integer<std::uint64_t>(input[consumed + index - 1]);
+        if (index > sizeof(std::uint64_t)) {
+            highTail ^= value << ((index - 1 - sizeof(std::uint64_t)) * kByteBits);
+        } else {
+            lowTail ^= value << ((index - 1) * kByteBits);
+        }
+    }
+    if (remainder > sizeof(std::uint64_t)) {
+        highTail *= kSecondMultiplier;
+        highTail = rotate(highTail, 33);
+        highTail *= kFirstMultiplier;
+        highLane ^= highTail;
+    }
+    if (remainder > 0) {
+        lowTail *= kFirstMultiplier;
+        lowTail = rotate(lowTail, 31);
+        lowTail *= kSecondMultiplier;
+        lowLane ^= lowTail;
+    }
+
+    const auto length = static_cast<std::uint64_t>(input.size());
+    lowLane ^= length;
+    highLane ^= length;
+    lowLane += highLane;
+    highLane += lowLane;
+    lowLane = finalize(lowLane);
+    highLane = finalize(highLane);
+    lowLane += highLane;
+    highLane += lowLane;
+
+    for (std::size_t index = 0; index < sizeof(std::uint64_t); ++index) {
+        output[index] = static_cast<std::byte>((lowLane >> (index * kByteBits)) & 0xFFU);
+        output[index + sizeof(std::uint64_t)] =
+            static_cast<std::byte>((highLane >> (index * kByteBits)) & 0xFFU);
+    }
+}
+
+} // namespace sunrise::middleware::crypto::murmur3

+ 26 - 0
Sunrise/src/middleware/crypto/murmur3.h

@@ -0,0 +1,26 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <span>
+
+namespace sunrise::middleware::crypto::murmur3 {
+
+/** The 128-bit variant produces 16 bytes. */
+inline constexpr std::size_t kDigestSize = 16;
+
+/** One finished digest. */
+using Digest = std::array<std::byte, kDigestSize>;
+
+/**
+ * Hashes two buffers as one message with the 64-bit 128-wide variant, seed zero.
+ * Two buffers, not one: callers hash a fixed block followed by a body.
+ * @param first Leading bytes.
+ * @param second Trailing bytes, possibly empty.
+ * @param output Receives the digest.
+ */
+void hash(std::span<const std::byte> first,
+          std::span<const std::byte> second,
+          Digest& output) noexcept;
+
+} // namespace sunrise::middleware::crypto::murmur3

+ 22 - 0
Sunrise/src/middleware/crypto/random_bytes.cpp

@@ -0,0 +1,22 @@
+#include "random_bytes.h"
+
+#include <Windows.h>
+
+#include <bcrypt.h>
+#include <limits>
+
+namespace sunrise::middleware::crypto::random {
+
+/** Fills a buffer with Windows system randomness. */
+bool fill(std::span<std::byte> output) noexcept {
+    if (output.empty() || output.size() > (std::numeric_limits<ULONG>::max)()) {
+        return false;
+    }
+    return BCryptGenRandom(nullptr,
+                           reinterpret_cast<PUCHAR>(output.data()),
+                           static_cast<ULONG>(output.size()),
+                           BCRYPT_USE_SYSTEM_PREFERRED_RNG)
+           >= 0;
+}
+
+} // namespace sunrise::middleware::crypto::random

+ 16 - 0
Sunrise/src/middleware/crypto/random_bytes.h

@@ -0,0 +1,16 @@
+#pragma once
+
+#include <cstddef>
+#include <span>
+
+namespace sunrise::middleware::crypto::random {
+
+/**
+ * Fills a buffer with Windows system randomness.
+ * Every ephemeral secret Sunrise owns comes from here, never from an authored default.
+ * @param output Storage to overwrite completely.
+ * @return True when Windows produced every byte.
+ */
+[[nodiscard]] bool fill(std::span<std::byte> output) noexcept;
+
+} // namespace sunrise::middleware::crypto::random

+ 78 - 0
Sunrise/src/middleware/crypto/sha256.cpp

@@ -0,0 +1,78 @@
+#include "sha256.h"
+
+#include <Windows.h>
+
+#include <bcrypt.h>
+#include <limits>
+
+namespace sunrise::middleware::crypto::sha256 {
+
+namespace {
+
+/** @return True for a BCrypt status that reports success. */
+[[nodiscard]] bool succeeded(NTSTATUS status) noexcept {
+    return status >= 0;
+}
+
+/**
+ * Adds one buffer to an open hash.
+ * @param handle Open hash object.
+ * @param part Bytes to add; an empty part is skipped.
+ * @return True when the bytes fit one call and BCrypt accepted them.
+ */
+[[nodiscard]] bool add(BCRYPT_HASH_HANDLE handle, std::span<const std::byte> part) noexcept {
+    if (part.empty()) {
+        return true;
+    }
+    if (part.size() > (std::numeric_limits<ULONG>::max)()) {
+        return false;
+    }
+    return succeeded(BCryptHashData(handle,
+                                    reinterpret_cast<PUCHAR>(const_cast<std::byte*>(part.data())),
+                                    static_cast<ULONG>(part.size()),
+                                    0));
+}
+
+/**
+ * Hashes up to two buffers as one message.
+ * @param first Leading bytes.
+ * @param second Trailing bytes, possibly empty.
+ * @param output Receives the digest only on success.
+ * @return True when every stage of the Windows hash succeeded.
+ */
+[[nodiscard]] bool digest(std::span<const std::byte> first,
+                          std::span<const std::byte> second,
+                          Digest& output) noexcept {
+    BCRYPT_ALG_HANDLE algorithm = nullptr;
+    if (!succeeded(BCryptOpenAlgorithmProvider(&algorithm, BCRYPT_SHA256_ALGORITHM, nullptr, 0))) {
+        return false;
+    }
+    BCRYPT_HASH_HANDLE handle = nullptr;
+    bool complete = false;
+    if (succeeded(BCryptCreateHash(algorithm, &handle, nullptr, 0, nullptr, 0, 0))) {
+        complete = add(handle, first) && add(handle, second)
+                   && succeeded(BCryptFinishHash(handle,
+                                                 reinterpret_cast<PUCHAR>(output.data()),
+                                                 static_cast<ULONG>(output.size()),
+                                                 0));
+        BCryptDestroyHash(handle);
+    }
+    BCryptCloseAlgorithmProvider(algorithm, 0);
+    return complete;
+}
+
+} // namespace
+
+/** Hashes one buffer. */
+bool hash(std::span<const std::byte> input, Digest& output) noexcept {
+    return digest(input, {}, output);
+}
+
+/** Hashes two buffers as one concatenated message. */
+bool hash_pair(std::span<const std::byte> first,
+               std::span<const std::byte> second,
+               Digest& output) noexcept {
+    return digest(first, second, output);
+}
+
+} // namespace sunrise::middleware::crypto::sha256

+ 35 - 0
Sunrise/src/middleware/crypto/sha256.h

@@ -0,0 +1,35 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <span>
+
+namespace sunrise::middleware::crypto::sha256 {
+
+/** SHA-256 produces 32 bytes. */
+inline constexpr std::size_t kDigestSize = 32;
+
+/** One finished SHA-256 digest. */
+using Digest = std::array<std::byte, kDigestSize>;
+
+/**
+ * Hashes one buffer.
+ * @param input Bytes to hash.
+ * @param output Receives the digest only on success.
+ * @return True when Windows completed the hash.
+ */
+[[nodiscard]] bool hash(std::span<const std::byte> input, Digest& output) noexcept;
+
+/**
+ * Hashes two buffers as one concatenated message.
+ * The SRP scrambling parameter is defined over a concatenation, so it needs no scratch copy.
+ * @param first Leading bytes.
+ * @param second Trailing bytes.
+ * @param output Receives the digest only on success.
+ * @return True when Windows completed the hash.
+ */
+[[nodiscard]] bool hash_pair(std::span<const std::byte> first,
+                             std::span<const std::byte> second,
+                             Digest& output) noexcept;
+
+} // namespace sunrise::middleware::crypto::sha256

+ 436 - 0
Sunrise/src/middleware/crypto/tiger192.cpp

@@ -0,0 +1,436 @@
+#include "tiger192.h"
+
+#include <cstdint>
+
+namespace sunrise::middleware::crypto::tiger {
+
+namespace {
+
+/** Bits in one byte. */
+constexpr unsigned kByteBits = 8;
+/** Mask of one byte. */
+constexpr std::uint64_t kByteMask = 0xFF;
+/** Tiger consumes the message in 64-byte blocks. */
+constexpr std::size_t kBlockSize = 64;
+/** A block is eight 64-bit words. */
+constexpr std::size_t kBlockWords = 8;
+/** Padding runs to this offset in the final block, leaving room for the length. */
+constexpr std::size_t kLengthOffset = 56;
+/** The byte that opens the padding. Tiger2 uses 0x80 instead. */
+constexpr std::byte kPadByte{0x01};
+
+/** The four substitution boxes, taken whole from the reference tables. */
+constexpr std::uint64_t kTable[] = {
+    0x02AAB17CF7E90C5EULL, 0xAC424B03E243A8ECULL, 0x72CD5BE30DD5FCD3ULL, 0x6D019B93F6F97F3AULL,
+    0xCD9978FFD21F9193ULL, 0x7573A1C9708029E2ULL, 0xB164326B922A83C3ULL, 0x46883EEE04915870ULL,
+    0xEAACE3057103ECE6ULL, 0xC54169B808A3535CULL, 0x4CE754918DDEC47CULL, 0x0AA2F4DFDC0DF40CULL,
+    0x10B76F18A74DBEFAULL, 0xC6CCB6235AD1AB6AULL, 0x13726121572FE2FFULL, 0x1A488C6F199D921EULL,
+    0x4BC9F9F4DA0007CAULL, 0x26F5E6F6E85241C7ULL, 0x859079DBEA5947B6ULL, 0x4F1885C5C99E8C92ULL,
+    0xD78E761EA96F864BULL, 0x8E36428C52B5C17DULL, 0x69CF6827373063C1ULL, 0xB607C93D9BB4C56EULL,
+    0x7D820E760E76B5EAULL, 0x645C9CC6F07FDC42ULL, 0xBF38A078243342E0ULL, 0x5F6B343C9D2E7D04ULL,
+    0xF2C28AEB600B0EC6ULL, 0x6C0ED85F7254BCACULL, 0x71592281A4DB4FE5ULL, 0x1967FA69CE0FED9FULL,
+    0xFD5293F8B96545DBULL, 0xC879E9D7F2A7600BULL, 0x860248920193194EULL, 0xA4F9533B2D9CC0B3ULL,
+    0x9053836C15957613ULL, 0xDB6DCF8AFC357BF1ULL, 0x18BEEA7A7A370F57ULL, 0x037117CA50B99066ULL,
+    0x6AB30A9774424A35ULL, 0xF4E92F02E325249BULL, 0x7739DB07061CCAE1ULL, 0xD8F3B49CECA42A05ULL,
+    0xBD56BE3F51382F73ULL, 0x45FAED5843B0BB28ULL, 0x1C813D5C11BF1F83ULL, 0x8AF0E4B6D75FA169ULL,
+    0x33EE18A487AD9999ULL, 0x3C26E8EAB1C94410ULL, 0xB510102BC0A822F9ULL, 0x141EEF310CE6123BULL,
+    0xFC65B90059DDB154ULL, 0xE0158640C5E0E607ULL, 0x884E079826C3A3CFULL, 0x930D0D9523C535FDULL,
+    0x35638D754E9A2B00ULL, 0x4085FCCF40469DD5ULL, 0xC4B17AD28BE23A4CULL, 0xCAB2F0FC6A3E6A2EULL,
+    0x2860971A6B943FCDULL, 0x3DDE6EE212E30446ULL, 0x6222F32AE01765AEULL, 0x5D550BB5478308FEULL,
+    0xA9EFA98DA0EDA22AULL, 0xC351A71686C40DA7ULL, 0x1105586D9C867C84ULL, 0xDCFFEE85FDA22853ULL,
+    0xCCFBD0262C5EEF76ULL, 0xBAF294CB8990D201ULL, 0xE69464F52AFAD975ULL, 0x94B013AFDF133E14ULL,
+    0x06A7D1A32823C958ULL, 0x6F95FE5130F61119ULL, 0xD92AB34E462C06C0ULL, 0xED7BDE33887C71D2ULL,
+    0x79746D6E6518393EULL, 0x5BA419385D713329ULL, 0x7C1BA6B948A97564ULL, 0x31987C197BFDAC67ULL,
+    0xDE6C23C44B053D02ULL, 0x581C49FED002D64DULL, 0xDD474D6338261571ULL, 0xAA4546C3E473D062ULL,
+    0x928FCE349455F860ULL, 0x48161BBACAAB94D9ULL, 0x63912430770E6F68ULL, 0x6EC8A5E602C6641CULL,
+    0x87282515337DDD2BULL, 0x2CDA6B42034B701BULL, 0xB03D37C181CB096DULL, 0xE108438266C71C6FULL,
+    0x2B3180C7EB51B255ULL, 0xDF92B82F96C08BBCULL, 0x5C68C8C0A632F3BAULL, 0x5504CC861C3D0556ULL,
+    0xABBFA4E55FB26B8FULL, 0x41848B0AB3BACEB4ULL, 0xB334A273AA445D32ULL, 0xBCA696F0A85AD881ULL,
+    0x24F6EC65B528D56CULL, 0x0CE1512E90F4524AULL, 0x4E9DD79D5506D35AULL, 0x258905FAC6CE9779ULL,
+    0x2019295B3E109B33ULL, 0xF8A9478B73A054CCULL, 0x2924F2F934417EB0ULL, 0x3993357D536D1BC4ULL,
+    0x38A81AC21DB6FF8BULL, 0x47C4FBF17D6016BFULL, 0x1E0FAADD7667E3F5ULL, 0x7ABCFF62938BEB96ULL,
+    0xA78DAD948FC179C9ULL, 0x8F1F98B72911E50DULL, 0x61E48EAE27121A91ULL, 0x4D62F7AD31859808ULL,
+    0xECEBA345EF5CEAEBULL, 0xF5CEB25EBC9684CEULL, 0xF633E20CB7F76221ULL, 0xA32CDF06AB8293E4ULL,
+    0x985A202CA5EE2CA4ULL, 0xCF0B8447CC8A8FB1ULL, 0x9F765244979859A3ULL, 0xA8D516B1A1240017ULL,
+    0x0BD7BA3EBB5DC726ULL, 0xE54BCA55B86ADB39ULL, 0x1D7A3AFD6C478063ULL, 0x519EC608E7669EDDULL,
+    0x0E5715A2D149AA23ULL, 0x177D4571848FF194ULL, 0xEEB55F3241014C22ULL, 0x0F5E5CA13A6E2EC2ULL,
+    0x8029927B75F5C361ULL, 0xAD139FABC3D6E436ULL, 0x0D5DF1A94CCF402FULL, 0x3E8BD948BEA5DFC8ULL,
+    0xA5A0D357BD3FF77EULL, 0xA2D12E251F74F645ULL, 0x66FD9E525E81A082ULL, 0x2E0C90CE7F687A49ULL,
+    0xC2E8BCBEBA973BC5ULL, 0x000001BCE509745FULL, 0x423777BBE6DAB3D6ULL, 0xD1661C7EAEF06EB5ULL,
+    0xA1781F354DAACFD8ULL, 0x2D11284A2B16AFFCULL, 0xF1FC4F67FA891D1FULL, 0x73ECC25DCB920ADAULL,
+    0xAE610C22C2A12651ULL, 0x96E0A810D356B78AULL, 0x5A9A381F2FE7870FULL, 0xD5AD62EDE94E5530ULL,
+    0xD225E5E8368D1427ULL, 0x65977B70C7AF4631ULL, 0x99F889B2DE39D74FULL, 0x233F30BF54E1D143ULL,
+    0x9A9675D3D9A63C97ULL, 0x5470554FF334F9A8ULL, 0x166ACB744A4F5688ULL, 0x70C74CAAB2E4AEADULL,
+    0xF0D091646F294D12ULL, 0x57B82A89684031D1ULL, 0xEFD95A5A61BE0B6BULL, 0x2FBD12E969F2F29AULL,
+    0x9BD37013FEFF9FE8ULL, 0x3F9B0404D6085A06ULL, 0x4940C1F3166CFE15ULL, 0x09542C4DCDF3DEFBULL,
+    0xB4C5218385CD5CE3ULL, 0xC935B7DC4462A641ULL, 0x3417F8A68ED3B63FULL, 0xB80959295B215B40ULL,
+    0xF99CDAEF3B8C8572ULL, 0x018C0614F8FCB95DULL, 0x1B14ACCD1A3ACDF3ULL, 0x84D471F200BB732DULL,
+    0xC1A3110E95E8DA16ULL, 0x430A7220BF1A82B8ULL, 0xB77E090D39DF210EULL, 0x5EF4BD9F3CD05E9DULL,
+    0x9D4FF6DA7E57A444ULL, 0xDA1D60E183D4A5F8ULL, 0xB287C38417998E47ULL, 0xFE3EDC121BB31886ULL,
+    0xC7FE3CCC980CCBEFULL, 0xE46FB590189BFD03ULL, 0x3732FD469A4C57DCULL, 0x7EF700A07CF1AD65ULL,
+    0x59C64468A31D8859ULL, 0x762FB0B4D45B61F6ULL, 0x155BAED099047718ULL, 0x68755E4C3D50BAA6ULL,
+    0xE9214E7F22D8B4DFULL, 0x2ADDBF532EAC95F4ULL, 0x32AE3909B4BD0109ULL, 0x834DF537B08E3450ULL,
+    0xFA209DA84220728DULL, 0x9E691D9B9EFE23F7ULL, 0x0446D288C4AE8D7FULL, 0x7B4CC524E169785BULL,
+    0x21D87F0135CA1385ULL, 0xCEBB400F137B8AA5ULL, 0x272E2B66580796BEULL, 0x3612264125C2B0DEULL,
+    0x057702BDAD1EFBB2ULL, 0xD4BABB8EACF84BE9ULL, 0x91583139641BC67BULL, 0x8BDC2DE08036E024ULL,
+    0x603C8156F49F68EDULL, 0xF7D236F7DBEF5111ULL, 0x9727C4598AD21E80ULL, 0xA08A0896670A5FD7ULL,
+    0xCB4A8F4309EBA9CBULL, 0x81AF564B0F7036A1ULL, 0xC0B99AA778199ABDULL, 0x959F1EC83FC8E952ULL,
+    0x8C505077794A81B9ULL, 0x3ACAAF8F056338F0ULL, 0x07B43F50627A6778ULL, 0x4A44AB49F5ECCC77ULL,
+    0x3BC3D6E4B679EE98ULL, 0x9CC0D4D1CF14108CULL, 0x4406C00B206BC8A0ULL, 0x82A18854C8D72D89ULL,
+    0x67E366B35C3C432CULL, 0xB923DD61102B37F2ULL, 0x56AB2779D884271DULL, 0xBE83E1B0FF1525AFULL,
+    0xFB7C65D4217E49A9ULL, 0x6BDBE0E76D48E7D4ULL, 0x08DF828745D9179EULL, 0x22EA6A9ADD53BD34ULL,
+    0xE36E141C5622200AULL, 0x7F805D1B8CB750EEULL, 0xAFE5C7A59F58E837ULL, 0xE27F996A4FB1C23CULL,
+    0xD3867DFB0775F0D0ULL, 0xD0E673DE6E88891AULL, 0x123AEB9EAFB86C25ULL, 0x30F1D5D5C145B895ULL,
+    0xBB434A2DEE7269E7ULL, 0x78CB67ECF931FA38ULL, 0xF33B0372323BBF9CULL, 0x52D66336FB279C74ULL,
+    0x505F33AC0AFB4EAAULL, 0xE8A5CD99A2CCE187ULL, 0x534974801E2D30BBULL, 0x8D2D5711D5876D90ULL,
+    0x1F1A412891BC038EULL, 0xD6E2E71D82E56648ULL, 0x74036C3A497732B7ULL, 0x89B67ED96361F5ABULL,
+    0xFFED95D8F1EA02A2ULL, 0xE72B3BD61464D43DULL, 0xA6300F170BDC4820ULL, 0xEBC18760ED78A77AULL,
+    0xE6A6BE5A05A12138ULL, 0xB5A122A5B4F87C98ULL, 0x563C6089140B6990ULL, 0x4C46CB2E391F5DD5ULL,
+    0xD932ADDBC9B79434ULL, 0x08EA70E42015AFF5ULL, 0xD765A6673E478CF1ULL, 0xC4FB757EAB278D99ULL,
+    0xDF11C6862D6E0692ULL, 0xDDEB84F10D7F3B16ULL, 0x6F2EF604A665EA04ULL, 0x4A8E0F0FF0E0DFB3ULL,
+    0xA5EDEEF83DBCBA51ULL, 0xFC4F0A2A0EA4371EULL, 0xE83E1DA85CB38429ULL, 0xDC8FF882BA1B1CE2ULL,
+    0xCD45505E8353E80DULL, 0x18D19A00D4DB0717ULL, 0x34A0CFEDA5F38101ULL, 0x0BE77E518887CAF2ULL,
+    0x1E341438B3C45136ULL, 0xE05797F49089CCF9ULL, 0xFFD23F9DF2591D14ULL, 0x543DDA228595C5CDULL,
+    0x661F81FD99052A33ULL, 0x8736E641DB0F7B76ULL, 0x15227725418E5307ULL, 0xE25F7F46162EB2FAULL,
+    0x48A8B2126C13D9FEULL, 0xAFDC541792E76EEAULL, 0x03D912BFC6D1898FULL, 0x31B1AAFA1B83F51BULL,
+    0xF1AC2796E42AB7D9ULL, 0x40A3A7D7FCD2EBACULL, 0x1056136D0AFBBCC5ULL, 0x7889E1DD9A6D0C85ULL,
+    0xD33525782A7974AAULL, 0xA7E25D09078AC09BULL, 0xBD4138B3EAC6EDD0ULL, 0x920ABFBE71EB9E70ULL,
+    0xA2A5D0F54FC2625CULL, 0xC054E36B0B1290A3ULL, 0xF6DD59FF62FE932BULL, 0x3537354511A8AC7DULL,
+    0xCA845E9172FADCD4ULL, 0x84F82B60329D20DCULL, 0x79C62CE1CD672F18ULL, 0x8B09A2ADD124642CULL,
+    0xD0C1E96A19D9E726ULL, 0x5A786A9B4BA9500CULL, 0x0E020336634C43F3ULL, 0xC17B474AEB66D822ULL,
+    0x6A731AE3EC9BAAC2ULL, 0x8226667AE0840258ULL, 0x67D4567691CAECA5ULL, 0x1D94155C4875ADB5ULL,
+    0x6D00FD985B813FDFULL, 0x51286EFCB774CD06ULL, 0x5E8834471FA744AFULL, 0xF72CA0AEE761AE2EULL,
+    0xBE40E4CDAEE8E09AULL, 0xE9970BBB5118F665ULL, 0x726E4BEB33DF1964ULL, 0x703B000729199762ULL,
+    0x4631D816F5EF30A7ULL, 0xB880B5B51504A6BEULL, 0x641793C37ED84B6CULL, 0x7B21ED77F6E97D96ULL,
+    0x776306312EF96B73ULL, 0xAE528948E86FF3F4ULL, 0x53DBD7F286A3F8F8ULL, 0x16CADCE74CFC1063ULL,
+    0x005C19BDFA52C6DDULL, 0x68868F5D64D46AD3ULL, 0x3A9D512CCF1E186AULL, 0x367E62C2385660AEULL,
+    0xE359E7EA77DCB1D7ULL, 0x526C0773749ABE6EULL, 0x735AE5F9D09F734BULL, 0x493FC7CC8A558BA8ULL,
+    0xB0B9C1533041AB45ULL, 0x321958BA470A59BDULL, 0x852DB00B5F46C393ULL, 0x91209B2BD336B0E5ULL,
+    0x6E604F7D659EF19FULL, 0xB99A8AE2782CCB24ULL, 0xCCF52AB6C814C4C7ULL, 0x4727D9AFBE11727BULL,
+    0x7E950D0C0121B34DULL, 0x756F435670AD471FULL, 0xF5ADD442615A6849ULL, 0x4E87E09980B9957AULL,
+    0x2ACFA1DF50AEE355ULL, 0xD898263AFD2FD556ULL, 0xC8F4924DD80C8FD6ULL, 0xCF99CA3D754A173AULL,
+    0xFE477BACAF91BF3CULL, 0xED5371F6D690C12DULL, 0x831A5C285E687094ULL, 0xC5D3C90A3708A0A4ULL,
+    0x0F7F903717D06580ULL, 0x19F9BB13B8FDF27FULL, 0xB1BD6F1B4D502843ULL, 0x1C761BA38FFF4012ULL,
+    0x0D1530C4E2E21F3BULL, 0x8943CE69A7372C8AULL, 0xE5184E11FEB5CE66ULL, 0x618BDB80BD736621ULL,
+    0x7D29BAD68B574D0BULL, 0x81BB613E25E6FE5BULL, 0x071C9C10BC07913FULL, 0xC7BEEB7909AC2D97ULL,
+    0xC3E58D353BC5D757ULL, 0xEB017892F38F61E8ULL, 0xD4EFFB9C9B1CC21AULL, 0x99727D26F494F7ABULL,
+    0xA3E063A2956B3E03ULL, 0x9D4A8B9A4AA09C30ULL, 0x3F6AB7D500090FB4ULL, 0x9CC0F2A057268AC0ULL,
+    0x3DEE9D2DEDBF42D1ULL, 0x330F49C87960A972ULL, 0xC6B2720287421B41ULL, 0x0AC59EC07C00369CULL,
+    0xEF4EAC49CB353425ULL, 0xF450244EEF0129D8ULL, 0x8ACC46E5CAF4DEB6ULL, 0x2FFEAB63989263F7ULL,
+    0x8F7CB9FE5D7A4578ULL, 0x5BD8F7644E634635ULL, 0x427A7315BF2DC900ULL, 0x17D0C4AA2125261CULL,
+    0x3992486C93518E50ULL, 0xB4CBFEE0A2D7D4C3ULL, 0x7C75D6202C5DDD8DULL, 0xDBC295D8E35B6C61ULL,
+    0x60B369D302032B19ULL, 0xCE42685FDCE44132ULL, 0x06F3DDB9DDF65610ULL, 0x8EA4D21DB5E148F0ULL,
+    0x20B0FCE62FCD496FULL, 0x2C1B912358B0EE31ULL, 0xB28317B818F5A308ULL, 0xA89C1E189CA6D2CFULL,
+    0x0C6B18576AAADBC8ULL, 0xB65DEAA91299FAE3ULL, 0xFB2B794B7F1027E7ULL, 0x04E4317F443B5BEBULL,
+    0x4B852D325939D0A6ULL, 0xD5AE6BEEFB207FFCULL, 0x309682B281C7D374ULL, 0xBAE309A194C3B475ULL,
+    0x8CC3F97B13B49F05ULL, 0x98A9422FF8293967ULL, 0x244B16B01076FF7CULL, 0xF8BF571C663D67EEULL,
+    0x1F0D6758EEE30DA1ULL, 0xC9B611D97ADEB9B7ULL, 0xB7AFD5887B6C57A2ULL, 0x6290AE846B984FE1ULL,
+    0x94DF4CDEACC1A5FDULL, 0x058A5BD1C5483AFFULL, 0x63166CC142BA3C37ULL, 0x8DB8526EB2F76F40ULL,
+    0xE10880036F0D6D4EULL, 0x9E0523C9971D311DULL, 0x45EC2824CC7CD691ULL, 0x575B8359E62382C9ULL,
+    0xFA9E400DC4889995ULL, 0xD1823ECB45721568ULL, 0xDAFD983B8206082FULL, 0xAA7D29082386A8CBULL,
+    0x269FCD4403B87588ULL, 0x1B91F5F728BDD1E0ULL, 0xE4669F39040201F6ULL, 0x7A1D7C218CF04ADEULL,
+    0x65623C29D79CE5CEULL, 0x2368449096C00BB1ULL, 0xAB9BF1879DA503BAULL, 0xBC23ECB1A458058EULL,
+    0x9A58DF01BB401ECCULL, 0xA070E868A85F143DULL, 0x4FF188307DF2239EULL, 0x14D565B41A641183ULL,
+    0xEE13337452701602ULL, 0x950E3DCF3F285E09ULL, 0x59930254B9C80953ULL, 0x3BF299408930DA6DULL,
+    0xA955943F53691387ULL, 0xA15EDECAA9CB8784ULL, 0x29142127352BE9A0ULL, 0x76F0371FFF4E7AFBULL,
+    0x0239F450274F2228ULL, 0xBB073AF01D5E868BULL, 0xBFC80571C10E96C1ULL, 0xD267088568222E23ULL,
+    0x9671A3D48E80B5B0ULL, 0x55B5D38AE193BB81ULL, 0x693AE2D0A18B04B8ULL, 0x5C48B4ECADD5335FULL,
+    0xFD743B194916A1CAULL, 0x2577018134BE98C4ULL, 0xE77987E83C54A4ADULL, 0x28E11014DA33E1B9ULL,
+    0x270CC59E226AA213ULL, 0x71495F756D1A5F60ULL, 0x9BE853FB60AFEF77ULL, 0xADC786A7F7443DBFULL,
+    0x0904456173B29A82ULL, 0x58BC7A66C232BD5EULL, 0xF306558C673AC8B2ULL, 0x41F639C6B6C9772AULL,
+    0x216DEFE99FDA35DAULL, 0x11640CC71C7BE615ULL, 0x93C43694565C5527ULL, 0xEA038E6246777839ULL,
+    0xF9ABF3CE5A3E2469ULL, 0x741E768D0FD312D2ULL, 0x0144B883CED652C6ULL, 0xC20B5A5BA33F8552ULL,
+    0x1AE69633C3435A9DULL, 0x97A28CA4088CFDECULL, 0x8824A43C1E96F420ULL, 0x37612FA66EEEA746ULL,
+    0x6B4CB165F9CF0E5AULL, 0x43AA1C06A0ABFB4AULL, 0x7F4DC26FF162796BULL, 0x6CBACC8E54ED9B0FULL,
+    0xA6B7FFEFD2BB253EULL, 0x2E25BC95B0A29D4FULL, 0x86D6A58BDEF1388CULL, 0xDED74AC576B6F054ULL,
+    0x8030BDBC2B45805DULL, 0x3C81AF70E94D9289ULL, 0x3EFF6DDA9E3100DBULL, 0xB38DC39FDFCC8847ULL,
+    0x123885528D17B87EULL, 0xF2DA0ED240B1B642ULL, 0x44CEFADCD54BF9A9ULL, 0x1312200E433C7EE6ULL,
+    0x9FFCC84F3A78C748ULL, 0xF0CD1F72248576BBULL, 0xEC6974053638CFE4ULL, 0x2BA7B67C0CEC4E4CULL,
+    0xAC2F4DF3E5CE32EDULL, 0xCB33D14326EA4C11ULL, 0xA4E9044CC77E58BCULL, 0x5F513293D934FCEFULL,
+    0x5DC9645506E55444ULL, 0x50DE418F317DE40AULL, 0x388CB31A69DDE259ULL, 0x2DB4A83455820A86ULL,
+    0x9010A91E84711AE9ULL, 0x4DF7F0B7B1498371ULL, 0xD62A2EABC0977179ULL, 0x22FAC097AA8D5C0EULL,
+    0xF49FCC2FF1DAF39BULL, 0x487FD5C66FF29281ULL, 0xE8A30667FCDCA83FULL, 0x2C9B4BE3D2FCCE63ULL,
+    0xDA3FF74B93FBBBC2ULL, 0x2FA165D2FE70BA66ULL, 0xA103E279970E93D4ULL, 0xBECDEC77B0E45E71ULL,
+    0xCFB41E723985E497ULL, 0xB70AAA025EF75017ULL, 0xD42309F03840B8E0ULL, 0x8EFC1AD035898579ULL,
+    0x96C6920BE2B2ABC5ULL, 0x66AF4163375A9172ULL, 0x2174ABDCCA7127FBULL, 0xB33CCEA64A72FF41ULL,
+    0xF04A4933083066A5ULL, 0x8D970ACDD7289AF5ULL, 0x8F96E8E031C8C25EULL, 0xF3FEC02276875D47ULL,
+    0xEC7BF310056190DDULL, 0xF5ADB0AEBB0F1491ULL, 0x9B50F8850FD58892ULL, 0x4975488358B74DE8ULL,
+    0xA3354FF691531C61ULL, 0x0702BBE481D2C6EEULL, 0x89FB24057DEDED98ULL, 0xAC3075138596E902ULL,
+    0x1D2D3580172772EDULL, 0xEB738FC28E6BC30DULL, 0x5854EF8F63044326ULL, 0x9E5C52325ADD3BBEULL,
+    0x90AA53CF325C4623ULL, 0xC1D24D51349DD067ULL, 0x2051CFEEA69EA624ULL, 0x13220F0A862E7E4FULL,
+    0xCE39399404E04864ULL, 0xD9C42CA47086FCB7ULL, 0x685AD2238A03E7CCULL, 0x066484B2AB2FF1DBULL,
+    0xFE9D5D70EFBF79ECULL, 0x5B13B9DD9C481854ULL, 0x15F0D475ED1509ADULL, 0x0BEBCD060EC79851ULL,
+    0xD58C6791183AB7F8ULL, 0xD1187C5052F3EEE4ULL, 0xC95D1192E54E82FFULL, 0x86EEA14CB9AC6CA2ULL,
+    0x3485BEB153677D5DULL, 0xDD191D781F8C492AULL, 0xF60866BAA784EBF9ULL, 0x518F643BA2D08C74ULL,
+    0x8852E956E1087C22ULL, 0xA768CB8DC410AE8DULL, 0x38047726BFEC8E1AULL, 0xA67738B4CD3B45AAULL,
+    0xAD16691CEC0DDE19ULL, 0xC6D4319380462E07ULL, 0xC5A5876D0BA61938ULL, 0x16B9FA1FA58FD840ULL,
+    0x188AB1173CA74F18ULL, 0xABDA2F98C99C021FULL, 0x3E0580AB134AE816ULL, 0x5F3B05B773645ABBULL,
+    0x2501A2BE5575F2F6ULL, 0x1B2F74004E7E8BA9ULL, 0x1CD7580371E8D953ULL, 0x7F6ED89562764E30ULL,
+    0xB15926FF596F003DULL, 0x9F65293DA8C5D6B9ULL, 0x6ECEF04DD690F84CULL, 0x4782275FFF33AF88ULL,
+    0xE41433083F820801ULL, 0xFD0DFE409A1AF9B5ULL, 0x4325A3342CDB396BULL, 0x8AE77E62B301B252ULL,
+    0xC36F9E9F6655615AULL, 0x85455A2D92D32C09ULL, 0xF2C7DEA949477485ULL, 0x63CFB4C133A39EBAULL,
+    0x83B040CC6EBC5462ULL, 0x3B9454C8FDB326B0ULL, 0x56F56A9E87FFD78CULL, 0x2DC2940D99F42BC6ULL,
+    0x98F7DF096B096E2DULL, 0x19A6E01E3AD852BFULL, 0x42A99CCBDBD4B40BULL, 0xA59998AF45E9C559ULL,
+    0x366295E807D93186ULL, 0x6B48181BFAA1F773ULL, 0x1FEC57E2157A0A1DULL, 0x4667446AF6201AD5ULL,
+    0xE615EBCACFB0F075ULL, 0xB8F31F4F68290778ULL, 0x22713ED6CE22D11EULL, 0x3057C1A72EC3C93BULL,
+    0xCB46ACC37C3F1F2FULL, 0xDBB893FD02AAF50EULL, 0x331FD92E600B9FCFULL, 0xA498F96148EA3AD6ULL,
+    0xA8D8426E8B6A83EAULL, 0xA089B274B7735CDCULL, 0x87F6B3731E524A11ULL, 0x118808E5CBC96749ULL,
+    0x9906E4C7B19BD394ULL, 0xAFED7F7E9B24A20CULL, 0x6509EADEEB3644A7ULL, 0x6C1EF1D3E8EF0EDEULL,
+    0xB9C97D43E9798FB4ULL, 0xA2F2D784740C28A3ULL, 0x7B8496476197566FULL, 0x7A5BE3E6B65F069DULL,
+    0xF96330ED78BE6F10ULL, 0xEEE60DE77A076A15ULL, 0x2B4BEE4AA08B9BD0ULL, 0x6A56A63EC7B8894EULL,
+    0x02121359BA34FEF4ULL, 0x4CBF99F8283703FCULL, 0x398071350CAF30C8ULL, 0xD0A77A89F017687AULL,
+    0xF1C1A9EB9E423569ULL, 0x8C7976282DEE8199ULL, 0x5D1737A5DD1F7ABDULL, 0x4F53433C09A9FA80ULL,
+    0xFA8B0C53DF7CA1D9ULL, 0x3FD9DCBC886CCB77ULL, 0xC040917CA91B4720ULL, 0x7DD00142F9D1DCDFULL,
+    0x8476FC1D4F387B58ULL, 0x23F8E7C5F3316503ULL, 0x032A2244E7E37339ULL, 0x5C87A5D750F5A74BULL,
+    0x082B4CC43698992EULL, 0xDF917BECB858F63CULL, 0x3270B8FC5BF86DDAULL, 0x10AE72BB29B5DD76ULL,
+    0x576AC94E7700362BULL, 0x1AD112DAC61EFB8FULL, 0x691BC30EC5FAA427ULL, 0xFF246311CC327143ULL,
+    0x3142368E30E53206ULL, 0x71380E31E02CA396ULL, 0x958D5C960AAD76F1ULL, 0xF8D6F430C16DA536ULL,
+    0xC8FFD13F1BE7E1D2ULL, 0x7578AE66004DDBE1ULL, 0x05833F01067BE646ULL, 0xBB34B5AD3BFE586DULL,
+    0x095F34C9A12B97F0ULL, 0x247AB64525D60CA8ULL, 0xDCDBC6F3017477D1ULL, 0x4A2E14D4DECAD24DULL,
+    0xBDB5E6D9BE0A1EEBULL, 0x2A7E70F7794301ABULL, 0xDEF42D8A270540FDULL, 0x01078EC0A34C22C1ULL,
+    0xE5DE511AF4C16387ULL, 0x7EBB3A52BD9A330AULL, 0x77697857AA7D6435ULL, 0x004E831603AE4C32ULL,
+    0xE7A21020AD78E312ULL, 0x9D41A70C6AB420F2ULL, 0x28E06C18EA1141E6ULL, 0xD2B28CBD984F6B28ULL,
+    0x26B75F6C446E9D83ULL, 0xBA47568C4D418D7FULL, 0xD80BADBFE6183D8EULL, 0x0E206D7F5F166044ULL,
+    0xE258A43911CBCA3EULL, 0x723A1746B21DC0BCULL, 0xC7CAA854F5D7CDD3ULL, 0x7CAC32883D261D9CULL,
+    0x7690C26423BA942CULL, 0x17E55524478042B8ULL, 0xE0BE477656A2389FULL, 0x4D289B5E67AB2DA0ULL,
+    0x44862B9C8FBBFD31ULL, 0xB47CC8049D141365ULL, 0x822C1B362B91C793ULL, 0x4EB14655FB13DFD8ULL,
+    0x1ECBBA0714E2A97BULL, 0x6143459D5CDE5F14ULL, 0x53A8FBF1D5F0AC89ULL, 0x97EA04D81C5E5B00ULL,
+    0x622181A8D4FDB3F3ULL, 0xE9BCD341572A1208ULL, 0x1411258643CCE58AULL, 0x9144C5FEA4C6E0A4ULL,
+    0x0D33D06565CF620FULL, 0x54A48D489F219CA1ULL, 0xC43E5EAC6D63C821ULL, 0xA9728B3A72770DAFULL,
+    0xD7934E7B20DF87EFULL, 0xE35503B61A3E86E5ULL, 0xCAE321FBC819D504ULL, 0x129A50B3AC60BFA6ULL,
+    0xCD5E68EA7E9FB6C3ULL, 0xB01C90199483B1C7ULL, 0x3DE93CD5C295376CULL, 0xAED52EDF2AB9AD13ULL,
+    0x2E60F512C0A07884ULL, 0xBC3D86A3E36210C9ULL, 0x35269D9B163951CEULL, 0x0C7D6E2AD0CDB5FAULL,
+    0x59E86297D87F5733ULL, 0x298EF221898DB0E7ULL, 0x55000029D1A5AA7EULL, 0x8BC08AE1B5061B45ULL,
+    0xC2C31C2B6C92703AULL, 0x94CC596BAF25EF42ULL, 0x0A1D73DB22540456ULL, 0x04B6A0F9D9C4179AULL,
+    0xEFFDAFA2AE3D3C60ULL, 0xF7C8075BB49496C4ULL, 0x9CC5C7141D1CD4E3ULL, 0x78BD1638218E5534ULL,
+    0xB2F11568F850246AULL, 0xEDFABCFA9502BC29ULL, 0x796CE5F2DA23051BULL, 0xAAE128B0DC93537CULL,
+    0x3A493DA0EE4B29AEULL, 0xB5DF6B2C416895D7ULL, 0xFCABBD25122D7F37ULL, 0x70810B58105DC4B1ULL,
+    0xE10FDD37F7882A90ULL, 0x524DCAB5518A3F5CULL, 0x3C9E85878451255BULL, 0x4029828119BD34E2ULL,
+    0x74A05B6F5D3CECCBULL, 0xB610021542E13ECAULL, 0x0FF979D12F59E2ACULL, 0x6037DA27E4F9CC50ULL,
+    0x5E92975A0DF1847DULL, 0xD66DE190D3E623FEULL, 0x5032D6B87B568048ULL, 0x9A36B7CE8235216EULL,
+    0x80272A7A24F64B4AULL, 0x93EFED8B8C6916F7ULL, 0x37DDBFF44CCE1555ULL, 0x4B95DB5D4B99BD25ULL,
+    0x92D3FDA169812FC0ULL, 0xFB1A4A9A90660BB6ULL, 0x730C196946A4B9B2ULL, 0x81E289AA7F49DA68ULL,
+    0x64669A0F83B1A05FULL, 0x27B3FF7D9644F48BULL, 0xCC6B615C8DB675B3ULL, 0x674F20B9BCEBBE95ULL,
+    0x6F31238275655982ULL, 0x5AE488713E45CF05ULL, 0xBF619F9954C21157ULL, 0xEABAC46040A8EAE9ULL,
+    0x454C6FE9F2C0C1CDULL, 0x419CF6496412691CULL, 0xD3DC3BEF265B0F70ULL, 0x6D0E60F5C3578A9EULL,
+    0x5B0E608526323C55ULL, 0x1A46C1A9FA1B59F5ULL, 0xA9E245A17C4C8FFAULL, 0x65CA5159DB2955D7ULL,
+    0x05DB0A76CE35AFC2ULL, 0x81EAC77EA9113D45ULL, 0x528EF88AB6AC0A0DULL, 0xA09EA253597BE3FFULL,
+    0x430DDFB3AC48CD56ULL, 0xC4B3A67AF45CE46FULL, 0x4ECECFD8FBE2D05EULL, 0x3EF56F10B39935F0ULL,
+    0x0B22D6829CD619C6ULL, 0x17FD460A74DF2069ULL, 0x6CF8CC8E8510ED40ULL, 0xD6C824BF3A6ECAA7ULL,
+    0x61243D581A817049ULL, 0x048BACB6BBC163A2ULL, 0xD9A38AC27D44CC32ULL, 0x7FDDFF5BAAF410ABULL,
+    0xAD6D495AA804824BULL, 0xE1A6A74F2D8C9F94ULL, 0xD4F7851235DEE8E3ULL, 0xFD4B7F886540D893ULL,
+    0x247C20042AA4BFDAULL, 0x096EA1C517D1327CULL, 0xD56966B4361A6685ULL, 0x277DA5C31221057DULL,
+    0x94D59893A43ACFF7ULL, 0x64F0C51CCDC02281ULL, 0x3D33BCC4FF6189DBULL, 0xE005CB184CE66AF1ULL,
+    0xFF5CCD1D1DB99BEAULL, 0xB0B854A7FE42980FULL, 0x7BD46A6A718D4B9FULL, 0xD10FA8CC22A5FD8CULL,
+    0xD31484952BE4BD31ULL, 0xC7FA975FCB243847ULL, 0x4886ED1E5846C407ULL, 0x28CDDB791EB70B04ULL,
+    0xC2B00BE2F573417FULL, 0x5C9590452180F877ULL, 0x7A6BDDFFF370EB00ULL, 0xCE509E38D6D9D6A4ULL,
+    0xEBEB0F00647FA702ULL, 0x1DCC06CF76606F06ULL, 0xE4D9F28BA286FF0AULL, 0xD85A305DC918C262ULL,
+    0x475B1D8732225F54ULL, 0x2D4FB51668CCB5FEULL, 0xA679B9D9D72BBA20ULL, 0x53841C0D912D43A5ULL,
+    0x3B7EAA48BF12A4E8ULL, 0x781E0E47F22F1DDFULL, 0xEFF20CE60AB50973ULL, 0x20D261D19DFFB742ULL,
+    0x16A12B03062A2E39ULL, 0x1960EB2239650495ULL, 0x251C16FED50EB8B8ULL, 0x9AC0C330F826016EULL,
+    0xED152665953E7671ULL, 0x02D63194A6369570ULL, 0x5074F08394B1C987ULL, 0x70BA598C90B25CE1ULL,
+    0x794A15810B9742F6ULL, 0x0D5925E9FCAF8C6CULL, 0x3067716CD868744EULL, 0x910AB077E8D7731BULL,
+    0x6A61BBDB5AC42F61ULL, 0x93513EFBF0851567ULL, 0xF494724B9E83E9D5ULL, 0xE887E1985C09648DULL,
+    0x34B1D3C675370CFDULL, 0xDC35E433BC0D255DULL, 0xD0AAB84234131BE0ULL, 0x08042A50B48B7EAFULL,
+    0x9997C4EE44A3AB35ULL, 0x829A7B49201799D0ULL, 0x263B8307B7C54441ULL, 0x752F95F4FD6A6CA6ULL,
+    0x927217402C08C6E5ULL, 0x2A8AB754A795D9EEULL, 0xA442F7552F72943DULL, 0x2C31334E19781208ULL,
+    0x4FA98D7CEAEE6291ULL, 0x55C3862F665DB309ULL, 0xBD0610175D53B1F3ULL, 0x46FE6CB840413F27ULL,
+    0x3FE03792DF0CFA59ULL, 0xCFE700372EB85E8FULL, 0xA7BE29E7ADBCE118ULL, 0xE544EE5CDE8431DDULL,
+    0x8A781B1B41F1873EULL, 0xA5C94C78A0D2F0E7ULL, 0x39412E2877B60728ULL, 0xA1265EF3AFC9A62CULL,
+    0xBCC2770C6A2506C5ULL, 0x3AB66DD5DCE1CE12ULL, 0xE65499D04A675B37ULL, 0x7D8F523481BFD216ULL,
+    0x0F6F64FCEC15F389ULL, 0x74EFBE618B5B13C8ULL, 0xACDC82B714273E1DULL, 0xDD40BFE003199D17ULL,
+    0x37E99257E7E061F8ULL, 0xFA52626904775AAAULL, 0x8BBBF63A463D56F9ULL, 0xF0013F1543A26E64ULL,
+    0xA8307E9F879EC898ULL, 0xCC4C27A4150177CCULL, 0x1B432F2CCA1D3348ULL, 0xDE1D1F8F9F6FA013ULL,
+    0x606602A047A7DDD6ULL, 0xD237AB64CC1CB2C7ULL, 0x9B938E7225FCD1D3ULL, 0xEC4E03708E0FF476ULL,
+    0xFEB2FBDA3D03C12DULL, 0xAE0BCED2EE43889AULL, 0x22CB8923EBFB4F43ULL, 0x69360D013CF7396DULL,
+    0x855E3602D2D4E022ULL, 0x073805BAD01F784CULL, 0x33E17A133852F546ULL, 0xDF4874058AC7B638ULL,
+    0xBA92B29C678AA14AULL, 0x0CE89FC76CFAADCDULL, 0x5F9D4E0908339E34ULL, 0xF1AFE9291F5923B9ULL,
+    0x6E3480F60F4A265FULL, 0xEEBF3A2AB29B841CULL, 0xE21938A88F91B4ADULL, 0x57DFEFF845C6D3C3ULL,
+    0x2F006B0BF62CAAF2ULL, 0x62F479EF6F75EE78ULL, 0x11A55AD41C8916A9ULL, 0xF229D29084FED453ULL,
+    0x42F1C27B16B000E6ULL, 0x2B1F76749823C074ULL, 0x4B76ECA3C2745360ULL, 0x8C98F463B91691BDULL,
+    0x14BCC93CF1ADE66AULL, 0x8885213E6D458397ULL, 0x8E177DF0274D4711ULL, 0xB49B73B5503F2951ULL,
+    0x10168168C3F96B6BULL, 0x0E3D963B63CAB0AEULL, 0x8DFC4B5655A1DB14ULL, 0xF789F1356E14DE5CULL,
+    0x683E68AF4E51DAC1ULL, 0xC9A84F9D8D4B0FD9ULL, 0x3691E03F52A0F9D1ULL, 0x5ED86E46E1878E80ULL,
+    0x3C711A0E99D07150ULL, 0x5A0865B20C4E9310ULL, 0x56FBFC1FE4F0682EULL, 0xEA8D5DE3105EDF9BULL,
+    0x71ABFDB12379187AULL, 0x2EB99DE1BEE77B9CULL, 0x21ECC0EA33CF4523ULL, 0x59A4D7521805C7A1ULL,
+    0x3896F5EB56AE7C72ULL, 0xAA638F3DB18F75DCULL, 0x9F39358DABE9808EULL, 0xB7DEFA91C00B72ACULL,
+    0x6B5541FD62492D92ULL, 0x6DC6DEE8F92E4D5BULL, 0x353F57ABC4BEEA7EULL, 0x735769D6DA5690CEULL,
+    0x0A234AA642391484ULL, 0xF6F9508028F80D9DULL, 0xB8E319A27AB3F215ULL, 0x31AD9C1151341A4DULL,
+    0x773C22A57BEF5805ULL, 0x45C7561A07968633ULL, 0xF913DA9E249DBE36ULL, 0xDA652D9B78A64C68ULL,
+    0x4C27A97F3BC334EFULL, 0x76621220E66B17F4ULL, 0x967743899ACD7D0BULL, 0xF3EE5BCAE0ED6782ULL,
+    0x409F753600C879FCULL, 0x06D09A39B5926DB6ULL, 0x6F83AEB0317AC588ULL, 0x01E6CA4A86381F21ULL,
+    0x66FF3462D19F3025ULL, 0x72207C24DDFD3BFBULL, 0x4AF6B6D3E2ECE2EBULL, 0x9C994DBEC7EA08DEULL,
+    0x49ACE597B09A8BC4ULL, 0xB38C4766CF0797BAULL, 0x131B9373C57C2A75ULL, 0xB1822CCE61931E58ULL,
+    0x9D7555B909BA1C0CULL, 0x127FAFDD937D11D2ULL, 0x29DA3BADC66D92E4ULL, 0xA2C1D57154C2ECBCULL,
+    0x58C5134D82F6FE24ULL, 0x1C3AE3515B62274FULL, 0xE907C82E01CB8126ULL, 0xF8ED091913E37FCBULL,
+    0x3249D8F9C80046C9ULL, 0x80CF9BEDE388FB63ULL, 0x1881539A116CF19EULL, 0x5103F3F76BD52457ULL,
+    0x15B7E6F5AE47F7A8ULL, 0xDBD7C6DED47E9CCFULL, 0x44E55C410228BB1AULL, 0xB647D4255EDB4E99ULL,
+    0x5D11882BB8AAFC30ULL, 0xF5098BBB29D3212AULL, 0x8FB5EA14E90296B3ULL, 0x677B942157DD025AULL,
+    0xFB58E7C0A390ACB5ULL, 0x89D3674C83BD4A01ULL, 0x9E2DA4DF4BF3B93BULL, 0xFCC41E328CAB4829ULL,
+    0x03F38C96BA582C52ULL, 0xCAD1BDBD7FD85DB2ULL, 0xBBB442C16082AE83ULL, 0xB95FE86BA5DA9AB0ULL,
+    0xB22E04673771A93FULL, 0x845358C9493152D8ULL, 0xBE2A488697B4541EULL, 0x95A2DC2DD38E6966ULL,
+    0xC02C11AC923C852BULL, 0x2388B1990DF2A87BULL, 0x7C8008FA1B4F37BEULL, 0x1F70D0C84D54E503ULL,
+    0x5490ADEC7ECE57D4ULL, 0x002B3C27D9063A3AULL, 0x7EAEA3848030A2BFULL, 0xC602326DED2003C0ULL,
+    0x83A7287D69A94086ULL, 0xC57A5FCB30F57A8AULL, 0xB56844E479EBE779ULL, 0xA373B40F05DCBCE9ULL,
+    0xD71A786E88570EE2ULL, 0x879CBACDBDE8F6A0ULL, 0x976AD1BCC164A32FULL, 0xAB21E25E9666D78BULL,
+    0x901063AAE5E5C33CULL, 0x9818B34448698D90ULL, 0xE36487AE3E1E8ABBULL, 0xAFBDF931893BDCB4ULL,
+    0x6345A0DC5FBBD519ULL, 0x8628FE269B9465CAULL, 0x1E5D01603F9C51ECULL, 0x4DE44006A15049B7ULL,
+    0xBF6C70E5F776CBB1ULL, 0x411218F2EF552BEDULL, 0xCB0C0708705A36A3ULL, 0xE74D14754F986044ULL,
+    0xCD56D9430EA8280EULL, 0xC12591D7535F5065ULL, 0xC83223F1720AEF96ULL, 0xC3A0396F7363A51FULL,
+};
+
+/** @param table Substitution box index. @param index Entry index. @return One box entry. */
+[[nodiscard]] constexpr std::uint64_t box(std::size_t table, std::uint64_t index) noexcept {
+    return kTable[(table * 256) + index];
+}
+
+/**
+ * Runs one Tiger round in place.
+ * The three state words swap roles between calls, so each is taken by reference.
+ * @param a First state word.
+ * @param b Second state word.
+ * @param c Third state word.
+ * @param x Message word for this round.
+ * @param mul Round multiplier.
+ */
+void round(std::uint64_t& a,
+           std::uint64_t& b,
+           std::uint64_t& c,
+           std::uint64_t x,
+           std::uint64_t mul) noexcept {
+    c ^= x;
+    a -= box(0, c & kByteMask) ^ box(1, (c >> 16U) & kByteMask) ^ box(2, (c >> 32U) & kByteMask)
+         ^ box(3, (c >> 48U) & kByteMask);
+    b += box(3, (c >> kByteBits) & kByteMask) ^ box(2, (c >> 24U) & kByteMask)
+         ^ box(1, (c >> 40U) & kByteMask) ^ box(0, (c >> 56U) & kByteMask);
+    b *= mul;
+}
+
+/**
+ * Runs the eight rounds of one pass in place.
+ * @param a First state word.
+ * @param b Second state word.
+ * @param c Third state word.
+ * @param x The eight message words.
+ * @param mul Round multiplier.
+ */
+void pass(std::uint64_t& a,
+          std::uint64_t& b,
+          std::uint64_t& c,
+          const std::uint64_t (&x)[kBlockWords],
+          std::uint64_t mul) noexcept {
+    round(a, b, c, x[0], mul);
+    round(b, c, a, x[1], mul);
+    round(c, a, b, x[2], mul);
+    round(a, b, c, x[3], mul);
+    round(b, c, a, x[4], mul);
+    round(c, a, b, x[5], mul);
+    round(a, b, c, x[6], mul);
+    round(b, c, a, x[7], mul);
+}
+
+/** Mixes the message words between passes. @param x The eight message words. */
+void key_schedule(std::uint64_t (&x)[kBlockWords]) noexcept {
+    /** Constant the schedule folds into the first and last words. */
+    constexpr std::uint64_t kOddMask = 0xA5A5A5A5A5A5A5A5ULL;
+    /** Constant the schedule folds into the last word. */
+    constexpr std::uint64_t kTailMask = 0x0123456789ABCDEFULL;
+    /** Left shift the schedule applies. */
+    constexpr unsigned kLeftShift = 19;
+    /** Right shift the schedule applies. */
+    constexpr unsigned kRightShift = 23;
+    x[0] -= x[7] ^ kOddMask;
+    x[1] ^= x[0];
+    x[2] += x[1];
+    x[3] -= x[2] ^ (~x[1] << kLeftShift);
+    x[4] ^= x[3];
+    x[5] += x[4];
+    x[6] -= x[5] ^ (~x[4] >> kRightShift);
+    x[7] ^= x[6];
+    x[0] += x[7];
+    x[1] -= x[0] ^ (~x[7] << kLeftShift);
+    x[2] ^= x[1];
+    x[3] += x[2];
+    x[4] -= x[3] ^ (~x[2] >> kRightShift);
+    x[5] ^= x[4];
+    x[6] += x[5];
+    x[7] -= x[6] ^ kTailMask;
+}
+
+/**
+ * Folds one 64-byte block into the state.
+ * @param state The three state words.
+ * @param block Block bytes.
+ */
+void compress(std::uint64_t (&state)[3], const std::byte* block) noexcept {
+    std::uint64_t x[kBlockWords]{};
+    for (std::size_t word = 0; word < kBlockWords; ++word) {
+        std::uint64_t value = 0;
+        for (std::size_t index = 0; index < sizeof(std::uint64_t); ++index) {
+            value |= static_cast<std::uint64_t>(block[(word * sizeof(std::uint64_t)) + index])
+                     << (index * kByteBits);
+        }
+        x[word] = value;
+    }
+
+    std::uint64_t a = state[0];
+    std::uint64_t b = state[1];
+    std::uint64_t c = state[2];
+    const std::uint64_t savedA = a;
+    const std::uint64_t savedB = b;
+    const std::uint64_t savedC = c;
+    // Three passes with the multipliers the algorithm fixes, and the roles rotate between them.
+    pass(a, b, c, x, 5);
+    key_schedule(x);
+    pass(c, a, b, x, 7);
+    key_schedule(x);
+    pass(b, c, a, x, 9);
+    state[0] = a ^ savedA;
+    state[1] = b - savedB;
+    state[2] = c + savedC;
+}
+
+} // namespace
+
+/** Hashes one buffer with Tiger/192, three passes. */
+void hash(std::span<const std::byte> input, Digest& output) noexcept {
+    /** The three state words start from these. */
+    constexpr std::uint64_t kInitial[3]{
+        0x0123456789ABCDEFULL, 0xFEDCBA9876543210ULL, 0xF096A5B4C3B2E187ULL};
+    std::uint64_t state[3]{kInitial[0], kInitial[1], kInitial[2]};
+
+    const std::size_t whole = input.size() - (input.size() % kBlockSize);
+    for (std::size_t offset = 0; offset < whole; offset += kBlockSize) {
+        compress(state, input.data() + offset);
+    }
+
+    // The tail never exceeds two blocks. The remainder is under one block before padding.
+    std::array<std::byte, 2 * kBlockSize> tail{};
+    const std::size_t remainder = input.size() - whole;
+    for (std::size_t index = 0; index < remainder; ++index) {
+        tail[index] = input[whole + index];
+    }
+    tail[remainder] = kPadByte;
+    std::size_t padded = remainder + 1;
+    while (padded % kBlockSize != kLengthOffset) {
+        ++padded;
+    }
+    const std::uint64_t bits = static_cast<std::uint64_t>(input.size()) * kByteBits;
+    for (std::size_t index = 0; index < sizeof(std::uint64_t); ++index) {
+        tail[padded + index] = static_cast<std::byte>((bits >> (index * kByteBits)) & kByteMask);
+    }
+    const std::size_t tailSize = padded + sizeof(std::uint64_t);
+    for (std::size_t offset = 0; offset < tailSize; offset += kBlockSize) {
+        compress(state, tail.data() + offset);
+    }
+
+    // The three words leave low byte first, the order the peer reads them in.
+    for (std::size_t word = 0; word < 3; ++word) {
+        for (std::size_t index = 0; index < sizeof(std::uint64_t); ++index) {
+            output[(word * sizeof(std::uint64_t)) + index] =
+                static_cast<std::byte>((state[word] >> (index * kByteBits)) & kByteMask);
+        }
+    }
+}
+
+} // namespace sunrise::middleware::crypto::tiger

+ 23 - 0
Sunrise/src/middleware/crypto/tiger192.h

@@ -0,0 +1,23 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <span>
+
+namespace sunrise::middleware::crypto::tiger {
+
+/** Tiger/192 produces 24 bytes. */
+inline constexpr std::size_t kDigestSize = 24;
+
+/** One finished Tiger/192 digest. */
+using Digest = std::array<std::byte, kDigestSize>;
+
+/**
+ * Hashes one buffer with Tiger/192, three passes.
+ * Windows has no provider for this hash, so it is implemented here.
+ * @param input Bytes to hash.
+ * @param output Receives the digest.
+ */
+void hash(std::span<const std::byte> input, Digest& output) noexcept;
+
+} // namespace sunrise::middleware::crypto::tiger

+ 8 - 5
Sunrise/src/middleware/datagen/family4/progression/layout.h

@@ -6,20 +6,23 @@
 
 namespace sunrise::middleware::datagen::family4::progression::layout {
 
-/** A native progression row has 12 value bytes before its definition index. */
-inline constexpr std::size_t kValueSize = 12;
+/** A native progression row carries 3 signed value lanes before its definition index. */
+inline constexpr std::size_t kValueLaneCount = 3;
+
+/** The value lanes of one progression row. Lane 0 is the progress the level walk reads. */
+using Values = std::array<std::int32_t, kValueLaneCount>;
 
 #pragma pack(push, 1)
 
-/** One fixed native progression row whose definition index follows 3 value lanes. */
+/** One fixed native progression row whose definition index follows its 3 value lanes. */
 struct Entry {
-    std::array<std::byte, kValueSize> values{};
+    Values values{};
     std::uint16_t definitionIndex{};
     std::uint16_t reserved{};
 };
 
 #pragma pack(pop)
 
-static_assert(sizeof(Entry) == kValueSize * sizeof(std::byte) + 2 * sizeof(std::uint16_t));
+static_assert(sizeof(Entry) == sizeof(Values) + 2 * sizeof(std::uint16_t));
 
 } // namespace sunrise::middleware::datagen::family4::progression::layout

+ 22 - 1
Sunrise/src/middleware/datagen/family4/progression/progression_bank_keys.cpp

@@ -1,8 +1,10 @@
 #include "progression_bank_keys.h"
 
 #include <array>
+#include <type_traits>
 
 #include "../../../../state/build_data/runtime.h"
+#include "../../../../state/unlocks/unlocks_runtime.h"
 
 namespace sunrise::middleware::datagen::family4::progression {
 namespace {
@@ -12,12 +14,28 @@ constexpr std::size_t kBankCapacity = 127;
 /** All bits set is the only value the record enumerator treats as an empty slot. */
 constexpr std::uint16_t kEmptyDefinitionIndex = 0xFFFF;
 
+// One lane type serves the authored bank and the native row, so no conversion can lose a lane.
+static_assert(std::is_same_v<layout::Values, state::unlocks::ProgressionLanes>);
+
+/**
+ * Picks the authored lanes of one replicated scope.
+ * @param scope Replicated object owning the bank.
+ * @return Lanes addressed by definition index.
+ */
+[[nodiscard]] const state::unlocks::ProgressionBank&
+authored_lanes(state::build_data::progressions::Scope scope) noexcept {
+    const state::unlocks::Table& table = state::unlocks::get();
+    return scope == state::build_data::progressions::Scope::account ? table.accountProgressions
+                                                                    : table.characterProgressions;
+}
+
 } // namespace
 
-/** Keys one object's progression bank from the installed progression definitions. */
+/** Keys one object's progression bank and fills each keyed row from the authored lanes. */
 bool key_bank(state::build_data::progressions::Scope scope,
               std::span<layout::Entry> bank) noexcept {
     for (layout::Entry& entry : bank) {
+        entry = layout::Entry{};
         entry.definitionIndex = kEmptyDefinitionIndex;
     }
     std::array<std::uint16_t, kBankCapacity> slots{};
@@ -25,8 +43,11 @@ bool key_bank(state::build_data::progressions::Scope scope,
     if (!state::build_data::find_progression_slots(scope, slots, count) || count > bank.size()) {
         return false;
     }
+    const state::unlocks::ProgressionBank& lanes = authored_lanes(scope);
     for (std::size_t slot = 0; slot < count; ++slot) {
+        // The definition catalog is dense, so every key it hands out addresses the authored bank.
         bank[slot].definitionIndex = slots[slot];
+        bank[slot].values = lanes[slots[slot]];
     }
     return true;
 }

+ 1 - 1
Sunrise/src/middleware/datagen/family4/progression/progression_bank_keys.h

@@ -8,7 +8,7 @@
 namespace sunrise::middleware::datagen::family4::progression {
 
 /**
- * Keys one object's progression bank from the installed progression definitions.
+ * Keys one object's progression bank and fills each keyed row from the authored lanes.
  * Slots start at the empty definition index: 0 is a real progression, and the record enumerator
  * skips a slot only when all bits are set. Keyed slots are this object's scope, in native order.
  * @param scope Replicated object owning the bank.

+ 67 - 0
Sunrise/src/middleware/encoding/bit_raw.cpp

@@ -0,0 +1,67 @@
+#include "bit_raw.h"
+
+#include <cstdint>
+
+namespace sunrise::middleware::encoding::bits {
+
+namespace {
+
+/** One raw byte is written as an 8-bit field. */
+constexpr std::uint8_t kByteWidth = 8;
+/** Mask of one raw byte. */
+constexpr std::uint64_t kByteMask = 0xFF;
+
+} // namespace
+
+/** Writes bytes in memory order. */
+bool write_raw(Writer& writer, std::span<const std::byte> bytes) noexcept {
+    for (const std::byte value : bytes) {
+        if (!writer.write(std::to_integer<std::uint64_t>(value), kByteWidth)) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** Reads bytes in memory order. */
+bool read_raw(Reader& reader, std::span<std::byte> bytes) noexcept {
+    for (std::byte& value : bytes) {
+        std::uint64_t field = 0;
+        if (!reader.read(kByteWidth, field)) {
+            return false;
+        }
+        value = static_cast<std::byte>(field);
+    }
+    return true;
+}
+
+/** Skips bytes in memory order. */
+bool skip_raw(Reader& reader, std::size_t count) noexcept {
+    return reader.skip(count * kByteWidth);
+}
+
+/** Reads one raw 64-bit integer. */
+bool read_raw_u64(Reader& reader, std::uint64_t& value) noexcept {
+    std::uint64_t assembled = 0;
+    for (std::size_t index = 0; index < sizeof(std::uint64_t); ++index) {
+        std::uint64_t byte = 0;
+        if (!reader.read(kByteWidth, byte)) {
+            return false;
+        }
+        assembled |= byte << (index * kByteWidth);
+    }
+    value = assembled;
+    return true;
+}
+
+/** Writes one raw 64-bit integer, low byte first. */
+bool write_raw_u64(Writer& writer, std::uint64_t value) noexcept {
+    for (std::size_t index = 0; index < sizeof(std::uint64_t); ++index) {
+        if (!writer.write((value >> (index * kByteWidth)) & kByteMask, kByteWidth)) {
+            return false;
+        }
+    }
+    return true;
+}
+
+} // namespace sunrise::middleware::encoding::bits

+ 53 - 0
Sunrise/src/middleware/encoding/bit_raw.h

@@ -0,0 +1,53 @@
+#pragma once
+
+#include <cstddef>
+#include <span>
+
+#include "bit_reader.h"
+#include "bit_writer.h"
+
+namespace sunrise::middleware::encoding::bits {
+
+/**
+ * Writes bytes in memory order.
+ * Raw fields copy bytes and keep engine order. Value fields are most-significant-bit first.
+ * @param writer Open writer.
+ * @param bytes Source bytes.
+ * @return True when every byte fit the output.
+ */
+[[nodiscard]] bool write_raw(Writer& writer, std::span<const std::byte> bytes) noexcept;
+
+/**
+ * Reads bytes in memory order.
+ * @param reader Open reader.
+ * @param bytes Destination, filled completely or not at all.
+ * @return True when the whole field was available.
+ */
+[[nodiscard]] bool read_raw(Reader& reader, std::span<std::byte> bytes) noexcept;
+
+/**
+ * Skips bytes in memory order.
+ * @param reader Open reader.
+ * @param count Byte count to consume.
+ * @return True when the whole field was available.
+ */
+[[nodiscard]] bool skip_raw(Reader& reader, std::size_t count) noexcept;
+
+/**
+ * Reads one raw 64-bit integer.
+ * A raw field keeps memory order, so an engine integer arrives low byte first.
+ * @param reader Open reader.
+ * @param value Receives the host-order value.
+ * @return True when all eight bytes were available.
+ */
+[[nodiscard]] bool read_raw_u64(Reader& reader, std::uint64_t& value) noexcept;
+
+/**
+ * Writes one raw 64-bit integer, low byte first.
+ * @param writer Open writer.
+ * @param value Host-order value.
+ * @return True when all eight bytes fit.
+ */
+[[nodiscard]] bool write_raw_u64(Writer& writer, std::uint64_t value) noexcept;
+
+} // namespace sunrise::middleware::encoding::bits

+ 152 - 0
Sunrise/src/middleware/gameplay/association/control_packet.cpp

@@ -0,0 +1,152 @@
+#include "control_packet.h"
+
+#include "../../encoding/bit_raw.h"
+#include "../../encoding/bit_reader.h"
+#include "../../encoding/bit_writer.h"
+
+namespace sunrise::middleware::gameplay::association {
+
+namespace {
+
+namespace bits = encoding::bits;
+
+/** The outer marker separates control packets from established protected packets. */
+constexpr std::uint8_t kMarkerWidth = 1;
+/** A control packet carries marker 1; an established packet carries 0. */
+constexpr std::uint64_t kControlMarker = 1;
+/** Both association words are 32-bit value fields. */
+constexpr std::uint8_t kWordWidth = 32;
+/** The opcode is a 3-bit value field. */
+constexpr std::uint8_t kOpcodeWidth = 3;
+/** The presence of the relayed SignOn field is one bit. */
+constexpr std::uint8_t kPresenceWidth = 1;
+/** The response echo is a 32-bit value field. */
+constexpr std::uint8_t kResponseWidth = 32;
+/** Highest opcode the enum defines. The three-bit field also holds 6 and 7, which are refused. */
+constexpr std::uint64_t kMaximumOpcode = 5;
+/** Bits in one byte of the trailer. */
+constexpr unsigned kByteBits = 8;
+/** Mask of one trailer byte. */
+constexpr std::uint16_t kByteMask = 0xFF;
+
+/** @return True when the opcode carries the full key-exchange body. */
+[[nodiscard]] bool carries_body(Opcode opcode) noexcept {
+    return opcode == Opcode::keyExchangeOffer || opcode == Opcode::keyExchangeResponse;
+}
+
+/**
+ * Reads the key-exchange body that both the offer and the response carry.
+ * @param reader Positioned just after the opcode.
+ * @param output Packet receiving the body fields.
+ * @return True when every field was present.
+ */
+[[nodiscard]] bool read_body(bits::Reader& reader, ControlPacket& output) noexcept {
+    if (!bits::read_raw(reader, output.networkId) || !bits::read_raw(reader, output.identityNonce)
+        || !bits::read_raw(reader, output.verifier)) {
+        return false;
+    }
+    std::uint64_t present = 0;
+    if (!reader.read(kPresenceWidth, present)) {
+        return false;
+    }
+    output.hasSignOnBlob = present != 0;
+    if (output.hasSignOnBlob && !bits::read_raw(reader, output.signOnBlob)) {
+        return false;
+    }
+    if (!bits::read_raw(reader, output.seed) || !bits::read_raw(reader, output.publicValue)) {
+        return false;
+    }
+    if (output.opcode != Opcode::keyExchangeResponse) {
+        return true;
+    }
+    std::uint64_t response = 0;
+    if (!reader.read(kResponseWidth, response)) {
+        return false;
+    }
+    output.responseValue = static_cast<std::uint32_t>(response);
+    return true;
+}
+
+/**
+ * Writes the key-exchange body that both the offer and the response carry.
+ * @param writer Positioned just after the opcode.
+ * @param packet Source fields.
+ * @return True when every field fit the output.
+ */
+[[nodiscard]] bool write_body(bits::Writer& writer, const ControlPacket& packet) noexcept {
+    if (!bits::write_raw(writer, packet.networkId) || !bits::write_raw(writer, packet.identityNonce)
+        || !bits::write_raw(writer, packet.verifier)
+        || !writer.write(packet.hasSignOnBlob ? 1U : 0U, kPresenceWidth)) {
+        return false;
+    }
+    if (packet.hasSignOnBlob && !bits::write_raw(writer, packet.signOnBlob)) {
+        return false;
+    }
+    if (!bits::write_raw(writer, packet.seed) || !bits::write_raw(writer, packet.publicValue)) {
+        return false;
+    }
+    if (packet.opcode != Opcode::keyExchangeResponse) {
+        return true;
+    }
+    return writer.write(packet.responseValue, kResponseWidth);
+}
+
+} // namespace
+
+/** Decodes one received control datagram. */
+bool decode(std::span<const std::byte> datagram, ControlPacket& output) noexcept {
+    if (datagram.size() <= kTrailerSize) {
+        return false;
+    }
+    // The address demux strips the clear trailer before any control field is read.
+    bits::Reader reader(datagram.subspan(0, datagram.size() - kTrailerSize));
+    std::uint64_t marker = 0;
+    std::uint64_t wordA = 0;
+    std::uint64_t wordB = 0;
+    std::uint64_t opcode = 0;
+    if (!reader.read(kMarkerWidth, marker) || marker != kControlMarker
+        || !reader.read(kWordWidth, wordA) || !reader.read(kWordWidth, wordB)
+        || !reader.read(kOpcodeWidth, opcode) || opcode > kMaximumOpcode) {
+        return false;
+    }
+    ControlPacket candidate{};
+    candidate.wordA = static_cast<std::uint32_t>(wordA);
+    candidate.wordB = static_cast<std::uint32_t>(wordB);
+    candidate.opcode = static_cast<Opcode>(opcode);
+    if (carries_body(candidate.opcode) && !read_body(reader, candidate)) {
+        return false;
+    }
+    output = candidate;
+    return true;
+}
+
+/** Encodes one control datagram. */
+bool encode(const ControlPacket& packet,
+            std::uint16_t destinationPort,
+            std::span<std::byte> output,
+            std::size_t& written) noexcept {
+    written = 0;
+    if (output.size() < kControlCapacity) {
+        return false;
+    }
+    bits::Writer writer(output.subspan(0, output.size() - kTrailerSize));
+    if (!writer.write(kControlMarker, kMarkerWidth) || !writer.write(packet.wordA, kWordWidth)
+        || !writer.write(packet.wordB, kWordWidth)
+        || !writer.write(static_cast<std::uint64_t>(packet.opcode), kOpcodeWidth)) {
+        return false;
+    }
+    if (carries_body(packet.opcode) && !write_body(writer, packet)) {
+        return false;
+    }
+    std::size_t bodySize = 0;
+    if (!writer.finish(bodySize)) {
+        return false;
+    }
+    // The trailer names the destination. It sits outside the bit padding.
+    output[bodySize] = static_cast<std::byte>(destinationPort & kByteMask);
+    output[bodySize + 1] = static_cast<std::byte>((destinationPort >> kByteBits) & kByteMask);
+    written = bodySize + kTrailerSize;
+    return true;
+}
+
+} // namespace sunrise::middleware::gameplay::association

+ 75 - 0
Sunrise/src/middleware/gameplay/association/control_packet.h

@@ -0,0 +1,75 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+namespace sunrise::middleware::gameplay::association {
+
+/** Identity fields are raw 64-bit values. */
+inline constexpr std::size_t kIdentitySize = 8;
+/** The connection nonce seed is a raw 96-bit value. */
+inline constexpr std::size_t kSeedSize = 12;
+/** Key material fields are raw 1024-bit values. */
+inline constexpr std::size_t kIntegerSize = 128;
+/** Direct IPv4 appends a two-byte clear address trailer after the control bits. */
+inline constexpr std::size_t kTrailerSize = 2;
+/** One control datagram never exceeds this, so it fits fixed endpoint storage. */
+inline constexpr std::size_t kControlCapacity = 512;
+
+/** Connection-control opcodes carried in the three bits after both association words. */
+enum class Opcode : std::uint8_t {
+    connectRequest = 0,
+    keyExchangeOffer = 1,
+    keyExchangeResponse = 2,
+    reject = 3,
+    keepAlive = 4,
+    invalid = 5,
+};
+
+/** One decoded or encodable key-exchange control packet. */
+struct ControlPacket {
+    /** Sender's association word A. An offer advances it on every retry. */
+    std::uint32_t wordA{};
+    /** Sender's association word B. It is stable across the retries of one attempt. */
+    std::uint32_t wordB{};
+    Opcode opcode{Opcode::invalid};
+    std::array<std::byte, kIdentitySize> networkId{};
+    std::array<std::byte, kIdentitySize> identityNonce{};
+    /** Identity public field. The exchange consumes it as the verifier. */
+    std::array<std::byte, kIntegerSize> verifier{};
+    bool hasSignOnBlob{};
+    /** Relayed SignOn material. Nothing in the exchange reads it. */
+    std::array<std::byte, kIntegerSize> signOnBlob{};
+    /** Sender's locally generated nonce seed. */
+    std::array<std::byte, kSeedSize> seed{};
+    /** Sender's public value. */
+    std::array<std::byte, kIntegerSize> publicValue{};
+    /** Echo of the offer's word B. Present on the response only. */
+    std::uint32_t responseValue{};
+};
+
+/**
+ * Decodes one received control datagram.
+ * The clear address trailer is stripped first. A datagram with no room for it is refused.
+ * @param datagram Whole received payload.
+ * @param output Receives the packet only when every field of the opcode was present.
+ * @return True for a complete offer or response. Other opcodes decode their header only.
+ */
+[[nodiscard]] bool decode(std::span<const std::byte> datagram, ControlPacket& output) noexcept;
+
+/**
+ * Encodes one control datagram.
+ * @param packet Fields to write. The opcode selects which of them are read.
+ * @param destinationPort Host-order UDP port of the destination, written into the trailer.
+ * @param output Caller storage of at least kControlCapacity bytes.
+ * @param written Receives the datagram length on success.
+ * @return True when the whole packet and its trailer fit the output.
+ */
+[[nodiscard]] bool encode(const ControlPacket& packet,
+                          std::uint16_t destinationPort,
+                          std::span<std::byte> output,
+                          std::size_t& written) noexcept;
+
+} // namespace sunrise::middleware::gameplay::association

+ 187 - 0
Sunrise/src/middleware/gameplay/association/protected_datagram.cpp

@@ -0,0 +1,187 @@
+#include "protected_datagram.h"
+
+#include "../../crypto/aes_gcm_decrypt.h"
+#include "../../crypto/aes_gcm_encrypt.h"
+#include "../../encoding/bit_raw.h"
+#include "../../encoding/bit_reader.h"
+#include "../../encoding/bit_writer.h"
+
+namespace sunrise::middleware::gameplay::association {
+
+namespace {
+
+namespace bits = encoding::bits;
+
+/** An established packet carries outer marker 0. */
+constexpr std::uint64_t kEstablishedMarker = 0;
+/** Width of the outer marker. */
+constexpr std::uint8_t kMarkerWidth = 1;
+/** Both clear words are 32-bit value fields. */
+constexpr std::uint8_t kWordWidth = 32;
+/** The mode selector is three bits and the writer emits zero. */
+constexpr std::uint8_t kModeWidth = 3;
+/** Direct IPv4 uses 11-bit byte-length fields. */
+constexpr std::uint8_t kLengthWidth = 11;
+/** Only mode zero is produced or accepted; the legacy modes are not negotiated. */
+constexpr std::uint64_t kMode = 0;
+/**
+ * Leading bit of the encrypted plaintext. Its meaning is unverified.
+ * Sends write zero. A received bit of either value is accepted.
+ */
+constexpr std::uint64_t kInnerMarker = 0;
+/** Width of the inner marker. */
+constexpr std::uint8_t kInnerMarkerWidth = 1;
+/** Bits in one byte. */
+constexpr unsigned kByteBits = 8;
+/** Mask of one byte. */
+constexpr std::uint32_t kByteMask = 0xFF;
+/** Bytes the clear header occupies once its 90 bits are padded to a byte boundary. */
+constexpr std::size_t kHeaderSize = 12;
+
+/**
+ * Derives one packet nonce.
+ * @param base Association nonce base for this direction.
+ * @param wordA Clear word A of the packet.
+ * @param wordB Clear word B of the packet.
+ * @param output Receives the 12-byte nonce.
+ */
+void derive_nonce(std::span<const std::byte, kNonceSize> base,
+                  std::uint32_t wordA,
+                  std::uint32_t wordB,
+                  std::span<std::byte, kNonceSize> output) noexcept {
+    // Each clear word covers four bytes of the nonce base, in big-endian order.
+    constexpr std::size_t kWordBytes = sizeof(std::uint32_t);
+    for (std::size_t index = 0; index < kNonceSize; ++index) {
+        output[index] = base[index];
+    }
+    for (std::size_t index = 0; index < kWordBytes; ++index) {
+        const unsigned shift = static_cast<unsigned>(kWordBytes - 1 - index) * kByteBits;
+        const auto highByte = static_cast<std::byte>((wordA >> shift) & kByteMask);
+        const auto lowByte = static_cast<std::byte>((wordB >> shift) & kByteMask);
+        output[index] ^= highByte;
+        output[kWordBytes + index] ^= lowByte;
+    }
+}
+
+} // namespace
+
+/** Seals one payload as a protected datagram. */
+bool seal(ProtectedContext& context,
+          std::span<const std::byte> payload,
+          std::uint16_t destinationPort,
+          std::span<std::byte> output,
+          std::size_t& written) noexcept {
+    written = 0;
+    if (!context.installed || payload.size() > kMaximumPayload
+        || output.size() < kProtectedCapacity) {
+        return false;
+    }
+    // Word A advances before the header so no two protected sends share a nonce.
+    ++context.outboundWordA;
+
+    std::array<std::byte, kProtectedCapacity> plaintext{};
+    bits::Writer plaintextWriter(plaintext);
+    std::array<std::byte, kTrailerSize> trailer{
+        static_cast<std::byte>(destinationPort & kByteMask),
+        static_cast<std::byte>((destinationPort >> kByteBits) & kByteMask),
+    };
+    std::size_t plaintextSize = 0;
+    if (!plaintextWriter.write(kInnerMarker, kInnerMarkerWidth)
+        || !bits::write_raw(plaintextWriter, payload) || !bits::write_raw(plaintextWriter, trailer)
+        || !plaintextWriter.finish(plaintextSize)) {
+        return false;
+    }
+
+    std::array<std::byte, kHeaderSize> header{};
+    bits::Writer headerWriter(header);
+    std::size_t headerSize = 0;
+    if (!headerWriter.write(kEstablishedMarker, kMarkerWidth)
+        || !headerWriter.write(context.outboundWordA, kWordWidth)
+        || !headerWriter.write(context.outboundWordB, kWordWidth)
+        || !headerWriter.write(kMode, kModeWidth)
+        || !headerWriter.write(payload.size(), kLengthWidth)
+        || !headerWriter.write(trailer.size(), kLengthWidth) || !headerWriter.finish(headerSize)
+        || headerSize != kHeaderSize) {
+        return false;
+    }
+
+    std::array<std::byte, kNonceSize> nonce{};
+    derive_nonce(context.outboundBase, context.outboundWordA, context.outboundWordB, nonce);
+    std::array<std::byte, kTagSize> tag{};
+    const std::size_t total = kHeaderSize + plaintextSize + kTagSize;
+    if (total > output.size()
+        || !crypto::aes_gcm::encrypt(context.key,
+                                     nonce,
+                                     std::span<const std::byte>(plaintext.data(), plaintextSize),
+                                     output.subspan(kHeaderSize, plaintextSize),
+                                     tag)) {
+        return false;
+    }
+    for (std::size_t index = 0; index < kHeaderSize; ++index) {
+        output[index] = header[index];
+    }
+    for (std::size_t index = 0; index < kTagSize; ++index) {
+        output[kHeaderSize + plaintextSize + index] = tag[index];
+    }
+    written = total;
+    return true;
+}
+
+/** Opens one received protected datagram. */
+bool open(const ProtectedContext& context,
+          std::span<const std::byte> datagram,
+          std::span<std::byte> payload,
+          std::size_t& payloadSize,
+          std::uint32_t& wordA) noexcept {
+    payloadSize = 0;
+    if (!context.installed || datagram.size() <= kHeaderSize + kTagSize
+        || datagram.size() > kProtectedCapacity) {
+        return false;
+    }
+    bits::Reader headerReader(datagram.subspan(0, kHeaderSize));
+    std::uint64_t marker = 0;
+    std::uint64_t receivedWordA = 0;
+    std::uint64_t receivedWordB = 0;
+    std::uint64_t mode = 0;
+    std::uint64_t declaredPayload = 0;
+    std::uint64_t declaredTrailer = 0;
+    if (!headerReader.read(kMarkerWidth, marker) || marker != kEstablishedMarker
+        || !headerReader.read(kWordWidth, receivedWordA)
+        || !headerReader.read(kWordWidth, receivedWordB) || !headerReader.read(kModeWidth, mode)
+        || mode != kMode || !headerReader.read(kLengthWidth, declaredPayload)
+        || !headerReader.read(kLengthWidth, declaredTrailer)) {
+        return false;
+    }
+
+    const std::size_t cipherSize = datagram.size() - kHeaderSize - kTagSize;
+    std::array<std::byte, kNonceSize> nonce{};
+    derive_nonce(context.inboundBase,
+                 static_cast<std::uint32_t>(receivedWordA),
+                 static_cast<std::uint32_t>(receivedWordB),
+                 nonce);
+    std::array<std::byte, kProtectedCapacity> plaintext{};
+    if (!crypto::aes_gcm::decrypt(
+            context.key,
+            nonce,
+            datagram.subspan(kHeaderSize, cipherSize),
+            datagram.subspan(kHeaderSize + cipherSize, kTagSize).first<kTagSize>(),
+            plaintext)) {
+        return false;
+    }
+
+    bits::Reader plaintextReader(std::span<const std::byte>(plaintext.data(), cipherSize));
+    std::uint64_t innerMarker = 0;
+    if (!plaintextReader.read(kInnerMarkerWidth, innerMarker)) {
+        return false;
+    }
+    const auto declared = static_cast<std::size_t>(declaredPayload);
+    if (declared > payload.size() || !bits::read_raw(plaintextReader, payload.subspan(0, declared))
+        || !bits::skip_raw(plaintextReader, static_cast<std::size_t>(declaredTrailer))) {
+        return false;
+    }
+    payloadSize = declared;
+    wordA = static_cast<std::uint32_t>(receivedWordA);
+    return true;
+}
+
+} // namespace sunrise::middleware::gameplay::association

+ 59 - 0
Sunrise/src/middleware/gameplay/association/protected_datagram.h

@@ -0,0 +1,59 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+#include "../../../state/gameplay/definition.h"
+#include "control_packet.h"
+
+namespace sunrise::middleware::gameplay::association {
+
+/** The envelope keys AES-128, so the derived key contributes its first 16 bytes. */
+inline constexpr std::size_t kKeySize = state::gameplay::kChannelKeySize;
+/** The nonce is 12 bytes, the same width as the exchanged seed. */
+inline constexpr std::size_t kNonceSize = kSeedSize;
+/** The envelope carries the full 16-byte tag. */
+inline constexpr std::size_t kTagSize = 16;
+/** One protected datagram stays inside a single unfragmented transport packet. */
+inline constexpr std::size_t kProtectedCapacity = 1500;
+/** The clear length fields are 11 bits, so a payload must stay below 2048 bytes. */
+inline constexpr std::size_t kMaximumPayload = 2047;
+
+/** Crypto state installed in one step when an association becomes established. */
+using ProtectedContext = state::gameplay::ProtectedContext;
+
+/**
+ * Seals one payload as a protected datagram.
+ * The clear header is not authenticated data: the tag covers the encrypted bits only.
+ * @param context Installed crypto state. Its outgoing word A is advanced before the header.
+ * @param payload Established transport payload bytes.
+ * @param destinationPort Host-order UDP port written into the encrypted trailer.
+ * @param output Caller storage of at least kProtectedCapacity bytes.
+ * @param written Receives the datagram length on success.
+ * @return True when the payload fit the length fields and the transform succeeded.
+ */
+[[nodiscard]] bool seal(ProtectedContext& context,
+                        std::span<const std::byte> payload,
+                        std::uint16_t destinationPort,
+                        std::span<std::byte> output,
+                        std::size_t& written) noexcept;
+
+/**
+ * Opens one received protected datagram.
+ * No sender state is updated. The caller applies its replay policy after this reports success.
+ * @param context Installed crypto state.
+ * @param datagram Whole received payload.
+ * @param payload Receives the decrypted transport payload.
+ * @param payloadSize Receives the payload length.
+ * @param wordA Receives the sender's clear word A, which the replay policy consumes.
+ * @return True only when the tag authenticates and every declared length is consistent.
+ */
+[[nodiscard]] bool open(const ProtectedContext& context,
+                        std::span<const std::byte> datagram,
+                        std::span<std::byte> payload,
+                        std::size_t& payloadSize,
+                        std::uint32_t& wordA) noexcept;
+
+} // namespace sunrise::middleware::gameplay::association

+ 138 - 0
Sunrise/src/middleware/gameplay/association/srp_exchange.cpp

@@ -0,0 +1,138 @@
+#include "srp_exchange.h"
+
+#include <Windows.h>
+
+#include "../../crypto/random_bytes.h"
+
+namespace sunrise::middleware::gameplay::association::srp {
+
+namespace {
+
+namespace modular = crypto::modular;
+
+/** Private exponents are 32 bytes, the width the remote peer also generates. */
+constexpr std::size_t kPrivateSize = 32;
+/** Generator of the group below. It is fixed at 2 by the group's definition. */
+constexpr std::uint64_t kGenerator = 2;
+/** Multiplier that scales the verifier into the public value. SRP fixes it at 3. */
+constexpr std::uint64_t kMultiplier = 3;
+
+/**
+ * The published 1024-bit SRP group modulus, least significant limb first.
+ * Both endpoints are fixed to this group. It is a public parameter and carries no secret.
+ */
+constexpr modular::Number kGroupModulus{
+    0x9FC61D2FC0EB06E3ULL,
+    0xFD5138FE8376435BULL,
+    0x2FD4CBF4976EAA9AULL,
+    0x68EDBC3C05726CC0ULL,
+    0xC529F566660E57ECULL,
+    0x82559B297BCF1885ULL,
+    0xCE8EF4AD69B15D49ULL,
+    0x5DC7D7B46154D6B6ULL,
+    0x8E495C1D6089DAD1ULL,
+    0xE0D5D8E250B98BE4ULL,
+    0x383B4813D692C6E0ULL,
+    0xD674DF7496EA81D3ULL,
+    0x9EA2314C9C256576ULL,
+    0x6072618775FF3C0BULL,
+    0x9C33F80AFA8FC5E8ULL,
+    0xEEAF0AB9ADB38DD6ULL,
+};
+
+/**
+ * Checks one exchanged public value against the Sunrise range policy.
+ * @param value Imported integer.
+ * @param modulus Prepared group.
+ * @return True when the value is nonzero and below the modulus.
+ */
+[[nodiscard]] bool in_range(const modular::Number& value,
+                            const modular::Modulus& modulus) noexcept {
+    return !modular::is_zero(value) && modular::less_than(value, modulus.value);
+}
+
+/**
+ * Computes the scrambling parameter from both public values.
+ * @param clientPublic Offered public value in wire form.
+ * @param serverPublic Answered public value in wire form.
+ * @param output Receives the parameter as a reduced integer.
+ * @return True when the hash succeeded.
+ */
+[[nodiscard]] bool scramble(const Integer& clientPublic,
+                            const Integer& serverPublic,
+                            modular::Number& output) noexcept {
+    crypto::sha256::Digest digest{};
+    if (!crypto::sha256::hash_pair(clientPublic, serverPublic, digest)) {
+        return false;
+    }
+    // The digest is shorter than one integer, so it enters the arithmetic left zero padded.
+    std::array<std::byte, kIntegerSize> padded{};
+    for (std::size_t index = 0; index < digest.size(); ++index) {
+        padded[kIntegerSize - digest.size() + index] = digest[index];
+    }
+    modular::import_big_endian(padded, output);
+    return true;
+}
+
+} // namespace
+
+/** Runs the server half of the exchange. */
+bool derive(Exchange& exchange) noexcept {
+    exchange.complete = false;
+    // Preparing the group is far cheaper than one exponentiation, so it stays call local.
+    modular::Modulus modulus{};
+    if (!modular::prepare(kGroupModulus, modulus)) {
+        return false;
+    }
+    modular::Number verifier{};
+    modular::Number clientPublic{};
+    modular::import_big_endian(exchange.verifier, verifier);
+    modular::import_big_endian(exchange.clientPublic, clientPublic);
+    if (!in_range(verifier, modulus) || !in_range(clientPublic, modulus)) {
+        return false;
+    }
+
+    std::array<std::byte, kPrivateSize> privateBytes{};
+    if (!crypto::random::fill(privateBytes)) {
+        return false;
+    }
+    std::array<std::byte, kIntegerSize> paddedPrivate{};
+    for (std::size_t index = 0; index < privateBytes.size(); ++index) {
+        paddedPrivate[kIntegerSize - privateBytes.size() + index] = privateBytes[index];
+    }
+    modular::Number exponent{};
+    modular::import_big_endian(paddedPrivate, exponent);
+    SecureZeroMemory(privateBytes.data(), privateBytes.size());
+    SecureZeroMemory(paddedPrivate.data(), paddedPrivate.size());
+
+    modular::Number generator{};
+    modular::set_small(generator, kGenerator);
+    modular::Number multiplier{};
+    modular::set_small(multiplier, kMultiplier);
+
+    modular::Number serverPublic{};
+    modular::power(modulus, generator, exponent, serverPublic);
+    modular::Number scaled{};
+    modular::multiply(modulus, multiplier, verifier, scaled);
+    modular::add(modulus, scaled, serverPublic, serverPublic);
+    modular::export_big_endian(serverPublic, exchange.serverPublic);
+
+    modular::Number parameter{};
+    bool derived = scramble(exchange.clientPublic, exchange.serverPublic, parameter);
+    if (derived) {
+        modular::Number shared{};
+        modular::power(modulus, verifier, parameter, shared);
+        modular::multiply(modulus, clientPublic, shared, shared);
+        modular::power(modulus, shared, exponent, shared);
+        std::array<std::byte, kIntegerSize> sharedBytes{};
+        modular::export_big_endian(shared, sharedBytes);
+        derived = crypto::sha256::hash(sharedBytes, exchange.derivedKey);
+        SecureZeroMemory(sharedBytes.data(), sharedBytes.size());
+        SecureZeroMemory(shared.data(), shared.size() * sizeof(std::uint64_t));
+    }
+    SecureZeroMemory(exponent.data(), exponent.size() * sizeof(std::uint64_t));
+    exchange.complete = derived;
+    return derived;
+}
+
+} // namespace sunrise::middleware::gameplay::association::srp

+ 41 - 0
Sunrise/src/middleware/gameplay/association/srp_exchange.h

@@ -0,0 +1,41 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+
+#include "../../crypto/modular_1024.h"
+#include "../../crypto/sha256.h"
+
+namespace sunrise::middleware::gameplay::association::srp {
+
+/** Every exchanged integer is 128 bytes, big-endian and left zero padded. */
+inline constexpr std::size_t kIntegerSize = crypto::modular::kByteSize;
+/** The derived key is one SHA-256 digest. */
+inline constexpr std::size_t kDerivedKeySize = crypto::sha256::kDigestSize;
+
+/** One fixed-width exchanged integer. */
+using Integer = std::array<std::byte, kIntegerSize>;
+
+/** One server-side exchange, filled in from the offer and answered in the response. */
+struct Exchange {
+    /** Verifier the offer carries as its identity public field. */
+    Integer verifier{};
+    /** Public value the offer carries as its last integer field. */
+    Integer clientPublic{};
+    /** Public value the response returns. Valid only once the exchange completes. */
+    Integer serverPublic{};
+    /** SHA-256 of the shared value. Its first 16 bytes key the protected envelope. */
+    std::array<std::byte, kDerivedKeySize> derivedKey{};
+    bool complete{};
+};
+
+/**
+ * Runs the server half of the exchange.
+ * The private exponent stays inside this call and is erased before it returns.
+ * A public value of zero, or one at or above the modulus, is refused. That bound is our policy.
+ * @param exchange Verifier and client public in; server public and derived key out.
+ * @return True when both inputs are in range and every stage of the arithmetic succeeded.
+ */
+[[nodiscard]] bool derive(Exchange& exchange) noexcept;
+
+} // namespace sunrise::middleware::gameplay::association::srp

+ 106 - 0
Sunrise/src/middleware/gameplay/descriptor/join_descriptor.cpp

@@ -0,0 +1,106 @@
+#include "join_descriptor.h"
+
+#include <algorithm>
+#include <span>
+
+namespace sunrise::middleware::gameplay::descriptor {
+
+namespace {
+
+/** The machine identity occupies the first eight bytes. */
+constexpr std::size_t kMachineOffset = 0;
+/** The NetAddr follows the machine identity. */
+constexpr std::size_t kNetAddrOffset = 8;
+/** The IPv4 address opens the first local entry, in network byte order. */
+constexpr std::size_t kAddressOffset = 0;
+/** The UDP port follows the address, low byte first. The IPv4 beside it stays network order. */
+constexpr std::size_t kPortOffset = 4;
+/** The public entry follows five 6-byte local entries, and carries the same two fields. */
+constexpr std::size_t kPublicAddressOffset = 30;
+/** The public UDP port follows the public address, low byte first. */
+constexpr std::size_t kPublicPortOffset = 34;
+/** The NAT type closes the address body at offset 40. */
+constexpr std::size_t kNatTypeOffset = 40;
+/** NAT type 1 reads as open. Zero reads as unknown and leaves the address unroutable. */
+constexpr std::byte kNatTypeOpen{1};
+/** The transport method is the last NetAddr byte. */
+constexpr std::size_t kMethodOffset = kNetAddrSize - 1;
+/** Method 0 selects the direct path. Methods 6 and 7 would select the relay instead. */
+constexpr std::byte kDirectMethod{0};
+/** The online session id occupies the tail, low half first. */
+constexpr std::size_t kSessionOffset = 110;
+/** Bits in one byte. */
+constexpr unsigned kByteBits = 8;
+/** Mask of one byte. */
+constexpr std::uint64_t kByteMask = 0xFF;
+/** The advertised port must be even. */
+constexpr std::uint16_t kPortAlignment = 2;
+
+/**
+ * Writes one unsigned value in network byte order.
+ * @param output Field storage.
+ * @param offset First byte of the field.
+ * @param value Host-order value.
+ * @param width Field width in bytes.
+ */
+void write_network_order(std::span<std::byte> output,
+                         std::size_t offset,
+                         std::uint64_t value,
+                         std::size_t width) noexcept {
+    for (std::size_t index = 0; index < width; ++index) {
+        const unsigned shift = static_cast<unsigned>(width - 1 - index) * kByteBits;
+        output[offset + index] = static_cast<std::byte>((value >> shift) & kByteMask);
+    }
+}
+
+/**
+ * Writes one unsigned value low byte first.
+ * @param output Field storage.
+ * @param offset First byte of the field.
+ * @param value Host-order value.
+ * @param width Field width in bytes.
+ */
+void write_memory_order(std::span<std::byte> output,
+                        std::size_t offset,
+                        std::uint64_t value,
+                        std::size_t width) noexcept {
+    for (std::size_t index = 0; index < width; ++index) {
+        const unsigned shift = static_cast<unsigned>(index) * kByteBits;
+        output[offset + index] = static_cast<std::byte>((value >> shift) & kByteMask);
+    }
+}
+
+} // namespace
+
+/** Builds one NetAddr for the direct method-0 path. */
+void write_net_addr(std::uint32_t address,
+                    std::uint16_t port,
+                    std::array<std::byte, kNetAddrSize>& output) noexcept {
+    output = {};
+    write_network_order(output, kAddressOffset, address, sizeof(std::uint32_t));
+    write_memory_order(output, kPortOffset, port, sizeof(std::uint16_t));
+    write_network_order(output, kPublicAddressOffset, address, sizeof(std::uint32_t));
+    write_memory_order(output, kPublicPortOffset, port, sizeof(std::uint16_t));
+    output[kNatTypeOffset] = kNatTypeOpen;
+    output[kMethodOffset] = kDirectMethod;
+}
+
+/** Builds one join descriptor for the direct method-0 path. */
+bool build(const JoinEndpoint& endpoint, std::array<std::byte, kDescriptorSize>& output) noexcept {
+    if (endpoint.machineId == 0 || endpoint.address == 0 || endpoint.port == 0
+        || endpoint.port % kPortAlignment != 0 || endpoint.onlineSessionId == 0) {
+        return false;
+    }
+    std::array<std::byte, kNetAddrSize> netAddr{};
+    write_net_addr(endpoint.address, endpoint.port, netAddr);
+
+    std::array<std::byte, kDescriptorSize> candidate{};
+    write_memory_order(candidate, kMachineOffset, endpoint.machineId, sizeof(std::uint64_t));
+    std::copy(netAddr.begin(), netAddr.end(), candidate.begin() + kNetAddrOffset);
+    // The join key stays zero. The direct path accepts it and carries no key material.
+    write_memory_order(candidate, kSessionOffset, endpoint.onlineSessionId, sizeof(std::uint64_t));
+    output = candidate;
+    return true;
+}
+
+} // namespace sunrise::middleware::gameplay::descriptor

+ 48 - 0
Sunrise/src/middleware/gameplay/descriptor/join_descriptor.h

@@ -0,0 +1,48 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+
+namespace sunrise::middleware::gameplay::descriptor {
+
+/** The join descriptor is exactly 128 bytes, and a different count makes it absent. */
+inline constexpr std::size_t kDescriptorSize = 128;
+
+/** A peer NetAddr is exactly 86 bytes wherever it appears. */
+inline constexpr std::size_t kNetAddrSize = 86;
+
+/** Everything one direct-path join descriptor publishes. */
+struct JoinEndpoint {
+    /** Nonzero machine identity. A zero identity makes the descriptor read as absent. */
+    std::uint64_t machineId{};
+    /** Advertised IPv4 in host order. */
+    std::uint32_t address{};
+    /** Advertised UDP port in host order. It must be even. */
+    std::uint16_t port{};
+    /** Nonzero online session id. Its low 64 bits are the lobby the client joins. */
+    std::uint64_t onlineSessionId{};
+};
+
+/**
+ * Builds one NetAddr for the direct method-0 path.
+ * The endpoint goes in twice, as local entry 0 and as the public entry. A peer with no public
+ * entry is unroutable and the client falls back to NAT traversal.
+ * @param address IPv4 in host order.
+ * @param port UDP port in host order.
+ * @param output Receives all 86 bytes.
+ */
+void write_net_addr(std::uint32_t address,
+                    std::uint16_t port,
+                    std::array<std::byte, kNetAddrSize>& output) noexcept;
+
+/**
+ * Builds one join descriptor for the direct method-0 path.
+ * @param endpoint Advertised identity and endpoint.
+ * @param output Receives all 128 bytes only on success.
+ * @return True when the identity, address, port, and session id are all usable.
+ */
+[[nodiscard]] bool build(const JoinEndpoint& endpoint,
+                         std::array<std::byte, kDescriptorSize>& output) noexcept;
+
+} // namespace sunrise::middleware::gameplay::descriptor

+ 76 - 0
Sunrise/src/middleware/gameplay/dtls/association_keys.cpp

@@ -0,0 +1,76 @@
+#include "association_keys.h"
+
+#include <Windows.h>
+
+#include <algorithm>
+#include <string_view>
+
+#include "../../crypto/tiger192.h"
+
+namespace sunrise::middleware::gameplay::dtls {
+
+namespace {
+
+/** The shared secret opens the derivation buffer. */
+constexpr std::size_t kSecretOffset = 0;
+/** The agreed secret is one field element wide. */
+constexpr std::size_t kSecretSize = 28;
+/** The security key follows the secret. */
+constexpr std::size_t kSecurityKeyOffset = kSecretOffset + kSecretSize;
+/** The suffix closes the buffer and is zero padded to its end. */
+constexpr std::size_t kSuffixOffset = kSecurityKeyOffset + kSecurityKeySize;
+/** Suffix that selects the cypher key. It travels without a terminator. */
+constexpr std::string_view kCypherSuffix{"cypherKeySuffix"};
+/** Suffix that selects the authentication key. Its terminator is part of the buffer. */
+constexpr std::string_view kAuthSuffix{"SuffixForHMACKey"};
+
+/**
+ * Builds the derivation buffer for one suffix.
+ * @param sharedSecret Agreed secret.
+ * @param securityKey Join key.
+ * @param suffix Suffix that selects the key.
+ * @param terminated True when the suffix carries its string terminator into the buffer.
+ * @param output Receives the whole buffer.
+ */
+void build(std::span<const std::byte> sharedSecret,
+           std::span<const std::byte> securityKey,
+           std::string_view suffix,
+           bool terminated,
+           std::array<std::byte, kDerivationSize>& output) noexcept {
+    output = {};
+    std::copy(sharedSecret.begin(), sharedSecret.end(), output.begin() + kSecretOffset);
+    std::copy(securityKey.begin(), securityKey.end(), output.begin() + kSecurityKeyOffset);
+    for (std::size_t index = 0; index < suffix.size(); ++index) {
+        output[kSuffixOffset + index] = static_cast<std::byte>(suffix[index]);
+    }
+    if (terminated) {
+        output[kSuffixOffset + suffix.size()] = std::byte{0};
+    }
+}
+
+} // namespace
+
+/** Derives both association keys from the agreed secret. */
+bool derive(std::span<const std::byte> sharedSecret,
+            std::span<const std::byte> securityKey,
+            Keys& output) noexcept {
+    if (sharedSecret.size() != kSecretSize || securityKey.size() != kSecurityKeySize) {
+        return false;
+    }
+    std::array<std::byte, kDerivationSize> buffer{};
+    crypto::tiger::Digest digest{};
+
+    build(sharedSecret, securityKey, kCypherSuffix, false, buffer);
+    crypto::tiger::hash(buffer, digest);
+    std::copy_n(digest.begin(), kCypherKeySize, output.cypher.begin());
+
+    build(sharedSecret, securityKey, kAuthSuffix, true, buffer);
+    crypto::tiger::hash(buffer, digest);
+    std::copy_n(digest.begin(), kAuthKeySize, output.auth.begin());
+    // The buffer holds the secret and the join key; the digest holds both derived keys.
+    SecureZeroMemory(buffer.data(), buffer.size());
+    SecureZeroMemory(digest.data(), digest.size());
+    return true;
+}
+
+} // namespace sunrise::middleware::gameplay::dtls

+ 37 - 0
Sunrise/src/middleware/gameplay/dtls/association_keys.h

@@ -0,0 +1,37 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <span>
+
+namespace sunrise::middleware::gameplay::dtls {
+
+/** The security key the derivation mixes in. It is the join key the descriptor advertises. */
+inline constexpr std::size_t kSecurityKeySize = 16;
+/** The derivation buffer holds the shared secret, the security key, and a zero-padded suffix. */
+inline constexpr std::size_t kDerivationSize = 68;
+/** The record cypher key width. The digest is truncated to it. */
+inline constexpr std::size_t kCypherKeySize = 16;
+/** The record authentication takes the whole digest. */
+inline constexpr std::size_t kAuthKeySize = 24;
+
+/** The two keys one association derives. */
+struct Keys {
+    /** Key the record cypher takes. */
+    std::array<std::byte, kCypherKeySize> cypher{};
+    /** Key the record authentication takes. */
+    std::array<std::byte, kAuthKeySize> auth{};
+};
+
+/**
+ * Derives both association keys from the agreed secret.
+ * @param sharedSecret Agreed x coordinate, high byte first.
+ * @param securityKey Join key the descriptor advertised.
+ * @param output Receives both keys only on success.
+ * @return True when the inputs are the widths the derivation requires.
+ */
+[[nodiscard]] bool derive(std::span<const std::byte> sharedSecret,
+                          std::span<const std::byte> securityKey,
+                          Keys& output) noexcept;
+
+} // namespace sunrise::middleware::gameplay::dtls

+ 192 - 0
Sunrise/src/middleware/gameplay/dtls/dtls_messages.cpp

@@ -0,0 +1,192 @@
+#include "dtls_messages.h"
+
+#include <algorithm>
+
+namespace sunrise::middleware::gameplay::dtls {
+
+namespace {
+
+/** Bits in one byte. */
+constexpr unsigned kByteBits = 8;
+/** Mask of one byte. */
+constexpr std::uint32_t kByteMask = 0xFF;
+
+/** The message type opens the header. */
+constexpr std::size_t kTypeOffset = 0;
+/** A constant the requester writes and never reads back. */
+constexpr std::size_t kVersionOffset = 1;
+/** Value the sender puts in that byte. */
+constexpr std::byte kVersion{2};
+/** The tag the packet is addressed to follows the version. */
+constexpr std::size_t kPeerTagOffset = 2;
+/** Four bytes close the header. Both handshake packets leave them zero. */
+constexpr std::size_t kReservedOffset = 4;
+
+/** The requester's own tag opens the init body. */
+constexpr std::size_t kInitTagOffset = kHeaderSize;
+/** The security id closes the init. */
+constexpr std::size_t kInitSecurityIdOffset = kInitTagOffset + 2;
+
+/** The timestamp opens the init ack body. */
+constexpr std::size_t kInitAckTimestampOffset = kHeaderSize;
+/** The cookie follows the timestamp. */
+constexpr std::size_t kInitAckCookieOffset = kInitAckTimestampOffset + 4;
+/** Five tags follow the cookie: responder, responder, requester, then two migration tags. */
+constexpr std::size_t kInitAckTagsOffset = kInitAckCookieOffset + kCookieSize;
+/** The requester address follows the tags, as four address bytes and a port. */
+constexpr std::size_t kInitAckAddressOffset = kInitAckTagsOffset + 10;
+/** The security id closes the init ack. */
+constexpr std::size_t kInitAckSecurityIdOffset = kInitAckAddressOffset + 6;
+
+/** The echoed init ack follows the cookie echo header. */
+constexpr std::size_t kCookieEchoInitAckOffset = kHeaderSize;
+/** The security id follows the address block and precedes the key. */
+constexpr std::size_t kCookieEchoSecurityIdOffset = kCookieEchoInitAckOffset + kInitAckSize + 41;
+/** The public key closes the cookie echo. */
+constexpr std::size_t kCookieEchoKeyOffset = kCookieEchoSize - kPublicKeySize;
+
+/** The public key follows the cookie ack header. */
+constexpr std::size_t kCookieAckKeyOffset = kHeaderSize;
+/** The security id closes the cookie ack. */
+constexpr std::size_t kCookieAckSecurityIdOffset = kCookieAckKeyOffset + kPublicKeySize;
+
+/**
+ * Writes one unsigned value low byte first. Every field of this layer travels that way.
+ * @param output Field storage.
+ * @param offset First byte of the field.
+ * @param value Host-order value.
+ * @param width Field width in bytes.
+ */
+void write_memory_order(std::span<std::byte> output,
+                        std::size_t offset,
+                        std::uint64_t value,
+                        std::size_t width) noexcept {
+    for (std::size_t index = 0; index < width; ++index) {
+        output[offset + index] = static_cast<std::byte>((value >> (index * kByteBits)) & kByteMask);
+    }
+}
+
+/**
+ * Reads one unsigned 16-bit value, low byte first.
+ * @param datagram Received bytes.
+ * @param offset First byte of the field.
+ * @return Host-order value.
+ */
+[[nodiscard]] std::uint16_t read_word(std::span<const std::byte> datagram,
+                                      std::size_t offset) noexcept {
+    return static_cast<std::uint16_t>(
+        std::to_integer<std::uint16_t>(datagram[offset])
+        | (std::to_integer<std::uint16_t>(datagram[offset + 1]) << kByteBits));
+}
+
+/**
+ * Writes the shared header.
+ * @param output Datagram storage.
+ * @param type Message type.
+ * @param peerTag Tag the receiver checks against its own.
+ */
+void write_header(std::span<std::byte> output, Type type, std::uint16_t peerTag) noexcept {
+    output[kTypeOffset] = static_cast<std::byte>(type);
+    output[kVersionOffset] = kVersion;
+    write_memory_order(output, kPeerTagOffset, peerTag, sizeof(std::uint16_t));
+    write_memory_order(output, kReservedOffset, 0, sizeof(std::uint32_t));
+}
+
+/**
+ * Writes one address as four address bytes then a port.
+ * @param output Datagram storage.
+ * @param offset First byte of the field.
+ * @param address IPv4 in host order.
+ * @param port UDP port in host order.
+ */
+void write_address(std::span<std::byte> output,
+                   std::size_t offset,
+                   std::uint32_t address,
+                   std::uint16_t port) noexcept {
+    // The address travels in dotted order and the port beside it travels low byte first.
+    output[offset] = static_cast<std::byte>((address >> 24U) & kByteMask);
+    output[offset + 1] = static_cast<std::byte>((address >> 16U) & kByteMask);
+    output[offset + 2] = static_cast<std::byte>((address >> kByteBits) & kByteMask);
+    output[offset + 3] = static_cast<std::byte>(address & kByteMask);
+    write_memory_order(output, offset + 4, port, sizeof(std::uint16_t));
+}
+
+} // namespace
+
+/** Reads the message type of one datagram. */
+bool read_type(std::span<const std::byte> datagram, std::uint8_t& output) noexcept {
+    if (datagram.size() < kHeaderSize) {
+        return false;
+    }
+    output = std::to_integer<std::uint8_t>(datagram[kTypeOffset]);
+    return true;
+}
+
+/** Reads the verification tag the sender addressed this packet to. */
+bool read_peer_tag(std::span<const std::byte> datagram, std::uint16_t& output) noexcept {
+    if (datagram.size() < kHeaderSize) {
+        return false;
+    }
+    output = read_word(datagram, kPeerTagOffset);
+    return true;
+}
+
+/** Reads one init. */
+bool read_init(std::span<const std::byte> datagram, Init& output) noexcept {
+    std::uint8_t type = 0;
+    if (datagram.size() != kInitSize || !read_type(datagram, type)
+        || type != static_cast<std::uint8_t>(Type::init)) {
+        return false;
+    }
+    output.initTag = read_word(datagram, kInitTagOffset);
+    std::copy_n(
+        datagram.begin() + kInitSecurityIdOffset, kSecurityIdSize, output.securityId.begin());
+    return true;
+}
+
+/** Reads the fields a cookie echo must surrender. */
+bool read_cookie_echo(std::span<const std::byte> datagram, CookieEcho& output) noexcept {
+    std::uint8_t type = 0;
+    if (datagram.size() != kCookieEchoSize || !read_type(datagram, type)
+        || type != static_cast<std::uint8_t>(Type::cookieEcho)) {
+        return false;
+    }
+    std::copy_n(
+        datagram.begin() + kCookieEchoInitAckOffset, kInitAckSize, output.echoedInitAck.begin());
+    std::copy_n(
+        datagram.begin() + kCookieEchoSecurityIdOffset, kSecurityIdSize, output.securityId.begin());
+    std::copy_n(datagram.begin() + kCookieEchoKeyOffset, kPublicKeySize, output.publicKey.begin());
+    return true;
+}
+
+/** Builds one init ack. */
+void write_init_ack(const InitAck& initAck, std::array<std::byte, kInitAckSize>& output) noexcept {
+    output = {};
+    write_header(output, Type::initAck, initAck.requesterTag);
+    write_memory_order(output, kInitAckTimestampOffset, initAck.timestamp, sizeof(std::uint32_t));
+    std::copy(initAck.cookie.begin(), initAck.cookie.end(), output.begin() + kInitAckCookieOffset);
+    // The requester adopts the first tag as its peer tag and checks the third against its own.
+    write_memory_order(output, kInitAckTagsOffset, initAck.responderTag, sizeof(std::uint16_t));
+    write_memory_order(output, kInitAckTagsOffset + 2, initAck.responderTag, sizeof(std::uint16_t));
+    write_memory_order(output, kInitAckTagsOffset + 4, initAck.requesterTag, sizeof(std::uint16_t));
+    // The last two tags carry a migrating association's old pair. A fresh one leaves them zero.
+    write_address(output, kInitAckAddressOffset, initAck.address, initAck.port);
+    std::copy(initAck.securityId.begin(),
+              initAck.securityId.end(),
+              output.begin() + kInitAckSecurityIdOffset);
+}
+
+/** Builds one cookie ack. */
+void write_cookie_ack(const CookieAck& cookieAck,
+                      std::array<std::byte, kCookieAckSize>& output) noexcept {
+    output = {};
+    write_header(output, Type::cookieAck, cookieAck.requesterTag);
+    std::copy(cookieAck.publicKey.begin(),
+              cookieAck.publicKey.end(),
+              output.begin() + kCookieAckKeyOffset);
+    std::copy(cookieAck.securityId.begin(),
+              cookieAck.securityId.end(),
+              output.begin() + kCookieAckSecurityIdOffset);
+}
+
+} // namespace sunrise::middleware::gameplay::dtls

+ 134 - 0
Sunrise/src/middleware/gameplay/dtls/dtls_messages.h

@@ -0,0 +1,134 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+namespace sunrise::middleware::gameplay::dtls {
+
+/** Every association packet opens with the same 8-byte header. */
+inline constexpr std::size_t kHeaderSize = 8;
+/** The security id every packet carries. The peer routes on it and drops a packet that misses. */
+inline constexpr std::size_t kSecurityIdSize = 8;
+/** An init is header plus a tag and the security id. */
+inline constexpr std::size_t kInitSize = 18;
+/** An init ack is header, timestamp, cookie, five tags, an address, and the security id. */
+inline constexpr std::size_t kInitAckSize = 52;
+/** The cookie the responder issues and the requester echoes. */
+inline constexpr std::size_t kCookieSize = 16;
+/** An exported public key is padded to this length whatever the curve produced. */
+inline constexpr std::size_t kPublicKeySize = 100;
+/** A cookie echo is header, the whole init ack, an address block, the security id, and the key. */
+inline constexpr std::size_t kCookieEchoSize =
+    kHeaderSize + kInitAckSize + 41 + kSecurityIdSize + kPublicKeySize;
+/** A cookie ack is header, the public key, and the security id. */
+inline constexpr std::size_t kCookieAckSize = kHeaderSize + kPublicKeySize + kSecurityIdSize;
+
+/** The 8-byte value the peer keys its association table on. It is opaque and travels verbatim. */
+using SecurityId = std::array<std::byte, kSecurityIdSize>;
+
+/** Message types this association understands. Others reach the next router unchanged. */
+enum class Type : std::uint8_t {
+    init = 1,
+    initAck = 2,
+    cookieEcho = 3,
+    cookieAck = 4,
+};
+
+/** Everything one received init carries. */
+struct Init {
+    /** Verification tag the requester chose for itself. */
+    std::uint16_t initTag{};
+    /** Security id to echo. Every later packet we send must repeat it or the peer discards it. */
+    SecurityId securityId{};
+};
+
+/** Everything one received cookie echo carries that the responder reads. */
+struct CookieEcho {
+    /** The responder's own init ack, returned unaltered. Comparing it is the cookie check. */
+    std::array<std::byte, kInitAckSize> echoedInitAck{};
+    /** Requester's exported public key. It closes the key exchange. */
+    std::array<std::byte, kPublicKeySize> publicKey{};
+    /** Security id the requester repeated. */
+    SecurityId securityId{};
+};
+
+/** Everything one init ack publishes. */
+struct InitAck {
+    /** Tag the requester chose. It refuses an init ack whose header names a different tag. */
+    std::uint16_t requesterTag{};
+    /** Tag the responder chose. The requester adopts it as its peer tag. */
+    std::uint16_t responderTag{};
+    /** Opaque cookie the requester must echo. */
+    std::array<std::byte, kCookieSize> cookie{};
+    /** Requester's address, in host order. */
+    std::uint32_t address{};
+    /** Requester's UDP port, in host order. */
+    std::uint16_t port{};
+    /** Free-running value the requester stores and never checks. */
+    std::uint32_t timestamp{};
+    /** Security id taken from the init. */
+    SecurityId securityId{};
+};
+
+/** Everything one cookie ack publishes. */
+struct CookieAck {
+    /** Tag the requester chose. It refuses a cookie ack whose header names a different tag. */
+    std::uint16_t requesterTag{};
+    /** Responder's exported public key. */
+    std::array<std::byte, kPublicKeySize> publicKey{};
+    /** Security id taken from the init. */
+    SecurityId securityId{};
+};
+
+/**
+ * Reads the message type of one datagram.
+ * @param datagram Received bytes.
+ * @param output Receives the type byte only on success.
+ * @return True when the datagram is long enough to carry a header.
+ */
+[[nodiscard]] bool read_type(std::span<const std::byte> datagram, std::uint8_t& output) noexcept;
+
+/**
+ * Reads the verification tag the sender addressed this packet to.
+ * @param datagram Received bytes.
+ * @param output Receives the tag only on success.
+ * @return True when the datagram is long enough to carry a header.
+ */
+[[nodiscard]] bool read_peer_tag(std::span<const std::byte> datagram,
+                                 std::uint16_t& output) noexcept;
+
+/**
+ * Reads one init.
+ * @param datagram Received bytes.
+ * @param output Receives the init only on success.
+ * @return True when the datagram is an init of the exact length.
+ */
+[[nodiscard]] bool read_init(std::span<const std::byte> datagram, Init& output) noexcept;
+
+/**
+ * Reads the two fields a cookie echo must surrender.
+ * @param datagram Received bytes.
+ * @param output Receives the echoed init ack and the public key only on success.
+ * @return True when the datagram is a cookie echo of the exact length.
+ */
+[[nodiscard]] bool read_cookie_echo(std::span<const std::byte> datagram,
+                                    CookieEcho& output) noexcept;
+
+/**
+ * Builds one init ack.
+ * @param initAck Fields to publish.
+ * @param output Receives the whole init ack.
+ */
+void write_init_ack(const InitAck& initAck, std::array<std::byte, kInitAckSize>& output) noexcept;
+
+/**
+ * Builds one cookie ack.
+ * @param cookieAck Fields to publish.
+ * @param output Receives the whole cookie ack.
+ */
+void write_cookie_ack(const CookieAck& cookieAck,
+                      std::array<std::byte, kCookieAckSize>& output) noexcept;
+
+} // namespace sunrise::middleware::gameplay::dtls

+ 250 - 0
Sunrise/src/middleware/gameplay/dtls/record.cpp

@@ -0,0 +1,250 @@
+#include "record.h"
+
+#include <algorithm>
+
+#include "../../crypto/aes_cbc.h"
+#include "../../crypto/tiger192.h"
+#include "dtls_messages.h"
+
+namespace sunrise::middleware::gameplay::dtls {
+
+namespace {
+
+/** Bits in one byte. */
+constexpr unsigned kByteBits = 8;
+/** Mask of one byte. */
+constexpr std::uint32_t kByteMask = 0xFF;
+/** The addressed tag sits inside the shared header. */
+constexpr std::size_t kTagOffset = 2;
+/** The sequence number closes the shared header. */
+constexpr std::size_t kSequenceOffset = 4;
+/** The authentication tag follows the header. */
+constexpr std::size_t kAuthOffset = kHeaderSize;
+/** The plaintext length follows the authentication tag. */
+constexpr std::size_t kLengthOffset = kAuthOffset + kRecordTagSize;
+/** The ciphertext follows the length. */
+constexpr std::size_t kCipherOffset = kRecordPrefixSize;
+/** The filler the peer writes between the plaintext end and the block boundary. */
+constexpr std::byte kFiller{0x01};
+/** The sequence and the tag are hashed together to make the vector. */
+constexpr std::size_t kVectorSeedSize = 6;
+/** Type value that marks a record. It shares the type field with the handshake messages. */
+constexpr std::byte kRecordType{6};
+/** A constant the sender writes into the header and never reads back. */
+constexpr std::byte kVersion{2};
+
+/**
+ * Writes one unsigned value low byte first.
+ * @param output Field storage.
+ * @param offset First byte of the field.
+ * @param value Host-order value.
+ * @param width Field width in bytes.
+ */
+void write_memory_order(std::span<std::byte> output,
+                        std::size_t offset,
+                        std::uint32_t value,
+                        std::size_t width) noexcept {
+    for (std::size_t index = 0; index < width; ++index) {
+        output[offset + index] = static_cast<std::byte>((value >> (index * kByteBits)) & kByteMask);
+    }
+}
+
+/**
+ * Reads one unsigned value, low byte first.
+ * @param datagram Record bytes.
+ * @param offset First byte of the field.
+ * @param width Field width in bytes.
+ * @return Host-order value.
+ */
+[[nodiscard]] std::uint32_t read_memory_order(std::span<const std::byte> datagram,
+                                              std::size_t offset,
+                                              std::size_t width) noexcept {
+    std::uint32_t value = 0;
+    for (std::size_t index = 0; index < width; ++index) {
+        value |= std::to_integer<std::uint32_t>(datagram[offset + index]) << (index * kByteBits);
+    }
+    return value;
+}
+
+/**
+ * Builds the record's initialization vector.
+ * @param sequence Record sequence number.
+ * @param tag Record addressed tag.
+ * @param output Receives one block.
+ */
+void build_vector(std::uint32_t sequence,
+                  std::uint16_t tag,
+                  std::array<std::byte, crypto::aes::kBlockSize>& output) noexcept {
+    std::array<std::byte, kVectorSeedSize> seed{};
+    write_memory_order(seed, 0, sequence, sizeof(std::uint32_t));
+    write_memory_order(seed, sizeof(std::uint32_t), tag, sizeof(std::uint16_t));
+    crypto::tiger::Digest digest{};
+    crypto::tiger::hash(seed, digest);
+    std::copy_n(digest.begin(), output.size(), output.begin());
+}
+
+/**
+ * Authenticates one record.
+ * The tag covers the header and everything from the length field on, but not the tag bytes.
+ * @param algorithm Digest to key.
+ * @param key Authentication key.
+ * @param datagram Whole record.
+ * @param output Receives the leading tag bytes.
+ * @return True when the digest completed.
+ */
+[[nodiscard]] bool authenticate(crypto::hmac::Algorithm algorithm,
+                                std::span<const std::byte> key,
+                                std::span<const std::byte> datagram,
+                                std::array<std::byte, kRecordTagSize>& output) noexcept {
+    crypto::hmac::Digest digest{};
+    if (!crypto::hmac::authenticate(
+            algorithm, key, datagram.first(kHeaderSize), datagram.subspan(kLengthOffset), digest)) {
+        return false;
+    }
+    std::copy_n(digest.bytes.begin(), output.size(), output.begin());
+    return true;
+}
+
+/**
+ * @param plaintext Plaintext length.
+ * @return The ciphertext length that plaintext length produces.
+ */
+[[nodiscard]] std::size_t padded_length(std::size_t plaintext) noexcept {
+    const std::size_t remainder = plaintext % crypto::aes::kBlockSize;
+    return remainder == 0 ? plaintext : plaintext + (crypto::aes::kBlockSize - remainder);
+}
+
+/**
+ * Checks the declared length against the record it arrived in.
+ * @param datagram Whole record.
+ * @param plaintext Receives the declared plaintext length.
+ * @return True when the record is long enough and its ciphertext is whole blocks.
+ */
+[[nodiscard]] bool shaped(std::span<const std::byte> datagram, std::size_t& plaintext) noexcept {
+    if (datagram.size() <= kRecordPrefixSize) {
+        return false;
+    }
+    plaintext = read_memory_order(datagram, kLengthOffset, sizeof(std::uint16_t));
+    const std::size_t cipher = datagram.size() - kCipherOffset;
+    return plaintext != 0 && padded_length(plaintext) == cipher
+           && cipher % crypto::aes::kBlockSize == 0 && plaintext <= kRecordPayloadCapacity;
+}
+
+/**
+ * Compares two tags without an early exit.
+ * @param left First tag.
+ * @param right Second tag.
+ * @return True when every byte matches.
+ */
+[[nodiscard]] bool same_tag(std::span<const std::byte> left,
+                            const std::array<std::byte, kRecordTagSize>& right) noexcept {
+    std::byte difference{0};
+    for (std::size_t index = 0; index < right.size(); ++index) {
+        difference |= left[index] ^ right[index];
+    }
+    return difference == std::byte{0};
+}
+
+} // namespace
+
+/** Reads the tag one received record is addressed to, without opening it. */
+bool read_record_tag(std::span<const std::byte> datagram, std::uint16_t& tag) noexcept {
+    tag = 0;
+    if (datagram.size() < kHeaderSize) {
+        return false;
+    }
+    tag =
+        static_cast<std::uint16_t>(read_memory_order(datagram, kTagOffset, sizeof(std::uint16_t)));
+    return true;
+}
+
+/** Opens one received record. */
+bool open(const RecordContext& context,
+          std::span<const std::byte> datagram,
+          std::span<std::byte> plaintext,
+          std::size_t& size,
+          std::uint32_t& sequence) noexcept {
+    std::size_t declared = 0;
+    if (!shaped(datagram, declared) || plaintext.size() < padded_length(declared)) {
+        return false;
+    }
+    std::array<std::byte, kRecordTagSize> expected{};
+    if (!authenticate(context.authAlgorithm, context.keys.auth, datagram, expected)
+        || !same_tag(datagram.subspan(kAuthOffset, kRecordTagSize), expected)) {
+        return false;
+    }
+
+    sequence = read_memory_order(datagram, kSequenceOffset, sizeof(std::uint32_t));
+    const auto tag =
+        static_cast<std::uint16_t>(read_memory_order(datagram, kTagOffset, sizeof(std::uint16_t)));
+    std::array<std::byte, crypto::aes::kBlockSize> vector{};
+    build_vector(sequence, tag, vector);
+    if (!crypto::aes::decrypt(
+            context.keys.cypher, vector, datagram.subspan(kCipherOffset), plaintext)) {
+        return false;
+    }
+    size = declared;
+    return true;
+}
+
+/** Builds one record for sending. */
+bool seal(const RecordContext& context,
+          std::uint32_t sequence,
+          std::span<const std::byte> payload,
+          std::span<std::byte> datagram,
+          std::size_t& size) noexcept {
+    const std::size_t cipher = padded_length(payload.size());
+    const std::size_t total = kCipherOffset + cipher;
+    if (payload.empty() || payload.size() > kRecordPayloadCapacity || datagram.size() < total) {
+        return false;
+    }
+    std::fill_n(datagram.begin(), total, std::byte{0});
+    datagram[0] = kRecordType;
+    datagram[1] = kVersion;
+    write_memory_order(datagram, kTagOffset, context.sendTag, sizeof(std::uint16_t));
+    write_memory_order(datagram, kSequenceOffset, sequence, sizeof(std::uint32_t));
+    write_memory_order(
+        datagram, kLengthOffset, static_cast<std::uint32_t>(payload.size()), sizeof(std::uint16_t));
+    std::copy(payload.begin(), payload.end(), datagram.begin() + kCipherOffset);
+    // The peer fills the tail of the last block with this byte and never reads it back.
+    const std::span<std::byte> filler =
+        datagram.subspan(kCipherOffset + payload.size(), cipher - payload.size());
+    std::fill(filler.begin(), filler.end(), kFiller);
+
+    std::array<std::byte, crypto::aes::kBlockSize> vector{};
+    build_vector(sequence, context.sendTag, vector);
+    const std::span<std::byte> region = datagram.subspan(kCipherOffset, cipher);
+    if (!crypto::aes::encrypt(context.keys.cypher, vector, region, region)) {
+        return false;
+    }
+    std::array<std::byte, kRecordTagSize> tag{};
+    if (!authenticate(context.authAlgorithm, context.keys.auth, datagram.first(total), tag)) {
+        return false;
+    }
+    std::copy(tag.begin(), tag.end(), datagram.begin() + kAuthOffset);
+    size = total;
+    return true;
+}
+
+/** Reports which digest authenticates a received record. */
+bool identify_auth(const Keys& keys,
+                   std::span<const std::byte> datagram,
+                   crypto::hmac::Algorithm& output) noexcept {
+    std::size_t declared = 0;
+    if (!shaped(datagram, declared)) {
+        return false;
+    }
+    for (const crypto::hmac::Algorithm candidate : {crypto::hmac::Algorithm::murmur3,
+                                                    crypto::hmac::Algorithm::sha256,
+                                                    crypto::hmac::Algorithm::sha1}) {
+        std::array<std::byte, kRecordTagSize> expected{};
+        if (authenticate(candidate, keys.auth, datagram, expected)
+            && same_tag(datagram.subspan(kAuthOffset, kRecordTagSize), expected)) {
+            output = candidate;
+            return true;
+        }
+    }
+    return false;
+}
+
+} // namespace sunrise::middleware::gameplay::dtls

+ 84 - 0
Sunrise/src/middleware/gameplay/dtls/record.h

@@ -0,0 +1,84 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+#include "../../crypto/hmac.h"
+#include "association_keys.h"
+
+namespace sunrise::middleware::gameplay::dtls {
+
+/** Header, authentication tag and length open every record. */
+inline constexpr std::size_t kRecordPrefixSize = 18;
+/** The authentication tag is the leading bytes of a wider digest. */
+inline constexpr std::size_t kRecordTagSize = 8;
+/** Largest plaintext one record carries. */
+inline constexpr std::size_t kRecordPayloadCapacity = 1400;
+/** Largest record this layer builds. */
+inline constexpr std::size_t kRecordCapacity = kRecordPrefixSize + kRecordPayloadCapacity + 16;
+
+/** Everything one record needs from its association. */
+struct RecordContext {
+    /** Keys the derivation produced. */
+    Keys keys{};
+    /** Digest the authentication tag is keyed with. */
+    crypto::hmac::Algorithm authAlgorithm{crypto::hmac::Algorithm::sha256};
+    /** Tag a record we send carries. Every record names the tag its receiver chose for itself. */
+    std::uint16_t sendTag{};
+};
+
+/**
+ * Reads the tag one received record is addressed to, without opening it.
+ * The tag is the one its receiver chose. It picks the association out of several on one endpoint.
+ * @param datagram Whole received record.
+ * @param tag Receives the addressed tag.
+ * @return True when the datagram is long enough to carry a header.
+ */
+[[nodiscard]] bool read_record_tag(std::span<const std::byte> datagram,
+                                   std::uint16_t& tag) noexcept;
+
+/**
+ * Opens one received record.
+ * @param context Association keys and tag.
+ * @param datagram Whole received record.
+ * @param plaintext Receives the decrypted payload.
+ * @param size Receives the payload length.
+ * @param sequence Receives the record's sequence number.
+ * @return True when the tag verified and the record decrypted.
+ */
+[[nodiscard]] bool open(const RecordContext& context,
+                        std::span<const std::byte> datagram,
+                        std::span<std::byte> plaintext,
+                        std::size_t& size,
+                        std::uint32_t& sequence) noexcept;
+
+/**
+ * Builds one record for sending.
+ * @param context Association keys and tag.
+ * @param sequence Sequence number this record carries.
+ * @param payload Plaintext to seal.
+ * @param datagram Receives the whole record.
+ * @param size Receives the record length.
+ * @return True when the record fit and every stage succeeded.
+ */
+[[nodiscard]] bool seal(const RecordContext& context,
+                        std::uint32_t sequence,
+                        std::span<const std::byte> payload,
+                        std::span<std::byte> datagram,
+                        std::size_t& size) noexcept;
+
+/**
+ * Reports which digest authenticates a received record.
+ * The peer never names its choice on the wire. Identify it once, then hold it for the association.
+ * @param keys Association keys.
+ * @param datagram Whole received record.
+ * @param output Receives the digest only when one of them verifies.
+ * @return True when a candidate verified.
+ */
+[[nodiscard]] bool identify_auth(const Keys& keys,
+                                 std::span<const std::byte> datagram,
+                                 crypto::hmac::Algorithm& output) noexcept;
+
+} // namespace sunrise::middleware::gameplay::dtls

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

@@ -0,0 +1,66 @@
+#include "member_messages.h"
+
+#include "../../encoding/bit_raw.h"
+
+namespace sunrise::middleware::gameplay::group {
+
+namespace {
+
+namespace bits = encoding::bits;
+
+/** Width of the NetAddr method selector, which leads the address codec. */
+constexpr std::uint8_t kMethodWidth = 3;
+/** Address bytes a method of 0 through 5 carries. */
+constexpr std::size_t kShortAddressBytes = 41;
+/** Address bytes a method of 6 or 7 carries. */
+constexpr std::size_t kLongAddressBytes = 85;
+/** Smallest method that selects the long address form. */
+constexpr std::uint64_t kFirstLongMethod = 6;
+/** Width of the reserved field a player-add carries after its session id. */
+constexpr std::uint8_t kReservedWidth = 1;
+/** Width of the player-add sequence. */
+constexpr std::uint8_t kSequenceWidth = 32;
+/** Width of the player-add kind. */
+constexpr std::uint8_t kKindWidth = 2;
+
+} // namespace
+
+/** Reads the session id and address method of a peer-properties message. */
+bool read_peer_properties_header(bits::Reader& reader, PeerPropertiesHeader& output) noexcept {
+    PeerPropertiesHeader candidate{};
+    std::uint64_t method = 0;
+    if (!bits::read_raw_u64(reader, candidate.sessionId) || !reader.read(kMethodWidth, method)) {
+        return false;
+    }
+    const std::size_t addressBytes =
+        method >= kFirstLongMethod ? kLongAddressBytes : kShortAddressBytes;
+    if (!bits::skip_raw(reader, addressBytes)) {
+        return false;
+    }
+    candidate.addressMethod = static_cast<std::uint8_t>(method);
+    output = candidate;
+    return true;
+}
+
+/** Reads the identity fields of a player-add message. */
+bool read_player_add(bits::Reader& reader, PlayerAddRequest& output) noexcept {
+    PlayerAddRequest 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)
+        || !bits::read_raw_u64(reader, candidate.playerId) || !reader.read(kSequenceWidth, sequence)
+        || !reader.read(kKindWidth, kind)) {
+        return false;
+    }
+    // The consumer refuses a set reserved bit.
+    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

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

@@ -0,0 +1,58 @@
+#pragma once
+
+#include <cstdint>
+
+#include "../../encoding/bit_reader.h"
+
+namespace sunrise::middleware::gameplay::group {
+
+/** Registry id a member uses to publish its own peer properties. */
+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;
+/** 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;
+
+/**
+ * Leading fields of a peer-properties message.
+ * The 304-byte property block after the address is not decoded.
+ */
+struct PeerPropertiesHeader {
+    std::uint64_t sessionId{};
+    /** NetAddr method. 0 through 5 carry 41 address bytes; 6 and 7 carry 85. */
+    std::uint8_t addressMethod{};
+};
+
+/**
+ * Leading fields of a player-add message.
+ * The 232-byte player block and its 20-byte tail after them are not decoded.
+ */
+struct PlayerAddRequest {
+    std::uint64_t sessionId{};
+    std::uint64_t playerId{};
+    std::uint32_t sequence{};
+    /** Player kind, 0 through 3. */
+    std::uint8_t kind{};
+};
+
+/**
+ * Reads the session id and address method of a peer-properties message.
+ * @param reader Reader positioned at the body.
+ * @param output Receives the fields.
+ * @return True when both were present.
+ */
+[[nodiscard]] bool read_peer_properties_header(encoding::bits::Reader& reader,
+                                               PeerPropertiesHeader& output) noexcept;
+
+/**
+ * Reads the identity fields of a player-add 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_add(encoding::bits::Reader& reader,
+                                   PlayerAddRequest& output) noexcept;
+
+} // namespace sunrise::middleware::gameplay::group

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

@@ -0,0 +1,161 @@
+#include "parameter_messages.h"
+
+#include <array>
+#include <cstddef>
+
+#include "../../encoding/bit_raw.h"
+
+namespace sunrise::middleware::gameplay::group {
+
+namespace {
+
+namespace bits = encoding::bits;
+
+/** The mode flag is one bit. */
+constexpr std::uint8_t kFlagWidth = 1;
+/** Width of a tag-reflection root bit. Clear skips the whole field walk. */
+constexpr std::uint8_t kRootBitClear = 1;
+/** Width of the `activity-host` id, written most-significant-bit first. */
+constexpr std::uint8_t kHostIdWidth = 64;
+/** Width of the `activity-host` address. */
+constexpr std::uint8_t kAddressWidth = 32;
+/** Width of the `activity-host` port. */
+constexpr std::uint8_t kPortWidth = 16;
+/** Bits in one byte. */
+constexpr unsigned kByteBits = 8;
+/** Mask of one byte. */
+constexpr std::uint32_t kByteMask = 0xFF;
+/** Parameters one presence group covers. The encoder divides 25 by this and rounds up. */
+constexpr std::uint8_t kGroupSize = 16;
+/** Groups the 25 parameters fall into, and the width of the presence field that names them. */
+constexpr std::uint8_t kGroupCount = 2;
+/** Only the low 25 bits of either mask name a parameter. */
+constexpr std::uint64_t kParameterMaskBits = 0x1FFFFFF;
+
+/**
+ * Reduces one parameter mask to the presence bits the encoder writes ahead of it.
+ * @param mask Parameter mask.
+ * @return Bit per group, set when that group holds any named parameter.
+ */
+[[nodiscard]] std::uint64_t group_presence(std::uint64_t mask) noexcept {
+    std::uint64_t groups = 0;
+    for (std::uint8_t group = 0; group < kGroupCount; ++group) {
+        const std::uint64_t span = (mask >> (group * kGroupSize)) & ((1ULL << kGroupSize) - 1);
+        if ((span & kParameterMaskBits) != 0) {
+            groups |= 1ULL << group;
+        }
+    }
+    return groups;
+}
+
+/**
+ * Writes the `activity-host` body.
+ * @param writer Open writer.
+ * @param body Field values.
+ * @return True when every field fit.
+ */
+[[nodiscard]] bool write_activity_host(bits::Writer& writer,
+                                       const ActivityHostParameter& body) noexcept {
+    std::array<std::byte, sizeof(std::uint32_t)> mask{};
+    for (std::size_t index = 0; index < mask.size(); ++index) {
+        const unsigned shift = static_cast<unsigned>(index) * kByteBits;
+        mask[index] = static_cast<std::byte>((body.memberMask >> shift) & kByteMask);
+    }
+    return bits::write_raw_u64(writer, body.selectionId) && writer.write(body.hostId, kHostIdWidth)
+           && bits::write_raw(writer, mask) && writer.write(body.address, kAddressWidth)
+           && writer.write(body.port, kPortWidth);
+}
+
+/**
+ * Writes one carried parameter body.
+ * A clear root bit is a complete body for both tag-reflection parameters. It skips the field
+ * walk and leaves the reader's own values in place.
+ * @param writer Open writer.
+ * @param parameter Registry index.
+ * @param update Update being written, for the parameters that carry field values.
+ * @return True when the parameter has an encoder here and its body fit.
+ */
+[[nodiscard]] bool write_parameter_body(bits::Writer& writer,
+                                        std::uint8_t parameter,
+                                        const ParameterUpdate& update) noexcept {
+    if (parameter == static_cast<std::uint8_t>(Parameter::publicSessionReservations)
+        || parameter == static_cast<std::uint8_t>(Parameter::currentActivity)) {
+        return writer.write(0U, kRootBitClear);
+    }
+    if (parameter == static_cast<std::uint8_t>(Parameter::activityHost)) {
+        return write_activity_host(writer, update.activityHost);
+    }
+    return false;
+}
+
+/**
+ * Writes the per-parameter bits of one mask, in ascending index order.
+ * Only parameters in a present group get a bit.
+ * @param writer Open writer.
+ * @param mask Parameter mask, already reduced to its meaningful bits.
+ * @param groups Presence bits for that mask.
+ * @param carriesBodies True for the value mask, whose named parameters carry a body.
+ * @param update Update being written, for the parameters that carry field values.
+ * @return True when every bit and body fit.
+ */
+[[nodiscard]] bool write_mask_bits(bits::Writer& writer,
+                                   std::uint64_t mask,
+                                   std::uint64_t groups,
+                                   bool carriesBodies,
+                                   const ParameterUpdate& update) noexcept {
+    for (std::uint8_t parameter = 0; parameter < kParameterCount; ++parameter) {
+        if ((groups & (1ULL << (parameter / kGroupSize))) == 0) {
+            continue;
+        }
+        const bool named = ((mask >> parameter) & 1ULL) != 0;
+        if (!writer.write(named ? 1U : 0U, kFlagWidth)) {
+            return false;
+        }
+        // A body must follow its own bit. The consumer reads them interleaved, not in two runs.
+        if (named && carriesBodies && !write_parameter_body(writer, parameter, update)) {
+            return false;
+        }
+    }
+    return true;
+}
+
+} // namespace
+
+/** Reads the header of a parameter request. */
+bool read_parameter_request(bits::Reader& reader, ParameterRequestHeader& output) noexcept {
+    ParameterRequestHeader candidate{};
+    std::uint64_t mode = 0;
+    if (!bits::read_raw_u64(reader, candidate.sessionId) || !reader.read(kFlagWidth, mode)
+        || !bits::read_raw_u64(reader, candidate.requestedMask)) {
+        return false;
+    }
+    candidate.modeFlag = mode != 0;
+    output = candidate;
+    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;
+    const std::uint64_t carried = body.carriedMask & kParameterMaskBits;
+    if ((carried & ~kEncodableParameters) != 0) {
+        return false;
+    }
+    // Field order differs from the request. The flag leads here, and the session id follows it.
+    return writer.write(body.resetFlag ? 1U : 0U, kFlagWidth)
+           && bits::write_raw_u64(writer, body.sessionId)
+           && writer.write(group_presence(released), kGroupCount)
+           && writer.write(group_presence(carried), kGroupCount)
+           && write_mask_bits(writer, released, group_presence(released), false, body)
+           && write_mask_bits(writer, carried, group_presence(carried), true, body);
+}
+
+/** Reports whether one parameter was requested. */
+bool requests(std::uint64_t mask, std::uint8_t parameter) noexcept {
+    if (parameter >= kParameterCount) {
+        return false;
+    }
+    return ((mask >> parameter) & 1U) != 0;
+}
+
+} // namespace sunrise::middleware::gameplay::group

+ 101 - 0
Sunrise/src/middleware/gameplay/group/parameter_messages.h

@@ -0,0 +1,101 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+
+#include "../../encoding/bit_reader.h"
+#include "../../encoding/bit_writer.h"
+#include "parameter_registry.h"
+
+namespace sunrise::middleware::gameplay::group {
+
+/** Registry id of a parameter update, sent by the authority. */
+inline constexpr std::uint8_t kParameterUpdateId = 38;
+/** Registry id of a parameter request, sent by a peer. */
+inline constexpr std::uint8_t kParameterRequestId = 39;
+/** Declared decoded size of a parameter update. */
+inline constexpr std::uint32_t kParameterUpdateSize = 44064;
+/** Declared decoded size of a parameter request. */
+inline constexpr std::uint32_t kParameterRequestSize = 44056;
+/** The registry holds 25 parameters, so only the low 25 bitmap bits are meaningful. */
+inline constexpr std::uint8_t kParameterCount = 25;
+
+/**
+ * Header of a parameter request.
+ * The bodies after it are not decoded. Each parameter has its own codec of unknown width, so
+ * the reader cannot walk past this point.
+ */
+struct ParameterRequestHeader {
+    std::uint64_t sessionId{};
+    /** Low 25 bits name the requested parameters. */
+    std::uint64_t requestedMask{};
+    bool modeFlag{};
+};
+
+/**
+ * Reads the header of a parameter request.
+ * @param reader Reader positioned at the body.
+ * @param output Receives the header fields.
+ * @return True when the session id, mode flag, and bitmap were all present.
+ */
+[[nodiscard]] bool read_parameter_request(encoding::bits::Reader& reader,
+                                          ParameterRequestHeader& output) noexcept;
+
+/**
+ * @param mask Requested bitmap from one request header.
+ * @param parameter Registry index, 0 through 24.
+ * @return True when the peer asked for that parameter.
+ */
+[[nodiscard]] bool requests(std::uint64_t mask, std::uint8_t parameter) noexcept;
+
+/** Parameters this host can encode a body for, as a mask over the registry indices. */
+inline constexpr std::uint64_t kEncodableParameters =
+    (std::uint64_t{1} << static_cast<std::uint8_t>(Parameter::activityHost))
+    | (std::uint64_t{1} << static_cast<std::uint8_t>(Parameter::currentActivity))
+    | (std::uint64_t{1} << static_cast<std::uint8_t>(Parameter::publicSessionReservations));
+
+/**
+ * Body of registry parameter 3 `activity-host`.
+ * The peer builds no activity client while this parameter holds no value.
+ */
+struct ActivityHostParameter {
+    /** Compared against the peer's own replicated activity selection. This host never sets it. */
+    std::uint64_t selectionId{};
+    /** Host identity. The peer refuses the parameter while this reads zero. */
+    std::uint64_t hostId{};
+    /** Bit per member index. The peer needs the bit for its own member set. */
+    std::uint32_t memberMask{};
+    /** Host address in host order. The peer builds a plain-UDP address object from it. */
+    std::uint32_t address{};
+    /** Host port in host order. */
+    std::uint16_t port{};
+};
+
+/**
+ * Body of a parameter update.
+ * A joining peer needs one applied to finish its join, whatever the update names.
+ */
+struct ParameterUpdate {
+    std::uint64_t sessionId{};
+    /** Set makes the peer rebuild its parameter object before applying the rest. */
+    bool resetFlag{};
+    /** Low 25 bits name parameters the peer drops. An empty slot makes that a no-op. */
+    std::uint64_t releasedMask{};
+    /** Low 25 bits name parameters the body carries, each one encoded after its own presence
+     *  bit. Only `kEncodableParameters` may be named. */
+    std::uint64_t carriedMask{};
+    /** Read only when `carriedMask` names `activityHost`. */
+    ActivityHostParameter activityHost{};
+};
+
+/**
+ * Writes a parameter update.
+ * @param writer Writer positioned at the body.
+ * @param body Update to publish.
+ * @return True when it fit. False when `carriedMask` names a parameter outside
+ *         `kEncodableParameters`, because the rest have no encoder here.
+ */
+[[nodiscard]] bool write_parameter_update(encoding::bits::Writer& writer,
+                                          const ParameterUpdate& body) noexcept;
+
+} // namespace sunrise::middleware::gameplay::group

+ 84 - 0
Sunrise/src/middleware/gameplay/group/parameter_registry.cpp

@@ -0,0 +1,84 @@
+#include "parameter_registry.h"
+
+#include <array>
+#include <cstddef>
+#include <cstring>
+
+namespace sunrise::middleware::gameplay::group {
+
+namespace {
+
+/** Registry names in index order. They are the registry's own strings. */
+constexpr std::array<const char*, 25> kNames{
+    "world-controller-goal-data",
+    "active-join-controls",
+    "atomic-cascade-join-data",
+    "activity-host",
+    "jip-gate",
+    "activity-selection",
+    "activity-selection-responses",
+    "current-activity",
+    "previous-activity",
+    "user-join-controls",
+    "desired-join-controls",
+    "language",
+    "requested-remote-join-data",
+    "remote-join-data",
+    "remote-join-result",
+    "host-selected",
+    "matchmaking-messaging",
+    "matchmaking-abort-requested",
+    "session-disband-reason",
+    "matchmaking-progress",
+    "initial-slice-set-status",
+    "public-session-reservations",
+    "matchmaking-data",
+    "matchmaking-peer-data",
+    "network-quality",
+};
+
+/**
+ * Appends one string, truncating at the buffer end.
+ * @param output Destination buffer.
+ * @param capacity Bytes available, including the terminator.
+ * @param used Running length, advanced by what was written.
+ * @param text Source string.
+ */
+void append(char* output, std::size_t capacity, std::size_t& used, const char* text) noexcept {
+    while (*text != '\0' && used + 1 < capacity) {
+        output[used] = *text;
+        ++used;
+        ++text;
+    }
+}
+
+} // namespace
+
+/** Reports one registry name. */
+const char* parameter_name(std::uint8_t index) noexcept {
+    return index < kNames.size() ? kNames[index] : "unknown";
+}
+
+/** Names the parameters one mask selects. */
+const char* parameter_names(std::uint64_t mask, char* output, std::size_t capacity) noexcept {
+    if (output == nullptr || capacity == 0) {
+        return "";
+    }
+    std::size_t used = 0;
+    for (std::size_t index = 0; index < kNames.size(); ++index) {
+        if (((mask >> index) & 1U) == 0) {
+            continue;
+        }
+        if (used != 0) {
+            append(output, capacity, used, ",");
+        }
+        append(output, capacity, used, kNames[index]);
+    }
+    if (used == 0) {
+        append(output, capacity, used, "none");
+    }
+    output[used] = '\0';
+    return output;
+}
+
+} // namespace sunrise::middleware::gameplay::group

+ 52 - 0
Sunrise/src/middleware/gameplay/group/parameter_registry.h

@@ -0,0 +1,52 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+
+namespace sunrise::middleware::gameplay::group {
+
+/** Registry index of every group-session parameter. The names are the binary's own strings. */
+enum class Parameter : std::uint8_t {
+    worldControllerGoalData = 0,
+    activeJoinControls = 1,
+    atomicCascadeJoinData = 2,
+    activityHost = 3,
+    jipGate = 4,
+    activitySelection = 5,
+    activitySelectionResponses = 6,
+    currentActivity = 7,
+    previousActivity = 8,
+    userJoinControls = 9,
+    desiredJoinControls = 10,
+    language = 11,
+    requestedRemoteJoinData = 12,
+    remoteJoinData = 13,
+    remoteJoinResult = 14,
+    hostSelected = 15,
+    matchmakingMessaging = 16,
+    matchmakingAbortRequested = 17,
+    sessionDisbandReason = 18,
+    matchmakingProgress = 19,
+    initialSliceSetStatus = 20,
+    publicSessionReservations = 21,
+    matchmakingData = 22,
+    matchmakingPeerData = 23,
+    networkQuality = 24,
+};
+
+/**
+ * @param index Registry index, 0 through 24.
+ * @return The registry's own name, or "unknown" outside the range.
+ */
+[[nodiscard]] const char* parameter_name(std::uint8_t index) noexcept;
+
+/**
+ * Names the parameters one mask selects, as a comma-separated list.
+ * @param mask Parameter mask; only its low 25 bits are read.
+ * @param output Destination buffer.
+ * @param capacity Bytes available, including the terminator.
+ * @return `output`, always terminated. An empty mask gives "none".
+ */
+const char* parameter_names(std::uint64_t mask, char* output, std::size_t capacity) noexcept;
+
+} // namespace sunrise::middleware::gameplay::group

+ 369 - 0
Sunrise/src/middleware/gameplay/group/session_messages.cpp

@@ -0,0 +1,369 @@
+#include "session_messages.h"
+
+#include <array>
+
+#include "../../encoding/bit_raw.h"
+#include "../../protobuf/codec.h"
+#include "session_state.h"
+
+namespace sunrise::middleware::gameplay::group {
+
+namespace {
+
+namespace bits = encoding::bits;
+
+/** The protocol version is a 16-bit value field. */
+constexpr std::uint8_t kProtocolWidth = 16;
+/** The join sequence is a 32-bit value field. */
+constexpr std::uint8_t kSequenceWidth = 32;
+/** Single-bit flags. */
+constexpr std::uint8_t kFlagWidth = 1;
+/** The boot kind is three bits. */
+constexpr std::uint8_t kBootKindWidth = 3;
+/** The boot reason is five bits. */
+constexpr std::uint8_t kBootReasonWidth = 5;
+/** Time samples are 64-bit value fields. */
+constexpr std::uint8_t kSampleWidth = 64;
+
+// --- Membership update, message id 30 -------------------------------------------------------
+
+/** Protobuf field numbers of the membership root. */
+constexpr std::uint32_t kRootRevision = 1;
+/** See kRootRevision. */
+constexpr std::uint32_t kRootHostIndex = 2;
+/** See kRootRevision. */
+constexpr std::uint32_t kRootSuccession = 3;
+/** See kRootRevision. */
+constexpr std::uint32_t kRootMemberCount = 4;
+/** See kRootRevision. */
+constexpr std::uint32_t kRootMemberMask = 5;
+/** See kRootRevision. */
+constexpr std::uint32_t kRootMember = 6;
+
+/** Protobuf field numbers of one member. Field 9 is not published. */
+constexpr std::uint32_t kMemberAddress = 1;
+/** See kMemberAddress. */
+constexpr std::uint32_t kMemberMachineId = 2;
+/** See kMemberAddress. */
+constexpr std::uint32_t kMemberJoinId = 3;
+/** See kMemberAddress. */
+constexpr std::uint32_t kMemberIdB = 8;
+/** See kMemberAddress. */
+constexpr std::uint32_t kMemberPlayerSlot = 10;
+/** See kMemberAddress. */
+constexpr std::uint32_t kMemberFlagA = 11;
+/** See kMemberAddress. */
+constexpr std::uint32_t kMemberFlagB = 12;
+
+/** Member field 8 has no recovered meaning. Zero is the value an empty id decodes to. */
+constexpr std::uint64_t kMemberIdEmpty = 0;
+/** Member fields 11 and 12 have no recovered meaning. Zero is their cleared value. */
+constexpr std::uint64_t kMemberFlagClear = 0;
+
+/** The protobuf body is length-prefixed with thirteen bits. */
+constexpr std::uint8_t kProtobufLengthWidth = 13;
+/** Both revision words are 32 bits. */
+constexpr std::uint8_t kRevisionWidth = 32;
+/** Both delta counts are six bits. */
+constexpr std::uint8_t kDeltaCountWidth = 6;
+/** A peer-delta index is six bits. */
+constexpr std::uint8_t kDeltaIndexWidth = 6;
+/** A member state is four bits. */
+constexpr std::uint8_t kMemberStateWidth = 4;
+/** The third connection value. */
+constexpr std::uint8_t kConnectionValueWidth = 8;
+/** The join compatibility word, and the trailing session-state hash. */
+constexpr std::uint8_t kWordWidth = 32;
+/** The join timestamp. It is a value field, so it goes out most significant byte first. */
+constexpr std::uint8_t kJoinTimestampWidth = 64;
+
+/** A clear flag ahead of the revision pair publishes it. Its absence decodes as -1. */
+constexpr std::uint64_t kRevisionPairPresent = 0;
+/** Base revision 0 selects a complete snapshot. Any other value is a delta against that revision.
+ */
+constexpr std::uint32_t kCompleteSnapshotBase = 0;
+/** The word after the base revision is never read by the consumer. */
+constexpr std::uint32_t kUnreadWord = 0;
+/** A set mode bit means a whole peer-delta entry follows the index. */
+constexpr std::uint64_t kDeltaEntryFull = 1;
+/** A player-delta index is five bits, one narrower than a peer-delta index. */
+constexpr std::uint8_t kPlayerIndexWidth = 5;
+/** The member index a player row names. */
+constexpr std::uint8_t kPlayerMemberWidth = 6;
+/** The member's own player index. The decoder refuses any value but zero, so a member publishes
+ *  at most one player this way. */
+constexpr std::uint8_t kPlayerOwnedIndexWidth = 1;
+/** The session player-add counter, which the consumer keeps modulo 2^20. */
+constexpr std::uint8_t kPlayerSequenceWidth = 20;
+/** Value the decoder requires of the member's own player index. */
+constexpr std::uint64_t kPlayerOwnedIndexZero = 0;
+/** A clear flag ends a player row after its identity group. The profile block it would gate has
+ *  no writer here, so no row carries one. */
+constexpr std::uint64_t kPlayerProfileAbsent = 0;
+/** This host publishes no 264-byte identity block and neither trailing delta-entry flag. */
+constexpr std::uint64_t kEntryFieldAbsent = 0;
+/** The four tail groups are all omitted, which leaves the consumer's own values alone. */
+constexpr std::uint64_t kTailGroupAbsent = 0;
+/** Tail groups omitted, one presence bit each. The encoder writes four, not five. */
+constexpr std::size_t kTailGroupCount = 4;
+/** Largest protobuf body the message codec accepts. */
+constexpr std::size_t kProtobufCapacity = 5972;
+/** One encoded member submessage cannot exceed this. */
+constexpr std::size_t kMemberBytes = 128;
+/** Machine identities are published as eight bytes in memory order. */
+constexpr std::size_t kMachineIdBytes = 8;
+/** Bits in one byte. */
+constexpr unsigned kByteBits = 8;
+/** Mask of one byte. */
+constexpr std::uint64_t kByteMask = 0xFF;
+
+/**
+ * Appends one member as a length-delimited submessage.
+ * @param writer Open protobuf writer for the root message.
+ * @param member Member to publish.
+ * @return True when the whole submessage fit.
+ */
+[[nodiscard]] bool write_member(protobuf::Writer& writer, const MembershipMember& member) noexcept {
+    std::array<std::byte, kMachineIdBytes> machineId{};
+    for (std::size_t index = 0; index < machineId.size(); ++index) {
+        machineId[index] =
+            static_cast<std::byte>((member.machineId >> (index * kByteBits)) & kByteMask);
+    }
+
+    std::array<std::byte, kMemberBytes> storage{};
+    protobuf::Writer body(storage);
+    if (!body.write_length_delimited(kMemberAddress, member.address)
+        || !body.write_length_delimited(kMemberMachineId, machineId)
+        || !body.write_varint(kMemberJoinId, member.joinId)
+        || !body.write_varint(kMemberIdB, kMemberIdEmpty)) {
+        return false;
+    }
+    if (member.ownsPlayerSlot && !body.write_varint(kMemberPlayerSlot, member.playerSlot)) {
+        return false;
+    }
+    if (!body.write_varint(kMemberFlagA, kMemberFlagClear)
+        || !body.write_varint(kMemberFlagB, kMemberFlagClear)) {
+        return false;
+    }
+    return writer.write_length_delimited(kRootMember, {storage.data(), body.size()});
+}
+
+/**
+ * Encodes the membership protobuf body.
+ * @param body Snapshot to publish.
+ * @param storage Caller-owned protobuf storage.
+ * @param size Receives the encoded byte count.
+ * @return True when every field fit.
+ */
+[[nodiscard]] bool write_membership_protobuf(const MembershipUpdate& body,
+                                             std::span<std::byte> storage,
+                                             std::size_t& size) noexcept {
+    const std::uint64_t count = static_cast<std::uint64_t>(body.members.size());
+    // Members occupy indices 0 upward, so the occupied-slot mask follows from the count.
+    const std::uint64_t mask = (std::uint64_t{1} << count) - 1;
+    protobuf::Writer writer(storage);
+    if (!writer.write_varint(kRootRevision, body.revision)
+        || !writer.write_varint(kRootHostIndex, body.hostMemberIndex)
+        || !writer.write_varint(kRootSuccession, body.successionIndex)
+        || !writer.write_varint(kRootMemberCount, count)
+        || !writer.write_varint(kRootMemberMask, mask)) {
+        return false;
+    }
+    for (const MembershipMember& member : body.members) {
+        if (!write_member(writer, member)) {
+            return false;
+        }
+    }
+    size = writer.size();
+    return true;
+}
+
+/**
+ * Writes one peer-delta entry.
+ * @param writer Open writer.
+ * @param index Member index the entry names.
+ * @param member Member whose state the entry publishes.
+ * @return True when every field fit.
+ */
+[[nodiscard]] bool
+write_peer_delta(bits::Writer& writer, std::size_t index, const MembershipMember& member) noexcept {
+    if (!writer.write(index, kDeltaIndexWidth) || !writer.write(kDeltaEntryFull, kFlagWidth)
+        || !writer.write(static_cast<std::uint64_t>(member.state), kMemberStateWidth)
+        || !writer.write(member.connectionPresent ? 1U : 0U, kFlagWidth)) {
+        return false;
+    }
+    if (member.connectionPresent
+        && (!writer.write(member.joinCompatibility, kWordWidth)
+            || !writer.write(member.joinTimestamp, kJoinTimestampWidth)
+            || !writer.write(member.connectionValue, kConnectionValueWidth))) {
+        return false;
+    }
+    return writer.write(kEntryFieldAbsent, kFlagWidth)
+           && writer.write(kEntryFieldAbsent, kFlagWidth)
+           && writer.write(kEntryFieldAbsent, kFlagWidth);
+}
+
+/**
+ * Writes one player-delta entry carrying an identity and no profile block.
+ * @param writer Open writer.
+ * @param player Player row to publish.
+ * @return True when every field fit.
+ */
+[[nodiscard]] bool write_player_delta(bits::Writer& writer,
+                                      const MembershipPlayer& player) noexcept {
+    return writer.write(player.slot, kPlayerIndexWidth) && writer.write(kDeltaEntryFull, kFlagWidth)
+           && writer.write(1U, kFlagWidth) && bits::write_raw_u64(writer, player.playerId)
+           && writer.write(player.memberIndex, kPlayerMemberWidth)
+           && writer.write(kPlayerOwnedIndexZero, kPlayerOwnedIndexWidth)
+           && writer.write(player.addSequence, kPlayerSequenceWidth)
+           && writer.write(player.flag ? 1U : 0U, kFlagWidth)
+           && writer.write(kPlayerProfileAbsent, kFlagWidth);
+}
+
+} // namespace
+
+/** Writes a peer-connect body. */
+bool write_peer_connect(bits::Writer& writer, const PeerConnect& body) noexcept {
+    return writer.write(body.protocolVersion, kProtocolWidth)
+           && bits::write_raw_u64(writer, body.machineId)
+           && bits::write_raw_u64(writer, body.sessionId);
+}
+
+/** Writes a join-complete body. */
+bool write_join_complete(bits::Writer& writer, const JoinComplete& body) noexcept {
+    return bits::write_raw_u64(writer, body.sessionId)
+           && bits::write_raw_u64(writer, body.machineId)
+           && writer.write(body.joinSequence, kSequenceWidth);
+}
+
+/** Reads a join-complete body. */
+bool read_join_complete(bits::Reader& reader, JoinComplete& output) noexcept {
+    JoinComplete candidate{};
+    std::uint64_t sequence = 0;
+    if (!bits::read_raw_u64(reader, candidate.sessionId)
+        || !bits::read_raw_u64(reader, candidate.machineId)
+        || !reader.read(kSequenceWidth, sequence)) {
+        return false;
+    }
+    candidate.joinSequence = static_cast<std::uint32_t>(sequence);
+    output = candidate;
+    return true;
+}
+
+/** Reads a join-abort body. */
+bool read_join_abort(bits::Reader& reader, SessionNotice& output) noexcept {
+    std::uint64_t flag = 0;
+    SessionNotice candidate{};
+    if (!bits::read_raw_u64(reader, candidate.sessionId)
+        || !bits::read_raw_u64(reader, candidate.machineId) || !reader.read(kFlagWidth, flag)) {
+        return false;
+    }
+    candidate.flag = flag != 0;
+    output = candidate;
+    return true;
+}
+
+/** Reads a session-identity-only body. */
+bool read_session_only(bits::Reader& reader, std::uint64_t& output) noexcept {
+    return bits::read_raw_u64(reader, output);
+}
+
+/** Writes a session-identity-only body. */
+bool write_session_only(bits::Writer& writer, std::uint64_t sessionId) noexcept {
+    return bits::write_raw_u64(writer, sessionId);
+}
+
+/** Writes a session-disband body. */
+bool write_session_disband(bits::Writer& writer, const SessionNotice& body) noexcept {
+    return bits::write_raw_u64(writer, body.sessionId)
+           && bits::write_raw_u64(writer, body.machineId)
+           && writer.write(body.flag ? 1U : 0U, kFlagWidth);
+}
+
+/** Writes a session-boot body. */
+bool write_session_boot(bits::Writer& writer, const SessionBoot& body) noexcept {
+    return bits::write_raw_u64(writer, body.sessionId) && writer.write(body.kind, kBootKindWidth)
+           && writer.write(body.reason, kBootReasonWidth)
+           && bits::write_raw_u64(writer, body.machineId);
+}
+
+/** Reads a time-synchronize body. */
+bool read_time_synchronize(bits::Reader& reader, TimeSynchronize& output) noexcept {
+    std::uint64_t variant = 0;
+    TimeSynchronize candidate{};
+    if (!bits::read_raw_u64(reader, candidate.sessionId) || !reader.read(kFlagWidth, variant)
+        || !reader.read(kSampleWidth, candidate.sampleA)) {
+        return false;
+    }
+    candidate.threeSample = variant != 0;
+    if (candidate.threeSample
+        && (!reader.read(kSampleWidth, candidate.sampleB)
+            || !reader.read(kSampleWidth, candidate.sampleC))) {
+        return false;
+    }
+    output = candidate;
+    return true;
+}
+
+/** Writes a time-synchronize body. */
+bool write_time_synchronize(bits::Writer& writer, const TimeSynchronize& body) noexcept {
+    if (!bits::write_raw_u64(writer, body.sessionId)
+        || !writer.write(body.threeSample ? 1U : 0U, kFlagWidth)
+        || !writer.write(body.sampleA, kSampleWidth)) {
+        return false;
+    }
+    if (!body.threeSample) {
+        return true;
+    }
+    return writer.write(body.sampleB, kSampleWidth) && writer.write(body.sampleC, kSampleWidth);
+}
+
+/** Writes a complete-snapshot membership update. */
+bool write_membership_update(bits::Writer& writer, const MembershipUpdate& body) noexcept {
+    // The consumer refuses the message unless the base revision is below the message revision,
+    // and a complete snapshot always publishes base revision 0.
+    if (body.revision == 0 || body.members.size() > kMemberCapacity
+        || body.players.size() > kPlayerCapacity) {
+        return false;
+    }
+    for (const MembershipPlayer& player : body.players) {
+        if (player.slot >= kPlayerCapacity || player.memberIndex >= kMemberCapacity) {
+            return false;
+        }
+    }
+    std::array<std::byte, kProtobufCapacity> protobufStorage{};
+    std::size_t protobufSize = 0;
+    if (!write_membership_protobuf(body, protobufStorage, protobufSize)) {
+        return false;
+    }
+    if (!bits::write_raw_u64(writer, body.hostMachineId)
+        || !writer.write(protobufSize, kProtobufLengthWidth)
+        || !bits::write_raw(writer, {protobufStorage.data(), protobufSize})
+        || !writer.write(kRevisionPairPresent, kFlagWidth)
+        || !writer.write(kCompleteSnapshotBase, kRevisionWidth)
+        || !writer.write(kUnreadWord, kRevisionWidth)
+        || !writer.write(body.members.size(), kDeltaCountWidth)
+        || !writer.write(body.players.size(), kDeltaCountWidth)) {
+        return false;
+    }
+    for (std::size_t index = 0; index < body.members.size(); ++index) {
+        if (!write_peer_delta(writer, index, body.members[index])) {
+            return false;
+        }
+    }
+    for (const MembershipPlayer& player : body.players) {
+        if (!write_player_delta(writer, player)) {
+            return false;
+        }
+    }
+    for (std::size_t group = 0; group < kTailGroupCount; ++group) {
+        if (!writer.write(kTailGroupAbsent, kFlagWidth)) {
+            return false;
+        }
+    }
+    // The consumer hashes its own state after applying and compares. The replica layout must
+    // stay in step with it.
+    return writer.write(session_state_hash(body), kWordWidth);
+}
+
+} // namespace sunrise::middleware::gameplay::group

+ 217 - 0
Sunrise/src/middleware/gameplay/group/session_messages.h

@@ -0,0 +1,217 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+#include "../../encoding/bit_reader.h"
+#include "../../encoding/bit_writer.h"
+#include "../descriptor/join_descriptor.h"
+
+namespace sunrise::middleware::gameplay::group {
+
+/** Registry ids of the group-session messages this host implements. */
+enum class SessionMessageId : std::uint8_t {
+    peerConnect = 11,
+    membershipUpdate = 30,
+    joinComplete = 12,
+    joinAbort = 13,
+    leaveSession = 15,
+    leaveAcknowledge = 16,
+    sessionDisband = 17,
+    sessionBoot = 18,
+    peerEstablish = 26,
+    timeSynchronize = 29,
+};
+
+/** Declared decoded sizes the registry holds. A message must carry its own constant. */
+inline constexpr std::uint32_t kPeerConnectSize = 24;
+/** See kPeerConnectSize. */
+inline constexpr std::uint32_t kJoinCompleteSize = 24;
+/** See kPeerConnectSize. */
+inline constexpr std::uint32_t kJoinAbortSize = 24;
+/** See kPeerConnectSize. */
+inline constexpr std::uint32_t kLeaveSessionSize = 8;
+/** See kPeerConnectSize. */
+inline constexpr std::uint32_t kLeaveAcknowledgeSize = 8;
+/** See kPeerConnectSize. */
+inline constexpr std::uint32_t kSessionDisbandSize = 24;
+/** See kPeerConnectSize. */
+inline constexpr std::uint32_t kSessionBootSize = 24;
+/** See kPeerConnectSize. */
+inline constexpr std::uint32_t kPeerEstablishSize = 8;
+/** See kPeerConnectSize. */
+inline constexpr std::uint32_t kTimeSynchronizeSize = 48;
+/** See kPeerConnectSize. */
+inline constexpr std::uint32_t kMembershipUpdateSize = 31104;
+
+/** Member slots one session holds. It is both the schema array bound and the delta index range. */
+inline constexpr std::size_t kMemberCapacity = 32;
+
+/** Body of a peer-connect fan-out. */
+struct PeerConnect {
+    std::uint16_t protocolVersion{};
+    std::uint64_t machineId{};
+    std::uint64_t sessionId{};
+};
+
+/** Body of a join-complete. */
+struct JoinComplete {
+    std::uint64_t sessionId{};
+    std::uint64_t machineId{};
+    std::uint32_t joinSequence{};
+};
+
+/** Body shared by join-abort and session-disband: two identities and one flag. */
+struct SessionNotice {
+    std::uint64_t sessionId{};
+    std::uint64_t machineId{};
+    bool flag{};
+};
+
+/** Body of a session-boot. */
+struct SessionBoot {
+    std::uint64_t sessionId{};
+    std::uint64_t machineId{};
+    std::uint8_t kind{};
+    std::uint8_t reason{};
+};
+
+/** Body of a time-synchronize probe or response. */
+struct TimeSynchronize {
+    std::uint64_t sessionId{};
+    /** The three-sample form carries the two later samples; the one-sample form does not. */
+    bool threeSample{};
+    std::uint64_t sampleA{};
+    std::uint64_t sampleB{};
+    std::uint64_t sampleC{};
+};
+
+/** Writes a peer-connect body. @return True when every field fit. */
+[[nodiscard]] bool write_peer_connect(encoding::bits::Writer& writer,
+                                      const PeerConnect& body) noexcept;
+
+/** Writes a join-complete body. @return True when every field fit. */
+[[nodiscard]] bool write_join_complete(encoding::bits::Writer& writer,
+                                       const JoinComplete& body) noexcept;
+
+/** Reads a join-complete body. @return True when every field was present. */
+[[nodiscard]] bool read_join_complete(encoding::bits::Reader& reader,
+                                      JoinComplete& output) noexcept;
+
+/** Reads a join-abort body. @return True when every field was present. */
+[[nodiscard]] bool read_join_abort(encoding::bits::Reader& reader, SessionNotice& output) noexcept;
+
+/** Reads a session-identity-only body, shared by leave, acknowledge, and establish. */
+[[nodiscard]] bool read_session_only(encoding::bits::Reader& reader,
+                                     std::uint64_t& output) noexcept;
+
+/** Writes a session-identity-only body. @return True when the field fit. */
+[[nodiscard]] bool write_session_only(encoding::bits::Writer& writer,
+                                      std::uint64_t sessionId) noexcept;
+
+/** Writes a session-disband body. @return True when every field fit. */
+[[nodiscard]] bool write_session_disband(encoding::bits::Writer& writer,
+                                         const SessionNotice& body) noexcept;
+
+/** Writes a session-boot body. @return True when every field fit. */
+[[nodiscard]] bool write_session_boot(encoding::bits::Writer& writer,
+                                      const SessionBoot& body) noexcept;
+
+/** Reads a time-synchronize body. @return True when the selected form was complete. */
+[[nodiscard]] bool read_time_synchronize(encoding::bits::Reader& reader,
+                                         TimeSynchronize& output) noexcept;
+
+/** Writes a time-synchronize body. @return True when the selected form fit. */
+[[nodiscard]] bool write_time_synchronize(encoding::bits::Writer& writer,
+                                          const TimeSynchronize& body) noexcept;
+
+/**
+ * Peer state a membership entry publishes. It is a ladder, and the decoder refuses any value
+ * above `established`. Only the values this host uses are named.
+ */
+enum class MemberState : std::uint8_t {
+    /** Reserved for an ambassador. Every occupancy check disqualifies this slot. */
+    reservedAmbassador = 3,
+    /** Reached once a channel is up. It is below the bar a joining peer sets for itself. */
+    connected = 5,
+    /** Smallest value a joining peer accepts for its own entry before it finishes its join. */
+    joined = 7,
+    /** Smallest value that keeps a member in the lists the consumer reports. */
+    waiting = 8,
+    /** Highest value below `established`, and what this host publishes for a joining peer. */
+    ready = 9,
+    /** End of the ladder. A joining peer whose own entry reads this ends its own join request. */
+    established = 10,
+};
+
+/** One member row of a membership snapshot. */
+struct MembershipMember {
+    /** NetAddr the member is reached at. The consumer refuses a zero address. */
+    std::array<std::byte, descriptor::kNetAddrSize> address{};
+    /** Machine identity, published as eight bytes in memory order. */
+    std::uint64_t machineId{};
+    /** Join id the peer sent in its join request. The peer looks itself up by address and refuses
+     *  the update when this does not echo the id its own join carries. Zero is the empty id. */
+    std::uint64_t joinId{};
+    MemberState state{MemberState::established};
+    /** True publishes the three values below and makes the consumer resolve the peer link. */
+    bool connectionPresent{};
+    std::uint32_t joinCompatibility{};
+    std::uint64_t joinTimestamp{};
+    /** The third connection value has no recovered name; zero is its cleared value. */
+    std::uint8_t connectionValue{};
+    /** True publishes the player slot below. A member with no player publishes none. */
+    bool ownsPlayerSlot{};
+    std::uint32_t playerSlot{};
+};
+
+/** Player slots one session holds. It is both the table bound and the delta index range. */
+inline constexpr std::size_t kPlayerCapacity = 32;
+
+/**
+ * One player row of a membership snapshot.
+ * The fields are the ones the consumer's own local add writes, in the same order.
+ */
+struct MembershipPlayer {
+    /** Player slot the row occupies. */
+    std::uint32_t slot{};
+    /** Player identity the owning member sent in its player-add. */
+    std::uint64_t playerId{};
+    /** Member index that owns the player. */
+    std::uint32_t memberIndex{};
+    /** Session player-add counter at the time of the add. The first player of a session gets 0. */
+    std::uint32_t addSequence{};
+    /** One-bit field the local add takes from its caller. Zero is its cleared value. */
+    bool flag{};
+};
+
+/** Complete membership snapshot one host publishes. */
+struct MembershipUpdate {
+    /** Host machine identity, written ahead of the protobuf. */
+    std::uint64_t hostMachineId{};
+    /** Membership revision. It must be at least 1 and must strictly increase. */
+    std::uint32_t revision{};
+    /** Member index of the host. */
+    std::uint32_t hostMemberIndex{};
+    /** Assumed to be the member index the host nominates to succeed it. */
+    std::uint32_t successionIndex{};
+    /** Members in index order, starting at index 0. */
+    std::span<const MembershipMember> members{};
+    /** Players the snapshot publishes. Each names the member that owns it. */
+    std::span<const MembershipPlayer> players{};
+};
+
+/**
+ * Writes a complete-snapshot membership update.
+ * The receiving peer must be in `members`. It clears its own table and finds itself by NetAddr.
+ * The trailing hash covers the state it will hold after applying; `session_state.h` builds it.
+ * @param writer Writer positioned at the body.
+ * @param body Snapshot to publish.
+ * @return True when the whole body fit and the revision and member count are encodable.
+ */
+[[nodiscard]] bool write_membership_update(encoding::bits::Writer& writer,
+                                           const MembershipUpdate& body) noexcept;
+
+} // namespace sunrise::middleware::gameplay::group

+ 208 - 0
Sunrise/src/middleware/gameplay/group/session_state.cpp

@@ -0,0 +1,208 @@
+#include "session_state.h"
+
+#include "../../crypto/lookup3.h"
+
+namespace sunrise::middleware::gameplay::group {
+
+namespace {
+
+// --- Protobuf region offsets ------------------------------------------------------------------
+// These are absolute byte offsets into the replica. The peer hashes the whole struct, so every
+// one of them must match its own layout exactly.
+
+/** Membership revision, protobuf field 1. */
+constexpr std::size_t kRevisionOffset = 4;
+/** Host member index, field 2. */
+constexpr std::size_t kHostIndexOffset = 12;
+/** Host-succession candidate, field 3. */
+constexpr std::size_t kSuccessionOffset = 20;
+/** Member count, field 4. */
+constexpr std::size_t kMemberCountOffset = 28;
+/** Member-slot bitmask, field 5. */
+constexpr std::size_t kMemberMaskOffset = 40;
+/** Element count of the repeated member field, which the apply copies as eight bytes. */
+constexpr std::size_t kMemberArrayCountOffset = 48;
+/** First member entry, field 6. */
+constexpr std::size_t kMemberArrayOffset = 56;
+/** Bytes one member entry occupies. */
+constexpr std::size_t kMemberStride = 184;
+
+/** NetAddr length, member field 1. The value slot holds 88 bytes and the blob fills 86. */
+constexpr std::size_t kMemberAddressLengthOffset = 8;
+/** See kMemberAddressLengthOffset. */
+constexpr std::size_t kMemberAddressOffset = 16;
+/** Machine-id length, member field 2. */
+constexpr std::size_t kMemberMachineLengthOffset = 112;
+/** See kMemberMachineLengthOffset. */
+constexpr std::size_t kMemberMachineOffset = 120;
+/** Join id, member field 3. */
+constexpr std::size_t kMemberJoinIdOffset = 136;
+/** Element count of the repeated player-slot field, member field 10. */
+constexpr std::size_t kMemberPlayerCountOffset = 168;
+/** See kMemberPlayerCountOffset. */
+constexpr std::size_t kMemberPlayerSlotOffset = 176;
+
+// --- Peer table -------------------------------------------------------------------------------
+// The only region outside the protobuf a complete snapshot writes. Indexed by member index.
+
+/** First peer entry. */
+constexpr std::size_t kPeerTableOffset = 5968;
+/** Bytes one peer entry occupies. */
+constexpr std::size_t kPeerStride = 288;
+/** Connection state, the field the consumer's own scan reads. */
+constexpr std::size_t kPeerStateOffset = 0;
+/** The eight-bit delta field, stored as a word. */
+constexpr std::size_t kPeerValueOffset = 4;
+/** Join compatibility. */
+constexpr std::size_t kPeerCompatibilityOffset = 8;
+/** Join timestamp. */
+constexpr std::size_t kPeerTimestampOffset = 16;
+
+// --- Player table -----------------------------------------------------------------------------
+// Mirrors the peer table. An applied row also sets two words to all ones.
+
+/** Live player count. */
+constexpr std::size_t kPlayerCountOffset = 15184;
+/** Occupied player-slot mask. */
+constexpr std::size_t kPlayerMaskOffset = 15188;
+/** First player entry. */
+constexpr std::size_t kPlayerTableOffset = 15192;
+/** Bytes one player entry occupies. */
+constexpr std::size_t kPlayerStride = 424;
+/** Player identity. */
+constexpr std::size_t kPlayerIdOffset = 4;
+/** Member index that owns the player. */
+constexpr std::size_t kPlayerMemberOffset = 12;
+/** The owning member's own player index, which the decoder forces to zero. */
+constexpr std::size_t kPlayerOwnedIndexOffset = 16;
+/** Session player-add counter. */
+constexpr std::size_t kPlayerSequenceOffset = 20;
+/** The one-bit field, stored as a byte. */
+constexpr std::size_t kPlayerFlagOffset = 24;
+/** First of the two words the apply sets to all ones. */
+constexpr std::size_t kPlayerClearedFirstOffset = 28;
+/** Second of the two words the apply sets to all ones. */
+constexpr std::size_t kPlayerClearedSecondOffset = 264;
+/** Value both cleared words take. */
+constexpr std::uint64_t kPlayerCleared = 0xFFFFFFFF;
+
+/** Length the consumer requires of a member NetAddr. */
+constexpr std::uint64_t kAddressLength = 86;
+/** Length the consumer requires of a member machine id. */
+constexpr std::uint64_t kMachineIdLength = 8;
+/** Player slots one member may own. */
+constexpr std::uint64_t kOwnedPlayerCount = 1;
+
+/** Starting value the three lookup3 accumulators share. It is a fixed literal, not length-derived.
+ */
+constexpr std::uint32_t kHashInitial = 0xDEAE2F4E;
+
+/** Bits in one byte. */
+constexpr unsigned kByteBits = 8;
+/** Mask of one byte. */
+constexpr std::uint64_t kByteMask = 0xFF;
+
+/**
+ * Writes one little-endian integer into the replica.
+ * @param output Replica being filled.
+ * @param offset Byte offset of the field.
+ * @param value Value to store.
+ * @param width Bytes the field occupies.
+ */
+void write_integer(SessionState& output,
+                   std::size_t offset,
+                   std::uint64_t value,
+                   std::size_t width) noexcept {
+    for (std::size_t index = 0; index < width; ++index) {
+        output[offset + index] = static_cast<std::byte>((value >> (index * kByteBits)) & kByteMask);
+    }
+}
+
+} // namespace
+
+/** Fills a replica of the session state a peer holds after applying one complete snapshot. */
+void build_session_state(const MembershipUpdate& body, SessionState& output) noexcept {
+    output = {};
+    const std::uint64_t count = static_cast<std::uint64_t>(body.members.size());
+    // Members occupy indices 0 upward, so the mask follows from the count. The encoder derives
+    // it the same way, and a mismatch changes the hash.
+    const std::uint64_t mask = count == 0 ? 0 : (std::uint64_t{1} << count) - 1;
+
+    write_integer(output, kRevisionOffset, body.revision, sizeof(std::uint32_t));
+    write_integer(output, kHostIndexOffset, body.hostMemberIndex, sizeof(std::uint32_t));
+    write_integer(output, kSuccessionOffset, body.successionIndex, sizeof(std::uint32_t));
+    write_integer(output, kMemberCountOffset, count, sizeof(std::uint32_t));
+    write_integer(output, kMemberMaskOffset, mask, sizeof(std::uint64_t));
+    write_integer(output, kMemberArrayCountOffset, count, sizeof(std::uint64_t));
+
+    for (std::size_t index = 0; index < body.members.size(); ++index) {
+        const MembershipMember& member = body.members[index];
+        const std::size_t entry = kMemberArrayOffset + kMemberStride * index;
+        write_integer(
+            output, entry + kMemberAddressLengthOffset, kAddressLength, sizeof(kAddressLength));
+        for (std::size_t byte = 0; byte < member.address.size(); ++byte) {
+            output[entry + kMemberAddressOffset + byte] = member.address[byte];
+        }
+        write_integer(
+            output, entry + kMemberMachineLengthOffset, kMachineIdLength, sizeof(kMachineIdLength));
+        write_integer(
+            output, entry + kMemberMachineOffset, member.machineId, sizeof(std::uint64_t));
+        write_integer(output, entry + kMemberJoinIdOffset, member.joinId, sizeof(std::uint64_t));
+        if (member.ownsPlayerSlot) {
+            write_integer(output,
+                          entry + kMemberPlayerCountOffset,
+                          kOwnedPlayerCount,
+                          sizeof(kOwnedPlayerCount));
+            write_integer(
+                output, entry + kMemberPlayerSlotOffset, member.playerSlot, sizeof(std::uint32_t));
+        }
+
+        // One peer entry per member, matching what the encoder publishes. The peer clears this
+        // table before applying, so a member with no connection block leaves the values zero.
+        const std::size_t peer = kPeerTableOffset + kPeerStride * index;
+        write_integer(output,
+                      peer + kPeerStateOffset,
+                      static_cast<std::uint64_t>(member.state),
+                      sizeof(std::uint32_t));
+        if (member.connectionPresent) {
+            write_integer(
+                output, peer + kPeerValueOffset, member.connectionValue, sizeof(std::uint32_t));
+            write_integer(output,
+                          peer + kPeerCompatibilityOffset,
+                          member.joinCompatibility,
+                          sizeof(std::uint32_t));
+            write_integer(
+                output, peer + kPeerTimestampOffset, member.joinTimestamp, sizeof(std::uint64_t));
+        }
+    }
+
+    std::uint64_t playerMask = 0;
+    for (const MembershipPlayer& player : body.players) {
+        playerMask |= std::uint64_t{1} << player.slot;
+        const std::size_t entry = kPlayerTableOffset + kPlayerStride * player.slot;
+        write_integer(output, entry + kPlayerIdOffset, player.playerId, sizeof(std::uint64_t));
+        write_integer(
+            output, entry + kPlayerMemberOffset, player.memberIndex, sizeof(std::uint32_t));
+        write_integer(output, entry + kPlayerOwnedIndexOffset, 0, sizeof(std::uint32_t));
+        write_integer(
+            output, entry + kPlayerSequenceOffset, player.addSequence, sizeof(std::uint32_t));
+        write_integer(
+            output, entry + kPlayerFlagOffset, player.flag ? 1U : 0U, sizeof(std::uint8_t));
+        write_integer(
+            output, entry + kPlayerClearedFirstOffset, kPlayerCleared, sizeof(std::uint32_t));
+        write_integer(
+            output, entry + kPlayerClearedSecondOffset, kPlayerCleared, sizeof(std::uint32_t));
+    }
+    write_integer(output, kPlayerCountOffset, body.players.size(), sizeof(std::uint32_t));
+    write_integer(output, kPlayerMaskOffset, playerMask, sizeof(std::uint32_t));
+}
+
+/** Computes the state hash a peer will expect for one complete snapshot. */
+std::uint32_t session_state_hash(const MembershipUpdate& body) noexcept {
+    // The replica is 28 KiB, too large for a stack frame on a game thread.
+    static thread_local SessionState state{};
+    build_session_state(body, state);
+    return crypto::lookup3::hash_bytes(state, kHashInitial);
+}
+
+} // namespace sunrise::middleware::gameplay::group

+ 32 - 0
Sunrise/src/middleware/gameplay/group/session_state.h

@@ -0,0 +1,32 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+
+#include "session_messages.h"
+
+namespace sunrise::middleware::gameplay::group {
+
+/** Bytes of group-session state the membership hash covers, and the whole struct's size. */
+inline constexpr std::size_t kSessionStateSize = 28768;
+
+/** One replica of that state. It is large, so it belongs in static or member storage. */
+using SessionState = std::array<std::byte, kSessionStateSize>;
+
+/**
+ * Fills a replica of the session state a peer holds after applying one complete snapshot.
+ * The peer clears its member and player tables first, so every byte follows from the message.
+ * @param body Snapshot the peer will apply.
+ * @param output Receives the replica, fully overwritten.
+ */
+void build_session_state(const MembershipUpdate& body, SessionState& output) noexcept;
+
+/**
+ * Computes the state hash a peer will expect for one complete snapshot.
+ * @param body Snapshot the peer will apply.
+ * @return The hash to publish in the message tail.
+ */
+[[nodiscard]] std::uint32_t session_state_hash(const MembershipUpdate& body) noexcept;
+
+} // namespace sunrise::middleware::gameplay::group

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

@@ -0,0 +1,103 @@
+#include "view_message.h"
+
+#include "../../encoding/bit_raw.h"
+
+namespace sunrise::middleware::gameplay::group {
+
+namespace {
+
+namespace bits = encoding::bits;
+
+/** The kind selector is three bits. */
+constexpr std::uint8_t kKindWidth = 3;
+/** Presence flags and the list flag are one bit each. */
+constexpr std::uint8_t kFlagWidth = 1;
+/** The optional value is a 32-bit field behind its presence bit. */
+constexpr std::uint8_t kOptionalWidth = 32;
+/** The list count is seven bits. */
+constexpr std::uint8_t kListCountWidth = 7;
+/** The session token is a 64-bit value field. */
+constexpr std::uint8_t kTokenWidth = 64;
+
+} // namespace
+
+/** Reads a view-establishment body. */
+bool read_view(bits::Reader& reader, ViewEstablishment& output) noexcept {
+    ViewEstablishment candidate{};
+    std::uint64_t kind = 0;
+    std::uint64_t present = 0;
+    if (!reader.read(kKindWidth, kind) || kind > kMaximumViewKind
+        || !reader.read(kFlagWidth, present)) {
+        return false;
+    }
+    candidate.kind = static_cast<std::uint8_t>(kind);
+    candidate.hasOptionalValue = present != 0;
+    if (candidate.hasOptionalValue) {
+        std::uint64_t value = 0;
+        if (!reader.read(kOptionalWidth, value)) {
+            return false;
+        }
+        candidate.optionalValue = static_cast<std::int32_t>(static_cast<std::uint32_t>(value));
+    }
+    std::uint64_t hasList = 0;
+    if (!reader.read(kFlagWidth, hasList)) {
+        return false;
+    }
+    candidate.hasList = hasList != 0;
+    if (candidate.hasList) {
+        std::uint64_t count = 0;
+        if (!reader.read(kListCountWidth, count) || count > kViewListCapacity) {
+            return false;
+        }
+        candidate.listCount = static_cast<std::uint8_t>(count);
+        if (!bits::read_raw(reader, {candidate.list.data(), candidate.listCount})) {
+            return false;
+        }
+    }
+    if (!reader.read(kTokenWidth, candidate.sessionToken)) {
+        return false;
+    }
+    output = candidate;
+    return true;
+}
+
+/** Writes a view-establishment body. */
+bool write_view(bits::Writer& writer, const ViewEstablishment& body) noexcept {
+    if (body.kind > kMaximumViewKind || body.listCount > kViewListCapacity) {
+        return false;
+    }
+    if (!writer.write(body.kind, kKindWidth)
+        || !writer.write(body.hasOptionalValue ? 1U : 0U, kFlagWidth)) {
+        return false;
+    }
+    if (body.hasOptionalValue
+        && !writer.write(static_cast<std::uint32_t>(body.optionalValue), kOptionalWidth)) {
+        return false;
+    }
+    if (!writer.write(body.hasList ? 1U : 0U, kFlagWidth)) {
+        return false;
+    }
+    if (body.hasList) {
+        if (!writer.write(body.listCount, kListCountWidth)
+            || !bits::write_raw(writer, {body.list.data(), body.listCount})) {
+            return false;
+        }
+    }
+    return writer.write(body.sessionToken, kTokenWidth);
+}
+
+/** Compares two views for replication compatibility. */
+bool compatible(const ViewEstablishment& left, const ViewEstablishment& right) noexcept {
+    if (left.sessionToken != right.sessionToken || left.kind != right.kind
+        || left.hasList != right.hasList || left.listCount != right.listCount) {
+        return false;
+    }
+    for (std::size_t index = 0; index < left.listCount; ++index) {
+        if (left.list[index] != right.list[index]) {
+            return false;
+        }
+    }
+    return true;
+}
+
+} // namespace sunrise::middleware::gameplay::group

+ 53 - 0
Sunrise/src/middleware/gameplay/group/view_message.h

@@ -0,0 +1,53 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+
+#include "../../encoding/bit_reader.h"
+#include "../../encoding/bit_writer.h"
+
+namespace sunrise::middleware::gameplay::group {
+
+/** Registry id of view establishment. */
+inline constexpr std::uint8_t kViewMessageId = 40;
+/** Declared decoded size the registry holds. */
+inline constexpr std::uint32_t kViewMessageSize = 40;
+/** The list carries at most 16 bytes. */
+inline constexpr std::size_t kViewListCapacity = 16;
+/** The kind field is 3 bits and its largest named value is 5. */
+inline constexpr std::uint8_t kMaximumViewKind = 5;
+
+/**
+ * One view-establishment body.
+ * Both sides must agree on it. A mismatch produces no replicated entities at all.
+ */
+struct ViewEstablishment {
+    std::array<std::byte, kViewListCapacity> list{};
+    /** Token both sides bind. */
+    std::uint64_t sessionToken{};
+    /** Present optional value, or absent when the sender sent none. */
+    std::int32_t optionalValue{};
+    std::uint8_t kind{};
+    std::uint8_t listCount{};
+    bool hasOptionalValue{};
+    bool hasList{};
+};
+
+/** Reads a view-establishment body. @return True when the selected form was complete. */
+[[nodiscard]] bool read_view(encoding::bits::Reader& reader, ViewEstablishment& output) noexcept;
+
+/** Writes a view-establishment body. @return True when the selected form fit. */
+[[nodiscard]] bool write_view(encoding::bits::Writer& writer,
+                              const ViewEstablishment& body) noexcept;
+
+/**
+ * Compares two views for replication compatibility.
+ * @param left One side's view.
+ * @param right The other side's view.
+ * @return True when the token, kind, and list all match.
+ */
+[[nodiscard]] bool compatible(const ViewEstablishment& left,
+                              const ViewEstablishment& right) noexcept;
+
+} // namespace sunrise::middleware::gameplay::group

+ 25 - 0
Sunrise/src/middleware/gameplay/nat/introduction.cpp

@@ -0,0 +1,25 @@
+#include "introduction.h"
+
+namespace sunrise::middleware::gameplay::nat {
+
+namespace {
+
+/** The message type opens the introduction. */
+constexpr std::size_t kTypeOffset = 0;
+/** Type 13 is a direct introduction request, sent to every address a peer advertises. */
+constexpr std::byte kRequestType{13};
+/** Type 12 is the reply the requester waits for. It resolves the address it arrived from. */
+constexpr std::byte kReplyType{12};
+
+} // namespace
+
+/** Turns one direct introduction request into its reply, in place. */
+bool make_introduction_reply(std::span<std::byte> datagram) noexcept {
+    if (datagram.size() != kIntroductionSize || datagram[kTypeOffset] != kRequestType) {
+        return false;
+    }
+    datagram[kTypeOffset] = kReplyType;
+    return true;
+}
+
+} // namespace sunrise::middleware::gameplay::nat

+ 20 - 0
Sunrise/src/middleware/gameplay/nat/introduction.h

@@ -0,0 +1,20 @@
+#pragma once
+
+#include <cstddef>
+#include <span>
+
+namespace sunrise::middleware::gameplay::nat {
+
+/** One introduction is 29 bytes. A datagram of any other size is not one. */
+inline constexpr std::size_t kIntroductionSize = 29;
+
+/**
+ * Turns one direct introduction request into its reply, in place.
+ * Only the type byte changes, so the requester gets its own token and target hash back. The
+ * reply must be sent from the address the request arrived at.
+ * @param datagram Received datagram, rewritten only on success.
+ * @return True when the datagram was a request and now holds the reply.
+ */
+[[nodiscard]] bool make_introduction_reply(std::span<std::byte> datagram) noexcept;
+
+} // namespace sunrise::middleware::gameplay::nat

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

@@ -0,0 +1,86 @@
+#include "connect_messages.h"
+
+#include "../../encoding/bit_raw.h"
+
+namespace sunrise::middleware::gameplay::peer {
+
+namespace {
+
+namespace bits = encoding::bits;
+
+/** Both sequence fields are 32-bit value fields. */
+constexpr std::uint8_t kSequenceWidth = 32;
+/** The refuse reason is three bits. */
+constexpr std::uint8_t kRefuseReasonWidth = 3;
+/** The close reason is five bits. */
+constexpr std::uint8_t kCloseReasonWidth = 5;
+
+/** Reads the two sequence fields every connection message starts with. */
+[[nodiscard]] bool
+read_sequences(bits::Reader& reader, std::uint32_t& connection, std::uint32_t& transport) noexcept {
+    std::uint64_t first = 0;
+    std::uint64_t second = 0;
+    if (!reader.read(kSequenceWidth, first) || !reader.read(kSequenceWidth, second)) {
+        return false;
+    }
+    connection = static_cast<std::uint32_t>(first);
+    transport = static_cast<std::uint32_t>(second);
+    return true;
+}
+
+/** Writes the two sequence fields every connection message starts with. */
+[[nodiscard]] bool
+write_sequences(bits::Writer& writer, std::uint32_t connection, std::uint32_t transport) noexcept {
+    return writer.write(connection, kSequenceWidth) && writer.write(transport, kSequenceWidth);
+}
+
+} // namespace
+
+/** Reads a connect request body. */
+bool read_request(bits::Reader& reader, ConnectRequest& output) noexcept {
+    return read_sequences(reader, output.channelId, output.sequence)
+           && bits::read_raw(reader, output.address);
+}
+
+/** Writes a connect response body. */
+bool write_response(bits::Writer& writer, const ConnectResponse& body) noexcept {
+    // The echoed pair must lead. The requester closes the connection when it does not match.
+    return write_sequences(writer, body.remoteChannelId, body.remoteSequence)
+           && write_sequences(writer, body.channelId, body.sequence)
+           && bits::write_raw(writer, body.address);
+}
+
+/** Reads a connect establish body. */
+bool read_establish(bits::Reader& reader, ConnectEstablish& output) noexcept {
+    return read_sequences(reader, output.remoteChannelId, output.channelId);
+}
+
+/** Writes a connect establish body. */
+bool write_establish(bits::Writer& writer, const ConnectEstablish& body) noexcept {
+    return write_sequences(writer, body.remoteChannelId, body.channelId);
+}
+
+/** Writes a connect refuse body. */
+bool write_refuse(bits::Writer& writer, const ConnectEnd& body) noexcept {
+    return write_sequences(writer, body.remoteChannelId, body.remoteSequence)
+           && writer.write(body.reason, kRefuseReasonWidth);
+}
+
+/** Writes a connect closed body. */
+bool write_closed(bits::Writer& writer, const ConnectEnd& body) noexcept {
+    return write_sequences(writer, body.remoteChannelId, body.remoteSequence)
+           && writer.write(body.reason, kCloseReasonWidth);
+}
+
+/** Reads a connect closed body. */
+bool read_closed(bits::Reader& reader, ConnectEnd& output) noexcept {
+    std::uint64_t reason = 0;
+    if (!read_sequences(reader, output.remoteChannelId, output.remoteSequence)
+        || !reader.read(kCloseReasonWidth, reason)) {
+        return false;
+    }
+    output.reason = static_cast<std::uint8_t>(reason);
+    return true;
+}
+
+} // namespace sunrise::middleware::gameplay::peer

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

@@ -0,0 +1,97 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+
+#include "../../encoding/bit_reader.h"
+#include "../../encoding/bit_writer.h"
+#include "../descriptor/join_descriptor.h"
+
+namespace sunrise::middleware::gameplay::peer {
+
+/** The connect messages carry one NetAddr. */
+inline constexpr std::size_t kAddressBlobSize = descriptor::kNetAddrSize;
+
+/** Registry ids of the connection-control messages this host implements. */
+enum class ConnectId : std::uint8_t {
+    request = 5,
+    response = 6,
+    refuse = 7,
+    establish = 8,
+    closed = 9,
+};
+
+/** Declared decoded sizes the registry holds for those ids. */
+inline constexpr std::uint32_t kRequestSize = 96;
+/** See kRequestSize. */
+inline constexpr std::uint32_t kResponseSize = 104;
+/** See kRequestSize. */
+inline constexpr std::uint32_t kRefuseSize = 12;
+/** See kRequestSize. */
+inline constexpr std::uint32_t kEstablishSize = 8;
+/** See kRequestSize. */
+inline constexpr std::uint32_t kClosedSize = 12;
+
+/** Body of a connect request. */
+struct ConnectRequest {
+    /** Incarnation counter the sender chose. It is ordered, not random, and never all ones. */
+    std::uint32_t channelId{};
+    /** First sequence number the sender's channel will use. */
+    std::uint32_t sequence{};
+    std::array<std::byte, kAddressBlobSize> address{};
+};
+
+/** Body of a connect response. The echoed pair leads and the responder's own pair follows. */
+struct ConnectResponse {
+    /** Channel id the requester sent, returned unaltered. */
+    std::uint32_t remoteChannelId{};
+    /** Sequence the requester sent, returned unaltered. A mismatch closes the connection. */
+    std::uint32_t remoteSequence{};
+    /** Channel id the responder chose. */
+    std::uint32_t channelId{};
+    /** First sequence number the responder's channel will use. */
+    std::uint32_t sequence{};
+    std::array<std::byte, kAddressBlobSize> address{};
+};
+
+/** Body of a connect establish. It names both channel ids rather than sequences. */
+struct ConnectEstablish {
+    std::uint32_t remoteChannelId{};
+    std::uint32_t channelId{};
+};
+
+/** Body of a connect refuse or a connect closed. Only the reason width differs. */
+struct ConnectEnd {
+    /** Channel id the requester sent. */
+    std::uint32_t remoteChannelId{};
+    /** Sequence the requester sent. */
+    std::uint32_t remoteSequence{};
+    std::uint8_t reason{};
+};
+
+/** Reads a connect request body. @return True when every field was present. */
+[[nodiscard]] bool read_request(encoding::bits::Reader& reader, ConnectRequest& output) noexcept;
+
+/** Writes a connect response body. @return True when every field fit. */
+[[nodiscard]] bool write_response(encoding::bits::Writer& writer,
+                                  const ConnectResponse& body) noexcept;
+
+/** Reads a connect establish body. @return True when every field was present. */
+[[nodiscard]] bool read_establish(encoding::bits::Reader& reader,
+                                  ConnectEstablish& output) noexcept;
+
+/** Writes a connect establish body. @return True when every field fit. */
+[[nodiscard]] bool write_establish(encoding::bits::Writer& writer,
+                                   const ConnectEstablish& body) noexcept;
+
+/** Writes a connect refuse body. @return True when every field fit. */
+[[nodiscard]] bool write_refuse(encoding::bits::Writer& writer, const ConnectEnd& body) noexcept;
+
+/** Writes a connect closed body. @return True when every field fit. */
+[[nodiscard]] bool write_closed(encoding::bits::Writer& writer, const ConnectEnd& body) noexcept;
+
+/** Reads a connect closed body. @return True when every field was present. */
+[[nodiscard]] bool read_closed(encoding::bits::Reader& reader, ConnectEnd& output) noexcept;
+
+} // namespace sunrise::middleware::gameplay::peer

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

@@ -0,0 +1,512 @@
+#include "established_packet.h"
+
+#include <array>
+
+#include "../../encoding/bit_raw.h"
+#include "peer_container.h"
+
+namespace sunrise::middleware::gameplay::peer {
+
+namespace {
+
+namespace bits = encoding::bits;
+
+/** An established packet carries marker 0. */
+constexpr std::uint64_t kEstablishedMarker = 0;
+/** Width of the marker and of the fragmented flag. */
+constexpr std::uint8_t kFlagWidth = 1;
+/** The connection sequence guard is two bits. */
+constexpr std::uint8_t kSequenceGuardWidth = 2;
+/** The outbound packet head is published as ten low bits. */
+constexpr std::uint8_t kOutboundHeadWidth = 10;
+/** The distance from the head back to the event cursor is seven bits. */
+constexpr std::uint8_t kCursorWidth = 7;
+/** The acknowledged base is seven bits, matching the 128-entry ring. */
+constexpr std::uint8_t kAckBaseWidth = 7;
+/** Every status form is selected by a two-bit prefix, one of which extends to three. */
+constexpr std::uint8_t kStatusPrefixWidth = 2;
+/** The extending bit of the three-bit prefixes. */
+constexpr std::uint8_t kStatusExtensionWidth = 1;
+/** Prefix of the empty-ring form. */
+constexpr std::uint64_t kStatusEmpty = 0;
+/** Prefix of the single-difference form. */
+constexpr std::uint64_t kStatusSingle = 1;
+/** Prefix of the eight-entry explicit form. */
+constexpr std::uint64_t kStatusExplicit = 2;
+/** Prefix shared by the long-span and uninitialized forms. */
+constexpr std::uint64_t kStatusExtended = 3;
+/** Extension bit selecting the long-span form. */
+constexpr std::uint64_t kStatusLongSpan = 0;
+/** Width of the long-span count. */
+constexpr std::uint8_t kStatusCountWidth = 7;
+/** Width of the index in the single-difference form. */
+constexpr std::uint8_t kSingleIndexWidth = 3;
+/** The acknowledgement delay is ten bits. */
+constexpr std::uint8_t kDelayWidth = 10;
+/** Longest status run the long-span form can name. */
+constexpr std::uint64_t kMaximumStatusCount = 128;
+/** The message sequence selector is two bits. */
+constexpr std::uint8_t kSelectorWidth = 2;
+/** Selector ending the record list. */
+constexpr std::uint64_t kSelectorEnd = 0;
+/** Selector introducing an absolute low-13 sequence. */
+constexpr std::uint64_t kSelectorAbsolute = 1;
+/** Selector introducing a four-bit delta above the previous sequence. */
+constexpr std::uint64_t kSelectorDelta = 2;
+/** Width of an absolute message sequence. */
+constexpr std::uint8_t kAbsoluteSequenceWidth = 13;
+/** Message sequences unwrap modulo 8,192. */
+constexpr std::uint16_t kSequenceModulus = 8192;
+/** Width of the delta form. */
+constexpr std::uint8_t kDeltaWidth = 4;
+/** Width of the queue-specific short length for the 32-byte queue. */
+constexpr std::uint8_t kLargeShortLengthWidth = 8;
+/** Width of the queue-specific short length for the 6-byte queue. */
+constexpr std::uint8_t kSmallShortLengthWidth = 6;
+/** Bits in one byte. */
+constexpr std::uint16_t kByteBits = 8;
+/** The reassembled message begins with a 6-bit registry id. */
+constexpr std::uint8_t kMessageIdWidth = 6;
+/** The declared decoded size after it is 18 bits. */
+constexpr std::uint8_t kMessageSizeWidth = 18;
+
+/**
+ * Reads one ternary packet status.
+ * @param reader Open reader.
+ * @param received Receives true for the two codes that mean received.
+ * @return True when a complete code was present.
+ */
+[[nodiscard]] bool read_status(bits::Reader& reader, bool& received) noexcept {
+    std::uint64_t first = 0;
+    if (!reader.read(kFlagWidth, first)) {
+        return false;
+    }
+    if (first == 0) {
+        // Code 0 is status 1, the ordinary received case.
+        received = true;
+        return true;
+    }
+    std::uint64_t second = 0;
+    if (!reader.read(kFlagWidth, second)) {
+        return false;
+    }
+    // Code 10 is status 0, unresolved. Code 11 is status 2, received out of order.
+    received = second != 0;
+    return true;
+}
+
+/** Writes one ternary packet status. @param received Selects status 1 or status 0. */
+[[nodiscard]] bool write_status(bits::Writer& writer, bool received) noexcept {
+    if (received) {
+        return writer.write(0, kFlagWidth);
+    }
+    return writer.write(1, kFlagWidth) && writer.write(0, kFlagWidth);
+}
+
+/**
+ * Reads the acknowledgement handler payload.
+ * @param reader Open reader positioned after the packet head.
+ * @param output Receives the peer's acknowledgement state.
+ * @return True when the selected status form was complete.
+ */
+[[nodiscard]] bool read_ack(bits::Reader& reader, AckState& output) noexcept {
+    std::uint64_t present = 0;
+    std::uint64_t head = 0;
+    std::uint64_t cursor = 0;
+    if (!reader.read(kFlagWidth, present) || !reader.read(kOutboundHeadWidth, head)
+        || !reader.read(kCursorWidth, cursor)) {
+        return false;
+    }
+    output.outboundHeadPresent = present != 0;
+    output.outboundHead = static_cast<std::uint16_t>(head);
+    output.headMinusCursor = static_cast<std::uint8_t>(cursor);
+
+    std::uint64_t base = 0;
+    std::uint64_t prefix = 0;
+    if (!reader.read(kAckBaseWidth, base) || !reader.read(kStatusPrefixWidth, prefix)) {
+        return false;
+    }
+    output.receiveHead = static_cast<std::uint16_t>(base);
+    output.received = {};
+    output.reportedCount = 0;
+    if (prefix == kStatusExtended) {
+        std::uint64_t extension = 0;
+        if (!reader.read(kStatusExtensionWidth, extension)) {
+            return false;
+        }
+        if (extension != kStatusLongSpan) {
+            // The uninitialized form ends the payload and carries no delay.
+            output.ringInitialized = false;
+            return true;
+        }
+        std::uint64_t count = 0;
+        // The field is the number of statuses, not one less than it.
+        if (!reader.read(kStatusCountWidth, count) || count > kMaximumStatusCount) {
+            return false;
+        }
+        for (std::uint64_t index = 0; index < count; ++index) {
+            bool received = false;
+            if (!read_status(reader, received)) {
+                return false;
+            }
+            if (index < output.received.size()) {
+                output.received[index] = received;
+                output.reportedCount = static_cast<std::uint8_t>(index + 1);
+            }
+        }
+    } else if (prefix == kStatusExplicit) {
+        for (bool& entry : output.received) {
+            if (!read_status(reader, entry)) {
+                return false;
+            }
+        }
+        output.reportedCount = static_cast<std::uint8_t>(output.received.size());
+    } else if (prefix == kStatusSingle) {
+        std::uint64_t outOfOrder = 0;
+        std::uint64_t index = 0;
+        if (!reader.read(kFlagWidth, outOfOrder) || !reader.read(kSingleIndexWidth, index)) {
+            return false;
+        }
+        // The named entry is the last one and the only one that is not an ordinary receive.
+        output.received.fill(true);
+        output.received[static_cast<std::size_t>(index)] = outOfOrder != 0;
+        output.reportedCount = static_cast<std::uint8_t>(index + 1);
+    } else {
+        std::uint64_t ringState = 0;
+        if (!reader.read(kFlagWidth, ringState)) {
+            return false;
+        }
+    }
+    std::uint64_t delay = 0;
+    if (!reader.read(kDelayWidth, delay)) {
+        return false;
+    }
+    output.delay = static_cast<std::uint16_t>(delay);
+    output.ringInitialized = true;
+    return true;
+}
+
+/**
+ * Reads one reliable queue payload.
+ * @param reader Open reader positioned at the queue payload.
+ * @param fragmentBytes Fixed fragment size of this queue.
+ * @param shortLengthWidth Width of this queue's short-fragment length field.
+ * @param output Receives every record the packet carried.
+ * @return True when the record list was well formed.
+ */
+[[nodiscard]] bool read_queue(bits::Reader& reader,
+                              std::size_t fragmentBytes,
+                              std::uint8_t shortLengthWidth,
+                              QueueRecords& output) noexcept {
+    output.count = 0;
+    std::uint64_t recordsPresent = 0;
+    if (!reader.read(kFlagWidth, recordsPresent)) {
+        return false;
+    }
+    if (recordsPresent == 0) {
+        return true;
+    }
+    std::uint16_t previous = 0;
+    bool hasPrevious = false;
+    for (;;) {
+        std::uint64_t selector = 0;
+        if (!reader.read(kSelectorWidth, selector)) {
+            return false;
+        }
+        if (selector == kSelectorEnd) {
+            return true;
+        }
+        if (output.count >= output.records.size()) {
+            return false;
+        }
+        QueueRecord& record = output.records[output.count];
+        record = {};
+        if (selector == kSelectorAbsolute) {
+            std::uint64_t sequence = 0;
+            if (!reader.read(kAbsoluteSequenceWidth, sequence)) {
+                return false;
+            }
+            record.sequence = static_cast<std::uint16_t>(sequence);
+        } else if (selector == kSelectorDelta) {
+            std::uint64_t delta = 0;
+            if (!hasPrevious || !reader.read(kDeltaWidth, delta)) {
+                return false;
+            }
+            record.sequence = static_cast<std::uint16_t>((previous + 1 + delta) % kSequenceModulus);
+        } else {
+            if (!hasPrevious) {
+                return false;
+            }
+            record.sequence = static_cast<std::uint16_t>((previous + 1) % kSequenceModulus);
+        }
+        previous = record.sequence;
+        hasPrevious = true;
+
+        std::uint64_t isShort = 0;
+        if (!reader.read(kFlagWidth, isShort)) {
+            return false;
+        }
+        record.shortFragment = isShort != 0;
+        if (record.shortFragment) {
+            std::uint64_t secondary = 0;
+            std::uint64_t lengthMinusOne = 0;
+            if (!reader.read(kFlagWidth, secondary)
+                || !reader.read(shortLengthWidth, lengthMinusOne)) {
+                return false;
+            }
+            record.bitCount = static_cast<std::uint16_t>(lengthMinusOne + 1);
+        } else {
+            record.bitCount = static_cast<std::uint16_t>(fragmentBytes * kByteBits);
+        }
+        if (record.bitCount > record.bytes.size() * kByteBits) {
+            return false;
+        }
+        // Fragment bits are read as whole bytes plus a remainder, so the tail keeps its padding.
+        const std::size_t wholeBytes = record.bitCount / kByteBits;
+        const std::uint8_t remainder = static_cast<std::uint8_t>(record.bitCount % kByteBits);
+        if (!bits::read_raw(reader, std::span<std::byte>(record.bytes.data(), wholeBytes))) {
+            return false;
+        }
+        if (remainder != 0) {
+            std::uint64_t tail = 0;
+            if (!reader.read(remainder, tail)) {
+                return false;
+            }
+            record.bytes[wholeBytes] = static_cast<std::byte>(tail << (kByteBits - remainder));
+        }
+        ++output.count;
+    }
+}
+
+} // namespace
+
+/** Decodes one established packet up to and including both reliable queues. */
+bool decode_established(std::span<const std::byte> payload,
+                        bool expectExternal,
+                        EstablishedPacket& output) noexcept {
+    bits::Reader reader(payload);
+    std::uint64_t marker = 0;
+    std::uint64_t fragmented = 0;
+    std::uint64_t guard = 0;
+    if (!reader.read(kFlagWidth, marker) || marker != kEstablishedMarker
+        || !reader.read(kFlagWidth, fragmented) || fragmented != 0
+        || !reader.read(kSequenceGuardWidth, guard)) {
+        return false;
+    }
+    output = {};
+    output.connectionSequenceLow2 = static_cast<std::uint8_t>(guard);
+    if (!read_ack(reader, output.ack)
+        || !read_queue(reader, kLargeFragmentBytes, kLargeShortLengthWidth, output.large)
+        || !read_queue(reader, kSmallFragmentBytes, kSmallShortLengthWidth, output.small)) {
+        return false;
+    }
+    // The sentinel handler writes no bits, so the external body starts here when one exists.
+    output.hasExternal = expectExternal;
+    output.externalBitOffset = payload.size() * kByteBits - reader.remaining_bits();
+    return true;
+}
+
+/** Reports whether one acknowledgement covers a packet this host sent. */
+bool acknowledgement_covers(const AckState& ack, std::uint16_t sentSequence) noexcept {
+    if (!ack.ringInitialized) {
+        return false;
+    }
+    const auto base = static_cast<std::uint16_t>(ack.receiveHead % kPacketRingSize);
+    const auto sent = static_cast<std::uint16_t>(sentSequence % kPacketRingSize);
+    const auto distance = static_cast<std::uint16_t>((base - sent) % kPacketRingSize);
+    if (distance >= kPacketRingSize / 2) {
+        // The base is behind the packet, so the peer has not reached it yet.
+        return false;
+    }
+    if (distance == 0) {
+        // The base is the newest packet the peer holds, and it carries no status entry.
+        return true;
+    }
+    if (distance <= ack.reportedCount) {
+        return ack.received[distance - 1U];
+    }
+    // Older than every entry the peer named, so it has left the peer's window.
+    return true;
+}
+
+/** Writes the packet head and the acknowledgement handler payload. */
+bool write_head_and_ack(bits::Writer& writer,
+                        std::uint8_t connectionSequenceLow2,
+                        const AckState& ack) noexcept {
+    if (!writer.write(kEstablishedMarker, kFlagWidth) || !writer.write(0, kFlagWidth)
+        || !writer.write(connectionSequenceLow2, kSequenceGuardWidth)) {
+        return false;
+    }
+    if (!writer.write(ack.outboundHeadPresent ? 1U : 0U, kFlagWidth)
+        || !writer.write(ack.outboundHead, kOutboundHeadWidth)
+        || !writer.write(ack.headMinusCursor, kCursorWidth)) {
+        return false;
+    }
+    if (!ack.ringInitialized) {
+        // Nothing has been received yet, so the uninitialized form ends the payload here.
+        return writer.write(0, kAckBaseWidth) && writer.write(kStatusExtended, kStatusPrefixWidth)
+               && writer.write(1, kStatusExtensionWidth);
+    }
+    if (!writer.write(ack.receiveHead, kAckBaseWidth)
+        || !writer.write(kStatusExplicit, kStatusPrefixWidth)) {
+        return false;
+    }
+    // Eight explicit statuses say exactly which packets arrived, without claiming any others.
+    for (const bool received : ack.received) {
+        if (!write_status(writer, received)) {
+            return false;
+        }
+    }
+    return writer.write(ack.delay, kDelayWidth);
+}
+
+/** Writes one reliable queue that carries no records. */
+bool write_empty_queue(bits::Writer& writer) noexcept {
+    return writer.write(0, kFlagWidth);
+}
+
+/** Writes one reliable queue and every fragment it owes. */
+bool write_queue(bits::Writer& writer, const state::gameplay::OutboundQueue& queue) noexcept {
+    if (queue.count == 0) {
+        return write_empty_queue(writer);
+    }
+    if (!writer.write(1, kFlagWidth)) {
+        return false;
+    }
+    for (std::size_t index = 0; index < queue.count; ++index) {
+        const state::gameplay::OutboundFragment& fragment = queue.fragments[index];
+        if (!writer.write(kSelectorAbsolute, kSelectorWidth)
+            || !writer.write(fragment.sequence, kAbsoluteSequenceWidth)
+            || !writer.write(fragment.shortFragment ? 1U : 0U, kFlagWidth)) {
+            return false;
+        }
+        if (fragment.shortFragment) {
+            // The secondary flag has no meaning for a fragment this host writes and stays clear.
+            if (!writer.write(0, kFlagWidth)
+                || !writer.write(fragment.bitCount - 1U, kLargeShortLengthWidth)) {
+                return false;
+            }
+        }
+        const std::size_t wholeBytes = fragment.bitCount / kByteBits;
+        const auto remainder = static_cast<std::uint8_t>(fragment.bitCount % kByteBits);
+        if (!bits::write_raw(writer, {fragment.bytes.data(), wholeBytes})) {
+            return false;
+        }
+        if (remainder != 0) {
+            const auto tail = std::to_integer<std::uint64_t>(fragment.bytes[wholeBytes]);
+            if (!writer.write(tail >> (kByteBits - remainder), remainder)) {
+                return false;
+            }
+        }
+    }
+    return writer.write(kSelectorEnd, kSelectorWidth);
+}
+
+/** Splits one reliable message into fragments and appends them to a send queue. */
+bool enqueue_message(state::gameplay::OutboundQueue& queue,
+                     std::uint8_t id,
+                     std::uint32_t declaredSize,
+                     std::span<const std::byte> body,
+                     std::size_t bodyBits) noexcept {
+    if (id > kMaximumMessageId) {
+        return false;
+    }
+    // The inner header precedes the body, so the message is staged once and then split.
+    std::array<std::byte, state::gameplay::kReassemblyCapacity> staged{};
+    bits::Writer writer(staged);
+    if (!writer.write(id, kMessageIdWidth) || !writer.write(declaredSize, kMessageSizeWidth)) {
+        return false;
+    }
+    bits::Reader reader(body);
+    std::size_t remaining = bodyBits;
+    while (remaining != 0) {
+        const auto width = static_cast<std::uint8_t>(remaining < kByteBits ? remaining : kByteBits);
+        std::uint64_t value = 0;
+        if (!reader.read(width, value) || !writer.write(value, width)) {
+            return false;
+        }
+        remaining -= width;
+    }
+    std::size_t stagedBytes = 0;
+    if (!writer.finish(stagedBytes)) {
+        return false;
+    }
+
+    const std::size_t totalBits = writer.bit_count();
+    // Every fragment but the last carries the queue's whole fixed size.
+    constexpr std::size_t kFragmentBits = kLargeFragmentBytes * kByteBits;
+    std::size_t consumed = 0;
+    bits::Reader source({staged.data(), stagedBytes});
+    // A half-enqueued message can never be reassembled, so any failure below restores the queue.
+    const std::size_t entryCount = queue.count;
+    const std::uint16_t entrySequence = queue.nextSequence;
+    const auto restore = [&queue, entryCount, entrySequence]() noexcept {
+        for (std::size_t index = entryCount; index < queue.count; ++index) {
+            queue.fragments[index] = {};
+        }
+        queue.count = entryCount;
+        queue.nextSequence = entrySequence;
+    };
+    while (consumed < totalBits) {
+        if (queue.count >= queue.fragments.size()) {
+            restore();
+            return false;
+        }
+        const std::size_t take =
+            (totalBits - consumed) < kFragmentBits ? totalBits - consumed : kFragmentBits;
+        // Only a short fragment closes a run. A full last fragment still needs one appended.
+        const bool last = consumed + take >= totalBits;
+        state::gameplay::OutboundFragment& fragment = queue.fragments[queue.count];
+        fragment = {};
+        bits::Writer chunk(fragment.bytes);
+        std::size_t written = 0;
+        std::size_t pending = take;
+        while (pending != 0) {
+            const auto width = static_cast<std::uint8_t>(pending < kByteBits ? pending : kByteBits);
+            std::uint64_t value = 0;
+            if (!source.read(width, value) || !chunk.write(value, width)) {
+                restore();
+                return false;
+            }
+            pending -= width;
+        }
+        if (!chunk.finish(written)) {
+            restore();
+            return false;
+        }
+        fragment.sequence = queue.nextSequence;
+        fragment.bitCount = static_cast<std::uint16_t>(take);
+        fragment.shortFragment = last && take < kFragmentBits;
+        fragment.occupied = true;
+        queue.nextSequence = static_cast<std::uint16_t>((queue.nextSequence + 1)
+                                                        % state::gameplay::kMessageSequenceModulus);
+        ++queue.count;
+        consumed += take;
+        if (last && !fragment.shortFragment) {
+            // An exact multiple of the fragment size still needs a closing short fragment.
+            if (queue.count >= queue.fragments.size()) {
+                restore();
+                return false;
+            }
+            state::gameplay::OutboundFragment& terminator = queue.fragments[queue.count];
+            terminator = {};
+            terminator.sequence = queue.nextSequence;
+            terminator.bitCount = 1;
+            terminator.shortFragment = true;
+            terminator.occupied = true;
+            queue.nextSequence = static_cast<std::uint16_t>(
+                (queue.nextSequence + 1) % state::gameplay::kMessageSequenceModulus);
+            ++queue.count;
+        }
+    }
+    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.
+    // The reader consumes both, so both must be written even though both are zero.
+    return writer.write(0, kFlagWidth) && writer.write(0, kFlagWidth);
+}
+
+} // namespace sunrise::middleware::gameplay::peer

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

@@ -0,0 +1,146 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+#include "../../../state/gameplay/definition.h"
+#include "../../encoding/bit_reader.h"
+#include "../../encoding/bit_writer.h"
+
+namespace sunrise::middleware::gameplay::peer {
+
+/** The acknowledgement history covers the eight newest packets. */
+inline constexpr std::size_t kAckHistory = 8;
+/** The outbound packet ring holds 128 entries, so the acknowledged base is 7 bits. */
+inline constexpr std::size_t kPacketRingSize = 128;
+/** The larger reliable queue uses 32-byte fragments. */
+inline constexpr std::size_t kLargeFragmentBytes = 32;
+/** The smaller reliable queue uses 6-byte fragments. */
+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;
+
+/** Acknowledgement state this side publishes and the other side reads. */
+struct AckState {
+    /** Sequence of our newest outbound packet, or absent when we have sent none. */
+    std::uint16_t outboundHead{};
+    bool outboundHeadPresent{};
+    /** Distance from the outbound head back to the event cursor. */
+    std::uint8_t headMinusCursor{};
+    /** Sequence of the newest packet received from the peer. */
+    std::uint16_t receiveHead{};
+    /** True once one packet has been received, which selects the initialized form. */
+    bool ringInitialized{};
+    /** Entry `i` is the packet `i + 1` before the head. The head itself carries no entry. */
+    std::array<bool, kAckHistory> received{};
+    /** Entries the wire form actually named. The rest of the array is unreported, not clear. */
+    std::uint8_t reportedCount{};
+    /** Half the measured delay, or the 1,023 sentinel. */
+    std::uint16_t delay{};
+};
+
+/** One reliable-queue record taken off the wire. */
+struct QueueRecord {
+    /** Unwrapped message sequence. */
+    std::uint16_t sequence{};
+    /** True for the shorter final fragment of a message. */
+    bool shortFragment{};
+    /** Fragment payload bits. A fixed fragment carries the queue's whole fragment size. */
+    std::uint16_t bitCount{};
+    std::array<std::byte, kLargeFragmentBytes> bytes{};
+};
+
+/** Every record one packet carried, in wire order. */
+struct QueueRecords {
+    std::array<QueueRecord, kMaximumRecords> records{};
+    std::size_t count{};
+};
+
+/** One decoded established packet. */
+struct EstablishedPacket {
+    std::uint8_t connectionSequenceLow2{};
+    AckState ack{};
+    /** Records from the 32-byte queue. */
+    QueueRecords large{};
+    /** Records from the 6-byte queue. */
+    QueueRecords small{};
+    /** Bit offset of the external gameplay handler body, once the view gate has opened. */
+    std::size_t externalBitOffset{};
+    bool hasExternal{};
+};
+
+/**
+ * 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.
+ * @param payload Whole decrypted transport payload.
+ * @param expectExternal True once one external handler is registered on this connection.
+ * @param output Receives the decoded packet.
+ * @return True when every field of the fixed handler run was present and in range.
+ */
+[[nodiscard]] bool decode_established(std::span<const std::byte> payload,
+                                      bool expectExternal,
+                                      EstablishedPacket& output) noexcept;
+
+/**
+ * Writes the packet head and the acknowledgement handler payload.
+ * @param writer Writer positioned at the start of the payload.
+ * @param connectionSequenceLow2 Local connection sequence modulo four.
+ * @param ack Acknowledgement state to publish.
+ * @return True when every field fit.
+ */
+[[nodiscard]] bool write_head_and_ack(encoding::bits::Writer& writer,
+                                      std::uint8_t connectionSequenceLow2,
+                                      const AckState& ack) noexcept;
+
+/**
+ * Reports whether one acknowledgement covers a packet this host sent.
+ * The base names the newest packet the peer holds. Anything older than its named entries counts
+ * as delivered.
+ * @param ack Acknowledgement state the peer published.
+ * @param sentSequence Sequence of the packet to test.
+ * @return True when the peer has that packet.
+ */
+[[nodiscard]] bool acknowledgement_covers(const AckState& ack, std::uint16_t sentSequence) noexcept;
+
+/**
+ * Writes one reliable queue that carries no records.
+ * @param writer Writer positioned at that queue's payload.
+ * @return True when the terminator fit.
+ */
+[[nodiscard]] bool write_empty_queue(encoding::bits::Writer& writer) noexcept;
+
+/**
+ * Writes one reliable queue and every fragment it owes.
+ * Each record names its sequence absolutely. The delta forms desynchronise if a record is dropped.
+ * @param writer Writer positioned at that queue's payload.
+ * @param queue Fragments to write, in sequence order.
+ * @return True when the whole list and its terminator fit.
+ */
+[[nodiscard]] bool write_queue(encoding::bits::Writer& writer,
+                               const state::gameplay::OutboundQueue& queue) noexcept;
+
+/**
+ * Splits one reliable message into fragments and appends them to a send queue.
+ * @param queue Queue receiving the fragments.
+ * @param id Registry message id.
+ * @param declaredSize Decoded structure size the registry declares for that id.
+ * @param body Encoded message body bits, without the inner header.
+ * @param bodyBits Number of meaningful bits in the body.
+ * @return True when the whole message fit the queue.
+ */
+[[nodiscard]] bool enqueue_message(state::gameplay::OutboundQueue& queue,
+                                   std::uint8_t id,
+                                   std::uint32_t declaredSize,
+                                   std::span<const std::byte> body,
+                                   std::size_t bodyBits) noexcept;
+
+/**
+ * Writes the filler trailer that ends every packet.
+ * @param writer Writer positioned after the last handler payload.
+ * @return True when the absent-filler bit fit.
+ */
+[[nodiscard]] bool write_absent_filler(encoding::bits::Writer& writer) noexcept;
+
+} // namespace sunrise::middleware::gameplay::peer

+ 75 - 0
Sunrise/src/middleware/gameplay/peer/join_messages.cpp

@@ -0,0 +1,75 @@
+#include "join_messages.h"
+
+#include "../../encoding/bit_raw.h"
+
+namespace sunrise::middleware::gameplay::peer {
+
+namespace {
+
+namespace bits = encoding::bits;
+
+/** The protocol version is a 16-bit value field. */
+constexpr std::uint8_t kProtocolWidth = 16;
+/** Both build fields are 32-bit value fields. */
+constexpr std::uint8_t kBuildWidth = 32;
+/** The executable type is three bits. */
+constexpr std::uint8_t kExecutableWidth = 3;
+/** The refusal reason is six bits. */
+constexpr std::uint8_t kReasonWidth = 6;
+} // namespace
+
+/** Reads the admission prefix of a join request. */
+bool read_join_request(bits::Reader& reader, JoinRequest& output) noexcept {
+    std::uint64_t protocol = 0;
+    std::uint64_t minimum = 0;
+    std::uint64_t maximum = 0;
+    std::uint64_t executable = 0;
+    JoinRequest candidate{};
+    if (!reader.read(kProtocolWidth, protocol) || !reader.read(kBuildWidth, minimum)
+        || !reader.read(kBuildWidth, maximum) || !reader.read(kExecutableWidth, executable)
+        || !bits::read_raw_u64(reader, candidate.sessionId)
+        || !bits::read_raw_u64(reader, candidate.joinId)) {
+        return false;
+    }
+    candidate.protocolVersion = static_cast<std::uint16_t>(protocol);
+    candidate.minimumBuild = static_cast<std::uint32_t>(minimum);
+    candidate.maximumBuild = static_cast<std::uint32_t>(maximum);
+    candidate.executableType = static_cast<std::uint8_t>(executable);
+    output = candidate;
+    return true;
+}
+
+/** Writes a join refusal body. */
+bool write_join_refuse(bits::Writer& writer, const JoinRefuse& body) noexcept {
+    return bits::write_raw_u64(writer, body.sessionId) && bits::write_raw_u64(writer, body.joinId)
+           && writer.write(static_cast<std::uint64_t>(body.reason), kReasonWidth);
+}
+
+/** Reports whether a request may be answered at all. */
+bool answerable(const JoinRequest& request) noexcept {
+    return request.protocolVersion == kProtocolVersion;
+}
+
+/** Applies the host's admission rules in their exact order. */
+bool admit(const JoinRequest& request, std::uint64_t hostSessionId, RefuseReason& reason) noexcept {
+    if (!answerable(request)) {
+        return false;
+    }
+    // This host holds one group session, so the request's session id needs no lookup.
+    (void)hostSessionId;
+    if (request.maximumBuild < kHostBuild) {
+        reason = RefuseReason::peerVersionTooLow;
+        return false;
+    }
+    if (request.minimumBuild > kHostBuild) {
+        reason = RefuseReason::hostVersionTooLow;
+        return false;
+    }
+    if (request.executableType != kExecutableType) {
+        reason = RefuseReason::executableTypeMismatch;
+        return false;
+    }
+    return true;
+}
+
+} // namespace sunrise::middleware::gameplay::peer

+ 85 - 0
Sunrise/src/middleware/gameplay/peer/join_messages.h

@@ -0,0 +1,85 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+
+#include "../../encoding/bit_reader.h"
+#include "../../encoding/bit_writer.h"
+
+namespace sunrise::middleware::gameplay::peer {
+
+/** Registry ids of the group-session join messages. */
+enum class JoinId : std::uint8_t {
+    request = 10,
+    refuse = 14,
+};
+
+/** Declared decoded sizes the registry holds for those ids. */
+inline constexpr std::uint32_t kJoinRequestSize = 6144;
+/** See kJoinRequestSize. */
+inline constexpr std::uint32_t kJoinRefuseSize = 24;
+
+/** Protocol version the host requires. A mismatch is dropped with no reply at all. */
+inline constexpr std::uint16_t kProtocolVersion = 0xA4F8;
+/** Build the host reports. The request's build interval has to contain it. */
+inline constexpr std::uint32_t kHostBuild = 86657;
+/** Executable type the host requires. */
+inline constexpr std::uint8_t kExecutableType = 5;
+
+/** Refusal reasons this host emits, by their registry order. */
+enum class RefuseReason : std::uint8_t {
+    notFound = 4,
+    peerVersionTooLow = 27,
+    hostVersionTooLow = 28,
+    executableTypeMismatch = 29,
+};
+
+/**
+ * Leading fixed fields of a join request.
+ * Everything after the join id is address and player tables. Admission does not read them.
+ */
+struct JoinRequest {
+    std::uint16_t protocolVersion{};
+    std::uint32_t minimumBuild{};
+    std::uint32_t maximumBuild{};
+    std::uint8_t executableType{};
+    std::uint64_t sessionId{};
+    /** Identifies this join attempt, not the machine. It changes on every retry, and the peer
+     *  refuses a membership update that does not echo it. */
+    std::uint64_t joinId{};
+};
+
+/** Body of a join refusal. The host echoes the request's join id. */
+struct JoinRefuse {
+    std::uint64_t sessionId{};
+    std::uint64_t joinId{};
+    RefuseReason reason{RefuseReason::notFound};
+};
+
+/**
+ * Reads the admission prefix of a join request.
+ * @param reader Reader positioned at the body.
+ * @param output Receives the fields admission checks.
+ * @return True when every admission field was present.
+ */
+[[nodiscard]] bool read_join_request(encoding::bits::Reader& reader, JoinRequest& output) noexcept;
+
+/** Writes a join refusal body. @return True when every field fit. */
+[[nodiscard]] bool write_join_refuse(encoding::bits::Writer& writer,
+                                     const JoinRefuse& body) noexcept;
+
+/**
+ * Applies the host's admission rules in their exact order.
+ * @param request Decoded admission prefix.
+ * @param hostSessionId Session id this host advertises.
+ * @param reason Receives the refusal reason when admission fails.
+ * @return True when the request is admitted. A protocol mismatch also returns false and leaves
+ *         the reason at its default. Drop such a request without a reply.
+ */
+[[nodiscard]] bool
+admit(const JoinRequest& request, std::uint64_t hostSessionId, RefuseReason& reason) noexcept;
+
+/** @return True when the request may be answered at all. A protocol mismatch may not. */
+[[nodiscard]] bool answerable(const JoinRequest& request) noexcept;
+
+} // namespace sunrise::middleware::gameplay::peer

+ 63 - 0
Sunrise/src/middleware/gameplay/peer/peer_container.cpp

@@ -0,0 +1,63 @@
+#include "peer_container.h"
+
+namespace sunrise::middleware::gameplay::peer {
+
+namespace {
+
+/** Width of the container marker, which is written once when the send buffer opens. */
+constexpr std::uint8_t kMarkerWidth = 1;
+/** A container always carries marker 1. */
+constexpr std::uint64_t kContainerMarker = 1;
+/** One bit says whether another message follows. */
+constexpr std::uint8_t kFollowsWidth = 1;
+/** Message ids are 6 bits. */
+constexpr std::uint8_t kIdWidth = 6;
+/** The declared decoded size is 18 bits. */
+constexpr std::uint8_t kSizeWidth = 18;
+
+} // namespace
+
+/** Opens a container for writing and emits its packet marker. */
+bool open_container(encoding::bits::Writer& writer) noexcept {
+    return writer.write(kContainerMarker, kMarkerWidth);
+}
+
+/** Writes one message header. */
+bool write_header(encoding::bits::Writer& writer, const MessageHeader& header) noexcept {
+    return header.id <= kMaximumMessageId && writer.write(1, kFollowsWidth)
+           && writer.write(header.id, kIdWidth) && writer.write(header.declaredSize, kSizeWidth);
+}
+
+/** Ends a container after its last message. */
+bool close_container(encoding::bits::Writer& writer) noexcept {
+    return writer.write(0, kFollowsWidth);
+}
+
+/** Reads the container marker. */
+bool read_marker(encoding::bits::Reader& reader) noexcept {
+    std::uint64_t marker = 0;
+    return reader.read(kMarkerWidth, marker) && marker == kContainerMarker;
+}
+
+/** Reads the next message header. */
+bool read_header(encoding::bits::Reader& reader, MessageHeader& header, bool& present) noexcept {
+    present = false;
+    std::uint64_t follows = 0;
+    if (!reader.read(kFollowsWidth, follows)) {
+        return false;
+    }
+    if (follows == 0) {
+        return true;
+    }
+    std::uint64_t id = 0;
+    std::uint64_t size = 0;
+    if (!reader.read(kIdWidth, id) || id > kMaximumMessageId || !reader.read(kSizeWidth, size)) {
+        return false;
+    }
+    header.id = static_cast<std::uint8_t>(id);
+    header.declaredSize = static_cast<std::uint32_t>(size);
+    present = true;
+    return true;
+}
+
+} // namespace sunrise::middleware::gameplay::peer

+ 65 - 0
Sunrise/src/middleware/gameplay/peer/peer_container.h

@@ -0,0 +1,65 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+#include "../../encoding/bit_reader.h"
+#include "../../encoding/bit_writer.h"
+
+namespace sunrise::middleware::gameplay::peer {
+
+/** The registry holds ids 0 through 44. */
+inline constexpr std::uint8_t kMaximumMessageId = 44;
+/** The out-of-band gateway caps one aggregated container at this many bytes. */
+inline constexpr std::size_t kContainerCapacity = 5120;
+
+/** One message header read from a container. */
+struct MessageHeader {
+    std::uint8_t id{};
+    /** Decoded C-structure size the registry declares. It is not the encoded length. */
+    std::uint32_t declaredSize{};
+};
+
+/**
+ * Opens a container for writing and emits its packet marker.
+ * @param writer Writer positioned at the start of the payload.
+ * @return True when the marker fit.
+ */
+[[nodiscard]] bool open_container(encoding::bits::Writer& writer) noexcept;
+
+/**
+ * Writes one message header.
+ * The declared size must be the registry's constant for that id, not the bits actually written.
+ * @param writer Open container writer.
+ * @param header Message id and declared size.
+ * @return True when the header fit.
+ */
+[[nodiscard]] bool write_header(encoding::bits::Writer& writer,
+                                const MessageHeader& header) noexcept;
+
+/**
+ * Ends a container after its last message.
+ * @param writer Open container writer.
+ * @return True when the terminator fit.
+ */
+[[nodiscard]] bool close_container(encoding::bits::Writer& writer) noexcept;
+
+/**
+ * Reads the container marker.
+ * @param reader Reader positioned at the start of the payload.
+ * @return True when the marker was present and set.
+ */
+[[nodiscard]] bool read_marker(encoding::bits::Reader& reader) noexcept;
+
+/**
+ * Reads the next message header.
+ * @param reader Open container reader.
+ * @param header Receives the header when one follows.
+ * @param present Receives false at the end of the chain.
+ * @return True when the chain was well formed, including at its end.
+ */
+[[nodiscard]] bool
+read_header(encoding::bits::Reader& reader, MessageHeader& header, bool& present) noexcept;
+
+} // namespace sunrise::middleware::gameplay::peer

+ 128 - 0
Sunrise/src/middleware/gameplay/peer/reliable_assembly.cpp

@@ -0,0 +1,128 @@
+#include "reliable_assembly.h"
+
+#include "../../encoding/bit_reader.h"
+#include "../../encoding/bit_writer.h"
+#include "peer_container.h"
+
+namespace sunrise::middleware::gameplay::peer {
+
+namespace {
+
+namespace bits = encoding::bits;
+
+/** Message ids are 6 bits. */
+constexpr std::uint8_t kIdWidth = 6;
+/** The declared decoded size is 18 bits. */
+constexpr std::uint8_t kSizeWidth = 18;
+/** Bits in one byte. */
+constexpr std::size_t kByteBits = 8;
+
+/** @return Distance from the queue's next sequence to one record sequence. */
+[[nodiscard]] std::uint16_t distance(std::uint16_t from, std::uint16_t to) noexcept {
+    // Add the modulus before subtracting. A bare difference is signed and goes negative on a wrap.
+    return static_cast<std::uint16_t>((to + state::gameplay::kMessageSequenceModulus - from)
+                                      % state::gameplay::kMessageSequenceModulus);
+}
+
+/** @return Slot one sequence occupies. */
+[[nodiscard]] std::size_t slot_of(std::uint16_t sequence) noexcept {
+    return sequence % state::gameplay::kReliableSlots;
+}
+
+} // namespace
+
+/** Files one packet's records into a queue. */
+std::size_t accept_records(const QueueRecords& records,
+                           state::gameplay::ReliableQueue& queue) noexcept {
+    std::size_t dropped = 0;
+    for (std::size_t index = 0; index < records.count; ++index) {
+        const QueueRecord& record = records.records[index];
+        if (!queue.started) {
+            // The first record seen fixes where the drain begins.
+            queue.started = true;
+            queue.nextSequence = record.sequence;
+        }
+        if (distance(queue.nextSequence, record.sequence) >= state::gameplay::kReliableSlots) {
+            ++dropped;
+            continue;
+        }
+        state::gameplay::ReliableFragment& fragment = queue.fragments[slot_of(record.sequence)];
+        if (fragment.occupied && fragment.sequence == record.sequence) {
+            continue;
+        }
+        fragment.sequence = record.sequence;
+        fragment.bitCount = record.bitCount;
+        fragment.shortFragment = record.shortFragment;
+        fragment.bytes = record.bytes;
+        fragment.occupied = true;
+    }
+    return dropped;
+}
+
+/** Takes the next complete message off a queue. */
+bool drain_message(state::gameplay::ReliableQueue& queue, AssembledMessage& output) noexcept {
+    if (!queue.started) {
+        return false;
+    }
+    // Find the run first: nothing is consumed until a short fragment closes it.
+    std::size_t runLength = 0;
+    std::size_t totalBits = 0;
+    for (; runLength < state::gameplay::kReliableSlots; ++runLength) {
+        const auto sequence = static_cast<std::uint16_t>(
+            (queue.nextSequence + runLength) % state::gameplay::kMessageSequenceModulus);
+        const state::gameplay::ReliableFragment& fragment = queue.fragments[slot_of(sequence)];
+        if (!fragment.occupied || fragment.sequence != sequence) {
+            return false;
+        }
+        totalBits += fragment.bitCount;
+        if (fragment.shortFragment) {
+            break;
+        }
+    }
+    if (runLength >= state::gameplay::kReliableSlots
+        || totalBits > state::gameplay::kReassemblyCapacity * kByteBits) {
+        return false;
+    }
+
+    AssembledMessage candidate{};
+    bits::Writer writer(candidate.bytes);
+    for (std::size_t index = 0; index <= runLength; ++index) {
+        const auto sequence = static_cast<std::uint16_t>(
+            (queue.nextSequence + index) % state::gameplay::kMessageSequenceModulus);
+        state::gameplay::ReliableFragment& fragment = queue.fragments[slot_of(sequence)];
+        bits::Reader reader({fragment.bytes.data(), fragment.bytes.size()});
+        std::size_t remaining = fragment.bitCount;
+        while (remaining != 0) {
+            const auto width =
+                static_cast<std::uint8_t>(remaining < kByteBits ? remaining : kByteBits);
+            std::uint64_t value = 0;
+            if (!reader.read(width, value) || !writer.write(value, width)) {
+                return false;
+            }
+            remaining -= width;
+        }
+        fragment = {};
+    }
+    queue.nextSequence = static_cast<std::uint16_t>((queue.nextSequence + runLength + 1)
+                                                    % state::gameplay::kMessageSequenceModulus);
+
+    std::size_t written = 0;
+    if (!writer.finish(written)) {
+        return false;
+    }
+    candidate.bitCount = totalBits;
+    bits::Reader header({candidate.bytes.data(), written});
+    std::uint64_t id = 0;
+    std::uint64_t declared = 0;
+    if (!header.read(kIdWidth, id) || id > kMaximumMessageId
+        || !header.read(kSizeWidth, declared)) {
+        return false;
+    }
+    candidate.id = static_cast<std::uint8_t>(id);
+    candidate.declaredSize = static_cast<std::uint32_t>(declared);
+    candidate.bodyBitOffset = kIdWidth + kSizeWidth;
+    output = candidate;
+    return true;
+}
+
+} // namespace sunrise::middleware::gameplay::peer

+ 46 - 0
Sunrise/src/middleware/gameplay/peer/reliable_assembly.h

@@ -0,0 +1,46 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+
+#include "../../../state/gameplay/definition.h"
+#include "established_packet.h"
+
+namespace sunrise::middleware::gameplay::peer {
+
+/** One message reassembled from a contiguous run of fragments. */
+struct AssembledMessage {
+    std::array<std::byte, state::gameplay::kReassemblyCapacity> bytes{};
+    /** Message bits, before the inner header is read. */
+    std::size_t bitCount{};
+    std::uint8_t id{};
+    /** Decoded C-structure size the registry declares for that id. */
+    std::uint32_t declaredSize{};
+    /** Body bit offset, just after the inner header. */
+    std::size_t bodyBitOffset{};
+};
+
+/**
+ * Files one packet's records into a queue.
+ * A record outside the buffered window is dropped. The sender retransmits it once the window
+ * moves, so a queue that never drains shows up only as a rising drop count.
+ * @param records Records the packet carried, in wire order.
+ * @param queue Queue receiving them.
+ * @return How many records the window refused.
+ */
+std::size_t accept_records(const QueueRecords& records,
+                           state::gameplay::ReliableQueue& queue) noexcept;
+
+/**
+ * Takes the next complete message off a queue.
+ * A message is a contiguous run of fragments ending in a short one. An incomplete run stays
+ * buffered until its missing fragment arrives.
+ * @param queue Queue to drain.
+ * @param output Receives the message and its decoded inner header.
+ * @return True when one whole message was drained.
+ */
+[[nodiscard]] bool drain_message(state::gameplay::ReliableQueue& queue,
+                                 AssembledMessage& output) noexcept;
+
+} // namespace sunrise::middleware::gameplay::peer

+ 103 - 0
Sunrise/src/middleware/web_service/messages/opcode901/opcode901_codec.cpp

@@ -0,0 +1,103 @@
+/**
+ * Opcode 901 is a vendor purchase. The request carries a vendor index, a sale index and a clock.
+ * The clock rule checks skew and freshness only. Replay needs a committed purchase to compare
+ * against, and no purchase commits yet.
+ */
+
+#include "opcode901_codec.h"
+
+#include "../../../encoding/bit_reader.h"
+
+namespace sunrise::middleware::web_service::messages::opcode901 {
+namespace {
+
+/** Both index fields are 16-bit signed values. */
+constexpr std::uint8_t kIndexWidth = 16;
+/** Their descriptor bias is the signed 16-bit midpoint. */
+constexpr std::int32_t kIndexBias = 0x8000;
+/** The optional clock is a 64-bit signed value with no bias. */
+constexpr std::uint8_t kClockWidth = 64;
+/** One presence bit precedes the clock. */
+constexpr std::uint8_t kPresenceWidth = 1;
+/**
+ * Bits allowed after the last field. Both legal forms end mid byte, so up to seven bits pad it.
+ * A whole byte left over is data, not padding.
+ */
+constexpr std::size_t kPaddingLimit = 8;
+
+/**
+ * Reads one biased index field.
+ * @param reader Open reader.
+ * @param output Receives the logical index.
+ * @return True when the field was present.
+ */
+[[nodiscard]] bool read_index(encoding::bits::Reader& reader, std::int16_t& output) noexcept {
+    std::uint64_t stored = 0;
+    if (!reader.read(kIndexWidth, stored)) {
+        return false;
+    }
+    output = static_cast<std::int16_t>(static_cast<std::int32_t>(stored) - kIndexBias);
+    return true;
+}
+
+} // namespace
+
+/** Decodes one purchase request body. */
+bool parse_request(const Message& message, Request& output) noexcept {
+    if (message.opcode != kOpcode) {
+        return false;
+    }
+    encoding::bits::Reader reader(message.payload);
+    Request candidate{};
+    std::uint64_t present = 0;
+    if (!read_index(reader, candidate.vendorIndex) || !read_index(reader, candidate.saleIndex)
+        || !reader.read(kPresenceWidth, present)) {
+        return false;
+    }
+    candidate.hasClock = present != 0;
+    if (candidate.hasClock) {
+        std::uint64_t clock = 0;
+        if (!reader.read(kClockWidth, clock)) {
+            return false;
+        }
+        candidate.clock = static_cast<std::int64_t>(clock);
+    }
+    if (reader.remaining_bits() >= kPaddingLimit) {
+        return false;
+    }
+    output = candidate;
+    return true;
+}
+
+/** Checks presence first, then both windows. */
+ClockPolicy check_clock(const Request& request, std::int64_t serverClock) noexcept {
+    if (!request.hasClock) {
+        return ClockPolicy::absent;
+    }
+    // Unsigned subtraction keeps the distance exact for any two signed clocks.
+    if (request.clock > serverClock) {
+        const std::uint64_t ahead =
+            static_cast<std::uint64_t>(request.clock) - static_cast<std::uint64_t>(serverClock);
+        return ahead > kClockAheadLimitSeconds ? ClockPolicy::ahead : ClockPolicy::accepted;
+    }
+    const std::uint64_t behind =
+        static_cast<std::uint64_t>(serverClock) - static_cast<std::uint64_t>(request.clock);
+    return behind > kClockBehindLimitSeconds ? ClockPolicy::stale : ClockPolicy::accepted;
+}
+
+/** Names one clock verdict for a log line. */
+const char* clock_policy_name(ClockPolicy policy) noexcept {
+    switch (policy) {
+    case ClockPolicy::accepted:
+        return "ok";
+    case ClockPolicy::absent:
+        return "absent";
+    case ClockPolicy::ahead:
+        return "ahead";
+    case ClockPolicy::stale:
+        return "stale";
+    }
+    return "unknown";
+}
+
+} // namespace sunrise::middleware::web_service::messages::opcode901

+ 70 - 0
Sunrise/src/middleware/web_service/messages/opcode901/opcode901_codec.h

@@ -0,0 +1,70 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+
+#include "../../web_service_envelope.h"
+
+namespace sunrise::middleware::web_service::messages::opcode901 {
+
+/** Web Service opcode for a vendor purchase. */
+inline constexpr std::uint16_t kOpcode = 901;
+
+/** One decoded purchase request. The two indices are its only identity. */
+struct Request {
+    /** Index into the vendor table. */
+    std::int16_t vendorIndex{};
+    /** Index into that vendor's sale rows. */
+    std::int16_t saleIndex{};
+    /** Client clock in Unix seconds. */
+    std::int64_t clock{};
+    /** False for the absent form, which the decoder still accepts. */
+    bool hasClock{};
+};
+
+/**
+ * Verdict of the clock rule on one decoded request.
+ * Every value is Sunrise policy, not a native rule.
+ */
+enum class ClockPolicy : std::uint8_t {
+    /** Present, and inside both windows. */
+    accepted,
+    /** The absent form. It decodes, but the clock rule refuses it. */
+    absent,
+    /** Ahead of the server by more than the skew window. */
+    ahead,
+    /** Behind the server by more than the freshness window. */
+    stale,
+};
+
+/** Seconds a request clock may run ahead of the server. Policy window, not a native value. */
+inline constexpr std::uint64_t kClockAheadLimitSeconds = 60;
+/** Seconds a request clock may run behind the server. Policy window, not a native value. */
+inline constexpr std::uint64_t kClockBehindLimitSeconds = 300;
+
+/**
+ * Decodes one purchase request body.
+ * A body with a whole byte left over after the last field is refused.
+ * @param message Parsed Web Service envelope.
+ * @param output Receives the request only when the whole body decodes.
+ * @return True when the opcode matches and the body is one of the two legal forms.
+ */
+[[nodiscard]] bool parse_request(const Message& message, Request& output) noexcept;
+
+/**
+ * Applies the clock rule to one decoded request.
+ * The clock must be present. The absent form decodes, then fails here.
+ * @param request Decoded request.
+ * @param serverClock Server's own time in Unix seconds.
+ * @return Which rule the clock met or broke.
+ */
+[[nodiscard]] ClockPolicy check_clock(const Request& request, std::int64_t serverClock) noexcept;
+
+/**
+ * Names one clock verdict for a log line.
+ * @param policy Verdict from check_clock.
+ * @return Stable lowercase token.
+ */
+[[nodiscard]] const char* clock_policy_name(ClockPolicy policy) noexcept;
+
+} // namespace sunrise::middleware::web_service::messages::opcode901

+ 7 - 4
Sunrise/src/server/bap/encrypted/activity_message/activity_message_route.cpp

@@ -6,6 +6,7 @@
 #include <cstdio>
 
 #include "../../../../core/logging/log.h"
+#include "../../../../core/settings/settings.h"
 #include "../../../../middleware/bap/activity_message/activity_client_identity_parser.h"
 #include "../../../../middleware/bap/activity_message/activity_client_keepalive_validator.h"
 #include "../../../../middleware/bap/activity_message/activity_high_water_validator.h"
@@ -157,12 +158,14 @@ void report_message(std::uint32_t messageType,
  */
 [[nodiscard]] bool prepare_join(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,
-                                                        parsed.memberKey,
-                                                        state::activity::entity_slots::kSlotCount,
-                                                        plan.entitySlotMutation)) {
+        || !state::activity::entity_slots::prepare_join(
+            parsed.sessionId, parsed.memberKey, granted, reserve, plan.entitySlotMutation)) {
         return false;
     }
     plan.correlation = parsed.correlation;

+ 35 - 4
Sunrise/src/server/bap/encrypted/activity_transaction/activity_transaction_notifications.cpp

@@ -1,5 +1,8 @@
 #include "activity_transaction_notifications.h"
 
+#include "../../../../core/logging/log.h"
+#include "../../../gameplay/gameplay_advertisement.h"
+#include "../push/activity/activity_arrival.h"
 #include "../push/activity/activity_global_state_push.h"
 #include "../push/activity/activity_membership_push.h"
 #include "../push/activity/activity_message_push.h"
@@ -8,6 +11,28 @@
 namespace sunrise::server::bap::encrypted::activity_transaction {
 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.
+ * @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 {
+    // 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);
+    if (state != server::gameplay::AdvertisementState::pending) {
+        return false;
+    }
+    core::log::write(core::log::Channel::server,
+                     core::log::Level::debug,
+                     "ev=gameplay stage=membership result=held reason=no_host_session");
+    return true;
+}
+
 /**
  * Stages the whole host snapshot the client's state-refresh request asks for.
  * The order matches the keepalive: the global state, then membership, then the roster, because the
@@ -30,7 +55,7 @@ namespace {
                                  std::size_t& written) noexcept {
     bool staged = push::activity::append_global_state_notification(
         scratch, activity.sessionId, key, nonce, response, written);
-    if (activity.membershipMutation.hasSnapshot) {
+    if (activity.membershipMutation.hasSnapshot && !advertisement_pending(activity)) {
         staged = push::activity::append_membership_notification(
                      scratch, activity, key, nonce, response, written)
                  || staged;
@@ -61,16 +86,22 @@ namespace {
                                        std::span<std::byte> response,
                                        std::size_t& written) noexcept {
     bool staged = false;
+    bool held = false;
     if (activity.membershipMutation.hasSnapshot) {
-        staged = push::activity::append_membership_notification(
-            scratch, activity, key, nonce, response, written);
+        held = advertisement_pending(activity);
+        if (!held) {
+            staged = push::activity::append_membership_notification(
+                scratch, activity, key, nonce, response, written);
+        }
     }
     if (activity.regionMoved) {
         staged = push::activity::append_roster_notification(
                      session, scratch, key, nonce, response, written, false)
                  || staged;
     }
-    return staged;
+    // A held membership is not a failed staging. A false here drops the very commit that moved
+    // the region the held body waits for. The keepalive publishes it on a later slice.
+    return staged || held;
 }
 
 } // namespace

+ 17 - 9
Sunrise/src/server/bap/encrypted/bap_connection_publication.cpp

@@ -44,6 +44,11 @@ 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;
+        }
         session.activitySessionId = publication.activitySessionId;
     }
     if (fields.joinMemberKey != 0) {
@@ -70,16 +75,19 @@ void publish_connection_fields(Session& session,
 
 /** 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 {
-    if (!queuezPublication.armsFamily4Repush || queuezPublication.family4RepushRoot == 0) {
-        return;
-    }
     const std::uint64_t now = GetTickCount64();
-    session.family4RepushDueTick = now + kFamily4RepushDelayMs;
-    session.family4RepushRoot = queuezPublication.family4RepushRoot;
-    session.family4RepushArmed = true;
-    session.bannerRepushDueTick = now + kBannerRepushDelayMs;
-    session.bannerRepushRoot = queuezPublication.family4RepushRoot;
-    session.bannerRepushArmed = true;
+    if (queuezPublication.armsFamily4Repush && queuezPublication.family4RepushRoot != 0) {
+        session.family4RepushDueTick = now + kFamily4RepushDelayMs;
+        session.family4RepushRoot = queuezPublication.family4RepushRoot;
+        session.family4RepushArmed = true;
+    }
+    // Armed on its own signal, not on family four's. Family zero re-subscribes on every record
+    // cycle, and each subscribe needs a delayed copy because the immediate answer arrives too soon.
+    if (queuezPublication.armsBannerRepush && queuezPublication.bannerRepushRoot != 0) {
+        session.bannerRepushDueTick = now + kBannerRepushDelayMs;
+        session.bannerRepushRoot = queuezPublication.bannerRepushRoot;
+        session.bannerRepushArmed = true;
+    }
 }
 
 } // namespace sunrise::server::bap::encrypted

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

@@ -1,3 +1,7 @@
+#include <array>
+#include <atomic>
+#include <cstdio>
+
 #include "../../../../core/logging/log.h"
 #include "../../../../middleware/bap/account_translation/account_translation_response.h"
 #include "../../../../middleware/bap/activity_host/activity_host_response.h"
@@ -6,6 +10,7 @@
 #include "../../../../middleware/bap/family_subscription.h"
 #include "../../../../middleware/bap/family_unsubscription.h"
 #include "../../../../middleware/bap/user_message/user_message_response.h"
+#include "../../../../middleware/encoding/byte_order.h"
 #include "../../../../middleware/web_service/messages/opcode505/opcode505_codec.h"
 #include "../../../../state/runtime/runtime.h"
 #include "../../../web_service/web_service_runtime.h"
@@ -16,6 +21,46 @@
 #include "../queuez/queuez_state_validation.h"
 
 namespace sunrise::server::bap::encrypted::body {
+namespace {
+
+/** One line carries the family and the root soid and nothing else. */
+constexpr std::size_t kSubscribeReportLimit = 96;
+/** The svc-23 request identity sits after its entry count and both type bytes. */
+constexpr std::size_t kTranslationIdentityOffset = 4;
+/** A request shorter than this carries no identity to read. */
+constexpr std::size_t kTranslationRequestSize =
+    kTranslationIdentityOffset + middleware::encoding::kU64Size;
+
+/**
+ * Identity already paired with the account soid, or zero before the first pairing.
+ * There is one account, so this is process-wide rather than per connection.
+ */
+std::atomic<std::uint64_t> g_translatedIdentity{0};
+
+/**
+ * Reports whether one svc-23 request may be paired with the account soid.
+ * The reply writes the soid into a queuez roster member. Two identities on one soid put two
+ * family-zero source entries on it, and every lookup then resolves only the first.
+ * @param requestBody Complete svc-23 request body.
+ * @return True when this identity is the one paired, or the first to ask.
+ */
+[[nodiscard]] bool pairs_identity(std::span<const std::byte> requestBody) noexcept {
+    if (requestBody.size() < kTranslationRequestSize) {
+        return false;
+    }
+    const std::uint64_t identity = middleware::encoding::read_u64_be(
+        requestBody.subspan<kTranslationIdentityOffset, middleware::encoding::kU64Size>());
+    if (identity == 0) {
+        return false;
+    }
+    std::uint64_t claimed = 0;
+    // A repeat of the same identity still pairs: the peer re-asks until the flag sticks.
+    return g_translatedIdentity.compare_exchange_strong(
+               claimed, identity, std::memory_order_relaxed)
+           || claimed == identity;
+}
+
+} // namespace
 
 /**
  * Processes the body for one authenticated service route.
@@ -44,8 +89,16 @@ bool process(const ServiceRoute& route,
         return true;
     case BodyCodec::accountTranslationResponse: {
         const state::AccountState account = state::account_snapshot();
+        // A zero soid makes the encoder write its zero-entry answer. That refuses an unpaired
+        // request without leaving the peer waiting.
+        const bool pairs = pairs_identity(requestBody);
+        const std::uint64_t soid = pairs ? account.primarySoid : 0;
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::info,
+                         pairs ? "ev=queuez stage=translate result=paired"
+                               : "ev=queuez stage=translate result=unpaired");
         return middleware::bap::account_translation::encode_response(
-            requestBody, account.primarySoid, output, written);
+            requestBody, soid, output, written);
     }
     case BodyCodec::activityHostManagerResponse:
         return activity_host_manager::encode_response(requestBody,
@@ -64,11 +117,27 @@ bool process(const ServiceRoute& route,
     }
     case BodyCodec::clientConfigResponse:
         return middleware::bap::client_config::encode_minimal_response(output, written);
-    case BodyCodec::familySubscription:
+    case BodyCodec::familySubscription: {
         written = 0;
         outcome.hasSubscription =
             middleware::bap::family_subscription::parse(requestBody, outcome.subscription);
+        // The subscribe names the record now ready for a snapshot. The family and root are the
+        // only way to tell one record's cycle from several records interleaving.
+        std::array<char, kSubscribeReportLimit> line{};
+        const int count =
+            std::snprintf(line.data(),
+                          line.size(),
+                          "ev=queuez stage=subscribe result=%s family=%u root=0x%016llX",
+                          outcome.hasSubscription ? "ok" : "unreadable",
+                          static_cast<unsigned>(outcome.subscription.familyType),
+                          static_cast<unsigned long long>(outcome.subscription.familyRootSoid));
+        if (count > 0) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::info,
+                             {line.data(), static_cast<std::size_t>(count)});
+        }
         return outcome.hasSubscription;
+    }
     case BodyCodec::familyUnsubscription: {
         written = 0;
         outcome.hasUnsubscription =

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott