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

Merge master into the inventory and vendor work

Resolve eight files. Both sides added cache domains and both bumped a
version, so the merged values are raised past either side.

- format.h: the merged Header holds 25 uint32 counts, from the PR's five
  item domains and master's four vendor domains. Cache format goes to 34,
  which neither side shipped, so every existing cache is rebuilt.
- settings: both sides called their change version 4. The layout goes to 5
  and region_private, topology and characters are replaced at 5, so a file
  already at 4 still takes them.
- build_data_persistence: master's four vendor domains move into the PR's
  vector scratch banks.
- encrypted_runtime, service_outcome_commit, queuez_outcome_staging:
  master's staged-roster commit, banner repush and join rebind move onto
  the PR's transaction_if model.
- web_service_runtime: keeps the PR's request trace and master's vendor
  purchase refusal. select_character now lives in web_service_actions.cpp.
stan 2 месяцев назад
Родитель
Сommit
9023a853c5
100 измененных файлов с 6543 добавлено и 287 удалено
  1. 6 1
      .gitignore
  2. 108 0
      CMakeLists.txt
  3. 106 5
      Sunrise/Sunrise.vcxproj
  4. 11 1
      Sunrise/resources/default_settings.json
  5. 3 3
      Sunrise/resources/resource.h
  6. 47 0
      Sunrise/src/client/content/vendors/layout.h
  7. 331 0
      Sunrise/src/client/content/vendors/package_vendor_build.cpp
  8. 22 0
      Sunrise/src/client/content/vendors/vendor_build.h
  9. 15 15
      Sunrise/src/client/hooks/assert_handler/assert_handler_lifecycle.cpp
  10. 96 16
      Sunrise/src/client/hooks/assert_handler/assert_handler_observer.cpp
  11. 15 8
      Sunrise/src/client/hooks/bootflow/region_private.cpp
  12. 5 2
      Sunrise/src/client/hooks/graphics/renderer/graphics_renderer_report.cpp
  13. 1 0
      Sunrise/src/client/hooks/graphics/renderer/graphics_renderer_report.h
  14. 7 0
      Sunrise/src/client/hooks/graphics/renderer/selection/graphics_swap_chain_selection.cpp
  15. 334 0
      Sunrise/src/client/hooks/noclip/horizontal_noclip.cpp
  16. 17 0
      Sunrise/src/client/hooks/noclip/runtime.h
  17. 129 13
      Sunrise/src/client/hooks/queuez/family0/family0_source_seed.cpp
  18. 19 2
      Sunrise/src/client/hooks/queuez/family0_subscription.cpp
  19. 14 4
      Sunrise/src/client/hooks/retail_log/retail_log_enqueue_observer.cpp
  20. 9 5
      Sunrise/src/client/hooks/teleport/teleport_move.cpp
  21. 23 11
      Sunrise/src/client/movement/movement_settings_store.cpp
  22. 6 4
      Sunrise/src/client/movement/movement_settings_store.h
  23. 88 29
      Sunrise/src/client/patterns/registry.cpp
  24. 1 1
      Sunrise/src/client/patterns/registry.h
  25. 15 0
      Sunrise/src/client/runtime/client_hook_activation.cpp
  26. 6 4
      Sunrise/src/client/runtime/client_runtime_lifecycle.cpp
  27. 5 0
      Sunrise/src/client/targets/game/assert_handler.h
  28. 4 1
      Sunrise/src/client/targets/game/assert_handler/game_assert_targets.cpp
  29. 2 2
      Sunrise/src/client/targets/game/assert_handler/game_assert_targets.h
  30. 79 34
      Sunrise/src/client/ui/movement/movement_panel.cpp
  31. 8 0
      Sunrise/src/client/ui/movement/movement_panel.h
  32. 8 8
      Sunrise/src/client/ui/runtime/client_ui_module_runtime.cpp
  33. 0 8
      Sunrise/src/client/ui/teleport/teleport_panel.h
  34. 48 1
      Sunrise/src/core/logging/log.cpp
  35. 13 0
      Sunrise/src/core/logging/log.h
  36. 31 18
      Sunrise/src/core/runtime/core_runtime.cpp
  37. 65 0
      Sunrise/src/core/settings/address_text.cpp
  38. 22 0
      Sunrise/src/core/settings/address_text.h
  39. 6 0
      Sunrise/src/core/settings/client/client_settings_parser.cpp
  40. 6 0
      Sunrise/src/core/settings/client/definition.h
  41. 3 45
      Sunrise/src/core/settings/client/external/client_external_settings_parser.cpp
  42. 7 0
      Sunrise/src/core/settings/parser.h
  43. 3 0
      Sunrise/src/core/settings/server/definition.h
  44. 72 0
      Sunrise/src/core/settings/server/gameplay/definition.h
  45. 117 0
      Sunrise/src/core/settings/server/gameplay/gameplay_settings_parser.cpp
  46. 39 0
      Sunrise/src/core/settings/server/gameplay/gameplay_settings_validation.cpp
  47. 6 0
      Sunrise/src/core/settings/server/server_settings_parser.cpp
  48. 3 3
      Sunrise/src/core/settings/settings.h
  49. 26 10
      Sunrise/src/core/settings/settings_upgrade.cpp
  50. 52 0
      Sunrise/src/core/settings/state_settings.cpp
  51. 6 0
      Sunrise/src/core/settings/steam/definition.h
  52. 86 9
      Sunrise/src/core/settings/steam/steam_settings_parser.cpp
  53. 2 2
      Sunrise/src/middleware/bap/activity_message/activity_entity_slot_request_parser.cpp
  54. 39 11
      Sunrise/src/middleware/bap/activity_message/activity_membership_region_writer.cpp
  55. 7 4
      Sunrise/src/middleware/bap/activity_message/activity_replicate_membership_encoder.cpp
  56. 38 0
      Sunrise/src/middleware/bap/activity_message/replicate_membership.h
  57. 144 0
      Sunrise/src/middleware/crypto/aes_cbc.cpp
  58. 38 0
      Sunrise/src/middleware/crypto/aes_cbc.h
  59. 65 0
      Sunrise/src/middleware/crypto/aes_gcm_encrypt.cpp
  60. 26 0
      Sunrise/src/middleware/crypto/aes_gcm_encrypt.h
  61. 298 0
      Sunrise/src/middleware/crypto/ecc_p224.cpp
  62. 31 0
      Sunrise/src/middleware/crypto/ecc_p224.h
  63. 143 0
      Sunrise/src/middleware/crypto/hmac.cpp
  64. 42 0
      Sunrise/src/middleware/crypto/hmac.h
  65. 135 0
      Sunrise/src/middleware/crypto/lookup3.cpp
  66. 28 0
      Sunrise/src/middleware/crypto/lookup3.h
  67. 285 0
      Sunrise/src/middleware/crypto/modular_1024.cpp
  68. 96 0
      Sunrise/src/middleware/crypto/modular_1024.h
  69. 139 0
      Sunrise/src/middleware/crypto/murmur3.cpp
  70. 26 0
      Sunrise/src/middleware/crypto/murmur3.h
  71. 22 0
      Sunrise/src/middleware/crypto/random_bytes.cpp
  72. 16 0
      Sunrise/src/middleware/crypto/random_bytes.h
  73. 78 0
      Sunrise/src/middleware/crypto/sha256.cpp
  74. 35 0
      Sunrise/src/middleware/crypto/sha256.h
  75. 436 0
      Sunrise/src/middleware/crypto/tiger192.cpp
  76. 23 0
      Sunrise/src/middleware/crypto/tiger192.h
  77. 8 5
      Sunrise/src/middleware/datagen/family4/progression/layout.h
  78. 22 1
      Sunrise/src/middleware/datagen/family4/progression/progression_bank_keys.cpp
  79. 1 1
      Sunrise/src/middleware/datagen/family4/progression/progression_bank_keys.h
  80. 67 0
      Sunrise/src/middleware/encoding/bit_raw.cpp
  81. 53 0
      Sunrise/src/middleware/encoding/bit_raw.h
  82. 152 0
      Sunrise/src/middleware/gameplay/association/control_packet.cpp
  83. 75 0
      Sunrise/src/middleware/gameplay/association/control_packet.h
  84. 187 0
      Sunrise/src/middleware/gameplay/association/protected_datagram.cpp
  85. 59 0
      Sunrise/src/middleware/gameplay/association/protected_datagram.h
  86. 138 0
      Sunrise/src/middleware/gameplay/association/srp_exchange.cpp
  87. 41 0
      Sunrise/src/middleware/gameplay/association/srp_exchange.h
  88. 106 0
      Sunrise/src/middleware/gameplay/descriptor/join_descriptor.cpp
  89. 48 0
      Sunrise/src/middleware/gameplay/descriptor/join_descriptor.h
  90. 76 0
      Sunrise/src/middleware/gameplay/dtls/association_keys.cpp
  91. 37 0
      Sunrise/src/middleware/gameplay/dtls/association_keys.h
  92. 192 0
      Sunrise/src/middleware/gameplay/dtls/dtls_messages.cpp
  93. 134 0
      Sunrise/src/middleware/gameplay/dtls/dtls_messages.h
  94. 250 0
      Sunrise/src/middleware/gameplay/dtls/record.cpp
  95. 84 0
      Sunrise/src/middleware/gameplay/dtls/record.h
  96. 66 0
      Sunrise/src/middleware/gameplay/group/member_messages.cpp
  97. 58 0
      Sunrise/src/middleware/gameplay/group/member_messages.h
  98. 161 0
      Sunrise/src/middleware/gameplay/group/parameter_messages.cpp
  99. 101 0
      Sunrise/src/middleware/gameplay/group/parameter_messages.h
  100. 84 0
      Sunrise/src/middleware/gameplay/group/parameter_registry.cpp

+ 6 - 1
.gitignore

@@ -1,10 +1,15 @@
-/build/
 /.vs/
 /.idea/
 /.vscode/
+/.direnv/
+/.xwin-cache/
+/.cache/
 *.user
 *.suo
 *.userosscache
 *.sln.docstates
 *.aps
 *.log
+
+# CMake build files
+/build/

+ 108 - 0
CMakeLists.txt

