ソースを参照

Merge master and fold sword skate into the Movement feature

Three conflicts, all from master renaming the teleport module to movement
while this branch added a third module beside it.

The fix is now one "Sword Skate Fix" section on the Movement page rather
than its own page and store:

- swordSkateEnabled and swordSkateJumpKey join movement Settings, and are
  read and written as sword_skate_enabled and sword_skate_jump_key
- the panel reuses the movement page's key picker, so its own copy of the
  key naming and capture code is gone
- the hook reads movement settings and keeps its physics-sync call site
- client/sword_skate and client/ui/sword_skate are removed

A movement.json without the new keys takes the defaults, so no migration
is needed. The fix stays off until the toggle is set.
stan 2 ヶ月 前
親
コミット
ade1e28570
100 ファイル変更、6865 行追加、1082 行削除
  1. 2 2
      .clang-tidy
  2. 6 1
      .gitignore
  3. 108 0
      CMakeLists.txt
  4. 139 9
      Sunrise/Sunrise.vcxproj
  5. 929 35
      Sunrise/resources/default_settings.json
  6. 3 3
      Sunrise/resources/resource.h
  7. 3 2
      Sunrise/src/client/content/investment/internal.h
  8. 14 58
      Sunrise/src/client/content/investment/investment_refresh.cpp
  9. 1 1
      Sunrise/src/client/content/investment/worker/investment_refresh_worker.cpp
  10. 3 3
      Sunrise/src/client/content/items/details/configured_item_detail_extractor.cpp
  11. 129 23
      Sunrise/src/client/content/items/packages/internal.h
  12. 124 6
      Sunrise/src/client/content/items/packages/package_build_report.cpp
  13. 176 0
      Sunrise/src/client/content/items/packages/package_collectible_build.cpp
  14. 89 55
      Sunrise/src/client/content/items/packages/package_detail_build.cpp
  15. 43 6
      Sunrise/src/client/content/items/packages/package_item_build.cpp
  16. 120 26
      Sunrise/src/client/content/items/packages/package_item_rows.cpp
  17. 205 0
      Sunrise/src/client/content/items/packages/package_material_requirement_build.cpp
  18. 171 8
      Sunrise/src/client/content/items/packages/package_root_tables.cpp
  19. 273 0
      Sunrise/src/client/content/items/packages/package_socket_plug_build.cpp
  20. 88 0
      Sunrise/src/client/content/items/packages/package_socket_plug_build.h
  21. 51 12
      Sunrise/src/client/content/items/packages/package_subclass_build.cpp
  22. 47 0
      Sunrise/src/client/content/vendors/layout.h
  23. 331 0
      Sunrise/src/client/content/vendors/package_vendor_build.cpp
  24. 22 0
      Sunrise/src/client/content/vendors/vendor_build.h
  25. 15 15
      Sunrise/src/client/hooks/assert_handler/assert_handler_lifecycle.cpp
  26. 96 16
      Sunrise/src/client/hooks/assert_handler/assert_handler_observer.cpp
  27. 15 8
      Sunrise/src/client/hooks/bootflow/region_private.cpp
  28. 5 2
      Sunrise/src/client/hooks/graphics/renderer/graphics_renderer_report.cpp
  29. 1 0
      Sunrise/src/client/hooks/graphics/renderer/graphics_renderer_report.h
  30. 7 0
      Sunrise/src/client/hooks/graphics/renderer/selection/graphics_swap_chain_selection.cpp
  31. 334 0
      Sunrise/src/client/hooks/noclip/horizontal_noclip.cpp
  32. 17 0
      Sunrise/src/client/hooks/noclip/runtime.h
  33. 220 0
      Sunrise/src/client/hooks/package_trust/package_trust_bypass.cpp
  34. 18 0
      Sunrise/src/client/hooks/package_trust/package_trust_bypass.h
  35. 129 13
      Sunrise/src/client/hooks/queuez/family0/family0_source_seed.cpp
  36. 19 2
      Sunrise/src/client/hooks/queuez/family0_subscription.cpp
  37. 20 1
      Sunrise/src/client/hooks/queuez/svc123_null_payload_guard.cpp
  38. 14 4
      Sunrise/src/client/hooks/retail_log/retail_log_enqueue_observer.cpp
  39. 6 4
      Sunrise/src/client/hooks/sword_skate/sword_skate.cpp
  40. 9 5
      Sunrise/src/client/hooks/teleport/teleport_move.cpp
  41. 35 11
      Sunrise/src/client/movement/movement_settings_store.cpp
  42. 11 4
      Sunrise/src/client/movement/movement_settings_store.h
  43. 88 29
      Sunrise/src/client/patterns/registry.cpp
  44. 1 1
      Sunrise/src/client/patterns/registry.h
  45. 24 4
      Sunrise/src/client/runtime/client_hook_activation.cpp
  46. 14 11
      Sunrise/src/client/runtime/client_runtime_lifecycle.cpp
  47. 0 234
      Sunrise/src/client/sword_skate/sword_skate_settings_store.cpp
  48. 0 38
      Sunrise/src/client/sword_skate/sword_skate_settings_store.h
  49. 5 0
      Sunrise/src/client/targets/game/assert_handler.h
  50. 4 1
      Sunrise/src/client/targets/game/assert_handler/game_assert_targets.cpp
  51. 2 2
      Sunrise/src/client/targets/game/assert_handler/game_assert_targets.h
  52. 216 0
      Sunrise/src/client/ui/movement/movement_panel.cpp
  53. 8 0
      Sunrise/src/client/ui/movement/movement_panel.h
  54. 8 20
      Sunrise/src/client/ui/runtime/client_ui_module_runtime.cpp
  55. 0 134
      Sunrise/src/client/ui/sword_skate/sword_skate_panel.cpp
  56. 0 8
      Sunrise/src/client/ui/sword_skate/sword_skate_panel.h
  57. 0 146
      Sunrise/src/client/ui/teleport/teleport_panel.cpp
  58. 0 8
      Sunrise/src/client/ui/teleport/teleport_panel.h
  59. 67 1
      Sunrise/src/core/logging/log.cpp
  60. 26 0
      Sunrise/src/core/logging/log.h
  61. 31 18
      Sunrise/src/core/runtime/core_runtime.cpp
  62. 65 0
      Sunrise/src/core/settings/address_text.cpp
  63. 22 0
      Sunrise/src/core/settings/address_text.h
  64. 6 0
      Sunrise/src/core/settings/client/client_settings_parser.cpp
  65. 6 0
      Sunrise/src/core/settings/client/definition.h
  66. 3 45
      Sunrise/src/core/settings/client/external/client_external_settings_parser.cpp
  67. 21 0
      Sunrise/src/core/settings/parser.h
  68. 3 0
      Sunrise/src/core/settings/server/definition.h
  69. 72 0
      Sunrise/src/core/settings/server/gameplay/definition.h
  70. 117 0
      Sunrise/src/core/settings/server/gameplay/gameplay_settings_parser.cpp
  71. 39 0
      Sunrise/src/core/settings/server/gameplay/gameplay_settings_validation.cpp
  72. 6 0
      Sunrise/src/core/settings/server/server_settings_parser.cpp
  73. 3 3
      Sunrise/src/core/settings/settings.h
  74. 5 4
      Sunrise/src/core/settings/settings_runtime.cpp
  75. 28 5
      Sunrise/src/core/settings/settings_upgrade.cpp
  76. 72 0
      Sunrise/src/core/settings/state/account_rows_parser.cpp
  77. 43 0
      Sunrise/src/core/settings/state/inventory_parser.cpp
  78. 59 1
      Sunrise/src/core/settings/state_settings.cpp
  79. 6 0
      Sunrise/src/core/settings/steam/definition.h
  80. 86 9
      Sunrise/src/core/settings/steam/steam_settings_parser.cpp
  81. 2 2
      Sunrise/src/middleware/bap/activity_message/activity_entity_slot_request_parser.cpp
  82. 39 11
      Sunrise/src/middleware/bap/activity_message/activity_membership_region_writer.cpp
  83. 7 4
      Sunrise/src/middleware/bap/activity_message/activity_replicate_membership_encoder.cpp
  84. 38 0
      Sunrise/src/middleware/bap/activity_message/replicate_membership.h
  85. 53 0
      Sunrise/src/middleware/content/packages/tables/definition_index_table.h
  86. 20 6
      Sunrise/src/middleware/content/packages/tables/item_appearance_reader.cpp
  87. 136 0
      Sunrise/src/middleware/content/packages/tables/item_definition_reader.cpp
  88. 35 2
      Sunrise/src/middleware/content/packages/tables/items.h
  89. 144 0
      Sunrise/src/middleware/crypto/aes_cbc.cpp
  90. 38 0
      Sunrise/src/middleware/crypto/aes_cbc.h
  91. 65 0
      Sunrise/src/middleware/crypto/aes_gcm_encrypt.cpp
  92. 26 0
      Sunrise/src/middleware/crypto/aes_gcm_encrypt.h
  93. 298 0
      Sunrise/src/middleware/crypto/ecc_p224.cpp
  94. 31 0
      Sunrise/src/middleware/crypto/ecc_p224.h
  95. 143 0
      Sunrise/src/middleware/crypto/hmac.cpp
  96. 42 0
      Sunrise/src/middleware/crypto/hmac.h
  97. 135 0
      Sunrise/src/middleware/crypto/lookup3.cpp
  98. 28 0
      Sunrise/src/middleware/crypto/lookup3.h
  99. 285 0
      Sunrise/src/middleware/crypto/modular_1024.cpp
  100. 96 0
      Sunrise/src/middleware/crypto/modular_1024.h

+ 2 - 2
.clang-tidy

@@ -68,11 +68,11 @@ Checks: >
   readability-redundant-string-cstr,
   readability-suspicious-call-argument
 WarningsAsErrors: '*'
-HeaderFilterRegex: '.*[\\/]prod_client[\\/]Sunrise[\\/]src[\\/].*'
+HeaderFilterRegex: '.*[\\/]Sunrise[\\/]src[\\/].*'
 SystemHeaders: false
 ExtraArgsBefore:
   - -Wdocumentation
 FormatStyle: file
 CheckOptions:
-  portability-restrict-system-includes.Includes: '-*,Windows.h,WinSock2.h,WS2tcpip.h,MSWSock.h,WinDNS.h,TlHelp32.h,Shellapi.h,bcrypt.h,d3d11.h,detours.h,dxgi.h,imgui.h,imgui_impl_dx11.h,imgui_impl_win32.h,intrin.h,algorithm,array,atomic,bit,bitset,cctype,charconv,chrono,climits,cmath,cstdarg,cstddef,cstdint,cstdio,cstdlib,cstring,cwchar,limits,new,optional,span,string_view,type_traits,utility,vector'
+  portability-restrict-system-includes.Includes: '-*,Windows.h,WinSock2.h,WS2tcpip.h,MSWSock.h,WinDNS.h,TlHelp32.h,Shellapi.h,bcrypt.h,d3d11.h,detours.h,dxgi.h,imgui.h,imgui_impl_dx11.h,imgui_impl_win32.h,intrin.h,algorithm,array,atomic,bit,bitset,cctype,charconv,chrono,climits,cmath,cstdarg,cstddef,cstdint,cstdio,cstdlib,cstring,cwchar,limits,memory,new,optional,span,string_view,type_traits,utility,variant,vector'
 ...

+ 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>"
+)

+ 139 - 9
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>
@@ -125,7 +128,16 @@
     <ClCompile Include="src\client\hooks\cursor\cursor_hook_lifecycle.cpp" />
     <ClCompile Include="src\client\hooks\graphics\renderer\graphics_renderer_device.cpp" />
     <ClCompile Include="src\middleware\web_service\messages\opcode504_codec.cpp" />
+    <ClCompile Include="src\middleware\web_service\messages\opcode903_codec.cpp" />
+    <ClCompile Include="src\middleware\web_service\messages\opcode1901_codec.cpp" />
+    <ClCompile Include="src\middleware\web_service\messages\opcode402_codec.cpp" />
+    <ClCompile Include="src\middleware\web_service\messages\opcode403_codec.cpp" />
+    <ClCompile Include="src\middleware\web_service\messages\opcode406_codec.cpp" />
+    <ClCompile Include="src\middleware\web_service\messages\opcode1820_codec.cpp" />
     <ClCompile Include="src\state\runtime\state_account_runtime.cpp" />
+    <ClCompile Include="src\state\runtime\state_account_acquisition_runtime.cpp" />
+    <ClCompile Include="src\state\runtime\state_account_dismantle_runtime.cpp" />
+    <ClCompile Include="src\state\runtime\state_account_item_action_runtime.cpp" />
     <ClCompile Include="src\core\ui\busy\ui_busy_overlay.cpp" />
     <ClCompile Include="src\core\ui\busy\ui_busy_state.cpp" />
     <ClCompile Include="src\core\ui\notice\ui_notice_overlay.cpp" />
@@ -164,6 +176,7 @@
     <ClCompile Include="src\client\hooks\banner\banner_hook_lifecycle.cpp" />
     <ClCompile Include="src\middleware\datagen\family4\loadout\subclass_socket_selection.cpp" />
     <ClCompile Include="src\state\build_data\cache\records\cache_socket_record_codec.cpp" />
+    <ClCompile Include="src\state\build_data\cache\records\cache_socket_plug_record_codec.cpp" />
     <ClCompile Include="src\client\hooks\graphics\renderer\selection\graphics_swap_chain_selection.cpp" />
     <ClCompile Include="src\client\hooks\graphics\renderer\graphics_renderer_lifecycle.cpp" />
     <ClCompile Include="src\client\hooks\graphics\renderer\graphics_renderer_frame.cpp" />
@@ -185,11 +198,10 @@
     <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\sword_skate\sword_skate_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\sword_skate\sword_skate.cpp" />
-    <ClCompile Include="src\client\ui\sword_skate\sword_skate_panel.cpp" />
-    <ClCompile Include="src\client\teleport\teleport_settings_store.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" />
@@ -200,6 +212,7 @@
     <ClCompile Include="src\client\memory\current_process_memory.cpp" />
     <ClCompile Include="src\client\hooks\network\investment\investment_derived_rebuild.cpp" />
     <ClCompile Include="src\client\hooks\network\investment\investment_family5_rearm.cpp" />
+    <ClCompile Include="src\client\hooks\package_trust\package_trust_bypass.cpp" />
     <ClCompile Include="src\client\hooks\bitmap\bitmap_hook_lifecycle.cpp" />
     <ClCompile Include="src\client\hooks\bitmap\bitmap_ref_guard.cpp" />
     <ClCompile Include="src\client\hooks\external_server\external_server_route.cpp" />
@@ -349,6 +362,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" />
@@ -385,6 +399,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" />
@@ -407,7 +425,13 @@
     <ClCompile Include="src\state\build_data\cache\write\cache_payload_writer.cpp" />
     <ClCompile Include="src\state\build_data\items\item_catalog.cpp" />
     <ClCompile Include="src\state\build_data\items\item_build_data_runtime.cpp" />
+    <ClCompile Include="src\state\build_data\collectibles\collectible_catalog.cpp" />
+    <ClCompile Include="src\state\build_data\collectibles\collectible_build_data_runtime.cpp" />
+    <ClCompile Include="src\state\build_data\material_requirements\material_requirement_catalog.cpp" />
+    <ClCompile Include="src\state\build_data\material_requirements\material_requirement_build_data_runtime.cpp" />
     <ClCompile Include="src\state\build_data\items\details\item_detail_catalog.cpp" />
+    <ClCompile Include="src\state\build_data\items\socket_plugs\socket_plug_catalog.cpp" />
+    <ClCompile Include="src\state\build_data\items\socket_plugs\socket_plug_build_data_runtime.cpp" />
     <ClCompile Include="src\state\build_data\constants\investment_constant_catalog.cpp" />
     <ClCompile Include="src\state\build_data\abilities\ability_bucket_catalog.cpp" />
     <ClCompile Include="src\state\build_data\progressions\progression_catalog.cpp" />
@@ -455,6 +479,7 @@
     <ClCompile Include="src\server\bap\encrypted\transactions\service_outcome_commit.cpp" />
     <ClCompile Include="src\server\bap\encrypted\queuez\queuez_state_validation.cpp" />
     <ClCompile Include="src\server\bap\encrypted\queuez\staging\queuez_family_staging.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\queuez\staging\queuez_family_refresh_staging.cpp" />
     <ClCompile Include="src\server\bap\encrypted\queuez\staging\queuez_character_staging.cpp" />
     <ClCompile Include="src\server\bap\encrypted\queuez\queuez_deferred_push.cpp" />
     <ClCompile Include="src\server\bap\encrypted\queuez\queuez_outcome_staging.cpp" />
@@ -476,12 +501,14 @@
     <ClCompile Include="src\server\bap\encrypted\push\snapshot\family4_selection_resolver.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\snapshot\family4_snapshot_preparer.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\snapshot\family4_selection_move.cpp" />
+    <ClCompile Include="src\server\bap\encrypted\push\snapshot\family4_inventory_updates.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\snapshot\initial_snapshot.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\snapshot\banner_snapshot.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\snapshot\roster_snapshot.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\snapshot\snapshot_storage.cpp" />
     <ClCompile Include="src\server\web_service\opcode_routes.cpp" />
     <ClCompile Include="src\server\web_service\web_service_runtime.cpp" />
+    <ClCompile Include="src\server\web_service\web_service_actions.cpp" />
     <ClCompile Include="src\server\http\server_http.cpp" />
     <ClCompile Include="src\middleware\runtime\middleware_runtime.cpp" />
     <ClCompile Include="src\middleware\protobuf\protobuf_measure.cpp" />
@@ -562,7 +589,10 @@
     <ClCompile Include="src\middleware\content\packages\tables\item_appearance_reader.cpp" />
     <ClCompile Include="src\middleware\content\packages\tables\ability_pool_reader.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_item_build.cpp" />
+    <ClCompile Include="src\client\content\items\packages\package_collectible_build.cpp" />
+    <ClCompile Include="src\client\content\items\packages\package_material_requirement_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_detail_build.cpp" />
+    <ClCompile Include="src\client\content\items\packages\package_socket_plug_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_ability_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_subclass_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_progression_build.cpp" />
@@ -607,6 +637,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" />
@@ -644,6 +717,8 @@
     <ClInclude Include="src\client\hooks\banner\banner_bind.h" />
     <ClInclude Include="src\client\hooks\banner\banner_hook_lifecycle.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode504.h" />
+    <ClInclude Include="src\middleware\web_service\messages\opcode903.h" />
+    <ClInclude Include="src\middleware\web_service\messages\opcode1901.h" />
     <ClInclude Include="src\core\ui\busy\busy.h" />
     <ClInclude Include="src\core\ui\busy\ui_busy_state.h" />
     <ClInclude Include="src\core\ui\notice\ui_notice_overlay.h" />
@@ -674,13 +749,12 @@
     <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\sword_skate\sword_skate_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\sword_skate\sword_skate.h" />
-    <ClInclude Include="src\client\ui\sword_skate\sword_skate_panel.h" />
-    <ClInclude Include="src\client\teleport\teleport_settings_store.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" />
@@ -766,6 +840,7 @@
     <ClInclude Include="src\state\unlocks\definition.h" />
     <ClInclude Include="src\state\unlocks\unlocks_runtime.h" />
     <ClInclude Include="src\state\runtime\runtime.h" />
+    <ClInclude Include="src\state\runtime\state_account_transaction_helpers.h" />
     <ClInclude Include="src\state\runtime\state.h" />
     <ClInclude Include="src\state\runtime\storage\internal.h" />
     <ClInclude Include="src\state\activity\definition.h" />
@@ -819,6 +894,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" />
@@ -832,8 +912,13 @@
     <ClInclude Include="src\state\build_data\cache\write\temporary\temporary_cache_file.h" />
     <ClInclude Include="src\state\build_data\cache\write\validation\cache_file_comparison.h" />
     <ClInclude Include="src\state\build_data\items\item_catalog.h" />
+    <ClInclude Include="src\state\build_data\collectibles\collectible_catalog.h" />
+    <ClInclude Include="src\state\build_data\material_requirements\material_requirement_catalog.h" />
     <ClInclude Include="src\state\build_data\items\details\definition.h" />
     <ClInclude Include="src\state\build_data\items\details\item_detail_catalog.h" />
+    <ClInclude Include="src\state\build_data\items\socket_plugs\definition.h" />
+    <ClInclude Include="src\state\build_data\items\socket_plugs\socket_plug_catalog.h" />
+    <ClInclude Include="src\client\content\items\packages\package_socket_plug_build.h" />
     <ClInclude Include="src\state\build_data\inventory\buckets\definition.h" />
     <ClInclude Include="src\state\build_data\inventory\buckets\inventory_bucket_catalog.h" />
     <ClInclude Include="src\state\build_data\socket_entry_lists\definition.h" />
@@ -876,6 +961,7 @@
     <ClInclude Include="src\server\bap\encrypted\push\snapshot\snapshot_storage.h" />
     <ClInclude Include="src\server\web_service\opcode_routes.h" />
     <ClInclude Include="src\server\web_service\web_service_runtime.h" />
+    <ClInclude Include="src\server\web_service\web_service_actions.h" />
     <ClInclude Include="src\server\http\server_http.h" />
     <ClInclude Include="src\middleware\runtime\middleware_runtime.h" />
     <ClInclude Include="src\middleware\protobuf\codec.h" />
@@ -1000,6 +1086,7 @@
     <ClInclude Include="src\client\hooks\network\investment\internal.h" />
     <ClInclude Include="src\client\hooks\queuez\internal.h" />
     <ClInclude Include="src\client\hooks\queuez\queuez_hook_lifecycle.h" />
+    <ClInclude Include="src\client\hooks\package_trust\package_trust_bypass.h" />
     <ClInclude Include="src\client\patterns\game\assert_handler\assert_signature_bytes.h" />
     <ClInclude Include="src\client\patterns\game\config_getter\config_getter_signature_bytes.h" />
     <ClInclude Include="src\client\patterns\game\packages\package_key_signature_bytes.h" />
@@ -1053,6 +1140,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>

+ 929 - 35
Sunrise/resources/default_settings.json

@@ -1,5 +1,5 @@
 {
-  "version": 3,
+  "version": 6,
   "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" },