@@ -0,0 +1,108 @@
+cmake_minimum_required(VERSION 3.31)
+project(Sunrise CXX)
+
+set(CMAKE_CXX_STANDARD 20)
+set(CMAKE_CXX_STANDARD_REQUIRED ON)
+set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
+set(XWIN_SDK_VERSION "10.0.26100" CACHE STRING "xwin SDK Version")
+
+file(GLOB_RECURSE SUNRISE_SOURCES CONFIGURE_DEPENDS
+    "Sunrise/src/*.cpp"
+    "Sunrise/src/*.h"
+)
+file(GLOB_RECURSE VENDOR_SOURCES CONFIGURE_DEPENDS
+    "Sunrise/vendor/detours/*.cpp"
+    "Sunrise/vendor/detours/*.h"
+    "Sunrise/vendor/imgui/*.cpp"
+    "Sunrise/vendor/imgui/*.h"
+    "Sunrise/vendor/*/*LICENSE*"
+    "Sunrise/vendor/*/*LICENSE.txt"
+)
+file(GLOB_RECURSE RESOURCE_FILES CONFIGURE_DEPENDS
+    "Sunrise/resources/*.rc"
+    "Sunrise/resources/*.h"
+    "Sunrise/resources/*.json"
+)
+
+add_library(steam_api64 SHARED
+    ${SUNRISE_SOURCES}
+    ${VENDOR_SOURCES}
+    ${RESOURCE_FILES}
+)
+
+set_property(TARGET steam_api64 PROPERTY
+    MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>"
+)
+
+include(CheckIPOSupported)
+check_ipo_supported(RESULT lto_supported)
+if(lto_supported)
+    set_property(TARGET steam_api64 APPEND PROPERTY
+        INTERPROCEDURAL_OPTIMIZATION_RELEASE TRUE
+    )
+endif()
+target_include_directories(steam_api64
+    PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/Sunrise/src"
+)
+# sunrise.rc names its payloads relative to itself, so its directory has to be on the include path.
+target_include_directories(steam_api64
+    PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/Sunrise/resources"
+)
+target_include_directories(steam_api64 SYSTEM PRIVATE
+    "${CMAKE_CURRENT_SOURCE_DIR}/Sunrise/vendor/detours"
+    "${CMAKE_CURRENT_SOURCE_DIR}/Sunrise/vendor/imgui"
+    "${CMAKE_CURRENT_SOURCE_DIR}/Sunrise/vendor/imgui/backends"
+)
+
+if(DEFINED XWIN_DIR)
+    target_include_directories(steam_api64 SYSTEM PRIVATE
+        "${XWIN_DIR}/sdk/Include/${XWIN_SDK_VERSION}/um"
+        "${XWIN_DIR}/sdk/Include/${XWIN_SDK_VERSION}/shared"
+    )
+endif()
+
+target_compile_definitions(steam_api64 PRIVATE
+    WIN32
+    _WINDOWS
+    _USRDLL
+    WIN32_LEAN_AND_MEAN
+    NOMINMAX
+    UNICODE
+    _UNICODE
+    _WINSOCK_DEPRECATED_NO_WARNINGS
+    $<$<CONFIG:Debug>:_DEBUG>
+    $<$<CONFIG:Release>:NDEBUG>
+    IMGUI_USER_CONFIG="core/ui/imgui_user_config.h"
+)
+
+if(MSVC OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
+    # Clang warnings are more strict the MSVC I guess, this only works on real clang
+    if(CMAKE_CXX_COMPILER_ID MATCHES "Clang")
+        list(APPEND STRICT_WARNING_FLAGS
+            -Wno-unused-const-variable
+            -Wno-unused-function
+            -Wno-braced-scalar-init
+            -Wno-missing-braces
+            -Wno-missing-designated-field-initializers
+            -Wno-cast-function-type-mismatch
+        )
+    endif()
+
+    set_source_files_properties(${SUNRISE_SOURCES} PROPERTIES
+        COMPILE_OPTIONS "${STRICT_WARNING_FLAGS}"
+    )
+    set_source_files_properties(${VENDOR_SOURCES} PROPERTIES
+        COMPILE_OPTIONS "/W3"
+    )
+endif()
+
+target_link_libraries(steam_api64 PRIVATE
+    kernel32 bcrypt user32 gdi32 dwmapi
+    d3dcompiler shell32 ws2_32 synchronization
+)
+
+set_target_properties(steam_api64 PROPERTIES
+    RUNTIME_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/build/x64/$<CONFIG>"
+    LIBRARY_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/build/x64/$<CONFIG>"
+    ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/build/x64/$<CONFIG>"
+)

+ 106 - 5
Sunrise/Sunrise.vcxproj

@@ -1,4 +1,4 @@
-<?xml version="1.0" encoding="utf-8"?>
+<?xml version="1.0" encoding="utf-8"?>
 <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
   <ItemGroup Label="ProjectConfigurations">
     <ProjectConfiguration Include="Debug|x64">
@@ -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>
@@ -195,8 +198,9 @@
     <ClCompile Include="src\client\hooks\egress\winsock\transmission\egress_datagram_transmission.cpp" />
     <ClCompile Include="src\client\hooks\egress\winsock\transmission\egress_message_transmission.cpp" />
     <ClCompile Include="src\client\ui\runtime\client_ui_module_runtime.cpp" />
-    <ClCompile Include="src\client\ui\teleport\teleport_panel.cpp" />
-    <ClCompile Include="src\client\teleport\teleport_settings_store.cpp" />
+    <ClCompile Include="src\client\ui\movement\movement_panel.cpp" />
+    <ClCompile Include="src\client\movement\movement_settings_store.cpp" />
+    <ClCompile Include="src\client\hooks\noclip\horizontal_noclip.cpp" />
     <ClCompile Include="src\client\hooks\teleport\teleport_lifecycle.cpp" />
     <ClCompile Include="src\client\hooks\teleport\teleport_move.cpp" />
     <ClCompile Include="src\client\hooks\teleport\teleport_action_key.cpp" />
@@ -356,6 +360,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" />
@@ -392,6 +397,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" />
@@ -626,6 +635,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" />
@@ -695,10 +747,11 @@
     <ClInclude Include="src\core\ui\layout\navigation\ui_layout_navigation.h" />
     <ClInclude Include="src\core\ui\layout\credits\sunrise_credits_badge.h" />
     <ClInclude Include="src\client\runtime\runtime.h" />
-    <ClInclude Include="src\client\teleport\teleport_settings_store.h" />
+    <ClInclude Include="src\client\movement\movement_settings_store.h" />
+    <ClInclude Include="src\client\hooks\noclip\runtime.h" />
     <ClInclude Include="src\client\hooks\teleport\internal.h" />
     <ClInclude Include="src\client\hooks\teleport\runtime.h" />
-    <ClInclude Include="src\client\ui\teleport\teleport_panel.h" />
+    <ClInclude Include="src\client\ui\movement\movement_panel.h" />
     <ClInclude Include="src\client\runtime\internal.h" />
     <ClInclude Include="src\client\runtime\host\game_host_classification.h" />
     <ClInclude Include="src\client\hooks\graphics\graphics_hook_lifecycle.h" />
@@ -838,6 +891,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" />
@@ -1078,6 +1136,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>

+ 11 - 1
Sunrise/resources/default_settings.json

@@ -1,5 +1,5 @@
 {
-  "version": 4,
+  "version": 5,
   "core": {
     "logging": {
       "debugger_sink": true,
@@ -14,6 +14,7 @@
     }
   },
   "steam": {
+    "language": "english",
     "user": {
       "persona_name": "Player"
     }
@@ -31,12 +32,21 @@
     },
     "fade_release": true,
     "force_join_request_ready": true,
+    "region_private": false,
     "pin_replicated_record": true,
     "hold_spawn": true,
     "spawn_hold_ms": 30000
   },
   "server": {
     "bap_port": 30974,
+    "gameplay": {
+      "topology": "embedded",
+      "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

+ 15 - 15
Sunrise/src/client/hooks/assert_handler/assert_handler_lifecycle.cpp

@@ -11,21 +11,16 @@ bool g_installed{};
 
 namespace {
 
-/**
- * Exchanges the handler slot under its own page protection.
- * @param slot Resolved handler slot.
- * @param expected Value the slot must currently hold.
- * @param value Replacement pointer.
- * @return True when the slot holds the replacement after the call.
- */
-[[nodiscard]] bool exchange(std::byte** slot, const void* expected, void* value) noexcept {
-    if (slot == nullptr || *slot != expected) {
+/** Calls the native setter after the expected-value ownership check. */
+[[nodiscard]] bool set_handler(const targets::game::assert_handler::Targets& targets,
+                               const void* expected,
+                               void* value) {
+    if (targets.setter == nullptr || targets.slot == nullptr
+        || static_cast<const void*>(*targets.slot) != expected) {
         return false;
     }
-    // The slot is a writable data global, so the write needs no protection change. The game's own
-    // setter stores it with a plain move. TODO: call that setter instead of writing the slot.
-    *slot = static_cast<std::byte*>(value);
-    return true;
+    targets.setter(value);
+    return static_cast<void*>(*targets.slot) == value;
 }
 
 } // namespace
@@ -45,7 +40,7 @@ bool install() noexcept {
         return true;
     }
     const targets::game::assert_handler::Targets& resolved = targets::game::assert_handler::get();
-    const bool installed = exchange(resolved.slot, resolved.original, handler_entry_point());
+    const bool installed = set_handler(resolved, resolved.original, handler_entry_point());
     g_installed = installed;
     ReleaseSRWLockExclusive(&g_lock);
     core::log::write(core::log::Channel::client,
@@ -63,9 +58,14 @@ bool uninstall() noexcept {
         return true;
     }
     const targets::game::assert_handler::Targets& resolved = targets::game::assert_handler::get();
-    const bool restored = exchange(resolved.slot, handler_entry_point(), resolved.original);
+    const bool restored = set_handler(resolved, handler_entry_point(), resolved.original);
     g_installed = !restored;
     ReleaseSRWLockExclusive(&g_lock);
+    if (!restored) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         "ev=assert stage=uninstall result=fail reason=slot");
+    }
     return restored;
 }
 

+ 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;
 }
 

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

@@ -7,6 +7,8 @@
 #include <string_view>
 
 #include "../../../core/logging/log.h"
+#include "../../../core/settings/settings.h"
+#include "../../../state/activity/forced/activity_forced_destination.h"
 #include "../../hooking/detour.h"
 #include "internal.h"
 
@@ -86,10 +88,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 +100,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 +112,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. The answer
+ * is public unless `client.region_private` is on, or a destination is forced.
  * @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 +129,11 @@ __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;
+    // No public host serves a forced destination, so that run waits forever. It must load solo.
+    const bool forced =
+        core::settings::get().client.regionPrivate || state::activity::forced::override_active();
+    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);

+ 334 - 0
Sunrise/src/client/hooks/noclip/horizontal_noclip.cpp

@@ -0,0 +1,334 @@
+/**
+ * Horizontal noclip at the Havok simulation boundary. The hook reads native horizontal velocity
+ * before simulation, lets Havok run normally, then replaces the character body's resolved X/Y
+ * position before Destiny publishes it.
+ */
+
+#include <Windows.h>
+
+#include <algorithm>
+#include <array>
+#include <atomic>
+#include <bit>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <string_view>
+
+#include "../../../core/logging/log.h"
+#include "../../../core/ui/runtime/ui_visibility_runtime.h"
+#include "../../hooking/detour.h"
+#include "../../movement/movement_settings_store.h"
+#include "../../patterns/image_scan.h"
+#include "runtime.h"
+
+namespace sunrise::client::hooks::noclip {
+namespace {
+
+/** hkpSimulation::stepDeltaTime, which encloses Havok integration and collision resolution. */
+constexpr std::string_view kHavokStepText =
+    "40 53 48 83 EC 20 83 79 38 01 48 8B D9 77 06 48 8B 01 FF 50 20 F7 43 38 FD FF FF FF "
+    "75 09 48 8B 03 48 8B CB FF 50 28";
+/** Masked form of the step text. This is the form the image scan takes. */
+constexpr auto kHavokStep =
+    patterns::signature<patterns::signature_length(kHavokStepText)>(kHavokStepText);
+
+/** Constructs hkpCharacterMotion and loads its vtable through the leading RIP-relative LEA. */
+constexpr std::string_view kCharacterMotionVtableText = "48 8D 05 ? ? ? ? C6 43 ? ? 48 89 03 EB";
+constexpr auto kCharacterMotionVtable =
+    patterns::signature<patterns::signature_length(kCharacterMotionVtableText)>(
+        kCharacterMotionVtableText);
+
+/** RIP-relative displacement and instruction-end offsets in the vtable LEA. */
+constexpr std::size_t kVtableDisplacement = 3;
+constexpr std::size_t kVtableInstructionLength = 7;
+
+/** hkpSimulation::m_world. */
+constexpr std::size_t kSimulationWorld = 0x18;
+/** hkpWorld simulation-island arrays. */
+constexpr std::array<std::size_t, 2> kWorldIslandArrays{0x40, 0x50};
+/** hkpSimulationIsland::m_entities. */
+constexpr std::size_t kIslandEntities = 0x60;
+/** hkpRigidBody's embedded hkpMotion object. */
+constexpr std::size_t kBodyMotion = 0x150;
+/** World position and linear velocity in the embedded motion. */
+constexpr std::size_t kBodyPosition = 0x1C0;
+constexpr std::size_t kBodyVelocity = 0x230;
+
+/** X and Y are Destiny's horizontal world-space lanes. */
+constexpr std::size_t kHorizontalX = 0;
+constexpr std::size_t kHorizontalY = 1;
+constexpr std::size_t kVectorLanes = 4;
+
+/**
+ * Havok stores ownership flags in the upper two capacity bits. Masking them leaves the allocation
+ * capacity used to validate an hkArray before walking it.
+ */
+constexpr std::uint32_t kArrayCapacityMask = 0x3FFFFFFF;
+/** Defensive limits for game-owned island and entity arrays. */
+constexpr std::int32_t kMaximumIslandCount = 4096;
+constexpr std::int32_t kMaximumEntityCount = 65536;
+/** Maximum accepted physics step; a resumed or stalled frame must not produce a large jump. */
+constexpr float kMaximumStepSeconds = 0.05F;
+/** Native velocity below this magnitude is treated as stationary. */
+constexpr float kMinimumVelocitySquared = 0.000001F;
+
+using HavokStep = std::int32_t(__fastcall*)(std::byte*, float);
+
+struct HavokArray {
+    std::byte** entries{};
+    std::int32_t size{};
+    std::uint32_t capacityAndFlags{};
+};
+
+/** hkArray packs its pointer, size and capacity into 16 bytes with no tail padding. */
+constexpr std::size_t kHavokArrayBytes = 16;
+static_assert(sizeof(HavokArray) == kHavokArrayBytes);
+
+std::atomic_bool g_installed{false};
+std::atomic_bool g_toggleDown{false};
+std::atomic_bool g_targetValid{false};
+/** The two target floats are one atomic publication, so readers never observe mixed coordinates. */
+std::atomic<std::uint64_t> g_horizontalTarget{};
+/** Module-owned vtable target; unlike Havok objects, its address is stable until DLL teardown. */
+std::uintptr_t g_characterMotionVtable{};
+hooking::detour::Handle g_stepHandle{};
+
+/** Views one field while its owning Havok object is live inside the simulation hook. */
+template <typename T> [[nodiscard]] T& field(std::byte* object, std::size_t offset) noexcept {
+    return *reinterpret_cast<T*>(object + offset);
+}
+
+/** @return True when the array header is internally consistent and within the supplied bound. */
+[[nodiscard]] bool valid_array(const HavokArray& array, std::int32_t maximum) noexcept {
+    const std::uint32_t capacity = array.capacityAndFlags & kArrayCapacityMask;
+    return array.size >= 0 && array.size <= maximum
+           && static_cast<std::uint32_t>(array.size) <= capacity
+           && (array.size == 0 || array.entries != nullptr);
+}
+
+/** @return The character rigid body in one island, or null when the island has none. */
+[[nodiscard]] std::byte* character_body_in(std::byte* island) noexcept {
+    if (island == nullptr) {
+        return nullptr;
+    }
+    const HavokArray& entities = field<HavokArray>(island, kIslandEntities);
+    if (!valid_array(entities, kMaximumEntityCount)) {
+        return nullptr;
+    }
+    for (std::int32_t index = 0; index < entities.size; ++index) {
+        std::byte* const body = entities.entries[index];
+        if (body != nullptr
+            && field<std::uintptr_t>(body, kBodyMotion) == g_characterMotionVtable) {
+            return body;
+        }
+    }
+    return nullptr;
+}
+
+/** @return The current character rigid body, resolved fresh from active and inactive islands. */
+[[nodiscard]] std::byte* character_body(std::byte* simulation) noexcept {
+    if (simulation == nullptr) {
+        return nullptr;
+    }
+    std::byte* const world = field<std::byte*>(simulation, kSimulationWorld);
+    if (world == nullptr) {
+        return nullptr;
+    }
+    for (const std::size_t offset : kWorldIslandArrays) {
+        const HavokArray& islands = field<HavokArray>(world, offset);
+        if (!valid_array(islands, kMaximumIslandCount)) {
+            continue;
+        }
+        for (std::int32_t index = 0; index < islands.size; ++index) {
+            std::byte* const island = islands.entries[index];
+            if (std::byte* const body = character_body_in(island); body != nullptr) {
+                return body;
+            }
+        }
+    }
+    return nullptr;
+}
+
+/** Packs the horizontal target into one atomic value. */
+[[nodiscard]] std::uint64_t pack_target(float x, float y) noexcept {
+    return std::bit_cast<std::uint64_t>(std::array<float, 2>{x, y});
+}
+
+/** Unpacks one atomically published horizontal target. */
+[[nodiscard]] std::array<float, 2> unpack_target(std::uint64_t value) noexcept {
+    return std::bit_cast<std::array<float, 2>>(value);
+}
+
+/**
+ * Polls the bound key on the physics thread and flips the stored switch when it goes down.
+ * The key and the interface toggle write the same stored value, so there is one on/off state.
+ * @return True while noclip is on.
+ */
+[[nodiscard]] bool poll_toggle() noexcept {
+    const client::movement::Settings settings = client::movement::get();
+    if (settings.noclipToggleKey == client::movement::kNoKey) {
+        g_toggleDown.store(false, std::memory_order_relaxed);
+        return settings.noclipEnabled;
+    }
+    const bool down = (GetAsyncKeyState(static_cast<int>(settings.noclipToggleKey)) & 0x8000) != 0;
+    // An open interface owns the keyboard, so the bound key only tracks the press, it never flips.
+    if (core::ui::runtime::snapshot().visible) {
+        g_toggleDown.store(down, std::memory_order_relaxed);
+        return settings.noclipEnabled;
+    }
+    if (down && !g_toggleDown.exchange(true, std::memory_order_acq_rel)) {
+        client::movement::Settings updated = settings;
+        updated.noclipEnabled = !settings.noclipEnabled;
+        if (!client::movement::publish(updated)) {
+            return settings.noclipEnabled;
+        }
+        invalidate_target();
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         updated.noclipEnabled
+                             ? "ev=noclip stage=toggle enabled=1 mode=rigid_body_position"
+                             : "ev=noclip stage=toggle enabled=0 mode=rigid_body_position");
+        return updated.noclipEnabled;
+    }
+    if (!down) {
+        g_toggleDown.store(false, std::memory_order_release);
+    }
+    return settings.noclipEnabled;
+}
+
+/** @return True while the stored switch has noclip on. */
+[[nodiscard]] bool enabled() noexcept {
+    return client::movement::get().noclipEnabled;
+}
+
+/** Runs Havok normally, then replaces collision-resolved horizontal position for the character. */
+std::int32_t __fastcall havok_step(std::byte* simulation, float deltaTime) noexcept {
+    std::array<float, kVectorLanes> nativeVelocity{};
+    const bool enabledBeforeStep = poll_toggle();
+    std::byte* const before = enabledBeforeStep ? character_body(simulation) : nullptr;
+    const bool hasVelocity = before != nullptr;
+    if (hasVelocity) {
+        nativeVelocity = field<std::array<float, kVectorLanes>>(before, kBodyVelocity);
+    }
+
+    const HavokStep next = reinterpret_cast<HavokStep>(g_stepHandle.original);
+    const std::int32_t result = next != nullptr ? next(simulation, deltaTime) : 0;
+
+    // Re-read after the step, so a toggle from the interface thread lands before a position write.
+    if (!enabledBeforeStep || !enabled()) {
+        return result;
+    }
+    std::byte* const body = character_body(simulation);
+    if (body == nullptr) {
+        // Other Havok worlds do not contain the player and must not disturb the shared target.
+        // Only a world that contained the character before this step can prove it was removed.
+        if (before != nullptr) {
+            invalidate_target();
+        }
+        return result;
+    }
+    std::array<float, kVectorLanes> position =
+        field<std::array<float, kVectorLanes>>(body, kBodyPosition);
+    // A character created or replaced during this step has no compatible velocity or target.
+    if (before == nullptr || body != before) {
+        invalidate_target();
+    }
+    if (!g_targetValid.load(std::memory_order_acquire)) {
+        g_horizontalTarget.store(pack_target(position[kHorizontalX], position[kHorizontalY]),
+                                 std::memory_order_relaxed);
+        g_targetValid.store(true, std::memory_order_release);
+        return result;
+    }
+    if (!hasVelocity) {
+        return result;
+    }
+
+    const float step = std::clamp(deltaTime, 0.0F, kMaximumStepSeconds);
+    const float velocitySquared = nativeVelocity[kHorizontalX] * nativeVelocity[kHorizontalX]
+                                  + nativeVelocity[kHorizontalY] * nativeVelocity[kHorizontalY];
+    if (step <= 0.0F || velocitySquared <= kMinimumVelocitySquared) {
+        return result;
+    }
+    const std::array<float, 2> target =
+        unpack_target(g_horizontalTarget.load(std::memory_order_acquire));
+    const float targetX = target[kHorizontalX] + nativeVelocity[kHorizontalX] * step;
+    const float targetY = target[kHorizontalY] + nativeVelocity[kHorizontalY] * step;
+    position[kHorizontalX] = targetX;
+    position[kHorizontalY] = targetY;
+    field<std::array<float, kVectorLanes>>(body, kBodyPosition) = position;
+
+    // Collision may consume horizontal velocity before publication. Restore the pre-simulation
+    // velocity so the next step keeps advancing the target, while retaining resolved vertical
+    // velocity for ordinary ground movement and gravity.
+    std::array<float, kVectorLanes> wakeVelocity =
+        field<std::array<float, kVectorLanes>>(body, kBodyVelocity);
+    wakeVelocity[kHorizontalX] = nativeVelocity[kHorizontalX];
+    wakeVelocity[kHorizontalY] = nativeVelocity[kHorizontalY];
+    field<std::array<float, kVectorLanes>>(body, kBodyVelocity) = wakeVelocity;
+    g_horizontalTarget.store(pack_target(targetX, targetY), std::memory_order_release);
+    return result;
+}
+
+/** @param reason Stable diagnostic key for an installation failure. */
+void report_install_failure(const char* reason) noexcept {
+    std::array<char, 96> line{};
+    const int written = std::snprintf(
+        line.data(), line.size(), "ev=noclip stage=install result=fail reason=%s", reason);
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+} // namespace
+
+/** Resolves the Havok targets and attaches the simulation-step detour. */
+bool install() noexcept {
+    if (g_installed.load(std::memory_order_acquire)) {
+        return true;
+    }
+    std::byte* const step = patterns::scan_main_image_unique(kHavokStep, "noclip_havok_step");
+    if (step == nullptr) {
+        report_install_failure("havok_step");
+        return false;
+    }
+    std::byte* const vtableLoad =
+        patterns::scan_main_image_unique(kCharacterMotionVtable, "noclip_character_motion");
+    if (vtableLoad == nullptr) {
+        report_install_failure("character_motion");
+        return false;
+    }
+    g_characterMotionVtable = reinterpret_cast<std::uintptr_t>(patterns::resolve_relative(
+        vtableLoad + kVtableDisplacement, vtableLoad + kVtableInstructionLength));
+    if (!hooking::detour::install(hooking::detour::Spec{step, reinterpret_cast<void*>(&havok_step)},
+                                  g_stepHandle)) {
+        g_characterMotionVtable = 0;
+        report_install_failure("attach");
+        return false;
+    }
+    g_installed.store(true, std::memory_order_release);
+    core::log::write(
+        core::log::Channel::client, core::log::Level::info, "ev=noclip stage=install result=ok");
+    return true;
+}
+
+/** Detaches the simulation-step detour, then clears the toggle and the horizontal target. */
+void uninstall() noexcept {
+    if (!g_installed.exchange(false, std::memory_order_acq_rel)) {
+        return;
+    }
+    (void)hooking::detour::uninstall(g_stepHandle);
+    g_stepHandle = {};
+    g_characterMotionVtable = 0;
+    // The switch is a stored setting, so detaching clears only the key state and the target.
+    g_toggleDown.store(false, std::memory_order_release);
+    invalidate_target();
+}
+
+void invalidate_target() noexcept {
+    g_targetValid.store(false, std::memory_order_release);
+}
+
+} // namespace sunrise::client::hooks::noclip

+ 17 - 0
Sunrise/src/client/hooks/noclip/runtime.h

@@ -0,0 +1,17 @@
+#pragma once
+
+namespace sunrise::client::hooks::noclip {
+
+/**
+ * Resolves the Havok targets and attaches the independent simulation-step detour.
+ * @return True when both signatures resolve and the detour is attached.
+ */
+[[nodiscard]] bool install() noexcept;
+
+/** Detaches the simulation-step detour and clears runtime state. */
+void uninstall() noexcept;
+
+/** Invalidates the horizontal target after another feature changes rigid-body position. */
+void invalidate_target() noexcept;
+
+} // namespace sunrise::client::hooks::noclip

+ 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>(

+ 9 - 5
Sunrise/src/client/hooks/teleport/teleport_move.cpp

@@ -16,7 +16,8 @@
 #include "../../../core/ui/runtime/ui_visibility_runtime.h"
 #include "../../../state/account/account_state.h"
 #include "../../../state/runtime/runtime.h"
-#include "../../teleport/teleport_settings_store.h"
+#include "../../movement/movement_settings_store.h"
+#include "../noclip/runtime.h"
 #include "../polled_input/runtime.h"
 #include "internal.h"
 #include "runtime.h"
@@ -317,7 +318,7 @@ void set_vertical_velocity(std::byte* body, float value) noexcept {
     }
     report_gates(component, body);
     set_vertical_velocity(body, 0.0F);
-    if (!move_body(body, client::teleport::get().distance)) {
+    if (!move_body(body, client::movement::get().distance)) {
         return false;
     }
     begin_press();
@@ -365,8 +366,8 @@ void capture_forward(std::uint32_t playerIndex) noexcept {
 void poll_request() noexcept {
     end_press();
     expire_request();
-    const client::teleport::Settings settings = client::teleport::get();
-    const bool usable = settings.enabled && settings.virtualKey != client::teleport::kNoKey;
+    const client::movement::Settings settings = client::movement::get();
+    const bool usable = settings.enabled && settings.virtualKey != client::movement::kNoKey;
     g_active.store(usable, std::memory_order_relaxed);
     if (!usable) {
         g_keyDown.store(false, std::memory_order_relaxed);
@@ -407,7 +408,9 @@ void apply_pending(void* component) noexcept {
         return;
     }
     g_requested.store(false, std::memory_order_release);
-    (void)perform_move(physics);
+    if (perform_move(physics)) {
+        noclip::invalidate_target();
+    }
 }
 
 /** Runs the move for a request no physics tick collected. */
@@ -426,6 +429,7 @@ void force_pending() noexcept {
     if (!perform_move(physics)) {
         return;
     }
+    noclip::invalidate_target();
     invoke_sync(physics);
     core::log::write(
         core::log::Channel::client, core::log::Level::info, "ev=teleport stage=force result=ok");

+ 23 - 11
Sunrise/src/client/teleport/teleport_settings_store.cpp → Sunrise/src/client/movement/movement_settings_store.cpp

@@ -1,10 +1,10 @@
 /**
- * The teleport configuration store. It is separate from Core settings because the interface
+ * The movement configuration store. It is separate from Core settings because the interface
  * changes these values while the game runs and saves each change at once. Core settings are
- * parsed once into an immutable global.
+ * read once and never change after that.
  */
 
-#include "teleport_settings_store.h"
+#include "movement_settings_store.h"
 
 #include <Windows.h>
 
@@ -17,12 +17,12 @@
 #include "../../core/filesystem/path.h"
 #include "../../core/logging/log.h"
 
-namespace sunrise::client::teleport {
+namespace sunrise::client::movement {
 namespace {
 
 /** The module-owned configuration file, beside the generated settings and logs. */
-constexpr std::wstring_view kFileSuffix = L"\\teleport.json";
-/** The document is 3 scalars, so one small buffer covers both reading and writing. */
+constexpr std::wstring_view kFileSuffix = L"\\movement.json";
+/** The document is a few scalars, so one small buffer covers both reading and writing. */
 constexpr std::size_t kFileCapacity = 512;
 /** Longest scalar accepted from the file. Anything longer is malformed rather than large. */
 constexpr std::size_t kScalarCapacity = 32;
@@ -37,14 +37,15 @@ bool g_pathResolved{};
 /** @param settings Candidate configuration. @return True when every field is in range. */
 [[nodiscard]] bool valid(const Settings& settings) noexcept {
     return settings.distance >= kMinimumDistance && settings.distance <= kMaximumDistance
-           && settings.virtualKey <= kMaximumVirtualKey;
+           && settings.virtualKey <= kMaximumVirtualKey
+           && settings.noclipToggleKey <= kMaximumVirtualKey;
 }
 
 /** @param reason Key naming the step that failed. */
 void report_fail(const char* reason) noexcept {
     std::array<char, 96> line{};
     const int written = std::snprintf(
-        line.data(), line.size(), "ev=teleport stage=store result=fail reason=%s", reason);
+        line.data(), line.size(), "ev=movement stage=store result=fail reason=%s", reason);
     if (written > 0) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,
@@ -118,6 +119,13 @@ void parse(std::string_view text, Settings& output) noexcept {
     if (scalar_for(text, "\"virtual_key\"", scalar) && terminated(scalar, buffer)) {
         output.virtualKey = static_cast<std::uint32_t>(std::strtoul(buffer.data(), nullptr, 0));
     }
+    if (scalar_for(text, "\"noclip_enabled\"", scalar)) {
+        output.noclipEnabled = scalar.starts_with("true");
+    }
+    if (scalar_for(text, "\"noclip_toggle_key\"", scalar) && terminated(scalar, buffer)) {
+        output.noclipToggleKey =
+            static_cast<std::uint32_t>(std::strtoul(buffer.data(), nullptr, 0));
+    }
 }
 
 /**
@@ -134,10 +142,14 @@ void parse(std::string_view text, Settings& output) noexcept {
     const int size = std::snprintf(document.data(),
                                    document.size(),
                                    "{\n  \"enabled\": %s,\n  \"distance\": %.3f,\n"
-                                   "  \"virtual_key\": %u\n}\n",
+                                   "  \"virtual_key\": %u,\n"
+                                   "  \"noclip_enabled\": %s,\n"
+                                   "  \"noclip_toggle_key\": %u\n}\n",
                                    settings.enabled ? "true" : "false",
                                    static_cast<double>(settings.distance),
-                                   static_cast<unsigned>(settings.virtualKey));
+                                   static_cast<unsigned>(settings.virtualKey),
+                                   settings.noclipEnabled ? "true" : "false",
+                                   static_cast<unsigned>(settings.noclipToggleKey));
     if (size <= 0) {
         return false;
     }
@@ -237,4 +249,4 @@ bool publish(const Settings& settings) noexcept {
     return true;
 }
 
-} // namespace sunrise::client::teleport
+} // namespace sunrise::client::movement

+ 6 - 4
Sunrise/src/client/teleport/teleport_settings_store.h → Sunrise/src/client/movement/movement_settings_store.h

@@ -2,7 +2,7 @@
 
 #include <cstdint>
 
-namespace sunrise::client::teleport {
+namespace sunrise::client::movement {
 
 /** Default distance, in world units along the camera's forward vector. */
 inline constexpr float kDefaultDistance = 10.0F;
@@ -10,14 +10,16 @@ inline constexpr float kDefaultDistance = 10.0F;
 inline constexpr float kMinimumDistance = 1.0F;
 /** Largest offered distance. Past this a press reliably lands through a wall or the floor. */
 inline constexpr float kMaximumDistance = 100.0F;
-/** No key is bound until one is picked, so a fresh install cannot teleport by accident. */
+/** No key is bound until one is picked, so a fresh install cannot fire a movement feature. */
 inline constexpr std::uint32_t kNoKey = 0;
 
-/** Runtime teleport configuration. This module owns it; Core settings do not carry it. */
+/** Runtime movement configuration. This module owns it; Core settings do not carry it. */
 struct Settings {
     bool enabled{false};
     float distance{kDefaultDistance};
     std::uint32_t virtualKey{kNoKey};
+    bool noclipEnabled{false};
+    std::uint32_t noclipToggleKey{kNoKey};
 };
 
 /**
@@ -39,4 +41,4 @@ void shutdown() noexcept;
  */
 bool publish(const Settings& settings) noexcept;
 
-} // namespace sunrise::client::teleport
+} // namespace sunrise::client::movement

+ 88 - 29
Sunrise/src/client/patterns/registry.cpp

@@ -1,23 +1,41 @@
 #include "registry.h"
 
+#include <cstring>
+
 namespace sunrise::client::patterns {
 namespace {
 
+/** Returned by next_candidate when a range holds no further anchor byte. */
+constexpr std::size_t kNoCandidate = static_cast<std::size_t>(-1);
+
+/**
+ * The one exact byte a pattern's candidate search keys on.
+ * A pattern with no exact byte cannot be scanned, so the anchor doubles as the validity check.
+ */
+struct Anchor {
+    /** Position of the anchor byte inside the pattern. */
+    std::size_t index{};
+    /** The byte itself, held unsigned so it reaches memchr without sign extension. */
+    unsigned char value{};
+    /** False for a pattern the sweep cannot scan, which is the pattern it must reject. */
+    bool valid{};
+};
+
 /**
- * Validates one pattern before the shared image sweep.
+ * Picks the anchor byte for one pattern.
  * @param pattern Pattern name, bytes, and exact-byte mask.
- * @return True when the pattern has a name, bytes, and at least one exact byte.
+ * @return A valid anchor when the pattern has a name, bytes, and at least one exact byte.
  */
-[[nodiscard]] bool is_valid(const Pattern& pattern) noexcept {
+[[nodiscard]] Anchor anchor_of(const Pattern& pattern) noexcept {
     if (pattern.name.empty() || pattern.bytes.empty()) {
-        return false;
+        return {};
     }
-    for (const PatternByte byte : pattern.bytes) {
-        if (byte.exact) {
-            return true;
+    for (std::size_t index = 0; index < pattern.bytes.size(); ++index) {
+        if (pattern.bytes[index].exact) {
+            return Anchor{index, std::to_integer<unsigned char>(pattern.bytes[index].value), true};
         }
     }
-    return false;
+    return {};
 }
 
 /**
@@ -29,6 +47,7 @@ namespace {
 [[nodiscard]] bool matches_at(std::span<const std::byte> image,
                               std::size_t offset,
                               std::span<const PatternByte> pattern) noexcept {
+    // next_candidate never returns an offset past the last whole match, so this cannot wrap.
     if (pattern.size() > image.size() - offset) {
         return false;
     }
@@ -40,9 +59,41 @@ namespace {
     return true;
 }
 
+/**
+ * Finds the next offset at or after one start where the anchor byte lines up.
+ * The bytes in between cannot begin a match, so memchr skips them at memory speed instead of the
+ * sweep testing every one of them.
+ * @param range One executable range.
+ * @param patternSize Pattern length, which bounds the last offset that can hold a whole match.
+ * @param anchor Valid anchor for that pattern.
+ * @param from First offset to consider.
+ * @return Candidate offset, or kNoCandidate when the range holds no further one.
+ */
+[[nodiscard]] std::size_t next_candidate(std::span<const std::byte> range,
+                                         std::size_t patternSize,
+                                         const Anchor& anchor,
+                                         std::size_t from) noexcept {
+    if (patternSize > range.size()) {
+        return kNoCandidate;
+    }
+    const std::size_t lastOffset = range.size() - patternSize;
+    if (from > lastOffset) {
+        return kNoCandidate;
+    }
+    // The anchor sits at offset + index, so the search window is the offset window shifted by it.
+    const std::size_t first = from + anchor.index;
+    const std::size_t last = lastOffset + anchor.index;
+    const void* const hit = std::memchr(range.data() + first, anchor.value, last - first + 1);
+    if (hit == nullptr) {
+        return kNoCandidate;
+    }
+    const auto* const found = static_cast<const std::byte*>(hit);
+    return static_cast<std::size_t>(found - range.data()) - anchor.index;
+}
+
 } // namespace
 
-/** Resolves all patterns during one sweep over one executable range. */
+/** Resolves every registered pattern against one executable range. */
 bool resolve_all(std::span<std::byte> image,
                  std::span<const Pattern> patterns,
                  std::span<Match> matches) noexcept {
@@ -50,7 +101,7 @@ bool resolve_all(std::span<std::byte> image,
     return resolve_all(std::span(&range, 1), patterns, matches);
 }
 
-/** Resolves all patterns during one sweep over disjoint executable ranges. */
+/** Resolves every pattern across disjoint executable image ranges. */
 bool resolve_all(std::span<const ImageRange> image,
                  std::span<const Pattern> patterns,
                  std::span<Match> matches) noexcept {
@@ -59,27 +110,32 @@ bool resolve_all(std::span<const ImageRange> image,
     }
 
     for (std::size_t index = 0; index < patterns.size(); ++index) {
-        matches[index] = is_valid(patterns[index]) ? Match{MatchStatus::missing, nullptr} : Match{};
-    }
+        const Anchor anchor = anchor_of(patterns[index]);
+        matches[index] = anchor.valid ? Match{MatchStatus::missing, nullptr} : Match{};
+        if (!anchor.valid) {
+            continue;
+        }
 
-    for (const ImageRange range : image) {
-        for (std::size_t offset = 0; offset < range.bytes.size(); ++offset) {
-            for (std::size_t index = 0; index < patterns.size(); ++index) {
-                Match& match = matches[index];
-                if (match.status == MatchStatus::invalid
-                    || match.status == MatchStatus::ambiguous) {
-                    continue;
-                }
-                if (!matches_at(range.bytes, offset, patterns[index].bytes)) {
-                    continue;
+        Match& match = matches[index];
+        const std::span<const PatternByte> bytes = patterns[index].bytes;
+        for (const ImageRange range : image) {
+            std::size_t offset = 0;
+            while (match.status != MatchStatus::ambiguous) {
+                offset = next_candidate(range.bytes, bytes.size(), anchor, offset);
+                if (offset == kNoCandidate) {
+                    break;
                 }
-
-                if (match.status == MatchStatus::missing) {
-                    match = Match{MatchStatus::unique, range.bytes.data() + offset};
-                } else {
+                if (matches_at(range.bytes, offset, bytes)) {
                     // A second match invalidates the address instead of choosing one.
-                    match = Match{MatchStatus::ambiguous, nullptr};
+                    match = match.status == MatchStatus::missing
+                                ? Match{MatchStatus::unique, range.bytes.data() + offset}
+                                : Match{MatchStatus::ambiguous, nullptr};
                 }
+                ++offset;
+            }
+            // Ambiguous is final, so the remaining ranges cannot change this pattern's result.
+            if (match.status == MatchStatus::ambiguous) {
+                break;
             }
         }
     }
@@ -90,12 +146,15 @@ bool resolve_all(std::span<const ImageRange> image,
 std::size_t collect_matches(std::span<const ImageRange> image,
                             const Pattern& pattern,
                             std::span<std::byte*> output) noexcept {
-    if (!is_valid(pattern) || output.empty()) {
+    const Anchor anchor = anchor_of(pattern);
+    if (!anchor.valid || output.empty()) {
         return 0;
     }
     std::size_t count = 0;
     for (const ImageRange range : image) {
-        for (std::size_t offset = 0; offset < range.bytes.size(); ++offset) {
+        for (std::size_t offset = next_candidate(range.bytes, pattern.bytes.size(), anchor, 0);
+             offset != kNoCandidate;
+             offset = next_candidate(range.bytes, pattern.bytes.size(), anchor, offset + 1)) {
             if (!matches_at(range.bytes, offset, pattern.bytes)) {
                 continue;
             }

+ 1 - 1
Sunrise/src/client/patterns/registry.h

@@ -37,7 +37,7 @@ struct ImageRange {
     std::span<std::byte> bytes;
 };
 
-/** Resolves every registered pattern during one image sweep. */
+/** Resolves every registered pattern against one executable range. */
 [[nodiscard]] bool resolve_all(std::span<std::byte> image,
                                std::span<const Pattern> patterns,
                                std::span<Match> matches) noexcept;

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

@@ -1,6 +1,7 @@
 #include <Windows.h>
 
 #include <array>
+#include <cstdint>
 #include <cstdio>
 #include <span>
 #include <string_view>
@@ -18,6 +19,7 @@
 #include "../hooks/cursor/runtime.h"
 #include "../hooks/graphics/graphics_hook_lifecycle.h"
 #include "../hooks/network/runtime.h"
+#include "../hooks/noclip/runtime.h"
 #include "../hooks/polled_input/runtime.h"
 #include "../hooks/queuez/queuez_hook_lifecycle.h"
 #include "../hooks/retail_log/retail_log_lifecycle.h"
@@ -157,6 +159,8 @@ void clear_game_targets() noexcept {
     // The teleport hooks attach whether or not the feature is on, so the interface can enable it
     // without a restart. Both replacements return immediately while nothing is requested.
     (void)hooks::teleport::install();
+    // Noclip owns its Havok-step target, so a patch-specific miss cannot disable teleport.
+    (void)hooks::noclip::install();
     (void)hooks::queuez::install();
     // The bitmap reference guard puts the none sentinel in place of a reference outside tag
     // space. Without it the widget's stored-reference reader faults.
@@ -179,10 +183,21 @@ bool activate_main_once() noexcept {
         ReleaseSRWLockExclusive(&runtime::g_lock);
         return active;
     }
+    // The image sweep dominates this call, so the pair of debug markers around it is what a
+    // boot-time measurement reads. Both are diagnostic and stay off at the usual levels.
+    core::log::write(
+        core::log::Channel::client, core::log::Level::debug, "ev=activate stage=main phase=begin");
     // The sweep stalls whichever thread calls it, so the overlay says what is happening. It
     // only reaches the screen once the presentation hooks are installed.
     core::ui::busy::begin(core::ui::busy::Task::initialization);
+    // Started after the overlay is up, because begin blocks for up to half a second waiting on
+    // presents. That wait belongs to the overlay, not to the work being measured.
+    const std::uint64_t startedTick = GetTickCount64();
     const bool active = runtime::activate_required_main_locked();
+    core::log::write_elapsed(core::log::Channel::client,
+                             "ev=activate stage=main phase=complete",
+                             startedTick,
+                             active ? "ok" : "fail");
     core::ui::busy::end(core::ui::busy::Task::initialization);
     if (!active) {
         // A failed sweep latches too: repeating it stalls the frame loop for nothing.

+ 6 - 4
Sunrise/src/client/runtime/client_runtime_lifecycle.cpp

@@ -7,13 +7,14 @@
 #include "../hooks/cursor/runtime.h"
 #include "../hooks/graphics/graphics_hook_lifecycle.h"
 #include "../hooks/network/runtime.h"
+#include "../hooks/noclip/runtime.h"
 #include "../hooks/polled_input/runtime.h"
 #include "../hooks/queuez/queuez_hook_lifecycle.h"
 #include "../hooks/retail_log/retail_log_lifecycle.h"
 #include "../hooks/teleport/runtime.h"
+#include "../movement/movement_settings_store.h"
 #include "../targets/game.h"
 #include "../targets/steam_targets.h"
-#include "../teleport/teleport_settings_store.h"
 #include "../ui/runtime/client_ui_module_runtime.h"
 #include "internal.h"
 #include "runtime.h"
@@ -22,8 +23,8 @@ namespace sunrise::client {
 
 /** Initializes Client-owned process state without installing hooks. */
 bool initialize(void* module) noexcept {
-    // Loaded before the pages register, so the teleport page draws saved values on its first frame.
-    teleport::initialize(module);
+    // Loaded before the pages register, so the movement page draws saved values on its first frame.
+    movement::initialize(module);
     return ui::runtime::initialize();
 }
 
@@ -49,6 +50,7 @@ bool shutdown() noexcept {
     }
     hooks::bitmap::uninstall();
     hooks::bootflow::uninstall();
+    hooks::noclip::uninstall();
     hooks::teleport::uninstall();
     hooks::queuez::uninstall();
     if (!hooks::config_getter::uninstall()) {
@@ -85,7 +87,7 @@ bool shutdown() noexcept {
     runtime::g_graphicsStage = runtime::StageState::pending;
     runtime::g_platformStage = runtime::StageState::pending;
     ui::runtime::shutdown();
-    teleport::shutdown();
+    movement::shutdown();
     core::log::write(core::log::Channel::client, core::log::Level::info, "ev=shutdown result=ok");
     ReleaseSRWLockExclusive(&runtime::g_lock);
     return true;

+ 5 - 0
Sunrise/src/client/targets/game/assert_handler.h

@@ -4,12 +4,17 @@
 
 namespace sunrise::client::targets::game::assert_handler {
 
+/** Native setter that installs the process assert handler. */
+using Setter = void(__fastcall*)(void*);
+
 /** Resolved assert-reporting targets. */
 struct Targets {
     /** Slot the assert sites call through when it is non-null. */
     std::byte** slot{};
     /** Handler the game installed, retained so detach can restore it. */
     void* original{};
+    /** Setter copy that writes the selected handler slot. */
+    Setter setter{};
 };
 
 /** Clears the published assert target group. */

+ 4 - 1
Sunrise/src/client/targets/game/assert_handler/game_assert_targets.cpp

@@ -77,7 +77,7 @@ constexpr std::size_t kDisplacementSize = 4;
 
 } // namespace
 
-/** Derives the assert handler slot by voting sampled assert sites against every setter copy. */
+/** Derives the assert handler target by voting sampled assert sites against every setter copy. */
 bool derive(std::span<const patterns::ImageRange> image, Targets& output) noexcept {
     output = {};
     const patterns::Pattern setterPattern{"assert_set_handler",
@@ -93,6 +93,7 @@ bool derive(std::span<const patterns::ImageRange> image, Targets& output) noexce
     }
 
     const std::span<std::byte* const> sampled(sites.data(), siteCount);
+    Setter winnerSetter = nullptr;
     std::byte** winner = nullptr;
     std::size_t best = 0;
     std::size_t runnerUp = 0;
@@ -102,6 +103,7 @@ bool derive(std::span<const patterns::ImageRange> image, Targets& output) noexce
         if (votes > best) {
             runnerUp = best;
             best = votes;
+            winnerSetter = reinterpret_cast<Setter>(setters[index]);
             winner = candidate;
         } else if (votes > runnerUp) {
             runnerUp = votes;
@@ -114,6 +116,7 @@ bool derive(std::span<const patterns::ImageRange> image, Targets& output) noexce
     Targets resolved;
     resolved.slot = winner;
     resolved.original = *winner;
+    resolved.setter = winnerSetter;
     output = resolved;
     return true;
 }

+ 2 - 2
Sunrise/src/client/targets/game/assert_handler/game_assert_targets.h

@@ -8,9 +8,9 @@
 namespace sunrise::client::targets::game::assert_handler {
 
 /**
- * Derives the assert handler slot from the setter copies and the assert sites that read it.
+ * Derives the assert handler setter and slot from the setter copies and the reading sites.
  * @param image Executable ranges from the main game image.
- * @param output Receives the slot and the handler currently installed in it.
+ * @param output Receives the setter, slot and handler currently installed in it.
  * @return True when one candidate wins the vote by the required margin.
  */
 [[nodiscard]] bool derive(std::span<const patterns::ImageRange> image, Targets& output) noexcept;

+ 79 - 34
Sunrise/src/client/ui/teleport/teleport_panel.cpp → Sunrise/src/client/ui/movement/movement_panel.cpp

@@ -1,9 +1,9 @@
 /**
- * The teleport module's interface. Every control writes the configuration straight to disk, so a
- * change made here survives the next launch without a settings edit.
+ * The movement module's interface. Every control saves to disk at once, so a change made here
+ * survives the next launch with no settings edit.
  */
 
-#include "teleport_panel.h"
+#include "movement_panel.h"
 
 #include <Windows.h>
 
@@ -12,9 +12,9 @@
 #include <imgui.h>
 
 #include "../../../core/ui/components/toggle/ui_toggle_component.h"
-#include "../../teleport/teleport_settings_store.h"
+#include "../../movement/movement_settings_store.h"
 
-namespace sunrise::client::ui::teleport {
+namespace sunrise::client::ui::movement {
 namespace {
 
 /** Lowest and highest virtual keys the picker scans. Zero is not a key. */
@@ -25,7 +25,13 @@ constexpr int kLastMouseKey = 6;
 /** Longest key name Windows returns, plus the null. */
 constexpr std::size_t kKeyNameCapacity = 64;
 
-bool g_capturing{};
+enum class CaptureTarget {
+    none,
+    teleport,
+    noclip,
+};
+
+CaptureTarget g_capturing{CaptureTarget::none};
 
 /**
  * Names one virtual key for display.
@@ -33,7 +39,7 @@ bool g_capturing{};
  * @param output Receives the name.
  */
 void key_name(std::uint32_t virtualKey, std::array<char, kKeyNameCapacity>& output) noexcept {
-    if (virtualKey == client::teleport::kNoKey) {
+    if (virtualKey == client::movement::kNoKey) {
         (void)std::snprintf(output.data(), output.size(), "None");
         return;
     }
@@ -65,7 +71,7 @@ void key_name(std::uint32_t virtualKey, std::array<char, kKeyNameCapacity>& outp
  */
 [[nodiscard]] bool capture_key(std::uint32_t& picked) noexcept {
     if ((GetAsyncKeyState(VK_ESCAPE) & 0x8000) != 0) {
-        picked = client::teleport::kNoKey;
+        picked = client::movement::kNoKey;
         return true;
     }
     for (int key = kFirstVirtualKey; key <= kLastVirtualKey; ++key) {
@@ -80,11 +86,45 @@ void key_name(std::uint32_t virtualKey, std::array<char, kKeyNameCapacity>& outp
     return false;
 }
 
+/**
+ * Draws one key picker while keeping capture ownership exclusive.
+ * @param id ImGui identity for the button.
+ * @param target Binding this picker captures.
+ * @param virtualKey Binding value to display and update.
+ * @param width Button width.
+ * @return True when a new binding was captured.
+ */
+[[nodiscard]] bool
+key_picker(const char* id, CaptureTarget target, std::uint32_t& virtualKey, float width) noexcept {
+    ImGui::PushID(id);
+    if (g_capturing == target) {
+        if (ImGui::Button("...", ImVec2(width, 0.0F))) {
+            g_capturing = CaptureTarget::none;
+        }
+        ImGui::PopID();
+        std::uint32_t picked = client::movement::kNoKey;
+        if (capture_key(picked)) {
+            virtualKey = picked;
+            g_capturing = CaptureTarget::none;
+            return true;
+        }
+        return false;
+    }
+    std::array<char, kKeyNameCapacity> name{};
+    key_name(virtualKey, name);
+    const bool clicked = ImGui::Button(name.data(), ImVec2(width, 0.0F));
+    ImGui::PopID();
+    if (clicked) {
+        g_capturing = target;
+    }
+    return false;
+}
+
 } // namespace
 
-/** Draws the teleport module inside the active Core UI frame. */
+/** Draws the movement module inside the active Core UI frame. */
 void draw() noexcept {
-    client::teleport::Settings settings = client::teleport::get();
+    client::movement::Settings settings = client::movement::get();
     bool changed = false;
 
     ImGui::TextUnformatted("Teleport");
@@ -93,12 +133,13 @@ void draw() noexcept {
                        "Cancels vertical momentum.");
     ImGui::Spacing();
 
-    changed = core::ui::components::toggle::control("Enabled", settings.enabled) || changed;
+    changed =
+        core::ui::components::toggle::control("Enabled##teleport", settings.enabled) || changed;
 
     ImGui::Spacing();
-    // One label column and one control column, so the slider and the key button share both edges.
+    // One label column and one control column, so the slider and key buttons share both edges.
     const float labelWidth =
-        ImGui::CalcTextSize("Distance").x + ImGui::GetStyle().ItemSpacing.x * 2;
+        ImGui::CalcTextSize("Toggle key").x + ImGui::GetStyle().ItemSpacing.x * 2;
     const float controlWidth = ImGui::GetContentRegionAvail().x - labelWidth;
 
     ImGui::AlignTextToFramePadding();
@@ -108,8 +149,8 @@ void draw() noexcept {
     float distance = settings.distance;
     if (ImGui::SliderFloat("##distance",
                            &distance,
-                           client::teleport::kMinimumDistance,
-                           client::teleport::kMaximumDistance,
+                           client::movement::kMinimumDistance,
+                           client::movement::kMaximumDistance,
                            "%.0f units")) {
         settings.distance = distance;
         changed = true;
@@ -119,28 +160,32 @@ void draw() noexcept {
     ImGui::AlignTextToFramePadding();
     ImGui::TextUnformatted("Key");
     ImGui::SameLine(labelWidth);
-    if (g_capturing) {
-        if (ImGui::Button("...", ImVec2(controlWidth, 0.0F))) {
-            g_capturing = false;
-        }
-        std::uint32_t picked = client::teleport::kNoKey;
-        if (capture_key(picked)) {
-            settings.virtualKey = picked;
-            g_capturing = false;
-            changed = true;
-        }
-    } else {
-        std::array<char, kKeyNameCapacity> name{};
-        key_name(settings.virtualKey, name);
-        if (ImGui::Button(name.data(), ImVec2(controlWidth, 0.0F))) {
-            g_capturing = true;
-        }
-    }
+    changed = key_picker("teleport_key", CaptureTarget::teleport, settings.virtualKey, controlWidth)
+              || changed;
+
+    ImGui::Spacing();
+    ImGui::Spacing();
+    ImGui::TextUnformatted("Noclip");
+    ImGui::Separator();
+    ImGui::TextWrapped("Uses native horizontal rigid-body velocity while preserving the game's "
+                       "vertical movement. The bound key turns it on and off in game.");
+    ImGui::Spacing();
+
+    changed =
+        core::ui::components::toggle::control("Enabled##noclip", settings.noclipEnabled) || changed;
+
+    ImGui::Spacing();
+    ImGui::AlignTextToFramePadding();
+    ImGui::TextUnformatted("Toggle key");
+    ImGui::SameLine(labelWidth);
+    changed =
+        key_picker("noclip_key", CaptureTarget::noclip, settings.noclipToggleKey, controlWidth)
+        || changed;
 
-    if (changed && !client::teleport::publish(settings)) {
+    if (changed && !client::movement::publish(settings)) {
         ImGui::Spacing();
         ImGui::TextUnformatted("value out of range, not saved");
     }
 }
 
-} // namespace sunrise::client::ui::teleport
+} // namespace sunrise::client::ui::movement

+ 8 - 0
Sunrise/src/client/ui/movement/movement_panel.h

@@ -0,0 +1,8 @@
+#pragma once
+
+namespace sunrise::client::ui::movement {
+
+/** Draws the movement module inside the active Core UI frame. */
+void draw() noexcept;
+
+} // namespace sunrise::client::ui::movement

+ 8 - 8
Sunrise/src/client/ui/runtime/client_ui_module_runtime.cpp

@@ -4,29 +4,29 @@
 
 #include "../../../core/ui/modules/registry/ui_module_registry.h"
 #include "../../../core/ui/modules/ui_module_descriptor.h"
-#include "../teleport/teleport_panel.h"
+#include "../movement/movement_panel.h"
 
 namespace sunrise::client::ui::runtime {
 namespace {
 
 /** Namespaced stable ID prevents Client modules from colliding with Server modules. */
-constexpr std::string_view kTeleportStableId = "client.teleport";
-/** Short menu label for the teleport page. */
-constexpr std::string_view kTeleportDisplayName = "Teleport";
+constexpr std::string_view kMovementStableId = "client.movement";
+/** Short menu label for the shared teleport and noclip page. */
+constexpr std::string_view kMovementDisplayName = "Movement";
 
-core::ui::modules::registry::PageRegistration g_teleportPage;
+core::ui::modules::registry::PageRegistration g_movementPage;
 
 } // namespace
 
 /** @return True when the Client module owns its Core UI registry slot. */
 bool initialize() noexcept {
-    return g_teleportPage.acquire(
-        core::ui::modules::Owner::client, kTeleportStableId, kTeleportDisplayName, &teleport::draw);
+    return g_movementPage.acquire(
+        core::ui::modules::Owner::client, kMovementStableId, kMovementDisplayName, &movement::draw);
 }
 
 /** Removes the Client module from the Core UI registry. */
 void shutdown() noexcept {
-    g_teleportPage.release();
+    g_movementPage.release();
 }
 
 } // namespace sunrise::client::ui::runtime

+ 0 - 8
Sunrise/src/client/ui/teleport/teleport_panel.h

@@ -1,8 +0,0 @@
-#pragma once
-
-namespace sunrise::client::ui::teleport {
-
-/** Draws the teleport module inside the active Core UI frame. */
-void draw() noexcept;
-
-} // namespace sunrise::client::ui::teleport

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

@@ -2,8 +2,10 @@
 
 #include <Windows.h>
 
+#include <algorithm>
 #include <array>
 #include <atomic>
+#include <cstdio>
 #include <cstring>
 
 #include "../filesystem/path.h"
@@ -27,14 +29,20 @@ constexpr std::wstring_view kPreviousLogSuffix = L".old";
 constexpr std::string_view kLineEnding = "\r\n";
 /** One trailing null byte is kept for the debugger sink. */
 constexpr std::size_t kLineTerminatorBytes = 1;
+/** An elapsed line is one event, a duration and an outcome, so it needs less than a full line. */
+constexpr std::size_t kElapsedCapacity = 128;
 /** 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 +140,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 +210,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());
@@ -227,6 +247,33 @@ void write(Channel channel, Level level, std::string_view event) noexcept {
     ReleaseSRWLockShared(&g_log.lock);
 }
 
+/** Formats and emits one debug event carrying a duration in the ms field. */
+void write_elapsed(Channel channel,
+                   std::string_view event,
+                   unsigned long long startedTick,
+                   std::string_view result) noexcept {
+    if (!accepts(channel, Level::debug)) {
+        return;
+    }
+    const unsigned long long elapsed = GetTickCount64() - startedTick;
+    std::array<char, kElapsedCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "%.*s ms=%llu result=%.*s",
+                                      static_cast<int>(event.size()),
+                                      event.data(),
+                                      elapsed,
+                                      static_cast<int>(result.size()),
+                                      result.data());
+    if (written <= 0) {
+        return;
+    }
+    // snprintf reports the length before truncation, so the emitted view is clamped to the buffer.
+    const auto length =
+        std::min(static_cast<std::size_t>(written), line.size() - kLineTerminatorBytes);
+    write(channel, Level::debug, {line.data(), length});
+}
+
 /** @return True while a sink write is in progress. */
 bool writers_active() noexcept {
     return g_writers.load(std::memory_order_acquire) != 0;

+ 13 - 0
Sunrise/src/core/logging/log.h

@@ -50,6 +50,19 @@ void shutdown() noexcept;
 /** Emits one structured event when allowed by the channel threshold. */
 void write(Channel channel, Level level, std::string_view event) noexcept;
 
+/**
+ * Emits one debug event carrying a duration in the ms field.
+ * Timing is diagnostic, so it stays off at the levels a normal run uses.
+ * @param channel Channel owning the measured boundary.
+ * @param event Event and phase text the duration is appended to.
+ * @param startedTick GetTickCount64 value taken when the boundary began.
+ * @param result Outcome text for the log line.
+ */
+void write_elapsed(Channel channel,
+                   std::string_view event,
+                   unsigned long long startedTick,
+                   std::string_view result) noexcept;
+
 /**
  * Reports whether any thread is inside a sink write.
  * A sink holds an operating system lock this process shares, so a thread suspended there

+ 31 - 18
Sunrise/src/core/runtime/core_runtime.cpp

@@ -4,6 +4,7 @@
 
 #include <array>
 #include <atomic>
+#include <cstdint>
 #include <cstdio>
 #include <string_view>
 
@@ -86,6 +87,8 @@ bool initialize(void* module) noexcept {
         ReleaseSRWLockExclusive(&g_runtimeLock);
         return true;
     }
+    // Taken before the first stage, so the reported duration covers settings and the sinks too.
+    const std::uint64_t startedTick = GetTickCount64();
 
     if (!settings::initialize(module)) {
         // Settings name their own failure; the sinks do not exist yet to carry a second line.
@@ -97,27 +100,36 @@ bool initialize(void* module) noexcept {
     const char* stage = nullptr;
     if (!log::initialize(module, settings::get().logging)) {
         stage = "logging";
-    } else if (!ui::runtime::initialize(settings::get().client.userInterface)) {
-        stage = "ui";
-    } else if (!ui::modules::logs::initialize()) {
-        stage = "ui_logs";
-    } else if (!state::entitlements::publish(settings::get().server.entitlements)) {
-        stage = "entitlements";
-    } else if (!state::initialize(module,
-                                  settings::get().initialAccount,
-                                  settings::get().initialActivityDefaults)) {
-        stage = "state";
-    } else if (!initialize_content_manifest(module)) {
-        stage = "content_manifest";
-    } else if (!middleware::initialize()) {
-        stage = "middleware";
-    } else if (!server::initialize()) {
-        stage = "server";
-    } else if (!client::initialize(module)) {
-        stage = "client";
+    } else {
+        // The sinks exist only from here, so this is the earliest a begin marker can reach a
+        // channel. The duration it pairs with still counts from function entry.
+        log::write(log::Channel::core, log::Level::debug, "ev=initialize phase=begin");
+        if (!ui::runtime::initialize(settings::get().client.userInterface)) {
+            stage = "ui";
+        } else if (!ui::modules::logs::initialize()) {
+            stage = "ui_logs";
+        } else if (!state::entitlements::publish(settings::get().server.entitlements)) {
+            stage = "entitlements";
+        } else if (!state::initialize(module,
+                                      settings::get().initialAccount,
+                                      settings::get().initialActivityDefaults)) {
+            stage = "state";
+        } else if (!initialize_content_manifest(module)) {
+            stage = "content_manifest";
+        } else if (!middleware::initialize()) {
+            stage = "middleware";
+        } else if (!server::initialize()) {
+            stage = "server";
+        } else if (!client::initialize(module)) {
+            stage = "client";
+        }
     }
     if (stage != nullptr) {
         report_stage_failure(stage);
+        // Reported before the unwind, so this measures initialization alone and stays comparable
+        // with the success line. The unwind's own quiesce waits would otherwise be counted here.
+        // A logging-stage failure has no sinks left to carry it, and reports nothing.
+        log::write_elapsed(log::Channel::core, "ev=initialize phase=complete", startedTick, "fail");
         // Reverse every stage because the failing expression may have completed earlier stages.
         (void)client::shutdown();
         server::shutdown();
@@ -136,6 +148,7 @@ bool initialize(void* module) noexcept {
     }
     g_initialized.store(true, std::memory_order_release);
     log::write(log::Channel::core, log::Level::info, "ev=initialize result=ok");
+    log::write_elapsed(log::Channel::core, "ev=initialize phase=complete", startedTick, "ok");
     ReleaseSRWLockExclusive(&g_runtimeLock);
     return true;
 }

+ 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, which is the
+     * route to the citizen join. A forced destination loads solo either way.
+     */
+    bool regionPrivate{false};
     /**
      * 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};
 };

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

@@ -0,0 +1,72 @@
+#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 {
+    /** Embedded by default. Public activities reach each other over the peer protocol. */
+    Topology topology{Topology::embedded};
+    /** 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;
         }

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

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

+ 26 - 10
Sunrise/src/core/settings/settings_upgrade.cpp

@@ -17,15 +17,25 @@ namespace {
 
 /** The layout version member, quoted so a value string cannot match it. */
 constexpr std::string_view kVersionMember = "\"version\"";
+
+/** One replaced member, and the layout version that changed it. */
+struct ReplacedMember {
+    /** Quoted member name, so a value string cannot match it. */
+    std::string_view name;
+    /** Replaced only while the file is older than this version. */
+    std::uint32_t version;
+};
+
 /**
- * Members replaced with the bundled default because their value form changed.
- *
- * Version 3 changed how a key binding names its key. Version 4 gave every character an authored
- * inventory, whose rows carry instance identities that have to be unique across the account and
- * agree with the equipment beside them, so a version-3 character cannot be carried forward and
- * takes the bundled one instead.
+ * Members replaced with the bundled default, each with the version that changed it.
+ * A member is listed because its value form changed, or because its default changed.
  */
-constexpr std::array<std::string_view, 2> kReplacedMembers{"\"key_bindings\"", "\"characters\""};
+constexpr std::array<ReplacedMember, 4> kReplacedMembers{{
+    {"\"key_bindings\"", 3},
+    {"\"region_private\"", 5},
+    {"\"topology\"", 5},
+    {"\"characters\"", 5},
+}};
 /** One splice per replaced member, plus the version member itself. */
 constexpr std::size_t kSpliceCapacity = kReplacedMembers.size() + 1;
 /** Room for the version member and its digits when the file predates versioning. */
@@ -202,11 +212,17 @@ bool apply(std::string_view document,
             root + 1, root + 1, {versionText.data(), static_cast<std::size_t>(length)}};
     }
 
-    for (const std::string_view member : kReplacedMembers) {
+    const std::uint32_t from = document_version(document);
+    for (const ReplacedMember& member : kReplacedMembers) {
+        // A file at or past that version keeps its own value, so an upgrade never overwrites a
+        // choice the user made against the current layout.
+        if (from >= member.version) {
+            continue;
+        }
         std::size_t replacementStart = 0;
         std::size_t replacementEnd = 0;
-        if (!value_span(document, member, start, end)
-            || !value_span(bundled, member, replacementStart, replacementEnd)) {
+        if (!value_span(document, member.name, start, end)
+            || !value_span(bundled, member.name, replacementStart, replacementEnd)) {
             // A member the file never carried needs no replacement.
             continue;
         }

+ 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);
         }

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

@@ -9,6 +9,10 @@ namespace sunrise::core::settings::steam {
 inline constexpr std::size_t kMaximumPersonaNameBytes = 63;
 /** Fixed persona storage includes one trailing null byte. */
 inline constexpr std::size_t kPersonaNameCapacity = kMaximumPersonaNameBytes + 1;
+/** Comfortably longer than any published Steam API language token (e.g. "vietnamese"). */
+inline constexpr std::size_t kMaximumLanguageBytes = 15;
+/** Fixed language storage includes one trailing null byte. */
+inline constexpr std::size_t kLanguageCapacity = kMaximumLanguageBytes + 1;
 
 /** Read-only settings for the single local Steam user. */
 struct User {
@@ -20,6 +24,8 @@ struct User {
 struct Settings {
     /** Options for the single local user exposed through Steam interfaces. */
     User user;
+    /** Steam API language token answered for GetCurrentGameLanguage/GetAvailableGameLanguages. */
+    std::array<char, kLanguageCapacity> language{"english"};
 };
 
 } // namespace sunrise::core::settings::steam

+ 86 - 9
Sunrise/src/core/settings/steam/steam_settings_parser.cpp

@@ -1,13 +1,17 @@
+#include <algorithm>
+#include <array>
+#include <cstdio>
 #include <string_view>
 
+#include "../../logging/log.h"
 #include "../parser.h"
 
 namespace sunrise::core::settings::parser {
 namespace {
 
-/** Space is the first printable ASCII value accepted by persona storage. */
+/** Space is the first printable ASCII value accepted by fixed string storage. */
 constexpr unsigned int kMinimumPrintableAscii = 0x20;
-/** Tilde is the last printable ASCII value accepted by persona storage. */
+/** Tilde is the last printable ASCII value accepted by fixed string storage. */
 constexpr unsigned int kMaximumPrintableAscii = 0x7E;
 /** Hex letter digits start at the value 10. */
 constexpr unsigned int kHexadecimalAlphaOffset = 10;
@@ -95,14 +99,16 @@ decode_escape(std::string_view encoded, std::size_t& position, char& output) noe
 }
 
 /**
- * Decodes a nonempty persona into process-owned fixed storage.
+ * Decodes a nonempty printable-ASCII JSON string into fixed storage.
+ * The storage reserves its last byte for the trailing null, so that byte bounds the decode.
  * @param encoded Borrowed encoded JSON string bytes.
  * @param output Receives the decoded bytes and a trailing null only on success.
- * @return True for 1 to 63 printable ASCII bytes.
+ * @return True for 1 to Capacity minus 1 printable ASCII bytes.
  */
-[[nodiscard]] bool decode_persona(std::string_view encoded,
-                                  std::array<char, steam::kPersonaNameCapacity>& output) noexcept {
-    std::array<char, steam::kPersonaNameCapacity> candidate{};
+template <std::size_t Capacity>
+[[nodiscard]] bool decode_bounded_string(std::string_view encoded,
+                                         std::array<char, Capacity>& output) noexcept {
+    std::array<char, Capacity> candidate{};
     std::size_t decodedCount = 0;
     for (std::size_t position = 0; position < encoded.size();) {
         char value = encoded[position++];
@@ -111,7 +117,7 @@ decode_escape(std::string_view encoded, std::size_t& position, char& output) noe
         }
         const auto byte = static_cast<unsigned char>(value);
         if (byte < kMinimumPrintableAscii || byte > kMaximumPrintableAscii
-            || decodedCount >= steam::kMaximumPersonaNameBytes) {
+            || decodedCount + 1 >= Capacity) {
             return false;
         }
         candidate[decodedCount++] = value;
@@ -123,6 +129,68 @@ decode_escape(std::string_view encoded, std::size_t& position, char& output) noe
     return true;
 }
 
+/** Longest rejected token copied into the fallback line, so that line stays short. */
+constexpr std::size_t kReportedTokenBytes = 32;
+
+/** Destiny 2 only supports these languages; anything else falls back to English. */
+constexpr std::array<std::string_view, 13> kSupportedLanguages{
+    "english",
+    "french",
+    "german",
+    "italian",
+    "japanese",
+    "brazilian",
+    "spanish",
+    "russian",
+    "polish",
+    "schinese",
+    "tchinese",
+    "latam",
+    "koreana",
+};
+
+/** @param token Candidate Steam API language code. @return True when this build ships it. */
+[[nodiscard]] bool is_supported_language(std::string_view token) noexcept {
+    return std::find(kSupportedLanguages.begin(), kSupportedLanguages.end(), token)
+           != kSupportedLanguages.end();
+}
+
+/**
+ * Names a replaced language token. Settings are read before the log sinks exist, so this early
+ * line is the only report.
+ * @param encoded Borrowed encoded token that was not accepted.
+ */
+void report_language_fallback(std::string_view encoded) noexcept {
+    std::array<char, 96> line{};
+    // The token comes from the file, so its length is capped here rather than trusted.
+    const std::string_view token = encoded.substr(0, std::min(encoded.size(), kReportedTokenBytes));
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=settings stage=language result=fallback token=%.*s",
+                                      static_cast<int>(token.size()),
+                                      token.data());
+    if (written > 0) {
+        log::early({line.data(), std::min(static_cast<std::size_t>(written), line.size() - 1)});
+    }
+}
+
+/**
+ * Resolves an authored Steam API language token, falling back to English for anything else.
+ * One token is the only accepted shape, because GetCurrentGameLanguage answers with this value.
+ * @param encoded Borrowed encoded JSON string bytes.
+ * @return The matching supported code, or the English fallback.
+ */
+[[nodiscard]] std::array<char, steam::kLanguageCapacity>
+resolve_language(std::string_view encoded) noexcept {
+    std::array<char, steam::kLanguageCapacity> decoded{};
+    if (decode_bounded_string(encoded, decoded)
+        && is_supported_language(std::string_view(decoded.data()))) {
+        return decoded;
+    }
+    report_language_fallback(encoded);
+    return {"english"};
+}
+
 } // namespace
 
 /** Parses Steam settings on top of the fixed defaults. */
@@ -132,6 +200,7 @@ bool Parser::steam_settings(steam::Settings& output) noexcept {
     }
     steam::Settings candidate = output;
     bool hasUser = false;
+    bool hasLanguage = false;
     if (consume('}')) {
         return true;
     }
@@ -145,6 +214,13 @@ bool Parser::steam_settings(steam::Settings& output) noexcept {
                 return false;
             }
             hasUser = true;
+        } else if (key == "language") {
+            std::string_view value;
+            if (hasLanguage || !string(value)) {
+                return false;
+            }
+            candidate.language = resolve_language(value);
+            hasLanguage = true;
         } else if (!skip_value(0)) {
             return false;
         }
@@ -175,7 +251,8 @@ bool Parser::steam_user_settings(steam::User& output) noexcept {
         }
         if (key == "persona_name") {
             std::string_view value;
-            if (hasPersonaName || !string(value) || !decode_persona(value, candidate.personaName)) {
+            if (hasPersonaName || !string(value)
+                || !decode_bounded_string(value, candidate.personaName)) {
                 return false;
             }
             hasPersonaName = true;

+ 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

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