@@ -129,35 +139,61 @@
     },
     "account": {
       "primary_soid": "0x9EAA300100100100",
+      "dismantle_rewards": [
+        {
+          "definition_hash": "0x3CF2E8E2",
+          "quantity": 4
+        },
+        {
+          "definition_hash": "0x28D6AC07",
+          "quantity": 3
+        }
+      ],
       "profile_items": [
-      {
-      "definition_hash": "0xBC53E66E",
-      "quantity": 10000
-      },
-      {
-      "definition_hash": "0x3CF2E8E2",
-      "quantity": 10000
-      },
-      {
-      "definition_hash": "0xA7EE4765",
-      "quantity": 10000
-      },
-      {
-      "definition_hash": "0x28D6AC07",
-      "quantity": 50
-      },
-      {
-      "definition_hash": "0xE5B38AD2",
-      "quantity": 50
-      },
-      {
-      "definition_hash": "0xFDC50EA0",
-      "quantity": 50
-      },
-      {
-      "definition_hash": "0xFDC50EA1",
-      "quantity": 10
-      }
+        {
+          "definition_hash": "0xBC53E66E",
+          "quantity": 250000
+        },
+        {
+          "definition_hash": "0x3CF2E8E2",
+          "quantity": 999999
+        },
+        {
+          "definition_hash": "0xA7EE4765",
+          "quantity": 999999
+        },
+        {
+          "definition_hash": "0xB19439E5",
+          "quantity": 25
+        },
+        {
+          "definition_hash": "0x28D6AC07",
+          "quantity": 9999
+        },
+        {
+          "definition_hash": "0xE5B38AD2",
+          "quantity": 999
+        },
+        {
+          "definition_hash": "0xFDC50EA0",
+          "quantity": 50
+        },
+        {
+          "definition_hash": "0xFDC50EA1",
+          "quantity": 10
+        },
+        {
+          "definition_hash": "0x54CCDFF2",
+          "quantity": 1000
+        },
+        {
+          "definition_hash": "0xDAAB73B4",
+          "quantity": 1000
+        },
+        {
+          "definition_hash": "0xCD8DA6F2",
+          "quantity": 1000
+        }
       ],
       "settings": {
         "controls": {
@@ -514,13 +550,299 @@
             "plugs": []
           },
           "finisher": {
+            "instance_soid": "0x4000000000000100",
+            "definition_hash": "0x09183EEF",
+            "level": 0,
+            "quantity": 1,
+            "plugs": []
+          }
+        },
+        "inventory": [
+          {
+            "instance_soid": "0x4000000000000011",
+            "definition_hash": "0x3E7B47F8",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000012",
+            "definition_hash": "0x26275FBA",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000013",
+            "definition_hash": "0xE9E6B958",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000014",
+            "definition_hash": "0xCA3F1095",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000015",
+            "definition_hash": "0xC4916786",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000016",
+            "definition_hash": "0xA25B8F8F",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000017",
+            "definition_hash": "0xDC0DA337",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000018",
+            "definition_hash": "0xDEF8BAA5",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000019",
+            "definition_hash": "0xDBF8EB6E",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x400000000000001A",
+            "definition_hash": "0xA1D55C26",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x400000000000001B",
+            "definition_hash": "0x670F94F7",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x400000000000001C",
+            "definition_hash": "0x40B7F441",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x400000000000001D",
+            "definition_hash": "0xD2DBA199",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x400000000000001E",
+            "definition_hash": "0x6F5C089C",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x400000000000001F",
+            "definition_hash": "0xA859BCA8",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000020",
+            "definition_hash": "0x65AD4E39",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000021",
+            "definition_hash": "0x5EFA7F9F",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000022",
+            "definition_hash": "0x1489642B",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000023",
+            "definition_hash": "0xC5162C2E",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000024",
+            "definition_hash": "0x27559F11",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000025",
+            "definition_hash": "0xD4DFDFE8",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000026",
+            "definition_hash": "0x34AD4160",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000027",
+            "definition_hash": "0x1684FA42",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000028",
+            "definition_hash": "0x0B8D3D9D",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000029",
+            "definition_hash": "0xF685696A",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x400000000000002A",
+            "definition_hash": "0x3083B063",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x400000000000002B",
+            "definition_hash": "0x35EE8905",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x400000000000002C",
+            "definition_hash": "0x4E86F9EC",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x400000000000002D",
+            "definition_hash": "0x71A87B55",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x400000000000002E",
+            "definition_hash": "0xA984374D",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x400000000000002F",
+            "definition_hash": "0xD8ACA0CE",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000030",
+            "definition_hash": "0xC7CBC15C",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000031",
+            "definition_hash": "0x11C83768",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000032",
+            "definition_hash": "0x5A6603AD",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null,
+            "flags": 0
+          },
+          {
+            "instance_soid": "0x4000000000000033",
+            "definition_hash": "0x4D24AB5D",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null,
+            "flags": 0
+          },
+          {
+            "instance_soid": "0x4000000000000034",
+            "definition_hash": "0x564C7E4C",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null,
+            "flags": 0
+          },
+          {
             "instance_soid": "0x4000000000000010",
             "definition_hash": "0xEF255BFB",
             "level": 0,
             "quantity": 1,
-            "plugs": []
+            "plugs": null,
+            "flags": 0
+          },
+          {
+            "instance_soid": "0x4000000000000035",
+            "definition_hash": "0x05B1C785",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000036",
+            "definition_hash": "0xA3CE6FD4",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000037",
+            "definition_hash": "0xE0B2E67F",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
           }
-        }
+        ]
       },
       {
         "soid": "0x9EAA300100100102",
@@ -747,13 +1069,299 @@
             "plugs": []
           },
           "finisher": {
+            "instance_soid": "0x4000000000000101",
+            "definition_hash": "0x09183EEF",
+            "level": 0,
+            "quantity": 1,
+            "plugs": []
+          }
+        },
+        "inventory": [
+          {
+            "instance_soid": "0x4000000000000075",
+            "definition_hash": "0x3E7B47F8",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000076",
+            "definition_hash": "0x26275FBA",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000077",
+            "definition_hash": "0xE9E6B958",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000078",
+            "definition_hash": "0xCA3F1095",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000079",
+            "definition_hash": "0xC4916786",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x400000000000007A",
+            "definition_hash": "0xA25B8F8F",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x400000000000007B",
+            "definition_hash": "0xDC0DA337",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x400000000000007C",
+            "definition_hash": "0xDEF8BAA5",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x400000000000007D",
+            "definition_hash": "0xDBF8EB6E",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x400000000000007E",
+            "definition_hash": "0x51ADBEB4",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x400000000000007F",
+            "definition_hash": "0xF2AF51CD",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000080",
+            "definition_hash": "0x966B9873",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000081",
+            "definition_hash": "0x1AAAE68F",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000082",
+            "definition_hash": "0x19DFB9AA",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000083",
+            "definition_hash": "0xF435CE34",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000084",
+            "definition_hash": "0x9537614D",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000085",
+            "definition_hash": "0xF29BFEF3",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000086",
+            "definition_hash": "0xBD32F60F",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000087",
+            "definition_hash": "0x7610202A",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000088",
+            "definition_hash": "0x103CB34D",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000089",
+            "definition_hash": "0x3723973C",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x400000000000008A",
+            "definition_hash": "0x9918CAA4",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x400000000000008B",
+            "definition_hash": "0x1287C646",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x400000000000008C",
+            "definition_hash": "0xC56EBCF9",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x400000000000008D",
+            "definition_hash": "0xF6856968",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x400000000000008E",
+            "definition_hash": "0xAA583AC8",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x400000000000008F",
+            "definition_hash": "0x45F41F39",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000090",
+            "definition_hash": "0x7B387201",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000091",
+            "definition_hash": "0xCAE2C4B0",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000092",
+            "definition_hash": "0xF322966C",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000093",
+            "definition_hash": "0xEAE9FA4A",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000094",
+            "definition_hash": "0xC7CBC15C",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000095",
+            "definition_hash": "0x11C83768",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x4000000000000096",
+            "definition_hash": "0xBB3CAA51",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null,
+            "flags": 0
+          },
+          {
+            "instance_soid": "0x4000000000000097",
+            "definition_hash": "0xCF1C4EF3",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null,
+            "flags": 0
+          },
+          {
+            "instance_soid": "0x4000000000000098",
+            "definition_hash": "0x29905E7A",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null,
+            "flags": 0
+          },
+          {
             "instance_soid": "0x4000000000000074",
             "definition_hash": "0xAA223B79",
             "level": 0,
             "quantity": 1,
-            "plugs": []
+            "plugs": null,
+            "flags": 0
+          },
+          {
+            "instance_soid": "0x4000000000000099",
+            "definition_hash": "0x1FA2AD3E",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x400000000000009A",
+            "definition_hash": "0x30A14DDC",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x400000000000009B",
+            "definition_hash": "0x46D82092",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
           }
-        }
+        ]
       },
       {
         "soid": "0x9EAA300100100103",
@@ -980,13 +1588,299 @@
             "plugs": []
           },
           "finisher": {
+            "instance_soid": "0x4000000000000102",
+            "definition_hash": "0x09183EEF",
+            "level": 0,
+            "quantity": 1,
+            "plugs": []
+          }
+        },
+        "inventory": [
+          {
+            "instance_soid": "0x40000000000000D9",
+            "definition_hash": "0x3E7B47F8",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000DA",
+            "definition_hash": "0x26275FBA",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000DB",
+            "definition_hash": "0xE9E6B958",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000DC",
+            "definition_hash": "0xCA3F1095",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000DD",
+            "definition_hash": "0xC4916786",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000DE",
+            "definition_hash": "0xA25B8F8F",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000DF",
+            "definition_hash": "0xDC0DA337",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000E0",
+            "definition_hash": "0xDEF8BAA5",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000E1",
+            "definition_hash": "0xDBF8EB6E",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000E2",
+            "definition_hash": "0x5CA160F5",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000E3",
+            "definition_hash": "0x0DB45944",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000E4",
+            "definition_hash": "0xC9F7B53C",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000E5",
+            "definition_hash": "0x80C7AB6E",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000E6",
+            "definition_hash": "0x326316B1",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000E7",
+            "definition_hash": "0x0B117DAB",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000E8",
+            "definition_hash": "0xA496C19A",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000E9",
+            "definition_hash": "0xAA71C8EE",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000EA",
+            "definition_hash": "0x0B4089D4",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000EB",
+            "definition_hash": "0xD6B2A7B7",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000EC",
+            "definition_hash": "0x5B1ACD6C",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000ED",
+            "definition_hash": "0x28B26925",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000EE",
+            "definition_hash": "0xDC124D7B",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000EF",
+            "definition_hash": "0x3D663DA7",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000F0",
+            "definition_hash": "0x7A1FC812",
+            "level": 106,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000F1",
+            "definition_hash": "0x3083B063",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000F2",
+            "definition_hash": "0x2E71E54E",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000F3",
+            "definition_hash": "0x4E86F9EC",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000F4",
+            "definition_hash": "0xC9993382",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000F5",
+            "definition_hash": "0xA984374D",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000F6",
+            "definition_hash": "0x28155DF4",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000F7",
+            "definition_hash": "0xD4753C25",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000F8",
+            "definition_hash": "0xC7CBC15C",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000F9",
+            "definition_hash": "0x11C83768",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000FA",
+            "definition_hash": "0x58E8183A",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null,
+            "flags": 0
+          },
+          {
+            "instance_soid": "0x40000000000000FB",
+            "definition_hash": "0x58F778E0",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null,
+            "flags": 0
+          },
+          {
+            "instance_soid": "0x40000000000000FC",
+            "definition_hash": "0xCABFBE57",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null,
+            "flags": 0
+          },
+          {
             "instance_soid": "0x40000000000000D8",
             "definition_hash": "0x0F2B9504",
             "level": 0,
             "quantity": 1,
-            "plugs": []
+            "plugs": null,
+            "flags": 0
+          },
+          {
+            "instance_soid": "0x40000000000000FD",
+            "definition_hash": "0xF5095792",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000FE",
+            "definition_hash": "0xEEF8905A",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
+          },
+          {
+            "instance_soid": "0x40000000000000FF",
+            "definition_hash": "0x6846D9F9",
+            "level": 0,
+            "quantity": 1,
+            "plugs": null
           }
-        }
+        ]
       }
     ]
   }

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

+ 3 - 2
Sunrise/src/client/content/investment/internal.h

@@ -5,7 +5,8 @@
 
 namespace sunrise::client::content::investment {
 
-/** @return True when the next refresh slice must hold the process for a package sweep. */
-[[nodiscard]] bool requires_process_freeze() noexcept;
+/** @return True when the next refresh slice needs one presented overlay before its package sweep.
+ */
+[[nodiscard]] bool requires_package_sweep() noexcept;
 
 } // namespace sunrise::client::content::investment

+ 14 - 58
Sunrise/src/client/content/investment/investment_refresh.cpp

@@ -1,15 +1,9 @@
 #include <Windows.h>
 
-#include <array>
-#include <cstdio>
-#include <string_view>
-
-#include "../../../core/logging/log.h"
 #include "../../../core/ui/busy/busy.h"
 #include "../../../middleware/content/packages/reader/reader.h"
 #include "../../../state/build_data/runtime.h"
 #include "../../../state/runtime/runtime.h"
-#include "../../process/freeze/client_process_freeze.h"
 #include "../items/packages/build.h"
 #include "internal.h"
 #include "runtime.h"
@@ -19,9 +13,6 @@ namespace {
 
 SRWLOCK g_refreshLock{SRWLOCK_INIT};
 
-/** One line reports the freeze, so a run that could not hold the game is visible. */
-constexpr std::size_t kLineLimit = 96;
-
 /**
  * @return True when every persistent mapping domain is fully published.
  * The destination layouts and spawn sets belong here even though they are not equipment mappings.
@@ -30,7 +21,10 @@ constexpr std::size_t kLineLimit = 96;
  */
 [[nodiscard]] bool ready() noexcept {
     return state::build_data::named_catalog_ready() && state::build_data::item_definitions_ready()
+           && state::build_data::collectible_definitions_ready()
+           && state::build_data::material_requirement_sets_ready()
            && state::build_data::configured_item_details_ready()
+           && state::build_data::socket_plug_rules_ready()
            && state::build_data::inventory_bucket_descriptors_ready()
            && state::build_data::socket_entry_lists_ready()
            && state::build_data::ability_buckets_ready()
@@ -40,34 +34,10 @@ constexpr std::size_t kLineLimit = 96;
            && state::build_data::investment_constants_ready();
 }
 
-/**
- * Reports the outcome of one freeze attempt.
- * @param frozen True when the game was held.
- * @param threadCount Threads that were suspended.
- */
-void report_freeze(bool frozen, std::size_t threadCount) noexcept {
-    std::array<char, kLineLimit> line{};
-    const int written = std::snprintf(line.data(),
-                                      line.size(),
-                                      "ev=extract stage=freeze result=%s threads=%zu",
-                                      frozen ? "ok" : "fail",
-                                      threadCount);
-    if (written <= 0) {
-        return;
-    }
-    const auto length = static_cast<std::size_t>(written) < line.size()
-                            ? static_cast<std::size_t>(written)
-                            : line.size() - 1;
-    // The pass runs in slices, so a working freeze reports at debug and only a failure is loud.
-    core::log::write(core::log::Channel::client,
-                     frozen ? core::log::Level::debug : core::log::Level::warn,
-                     std::string_view(line.data(), length));
-}
-
 } // namespace
 
-/** @return True when the next refresh slice must hold the process for a package sweep. */
-bool requires_process_freeze() noexcept {
+/** @return True when the next refresh slice needs a visible overlay for a package sweep. */
+bool requires_package_sweep() noexcept {
     return !state::build_data::item_definitions_ready() && items::packages::readable();
 }
 
@@ -77,7 +47,8 @@ bool refresh() noexcept {
         // The same lock as the extraction path. A cache write holds its own lock across file
         // calls, so a held thread stopped inside one would deadlock the freeze below.
         AcquireSRWLockExclusive(&g_refreshLock);
-        const bool persisted = state::build_data::persist();
+        const bool persisted =
+            state::ensure_profile_item_identities() && state::build_data::persist();
         // Nothing reads a package again until the next boot, so the open files and the held
         // tables go back now rather than at process exit.
         middleware::content::packages::reader::release_caches();
@@ -87,35 +58,20 @@ bool refresh() noexcept {
     }
 
     AcquireSRWLockExclusive(&g_refreshLock);
-    // Only the item sweep holds the game, and only once the block keys exist. The destination and
-    // spawn-set passes after it are tens of thousands of tag reads over many slices, so the
-    // overlay covers the whole pass: without it the longest stall of the boot has nothing on
-    // screen.
-    const bool sweeping = requires_process_freeze();
-    process::freeze::Held held{};
-    bool frozen = false;
-    std::size_t heldThreads = 0;
-    if (sweeping) {
-        // The overlay reaches the screen before the freeze stops the frame loop. A held game
-        // cannot time its connection out, which a slow disk otherwise causes here.
-        core::ui::busy::begin(core::ui::busy::Task::contentExtraction);
-        frozen = process::freeze::hold(held);
-        heldThreads = held.count;
-    } else {
-        core::ui::busy::raise(core::ui::busy::Task::contentExtraction);
-    }
+    // The package pass creates parallel readers. Suspending the client while those threads start
+    // can block their DLL thread-attach work behind a suspended owner, so the visible preflight
+    // runs one frame early and extraction proceeds with the process live.
+    core::ui::busy::raise(core::ui::busy::Task::contentExtraction);
     // The package pass owns the item table and must not wait on runtime content lookups.
     (void)items::packages::build();
-    const bool complete = ready() && state::build_data::persist();
-    process::freeze::release(held);
+    const bool domainsReady = ready();
+    const bool complete =
+        domainsReady && state::ensure_profile_item_identities() && state::build_data::persist();
     // The overlay ends with the work, not with the slice, so it spans every retry the pass needs.
     if (complete) {
         core::ui::busy::end(core::ui::busy::Task::contentExtraction);
     }
     ReleaseSRWLockExclusive(&g_refreshLock);
-    if (sweeping) {
-        report_freeze(frozen, heldThreads);
-    }
     return complete;
 }
 

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

@@ -46,7 +46,7 @@ void service(std::uint64_t nowMilliseconds) noexcept {
     }
     g_nextEligible = nowMilliseconds + kRefreshIntervalMilliseconds;
 
-    if (sunrise::client::content::investment::requires_process_freeze()) {
+    if (sunrise::client::content::investment::requires_package_sweep()) {
         g_overlayPending = true;
         if (sunrise::core::ui::busy::raise_early(
                 sunrise::core::ui::busy::Task::contentExtraction)) {

+ 3 - 3
Sunrise/src/client/content/items/details/configured_item_detail_extractor.cpp

@@ -1,10 +1,10 @@
 #include "configured_item_detail_extractor.h"
 
 #include <algorithm>
-#include <array>
 #include <bitset>
 #include <cstddef>
 #include <cstdint>
+#include <vector>
 
 #include "../../../../state/build_data/items/item_catalog.h"
 #include "../../../../state/build_data/socket_entry_lists/definition.h"
@@ -94,7 +94,7 @@ bool extract(const investment::Source& source,
     }
 
     std::bitset<build_items::kDefinitionCapacity> seen{};
-    std::array<build_details::Definition, build_details::kDefinitionCapacity> staged{};
+    std::vector<build_details::Definition> staged(requestedDefinitionIndices.size());
     for (std::size_t position = 0; position < requestedDefinitionIndices.size(); ++position) {
         const std::uint16_t definitionIndex = requestedDefinitionIndices[position];
         if (static_cast<std::size_t>(definitionIndex) >= table.rowCount
@@ -112,7 +112,7 @@ bool extract(const investment::Source& source,
     if (!stable_count(source, table)) {
         return false;
     }
-    std::copy_n(staged.begin(), requestedDefinitionIndices.size(), output.begin());
+    std::copy_n(staged.data(), requestedDefinitionIndices.size(), output.begin());
     count = requestedDefinitionIndices.size();
     return true;
 }

+ 129 - 23
Sunrise/src/client/content/items/packages/internal.h

@@ -1,6 +1,7 @@
 #pragma once
 
 #include <array>
+#include <bitset>
 #include <cstddef>
 #include <cstdint>
 #include <span>
@@ -11,20 +12,27 @@
 #include "../../../../middleware/content/packages/tables/definition_index_table.h"
 #include "../../../../middleware/content/packages/tables/items.h"
 #include "../../../../state/build_data/abilities/definition.h"
+#include "../../../../state/build_data/collectibles/collectible_catalog.h"
 #include "../../../../state/build_data/constants/definition.h"
+#include "../../../../state/build_data/inventory/buckets/definition.h"
 #include "../../../../state/build_data/items/details/definition.h"
 #include "../../../../state/build_data/items/item_catalog.h"
+#include "../../../../state/build_data/material_requirements/material_requirement_catalog.h"
 #include "../../../../state/build_data/progressions/definition.h"
+#include "../../../../state/build_data/runtime.h"
 
 namespace sunrise::client::content::items::packages {
 
 namespace reader = middleware::content::packages::reader;
 namespace tables = middleware::content::packages::tables;
 
-/** Configured equipment rows plus every plug they socket. */
+/** Equippable rows, their initial plugs, and authored override plugs. */
 inline constexpr std::size_t kDetailCapacity =
     state::build_data::items::details::kDefinitionCapacity;
 
+/** Native item indices selected for the deduplicated detail closure. */
+using DetailRequests = std::bitset<state::build_data::items::kDefinitionCapacity>;
+
 /** Authored definition hashes one pass looks for while walking the item index table. */
 struct AuthoredHashes {
     std::array<std::uint32_t, kDetailCapacity> values{};
@@ -51,8 +59,20 @@ struct Storage {
     std::vector<std::byte> child{};
     std::vector<std::byte> root{};
     std::vector<std::byte> definition{};
-    std::array<std::uint16_t, kDetailCapacity> requested{};
-    std::array<state::build_data::items::details::Definition, kDetailCapacity> details{};
+    /** Shared reusable/randomized plug-set table read from investment-root slot 51. */
+    std::vector<std::byte> plugSetTable{};
+    /** Compact 0..3 special plug-category code of every dense installed item row. */
+    std::array<std::uint8_t, state::build_data::items::kDefinitionCapacity> specialPlugCategories{};
+    /** Inventory routing rows held until the paired bucket-definition table is resolved. */
+    std::array<state::build_data::inventory::buckets::Descriptor,
+               state::build_data::inventory::buckets::kDescriptorCapacity>
+        bucketRows{};
+    std::size_t bucketCount{};
+    std::array<std::int8_t, state::build_data::inventory::buckets::kDescriptorCapacity>
+        equipmentSlotByBucket{};
+    DetailRequests detailRequests{};
+    std::array<std::uint16_t, kDetailCapacity> requestedDetailIndices{};
+    std::vector<state::build_data::items::details::Definition> details{};
     AuthoredHashes authoredHashes{};
     std::vector<std::byte> abilityTable{};
     std::vector<std::byte> abilityPool{};
@@ -62,6 +82,12 @@ struct Storage {
     std::array<state::build_data::progressions::Definition,
                state::build_data::progressions::kDefinitionCapacity>
         progressionRows{};
+    std::array<state::build_data::collectibles::Definition,
+               state::build_data::collectibles::kDefinitionCapacity>
+        collectibleRows{};
+    std::array<state::build_data::material_requirements::Definition,
+               state::build_data::material_requirements::kDefinitionCapacity>
+        materialRequirementRows{};
     std::array<state::build_data::items::Definition, state::build_data::items::kDefinitionCapacity>
         rows{};
 };
@@ -79,41 +105,62 @@ struct Storage {
 [[nodiscard]] bool authored(const AuthoredHashes& hashes, std::uint32_t hash) noexcept;
 
 /**
- * Adds one definition index to the requested set.
- * @param definitionIndex Native item index.
+ * @param row Installed item row.
+ * @return True when its bucket maps to an equipment slot
+ * supported by the generated loadout.
+ */
+[[nodiscard]] bool equippable(const tables::items::Row& row) noexcept;
+
+/** Publishes parsed inventory buckets after applying the extracted item-slot relation. */
+[[nodiscard]] bool publish_buckets(Storage& storage) noexcept;
+
+/**
+ * Adds one definition index to the deduplicated requested set.
+ * @param definitionIndex Native
+ * item index.
  * @param requested Requested-set storage.
- * @param count Used entries, advanced on success.
- * @return True when the index fits.
  */
-[[nodiscard]] bool request(std::uint16_t definitionIndex,
-                           std::span<std::uint16_t> requested,
-                           std::size_t& count) noexcept;
+void request(std::uint16_t definitionIndex, DetailRequests& requested) noexcept;
 
 /**
  * Adds every socket lane's initial plug to the requested set.
- * A lane the authored loadout leaves unset falls back to this plug, so its detail must exist.
- * @param row Item row already read from its definition blob.
+ * A lane using native defaults falls back to this plug, so its detail must exist.
+ * @param row
+ * Item row already read from its definition blob.
+ * @param itemDefinitionCount Installed
+ * item-table bound.
  * @param requested Requested-set storage.
- * @param count Used entries, advanced per added lane.
- * @return True when every lane fits.
  */
-[[nodiscard]] bool append_initial_plugs(const tables::items::Row& row,
-                                        std::span<std::uint16_t> requested,
-                                        std::size_t& count) noexcept;
+void append_initial_plugs(const tables::items::Row& row,
+                          std::uint64_t itemDefinitionCount,
+                          DetailRequests& requested) noexcept;
 
-/** @param requested Requested-set storage. @param count Sorted and deduplicated in place. */
-void compact_requested(std::span<std::uint16_t> requested, std::size_t& count) noexcept;
+/**
+ * Materializes requested native indices in ascending order.
+ * @param requested Deduplicated
+ * native-index set.
+ * @param output Fixed detail-index storage.
+ * @param count Receives the
+ * number of selected rows, or zero when output is too small.
+ * @return True when every selected
+ * row fits.
+ */
+[[nodiscard]] bool materialize_requests(const DetailRequests& requested,
+                                        std::span<std::uint16_t> output,
+                                        std::size_t& count) noexcept;
 
 /**
  * Reads one requested definition and turns it into its cached detail form.
  * @param source Located item index table and package reader state.
  * @param definitionIndex Native item index.
  * @param detail Receives the cached detail.
+ * @param item Receives the raw definition row the detail was built from.
  * @return True when the row is found and its definition blob reads.
  */
 [[nodiscard]] bool build_detail(const DetailSource& source,
                                 std::uint16_t definitionIndex,
-                                state::build_data::items::details::Definition& detail) noexcept;
+                                state::build_data::items::details::Definition& detail,
+                                tables::items::Row& item) noexcept;
 
 /**
  * Reads the stat rows the installed investment constants blob names.
@@ -217,10 +264,46 @@ void report_detail_failure(std::size_t slot, std::uint16_t definitionIndex) noex
 /** @param count Ability bucket rows the pass built, one per subclass and ability selection. */
 void report_ability_count(std::size_t count) noexcept;
 
-/** @param count Detail rows the pass built, covering equipped items and every plug they socket. */
-void report_detail_count(std::size_t count) noexcept;
+/** Reports a bounded number of exact subclass/ability extraction failures per process. */
+void report_ability_failure(const char* stage,
+                            std::size_t character,
+                            std::size_t first,
+                            std::size_t second) noexcept;
 
-/** Reports the pass outcome once. @param published Rows published, or zero on failure. */
+/** Reports requested, retained, and skipped detail closure rows. */
+void report_detail_count(std::size_t requested, std::size_t built) noexcept;
+
+/**
+ * Reports the item index-table walk, including rows the pass could not read.
+ * @param walked Table entries the pass visited.
+ * @param rows Readable rows it retained.
+ * @param skipped Entries omitted because their index row or definition was malformed.
+ * @param truncated True when the walk stopped early because the row storage filled.
+ */
+void report_row_count(std::size_t walked,
+                      std::size_t rows,
+                      std::size_t skipped,
+                      bool truncated) noexcept;
+
+/** Reports the exact socket-rule, deduplicated-pool, member, and skipped-lane counts. */
+void report_socket_plug_count(std::size_t rules,
+                              std::size_t pools,
+                              std::size_t members,
+                              std::size_t skipped) noexcept;
+
+/** Reports the validated installed bucket/equipment-slot coverage. */
+void report_bucket_equipment_mapping(std::size_t mappedSlots) noexcept;
+
+/** Reports the first rejected bucket-definition invariant in one process. */
+void report_bucket_equipment_failure(const char* stage,
+                                     std::size_t first,
+                                     std::size_t second) noexcept;
+
+/**
+ * Reports the pass outcome once.
+ * @param published Rows published, or zero on failure.
+ * @param reason Stage the pass reached, named in the line.
+ */
 void report(std::size_t published, const char* reason) noexcept;
 
 /**
@@ -245,6 +328,23 @@ void report(std::size_t published, const char* reason) noexcept;
                                             Storage& storage,
                                             std::span<const std::byte> root) noexcept;
 
+/**
+ * Reads and publishes the root's dense collectible-to-item mapping table.
+ * @param source
+ * Package source.
+ * @param storage Pass storage, including scratch bytes and bounded row storage.
+
+ * * @param root Investment root bytes.
+ * @param itemDefinitionCount Number of rows in the
+ * installed item index table.
+ * @return True when every tag, class, bound, and item link validates
+ * and publishes.
+ */
+[[nodiscard]] bool build_collectibles(const reader::Source& source,
+                                      Storage& storage,
+                                      std::span<const std::byte> root,
+                                      std::uint64_t itemDefinitionCount) noexcept;
+
 /**
  * Walks the located item index table, then publishes every domain that depends on it.
  * @param source Package source.
@@ -260,4 +360,10 @@ void report(std::size_t published, const char* reason) noexcept;
                                    std::size_t& rowCount,
                                    const char*& reason) noexcept;
 
+/** Reads and publishes every native material-requirement set from investment-root slot 96. */
+[[nodiscard]] bool build_material_requirements(const reader::Source& source,
+                                               Storage& storage,
+                                               std::span<const std::byte> root,
+                                               std::uint64_t itemDefinitionCount) noexcept;
+
 } // namespace sunrise::client::content::items::packages

+ 124 - 6
Sunrise/src/client/content/items/packages/package_build_report.cpp

@@ -3,12 +3,15 @@
 #include <cstdio>
 
 #include "../../../../core/logging/log.h"
+#include "../../../../middleware/content/packages/tables/definition_index_table.h"
 #include "internal.h"
 
 namespace sunrise::client::content::items::packages {
 namespace {
 
 std::atomic<bool> g_reported{};
+std::atomic<bool> g_bucketFailureReported{};
+std::atomic<std::size_t> g_abilityFailureReports{};
 
 } // namespace
 
@@ -39,11 +42,42 @@ void report_ability_count(std::size_t count) noexcept {
     }
 }
 
-/** @param count Detail rows the pass built, covering equipped items and every plug they socket. */
-void report_detail_count(std::size_t count) noexcept {
-    std::array<char, 96> line{};
-    const int written =
-        std::snprintf(line.data(), line.size(), "ev=pkg stage=details result=ok rows=%zu", count);
+/** Reports the precise ability boundary without flooding the periodic extraction retry. */
+void report_ability_failure(const char* stage,
+                            std::size_t character,
+                            std::size_t first,
+                            std::size_t second) noexcept {
+    // Extraction retries on every boot pass, so the count is capped to keep one failure readable.
+    constexpr std::size_t kReportLimit = 12;
+    if (g_abilityFailureReports.fetch_add(1, std::memory_order_relaxed) >= kReportLimit) {
+        return;
+    }
+    std::array<char, 176> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=pkg stage=ability_failure reason=%s character=%zu "
+                                      "first=%zu second=%zu",
+                                      stage,
+                                      character,
+                                      first,
+                                      second);
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/** Reports requested, retained, and skipped rows for the equippable-item detail closure. */
+void report_detail_count(std::size_t requested, std::size_t built) noexcept {
+    std::array<char, 128> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=pkg stage=details result=ok requested=%zu rows=%zu "
+                                      "skipped=%zu",
+                                      requested,
+                                      built,
+                                      requested - built);
     if (written > 0) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::info,
@@ -51,7 +85,91 @@ void report_detail_count(std::size_t count) noexcept {
     }
 }
 
-/** Reports the pass outcome once. @param published Rows published, or zero on failure. */
+/** Reports the item index-table walk and every entry it could not read. */
+void report_row_count(std::size_t walked,
+                      std::size_t rows,
+                      std::size_t skipped,
+                      bool truncated) noexcept {
+    std::array<char, 128> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=pkg stage=rows result=ok walked=%zu rows=%zu "
+                                      "skipped=%zu truncated=%u",
+                                      walked,
+                                      rows,
+                                      skipped,
+                                      static_cast<unsigned>(truncated));
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         skipped == 0 && !truncated ? core::log::Level::info
+                                                    : core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/** Reports the bounded exact ordinary-socket relation extracted from the installed packages. */
+void report_socket_plug_count(std::size_t rules,
+                              std::size_t pools,
+                              std::size_t members,
+                              std::size_t skipped) noexcept {
+    std::array<char, 160> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=pkg stage=socket_plugs result=ok rules=%zu pools=%zu "
+                                      "members=%zu skipped=%zu",
+                                      rules,
+                                      pools,
+                                      members,
+                                      skipped);
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         skipped == 0 ? core::log::Level::info : core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/** Reports the installed bucket definition relation used by loadout resolution. */
+void report_bucket_equipment_mapping(std::size_t mappedSlots) noexcept {
+    std::array<char, 128> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=pkg stage=bucket_equipment result=ok rows=%zu mapped=%zu",
+                                      tables::kBucketDefinitionCount,
+                                      mappedSlots);
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/** Reports one fail-closed bucket-definition rejection without flooding extraction retries. */
+void report_bucket_equipment_failure(const char* stage,
+                                     std::size_t first,
+                                     std::size_t second) noexcept {
+    if (g_bucketFailureReported.exchange(true, std::memory_order_relaxed)) {
+        return;
+    }
+    std::array<char, 160> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=pkg stage=bucket_equipment result=fail reason=%s "
+                                      "first=%zu second=%zu",
+                                      stage,
+                                      first,
+                                      second);
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Reports the pass outcome once.
+ * @param published Rows published, or zero on failure.
+ * @param reason Stage the pass reached, named in the line.
+ */
 void report(std::size_t published, const char* reason) noexcept {
     if (g_reported.exchange(true, std::memory_order_relaxed)) {
         return;

+ 176 - 0
Sunrise/src/client/content/items/packages/package_collectible_build.cpp

@@ -0,0 +1,176 @@
+#include <cstring>
+#include <limits>
+
+#include "../../../../state/build_data/runtime.h"
+#include "internal.h"
+
+namespace sunrise::client::content::items::packages {
+
+/** Reads the fixed collectible table and publishes its native-index item links. */
+bool build_collectibles(const reader::Source& source,
+                        Storage& storage,
+                        std::span<const std::byte> root,
+                        std::uint64_t itemDefinitionCount) noexcept {
+    namespace domain = state::build_data::collectibles;
+    if (state::build_data::collectible_definitions_ready()) {
+        return true;
+    }
+    if (itemDefinitionCount == 0
+        || itemDefinitionCount > state::build_data::items::kDefinitionCapacity) {
+        return false;
+    }
+
+    std::uint32_t requirementTableTag = 0;
+    std::uint32_t requirementTableClass = 0;
+    tables::Array requirementSets{};
+    if (!tables::slot_tag(root, tables::kMaterialRequirementTableSlot, requirementTableTag)
+        || requirementTableTag == 0
+        || tables::package_of(requirementTableTag) == tables::kAbsentPackageId
+        || !reader::read_tag(
+            source, storage.scratch, requirementTableTag, storage.definition, requirementTableClass)
+        || requirementTableClass != tables::kMaterialRequirementTableClass
+        || !tables::find_array_at(std::span<const std::byte>{storage.definition},
+                                  tables::kTableArrayDescriptor,
+                                  requirementSets)
+        || requirementSets.elementClass != tables::kMaterialRequirementSetRowClass
+        || requirementSets.count == 0
+        || requirementSets.count > (std::numeric_limits<std::uint16_t>::max)()) {
+        return false;
+    }
+    const std::span<const std::byte> requirementTable{storage.definition};
+    if (requirementSets.dataOffset > requirementTable.size()
+        || requirementSets.count > (requirementTable.size() - requirementSets.dataOffset)
+                                       / tables::kMaterialRequirementSetRowStride) {
+        return false;
+    }
+
+    std::uint32_t tableTag = 0;
+    std::uint32_t tableClass = 0;
+    tables::Array rows{};
+    if (!tables::slot_tag(root, tables::kCollectibleTableSlot, tableTag) || tableTag == 0
+        || tables::package_of(tableTag) == tables::kAbsentPackageId
+        || !reader::read_tag(source, storage.scratch, tableTag, storage.child, tableClass)
+        || tableClass != tables::kCollectibleTableClass
+        || !tables::find_array_at(
+            std::span<const std::byte>{storage.child}, tables::kTableArrayDescriptor, rows)
+        || rows.elementClass != tables::kCollectibleRowClass || rows.count == 0
+        || rows.count > storage.collectibleRows.size()) {
+        return false;
+    }
+
+    const std::span<const std::byte> table{storage.child};
+    if (rows.dataOffset > table.size()
+        || rows.count > (table.size() - rows.dataOffset) / tables::kCollectibleRowStride) {
+        return false;
+    }
+    for (std::uint64_t row = 0; row < rows.count; ++row) {
+        const std::size_t at =
+            rows.dataOffset + static_cast<std::size_t>(row) * tables::kCollectibleRowStride;
+        std::uint32_t collectibleHash = 0;
+        std::uint16_t itemDefinitionIndex = domain::kUnavailableItemDefinitionIndex;
+        std::uint16_t requirementSetIndex = domain::kUnavailableMaterialRequirementSetIndex;
+        std::memcpy(&collectibleHash,
+                    table.data() + at + tables::kCollectibleHashOffset,
+                    sizeof collectibleHash);
+        std::memcpy(&itemDefinitionIndex,
+                    table.data() + at + tables::kCollectibleItemIndexOffset,
+                    sizeof itemDefinitionIndex);
+        std::memcpy(&requirementSetIndex,
+                    table.data() + at + tables::kCollectibleMaterialRequirementIndexOffset,
+                    sizeof requirementSetIndex);
+        if (itemDefinitionIndex != domain::kUnavailableItemDefinitionIndex
+            && itemDefinitionIndex >= itemDefinitionCount) {
+            return false;
+        }
+        domain::Definition& output = storage.collectibleRows[static_cast<std::size_t>(row)];
+        output = {};
+        output.collectibleHash = collectibleHash;
+        output.collectibleIndex = static_cast<std::uint16_t>(row);
+        output.itemDefinitionIndex = itemDefinitionIndex;
+        if (requirementSetIndex == domain::kUnavailableMaterialRequirementSetIndex) {
+            continue;
+        }
+        if (requirementSetIndex >= requirementSets.count) {
+            return false;
+        }
+        const std::size_t setAt = requirementSets.dataOffset
+                                  + static_cast<std::size_t>(requirementSetIndex)
+                                        * tables::kMaterialRequirementSetRowStride;
+        std::uint32_t requirementSetHash = 0;
+        std::int64_t descriptorRelative = 0;
+        std::memcpy(&requirementSetHash,
+                    requirementTable.data() + setAt + tables::kMaterialRequirementSetHashOffset,
+                    sizeof requirementSetHash);
+        std::memcpy(&descriptorRelative,
+                    requirementTable.data() + setAt
+                        + tables::kMaterialRequirementSetArrayPointerOffset,
+                    sizeof descriptorRelative);
+        const std::size_t pointerAt = setAt + tables::kMaterialRequirementSetArrayPointerOffset;
+        if (requirementSetHash == 0 || descriptorRelative < -static_cast<std::int64_t>(pointerAt)
+            || descriptorRelative
+                   > static_cast<std::int64_t>(requirementTable.size() - pointerAt)) {
+            return false;
+        }
+        const auto descriptorSigned = static_cast<std::int64_t>(pointerAt) + descriptorRelative;
+        if (descriptorSigned < 0) {
+            return false;
+        }
+        tables::Array requirements{};
+        if (!tables::find_array_at(
+                requirementTable, static_cast<std::size_t>(descriptorSigned), requirements)
+            || requirements.elementClass != tables::kMaterialRequirementRowClass
+            || requirements.count == 0 || requirements.count > output.materialRequirements.size()
+            || requirements.dataOffset > requirementTable.size()
+            || requirements.count > (requirementTable.size() - requirements.dataOffset)
+                                        / tables::kMaterialRequirementRowStride) {
+            return false;
+        }
+        output.materialRequirementSetHash = requirementSetHash;
+        output.materialRequirementSetIndex = requirementSetIndex;
+        output.materialRequirementCount = static_cast<std::uint8_t>(requirements.count);
+        for (std::size_t requirement = 0; requirement < requirements.count; ++requirement) {
+            const std::size_t requirementAt =
+                requirements.dataOffset + requirement * tables::kMaterialRequirementRowStride;
+            std::uint32_t nativeItemIndex = 0;
+            std::uint32_t quantity = 0;
+            std::uint8_t deleteOnAction = 0;
+            std::uint8_t omitFromRequirements = 0;
+            std::uint16_t sentinel = 0;
+            std::memcpy(&nativeItemIndex,
+                        requirementTable.data() + requirementAt
+                            + tables::kMaterialRequirementItemIndexOffset,
+                        sizeof nativeItemIndex);
+            std::memcpy(&quantity,
+                        requirementTable.data() + requirementAt
+                            + tables::kMaterialRequirementQuantityOffset,
+                        sizeof quantity);
+            std::memcpy(&deleteOnAction,
+                        requirementTable.data() + requirementAt
+                            + tables::kMaterialRequirementDeleteOffset,
+                        sizeof deleteOnAction);
+            std::memcpy(&omitFromRequirements,
+                        requirementTable.data() + requirementAt
+                            + tables::kMaterialRequirementOmitOffset,
+                        sizeof omitFromRequirements);
+            std::memcpy(&sentinel,
+                        requirementTable.data() + requirementAt
+                            + tables::kMaterialRequirementSentinelOffset,
+                        sizeof sentinel);
+            if (nativeItemIndex >= itemDefinitionCount
+                || nativeItemIndex > (std::numeric_limits<std::uint16_t>::max)() || quantity == 0
+                || deleteOnAction > 1 || omitFromRequirements > 1 || sentinel != 0xFFFFU) {
+                return false;
+            }
+            output.materialRequirements[requirement] = {
+                quantity,
+                static_cast<std::uint16_t>(nativeItemIndex),
+                deleteOnAction != 0,
+                omitFromRequirements != 0,
+            };
+        }
+    }
+    return state::build_data::publish_collectible_definitions(
+        std::span(storage.collectibleRows).first(static_cast<std::size_t>(rows.count)));
+}
+
+} // namespace sunrise::client::content::items::packages

+ 89 - 55
Sunrise/src/client/content/items/packages/package_detail_build.cpp

@@ -2,7 +2,6 @@
 #include <array>
 #include <cstring>
 #include <optional>
-#include <utility>
 
 #include "../../../../state/account/account_state.h"
 #include "../../../../state/runtime/runtime.h"
@@ -13,32 +12,13 @@ namespace {
 
 namespace domain = state::build_data::items::details;
 
-/** Equipment slot for each equippable inventory bucket. */
-constexpr std::array<std::pair<std::uint8_t, std::int8_t>, 16> kEquipmentSlotOfBucket{{
-    {16, 0},
-    {3, 1},
-    {4, 2},
-    {5, 4},
-    {6, 5},
-    {7, 6},
-    {0, 7},
-    {1, 8},
-    {2, 9},
-    {10, 10},
-    {9, 11},
-    {8, 12},
-    {27, 13},
-    {41, 14},
-    {17, 15},
-    {47, 17},
-}};
-
 /** @param bucketId Inventory bucket. @return Its equipment slot, or none when not equippable. */
 [[nodiscard]] std::optional<std::int8_t> equipment_slot(std::uint8_t bucketId) noexcept {
-    for (const auto& entry : kEquipmentSlotOfBucket) {
-        if (entry.first == bucketId) {
-            return entry.second;
-        }
+    state::build_data::inventory::buckets::Descriptor descriptor{};
+    if (state::build_data::find_inventory_bucket_descriptor(bucketId, descriptor)
+        && descriptor.equipmentSlot
+               != state::build_data::inventory::buckets::kUnavailableEquipmentSlot) {
+        return descriptor.equipmentSlot;
     }
     return std::nullopt;
 }
@@ -52,7 +32,8 @@ constexpr std::array<std::pair<std::uint8_t, std::int8_t>, 16> kEquipmentSlotOfB
     detail.maxStackSize = row.maxStackSize;
     detail.instancedDefinitionState = row.instanced ? domain::InstancedDefinitionState::instanced
                                                     : domain::InstancedDefinitionState::stackable;
-    detail.equipmentSlot = equipment_slot(row.bucketId);
+    detail.equipmentSlot =
+        row.equipmentSlot.has_value() ? row.equipmentSlot : equipment_slot(row.bucketId);
     detail.ordinarySocketState =
         row.hasSockets ? domain::OrdinarySocketState::present : domain::OrdinarySocketState::absent;
     detail.ordinarySocketCount = row.socketCount;
@@ -69,7 +50,9 @@ constexpr std::array<std::pair<std::uint8_t, std::int8_t>, 16> kEquipmentSlotOfB
     }
     detail.statCount = static_cast<std::uint8_t>(stats);
     detail.gearArtIndex = row.gearArtIndex;
-    detail.artArrangementIndex = row.artArrangementIndex;
+    for (std::size_t index = 0; index < detail.artArrangementIndices.size(); ++index) {
+        detail.artArrangementIndices[index] = row.artArrangementIndices[index];
+    }
     const std::size_t perks = row.sandboxPerkCount < detail.sandboxPerks.size()
                                   ? row.sandboxPerkCount
                                   : detail.sandboxPerks.size();
@@ -108,16 +91,36 @@ bool collect_authored_hashes(AuthoredHashes& output) noexcept {
     if (!state::account::valid(account)) {
         return false;
     }
+    const auto append = [&output](const state::account::inventory::Item& item) noexcept {
+        if (output.count >= output.values.size()) {
+            return false;
+        }
+        output.values[output.count++] = item.definitionHash;
+        for (std::size_t lane = 0; lane < item.sockets.plugCount; ++lane) {
+            if (!item.sockets.plugs[lane].has_value()) {
+                continue;
+            }
+            if (output.count >= output.values.size()) {
+                return false;
+            }
+            output.values[output.count++] = *item.sockets.plugs[lane];
+        }
+        return true;
+    };
     for (std::size_t character = 0; character < account.characterCount; ++character) {
         for (const auto& item : account.characters[character].equipment.slots) {
-            if (!item.has_value() || output.count >= output.values.size()) {
+            if (!item.has_value()) {
                 continue;
             }
-            output.values[output.count++] = item->definitionHash;
-            for (std::size_t lane = 0; lane < item->sockets.plugCount; ++lane) {
-                if (item->sockets.plugs[lane].has_value() && output.count < output.values.size()) {
-                    output.values[output.count++] = *item->sockets.plugs[lane];
-                }
+            if (!append(*item)) {
+                return false;
+            }
+        }
+        const state::account::inventory::CharacterItems& inventory =
+            account.characters[character].inventory;
+        for (std::size_t item = 0; item < inventory.count; ++item) {
+            if (!append(inventory.values[item])) {
+                return false;
             }
         }
     }
@@ -135,46 +138,77 @@ bool authored(const AuthoredHashes& hashes, std::uint32_t hash) noexcept {
     return std::binary_search(begin, end, hash);
 }
 
-/** Adds one definition index to the requested set. */
-bool request(std::uint16_t definitionIndex,
-             std::span<std::uint16_t> requested,
-             std::size_t& count) noexcept {
-    if (count >= requested.size()) {
+/** @return True when the row's bucket maps to a supported equipment slot. */
+bool equippable(const tables::items::Row& row) noexcept {
+    return row.equipmentSlot.has_value() || equipment_slot(row.bucketId).has_value();
+}
+
+/** Applies the bucket-definition equipment mapping and publishes the complete bucket table. */
+bool publish_buckets(Storage& storage) noexcept {
+    namespace buckets = state::build_data::inventory::buckets;
+    if (state::build_data::inventory_bucket_descriptors_ready()) {
+        return true;
+    }
+    if (storage.bucketCount == 0 || storage.bucketCount > storage.bucketRows.size()) {
         return false;
     }
-    requested[count++] = definitionIndex;
-    return true;
+    bool hasEquipmentSlot = false;
+    for (std::size_t index = 0; index < storage.bucketCount; ++index) {
+        buckets::Descriptor& descriptor = storage.bucketRows[index];
+        descriptor.equipmentSlot = storage.equipmentSlotByBucket[descriptor.bucketId];
+        hasEquipmentSlot =
+            hasEquipmentSlot || descriptor.equipmentSlot != buckets::kUnavailableEquipmentSlot;
+    }
+    return hasEquipmentSlot
+           && state::build_data::publish_inventory_bucket_descriptors(
+               std::span(storage.bucketRows).first(storage.bucketCount));
+}
+
+/** Adds one definition index to the deduplicated requested set. */
+void request(std::uint16_t definitionIndex, DetailRequests& requested) noexcept {
+    if (static_cast<std::size_t>(definitionIndex) < requested.size()) {
+        requested.set(definitionIndex);
+    }
 }
 
 /** Adds every socket lane's initial plug to the requested set. */
-bool append_initial_plugs(const tables::items::Row& row,
-                          std::span<std::uint16_t> requested,
-                          std::size_t& count) noexcept {
+void append_initial_plugs(const tables::items::Row& row,
+                          std::uint64_t itemDefinitionCount,
+                          DetailRequests& requested) noexcept {
     for (std::size_t lane = 0; lane < row.socketCount; ++lane) {
-        if (row.initialPlugs[lane] == tables::items::kUnavailablePlug) {
+        if (row.initialPlugs[lane] == tables::items::kUnavailablePlug
+            || row.initialPlugs[lane] >= itemDefinitionCount) {
+            continue;
+        }
+        request(row.initialPlugs[lane], requested);
+    }
+}
+
+/** Materializes requested native indices in ascending order. */
+bool materialize_requests(const DetailRequests& requested,
+                          std::span<std::uint16_t> output,
+                          std::size_t& count) noexcept {
+    count = 0;
+    for (std::size_t index = 0; index < requested.size(); ++index) {
+        if (!requested.test(index)) {
             continue;
         }
-        if (!request(row.initialPlugs[lane], requested, count)) {
+        if (count >= output.size()) {
+            count = 0;
             return false;
         }
+        output[count++] = static_cast<std::uint16_t>(index);
     }
     return true;
 }
 
-/** @param requested Requested-set storage. @param count Sorted and deduplicated in place. */
-void compact_requested(std::span<std::uint16_t> requested, std::size_t& count) noexcept {
-    auto end = requested.begin() + static_cast<std::ptrdiff_t>(count);
-    std::sort(requested.begin(), end);
-    end = std::unique(requested.begin(), end);
-    count = static_cast<std::size_t>(end - requested.begin());
-}
-
 /** Reads one requested definition and turns it into its cached detail form. */
 bool build_detail(const DetailSource& source,
                   std::uint16_t definitionIndex,
-                  domain::Definition& detail) noexcept {
+                  domain::Definition& detail,
+                  tables::items::Row& item) noexcept {
     tables::IndexRow indexRow{};
-    tables::items::Row item{};
+    item = {};
     item.definitionIndex = definitionIndex;
     if (!tables::index_row(source.table, source.array, definitionIndex, indexRow)
         || !reader::read_tag(

+ 43 - 6
Sunrise/src/client/content/items/packages/package_item_build.cpp

@@ -30,7 +30,10 @@ namespace {
 /** @return True when every domain owned by the package pass is published. */
 [[nodiscard]] bool package_domains_ready() noexcept {
     return state::build_data::item_definitions_ready()
+           && state::build_data::collectible_definitions_ready()
+           && state::build_data::material_requirement_sets_ready()
            && state::build_data::configured_item_details_ready()
+           && state::build_data::socket_plug_rules_ready()
            && state::build_data::inventory_bucket_descriptors_ready()
            && state::build_data::socket_entry_lists_ready()
            && state::build_data::ability_buckets_ready()
@@ -43,7 +46,10 @@ namespace {
 /** @return True when every item and investment-root domain is published. */
 [[nodiscard]] bool root_domains_ready() noexcept {
     return state::build_data::item_definitions_ready()
+           && state::build_data::collectible_definitions_ready()
+           && state::build_data::material_requirement_sets_ready()
            && state::build_data::configured_item_details_ready()
+           && state::build_data::socket_plug_rules_ready()
            && state::build_data::inventory_bucket_descriptors_ready()
            && state::build_data::socket_entry_lists_ready()
            && state::build_data::ability_buckets_ready()
@@ -101,18 +107,38 @@ bool build() noexcept {
             // Fixed navigation: globals child zero is the investment root, whose slot holds the
             // item table, whose array descriptor sits at a fixed offset.
             std::uint32_t rootTag = 0;
+            std::uint32_t rootClass = 0;
             std::uint32_t tableTag = 0;
             reason = "root";
             if (!tables::child_tag(std::span<const std::byte>{storage.container},
                                    tables::kInvestmentRootChild,
                                    rootTag)
-                || rootTag == 0
-                || !reader::read_tag(source, storage.scratch, rootTag, storage.child)) {
+                || rootTag == 0 || tables::package_of(rootTag) == tables::kAbsentPackageId
+                || !reader::read_tag(source, storage.scratch, rootTag, storage.child, rootClass)
+                || rootClass != tables::kInvestmentRootClass) {
                 continue;
             }
             // The same root names the bucket and socket-list tables.
             storage.root = storage.child;
-            (void)build_buckets(source, storage, std::span<const std::byte>{storage.root});
+            if (!state::build_data::socket_plug_rules_ready()) {
+                std::uint32_t plugSetTag = 0;
+                tables::Array plugSets{};
+                reason = "plug_sets";
+                if (!tables::slot_tag(std::span<const std::byte>{storage.root},
+                                      tables::kPlugSetTableSlot,
+                                      plugSetTag)
+                    || plugSetTag == 0
+                    || !reader::read_tag(source, storage.scratch, plugSetTag, storage.plugSetTable)
+                    || !tables::find_array_at(std::span<const std::byte>{storage.plugSetTable},
+                                              tables::kTableArrayDescriptor,
+                                              plugSets)) {
+                    continue;
+                }
+            }
+            reason = "buckets";
+            if (!build_buckets(source, storage, std::span<const std::byte>{storage.root})) {
+                continue;
+            }
             (void)build_socket_entry_lists(
                 source, storage, std::span<const std::byte>{storage.root});
             if (!state::build_data::progression_definitions_ready()) {
@@ -150,17 +176,28 @@ bool build() noexcept {
                                             table)
                       && table.elementClass == tables::kItemIndexTableClass;
         }
-        if (located) {
-            (void)build_item_rows(source, storage, table, rowCount, reason);
+        if (located && build_item_rows(source, storage, table, rowCount, reason)) {
+            if (!build_material_requirements(
+                    source, storage, std::span<const std::byte>{storage.root}, table.count)) {
+                reason = "materials";
+            } else if (!build_collectibles(source,
+                                           storage,
+                                           std::span<const std::byte>{storage.root},
+                                           table.count)) {
+                reason = "collectibles";
+            }
         }
     }
     SecureZeroMemory(&keys, sizeof keys);
     const bool complete = package_domains_ready();
+    const bool itemDomainsReady = root_domains_ready();
     if (complete) {
         // Nothing reads a package again until the next boot, so this reader's files go back now.
         reader::close_files(storage.scratch);
     }
-    report(complete ? state::build_data::item_definition_count() : 0, reason);
+    // Scenario, spawn-set, and hash-name extraction advance over later refresh slices and report
+    // their own progress. Do not mislabel one of those pending domains as the last item substage.
+    report(itemDomainsReady ? state::build_data::item_definition_count() : 0, reason);
     return complete;
 }
 

+ 120 - 26
Sunrise/src/client/content/items/packages/package_item_rows.cpp

@@ -2,10 +2,37 @@
 #include <span>
 #include <vector>
 
+#include "../../../../state/build_data/items/details/item_detail_catalog.h"
 #include "../../../../state/build_data/runtime.h"
 #include "internal.h"
+#include "package_socket_plug_build.h"
 
 namespace sunrise::client::content::items::packages {
+namespace {
+
+namespace build_details = state::build_data::items::details;
+namespace build_items = state::build_data::items;
+
+/** @return True when one extracted detail can join the currently published numeric domains. */
+[[nodiscard]] bool publishable_detail(const build_details::Definition& detail) noexcept {
+    const std::size_t itemCount = state::build_data::item_definition_count();
+    const std::size_t socketListCount = state::build_data::socket_entry_list_count();
+    build_items::Definition item{};
+    if (!build_details::valid(std::span<const build_details::Definition>{&detail, 1})
+        || detail.definitionIndex >= itemCount || detail.socketEntryListIndex >= socketListCount
+        || !build_items::find_index(detail.definitionIndex, item)
+        || item.bucketId != detail.bucketId) {
+        return false;
+    }
+    for (const std::uint16_t plugIndex : detail.initialPlugIndices) {
+        if (plugIndex != build_details::kUnavailableItemIndex && plugIndex >= itemCount) {
+            return false;
+        }
+    }
+    return true;
+}
+
+} // namespace
 
 /** Walks the located item index table, then publishes every domain that depends on it. */
 bool build_item_rows(const reader::Source& source,
@@ -15,19 +42,34 @@ bool build_item_rows(const reader::Source& source,
                      const char*& reason) noexcept {
     const bool needDefinitions = !state::build_data::item_definitions_ready();
     const bool needDetails = !state::build_data::configured_item_details_ready();
-    const bool needRows = needDefinitions || needDetails;
+    const bool needSocketPlugs = !state::build_data::socket_plug_rules_ready();
+    const bool needBuckets = !state::build_data::inventory_bucket_descriptors_ready();
+    const bool needDetailRows = needDetails || needSocketPlugs;
+    // Bucket equipment slots are derived from this same complete item walk, so a partial retry
+    // must still revisit the table even when definitions and detail domains already published.
+    const bool needRows = needDefinitions || needDetailRows || needBuckets;
     bool published = !needRows;
+    if (needDetails && storage.details.size() != kDetailCapacity) {
+        storage.details.assign(kDetailCapacity, build_details::Definition{});
+    }
+    const bool detailStorageReady = !needDetails || storage.details.size() == kDetailCapacity;
     const std::span<const std::byte> container{storage.child};
     reason = "rows";
-    // The requested detail set is gathered during this one walk. An authored row is matched by
-    // hash, because the index table that maps a hash to its index is what this loop is building.
-    const bool haveAuthored = needRows && collect_authored_hashes(storage.authoredHashes);
+    // The detail closure is gathered during this one walk. Collections can name any installed
+    // item row, including profile-owned shaders and modifications, so retain every readable row
+    // rather than only startup-authored/equippable definitions. The fixed request bitset still
+    // bounds this to the installed 16-bit item-table domain.
+    storage.detailRequests.reset();
+    storage.specialPlugCategories.fill(0);
     std::size_t detailCount = 0;
-    for (std::uint64_t index = 0; needRows && index < table.count && rowCount < storage.rows.size();
-         ++index) {
+    // One malformed entry is omitted on its own so the rest of the table still publishes. An
+    // entry can fail either at its index row or at the definition the row points to, and neither
+    // says anything about the entries that follow it.
+    std::uint64_t index = 0;
+    for (; needRows && index < table.count && rowCount < storage.rows.size(); ++index) {
         tables::IndexRow row{};
         if (!tables::index_row(container, table, index, row)) {
-            break;
+            continue;
         }
         tables::items::Row item{};
         item.definitionHash = row.definitionHash;
@@ -37,40 +79,91 @@ bool build_item_rows(const reader::Source& source,
                                                item)) {
             continue;
         }
-        storage.rows[rowCount++] = state::build_data::items::Definition{
-            item.definitionHash, item.definitionIndex, item.bucketId};
-        if (haveAuthored && authored(storage.authoredHashes, item.definitionHash)) {
-            (void)request(item.definitionIndex, storage.requested, detailCount);
-            (void)append_initial_plugs(item, storage.requested, detailCount);
+        storage.rows[rowCount++] =
+            state::build_data::items::Definition{item.definitionHash,
+                                                 item.definitionIndex,
+                                                 item.bucketId,
+                                                 item.insertionMaterialRequirementSetIndex,
+                                                 item.enabledMaterialRequirementSetIndex};
+        if (needSocketPlugs) {
+            storage.specialPlugCategories[item.definitionIndex] =
+                special_plug_category(item.plugCategoryHash);
+        }
+        if (needDetailRows) {
+            request(item.definitionIndex, storage.detailRequests);
+            append_initial_plugs(item, table.count, storage.detailRequests);
         }
     }
+    bool requestsFit = true;
     if (needRows) {
-        compact_requested(storage.requested, detailCount);
-        published = rowCount != 0;
+        // Every walked entry either published a row or was skipped, so the count of one
+        // follows from the other rather than being tracked alongside them.
+        report_row_count(index, rowCount, index - rowCount, index < table.count);
+        requestsFit = publish_buckets(storage)
+                      && (!needDetailRows
+                          || materialize_requests(
+                              storage.detailRequests, storage.requestedDetailIndices, detailCount));
+        published = rowCount != 0 && requestsFit && detailStorageReady;
     }
     if (published && needDefinitions) {
         published =
             state::build_data::publish_item_definitions(std::span(storage.rows).first(rowCount));
     }
     if (!published) {
-        reason = "publish";
+        reason = !detailStorageReady ? "detail_storage"
+                 : !requestsFit      ? "detail_capacity"
+                                     : "publish";
     }
-    // Configured rows and every plug they socket each need a detail record, and they are found
-    // through the table this pass just published.
-    if (published && needDetails) {
+    SocketPlugBuild socketPlugBuild;
+    const bool socketStorageReady =
+        !needSocketPlugs
+        || socketPlugBuild.prepare(storage.specialPlugCategories,
+                                   std::span(storage.rows).first(rowCount));
+    if (published && !socketStorageReady) {
+        published = false;
+        reason = "socket_storage";
+    }
+    // Every readable installed row is found through the table this pass just published. One
+    // malformed row is omitted independently so unrelated Collections categories stay usable.
+    if (published && needDetailRows) {
         reason = "details";
         const DetailSource detailSource{
             &source, &storage.scratch, container, table, &storage.definition};
-        for (std::size_t slot = 0; published && slot < detailCount; ++slot) {
-            published = build_detail(detailSource, storage.requested[slot], storage.details[slot]);
-            if (!published) {
-                report_detail_failure(slot, storage.requested[slot]);
+        std::size_t builtDetailCount = 0;
+        for (std::size_t slot = 0; slot < detailCount; ++slot) {
+            build_details::Definition detail{};
+            tables::items::Row item{};
+            if (!build_detail(detailSource, storage.requestedDetailIndices[slot], detail, item)
+                || !publishable_detail(detail)) {
+                report_detail_failure(slot, storage.requestedDetailIndices[slot]);
+                continue;
+            }
+            if (needDetails) {
+                storage.details[builtDetailCount++] = detail;
+            }
+            if (needSocketPlugs) {
+                (void)socketPlugBuild.append(item,
+                                             std::span<const std::byte>{storage.definition},
+                                             std::span<const std::byte>{storage.plugSetTable},
+                                             table.count);
+            }
+        }
+        if (needDetails) {
+            published = state::build_data::publish_configured_item_details(
+                std::span<build_details::Definition>{storage.details}.first(builtDetailCount));
+            report_detail_count(detailCount, builtDetailCount);
+        }
+        if (published && needSocketPlugs) {
+            const std::size_t rules = socketPlugBuild.rule_count();
+            const std::size_t pools = socketPlugBuild.pool_count();
+            const std::size_t members = socketPlugBuild.member_count();
+            const std::size_t skipped = socketPlugBuild.skipped();
+            reason = "socket_plugs";
+            published = socketPlugBuild.publish();
+            if (published) {
+                report_socket_plug_count(rules, pools, members, skipped);
             }
         }
-        published = published
-                    && state::build_data::publish_configured_item_details(
-                        std::span(storage.details).first(detailCount));
-        report_detail_count(detailCount);
     }
     // Ability buckets read the socket entry list table again and depend on the detail domain, so
     // they run last.
@@ -94,6 +187,7 @@ bool build_item_rows(const reader::Source& source,
     }
     return published && state::build_data::item_definitions_ready()
            && state::build_data::configured_item_details_ready()
+           && state::build_data::socket_plug_rules_ready()
            && state::build_data::ability_buckets_ready();
 }
 

+ 205 - 0
Sunrise/src/client/content/items/packages/package_material_requirement_build.cpp

@@ -0,0 +1,205 @@
+#include <algorithm>
+#include <array>
+#include <cstdio>
+#include <cstring>
+#include <limits>
+
+#include "../../../../core/logging/log.h"
+#include "../../../../state/build_data/material_requirements/material_requirement_catalog.h"
+#include "../../../../state/build_data/runtime.h"
+#include "internal.h"
+
+namespace sunrise::client::content::items::packages {
+namespace {
+
+/**
+ * Reports the requirement-set extraction result once per build.
+ * @param sets Sets read from the table.
+ * @param conditionalRows Rows carrying a condition, counted for the same line.
+ * @param valid True when the table passed its checks.
+ * @param published True when the table reached the catalog, which sets the level.
+ */
+void report_material_requirements(std::size_t sets,
+                                  std::size_t conditionalRows,
+                                  bool valid,
+                                  bool published) noexcept {
+    std::array<char, 160> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=pkg stage=materials result=%s sets=%zu conditional=%zu "
+                                      "valid=%u",
+                                      published ? "ok" : "fail",
+                                      sets,
+                                      conditionalRows,
+                                      static_cast<unsigned>(valid));
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         published ? core::log::Level::info : core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Names the row that stopped the requirement-set build.
+ * @param reason Short key naming the failing step.
+ * @param set Requirement set being read, or zero before one was picked.
+ * @param row Requirement row being read, or zero before one was picked.
+ */
+void report_material_requirement_failure(const char* reason,
+                                         std::size_t set,
+                                         std::size_t row) noexcept {
+    std::array<char, 144> line{};
+    const int written =
+        std::snprintf(line.data(),
+                      line.size(),
+                      "ev=pkg stage=materials result=fail reason=%s set=%zu row=%zu",
+                      reason,
+                      set,
+                      row);
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+} // namespace
+
+/**
+ * Extracts the material requirement sets from the installed packages and publishes them.
+ * The build is skipped when the catalog already holds the domain.
+ * @param source Borrowed package reader.
+ * @param storage Caller-owned scratch and definition buffers.
+ * @param root Borrowed root table bytes.
+ * @param itemDefinitionCount Item count the requirement rows are checked against.
+ * @return True when the domain is ready, either already or after this build.
+ */
+bool build_material_requirements(const reader::Source& source,
+                                 Storage& storage,
+                                 std::span<const std::byte> root,
+                                 std::uint64_t itemDefinitionCount) noexcept {
+    namespace domain = state::build_data::material_requirements;
+    if (state::build_data::material_requirement_sets_ready()) {
+        return true;
+    }
+    if (itemDefinitionCount == 0
+        || itemDefinitionCount > state::build_data::items::kDefinitionCapacity) {
+        report_material_requirement_failure("item_count", 0, 0);
+        return false;
+    }
+    std::uint32_t tableTag = 0;
+    std::uint32_t tableClass = 0;
+    tables::Array sets{};
+    if (!tables::slot_tag(root, tables::kMaterialRequirementTableSlot, tableTag) || tableTag == 0
+        || tables::package_of(tableTag) == tables::kAbsentPackageId
+        || !reader::read_tag(source, storage.scratch, tableTag, storage.definition, tableClass)
+        || tableClass != tables::kMaterialRequirementTableClass
+        || !tables::find_array_at(
+            std::span<const std::byte>{storage.definition}, tables::kTableArrayDescriptor, sets)
+        || sets.elementClass != tables::kMaterialRequirementSetRowClass || sets.count == 0
+        || sets.count > storage.materialRequirementRows.size()) {
+        report_material_requirement_failure("table", 0, 0);
+        return false;
+    }
+    const std::span<const std::byte> table{storage.definition};
+    if (sets.dataOffset > table.size()
+        || sets.count
+               > (table.size() - sets.dataOffset) / tables::kMaterialRequirementSetRowStride) {
+        report_material_requirement_failure("table_bounds", 0, 0);
+        return false;
+    }
+    std::size_t conditionalRows = 0;
+    for (std::size_t set = 0; set < sets.count; ++set) {
+        const std::size_t setAt = sets.dataOffset + set * tables::kMaterialRequirementSetRowStride;
+        auto& output = storage.materialRequirementRows[set];
+        output = {};
+        std::int64_t relative = 0;
+        std::memcpy(&output.requirementSetHash,
+                    table.data() + setAt + tables::kMaterialRequirementSetHashOffset,
+                    sizeof output.requirementSetHash);
+        std::memcpy(&relative,
+                    table.data() + setAt + tables::kMaterialRequirementSetArrayPointerOffset,
+                    sizeof relative);
+        const std::size_t pointerAt = setAt + tables::kMaterialRequirementSetArrayPointerOffset;
+        if (output.requirementSetHash == 0 || relative < -static_cast<std::int64_t>(pointerAt)
+            || relative > static_cast<std::int64_t>(table.size() - pointerAt)) {
+            report_material_requirement_failure("set_pointer", set, 0);
+            return false;
+        }
+        const auto descriptor = static_cast<std::int64_t>(pointerAt) + relative;
+        if (descriptor >= 0 && static_cast<std::size_t>(descriptor) <= table.size()
+            && table.size() - static_cast<std::size_t>(descriptor)
+                   >= tables::kTableArrayDescriptor + sizeof(std::uint64_t)) {
+            const auto descriptorBytes =
+                table.subspan(static_cast<std::size_t>(descriptor),
+                              tables::kTableArrayDescriptor + sizeof(std::uint64_t));
+            if (std::all_of(descriptorBytes.begin(), descriptorBytes.end(), [](std::byte value) {
+                    return value == std::byte{};
+                })) {
+                // Native free actions keep a real set hash/index but point at one canonical
+                // all-zero array descriptor. Preserve that empty set instead of treating it as
+                // a malformed nonempty array.
+                output.requirementSetIndex = static_cast<std::uint16_t>(set);
+                output.requirementCount = 0;
+                continue;
+            }
+        }
+        tables::Array requirements{};
+        if (descriptor < 0
+            || !tables::find_array_at(table, static_cast<std::size_t>(descriptor), requirements)
+            || requirements.elementClass != tables::kMaterialRequirementRowClass
+            || requirements.count == 0 || requirements.count > output.requirements.size()
+            || requirements.dataOffset > table.size()
+            || requirements.count > (table.size() - requirements.dataOffset)
+                                        / tables::kMaterialRequirementRowStride) {
+            report_material_requirement_failure("set_array", set, 0);
+            return false;
+        }
+        output.requirementSetIndex = static_cast<std::uint16_t>(set);
+        output.requirementCount = static_cast<std::uint8_t>(requirements.count);
+        for (std::size_t row = 0; row < requirements.count; ++row) {
+            const std::size_t at =
+                requirements.dataOffset + row * tables::kMaterialRequirementRowStride;
+            std::uint32_t itemIndex = 0;
+            std::uint8_t deleted = 0;
+            std::uint8_t omitted = 0;
+            std::uint16_t condition = 0;
+            auto& requirement = output.requirements[row];
+            std::memcpy(&itemIndex,
+                        table.data() + at + tables::kMaterialRequirementItemIndexOffset,
+                        sizeof itemIndex);
+            std::memcpy(&requirement.quantity,
+                        table.data() + at + tables::kMaterialRequirementQuantityOffset,
+                        sizeof requirement.quantity);
+            std::memcpy(&deleted,
+                        table.data() + at + tables::kMaterialRequirementDeleteOffset,
+                        sizeof deleted);
+            std::memcpy(&omitted,
+                        table.data() + at + tables::kMaterialRequirementOmitOffset,
+                        sizeof omitted);
+            std::memcpy(&condition,
+                        table.data() + at + tables::kMaterialRequirementSentinelOffset,
+                        sizeof condition);
+            if (itemIndex >= itemDefinitionCount
+                || itemIndex > (std::numeric_limits<std::uint16_t>::max)() || deleted > 1
+                || omitted > 1) {
+                report_material_requirement_failure("row", set, row);
+                return false;
+            }
+            requirement.itemDefinitionIndex = static_cast<std::uint16_t>(itemIndex);
+            requirement.condition = condition;
+            requirement.deleteOnAction = deleted != 0;
+            requirement.omitFromRequirements = omitted != 0;
+            conditionalRows += condition != domain::kUnconditionalRequirement ? 1U : 0U;
+        }
+    }
+    const auto definitions =
+        std::span(storage.materialRequirementRows).first(static_cast<std::size_t>(sets.count));
+    const bool valid = domain::valid(definitions);
+    const bool published =
+        valid && state::build_data::publish_material_requirement_sets(definitions);
+    report_material_requirements(definitions.size(), conditionalRows, valid, published);
+    return published;
+}
+
+} // namespace sunrise::client::content::items::packages

+ 171 - 8
Sunrise/src/client/content/items/packages/package_root_tables.cpp

@@ -1,3 +1,5 @@
+#include <algorithm>
+#include <array>
 #include <cstring>
 #include <vector>
 
@@ -7,15 +9,167 @@
 #include "internal.h"
 
 namespace sunrise::client::content::items::packages {
+namespace {
+
+namespace buckets = state::build_data::inventory::buckets;
+
+/** The installed equipment-slot catalogue has rows 0 through 18. */
+constexpr std::uint8_t kEquipmentSlotCount = 19;
+/** Slot 16 is not backed by an inventory bucket in the installed equipment ABI. */
+constexpr std::uint8_t kUnavailableBucket = 0xFF;
+/**
+ * Inventory buckets are storage ranges; equipment slots are render/loadout positions. Their
+ * installed-build ABI is independent of item definitions and routes every item through its bucket.
+ */
+constexpr std::array<std::uint8_t, kEquipmentSlotCount> kBucketByEquipmentSlot{
+    16, 3, 4, 36, 5, 6, 7, 0, 1, 2, 10, 9, 8, 27, 41, 17, kUnavailableBucket, 47, 49};
+
+/** @return The unique descriptor carrying one bucket id, or null. */
+[[nodiscard]] const buckets::Descriptor*
+find_bucket(std::span<const buckets::Descriptor> descriptors, std::uint8_t bucketId) noexcept {
+    const buckets::Descriptor* found = nullptr;
+    for (const buckets::Descriptor& descriptor : descriptors) {
+        if (descriptor.bucketId != bucketId) {
+            continue;
+        }
+        if (found != nullptr) {
+            return nullptr;
+        }
+        found = &descriptor;
+    }
+    return found;
+}
+
+/** The first tag a class sweep reported and how many entries carried the class. */
+struct BucketDefinitionTable {
+    std::uint32_t tag{};
+    std::size_t matches{};
+};
+
+/** @param context Sweep result. @param tag One matching tag. @return Always true, to count all. */
+bool collect_bucket_definition_tag(void* context, std::uint32_t tag) noexcept {
+    auto* located = static_cast<BucketDefinitionTable*>(context);
+    if (located->matches == 0) {
+        located->tag = tag;
+    }
+    ++located->matches;
+    return true;
+}
+
+/**
+ * Locates the bucket-definition table by the class its entry record carries.
+ * A tag is a package-local handle that a repack moves, so naming one pins the extraction to a
+ * single install. The class belongs to the definition ABI, so it names the table wherever it was
+ * packed. Entry tables are plain file data, so the sweep needs no block keys.
+ * @param source Package source.
+ * @param tag Receives the located tag.
+ * @return True when exactly one installed entry carries the class.
+ */
+[[nodiscard]] bool find_bucket_definition_table(const reader::Source& source,
+                                                std::uint32_t& tag) noexcept {
+    BucketDefinitionTable located{};
+    reader::ScanResult scanned{};
+    if (!reader::scan_class(source.directory,
+                            tables::kBucketDefinitionTableClass,
+                            &collect_bucket_definition_tag,
+                            &located,
+                            scanned)
+        || located.matches != 1) {
+        report_bucket_equipment_failure("table_sweep", located.matches, scanned.packages);
+        return false;
+    }
+    tag = located.tag;
+    return true;
+}
+
+/**
+ * Extracts and validates the installed bucket/equipment-slot relation.
+ * The table contains inline 72-byte records; its array elements are not index rows or tag links.
+ */
+[[nodiscard]] bool read_bucket_equipment_slots(
+    const reader::Source& source,
+    Storage& storage,
+    std::span<const buckets::Descriptor> descriptors,
+    std::array<std::int8_t, buckets::kDescriptorCapacity>& equipmentSlots) noexcept {
+    equipmentSlots.fill(buckets::kUnavailableEquipmentSlot);
+    std::uint32_t tableTag = 0;
+    if (!find_bucket_definition_table(source, tableTag)) {
+        return false;
+    }
+    std::uint32_t tableClass = 0;
+    tables::Array table{};
+    if (!reader::read_tag(source, storage.scratch, tableTag, storage.child, tableClass)) {
+        report_bucket_equipment_failure("table_read", 0, 0);
+        return false;
+    }
+    // The sweep and the reader resolve the installed entry independently, so the class the reader
+    // reports still has to agree with the one the table was selected by.
+    if (tableClass != tables::kBucketDefinitionTableClass) {
+        report_bucket_equipment_failure("table_class", tableClass, 0);
+        return false;
+    }
+    if (!tables::find_array_at(
+            std::span<const std::byte>{storage.child}, tables::kTableArrayDescriptor, table)) {
+        report_bucket_equipment_failure("table_array", storage.child.size(), 0);
+        return false;
+    }
+    if (table.count != tables::kBucketDefinitionCount) {
+        report_bucket_equipment_failure("table_count", table.count, table.elementClass);
+        return false;
+    }
+    const std::span<const std::byte> tableBlob{storage.child};
+    const std::size_t tableSize = table.count * tables::kBucketDefinitionSize;
+    if (tableSize > tableBlob.size() || table.dataOffset > tableBlob.size() - tableSize) {
+        report_bucket_equipment_failure("table_extent", table.dataOffset, tableBlob.size());
+        return false;
+    }
+    std::array<bool, kEquipmentSlotCount> seenEquipmentSlots{};
+    std::array<bool, buckets::kDescriptorCapacity> seenBuckets{};
+    std::size_t mappedSlots = 0;
+    for (std::size_t index = 0; index < table.count; ++index) {
+        const std::size_t base = table.dataOffset + index * tables::kBucketDefinitionSize;
+        const std::uint8_t equipmentSlot = std::to_integer<std::uint8_t>(
+            tableBlob[base + tables::kBucketDefinitionEquipmentSlotOffset]);
+        if (equipmentSlot != tables::kBucketDefinitionUnavailableEquipmentSlot
+            && (equipmentSlot >= kEquipmentSlotCount || seenEquipmentSlots[equipmentSlot])) {
+            report_bucket_equipment_failure("equipment_slot", index, equipmentSlot);
+            return false;
+        }
+        if (equipmentSlot == tables::kBucketDefinitionUnavailableEquipmentSlot) {
+            continue;
+        }
+        const std::uint8_t bucketId = kBucketByEquipmentSlot[equipmentSlot];
+        const buckets::Descriptor* descriptor =
+            bucketId < buckets::kDescriptorCapacity ? find_bucket(descriptors, bucketId) : nullptr;
+        if (descriptor == nullptr || seenBuckets[bucketId]
+            || descriptor->arraySelector != buckets::ArraySelector::character) {
+            report_bucket_equipment_failure("bucket_relation", equipmentSlot, bucketId);
+            return false;
+        }
+        seenEquipmentSlots[equipmentSlot] = true;
+        seenBuckets[bucketId] = true;
+        equipmentSlots[bucketId] = static_cast<std::int8_t>(equipmentSlot);
+        ++mappedSlots;
+    }
+    if (mappedSlots != 18 || seenEquipmentSlots[16]) {
+        report_bucket_equipment_failure("mapped_slots", mappedSlots, seenEquipmentSlots[16]);
+        return false;
+    }
+    report_bucket_equipment_mapping(mappedSlots);
+    return true;
+}
+
+} // namespace
 
 /** Publishes the inventory bucket descriptors from the root's bucket table. */
 bool build_buckets(const reader::Source& source,
                    Storage& storage,
                    std::span<const std::byte> root) noexcept {
-    namespace buckets = state::build_data::inventory::buckets;
     if (state::build_data::inventory_bucket_descriptors_ready()) {
         return true;
     }
+    storage.bucketCount = 0;
+    storage.equipmentSlotByBucket.fill(buckets::kUnavailableEquipmentSlot);
     std::uint32_t tableTag = 0;
     if (!tables::slot_tag(root, tables::kBucketTableSlot, tableTag) || tableTag == 0
         || !reader::read_tag(source, storage.scratch, tableTag, storage.child)) {
@@ -30,8 +184,9 @@ bool build_buckets(const reader::Source& source,
     if (count <= 0 || static_cast<std::size_t>(count) > buckets::kDescriptorCapacity) {
         return false;
     }
-    std::vector<buckets::Descriptor> rows(static_cast<std::size_t>(count));
-    for (std::size_t index = 0; index < rows.size(); ++index) {
+    const std::size_t bucketCount = static_cast<std::size_t>(count);
+    std::array<buckets::Descriptor, buckets::kDescriptorCapacity> descriptors{};
+    for (std::size_t index = 0; index < bucketCount; ++index) {
         const std::size_t base =
             tables::kBucketFirstDescriptor + index * tables::kBucketDescriptorSize;
         if (base + tables::kBucketDescriptorSize > blob.size()) {
@@ -43,13 +198,21 @@ bool build_buckets(const reader::Source& source,
             &firstSlot, blob.data() + base + tables::kBucketFirstSlotOffset, sizeof firstSlot);
         std::memcpy(
             &slotCount, blob.data() + base + tables::kBucketSlotCountOffset, sizeof slotCount);
-        rows[index].bucketId = std::to_integer<std::uint8_t>(blob[base]);
-        rows[index].firstSlot = static_cast<std::uint16_t>(firstSlot);
-        rows[index].slotCount = static_cast<std::uint16_t>(slotCount);
-        rows[index].arraySelector = static_cast<buckets::ArraySelector>(
+        descriptors[index].bucketId = std::to_integer<std::uint8_t>(blob[base]);
+        descriptors[index].firstSlot = static_cast<std::uint16_t>(firstSlot);
+        descriptors[index].slotCount = static_cast<std::uint16_t>(slotCount);
+        descriptors[index].arraySelector = static_cast<buckets::ArraySelector>(
             std::to_integer<std::uint8_t>(blob[base + tables::kBucketArraySelectorOffset]));
     }
-    return state::build_data::publish_inventory_bucket_descriptors(rows);
+    std::array<std::int8_t, buckets::kDescriptorCapacity> equipmentSlots{};
+    if (!read_bucket_equipment_slots(
+            source, storage, std::span(descriptors).first(bucketCount), equipmentSlots)) {
+        return false;
+    }
+    storage.bucketRows = descriptors;
+    storage.bucketCount = bucketCount;
+    storage.equipmentSlotByBucket = equipmentSlots;
+    return true;
 }
 
 /** Publishes the socket entry list table from the root. */

+ 273 - 0
Sunrise/src/client/content/items/packages/package_socket_plug_build.cpp

@@ -0,0 +1,273 @@
+#include "package_socket_plug_build.h"
+
+#include <algorithm>
+#include <limits>
+
+#include "../../../../state/build_data/runtime.h"
+
+namespace sunrise::client::content::items::packages {
+namespace {
+
+/** Sundial/native category families whose socket seed expands to every plug in that family. */
+constexpr std::array<std::uint32_t, 3> kExpandableCategories{
+    0xB134761EU,
+    0x87727F34U,
+    0x6C863692U,
+};
+/** Tracker sockets synthesize these three safe plug choices by socket type. */
+constexpr std::array<std::uint32_t, 3> kTrackerPlugHashes{
+    2'285'418'970U,
+    2'302'094'943U,
+    38'912'240U,
+};
+/** Native ordinary socket type whose choices are the synthetic tracker set. */
+constexpr std::uint16_t kTrackerSocketType = 518;
+/** FNV-1a constants make pool fingerprints stable and cheap. */
+constexpr std::uint64_t kHashOffsetBasis = 14695981039346656037ULL;
+constexpr std::uint64_t kHashPrime = 1099511628211ULL;
+
+/** Visitor adapter that appends one list member to a bounded lane candidate. */
+struct VisitorContext {
+    SocketPlugBuild* build{};
+    std::size_t itemDefinitionCount{};
+};
+
+/** @return Whether the package-provided member was accepted into bounded scratch. */
+[[nodiscard]] bool visit_member(void* opaque, std::uint32_t itemDefinitionIndex) noexcept {
+    auto& context = *static_cast<VisitorContext*>(opaque);
+    return context.build != nullptr
+           && context.build->add(itemDefinitionIndex, context.itemDefinitionCount);
+}
+
+} // namespace
+
+/** Returns the compact 1-based code of one native category-expansion family. */
+std::uint8_t special_plug_category(std::uint32_t categoryHash) noexcept {
+    for (std::size_t index = 0; index < kExpandableCategories.size(); ++index) {
+        if (kExpandableCategories[index] == categoryHash) {
+            return static_cast<std::uint8_t>(index + 1);
+        }
+    }
+    return 0;
+}
+
+/** Allocates the bounded build state and indexes expansion/tracker plug definitions. */
+bool SocketPlugBuild::prepare(
+    std::span<const std::uint8_t> specialCategories,
+    std::span<const state::build_data::items::Definition> itemDefinitions) noexcept {
+    release();
+    if (specialCategories.size() < itemDefinitions.size()
+        || itemDefinitions.size() > state::build_data::items::kDefinitionCapacity) {
+        return false;
+    }
+    rules_.assign(socket_plugs::kRuleCapacity, {});
+    pools_.assign(socket_plugs::kPoolCapacity, {});
+    members_.assign(socket_plugs::kMemberCapacity, {});
+    candidates_.assign(state::build_data::items::kDefinitionCapacity, {});
+    categoryMembers_.assign(kCategoryCount * state::build_data::items::kDefinitionCapacity, {});
+    lookup_.assign(kLookupCapacity, {});
+    pools_[socket_plugs::kEmptyPoolIndex] = {};
+    poolCount_ = 1;
+    for (std::size_t item = 0; item < itemDefinitions.size(); ++item) {
+        const std::uint8_t category = specialCategories[item];
+        if (category != 0 && category <= kCategoryCount) {
+            const std::size_t family = category - 1;
+            categoryMembers_[family * state::build_data::items::kDefinitionCapacity
+                             + categoryCounts_[family]++] = static_cast<std::uint16_t>(item);
+        }
+        for (const std::uint32_t trackerHash : kTrackerPlugHashes) {
+            if (itemDefinitions[item].definitionHash != trackerHash) {
+                continue;
+            }
+            if (trackerCount_ >= trackerMembers_.size()) {
+                release();
+                return false;
+            }
+            trackerMembers_[trackerCount_++] = static_cast<std::uint16_t>(item);
+        }
+    }
+    return true;
+}
+
+/** Appends one package member after enforcing the installed item-table bound. */
+bool SocketPlugBuild::add(std::uint32_t itemDefinitionIndex,
+                          std::size_t itemDefinitionCount) noexcept {
+    if (candidates_.empty() || itemDefinitionIndex >= itemDefinitionCount
+        || itemDefinitionIndex >= state::build_data::items::kDefinitionCapacity
+        || candidateCount_ >= state::build_data::items::kDefinitionCapacity) {
+        return false;
+    }
+    candidates_[candidateCount_++] = static_cast<std::uint16_t>(itemDefinitionIndex);
+    return true;
+}
+
+/** Expands special category seeds, sorts/deduplicates, then interns one exact pool. */
+bool SocketPlugBuild::intern(std::uint32_t& poolIndex) noexcept {
+    poolIndex = socket_plugs::kEmptyPoolIndex;
+    if (candidates_.empty() || categoryMembers_.empty() || lookup_.empty()) {
+        return false;
+    }
+    std::sort(candidates_.data(), candidates_.data() + candidateCount_);
+    candidateCount_ = static_cast<std::size_t>(
+        std::unique(candidates_.data(), candidates_.data() + candidateCount_) - candidates_.data());
+    std::array<bool, kCategoryCount> expand{};
+    // Category codes were indexed by native item definition index during prepare().
+    for (std::size_t family = 0; family < kCategoryCount; ++family) {
+        const auto* familyMembers =
+            categoryMembers_.data() + family * state::build_data::items::kDefinitionCapacity;
+        for (std::size_t seed = 0; seed < candidateCount_ && !expand[family]; ++seed) {
+            expand[family] = std::binary_search(
+                familyMembers, familyMembers + categoryCounts_[family], candidates_[seed]);
+        }
+    }
+    for (std::size_t family = 0; family < kCategoryCount; ++family) {
+        if (!expand[family]) {
+            continue;
+        }
+        if (categoryCounts_[family]
+            > state::build_data::items::kDefinitionCapacity - candidateCount_) {
+            return false;
+        }
+        const auto* first =
+            categoryMembers_.data() + family * state::build_data::items::kDefinitionCapacity;
+        std::copy_n(first, categoryCounts_[family], candidates_.data() + candidateCount_);
+        candidateCount_ += categoryCounts_[family];
+    }
+    std::sort(candidates_.data(), candidates_.data() + candidateCount_);
+    candidateCount_ = static_cast<std::size_t>(
+        std::unique(candidates_.data(), candidates_.data() + candidateCount_) - candidates_.data());
+    if (candidateCount_ == 0) {
+        return true;
+    }
+
+    std::uint64_t fingerprint = kHashOffsetBasis;
+    for (std::size_t member = 0; member < candidateCount_; ++member) {
+        std::uint16_t value = candidates_[member];
+        for (std::size_t byte = 0; byte < sizeof value; ++byte) {
+            fingerprint ^= static_cast<std::uint8_t>(value);
+            fingerprint *= kHashPrime;
+            value >>= 8U;
+        }
+    }
+    fingerprint ^= candidateCount_;
+    fingerprint *= kHashPrime;
+    static_assert((kLookupCapacity & (kLookupCapacity - 1)) == 0);
+    const std::size_t start = static_cast<std::size_t>(fingerprint) & (kLookupCapacity - 1);
+    for (std::size_t probe = 0; probe < kLookupCapacity; ++probe) {
+        PoolLookup& slot = lookup_[(start + probe) & (kLookupCapacity - 1)];
+        if (slot.poolIndex == UINT32_MAX) {
+            if (poolCount_ >= socket_plugs::kPoolCapacity
+                || candidateCount_ > socket_plugs::kMemberCapacity - memberCount_) {
+                return false;
+            }
+            poolIndex = static_cast<std::uint32_t>(poolCount_);
+            pools_[poolCount_++] = {static_cast<std::uint32_t>(memberCount_),
+                                    static_cast<std::uint32_t>(candidateCount_)};
+            std::copy_n(candidates_.data(), candidateCount_, members_.data() + memberCount_);
+            memberCount_ += candidateCount_;
+            slot = {fingerprint, poolIndex};
+            return true;
+        }
+        if (slot.fingerprint != fingerprint || slot.poolIndex >= poolCount_) {
+            continue;
+        }
+        const socket_plugs::Pool& pool = pools_[slot.poolIndex];
+        if (pool.memberCount == candidateCount_
+            && std::equal(candidates_.data(),
+                          candidates_.data() + candidateCount_,
+                          members_.data() + pool.memberOffset)) {
+            poolIndex = slot.poolIndex;
+            return true;
+        }
+    }
+    return false;
+}
+
+/** Extracts every exact ordinary socket pool of one installed item definition. */
+bool SocketPlugBuild::append(const tables::items::Row& item,
+                             std::span<const std::byte> itemDefinition,
+                             std::span<const std::byte> plugSetTable,
+                             std::size_t itemDefinitionCount) noexcept {
+    if (rules_.empty() || item.definitionIndex >= itemDefinitionCount
+        || item.socketCount > socket_plugs::kLaneCapacity) {
+        return false;
+    }
+    bool complete = true;
+    for (std::uint8_t lane = 0; lane < item.socketCount; ++lane) {
+        candidateCount_ = 0;
+        VisitorContext visitor{this, itemDefinitionCount};
+        bool laneValid = tables::items::visit_allowed_plugs(
+            itemDefinition, plugSetTable, lane, visit_member, &visitor);
+        if (laneValid && item.initialPlugs[lane] != tables::items::kUnavailablePlug) {
+            laneValid = add(item.initialPlugs[lane], itemDefinitionCount);
+        }
+        if (laneValid && item.socketTypes[lane] == kTrackerSocketType) {
+            for (std::size_t tracker = 0; tracker < trackerCount_ && laneValid; ++tracker) {
+                laneValid = add(trackerMembers_[tracker], itemDefinitionCount);
+            }
+        }
+        std::uint32_t poolIndex = socket_plugs::kEmptyPoolIndex;
+        laneValid = laneValid && intern(poolIndex);
+        if (!laneValid || ruleCount_ >= socket_plugs::kRuleCapacity) {
+            ++skipped_;
+            complete = false;
+            continue;
+        }
+        rules_[ruleCount_++] = {item.definitionIndex, lane, 0, poolIndex};
+    }
+    return complete;
+}
+
+/** Publishes the bounded relation and releases all transient interning memory. */
+bool SocketPlugBuild::publish() noexcept {
+    const bool published =
+        !rules_.empty() && !pools_.empty() && !members_.empty()
+        && state::build_data::publish_socket_plug_rules(std::span(rules_.data(), ruleCount_),
+                                                        std::span(pools_.data(), poolCount_),
+                                                        std::span(members_.data(), memberCount_));
+    release();
+    return published;
+}
+
+/** Reports how many lanes failed closed during extraction. */
+std::size_t SocketPlugBuild::skipped() const noexcept {
+    return skipped_;
+}
+
+std::size_t SocketPlugBuild::rule_count() const noexcept {
+    return ruleCount_;
+}
+
+std::size_t SocketPlugBuild::pool_count() const noexcept {
+    return poolCount_;
+}
+
+std::size_t SocketPlugBuild::member_count() const noexcept {
+    return memberCount_;
+}
+
+/** Drops all heap-backed extraction scratch and resets every count. */
+void SocketPlugBuild::release() noexcept {
+    rules_.clear();
+    rules_.shrink_to_fit();
+    pools_.clear();
+    pools_.shrink_to_fit();
+    members_.clear();
+    members_.shrink_to_fit();
+    candidates_.clear();
+    candidates_.shrink_to_fit();
+    categoryMembers_.clear();
+    categoryMembers_.shrink_to_fit();
+    lookup_.clear();
+    lookup_.shrink_to_fit();
+    categoryCounts_ = {};
+    trackerMembers_ = {};
+    trackerCount_ = 0;
+    ruleCount_ = 0;
+    poolCount_ = 0;
+    memberCount_ = 0;
+    candidateCount_ = 0;
+    skipped_ = 0;
+}
+
+} // namespace sunrise::client::content::items::packages

+ 88 - 0
Sunrise/src/client/content/items/packages/package_socket_plug_build.h

@@ -0,0 +1,88 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <span>
+#include <vector>
+
+#include "../../../../middleware/content/packages/tables/items.h"
+#include "../../../../state/build_data/items/item_catalog.h"
+#include "../../../../state/build_data/items/socket_plugs/definition.h"
+
+namespace sunrise::client::content::items::packages {
+
+namespace tables = middleware::content::packages::tables;
+namespace socket_plugs = state::build_data::items::socket_plugs;
+
+/** Fixed-size, heap-backed interning state for one installed package pass. */
+class SocketPlugBuild final {
+public:
+    SocketPlugBuild() = default;
+    ~SocketPlugBuild() = default;
+    SocketPlugBuild(const SocketPlugBuild&) = delete;
+    SocketPlugBuild& operator=(const SocketPlugBuild&) = delete;
+
+    /** Allocates bounded scratch and indexes the three native expandable plug categories. */
+    [[nodiscard]] bool
+    prepare(std::span<const std::uint8_t> specialCategories,
+            std::span<const state::build_data::items::Definition> itemDefinitions) noexcept;
+
+    /** Extracts, expands, interns, and records every declared socket lane of one base item. */
+    [[nodiscard]] bool append(const tables::items::Row& item,
+                              std::span<const std::byte> itemDefinition,
+                              std::span<const std::byte> plugSetTable,
+                              std::size_t itemDefinitionCount) noexcept;
+
+    /** Publishes the completed exact relation, then releases its transient scratch. */
+    [[nodiscard]] bool publish() noexcept;
+
+    /** @return Socket lanes skipped because their package lists were malformed or over capacity. */
+    [[nodiscard]] std::size_t skipped() const noexcept;
+    [[nodiscard]] std::size_t rule_count() const noexcept;
+    [[nodiscard]] std::size_t pool_count() const noexcept;
+    [[nodiscard]] std::size_t member_count() const noexcept;
+
+    /** Package-list visitor entry point; accepts only an in-range bounded native index. */
+    [[nodiscard]] bool add(std::uint32_t itemDefinitionIndex,
+                           std::size_t itemDefinitionCount) noexcept;
+
+private:
+    struct PoolLookup {
+        std::uint64_t fingerprint{};
+        std::uint32_t poolIndex{UINT32_MAX};
+    };
+
+    /** A plug set names three categories: reusable, randomized, and the socket's own default. */
+    static constexpr std::size_t kCategoryCount = 3;
+    /** Power-of-two open-addressed table, sized so the largest package's pools stay sparse. */
+    static constexpr std::size_t kLookupCapacity = 1U << 19U;
+
+    std::vector<socket_plugs::Rule> rules_{};
+    std::vector<socket_plugs::Pool> pools_{};
+    std::vector<socket_plugs::Member> members_{};
+    std::vector<socket_plugs::Member> candidates_{};
+    std::vector<socket_plugs::Member> categoryMembers_{};
+    std::vector<PoolLookup> lookup_{};
+    std::array<std::size_t, kCategoryCount> categoryCounts_{};
+    std::array<socket_plugs::Member, 3> trackerMembers_{};
+    std::size_t trackerCount_{};
+    std::size_t ruleCount_{};
+    std::size_t poolCount_{};
+    std::size_t memberCount_{};
+    std::size_t candidateCount_{};
+    std::size_t skipped_{};
+
+    /** Expands native category families, canonicalizes, and interns the current candidate. */
+    [[nodiscard]] bool intern(std::uint32_t& poolIndex) noexcept;
+    /** Releases every transient allocation and count. */
+    void release() noexcept;
+};
+
+/**
+ * Classifies the only three plug-category hashes whose native socket rules expand by category.
+ * @return 1..3 for a supported expansion family, or zero otherwise.
+ */
+[[nodiscard]] std::uint8_t special_plug_category(std::uint32_t categoryHash) noexcept;
+
+} // namespace sunrise::client::content::items::packages

+ 51 - 12
Sunrise/src/client/content/items/packages/package_subclass_build.cpp

@@ -21,20 +21,32 @@ constexpr std::size_t kSubclassSlot =
  * @return True when the character equips a subclass whose detail is published.
  */
 [[nodiscard]] bool subclass_list(const state::CharacterState& character,
-                                 std::uint16_t& socketEntryListIndex) noexcept {
+                                 std::uint16_t& socketEntryListIndex,
+                                 const char*& reason) noexcept {
     const auto& slot = character.equipment.slots[kSubclassSlot];
     state::build_data::items::Definition item{};
     state::build_data::items::details::Definition detail{};
-    if (!slot.has_value()
-        || !state::build_data::find_item_definition_hash(slot->definitionHash, item)
-        || !state::build_data::find_configured_item_detail(item.definitionIndex, detail)) {
+    reason = "subclass_slot";
+    if (!slot.has_value()) {
+        return false;
+    }
+    reason = "subclass_item";
+    if (!state::build_data::find_item_definition_hash(slot->definitionHash, item)) {
+        return false;
+    }
+    reason = "subclass_detail";
+    if (!state::build_data::find_configured_item_detail(item.definitionIndex, detail)) {
         return false;
     }
     socketEntryListIndex = detail.socketEntryListIndex;
     return true;
 }
 
-/** @param rows Rows built so far. @return True when the candidate's key is already held. */
+/**
+ * @param rows Rows built so far.
+ * @param row Candidate whose key is tested against them.
+ * @return True when the candidate's key is already held.
+ */
 [[nodiscard]] bool held(std::span<const domain::Definition> rows,
                         const domain::Definition& row) noexcept {
     for (const domain::Definition& existing : rows) {
@@ -69,17 +81,29 @@ bool build_character_abilities(const reader::Source& source,
     count = 0;
     std::uint32_t tableTag = 0;
     tables::Array rows{};
-    if (!tables::slot_tag(root, tables::kSocketEntryListTableSlot, tableTag) || tableTag == 0
-        || !reader::read_tag(source, scratch, tableTag, table)
-        || !tables::find_array_at(
+    if (!tables::slot_tag(root, tables::kSocketEntryListTableSlot, tableTag) || tableTag == 0) {
+        report_ability_failure("table_slot", 0, root.size(), tableTag);
+        return false;
+    }
+    if (!reader::read_tag(source, scratch, tableTag, table)) {
+        report_ability_failure("table_read", 0, tableTag, 0);
+        return false;
+    }
+    if (!tables::find_array_at(
             std::span<const std::byte>{table}, tables::kTableArrayDescriptor, rows)) {
+        report_ability_failure("table_array", 0, table.size(), 0);
         return false;
     }
     const state::AccountState account = state::account_snapshot();
     for (std::size_t character = 0; character < account.characterCount && count < output.size();
          ++character) {
         domain::Definition row{};
-        if (!subclass_list(account.characters[character], row.socketEntryListIndex)) {
+        const char* subclassReason = "subclass";
+        if (!subclass_list(
+                account.characters[character], row.socketEntryListIndex, subclassReason)) {
+            const auto& subclass = account.characters[character].equipment.slots[kSubclassSlot];
+            report_ability_failure(
+                subclassReason, character, subclass.has_value() ? subclass->definitionHash : 0, 0);
             continue;
         }
         // The selection is held in a local because the row it also keys is the build's output.
@@ -91,14 +115,29 @@ bool build_character_abilities(const reader::Source& source,
         tables::IndexRow indexRow{};
         if (!tables::index_row(
                 std::span<const std::byte>{table}, rows, row.socketEntryListIndex, indexRow)
-            || indexRow.targetTag == 0
-            || !reader::read_tag(source, scratch, indexRow.targetTag, definition)
-            || !build_ability_buckets(
+            || indexRow.targetTag == 0) {
+            report_ability_failure("index_row", character, row.socketEntryListIndex, rows.count);
+            continue;
+        }
+        if (!reader::read_tag(source, scratch, indexRow.targetTag, definition)) {
+            report_ability_failure(
+                "definition_read", character, row.socketEntryListIndex, indexRow.targetTag);
+            continue;
+        }
+        if (!build_ability_buckets(
                 source, scratch, std::span<const std::byte>{definition}, blob, selection, row)) {
+            const std::size_t packedSelection =
+                selection.movementEntry | (selection.grenadeEntry << 8U)
+                | (selection.superEntry << 16U) | (selection.meleeEntry << 24U);
+            report_ability_failure(
+                "bucket_build", character, row.socketEntryListIndex, packedSelection);
             continue;
         }
         output[count++] = row;
     }
+    if (count == 0) {
+        report_ability_failure("empty", account.characterCount, rows.count, output.size());
+    }
     return true;
 }
 

+ 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

+ 220 - 0
Sunrise/src/client/hooks/package_trust/package_trust_bypass.cpp

@@ -0,0 +1,220 @@
+#include "package_trust_bypass.h"
+
+#include <Windows.h>
+
+#include <algorithm>
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <cstring>
+#include <string_view>
+
+#include "../../../core/logging/log.h"
+#include "../../hooking/detour.h"
+#include "../../patterns/image_scan.h"
+
+namespace sunrise::client::hooks::package_trust {
+namespace {
+
+using patterns::scan_main_image_unique;
+using patterns::signature;
+using patterns::signature_length;
+
+/**
+ * Native package-header validator. The matched prologue tests its sixth argument and returns -93
+ * when RSA verification did not mark the header trusted. The following bytes enter the ordinary
+ * structural checks, keeping this target distinct from the RSA implementation itself.
+ */
+constexpr std::string_view kValidatorSignatureText =
+    "40 53 48 83 EC 20 80 7C 24 58 00 44 0F B7 DA 4C 8B D1 BB 01 00 00 00 "
+    "75 0D BB A3 FF FF FF 8B C3 48 83 C4 20 5B C3";
+/** Masked form of the validator text, which is the form the image scan takes. */
+constexpr auto kValidatorSignature =
+    signature<signature_length(kValidatorSignatureText)>(kValidatorSignatureText);
+
+/**
+ * The patchable registrar's extended-header authentication failure. Native code loads -89 here,
+ * then joins the common result/cleanup path. The site is unique in this client build.
+ */
+constexpr std::string_view kExtendedHeaderFailureText = "B8 A7 FF FF FF E9 ? ? ? ?";
+constexpr auto kExtendedHeaderFailure =
+    signature<signature_length(kExtendedHeaderFailureText)>(kExtendedHeaderFailureText);
+
+/**
+ * Cached-data authentication gate used while registering/loading base packages. The hash routine
+ * returns a boolean in AL. Native code conditionally jumps to the ordinary success continuation;
+ * otherwise it enters the unique "Failed to validate cached data hash" error path with -89.
+ */
+constexpr std::string_view kCachedDataHashGateText =
+    "84 C0 0F 85 ? ? ? ? E9 ? ? ? ? 48 8B 45 48 89 08";
+/** Masked form of the gate text, which is the form the image scan takes. */
+constexpr auto kCachedDataHashGate =
+    signature<signature_length(kCachedDataHashGateText)>(kCachedDataHashGateText);
+
+/** Only the MOV EAX immediate changes; the native continuation remains untouched. */
+constexpr std::size_t kResultImmediateOffset = 1;
+constexpr std::array<std::byte, 4> kSuccessResult{
+    std::byte{0x01}, std::byte{0x00}, std::byte{0x00}, std::byte{0x00}};
+
+/** Replace JNZ rel32 (0F 85) with NOP; JMP rel32 (90 E9), retaining its native destination. */
+constexpr std::size_t kCachedDataBranchOffset = 2;
+constexpr std::array<std::byte, 2> kAlwaysTakeSuccessBranch{std::byte{0x90}, std::byte{0xE9}};
+
+/** ABI recovered from the validator's native call site. */
+using ValidateHeader = std::int32_t(__fastcall*)(const std::uint32_t* validationMask,
+                                                 std::uint16_t packageGroup,
+                                                 std::uint64_t buildSignature,
+                                                 std::int32_t expectedFileSize,
+                                                 std::uint16_t localeToken,
+                                                 std::uint8_t rsaTrusted,
+                                                 const void* header) noexcept;
+
+hooking::detour::Handle g_handle{};
+std::byte* g_extendedHeaderResult{};
+std::array<std::byte, kSuccessResult.size()> g_extendedHeaderOriginal{};
+std::byte* g_cachedDataBranch{};
+std::array<std::byte, kAlwaysTakeSuccessBranch.size()> g_cachedDataBranchOriginal{};
+
+/** Writes instruction bytes and restores the page's original protection. */
+template <std::size_t Size>
+[[nodiscard]] bool write_code(std::byte* destination,
+                              const std::array<std::byte, Size>& value) noexcept {
+    if (destination == nullptr) {
+        return false;
+    }
+    DWORD originalProtection = 0;
+    if (VirtualProtect(destination, value.size(), PAGE_EXECUTE_READWRITE, &originalProtection)
+        == FALSE) {
+        return false;
+    }
+    std::memcpy(destination, value.data(), value.size());
+    FlushInstructionCache(GetCurrentProcess(), destination, value.size());
+    DWORD ignored = 0;
+    return VirtualProtect(destination, value.size(), originalProtection, &ignored) != FALSE;
+}
+
+/** Runs every native validation rule while forcing only the RSA result to trusted. */
+std::int32_t __fastcall validate_header(const std::uint32_t* validationMask,
+                                        std::uint16_t packageGroup,
+                                        std::uint64_t buildSignature,
+                                        std::int32_t expectedFileSize,
+                                        std::uint16_t localeToken,
+                                        std::uint8_t,
+                                        const void* header) noexcept {
+    if (header != nullptr) {
+        const auto* const bytes = static_cast<const std::byte*>(header);
+        std::uint16_t packageId = 0;
+        std::uint16_t patchId = 0;
+        std::uint32_t headerFileSize = 0;
+        std::memcpy(&packageId, bytes + 0x04, sizeof packageId);
+        std::memcpy(&patchId, bytes + 0x20, sizeof patchId);
+        std::memcpy(&headerFileSize, bytes + 0x164, sizeof headerFileSize);
+        if (headerFileSize != static_cast<std::uint32_t>(expectedFileSize)) {
+            std::array<char, 256> event{};
+            const int length = std::snprintf(event.data(),
+                                             event.size(),
+                                             "ev=package_trust stage=header_size result=mismatch "
+                                             "package=0x%04X patch=%u header=%u expected=%u",
+                                             packageId,
+                                             patchId,
+                                             headerFileSize,
+                                             static_cast<std::uint32_t>(expectedFileSize));
+            if (length > 0) {
+                core::log::write(core::log::Channel::client,
+                                 core::log::Level::error,
+                                 std::string_view(event.data(),
+                                                  (std::min)(static_cast<std::size_t>(length),
+                                                             event.size() - 1)));
+            }
+        }
+    }
+    const auto original = reinterpret_cast<ValidateHeader>(g_handle.original);
+    return original(
+        validationMask, packageGroup, buildSignature, expectedFileSize, localeToken, 1, header);
+}
+
+} // namespace
+
+/** Attaches the native package-header trust bypass. */
+bool install() noexcept {
+    if (g_handle.attached) {
+        return true;
+    }
+    std::byte* const target =
+        scan_main_image_unique(kValidatorSignature, "package_header_validator");
+    std::byte* const extendedHeaderFailure =
+        scan_main_image_unique(kExtendedHeaderFailure, "package_extended_header_failure");
+    std::byte* const cachedDataHashGate =
+        scan_main_image_unique(kCachedDataHashGate, "package_cached_data_hash_gate");
+    if (target == nullptr || extendedHeaderFailure == nullptr || cachedDataHashGate == nullptr) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::error,
+                         "ev=package_trust stage=resolve result=fail");
+        return false;
+    }
+    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&validate_header)};
+    if (!hooking::detour::install(spec, g_handle)) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::error,
+                         "ev=package_trust stage=attach result=fail");
+        return false;
+    }
+    g_extendedHeaderResult = extendedHeaderFailure + kResultImmediateOffset;
+    std::memcpy(
+        g_extendedHeaderOriginal.data(), g_extendedHeaderResult, g_extendedHeaderOriginal.size());
+    if (!write_code(g_extendedHeaderResult, kSuccessResult)) {
+        (void)hooking::detour::uninstall(g_handle);
+        g_extendedHeaderResult = nullptr;
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::error,
+                         "ev=package_trust stage=extended_header result=fail");
+        return false;
+    }
+    g_cachedDataBranch = cachedDataHashGate + kCachedDataBranchOffset;
+    std::memcpy(
+        g_cachedDataBranchOriginal.data(), g_cachedDataBranch, g_cachedDataBranchOriginal.size());
+    if (!write_code(g_cachedDataBranch, kAlwaysTakeSuccessBranch)) {
+        (void)write_code(g_extendedHeaderResult, g_extendedHeaderOriginal);
+        (void)hooking::detour::uninstall(g_handle);
+        g_extendedHeaderResult = nullptr;
+        g_cachedDataBranch = nullptr;
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::error,
+                         "ev=package_trust stage=cached_data result=fail");
+        return false;
+    }
+    core::log::write(core::log::Channel::client,
+                     core::log::Level::info,
+                     "ev=package_trust stage=attach result=ok mode=package_integrity_bypass");
+    return true;
+}
+
+/** Detaches the native package-header trust bypass. */
+bool uninstall() noexcept {
+    bool restored = true;
+    if (g_cachedDataBranch != nullptr) {
+        const bool cachedDataRestored = write_code(g_cachedDataBranch, g_cachedDataBranchOriginal);
+        restored = restored && cachedDataRestored;
+        if (cachedDataRestored) {
+            g_cachedDataBranch = nullptr;
+        }
+    }
+    if (g_extendedHeaderResult != nullptr) {
+        const bool extendedHeaderRestored =
+            write_code(g_extendedHeaderResult, g_extendedHeaderOriginal);
+        restored = restored && extendedHeaderRestored;
+        if (extendedHeaderRestored) {
+            g_extendedHeaderResult = nullptr;
+        }
+    }
+    const bool detached = !g_handle.attached || hooking::detour::uninstall(g_handle);
+    return restored && detached;
+}
+
+/** @return True while the validator detour is attached. */
+bool is_installed() noexcept {
+    return g_handle.attached && g_extendedHeaderResult != nullptr && g_cachedDataBranch != nullptr;
+}
+
+} // namespace sunrise::client::hooks::package_trust

+ 18 - 0
Sunrise/src/client/hooks/package_trust/package_trust_bypass.h

@@ -0,0 +1,18 @@
+#pragma once
+
+namespace sunrise::client::hooks::package_trust {
+
+/**
+ * Accepts package RSA, extended-header hash and cached-data hash authentication. Native package
+ * parsing, decompression, file-size, table and bounds validation remain active.
+ * @return True when the validator detour is attached.
+ */
+[[nodiscard]] bool install() noexcept;
+
+/** @return True when the validator detour is detached. */
+[[nodiscard]] bool uninstall() noexcept;
+
+/** @return True while the validator detour is attached. */
+[[nodiscard]] bool is_installed() noexcept;
+
+} // namespace sunrise::client::hooks::package_trust

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

+ 20 - 1
Sunrise/src/client/hooks/queuez/svc123_null_payload_guard.cpp

@@ -2,6 +2,7 @@
 #include <atomic>
 #include <cstddef>
 #include <cstdint>
+#include <cstdio>
 #include <cstring>
 #include <string_view>
 
@@ -70,7 +71,25 @@ __declspec(noinline) char __fastcall handler(void* self,
         return kNotHandled;
     }
     const Handler original = g_original.load(std::memory_order_acquire);
-    return original != nullptr ? original(self, context, message) : kNotHandled;
+    const char result = original != nullptr ? original(self, context, message) : kNotHandled;
+    std::uint32_t messageId = 0;
+    if (message != nullptr) {
+        std::memcpy(&messageId, message + MessageLayout::id, sizeof messageId);
+    }
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count = std::snprintf(line.data(),
+                                    line.size(),
+                                    "ev=queuez stage=native_handler result=%d message_id=%u "
+                                    "payload=0x%llX",
+                                    static_cast<int>(result),
+                                    messageId,
+                                    static_cast<unsigned long long>(payload));
+    if (count > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+    return result;
 }
 
 } // namespace

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

+ 6 - 4
Sunrise/src/client/hooks/sword_skate/sword_skate.cpp

@@ -22,7 +22,7 @@
 #include <cstdint>
 
 #include "../../../core/ui/runtime/ui_visibility_runtime.h"
-#include "../../sword_skate/sword_skate_settings_store.h"
+#include "../../movement/movement_settings_store.h"
 #include "../teleport/runtime.h"
 
 namespace sunrise::client::hooks::sword_skate {
@@ -70,9 +70,10 @@ bool g_jumpHeld{false};
 
 /** Clears the glide refusal for one physics tick of the local player. */
 void apply(void* component) noexcept {
-    const client::sword_skate::Settings settings = client::sword_skate::get();
+    const client::movement::Settings settings = client::movement::get();
     // An open interface owns the keyboard, so a press meant for it must not reach this either.
-    const bool usable = settings.enabled && settings.jumpKey != client::sword_skate::kNoKey
+    const bool usable = settings.swordSkateEnabled
+                        && settings.swordSkateJumpKey != client::movement::kNoKey
                         && !core::ui::runtime::snapshot().visible;
     if (!usable) {
         // Cleared rather than left as it was, or the first press after the feature comes back is
@@ -87,7 +88,8 @@ void apply(void* component) noexcept {
     if (component == nullptr || !teleport::owns_local_player(component)) {
         return;
     }
-    const bool held = (GetAsyncKeyState(static_cast<int>(settings.jumpKey)) & kKeyHeldBit) != 0;
+    const bool held =
+        (GetAsyncKeyState(static_cast<int>(settings.swordSkateJumpKey)) & kKeyHeldBit) != 0;
     const bool wasHeld = g_jumpHeld;
     g_jumpHeld = held;
     // Only the press matters. Clearing the flag for as long as the key is down would keep it clear

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

+ 35 - 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,16 @@ 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
+           && settings.swordSkateJumpKey <= 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 +120,20 @@ 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));
+    }
+    if (scalar_for(text, "\"sword_skate_enabled\"", scalar)) {
+        output.swordSkateEnabled = scalar.starts_with("true");
+    }
+    if (scalar_for(text, "\"sword_skate_jump_key\"", scalar) && terminated(scalar, buffer)) {
+        output.swordSkateJumpKey =
+            static_cast<std::uint32_t>(std::strtoul(buffer.data(), nullptr, 0));
+    }
 }
 
 /**
@@ -134,10 +150,18 @@ 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"
+                                   "  \"sword_skate_enabled\": %s,\n"
+                                   "  \"sword_skate_jump_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),
+                                   settings.swordSkateEnabled ? "true" : "false",
+                                   static_cast<unsigned>(settings.swordSkateJumpKey));
     if (size <= 0) {
         return false;
     }
@@ -237,4 +261,4 @@ bool publish(const Settings& settings) noexcept {
     return true;
 }
 
-} // namespace sunrise::client::teleport
+} // namespace sunrise::client::movement

+ 11 - 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,21 @@ 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;
+/** Space is the stock jump binding, and the one the sword skate fix has to watch to do anything. */
+inline constexpr std::uint32_t kDefaultJumpKey = 0x20;
 
-/** 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};
+    bool swordSkateEnabled{false};
+    /** The player's jump key. The refusal is only cleared on the tick this key goes down. */
+    std::uint32_t swordSkateJumpKey{kDefaultJumpKey};
 };
 
 /**
@@ -39,4 +46,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;

+ 24 - 4
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>
@@ -12,13 +13,14 @@
 #include "../content/investment/worker.h"
 #include "../executable/image.h"
 #include "../hooks/assert_handler/assert_handler_lifecycle.h"
-#include "../hooks/banner/banner_hook_lifecycle.h"
 #include "../hooks/bitmap/bitmap_hook_lifecycle.h"
 #include "../hooks/bootflow/bootflow_hook_lifecycle.h"
 #include "../hooks/config_getter/config_getter_lifecycle.h"
 #include "../hooks/cursor/runtime.h"
 #include "../hooks/graphics/graphics_hook_lifecycle.h"
 #include "../hooks/network/runtime.h"
+#include "../hooks/noclip/runtime.h"
+#include "../hooks/package_trust/package_trust_bypass.h"
 #include "../hooks/polled_input/runtime.h"
 #include "../hooks/queuez/queuez_hook_lifecycle.h"
 #include "../hooks/retail_log/retail_log_lifecycle.h"
@@ -125,9 +127,16 @@ void clear_game_targets() noexcept {
         report_resolve_failure();
         return false;
     }
+    // Steam initialization installs package trust before base-package registration. Keep this
+    // idempotent check beside the other main-image hooks so activation also verifies ownership.
+    if (!hooks::package_trust::install()) {
+        clear_game_targets();
+        return false;
+    }
     // The SignOn config blob carries this token. It must reach State before any hook owns the
     // resolved targets: extraction cannot recover from a missing bootstrap token.
     if (!content::bootstrap::publish_token()) {
+        (void)hooks::package_trust::uninstall();
         clear_game_targets();
         return false;
     }
@@ -136,6 +145,7 @@ void clear_game_targets() noexcept {
                          core::log::Level::error,
                          "ev=activate stage=game_network result=fail");
         if (!hooks::network::has_game_ownership()) {
+            (void)hooks::package_trust::uninstall();
             clear_game_targets();
         }
         return false;
@@ -158,13 +168,12 @@ 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.
     (void)hooks::bitmap::install();
-    // The orbit banner component ships unbound, so its update body never runs and it draws the
-    // constructor's values.
-    (void)hooks::banner::install();
     content::investment::worker::activate();
     return true;
 }
@@ -183,10 +192,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.

+ 14 - 11
Sunrise/src/client/runtime/client_runtime_lifecycle.cpp

@@ -1,21 +1,21 @@
 #include "../../core/logging/log.h"
 #include "../content/investment/worker.h"
 #include "../hooks/assert_handler/assert_handler_lifecycle.h"
-#include "../hooks/banner/banner_hook_lifecycle.h"
 #include "../hooks/bitmap/bitmap_hook_lifecycle.h"
 #include "../hooks/bootflow/bootflow_hook_lifecycle.h"
 #include "../hooks/config_getter/config_getter_lifecycle.h"
 #include "../hooks/cursor/runtime.h"
 #include "../hooks/graphics/graphics_hook_lifecycle.h"
 #include "../hooks/network/runtime.h"
+#include "../hooks/noclip/runtime.h"
+#include "../hooks/package_trust/package_trust_bypass.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 "../sword_skate/sword_skate_settings_store.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"
@@ -24,9 +24,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);
-    sword_skate::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();
 }
 
@@ -50,11 +49,16 @@ bool shutdown() noexcept {
         ReleaseSRWLockExclusive(&runtime::g_lock);
         return false;
     }
-    // Detached before the other game hooks because each fix is a plain one-site detour with
-    // no shared target state to hand back.
-    hooks::banner::uninstall();
+    if (!hooks::package_trust::uninstall()) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::error,
+                         "ev=shutdown stage=package_trust result=fail");
+        ReleaseSRWLockExclusive(&runtime::g_lock);
+        return false;
+    }
     hooks::bitmap::uninstall();
     hooks::bootflow::uninstall();
+    hooks::noclip::uninstall();
     hooks::teleport::uninstall();
     hooks::queuez::uninstall();
     if (!hooks::config_getter::uninstall()) {
@@ -91,8 +95,7 @@ bool shutdown() noexcept {
     runtime::g_graphicsStage = runtime::StageState::pending;
     runtime::g_platformStage = runtime::StageState::pending;
     ui::runtime::shutdown();
-    teleport::shutdown();
-    sword_skate::shutdown();
+    movement::shutdown();
     core::log::write(core::log::Channel::client, core::log::Level::info, "ev=shutdown result=ok");
     ReleaseSRWLockExclusive(&runtime::g_lock);
     return true;

+ 0 - 234
Sunrise/src/client/sword_skate/sword_skate_settings_store.cpp

@@ -1,234 +0,0 @@
-/**
- * The sword-skate 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.
- */
-
-#include "sword_skate_settings_store.h"
-
-#include <Windows.h>
-
-#include <array>
-#include <cstddef>
-#include <cstdio>
-#include <cstdlib>
-#include <string_view>
-
-#include "../../core/filesystem/path.h"
-#include "../../core/logging/log.h"
-
-namespace sunrise::client::sword_skate {
-namespace {
-
-/** The module-owned configuration file, beside the generated settings and logs. */
-constexpr std::wstring_view kFileSuffix = L"\\sword_skate.json";
-/** The document is 2 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;
-/** Highest Windows virtual-key code, so a stored binding cannot name a key that cannot exist. */
-constexpr std::uint32_t kMaximumVirtualKey = 254;
-
-SRWLOCK g_lock{SRWLOCK_INIT};
-Settings g_settings{};
-core::path::Buffer g_path{};
-bool g_pathResolved{};
-
-/** @param settings Candidate configuration. @return True when every field is in range. */
-[[nodiscard]] bool valid(const Settings& settings) noexcept {
-    return settings.jumpKey <= 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=sword_skate stage=store 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)});
-    }
-}
-
-/**
- * Finds one key's raw scalar text.
- * @param text Whole document.
- * @param key Quoted key to locate.
- * @param output Receives the text between the colon and the next separator.
- * @return True when the key exists and carries a non-empty value.
- */
-[[nodiscard]] bool
-scalar_for(std::string_view text, std::string_view key, std::string_view& output) noexcept {
-    const std::size_t at = text.find(key);
-    if (at == std::string_view::npos) {
-        return false;
-    }
-    const std::size_t colon = text.find(':', at + key.size());
-    if (colon == std::string_view::npos) {
-        return false;
-    }
-    std::size_t begin = colon + 1;
-    while (begin < text.size() && (text[begin] == ' ' || text[begin] == '\t')) {
-        ++begin;
-    }
-    std::size_t end = begin;
-    while (end < text.size() && text[end] != ',' && text[end] != '}' && text[end] != '\n'
-           && text[end] != '\r') {
-        ++end;
-    }
-    output = text.substr(begin, end - begin);
-    return !output.empty();
-}
-
-/**
- * Copies one scalar into null-terminated storage the C conversions require.
- * @param value Scalar text taken from the document.
- * @param output Receives the terminated copy.
- * @return True when the scalar fits.
- */
-[[nodiscard]] bool terminated(std::string_view value,
-                              std::array<char, kScalarCapacity>& output) noexcept {
-    if (value.size() >= output.size()) {
-        return false;
-    }
-    for (std::size_t index = 0; index < value.size(); ++index) {
-        output[index] = value[index];
-    }
-    output[value.size()] = '\0';
-    return true;
-}
-
-/**
- * Layers one document over the current defaults. A missing or malformed key keeps its default,
- * so a hand-edited file cannot stop the module loading.
- * @param text Whole document.
- * @param output Receives the parsed configuration.
- */
-void parse(std::string_view text, Settings& output) noexcept {
-    std::string_view scalar;
-    if (scalar_for(text, "\"enabled\"", scalar)) {
-        output.enabled = scalar.starts_with("true");
-    }
-    std::array<char, kScalarCapacity> buffer{};
-    if (scalar_for(text, "\"jump_key\"", scalar) && terminated(scalar, buffer)) {
-        output.jumpKey = static_cast<std::uint32_t>(std::strtoul(buffer.data(), nullptr, 0));
-    }
-}
-
-/**
- * Writes the whole document. It is small enough that a complete rewrite is the simplest
- * correct save, which the shared settings file is not.
- * @param settings Configuration to store.
- * @return True when every byte reached the file.
- */
-[[nodiscard]] bool store(const Settings& settings) noexcept {
-    if (!g_pathResolved) {
-        return false;
-    }
-    std::array<char, kFileCapacity> document{};
-    const int size = std::snprintf(document.data(),
-                                   document.size(),
-                                   "{\n  \"enabled\": %s,\n  \"jump_key\": %u\n}\n",
-                                   settings.enabled ? "true" : "false",
-                                   static_cast<unsigned>(settings.jumpKey));
-    if (size <= 0) {
-        return false;
-    }
-    const HANDLE file = CreateFileW(g_path.chars.data(),
-                                    GENERIC_WRITE,
-                                    0,
-                                    nullptr,
-                                    CREATE_ALWAYS,
-                                    FILE_ATTRIBUTE_NORMAL,
-                                    nullptr);
-    if (file == INVALID_HANDLE_VALUE) {
-        return false;
-    }
-    DWORD written = 0;
-    bool complete =
-        WriteFile(file, document.data(), static_cast<DWORD>(size), &written, nullptr) != FALSE
-        && written == static_cast<DWORD>(size);
-    complete = CloseHandle(file) != FALSE && complete;
-    return complete;
-}
-
-/** Reads the configuration file into the active settings when one exists. */
-void load() noexcept {
-    const HANDLE file = CreateFileW(g_path.chars.data(),
-                                    GENERIC_READ,
-                                    FILE_SHARE_READ,
-                                    nullptr,
-                                    OPEN_EXISTING,
-                                    FILE_ATTRIBUTE_NORMAL,
-                                    nullptr);
-    if (file == INVALID_HANDLE_VALUE) {
-        return;
-    }
-    std::array<char, kFileCapacity> buffer{};
-    DWORD read = 0;
-    const bool readOk =
-        ReadFile(file, buffer.data(), static_cast<DWORD>(buffer.size() - 1), &read, nullptr)
-        != FALSE;
-    (void)CloseHandle(file);
-    if (!readOk || read == 0) {
-        return;
-    }
-    Settings parsed{};
-    parse(std::string_view(buffer.data(), read), parsed);
-    if (!valid(parsed)) {
-        report_fail("range");
-        return;
-    }
-    g_settings = parsed;
-}
-
-} // namespace
-
-/** Resolves the configuration file and loads it when one exists. */
-void initialize(void* module) noexcept {
-    AcquireSRWLockExclusive(&g_lock);
-    g_settings = Settings{};
-    g_pathResolved =
-        core::path::artifact_directory(module, g_path) && core::path::append(g_path, kFileSuffix);
-    if (g_pathResolved) {
-        load();
-    } else {
-        report_fail("path");
-    }
-    ReleaseSRWLockExclusive(&g_lock);
-}
-
-/** Drops the runtime configuration and the resolved file path. */
-void shutdown() noexcept {
-    AcquireSRWLockExclusive(&g_lock);
-    g_settings = Settings{};
-    g_path = core::path::Buffer{};
-    g_pathResolved = false;
-    ReleaseSRWLockExclusive(&g_lock);
-}
-
-/** @return One lock-consistent copy of the current configuration. */
-Settings get() noexcept {
-    AcquireSRWLockShared(&g_lock);
-    const Settings snapshot = g_settings;
-    ReleaseSRWLockShared(&g_lock);
-    return snapshot;
-}
-
-/** Publishes one configuration and writes it straight to disk. */
-bool publish(const Settings& settings) noexcept {
-    if (!valid(settings)) {
-        return false;
-    }
-    AcquireSRWLockExclusive(&g_lock);
-    g_settings = settings;
-    const bool stored = store(settings);
-    ReleaseSRWLockExclusive(&g_lock);
-    if (!stored) {
-        report_fail("write");
-    }
-    return true;
-}
-
-} // namespace sunrise::client::sword_skate

+ 0 - 38
Sunrise/src/client/sword_skate/sword_skate_settings_store.h

@@ -1,38 +0,0 @@
-#pragma once
-
-#include <cstdint>
-
-namespace sunrise::client::sword_skate {
-
-/** No key is bound until one is picked, so a fresh install cannot change movement by accident. */
-inline constexpr std::uint32_t kNoKey = 0;
-/** Space is the stock jump binding, and the one this feature has to watch to do anything. */
-inline constexpr std::uint32_t kDefaultJumpKey = 0x20;
-
-/** Runtime sword-skate configuration. This module owns it; Core settings do not carry it. */
-struct Settings {
-    bool enabled{false};
-    /** The player's jump key. The refusal is only cleared on the tick this key goes down. */
-    std::uint32_t jumpKey{kDefaultJumpKey};
-};
-
-/**
- * Resolves the configuration file and loads it when one exists.
- * @param module Loaded DLL used to resolve the owned artifact directory.
- */
-void initialize(void* module) noexcept;
-
-/** Drops the runtime configuration and the resolved file path. */
-void shutdown() noexcept;
-
-/** @return One lock-consistent copy of the current configuration. */
-[[nodiscard]] Settings get() noexcept;
-
-/**
- * Publishes one configuration and writes it straight to disk.
- * @param settings Candidate configuration, refused when a field is out of range.
- * @return True when the value was published. A failed write is logged, not returned.
- */
-bool publish(const Settings& settings) noexcept;
-
-} // namespace sunrise::client::sword_skate

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

+ 216 - 0
Sunrise/src/client/ui/movement/movement_panel.cpp

@@ -0,0 +1,216 @@
+/**
+ * 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 "movement_panel.h"
+
+#include <Windows.h>
+
+#include <array>
+#include <cstdio>
+#include <imgui.h>
+
+#include "../../../core/ui/components/toggle/ui_toggle_component.h"
+#include "../../movement/movement_settings_store.h"
+
+namespace sunrise::client::ui::movement {
+namespace {
+
+/** Lowest and highest virtual keys the picker scans. Zero is not a key. */
+constexpr int kFirstVirtualKey = 1;
+constexpr int kLastVirtualKey = 254;
+/** Mouse buttons are skipped so a click on the picker cannot bind itself. */
+constexpr int kLastMouseKey = 6;
+/** Longest key name Windows returns, plus the null. */
+constexpr std::size_t kKeyNameCapacity = 64;
+
+enum class CaptureTarget {
+    none,
+    teleport,
+    noclip,
+    swordSkate,
+};
+
+CaptureTarget g_capturing{CaptureTarget::none};
+
+/**
+ * Names one virtual key for display.
+ * @param virtualKey Key to name, or zero for no binding.
+ * @param output Receives the name.
+ */
+void key_name(std::uint32_t virtualKey, std::array<char, kKeyNameCapacity>& output) noexcept {
+    if (virtualKey == client::movement::kNoKey) {
+        (void)std::snprintf(output.data(), output.size(), "None");
+        return;
+    }
+    const UINT scanCode = MapVirtualKeyW(virtualKey, MAPVK_VK_TO_VSC);
+    std::array<wchar_t, kKeyNameCapacity> wide{};
+    const int written = scanCode != 0 ? GetKeyNameTextW(static_cast<LONG>(scanCode << 16),
+                                                        wide.data(),
+                                                        static_cast<int>(wide.size()))
+                                      : 0;
+    if (written <= 0
+        || WideCharToMultiByte(CP_UTF8,
+                               0,
+                               wide.data(),
+                               written,
+                               output.data(),
+                               static_cast<int>(output.size() - 1),
+                               nullptr,
+                               nullptr)
+               <= 0) {
+        (void)std::snprintf(
+            output.data(), output.size(), "Key 0x%02X", static_cast<unsigned>(virtualKey));
+    }
+}
+
+/**
+ * Takes the first key held while the picker is armed.
+ * @param picked Receives the key, or zero when Escape clears the binding.
+ * @return True when this frame ended the capture.
+ */
+[[nodiscard]] bool capture_key(std::uint32_t& picked) noexcept {
+    if ((GetAsyncKeyState(VK_ESCAPE) & 0x8000) != 0) {
+        picked = client::movement::kNoKey;
+        return true;
+    }
+    for (int key = kFirstVirtualKey; key <= kLastVirtualKey; ++key) {
+        if (key <= kLastMouseKey) {
+            continue;
+        }
+        if ((GetAsyncKeyState(key) & 0x8000) != 0) {
+            picked = static_cast<std::uint32_t>(key);
+            return true;
+        }
+    }
+    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 movement module inside the active Core UI frame. */
+void draw() noexcept {
+    client::movement::Settings settings = client::movement::get();
+    bool changed = false;
+
+    ImGui::TextUnformatted("Teleport");
+    ImGui::Separator();
+    ImGui::TextWrapped("Teleports you forward in the facing direction. "
+                       "Cancels vertical momentum.");
+    ImGui::Spacing();
+
+    changed =
+        core::ui::components::toggle::control("Enabled##teleport", settings.enabled) || changed;
+
+    ImGui::Spacing();
+    // One label column and one control column, so the slider and key buttons share both edges.
+    const float labelWidth =
+        ImGui::CalcTextSize("Toggle key").x + ImGui::GetStyle().ItemSpacing.x * 2;
+    const float controlWidth = ImGui::GetContentRegionAvail().x - labelWidth;
+
+    ImGui::AlignTextToFramePadding();
+    ImGui::TextUnformatted("Distance");
+    ImGui::SameLine(labelWidth);
+    ImGui::SetNextItemWidth(controlWidth);
+    float distance = settings.distance;
+    if (ImGui::SliderFloat("##distance",
+                           &distance,
+                           client::movement::kMinimumDistance,
+                           client::movement::kMaximumDistance,
+                           "%.0f units")) {
+        settings.distance = distance;
+        changed = true;
+    }
+
+    ImGui::Spacing();
+    ImGui::AlignTextToFramePadding();
+    ImGui::TextUnformatted("Key");
+    ImGui::SameLine(labelWidth);
+    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;
+
+    ImGui::Spacing();
+    ImGui::Spacing();
+    ImGui::TextUnformatted("Sword Skate Fix");
+    ImGui::Separator();
+    ImGui::TextWrapped("A sword's air attack throws you forward, and a glide started while that "
+                       "throw is still carrying you keeps the speed. The client refuses to start a "
+                       "glide during the throw. This clears that refusal on the tick you press "
+                       "jump, and the client's own glide runs from there.");
+    ImGui::Spacing();
+
+    changed =
+        core::ui::components::toggle::control("Enabled##sword_skate", settings.swordSkateEnabled)
+        || changed;
+
+    ImGui::Spacing();
+    ImGui::AlignTextToFramePadding();
+    ImGui::TextUnformatted("Jump key");
+    ImGui::SameLine(labelWidth);
+    changed =
+        key_picker(
+            "sword_skate_key", CaptureTarget::swordSkate, settings.swordSkateJumpKey, controlWidth)
+        || changed;
+    ImGui::TextWrapped("Must match the key the game jumps on. Nothing happens on any other key.");
+
+    if (changed && !client::movement::publish(settings)) {
+        ImGui::Spacing();
+        ImGui::TextUnformatted("value out of range, not saved");
+    }
+}
+
+} // 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 - 20
Sunrise/src/client/ui/runtime/client_ui_module_runtime.cpp

@@ -4,41 +4,29 @@
 
 #include "../../../core/ui/modules/registry/ui_module_registry.h"
 #include "../../../core/ui/modules/ui_module_descriptor.h"
-#include "../sword_skate/sword_skate_panel.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";
-/** Namespaced stable ID for the sword-skate page. */
-constexpr std::string_view kSwordSkateStableId = "client.sword_skate";
-/** Short menu label for the sword-skate page. */
-constexpr std::string_view kSwordSkateDisplayName = "Sword Skate";
+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_swordSkatePage;
+core::ui::modules::registry::PageRegistration g_movementPage;
 
 } // namespace
 
 /** @return True when the Client module owns its Core UI registry slot. */
 bool initialize() noexcept {
-    const bool teleportPage = g_teleportPage.acquire(
-        core::ui::modules::Owner::client, kTeleportStableId, kTeleportDisplayName, &teleport::draw);
-    const bool swordSkatePage = g_swordSkatePage.acquire(core::ui::modules::Owner::client,
-                                                         kSwordSkateStableId,
-                                                         kSwordSkateDisplayName,
-                                                         &sword_skate::draw);
-    return teleportPage && swordSkatePage;
+    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_swordSkatePage.release();
+    g_movementPage.release();
 }
 
 } // namespace sunrise::client::ui::runtime

+ 0 - 134
Sunrise/src/client/ui/sword_skate/sword_skate_panel.cpp

@@ -1,134 +0,0 @@
-/**
- * The sword-skate module's interface. Every control writes the configuration straight to disk, so
- * a change made here survives the next launch without a settings edit.
- */
-
-#include "sword_skate_panel.h"
-
-#include <Windows.h>
-
-#include <array>
-#include <cstdio>
-#include <imgui.h>
-
-#include "../../../core/ui/components/toggle/ui_toggle_component.h"
-#include "../../sword_skate/sword_skate_settings_store.h"
-
-namespace sunrise::client::ui::sword_skate {
-namespace {
-
-/** Lowest and highest virtual keys the picker scans. Zero is not a key. */
-constexpr int kFirstVirtualKey = 1;
-constexpr int kLastVirtualKey = 254;
-/** Mouse buttons are skipped so a click on the picker cannot bind itself. */
-constexpr int kLastMouseKey = 6;
-/** Longest key name Windows returns, plus the null. */
-constexpr std::size_t kKeyNameCapacity = 64;
-
-bool g_capturing{};
-
-/**
- * Names one virtual key for display.
- * @param virtualKey Key to name, or zero for no binding.
- * @param output Receives the name.
- */
-void key_name(std::uint32_t virtualKey, std::array<char, kKeyNameCapacity>& output) noexcept {
-    if (virtualKey == client::sword_skate::kNoKey) {
-        (void)std::snprintf(output.data(), output.size(), "None");
-        return;
-    }
-    const UINT scanCode = MapVirtualKeyW(virtualKey, MAPVK_VK_TO_VSC);
-    std::array<wchar_t, kKeyNameCapacity> wide{};
-    const int written = scanCode != 0 ? GetKeyNameTextW(static_cast<LONG>(scanCode << 16),
-                                                        wide.data(),
-                                                        static_cast<int>(wide.size()))
-                                      : 0;
-    if (written <= 0
-        || WideCharToMultiByte(CP_UTF8,
-                               0,
-                               wide.data(),
-                               written,
-                               output.data(),
-                               static_cast<int>(output.size() - 1),
-                               nullptr,
-                               nullptr)
-               <= 0) {
-        (void)std::snprintf(
-            output.data(), output.size(), "Key 0x%02X", static_cast<unsigned>(virtualKey));
-    }
-}
-
-/**
- * Takes the first key held while the picker is armed.
- * @param picked Receives the key, or zero when Escape clears the binding.
- * @return True when this frame ended the capture.
- */
-[[nodiscard]] bool capture_key(std::uint32_t& picked) noexcept {
-    if ((GetAsyncKeyState(VK_ESCAPE) & 0x8000) != 0) {
-        picked = client::sword_skate::kNoKey;
-        return true;
-    }
-    for (int key = kFirstVirtualKey; key <= kLastVirtualKey; ++key) {
-        if (key <= kLastMouseKey) {
-            continue;
-        }
-        if ((GetAsyncKeyState(key) & 0x8000) != 0) {
-            picked = static_cast<std::uint32_t>(key);
-            return true;
-        }
-    }
-    return false;
-}
-
-} // namespace
-
-/** Draws the sword-skate module inside the active Core UI frame. */
-void draw() noexcept {
-    client::sword_skate::Settings settings = client::sword_skate::get();
-    bool changed = false;
-
-    ImGui::TextUnformatted("Sword Skate");
-    ImGui::Separator();
-    ImGui::TextWrapped("A sword's air attack throws you forward, and a glide started while that "
-                       "throw is still carrying you keeps the speed. The client refuses to start "
-                       "a glide during the throw; this clears that refusal on the tick you press "
-                       "jump, and the client's own glide runs from there.");
-    ImGui::Spacing();
-
-    changed = core::ui::components::toggle::control("Enabled", settings.enabled) || changed;
-
-    ImGui::Spacing();
-    // One label column and one control column, so the key button spans both edges.
-    const float labelWidth =
-        ImGui::CalcTextSize("Jump key").x + ImGui::GetStyle().ItemSpacing.x * 2;
-    const float controlWidth = ImGui::GetContentRegionAvail().x - labelWidth;
-
-    ImGui::AlignTextToFramePadding();
-    ImGui::TextUnformatted("Jump key");
-    ImGui::SameLine(labelWidth);
-    if (g_capturing) {
-        if (ImGui::Button("...", ImVec2(controlWidth, 0.0F))) {
-            g_capturing = false;
-        }
-        std::uint32_t picked = client::sword_skate::kNoKey;
-        if (capture_key(picked)) {
-            settings.jumpKey = picked;
-            g_capturing = false;
-            changed = true;
-        }
-    } else {
-        std::array<char, kKeyNameCapacity> name{};
-        key_name(settings.jumpKey, name);
-        if (ImGui::Button(name.data(), ImVec2(controlWidth, 0.0F))) {
-            g_capturing = true;
-        }
-    }
-    ImGui::TextWrapped("Must match the key the game jumps on. Nothing happens on any other key.");
-
-    if (changed && !client::sword_skate::publish(settings)) {
-        ImGui::Spacing();
-        ImGui::TextUnformatted("value out of range, not saved");
-    }
-}
-
-} // namespace sunrise::client::ui::sword_skate

+ 0 - 8
Sunrise/src/client/ui/sword_skate/sword_skate_panel.h

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

+ 0 - 146
Sunrise/src/client/ui/teleport/teleport_panel.cpp

@@ -1,146 +0,0 @@
-/**
- * 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.
- */
-
-#include "teleport_panel.h"
-
-#include <Windows.h>
-
-#include <array>
-#include <cstdio>
-#include <imgui.h>
-
-#include "../../../core/ui/components/toggle/ui_toggle_component.h"
-#include "../../teleport/teleport_settings_store.h"
-
-namespace sunrise::client::ui::teleport {
-namespace {
-
-/** Lowest and highest virtual keys the picker scans. Zero is not a key. */
-constexpr int kFirstVirtualKey = 1;
-constexpr int kLastVirtualKey = 254;
-/** Mouse buttons are skipped so a click on the picker cannot bind itself. */
-constexpr int kLastMouseKey = 6;
-/** Longest key name Windows returns, plus the null. */
-constexpr std::size_t kKeyNameCapacity = 64;
-
-bool g_capturing{};
-
-/**
- * Names one virtual key for display.
- * @param virtualKey Key to name, or zero for no binding.
- * @param output Receives the name.
- */
-void key_name(std::uint32_t virtualKey, std::array<char, kKeyNameCapacity>& output) noexcept {
-    if (virtualKey == client::teleport::kNoKey) {
-        (void)std::snprintf(output.data(), output.size(), "None");
-        return;
-    }
-    const UINT scanCode = MapVirtualKeyW(virtualKey, MAPVK_VK_TO_VSC);
-    std::array<wchar_t, kKeyNameCapacity> wide{};
-    const int written = scanCode != 0 ? GetKeyNameTextW(static_cast<LONG>(scanCode << 16),
-                                                        wide.data(),
-                                                        static_cast<int>(wide.size()))
-                                      : 0;
-    if (written <= 0
-        || WideCharToMultiByte(CP_UTF8,
-                               0,
-                               wide.data(),
-                               written,
-                               output.data(),
-                               static_cast<int>(output.size() - 1),
-                               nullptr,
-                               nullptr)
-               <= 0) {
-        (void)std::snprintf(
-            output.data(), output.size(), "Key 0x%02X", static_cast<unsigned>(virtualKey));
-    }
-}
-
-/**
- * Takes the first key held while the picker is armed.
- * @param picked Receives the key, or zero when Escape clears the binding.
- * @return True when this frame ended the capture.
- */
-[[nodiscard]] bool capture_key(std::uint32_t& picked) noexcept {
-    if ((GetAsyncKeyState(VK_ESCAPE) & 0x8000) != 0) {
-        picked = client::teleport::kNoKey;
-        return true;
-    }
-    for (int key = kFirstVirtualKey; key <= kLastVirtualKey; ++key) {
-        if (key <= kLastMouseKey) {
-            continue;
-        }
-        if ((GetAsyncKeyState(key) & 0x8000) != 0) {
-            picked = static_cast<std::uint32_t>(key);
-            return true;
-        }
-    }
-    return false;
-}
-
-} // namespace
-
-/** Draws the teleport module inside the active Core UI frame. */
-void draw() noexcept {
-    client::teleport::Settings settings = client::teleport::get();
-    bool changed = false;
-
-    ImGui::TextUnformatted("Teleport");
-    ImGui::Separator();
-    ImGui::TextWrapped("Teleports you forward in the facing direction. "
-                       "Cancels vertical momentum.");
-    ImGui::Spacing();
-
-    changed = core::ui::components::toggle::control("Enabled", settings.enabled) || changed;
-
-    ImGui::Spacing();
-    // One label column and one control column, so the slider and the key button share both edges.
-    const float labelWidth =
-        ImGui::CalcTextSize("Distance").x + ImGui::GetStyle().ItemSpacing.x * 2;
-    const float controlWidth = ImGui::GetContentRegionAvail().x - labelWidth;
-
-    ImGui::AlignTextToFramePadding();
-    ImGui::TextUnformatted("Distance");
-    ImGui::SameLine(labelWidth);
-    ImGui::SetNextItemWidth(controlWidth);
-    float distance = settings.distance;
-    if (ImGui::SliderFloat("##distance",
-                           &distance,
-                           client::teleport::kMinimumDistance,
-                           client::teleport::kMaximumDistance,
-                           "%.0f units")) {
-        settings.distance = distance;
-        changed = true;
-    }
-
-    ImGui::Spacing();
-    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;
-        }
-    }
-
-    if (changed && !client::teleport::publish(settings)) {
-        ImGui::Spacing();
-        ImGui::TextUnformatted("value out of range, not saved");
-    }
-}
-
-} // namespace sunrise::client::ui::teleport

+ 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

+ 67 - 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,24 @@ 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;
+/** Uppercase digits for the hex traces, which are read beside hex dumps of the same bytes. */
+constexpr std::string_view kHexDigits = "0123456789ABCDEF";
+/** One byte prints as two hex digits, which is the room each appended byte needs. */
+constexpr std::size_t kHexDigitsPerByte = 2;
 /** 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 +144,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 +214,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 +251,48 @@ 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});
+}
+
+/** Appends bytes as uppercase hex to a line that already holds its key prefix. */
+bool append_hex(std::span<char> line,
+                std::size_t& length,
+                std::span<const std::byte> bytes) noexcept {
+    for (const std::byte byte : bytes) {
+        if (length + kHexDigitsPerByte >= line.size()) {
+            return false;
+        }
+        const auto value = std::to_integer<unsigned>(byte);
+        line[length++] = kHexDigits[(value >> 4U) & 0xFU];
+        line[length++] = kHexDigits[value & 0xFU];
+    }
+    return true;
+}
+
 /** @return True while a sink write is in progress. */
 bool writers_active() noexcept {
     return g_writers.load(std::memory_order_acquire) != 0;

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

@@ -2,6 +2,7 @@
 
 #include <array>
 #include <cstddef>
+#include <span>
 #include <string_view>
 
 namespace sunrise::core::log {
@@ -50,6 +51,31 @@ 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;
+
+/**
+ * Appends bytes as uppercase hex to a line that already holds its key prefix.
+ * Stops before the first pair that would not fit, so a long payload truncates.
+ * @param line Line storage holding the prefix.
+ * @param length Bytes already written, raised by two for each encoded byte.
+ * @param bytes Borrowed payload to encode.
+ * @return True when every byte fit.
+ */
+bool append_hex(std::span<char> line,
+                std::size_t& length,
+                std::span<const std::byte> bytes) 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]);

+ 21 - 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.
@@ -146,6 +153,8 @@ private:
      * @return True when every entry is complete and the array fits its size.
      */
     [[nodiscard]] bool profile_items(state::AccountState& output) noexcept;
+    /** Parses the server-authored, definition-driven ordinary-gear dismantle payout. */
+    [[nodiscard]] bool dismantle_rewards(state::AccountState& output) noexcept;
     /**
      * Parses one authored character identity.
      * @param output Receives one complete authored character row.
@@ -165,6 +174,15 @@ private:
      * @return True when every name is known and appears at most once.
      */
     [[nodiscard]] bool equipment(state::account::inventory::Equipment& output) noexcept;
+    /**
+     * Parses the optional unequipped character inventory array.
+     * @param output
+     * Receives items in authored bucket-placement order.
+     * @return True when every item is
+     * complete and the fixed array has room.
+     */
+    [[nodiscard]] bool
+    character_inventory(state::account::inventory::CharacterItems& output) noexcept;
     /**
      * Parses one item only when every required named field appears exactly once.
      * @param output Receives the complete item after checking.
@@ -241,6 +259,7 @@ private:
     /**
      * Turns one authored input name into its input code.
      * @param name Key name, or one modifier and the key it prefixes joined by "+".
+     * @param output Receives the input code.
      * @return True when every part of the name is in the Client's input table.
      */
     [[nodiscard]] static bool input_code_value(std::string_view name,
@@ -327,6 +346,7 @@ private:
     /**
      * Turns a JSON level token into the logging enum.
      * @param name Borrowed level token.
+     * @param output Receives the logging level.
      * @return True when the token names a supported level.
      */
     [[nodiscard]] static bool level_value(std::string_view name, log::Level& output) noexcept;
@@ -339,6 +359,7 @@ private:
     /**
      * Maps one readable setting name to a Windows SDK virtual key.
      * @param name Lowercase settings key name.
+     * @param output Receives the Windows SDK virtual key.
      * @return True when the name is in the supported menu-key set.
      */
     [[nodiscard]] static bool ui_toggle_key_value(std::string_view name, UINT& output) noexcept;

+ 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 = 3;
+inline constexpr std::uint32_t kSettingsVersion = 6;
 
 /** Parsed read-only process settings. */
 struct Settings {

+ 5 - 4
Sunrise/src/core/settings/settings_runtime.cpp

@@ -18,8 +18,8 @@ namespace {
 constexpr std::wstring_view kSettingsFileSuffix = L"\\settings.json";
 /** An upgraded document is staged under this suffix before it replaces the settings file. */
 constexpr std::wstring_view kUpgradeStageSuffix = L".new";
-/** Largest settings file accepted into fixed stack storage. */
-constexpr std::size_t kConfigCapacity = 64 * 1024;
+/** Largest settings file accepted into fixed storage. */
+constexpr std::size_t kConfigCapacity = 1024 * 1024;
 
 Settings g_settings = defaults();
 
@@ -221,7 +221,8 @@ bool initialize(void* module) noexcept {
         return fail("too_large");
     }
 
-    std::array<char, kConfigCapacity> buffer{};
+    // Static because two 1 MiB banks overflow the stack. Settings load once, on one thread.
+    static std::array<char, kConfigCapacity> buffer{};
     DWORD read = 0;
     const bool readOk =
         ReadFile(readableFile, buffer.data(), static_cast<DWORD>(size.QuadPart), &read, nullptr)
@@ -232,7 +233,7 @@ bool initialize(void* module) noexcept {
         return fail("read");
     }
     std::string_view document(buffer.data(), read);
-    std::array<char, kConfigCapacity> upgradedBuffer{};
+    static std::array<char, kConfigCapacity> upgradedBuffer{};
     const bool upgrading = upgrade::needed(document);
     if (upgrading) {
         std::string_view bundled;

+ 28 - 5
Sunrise/src/core/settings/settings_upgrade.cpp

@@ -17,8 +17,26 @@ namespace {
 
 /** The layout version member, quoted so a value string cannot match it. */
 constexpr std::string_view kVersionMember = "\"version\"";
-/** Members replaced with the bundled default because their value form changed. */
-constexpr std::array<std::string_view, 1> kReplacedMembers{"\"key_bindings\""};
+
+/** One 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, each with the version that changed it.
+ * A member is listed because its value form changed, or because its default changed.
+ */
+constexpr std::array<ReplacedMember, 5> kReplacedMembers{{
+    {"\"key_bindings\"", 3},
+    {"\"region_private\"", 5},
+    {"\"topology\"", 5},
+    {"\"characters\"", 5},
+    {"\"profile_items\"", 6},
+}};
 /** 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. */
@@ -195,11 +213,16 @@ 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 a user choice is never lost.
+        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;
         }

+ 72 - 0
Sunrise/src/core/settings/state/account_rows_parser.cpp

@@ -12,9 +12,75 @@ constexpr std::uint64_t kMaximumDestinationHash = (std::numeric_limits<std::uint
 
 } // namespace
 
+/** Parses the definition hashes and quantities credited by ordinary gear dismantles. */
+bool Parser::dismantle_rewards(state::AccountState& output) noexcept {
+    output.dismantleRewards = {};
+    output.dismantleRewardCount = 0;
+    if (!consume('[')) {
+        return false;
+    }
+    if (consume(']')) {
+        return true;
+    }
+    for (;;) {
+        if (output.dismantleRewardCount >= output.dismantleRewards.size() || !consume('{')) {
+            return false;
+        }
+        state::DismantleRewardPolicy reward{};
+        bool hasHash = false;
+        bool hasQuantity = false;
+        for (;;) {
+            std::string_view key;
+            if (!string(key) || !consume(':')) {
+                return false;
+            }
+            std::uint64_t value = 0;
+            if (key == "definition_hash") {
+                if (hasHash || !unsigned_value(value) || value == 0
+                    || value > (std::numeric_limits<std::uint32_t>::max)()) {
+                    return false;
+                }
+                reward.definitionHash = static_cast<std::uint32_t>(value);
+                hasHash = true;
+            } else if (key == "quantity") {
+                if (hasQuantity || !unsigned_integer(value) || value == 0
+                    || value > (std::numeric_limits<std::int32_t>::max)()) {
+                    return false;
+                }
+                reward.quantity = static_cast<std::int32_t>(value);
+                hasQuantity = true;
+            } else if (!skip_value(0)) {
+                return false;
+            }
+            if (consume('}')) {
+                break;
+            }
+            if (!consume(',')) {
+                return false;
+            }
+        }
+        for (std::size_t index = 0; index < output.dismantleRewardCount; ++index) {
+            if (output.dismantleRewards[index].definitionHash == reward.definitionHash) {
+                return false;
+            }
+        }
+        if (!hasHash || !hasQuantity) {
+            return false;
+        }
+        output.dismantleRewards[output.dismantleRewardCount++] = reward;
+        if (consume(']')) {
+            return true;
+        }
+        if (!consume(',')) {
+            return false;
+        }
+    }
+}
+
 /** Parses the authored account-wide item array. */
 bool Parser::profile_items(state::AccountState& output) noexcept {
     namespace inventory = state::account::inventory;
+    output.profileItems = {};
     output.profileItemCount = 0;
     if (!consume('[')) {
         return false;
@@ -115,6 +181,7 @@ bool Parser::character(state::CharacterState& output) noexcept {
     }
     bool hasSoid = false;
     bool hasEquipment = false;
+    bool hasInventory = false;
     if (consume('}')) {
         return false;
     }
@@ -202,6 +269,11 @@ bool Parser::character(state::CharacterState& output) noexcept {
                 return false;
             }
             hasEquipment = true;
+        } else if (key == "inventory") {
+            if (hasInventory || !character_inventory(output.inventory)) {
+                return false;
+            }
+            hasInventory = true;
         } else if (!skip_value(0)) {
             return false;
         }

+ 43 - 0
Sunrise/src/core/settings/state/inventory_parser.cpp

@@ -16,9 +16,13 @@ enum class ItemField : std::size_t {
     level,
     quantity,
     plugs,
+    flags,
     count,
 };
 
+/** The supported client exposes Locked and Tracked/Favorite as accumulated item-state bits. */
+constexpr std::uint32_t kSupportedItemStateMask = 0x3U;
+
 } // namespace
 
 /** Parses the optional equipment object with its fixed named slots. */
@@ -72,6 +76,34 @@ bool Parser::equipment(authored_inventory::Equipment& output) noexcept {
     }
 }
 
+/** Parses the optional unequipped character inventory array. */
+bool Parser::character_inventory(authored_inventory::CharacterItems& output) noexcept {
+    authored_inventory::CharacterItems parsed{};
+    if (!consume('[')) {
+        return false;
+    }
+    if (consume(']')) {
+        output = parsed;
+        return true;
+    }
+    for (;;) {
+        if (parsed.count >= parsed.values.size() || !equipment_item(parsed.values[parsed.count])) {
+            return false;
+        }
+        ++parsed.count;
+        if (consume(']')) {
+            if (!authored_inventory::valid(parsed)) {
+                return false;
+            }
+            output = parsed;
+            return true;
+        }
+        if (!consume(',')) {
+            return false;
+        }
+    }
+}
+
 /** Parses one item only when every required named field appears exactly once. */
 bool Parser::equipment_item(authored_inventory::Item& output) noexcept {
     authored_inventory::Item parsed{};
@@ -118,11 +150,22 @@ bool Parser::equipment_item(authored_inventory::Item& output) noexcept {
             if (!mark(ItemField::plugs) || !equipment_plugs(parsed.sockets)) {
                 return false;
             }
+        } else if (key == "flags") {
+            std::uint64_t flags = 0;
+            if (!mark(ItemField::flags) || !unsigned_value(flags)
+                || flags > kSupportedItemStateMask) {
+                return false;
+            }
+            parsed.flags = static_cast<std::uint32_t>(flags);
         } else {
             return false;
         }
 
         if (consume('}')) {
+            // Authored flags are optional so existing settings remain valid; an omitted value is
+            // the canonical zero state. Every identity, quantity, level and socket field remains
+            // mandatory.
+            supplied.set(static_cast<std::size_t>(ItemField::flags));
             if (!supplied.all() || !authored_inventory::valid(parsed)) {
                 return false;
             }

+ 59 - 1
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);
         }
@@ -186,7 +238,7 @@ bool Parser::state_settings(Settings& output) noexcept {
             return false;
         }
         if (consume('}')) {
-            return state::account::valid(output.initialAccount)
+            return state::account::valid_authored(output.initialAccount)
                    && state::activity::defaults::valid(output.initialActivityDefaults);
         }
         if (!consume(',')) {
@@ -210,6 +262,7 @@ bool Parser::account(state::AccountState& output) noexcept {
     }
     bool hasPrimarySoid = false;
     bool hasSettings = false;
+    bool hasDismantleRewards = false;
     if (consume('}')) {
         return false;
     }
@@ -232,6 +285,11 @@ bool Parser::account(state::AccountState& output) noexcept {
             if (!profile_items(output)) {
                 return false;
             }
+        } else if (key == "dismantle_rewards") {
+            if (hasDismantleRewards || !dismantle_rewards(output)) {
+                return false;
+            }
+            hasDismantleRewards = true;
         } else if (!skip_value(0)) {
             return false;
         }

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

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

@@ -30,6 +30,43 @@ inline constexpr std::uint16_t kAbsentPackageId = 0xFFFFU;
 
 /** Element class of the item index table inside the investment container. */
 inline constexpr std::uint32_t kItemIndexTableClass = 0x80807BE8U;
+/** The investment root holds the installed collectible definition table at this slot. */
+inline constexpr std::size_t kCollectibleTableSlot = 19;
+/** Definition class recorded for the installed investment root tag. */
+inline constexpr std::uint32_t kInvestmentRootClass = 0x80807D84U;
+/** Definition class recorded for the installed collectible table tag. */
+inline constexpr std::uint32_t kCollectibleTableClass = 0x8080306DU;
+/** Element class recorded by the collectible table's inline array header. */
+inline constexpr std::uint32_t kCollectibleRowClass = 0x80803475U;
+/** One collectible row in this installed build occupies 184 bytes. */
+inline constexpr std::size_t kCollectibleRowStride = 0xB8;
+/** Authored DestinyCollectibleDefinition hash inside one collectible row. */
+inline constexpr std::size_t kCollectibleHashOffset = 0x28;
+/** Native item-definition index granted by one collectible row. */
+inline constexpr std::size_t kCollectibleItemIndexOffset = 0x2C;
+/** Native material-requirement-set ordinal carried by one collectible row. */
+inline constexpr std::size_t kCollectibleMaterialRequirementIndexOffset = 0x9A;
+/** The investment root holds the material-requirement-set table at this slot. */
+inline constexpr std::size_t kMaterialRequirementTableSlot = 96;
+/** Installed material-requirement-set table and row classes. */
+inline constexpr std::uint32_t kMaterialRequirementTableClass = 0x80807ACEU;
+inline constexpr std::uint32_t kMaterialRequirementSetRowClass = 0x80807AD4U;
+inline constexpr std::uint32_t kMaterialRequirementRowClass = 0x80807AD7U;
+/** One set row is a hash followed by a self-relative pointer to an array descriptor. */
+inline constexpr std::size_t kMaterialRequirementSetRowStride = 0x10;
+inline constexpr std::size_t kMaterialRequirementSetHashOffset = 0;
+inline constexpr std::size_t kMaterialRequirementSetArrayPointerOffset = 8;
+/** One requirement row is item index, quantity, two flags, then a native sentinel. */
+inline constexpr std::size_t kMaterialRequirementRowStride = 0x0C;
+inline constexpr std::size_t kMaterialRequirementItemIndexOffset = 0;
+inline constexpr std::size_t kMaterialRequirementQuantityOffset = 4;
+inline constexpr std::size_t kMaterialRequirementDeleteOffset = 8;
+inline constexpr std::size_t kMaterialRequirementOmitOffset = 9;
+inline constexpr std::size_t kMaterialRequirementSentinelOffset = 10;
+/** Plug item definitions name insertion/enabled requirement-set ordinals at fixed offsets. */
+inline constexpr std::size_t kInsertionMaterialRequirementSetIndexOffset = 0x1E8;
+inline constexpr std::size_t kEnabledMaterialRequirementSetIndexOffset = 0x200;
+inline constexpr std::uint16_t kUnavailableMaterialRequirementSetIndex = 0xFFFFU;
 /** Element class of an item's ordinary socket array. */
 inline constexpr std::uint32_t kOrdinarySocketClass = 0x808077C4U;
 /** One ordinary socket entry is 80 bytes. */
@@ -131,12 +168,28 @@ slot_tag(std::span<const std::byte> blob, std::size_t index, std::uint32_t& tag)
 
 /** The investment root holds the item index table at this slot. */
 inline constexpr std::size_t kItemTableSlot = 48;
+/** The investment root holds the shared reusable/randomized plug-set table at this slot. */
+inline constexpr std::size_t kPlugSetTableSlot = 51;
 /** The investment root holds the socket entry list table at this slot. */
 inline constexpr std::size_t kSocketEntryListTableSlot = 97;
 /** The globals container names the investment root as its first child. */
 inline constexpr std::size_t kInvestmentRootChild = 0;
 /** The investment root holds the inventory bucket table at this slot. */
 inline constexpr std::size_t kBucketTableSlot = 17;
+/**
+ * Package class of the bucket-definition table pairing inventory buckets with native equipment
+ * slots. Exactly one installed entry carries it, so a class sweep locates the table without
+ * naming the tag it happens to hold in one install.
+ */
+inline constexpr std::uint32_t kBucketDefinitionTableClass = 0x80805936U;
+/** The installed table contains one row for each item-bearing bucket definition. */
+inline constexpr std::size_t kBucketDefinitionCount = 34;
+/** One resolved bucket definition occupies 72 bytes. */
+inline constexpr std::size_t kBucketDefinitionSize = 72;
+/** The native equipment-slot byte sits here in each resolved bucket definition. */
+inline constexpr std::size_t kBucketDefinitionEquipmentSlotOffset = 64;
+/** Non-equippable bucket definitions carry the all-one slot sentinel. */
+inline constexpr std::uint8_t kBucketDefinitionUnavailableEquipmentSlot = 0xFF;
 /** Element class of the socket entry list table. */
 inline constexpr std::uint32_t kSocketEntryListTableClass = 0x80807A7EU;
 /** A socket entry list definition holds its entry array descriptor here. */

+ 20 - 6
Sunrise/src/middleware/content/packages/tables/item_appearance_reader.cpp

@@ -8,7 +8,8 @@ namespace {
 
 /** The art block declares its gear art definition index here. */
 constexpr std::size_t kGearArtIndexOffset = 88;
-/** One art row is 4 bytes and carries its art index after a class byte and one pad byte. */
+/** One art row is 4 bytes: signed class, one pad byte, then its art index. */
+constexpr std::size_t kArtRowStride = 4;
 constexpr std::size_t kArtRowValueOffset = 2;
 /** Material override arrays sit at these art-block offsets, one per stage. */
 constexpr std::size_t kMaterialStageOffsets[]{40, 56, 72};
@@ -57,9 +58,7 @@ read(std::span<const std::byte> blob, std::size_t offset, Value& value) noexcept
 }
 
 /**
- * Reads the two art indices the art block declares.
- * The arrangement index comes from the block's first art row, so the row count must be checked.
- * An art block with no rows would read arrangement zero, which is a real arrangement, not none.
+ * Reads the gear-art index and every class-qualified arrangement the art block declares.
  * @param definition Whole item definition bytes.
  * @param art Art block offset.
  * @param row Receives both art indices.
@@ -70,7 +69,20 @@ void read_art(std::span<const std::byte> definition, std::size_t art, Row& row)
     if (!find_array_at(definition, art, rows) || rows.elementClass != kArtRowClass) {
         return;
     }
-    (void)read(definition, rows.dataOffset + kArtRowValueOffset, row.artArrangementIndex);
+    for (std::uint64_t index = 0; index < rows.count; ++index) {
+        const std::size_t at = rows.dataOffset + static_cast<std::size_t>(index) * kArtRowStride;
+        std::int8_t characterClass = -1;
+        std::uint16_t arrangement = kUnavailableArtIndex;
+        if (!read(definition, at, characterClass)
+            || !read(definition, at + kArtRowValueOffset, arrangement)) {
+            return;
+        }
+        const std::size_t slot =
+            characterClass == -1 ? 0 : static_cast<std::size_t>(characterClass) + 1U;
+        if (slot < kArtClassCapacity && row.artArrangementIndices[slot] == kUnavailableArtIndex) {
+            row.artArrangementIndices[slot] = arrangement;
+        }
+    }
 }
 
 /**
@@ -147,7 +159,9 @@ void read_sandbox_perks(std::span<const std::byte> definition, Row& row) noexcep
 /** Reads the art indices, material override rows and sandbox perks one definition declares. */
 void read_appearance(std::span<const std::byte> definition, Row& row) noexcept {
     row.gearArtIndex = kUnavailableArtIndex;
-    row.artArrangementIndex = kUnavailableArtIndex;
+    std::fill(std::begin(row.artArrangementIndices),
+              std::end(row.artArrangementIndices),
+              kUnavailableArtIndex);
     row.sandboxPerkCount = 0;
     row.renderOverrideCount = 0;
     std::size_t art = 0;

+ 136 - 0
Sunrise/src/middleware/content/packages/tables/item_definition_reader.cpp

@@ -26,6 +26,21 @@ constexpr std::size_t kSocketTypeOffset = 0;
 constexpr std::size_t kSocketPlugOffset = 2;
 /** Fixed fields end after the instanced predicate. */
 constexpr std::size_t kFixedFieldEnd = kInstancedOffset + 1;
+/** Optional plug category used to expand three native reusable plug families. */
+constexpr std::size_t kPlugCategoryOffset = 392;
+/** Embedded reusable-list array descriptor inside one 80-byte ordinary socket entry. */
+constexpr std::size_t kEmbeddedPlugListOffset = 64;
+/** Reusable and randomized shared plug-set row indices inside one socket entry. */
+constexpr std::size_t kReusablePlugSetIndexOffset = 12;
+constexpr std::size_t kRandomizedPlugSetIndexOffset = 32;
+/** One shared plug-set table row is 24 bytes and carries its member descriptor at byte 8. */
+constexpr std::size_t kPlugSetRowStride = 24;
+constexpr std::size_t kPlugSetMemberDescriptorOffset = 8;
+/** Both embedded and shared plug member rows name an item index first and occupy 32 bytes. */
+constexpr std::size_t kPlugMemberStride = 32;
+constexpr std::size_t kPlugMemberIndexOffset = 0;
+/** Native arrays use 16-bit definition indices even though their serialized field is 32-bit. */
+constexpr std::uint64_t kMaximumPlugMemberCount = 65535;
 
 /** @param blob Source bytes. @param offset Field offset. @param value Receives the field. */
 template <typename Value>
@@ -69,6 +84,7 @@ void read_socket_entry_list(std::span<const std::byte> definition, Row& row) noe
  * Reads the optional equipment slot a definition declares.
  * @param definition Whole item definition bytes.
  * @param slot Receives the slot when the block is present and readable.
+ * @param raw Receives the unvalidated declared value, or -1 when the block is absent.
  */
 void read_equipment_slot(std::span<const std::byte> definition,
                          std::optional<std::int8_t>& slot,
@@ -133,6 +149,61 @@ void read_sockets(std::span<const std::byte> definition, Row& row) noexcept {
     }
 }
 
+/** Walks one checked array of 32-byte plug rows. */
+[[nodiscard]] bool visit_plug_array(std::span<const std::byte> blob,
+                                    const Array& array,
+                                    AllowedPlugVisitor visitor,
+                                    void* context) noexcept {
+    if (array.count > kMaximumPlugMemberCount || array.dataOffset > blob.size()
+        || array.count > (blob.size() - array.dataOffset) / kPlugMemberStride) {
+        return false;
+    }
+    for (std::uint64_t index = 0; index < array.count; ++index) {
+        std::uint32_t itemDefinitionIndex = 0;
+        const std::size_t row =
+            array.dataOffset + static_cast<std::size_t>(index) * kPlugMemberStride;
+        if (!read(blob, row + kPlugMemberIndexOffset, itemDefinitionIndex)
+            || !visitor(context, itemDefinitionIndex)) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** Walks one reusable or randomized shared plug-set row, when the socket declares it. */
+[[nodiscard]] bool visit_shared_plug_set(std::span<const std::byte> definition,
+                                         std::size_t socketEntry,
+                                         std::size_t setIndexOffset,
+                                         std::span<const std::byte> plugSetTable,
+                                         const Array& sets,
+                                         AllowedPlugVisitor visitor,
+                                         void* context) noexcept {
+    std::uint16_t setIndex = kUnavailablePlug;
+    if (!read(definition, socketEntry + setIndexOffset, setIndex)) {
+        return false;
+    }
+    if (setIndex == kUnavailablePlug) {
+        return true;
+    }
+    if (setIndex >= sets.count || sets.dataOffset > plugSetTable.size()
+        || sets.count > (plugSetTable.size() - sets.dataOffset) / kPlugSetRowStride) {
+        return false;
+    }
+    const std::size_t descriptor = sets.dataOffset
+                                   + static_cast<std::size_t>(setIndex) * kPlugSetRowStride
+                                   + kPlugSetMemberDescriptorOffset;
+    std::uint64_t memberCount = 0;
+    if (!read(plugSetTable, descriptor, memberCount)) {
+        return false;
+    }
+    if (memberCount == 0) {
+        return true;
+    }
+    Array members{};
+    return find_array_at(plugSetTable, descriptor, members)
+           && visit_plug_array(plugSetTable, members, visitor, context);
+}
+
 /** The block header carries its own self-relative pointer to the entries at byte 8. */
 constexpr std::size_t kStatDataMember = 8;
 /** One stat entry is 40 blob bytes. */
@@ -192,6 +263,8 @@ bool read_definition(std::span<const std::byte> definition, Row& row) noexcept {
     row = {};
     row.definitionHash = hash;
     row.definitionIndex = index;
+    row.insertionMaterialRequirementSetIndex = kUnavailableMaterialRequirementSetIndex;
+    row.enabledMaterialRequirementSetIndex = kUnavailableMaterialRequirementSetIndex;
     std::fill(std::begin(row.initialPlugs), std::end(row.initialPlugs), kUnavailablePlug);
     std::fill(std::begin(row.socketTypes), std::end(row.socketTypes), kUnavailableSocketType);
     if (definition.size() < kFixedFieldEnd) {
@@ -204,6 +277,14 @@ bool read_definition(std::span<const std::byte> definition, Row& row) noexcept {
         return false;
     }
     row.instanced = instanced != 0;
+    // Short legacy definitions simply do not declare a plug category.
+    (void)read(definition, kPlugCategoryOffset, row.plugCategoryHash);
+    (void)read(definition,
+               kInsertionMaterialRequirementSetIndexOffset,
+               row.insertionMaterialRequirementSetIndex);
+    (void)read(definition,
+               kEnabledMaterialRequirementSetIndexOffset,
+               row.enabledMaterialRequirementSetIndex);
     read_stats(definition, row);
     read_appearance(definition, row);
     read_socket_entry_list(definition, row);
@@ -212,4 +293,59 @@ bool read_definition(std::span<const std::byte> definition, Row& row) noexcept {
     return true;
 }
 
+/** Visits every list-backed allowed plug for one exact ordinary socket lane. */
+bool visit_allowed_plugs(std::span<const std::byte> definition,
+                         std::span<const std::byte> plugSetTable,
+                         std::uint8_t lane,
+                         AllowedPlugVisitor visitor,
+                         void* context) noexcept {
+    if (visitor == nullptr || lane >= kSocketCapacity) {
+        return false;
+    }
+    std::int64_t socketBlockRelative = 0;
+    if (!read(definition, kSocketBlockOffset, socketBlockRelative) || socketBlockRelative == 0) {
+        return false;
+    }
+    const std::int64_t socketBlock =
+        static_cast<std::int64_t>(kSocketBlockOffset) + socketBlockRelative;
+    if (socketBlock < 0 || static_cast<std::uint64_t>(socketBlock) >= definition.size()) {
+        return false;
+    }
+    Array sockets{};
+    if (!find_array_at(definition, static_cast<std::size_t>(socketBlock), sockets)
+        || sockets.elementClass != kOrdinarySocketClass || lane >= sockets.count
+        || sockets.dataOffset > definition.size()
+        || sockets.count > (definition.size() - sockets.dataOffset) / kSocketEntryStride) {
+        return false;
+    }
+    Array sets{};
+    if (!find_array_at(plugSetTable, kTableArrayDescriptor, sets)) {
+        return false;
+    }
+    const std::size_t socketEntry =
+        sockets.dataOffset + static_cast<std::size_t>(lane) * kSocketEntryStride;
+    Array embedded{};
+    std::uint64_t embeddedCount = 0;
+    if (!read(definition, socketEntry + kEmbeddedPlugListOffset, embeddedCount)
+        || (embeddedCount != 0
+            && (!find_array_at(definition, socketEntry + kEmbeddedPlugListOffset, embedded)
+                || !visit_plug_array(definition, embedded, visitor, context)))) {
+        return false;
+    }
+    return visit_shared_plug_set(definition,
+                                 socketEntry,
+                                 kReusablePlugSetIndexOffset,
+                                 plugSetTable,
+                                 sets,
+                                 visitor,
+                                 context)
+           && visit_shared_plug_set(definition,
+                                    socketEntry,
+                                    kRandomizedPlugSetIndexOffset,
+                                    plugSetTable,
+                                    sets,
+                                    visitor,
+                                    context);
+}
+
 } // namespace sunrise::middleware::content::packages::tables::items

+ 35 - 2
Sunrise/src/middleware/content/packages/tables/items.h

@@ -11,6 +11,8 @@ namespace sunrise::middleware::content::packages::tables::items {
 inline constexpr std::size_t kSocketCapacity = 12;
 /** All bits set marks a socket lane with no initial plug. */
 inline constexpr std::uint16_t kUnavailablePlug = 0xFFFF;
+/** All bits set marks an item definition with no insertion or enable requirement set. */
+inline constexpr std::uint16_t kUnavailableMaterialRequirementSetIndex = 0xFFFFU;
 
 /** Declared stat contributions one definition carries. Shipped rows stay far below this. */
 inline constexpr std::size_t kStatCapacity = 64;
@@ -20,6 +22,8 @@ inline constexpr std::size_t kSandboxPerkCapacity = 4;
 inline constexpr std::size_t kRenderOverrideCapacity = 32;
 /** All bits set marks an art index the definition does not declare. */
 inline constexpr std::uint16_t kUnavailableArtIndex = 0xFFFF;
+/** Generic art plus one row for each of Titan, Hunter, and Warlock. */
+inline constexpr std::size_t kArtClassCapacity = 4;
 /** All bits set marks a socket lane whose type the definition does not declare. */
 inline constexpr std::uint16_t kUnavailableSocketType = 0xFFFF;
 /** A signed material override key is empty at minus one. */
@@ -47,13 +51,18 @@ struct Row {
     std::uint8_t socketCount{};
     std::uint16_t initialPlugs[kSocketCapacity]{};
     std::uint16_t socketTypes[kSocketCapacity]{};
+    /** Plug category used by a few native sockets to expand a seed into its whole safe family. */
+    std::uint32_t plugCategoryHash{};
+    /** Native material sets used when this definition is inserted or enabled as a plug. */
+    std::uint16_t insertionMaterialRequirementSetIndex{kUnavailableMaterialRequirementSetIndex};
+    std::uint16_t enabledMaterialRequirementSetIndex{kUnavailableMaterialRequirementSetIndex};
     std::uint8_t statCount{};
     std::uint8_t statRows[kStatCapacity]{};
     std::int32_t statValues[kStatCapacity]{};
     /** Gear art definition index, read straight from the art block. */
     std::uint16_t gearArtIndex{kUnavailableArtIndex};
-    /** Art arrangement index, read from the art block's first art row. */
-    std::uint16_t artArrangementIndex{kUnavailableArtIndex};
+    /** Generic, Titan, Hunter, and Warlock art arrangements declared by the art block. */
+    std::uint16_t artArrangementIndices[kArtClassCapacity]{};
     std::uint8_t sandboxPerkCount{};
     std::uint16_t sandboxPerks[kSandboxPerkCapacity]{};
     std::uint8_t renderOverrideCount{};
@@ -77,4 +86,28 @@ void read_appearance(std::span<const std::byte> definition, Row& row) noexcept;
  */
 [[nodiscard]] bool read_definition(std::span<const std::byte> definition, Row& row) noexcept;
 
+/** Visitor called for each native item-definition index an ordinary socket list names. */
+using AllowedPlugVisitor = bool (*)(void* context, std::uint32_t itemDefinitionIndex) noexcept;
+
+/**
+ * Visits the embedded, reusable, and randomized plug-list members declared for one socket
+ * lane.
+ * The initial plug is a separate fixed field and is intentionally left to the caller.
+ *
+ * @param definition Whole base-item definition bytes.
+ * @param plugSetTable Whole shared plug-set
+ * definition table from investment-root slot 51.
+ * @param lane Ordinary socket lane to inspect.
+ *
+ * @param visitor Required bounded consumer.
+ * @param context Opaque consumer state.
+ * @return
+ * True when every referenced array is structurally valid and accepted by the visitor.
+ */
+[[nodiscard]] bool visit_allowed_plugs(std::span<const std::byte> definition,
+                                       std::span<const std::byte> plugSetTable,
+                                       std::uint8_t lane,
+                                       AllowedPlugVisitor visitor,
+                                       void* context) noexcept;
+
 } // namespace sunrise::middleware::content::packages::tables::items

+ 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

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません