Przeglądaj źródła

Merge master into the custom package loading work

One conflict, in the client shutdown sequence. Master removed the banner
hook while this branch added the package-trust uninstall above it, so the
merge keeps the package-trust block and drops the banner call with its
comment.

Also bring the new file to the lint contract: fix the include order and
the clang-format policy, and give the two derived signature constants a
nearby comment.
stan 3 tygodni temu
rodzic
commit
335402a257
100 zmienionych plików z 7929 dodań i 566 usunięć
  1. 2 2
      .clang-tidy
  2. 4 0
      CMakeLists.txt
  3. 36 5
      Sunrise/Sunrise.vcxproj
  4. 925 37
      Sunrise/resources/default_settings.json
  5. 2 2
      Sunrise/resources/sunrise.rc
  6. 3 2
      Sunrise/src/client/content/investment/internal.h
  7. 14 58
      Sunrise/src/client/content/investment/investment_refresh.cpp
  8. 1 1
      Sunrise/src/client/content/investment/worker/investment_refresh_worker.cpp
  9. 3 3
      Sunrise/src/client/content/items/details/configured_item_detail_extractor.cpp
  10. 129 23
      Sunrise/src/client/content/items/packages/internal.h
  11. 124 6
      Sunrise/src/client/content/items/packages/package_build_report.cpp
  12. 176 0
      Sunrise/src/client/content/items/packages/package_collectible_build.cpp
  13. 89 55
      Sunrise/src/client/content/items/packages/package_detail_build.cpp
  14. 43 6
      Sunrise/src/client/content/items/packages/package_item_build.cpp
  15. 120 26
      Sunrise/src/client/content/items/packages/package_item_rows.cpp
  16. 205 0
      Sunrise/src/client/content/items/packages/package_material_requirement_build.cpp
  17. 171 8
      Sunrise/src/client/content/items/packages/package_root_tables.cpp
  18. 273 0
      Sunrise/src/client/content/items/packages/package_socket_plug_build.cpp
  19. 88 0
      Sunrise/src/client/content/items/packages/package_socket_plug_build.h
  20. 51 12
      Sunrise/src/client/content/items/packages/package_subclass_build.cpp
  21. 6 3
      Sunrise/src/client/hooks/bootflow/region_private.cpp
  22. 38 34
      Sunrise/src/client/hooks/noclip/horizontal_noclip.cpp
  23. 0 6
      Sunrise/src/client/hooks/noclip/runtime.h
  24. 18 26
      Sunrise/src/client/hooks/package_trust/package_trust_bypass.cpp
  25. 20 1
      Sunrise/src/client/hooks/queuez/svc123_null_payload_guard.cpp
  26. 4 4
      Sunrise/src/client/hooks/teleport/teleport_move.cpp
  27. 10 6
      Sunrise/src/client/movement/movement_settings_store.cpp
  28. 4 4
      Sunrise/src/client/movement/movement_settings_store.h
  29. 3 6
      Sunrise/src/client/patterns/registry.cpp
  30. 5 32
      Sunrise/src/client/runtime/client_hook_activation.cpp
  31. 5 9
      Sunrise/src/client/runtime/client_runtime_lifecycle.cpp
  32. 1 4
      Sunrise/src/client/targets/game/assert_handler/game_assert_targets.h
  33. 24 29
      Sunrise/src/client/ui/movement/movement_panel.cpp
  34. 8 0
      Sunrise/src/client/ui/movement/movement_panel.h
  35. 7 7
      Sunrise/src/client/ui/runtime/client_ui_module_runtime.cpp
  36. 0 8
      Sunrise/src/client/ui/teleport/teleport_panel.h
  37. 49 0
      Sunrise/src/core/logging/log.cpp
  38. 26 0
      Sunrise/src/core/logging/log.h
  39. 2 27
      Sunrise/src/core/runtime/core_runtime.cpp
  40. 3 3
      Sunrise/src/core/settings/client/definition.h
  41. 14 0
      Sunrise/src/core/settings/parser.h
  42. 2 1
      Sunrise/src/core/settings/server/gameplay/definition.h
  43. 3 3
      Sunrise/src/core/settings/settings.h
  44. 5 4
      Sunrise/src/core/settings/settings_runtime.cpp
  45. 28 5
      Sunrise/src/core/settings/settings_upgrade.cpp
  46. 72 0
      Sunrise/src/core/settings/state/account_rows_parser.cpp
  47. 43 0
      Sunrise/src/core/settings/state/inventory_parser.cpp
  48. 7 1
      Sunrise/src/core/settings/state_settings.cpp
  49. 35 15
      Sunrise/src/core/settings/steam/steam_settings_parser.cpp
  50. 53 0
      Sunrise/src/middleware/content/packages/tables/definition_index_table.h
  51. 20 6
      Sunrise/src/middleware/content/packages/tables/item_appearance_reader.cpp
  52. 136 0
      Sunrise/src/middleware/content/packages/tables/item_definition_reader.cpp
  53. 35 2
      Sunrise/src/middleware/content/packages/tables/items.h
  54. 33 1
      Sunrise/src/middleware/datagen/character_record/appearance/character_appearance_render.cpp
  55. 14 0
      Sunrise/src/middleware/datagen/character_record/appearance/internal.h
  56. 1 1
      Sunrise/src/middleware/datagen/character_record/character_record_encoder.cpp
  57. 16 1
      Sunrise/src/middleware/datagen/family4/account/account_encoder.cpp
  58. 36 3
      Sunrise/src/middleware/datagen/family4/account/layout.h
  59. 11 0
      Sunrise/src/middleware/datagen/family4/character/abi.h
  60. 25 6
      Sunrise/src/middleware/datagen/family4/character/character_encoder.cpp
  61. 40 3
      Sunrise/src/middleware/datagen/family4/character/layout.h
  62. 9 2
      Sunrise/src/middleware/datagen/family4/loadout/definition.h
  63. 2 0
      Sunrise/src/middleware/datagen/family4/loadout/loadout_item_resolver.cpp
  64. 73 10
      Sunrise/src/middleware/datagen/family4/loadout/loadout_resolver.cpp
  65. 9 0
      Sunrise/src/middleware/datagen/family4/loadout/loadout_resolver.h
  66. 29 0
      Sunrise/src/middleware/web_service/messages/opcode1820.h
  67. 40 0
      Sunrise/src/middleware/web_service/messages/opcode1820_codec.cpp
  68. 71 0
      Sunrise/src/middleware/web_service/messages/opcode1901.h
  69. 124 0
      Sunrise/src/middleware/web_service/messages/opcode1901_codec.cpp
  70. 36 0
      Sunrise/src/middleware/web_service/messages/opcode402.h
  71. 67 0
      Sunrise/src/middleware/web_service/messages/opcode402_codec.cpp
  72. 31 0
      Sunrise/src/middleware/web_service/messages/opcode403.h
  73. 32 0
      Sunrise/src/middleware/web_service/messages/opcode403_codec.cpp
  74. 35 0
      Sunrise/src/middleware/web_service/messages/opcode406.h
  75. 62 0
      Sunrise/src/middleware/web_service/messages/opcode406_codec.cpp
  76. 35 0
      Sunrise/src/middleware/web_service/messages/opcode903.h
  77. 81 0
      Sunrise/src/middleware/web_service/messages/opcode903_codec.cpp
  78. 41 0
      Sunrise/src/server/bap/bap_route.cpp
  79. 8 8
      Sunrise/src/server/bap/encrypted/bap_connection_publication.cpp
  80. 248 16
      Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp
  81. 163 5
      Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp
  82. 195 6
      Sunrise/src/server/bap/encrypted/internal.h
  83. 287 0
      Sunrise/src/server/bap/encrypted/push/queuez/queuez_banner_push.cpp
  84. 255 0
      Sunrise/src/server/bap/encrypted/push/queuez/queuez_select_character.cpp
  85. 53 1
      Sunrise/src/server/bap/encrypted/push/queuez/queuez_subscription.cpp
  86. 137 0
      Sunrise/src/server/bap/encrypted/push/snapshot/banner_snapshot.cpp
  87. 637 0
      Sunrise/src/server/bap/encrypted/push/snapshot/family4_inventory_updates.cpp
  88. 98 0
      Sunrise/src/server/bap/encrypted/push/snapshot/family4_object_staging.cpp
  89. 381 0
      Sunrise/src/server/bap/encrypted/push/snapshot/family4_selection_move.cpp
  90. 21 1
      Sunrise/src/server/bap/encrypted/push/snapshot/family4_snapshot_preparer.cpp
  91. 20 0
      Sunrise/src/server/bap/encrypted/push/snapshot/initial_snapshot.cpp
  92. 103 0
      Sunrise/src/server/bap/encrypted/push/snapshot/internal.h
  93. 95 0
      Sunrise/src/server/bap/encrypted/push/snapshot/roster_snapshot.cpp
  94. 53 4
      Sunrise/src/server/bap/encrypted/push/snapshot/snapshot.h
  95. 93 4
      Sunrise/src/server/bap/encrypted/queuez/definition.h
  96. 81 0
      Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp
  97. 298 0
      Sunrise/src/server/bap/encrypted/queuez/queuez_outcome_staging.cpp
  98. 13 0
      Sunrise/src/server/bap/encrypted/queuez/queuez_state_validation.cpp
  99. 118 0
      Sunrise/src/server/bap/encrypted/queuez/queuez_state_validation.h
  100. 474 2
      Sunrise/src/server/bap/encrypted/queuez/staging/queuez_character_staging.cpp

+ 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'
 ...

+ 4 - 0
CMakeLists.txt

@@ -44,6 +44,10 @@ 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"

+ 36 - 5
Sunrise/Sunrise.vcxproj

@@ -1,4 +1,4 @@
-<?xml version="1.0" encoding="utf-8"?>
+<?xml version="1.0" encoding="utf-8"?>
 <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
   <ItemGroup Label="ProjectConfigurations">
     <ProjectConfiguration Include="Debug|x64">
@@ -128,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" />
@@ -167,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" />
@@ -188,8 +198,8 @@
     <ClCompile Include="src\client\hooks\egress\winsock\transmission\egress_datagram_transmission.cpp" />
     <ClCompile Include="src\client\hooks\egress\winsock\transmission\egress_message_transmission.cpp" />
     <ClCompile Include="src\client\ui\runtime\client_ui_module_runtime.cpp" />
-    <ClCompile Include="src\client\ui\teleport\teleport_panel.cpp" />
-    <ClCompile Include="src\client\teleport\teleport_settings_store.cpp" />
+    <ClCompile Include="src\client\ui\movement\movement_panel.cpp" />
+    <ClCompile Include="src\client\movement\movement_settings_store.cpp" />
     <ClCompile Include="src\client\hooks\noclip\horizontal_noclip.cpp" />
     <ClCompile Include="src\client\hooks\teleport\teleport_lifecycle.cpp" />
     <ClCompile Include="src\client\hooks\teleport\teleport_move.cpp" />
@@ -414,7 +424,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" />
@@ -462,6 +478,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" />
@@ -483,12 +500,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" />
@@ -569,7 +588,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" />
@@ -694,6 +716,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" />
@@ -724,11 +748,11 @@
     <ClInclude Include="src\core\ui\layout\navigation\ui_layout_navigation.h" />
     <ClInclude Include="src\core\ui\layout\credits\sunrise_credits_badge.h" />
     <ClInclude Include="src\client\runtime\runtime.h" />
-    <ClInclude Include="src\client\teleport\teleport_settings_store.h" />
+    <ClInclude Include="src\client\movement\movement_settings_store.h" />
     <ClInclude Include="src\client\hooks\noclip\runtime.h" />
     <ClInclude Include="src\client\hooks\teleport\internal.h" />
     <ClInclude Include="src\client\hooks\teleport\runtime.h" />
-    <ClInclude Include="src\client\ui\teleport\teleport_panel.h" />
+    <ClInclude Include="src\client\ui\movement\movement_panel.h" />
     <ClInclude Include="src\client\runtime\internal.h" />
     <ClInclude Include="src\client\runtime\host\game_host_classification.h" />
     <ClInclude Include="src\client\hooks\graphics\graphics_hook_lifecycle.h" />
@@ -814,6 +838,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" />
@@ -885,8 +910,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" />
@@ -929,6 +959,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" />

+ 925 - 37
Sunrise/resources/default_settings.json

@@ -1,5 +1,5 @@
 {
-  "version": 3,
+  "version": 6,
   "core": {
     "logging": {
       "debugger_sink": true,
@@ -32,7 +32,7 @@
     },
     "fade_release": true,
     "force_join_request_ready": true,
-    "region_private": true,
+    "region_private": false,
     "pin_replicated_record": true,
     "hold_spawn": true,
     "spawn_hold_ms": 30000
@@ -40,7 +40,7 @@
   "server": {
     "bap_port": 30974,
     "gameplay": {
-      "topology": "disabled",
+      "topology": "embedded",
       "bind_address": "127.0.0.1",
       "advertised_address": "127.0.0.1",
       "transport_address": "127.0.0.1",
@@ -139,35 +139,65 @@
     },
     "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": "0xBB97AFD2",
+          "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": {
@@ -524,13 +554,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",
@@ -757,13 +1073,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",
@@ -990,13 +1592,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
           }
-        }
+        ]
       }
     ]
   }

+ 2 - 2
Sunrise/resources/sunrise.rc

@@ -3,8 +3,8 @@
 #include "resource.h"
 
 IDR_DEFAULT_SETTINGS RCDATA "default_settings.json"
-IDR_IMGUI_LICENSE RCDATA "LICENSE.txt"
-IDR_DETOURS_LICENSE RCDATA "LICENSE.md"
+IDR_IMGUI_LICENSE RCDATA "..\\vendor\\imgui\\LICENSE.txt"
+IDR_DETOURS_LICENSE RCDATA "..\\vendor\\detours\\LICENSE.md"
 
 VS_VERSION_INFO VERSIONINFO
 FILEVERSION SUNRISE_VER_MAJOR, SUNRISE_VER_MINOR, SUNRISE_VER_PATCH, SUNRISE_VER_BUILD

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

+ 6 - 3
Sunrise/src/client/hooks/bootflow/region_private.cpp

@@ -8,6 +8,7 @@
 
 #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"
 
@@ -111,8 +112,8 @@ void report(std::uint32_t sliceSet, bool forced) noexcept {
 
 /**
  * Reports a bubble as private, for the region transition's own call only.
- * A public region holds its slice-set switch until a public activity host connects.
- * `client.region_private` off keeps the public answer, so the citizen join can run.
+ * 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 {
@@ -128,7 +129,9 @@ __declspec(noinline) bool __fastcall reader(std::uint32_t sliceSet) noexcept {
     if (caller != g_returnSite.load(std::memory_order_acquire)) {
         return true;
     }
-    const bool forced = core::settings::get().client.regionPrivate;
+    // 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;
 }

+ 38 - 34
Sunrise/src/client/hooks/noclip/horizontal_noclip.cpp

@@ -18,8 +18,8 @@
 #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 "../../teleport/teleport_settings_store.h"
 #include "runtime.h"
 
 namespace sunrise::client::hooks::noclip {
@@ -29,6 +29,7 @@ namespace {
 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);
 
@@ -80,10 +81,11 @@ struct HavokArray {
     std::uint32_t capacityAndFlags{};
 };
 
-static_assert(sizeof(HavokArray) == 16);
+/** 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_active{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. */
@@ -158,45 +160,52 @@ template <typename T> [[nodiscard]] T& field(std::byte* object, std::size_t offs
     return std::bit_cast<std::array<float, 2>>(value);
 }
 
-/** Polls the configured edge-triggered toggle on the physics thread. */
-void poll_toggle() noexcept {
-    const client::teleport::Settings settings = client::teleport::get();
-    const bool usable =
-        settings.noclipEnabled && settings.noclipToggleKey != client::teleport::kNoKey;
-    const bool down =
-        usable && (GetAsyncKeyState(static_cast<int>(settings.noclipToggleKey)) & 0x8000) != 0;
-    if (!usable) {
+/**
+ * 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);
-        if (g_active.exchange(false, std::memory_order_acq_rel)) {
-            invalidate_target();
-        }
-        return;
+        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;
+        return settings.noclipEnabled;
     }
     if (down && !g_toggleDown.exchange(true, std::memory_order_acq_rel)) {
-        const bool enabled = !g_active.load(std::memory_order_acquire);
-        g_active.store(enabled, std::memory_order_release);
+        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,
-                         enabled ? "ev=noclip stage=toggle active=1 mode=rigid_body_position"
-                                 : "ev=noclip stage=toggle active=0 mode=rigid_body_position");
-        return;
+                         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 {
-    poll_toggle();
-
     std::array<float, kVectorLanes> nativeVelocity{};
-    const bool enabledBeforeStep = active();
+    const bool enabledBeforeStep = poll_toggle();
     std::byte* const before = enabledBeforeStep ? character_body(simulation) : nullptr;
     const bool hasVelocity = before != nullptr;
     if (hasVelocity) {
@@ -206,7 +215,8 @@ std::int32_t __fastcall havok_step(std::byte* simulation, float deltaTime) noexc
     const HavokStep next = reinterpret_cast<HavokStep>(g_stepHandle.original);
     const std::int32_t result = next != nullptr ? next(simulation, deltaTime) : 0;
 
-    if (!enabledBeforeStep || !active()) {
+    // 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);
@@ -274,6 +284,7 @@ void report_install_failure(const char* reason) noexcept {
 
 } // namespace
 
+/** Resolves the Havok targets and attaches the simulation-step detour. */
 bool install() noexcept {
     if (g_installed.load(std::memory_order_acquire)) {
         return true;
@@ -303,6 +314,7 @@ bool install() noexcept {
     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;
@@ -310,19 +322,11 @@ void uninstall() noexcept {
     (void)hooking::detour::uninstall(g_stepHandle);
     g_stepHandle = {};
     g_characterMotionVtable = 0;
-    reset();
-}
-
-void reset() noexcept {
-    g_active.store(false, std::memory_order_release);
+    // 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();
 }
 
-bool active() noexcept {
-    return g_active.load(std::memory_order_acquire);
-}
-
 void invalidate_target() noexcept {
     g_targetValid.store(false, std::memory_order_release);
 }

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

@@ -11,12 +11,6 @@ namespace sunrise::client::hooks::noclip {
 /** Detaches the simulation-step detour and clears runtime state. */
 void uninstall() noexcept;
 
-/** Clears the active toggle and horizontal target. */
-void reset() noexcept;
-
-/** @return True while horizontal noclip is toggled on. */
-[[nodiscard]] bool active() noexcept;
-
 /** Invalidates the horizontal target after another feature changes rigid-body position. */
 void invalidate_target() noexcept;
 

+ 18 - 26
Sunrise/src/client/hooks/package_trust/package_trust_bypass.cpp

@@ -5,8 +5,8 @@
 #include <algorithm>
 #include <array>
 #include <cstddef>
-#include <cstdio>
 #include <cstdint>
+#include <cstdio>
 #include <cstring>
 #include <string_view>
 
@@ -29,6 +29,7 @@ using patterns::signature_length;
 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);
 
@@ -36,8 +37,7 @@ constexpr auto kValidatorSignature =
  * 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 std::string_view kExtendedHeaderFailureText = "B8 A7 FF FF FF E9 ? ? ? ?";
 constexpr auto kExtendedHeaderFailure =
     signature<signature_length(kExtendedHeaderFailureText)>(kExtendedHeaderFailureText);
 
@@ -48,6 +48,7 @@ constexpr auto kExtendedHeaderFailure =
  */
 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);
 
@@ -58,17 +59,16 @@ constexpr std::array<std::byte, 4> kSuccessResult{
 
 /** 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}};
+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;
+                                                 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{};
@@ -130,13 +130,8 @@ std::int32_t __fastcall validate_header(const std::uint32_t* validationMask,
         }
     }
     const auto original = reinterpret_cast<ValidateHeader>(g_handle.original);
-    return original(validationMask,
-                    packageGroup,
-                    buildSignature,
-                    expectedFileSize,
-                    localeToken,
-                    1,
-                    header);
+    return original(
+        validationMask, packageGroup, buildSignature, expectedFileSize, localeToken, 1, header);
 }
 
 } // namespace
@@ -166,9 +161,8 @@ bool install() noexcept {
         return false;
     }
     g_extendedHeaderResult = extendedHeaderFailure + kResultImmediateOffset;
-    std::memcpy(g_extendedHeaderOriginal.data(),
-                g_extendedHeaderResult,
-                g_extendedHeaderOriginal.size());
+    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;
@@ -178,9 +172,8 @@ bool install() noexcept {
         return false;
     }
     g_cachedDataBranch = cachedDataHashGate + kCachedDataBranchOffset;
-    std::memcpy(g_cachedDataBranchOriginal.data(),
-                g_cachedDataBranch,
-                g_cachedDataBranchOriginal.size());
+    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);
@@ -201,8 +194,7 @@ bool install() noexcept {
 bool uninstall() noexcept {
     bool restored = true;
     if (g_cachedDataBranch != nullptr) {
-        const bool cachedDataRestored =
-            write_code(g_cachedDataBranch, g_cachedDataBranchOriginal);
+        const bool cachedDataRestored = write_code(g_cachedDataBranch, g_cachedDataBranchOriginal);
         restored = restored && cachedDataRestored;
         if (cachedDataRestored) {
             g_cachedDataBranch = nullptr;

+ 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

+ 4 - 4
Sunrise/src/client/hooks/teleport/teleport_move.cpp

@@ -16,7 +16,7 @@
 #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"
@@ -318,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();
@@ -366,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);

+ 10 - 6
Sunrise/src/client/teleport/teleport_settings_store.cpp → Sunrise/src/client/movement/movement_settings_store.cpp

@@ -1,6 +1,10 @@
-// Runtime-safe movement settings persisted beside the module after every interface change.
+/**
+ * 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
+ * read once and never change after that.
+ */
 
-#include "teleport_settings_store.h"
+#include "movement_settings_store.h"
 
 #include <Windows.h>
 
@@ -13,11 +17,11 @@
 #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";
+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. */
@@ -41,7 +45,7 @@ bool g_pathResolved{};
 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,
@@ -245,4 +249,4 @@ bool publish(const Settings& settings) noexcept {
     return true;
 }
 
-} // namespace sunrise::client::teleport
+} // namespace sunrise::client::movement

+ 4 - 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,10 +10,10 @@ inline constexpr float kDefaultDistance = 10.0F;
 inline constexpr float kMinimumDistance = 1.0F;
 /** Largest offered distance. Past this a press reliably lands through a wall or the floor. */
 inline constexpr float kMaximumDistance = 100.0F;
-/** No key is bound until one is picked, so a fresh install cannot teleport by accident. */
+/** No key is bound until one is picked, so a fresh install cannot fire a movement feature. */
 inline constexpr std::uint32_t kNoKey = 0;
 
-/** Runtime teleport configuration. This module owns it; Core settings do not carry it. */
+/** Runtime movement configuration. This module owns it; Core settings do not carry it. */
 struct Settings {
     bool enabled{false};
     float distance{kDefaultDistance};
@@ -41,4 +41,4 @@ void shutdown() noexcept;
  */
 bool publish(const Settings& settings) noexcept;
 
-} // namespace sunrise::client::teleport
+} // namespace sunrise::client::movement

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

@@ -10,9 +10,7 @@ constexpr std::size_t kNoCandidate = static_cast<std::size_t>(-1);
 
 /**
  * The one exact byte a pattern's candidate search keys on.
- * Only exact bytes can be searched for, so a pattern without one cannot be scanned at all. That
- * is the same condition the sweep already rejected patterns on, so the anchor doubles as the
- * validity check.
+ * 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. */
@@ -49,9 +47,8 @@ struct Anchor {
 [[nodiscard]] bool matches_at(std::span<const std::byte> image,
                               std::size_t offset,
                               std::span<const PatternByte> pattern) noexcept {
-    // The offset is checked before the subtraction, so this holds for any caller value rather
-    // than only for the bounded offsets next_candidate produces.
-    if (offset >= image.size() || pattern.size() > image.size() - offset) {
+    // next_candidate never returns an offset past the last whole match, so this cannot wrap.
+    if (pattern.size() > image.size() - offset) {
         return false;
     }
     for (std::size_t index = 0; index < pattern.size(); ++index) {

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

@@ -13,15 +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/package_trust/package_trust_bypass.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"
@@ -104,31 +103,6 @@ void report_resolve_failure() noexcept {
         core::log::Channel::client, core::log::Level::error, std::string_view(line.data(), length));
 }
 
-/**
- * Reports how long main activation took, for the debug channel only.
- * Timing is diagnostic, so it never appears at the levels a normal run uses.
- * @param event Event and phase text the duration is appended to.
- * @param startedTick Tick count taken when activation began.
- * @param result Outcome text for the log line.
- */
-void report_elapsed(const char* event, std::uint64_t startedTick, const char* result) noexcept {
-    const std::uint64_t elapsed = GetTickCount64() - startedTick;
-    std::array<char, 96> line{};
-    const int written = std::snprintf(line.data(),
-                                      line.size(),
-                                      "%s ms=%llu result=%s",
-                                      event,
-                                      static_cast<unsigned long long>(elapsed),
-                                      result);
-    if (written <= 0) {
-        return;
-    }
-    const auto length = static_cast<std::size_t>(written) < line.size()
-                            ? static_cast<std::size_t>(written)
-                            : line.size() - 1;
-    core::log::write(core::log::Channel::client, core::log::Level::debug, {line.data(), length});
-}
-
 /** Clears both main-image target groups while no game hook owns their entries. */
 void clear_game_targets() noexcept {
     targets::game::content::clear();
@@ -200,9 +174,6 @@ void clear_game_targets() noexcept {
     // The bitmap reference guard puts the none sentinel in place of a reference outside tag
     // space. Without it the widget's stored-reference reader faults.
     (void)hooks::bitmap::install();
-    // 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;
 }
@@ -232,8 +203,10 @@ bool activate_main_once() noexcept {
     // 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();
-    runtime::report_elapsed(
-        "ev=activate stage=main phase=complete", startedTick, active ? "ok" : "fail");
+    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.

+ 5 - 9
Sunrise/src/client/runtime/client_runtime_lifecycle.cpp

@@ -1,22 +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/package_trust/package_trust_bypass.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 "../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"
@@ -25,8 +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);
+    // Loaded before the pages register, so the movement page draws saved values on its first frame.
+    movement::initialize(module);
     return ui::runtime::initialize();
 }
 
@@ -57,9 +56,6 @@ 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();
     hooks::bitmap::uninstall();
     hooks::bootflow::uninstall();
     hooks::noclip::uninstall();
@@ -99,7 +95,7 @@ bool shutdown() noexcept {
     runtime::g_graphicsStage = runtime::StageState::pending;
     runtime::g_platformStage = runtime::StageState::pending;
     ui::runtime::shutdown();
-    teleport::shutdown();
+    movement::shutdown();
     core::log::write(core::log::Channel::client, core::log::Level::info, "ev=shutdown result=ok");
     ReleaseSRWLockExclusive(&runtime::g_lock);
     return true;

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

@@ -7,15 +7,12 @@
 
 namespace sunrise::client::targets::game::assert_handler {
 
-// clang-format off
 /**
- * Derives the assert handler setter and 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 setter, slot and handler currently installed in it.
  * @return True when one candidate wins the vote by the required margin.
  */
-// clang-format on
 [[nodiscard]] bool derive(std::span<const patterns::ImageRange> image, Targets& output) noexcept;
 
 /** @param targets Validated assert table published without failure. */

+ 24 - 29
Sunrise/src/client/ui/teleport/teleport_panel.cpp → Sunrise/src/client/ui/movement/movement_panel.cpp

@@ -1,6 +1,9 @@
-// Movement controls whose changes are persisted immediately.
+/**
+ * The movement module's interface. Every control saves to disk at once, so a change made here
+ * survives the next launch with no settings edit.
+ */
 
-#include "teleport_panel.h"
+#include "movement_panel.h"
 
 #include <Windows.h>
 
@@ -9,10 +12,9 @@
 #include <imgui.h>
 
 #include "../../../core/ui/components/toggle/ui_toggle_component.h"
-#include "../../hooks/noclip/runtime.h"
-#include "../../teleport/teleport_settings_store.h"
+#include "../../movement/movement_settings_store.h"
 
-namespace sunrise::client::ui::teleport {
+namespace sunrise::client::ui::movement {
 namespace {
 
 /** Lowest and highest virtual keys the picker scans. Zero is not a key. */
@@ -37,7 +39,7 @@ CaptureTarget g_capturing{CaptureTarget::none};
  * @param output Receives the name.
  */
 void key_name(std::uint32_t virtualKey, std::array<char, kKeyNameCapacity>& output) noexcept {
-    if (virtualKey == client::teleport::kNoKey) {
+    if (virtualKey == client::movement::kNoKey) {
         (void)std::snprintf(output.data(), output.size(), "None");
         return;
     }
@@ -69,7 +71,7 @@ void key_name(std::uint32_t virtualKey, std::array<char, kKeyNameCapacity>& outp
  */
 [[nodiscard]] bool capture_key(std::uint32_t& picked) noexcept {
     if ((GetAsyncKeyState(VK_ESCAPE) & 0x8000) != 0) {
-        picked = client::teleport::kNoKey;
+        picked = client::movement::kNoKey;
         return true;
     }
     for (int key = kFirstVirtualKey; key <= kLastVirtualKey; ++key) {
@@ -86,14 +88,11 @@ void key_name(std::uint32_t virtualKey, std::array<char, kKeyNameCapacity>& outp
 
 /**
  * Draws one key picker while keeping capture ownership exclusive.
- * @param id ImGui identity
- * for the button.
+ * @param id ImGui identity for the button.
  * @param target Binding this picker captures.
- * @param virtualKey Binding value
- * to display and update.
+ * @param virtualKey Binding value to display and update.
  * @param width Button width.
- * @return True when a new binding was
- * captured.
+ * @return True when a new binding was captured.
  */
 [[nodiscard]] bool
 key_picker(const char* id, CaptureTarget target, std::uint32_t& virtualKey, float width) noexcept {
@@ -103,7 +102,7 @@ key_picker(const char* id, CaptureTarget target, std::uint32_t& virtualKey, floa
             g_capturing = CaptureTarget::none;
         }
         ImGui::PopID();
-        std::uint32_t picked = client::teleport::kNoKey;
+        std::uint32_t picked = client::movement::kNoKey;
         if (capture_key(picked)) {
             virtualKey = picked;
             g_capturing = CaptureTarget::none;
@@ -123,9 +122,9 @@ key_picker(const char* id, CaptureTarget target, std::uint32_t& virtualKey, floa
 
 } // namespace
 
-/** Draws the teleport module inside the active Core UI frame. */
+/** Draws the movement module inside the active Core UI frame. */
 void draw() noexcept {
-    client::teleport::Settings settings = client::teleport::get();
+    client::movement::Settings settings = client::movement::get();
     bool changed = false;
 
     ImGui::TextUnformatted("Teleport");
@@ -134,7 +133,8 @@ void draw() noexcept {
                        "Cancels vertical momentum.");
     ImGui::Spacing();
 
-    changed = core::ui::components::toggle::control("Enabled", settings.enabled) || changed;
+    changed =
+        core::ui::components::toggle::control("Enabled##teleport", settings.enabled) || changed;
 
     ImGui::Spacing();
     // One label column and one control column, so the slider and key buttons share both edges.
@@ -149,8 +149,8 @@ void draw() noexcept {
     float distance = settings.distance;
     if (ImGui::SliderFloat("##distance",
                            &distance,
-                           client::teleport::kMinimumDistance,
-                           client::teleport::kMaximumDistance,
+                           client::movement::kMinimumDistance,
+                           client::movement::kMaximumDistance,
                            "%.0f units")) {
         settings.distance = distance;
         changed = true;
@@ -168,16 +168,11 @@ void draw() noexcept {
     ImGui::TextUnformatted("Noclip");
     ImGui::Separator();
     ImGui::TextWrapped("Uses native horizontal rigid-body velocity while preserving the game's "
-                       "vertical movement.");
+                       "vertical movement. The bound key turns it on and off in game.");
     ImGui::Spacing();
 
-    changed = core::ui::components::toggle::control("Available", settings.noclipEnabled) || changed;
-
-    ImGui::Spacing();
-    ImGui::AlignTextToFramePadding();
-    ImGui::TextUnformatted("Active");
-    ImGui::SameLine(labelWidth);
-    ImGui::TextUnformatted(client::hooks::noclip::active() ? "Yes" : "No");
+    changed =
+        core::ui::components::toggle::control("Enabled##noclip", settings.noclipEnabled) || changed;
 
     ImGui::Spacing();
     ImGui::AlignTextToFramePadding();
@@ -187,10 +182,10 @@ void draw() noexcept {
         key_picker("noclip_key", CaptureTarget::noclip, settings.noclipToggleKey, controlWidth)
         || changed;
 
-    if (changed && !client::teleport::publish(settings)) {
+    if (changed && !client::movement::publish(settings)) {
         ImGui::Spacing();
         ImGui::TextUnformatted("value out of range, not saved");
     }
 }
 
-} // namespace sunrise::client::ui::teleport
+} // namespace sunrise::client::ui::movement

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

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

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

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

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

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

+ 49 - 0
Sunrise/src/core/logging/log.cpp

@@ -2,6 +2,7 @@
 
 #include <Windows.h>
 
+#include <algorithm>
 #include <array>
 #include <atomic>
 #include <cstdio>
@@ -28,6 +29,12 @@ 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;
@@ -244,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

+ 2 - 27
Sunrise/src/core/runtime/core_runtime.cpp

@@ -78,31 +78,6 @@ void report_stage_failure(const char* stage) noexcept {
     log::write(log::Channel::core, log::Level::error, event);
 }
 
-/**
- * Reports how long one boot boundary took, for the debug channel only.
- * Timing is diagnostic, so it never appears at the levels a normal run uses.
- * @param event Event and phase text the duration is appended to.
- * @param startedTick Tick count taken when the boundary began.
- * @param result Outcome text for the log line.
- */
-void report_elapsed(const char* event, std::uint64_t startedTick, const char* result) noexcept {
-    const std::uint64_t elapsed = GetTickCount64() - startedTick;
-    std::array<char, 96> line{};
-    const int written = std::snprintf(line.data(),
-                                      line.size(),
-                                      "%s ms=%llu result=%s",
-                                      event,
-                                      static_cast<unsigned long long>(elapsed),
-                                      result);
-    if (written <= 0) {
-        return;
-    }
-    const auto length = static_cast<std::size_t>(written) < line.size()
-                            ? static_cast<std::size_t>(written)
-                            : line.size() - 1;
-    log::write(log::Channel::core, log::Level::debug, {line.data(), length});
-}
-
 } // namespace
 
 /** Initializes every runtime layer in dependency order. */
@@ -154,7 +129,7 @@ bool initialize(void* module) noexcept {
         // 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.
-        report_elapsed("ev=initialize phase=complete", startedTick, "fail");
+        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();
@@ -173,7 +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");
-    report_elapsed("ev=initialize phase=complete", startedTick, "ok");
+    log::write_elapsed(log::Channel::core, "ev=initialize phase=complete", startedTick, "ok");
     ReleaseSRWLockExclusive(&g_runtimeLock);
     return true;
 }

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

@@ -31,10 +31,10 @@ struct Settings {
     bool forceJoinRequestReady{true};
     /**
      * Reports a public region as private to the region transition.
-     * On, a public region loads solo. Off, it waits for a public activity host.
-     * That host is the only route to the citizen join and the gameplay channel.
+     * 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{true};
+    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

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

@@ -153,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.
@@ -172,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.
@@ -248,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,
@@ -334,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;
@@ -346,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;

+ 2 - 1
Sunrise/src/core/settings/server/gameplay/definition.h

@@ -39,7 +39,8 @@ inline constexpr std::uint16_t kClientLeaseMinimum = 4096;
  * datagram lands after the Client's egress rewrite. Both carry the same port.
  */
 struct Settings {
-    Topology topology{Topology::disabled};
+    /** 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. */

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

+ 7 - 1
Sunrise/src/core/settings/state_settings.cpp

@@ -238,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(',')) {
@@ -262,6 +262,7 @@ bool Parser::account(state::AccountState& output) noexcept {
     }
     bool hasPrimarySoid = false;
     bool hasSettings = false;
+    bool hasDismantleRewards = false;
     if (consume('}')) {
         return false;
     }
@@ -284,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;
         }

+ 35 - 15
Sunrise/src/core/settings/steam/steam_settings_parser.cpp

@@ -1,15 +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;
@@ -97,16 +99,14 @@ decode_escape(std::string_view encoded, std::size_t& position, char& output) noe
 }
 
 /**
- * Decodes a nonempty printable-ASCII JSON string into fixed storage. Shared by every settings
- * field that stores a short bounded string, so each caller only names its own byte limit.
+ * 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 maxBytes Largest accepted decoded length, excluding the trailing null.
  * @param output Receives the decoded bytes and a trailing null only on success.
- * @return True for 1 to maxBytes printable ASCII bytes.
+ * @return True for 1 to Capacity minus 1 printable ASCII bytes.
  */
 template <std::size_t Capacity>
 [[nodiscard]] bool decode_bounded_string(std::string_view encoded,
-                                         std::size_t maxBytes,
                                          std::array<char, Capacity>& output) noexcept {
     std::array<char, Capacity> candidate{};
     std::size_t decodedCount = 0;
@@ -117,7 +117,7 @@ template <std::size_t Capacity>
         }
         const auto byte = static_cast<unsigned char>(value);
         if (byte < kMinimumPrintableAscii || byte > kMaximumPrintableAscii
-            || decodedCount >= maxBytes) {
+            || decodedCount + 1 >= Capacity) {
             return false;
         }
         candidate[decodedCount++] = value;
@@ -129,6 +129,9 @@ template <std::size_t Capacity>
     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",
@@ -153,20 +156,38 @@ constexpr std::array<std::string_view, 13> kSupportedLanguages{
 }
 
 /**
- * Resolves an authored Steam API language token, falling back to English for anything that
- * isn't exactly one language this build ships. This also catches a comma-separated list — a
- * shape only GetAvailableGameLanguages should ever receive — which would otherwise leave
- * GetCurrentGameLanguage answering with a value the Client cannot parse as one language.
+ * 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, steam::kMaximumLanguageBytes, decoded)
+    if (decode_bounded_string(encoded, decoded)
         && is_supported_language(std::string_view(decoded.data()))) {
         return decoded;
     }
+    report_language_fallback(encoded);
     return {"english"};
 }
 
@@ -231,8 +252,7 @@ bool Parser::steam_user_settings(steam::User& output) noexcept {
         if (key == "persona_name") {
             std::string_view value;
             if (hasPersonaName || !string(value)
-                || !decode_bounded_string(
-                    value, steam::kMaximumPersonaNameBytes, candidate.personaName)) {
+                || !decode_bounded_string(value, candidate.personaName)) {
                 return false;
             }
             hasPersonaName = true;

+ 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

+ 33 - 1
Sunrise/src/middleware/datagen/character_record/appearance/character_appearance_render.cpp

@@ -1,3 +1,5 @@
+#include <algorithm>
+
 #include "../../../../state/build_data/runtime.h"
 #include "internal.h"
 
@@ -25,6 +27,8 @@ struct Fold {
  * Records one material row, keeping insertion order and letting a later stage update a value.
  * A key arriving after 6 distinct keys are held can never reach the record, so it is dropped.
  * @param fold Ordered material keys.
+ * @param key Material key this row names.
+ * @param value Material value stored against that key.
  */
 void record(Fold& fold, std::int8_t key, std::uint16_t value) noexcept {
     for (std::size_t entry = 0; entry < fold.count; ++entry) {
@@ -96,6 +100,32 @@ void apply_material_pairs(const details::Definition& detail,
     }
 }
 
+/** Applies plug-owned gear art and appends its class-qualified overlay arrangement. */
+void apply_plug_art(const Equipped& equipped,
+                    state::CharacterClass characterClass,
+                    layout::RenderEntry& entry) noexcept {
+    for (std::size_t lane = 0; lane < equipped.laneCount; ++lane) {
+        details::Definition plug{};
+        if (equipped.plugs[lane] == details::kUnavailableItemIndex
+            || !state::build_data::find_configured_item_detail(equipped.plugs[lane], plug)) {
+            continue;
+        }
+        // Shaders normally carry material rows only. Ornaments carry one or both art indices;
+        // the effective plug therefore replaces only the fields it actually declares.
+        if (plug.gearArtIndex != details::kUnavailableArtIndex) {
+            entry.art[layout::kGearArtSlot] = plug.gearArtIndex;
+        }
+        const std::uint16_t overlay = select_art_arrangement(plug, characterClass);
+        if (overlay != details::kUnavailableArtIndex) {
+            const auto empty = std::find(
+                entry.overlays.begin(), entry.overlays.end(), layout::kEmptyDefinitionIndex);
+            if (empty != entry.overlays.end()) {
+                *empty = overlay;
+            }
+        }
+    }
+}
+
 } // namespace
 
 /** Resolves one equipped instance to its detail and the plugs its sockets hold. */
@@ -124,6 +154,7 @@ bool resolve_equipped(const family4::loadout::SlottedInstance& slotted,
 
 /** Fills each equipped render row with its instance, definition, art and material pairs. */
 bool apply_render(const family4::loadout::ResolvedInstances& instances,
+                  state::CharacterClass characterClass,
                   layout::Appearance& appearance) noexcept {
     for (std::size_t index = 0; index < instances.itemCount; ++index) {
         const family4::loadout::SlottedInstance& slotted = instances.items[index];
@@ -141,7 +172,8 @@ bool apply_render(const family4::loadout::ResolvedInstances& instances,
         // Both art lookups accept 0, so an item with no art block keeps the empty sentinel
         // rather than taking art row 0.
         entry.art[layout::kGearArtSlot] = detail.gearArtIndex;
-        entry.art[layout::kArtArrangementSlot] = detail.artArrangementIndex;
+        entry.art[layout::kArtArrangementSlot] = select_art_arrangement(detail, characterClass);
+        apply_plug_art(equipped, characterClass, entry);
         apply_material_pairs(detail, equipped, entry);
     }
     return true;

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

@@ -28,6 +28,18 @@ struct Equipped {
     std::size_t laneCount{};
 };
 
+/** Selects class-qualified art when present, otherwise the definition's generic arrangement. */
+[[nodiscard]] constexpr std::uint16_t
+select_art_arrangement(const details::Definition& detail,
+                       state::CharacterClass characterClass) noexcept {
+    const std::size_t classSlot = static_cast<std::size_t>(characterClass) + 1U;
+    if (classSlot < detail.artArrangementIndices.size()
+        && detail.artArrangementIndices[classSlot] != details::kUnavailableArtIndex) {
+        return detail.artArrangementIndices[classSlot];
+    }
+    return detail.artArrangementIndices.front();
+}
+
 /**
  * Resolves one equipped instance to its detail and the plugs its sockets hold.
  * The resolver has already applied the authored or native-default socket policy, so a lane that
@@ -47,10 +59,12 @@ void apply_sentinels(layout::Appearance& appearance) noexcept;
 /**
  * Fills each equipped render row with its instance, definition, art and material pairs.
  * @param instances Resolved item instances belonging to one character.
+ * @param characterClass Class whose art rows are selected when a definition carries them.
  * @param appearance Appearance block receiving the render rows.
  * @return True when every instance addresses a render row.
  */
 [[nodiscard]] bool apply_render(const family4::loadout::ResolvedInstances& instances,
+                                state::CharacterClass characterClass,
                                 layout::Appearance& appearance) noexcept;
 
 /**

+ 1 - 1
Sunrise/src/middleware/datagen/character_record/character_record_encoder.cpp

@@ -48,7 +48,7 @@ constexpr std::size_t kPreviewFlagOffsets[]{8, 9};
     output.unusedFloatA = 1.0F;
     output.unusedFloatB = static_cast<float>(light);
     output.light = static_cast<float>(light);
-    if (!appearance::apply_render(instances, output)
+    if (!appearance::apply_render(instances, character.characterClass, output)
         || !appearance::apply_stats(instances, light, output)
         || !appearance::apply_ability_buckets(character, instances, output)) {
         return false;

+ 16 - 1
Sunrise/src/middleware/datagen/family4/account/account_encoder.cpp

@@ -38,12 +38,25 @@ constexpr std::size_t kBucketIdentityCapacity = 256;
     // The dense item table already carries the bucket, so a profile item needs no detail record.
     // Only equipped items and their plugs have one.
     state::build_data::items::Definition definition{};
+    state::build_data::items::details::Definition detail{};
     state::build_data::inventory::buckets::Descriptor bucket{};
-    if (!state::build_data::find_item_definition_hash(item.definitionHash, definition)
+    if (item.quantity <= 0 || item.mutationSerial < 0
+        || !state::build_data::find_item_definition_hash(item.definitionHash, definition)
+        || !state::build_data::find_configured_item_detail(definition.definitionIndex, detail)
+        || detail.definitionIndex != definition.definitionIndex
+        || detail.definitionHash != definition.definitionHash
+        || detail.bucketId != definition.bucketId
+        || detail.instancedDefinitionState
+               != state::build_data::items::details::InstancedDefinitionState::stackable
         || !state::build_data::find_inventory_bucket_descriptor(definition.bucketId, bucket)
         || bucket.arraySelector != state::build_data::inventory::buckets::ArraySelector::profile) {
         return false;
     }
+    const bool actionSource = state::build_data::is_profile_action_source(
+        definition.definitionIndex, definition.bucketId);
+    if (actionSource != (item.instanceSoid != 0)) {
+        return false;
+    }
     const std::uint16_t used = taken[definition.bucketId];
     if (used >= bucket.slotCount) {
         return false;
@@ -54,7 +67,9 @@ constexpr std::size_t kBucketIdentityCapacity = 256;
     }
     taken[definition.bucketId] = static_cast<std::uint16_t>(used + 1);
     rows[slot].definitionIndex = definition.definitionIndex;
+    rows[slot].instanceSoid = item.instanceSoid;
     rows[slot].quantity = item.quantity;
+    rows[slot].mutationSerial = item.mutationSerial;
     return true;
 }
 

+ 36 - 3
Sunrise/src/middleware/datagen/family4/account/layout.h

@@ -36,8 +36,10 @@ inline constexpr std::size_t kPreferencesBindingsPaddingSize = 607;
 inline constexpr std::size_t kBindingsProfilePaddingSize = 456;
 /** Native inventory counts are followed by 4 reserved alignment bytes. */
 inline constexpr std::size_t kInventoryCountPaddingSize = 4;
-/** 708 reserved bytes separate secondary items from progressions. */
-inline constexpr std::size_t kSecondaryProgressionPaddingSize = 708;
+/** The profile inventory observer reads 16 transient mutation descriptors. */
+inline constexpr std::size_t kProfileInventoryChangeRecordCapacity = 16;
+/** Opaque bytes after the profile mutation bank keep the progression bank at its native offset. */
+inline constexpr std::size_t kProfileInventoryProgressionPaddingSize = 512;
 /** The account acquired-flag bank holds one byte per flag. */
 inline constexpr std::size_t kAcquiredFlagCapacity = 12'300;
 /** The account objective-value bank holds one signed value per objective. */
@@ -78,6 +80,8 @@ inline constexpr std::size_t kProfileItemsOffset = 4'368;
 inline constexpr std::size_t kSecondaryItemCountOffset = 26'800;
 /** The secondary inventory rows follow their count and alignment bytes. */
 inline constexpr std::size_t kSecondaryItemsOffset = 26'808;
+/** The profile-inventory mutation bank follows the complete secondary inventory. */
+inline constexpr std::size_t kProfileInventoryChangesOffset = 27'000;
 /** The fixed progression bank precedes the account acquired-flag bank. */
 inline constexpr std::size_t kProgressionsOffset = 27'708;
 /** The account acquired-flag bank follows every fixed progression row. */
@@ -97,6 +101,26 @@ struct CharacterUnlockBlock {
     std::array<std::int32_t, kCharacterValueCapacity> values{};
 };
 
+/** One transient profile inventory mutation consumed by the native account-object observer. */
+struct ProfileInventoryChangeRecord {
+    std::uint16_t sequence{};
+    std::uint16_t reserved{};
+    /** Mutation serial of the profile inventory row this record describes. */
+    std::int32_t mutationSerial{};
+    /** Nonzero mutation kind. Kind 1 follows the ordinary acquisition path. */
+    std::uint8_t kind{};
+    std::uint8_t reservedKind{};
+    /** Native observer policy bits; 0 enables the ordinary acquisition path. */
+    std::uint16_t flags{};
+};
+
+/** Header and fixed record bank beginning at native account-object offset 0x6978. */
+struct ProfileInventoryChangeList {
+    std::uint16_t writeSlot{};
+    std::uint16_t nextSequence{};
+    std::array<ProfileInventoryChangeRecord, kProfileInventoryChangeRecordCapacity> records{};
+};
+
 /** Byte-exact Family-4 account object generated from State. */
 struct Object {
     std::uint64_t accountSoid{};
@@ -118,7 +142,9 @@ struct Object {
     std::uint32_t secondaryItemCount{};
     std::array<std::byte, kInventoryCountPaddingSize> secondaryCountPadding{};
     std::array<inventory::layout::Entry, kSecondaryItemCapacity> secondaryItems{};
-    std::array<std::byte, kSecondaryProgressionPaddingSize> secondaryProgressionPadding{};
+    ProfileInventoryChangeList profileInventoryChanges{};
+    std::array<std::byte, kProfileInventoryProgressionPaddingSize>
+        profileInventoryProgressionPadding{};
     std::array<progression::layout::Entry, kProgressionCapacity> progressions{};
     std::array<std::uint8_t, kAcquiredFlagCapacity> acquiredFlags{};
     std::array<std::int32_t, kObjectiveValueCapacity> objectiveValues{};
@@ -145,6 +171,7 @@ static_assert(offsetof(Object, profileItemCount) == kProfileItemCountOffset);
 static_assert(offsetof(Object, profileItems) == kProfileItemsOffset);
 static_assert(offsetof(Object, secondaryItemCount) == kSecondaryItemCountOffset);
 static_assert(offsetof(Object, secondaryItems) == kSecondaryItemsOffset);
+static_assert(offsetof(Object, profileInventoryChanges) == kProfileInventoryChangesOffset);
 static_assert(offsetof(Object, progressions) == kProgressionsOffset);
 static_assert(offsetof(Object, acquiredFlags) == kAcquiredFlagsOffset);
 static_assert(offsetof(Object, objectiveValues) == kObjectiveValuesOffset);
@@ -152,6 +179,12 @@ static_assert(offsetof(Object, characterUnlocks) == kCharacterUnlocksOffset);
 static_assert(offsetof(Object, profileUnlockFlags) == kProfileUnlockFlagsOffset);
 static_assert(sizeof(CharacterUnlockBlock)
               == kCharacterFlagCapacity + kCharacterValueCapacity * sizeof(std::int32_t));
+static_assert(sizeof(ProfileInventoryChangeRecord)
+              == 3 * sizeof(std::uint16_t) + sizeof(std::int32_t) + 2 * sizeof(std::uint8_t));
+static_assert(sizeof(ProfileInventoryChangeList)
+              == 2 * sizeof(std::uint16_t)
+                     + kProfileInventoryChangeRecordCapacity
+                           * sizeof(ProfileInventoryChangeRecord));
 static_assert(std::is_standard_layout_v<Object>);
 static_assert(std::is_trivially_copyable_v<Object>);
 

+ 11 - 0
Sunrise/src/middleware/datagen/family4/character/abi.h

@@ -15,6 +15,12 @@ inline constexpr std::size_t kIdentityOffset = 8;
 inline constexpr std::size_t kNextInventorySerialOffset = 48;
 /** The first fixed inventory row begins at native byte 56. */
 inline constexpr std::size_t kInventoryItemsOffset = 56;
+/** Four opaque bytes after inventory rows begin at native byte 11,256. */
+inline constexpr std::size_t kInventoryChangeUnknownOffset = 11256;
+/** The transient inventory-change header begins at native byte 11,260. */
+inline constexpr std::size_t kInventoryChangeHeaderOffset = 11260;
+/** The first 12-byte inventory-change record begins at native byte 11,264. */
+inline constexpr std::size_t kInventoryChangeRecordsOffset = 11264;
 /** Equipped instance SOIDs begin after all fixed inventory rows at native byte 11,456. */
 inline constexpr std::size_t kEquippedInstancesOffset = 11456;
 /** The stored equipment summary begins at native byte 11,616. */
@@ -49,6 +55,11 @@ static_assert(offsetof(layout::Object, characterSoid) == kCharacterSoidOffset);
 static_assert(offsetof(layout::Object, identity) == kIdentityOffset);
 static_assert(offsetof(layout::Object, nextInventorySerial) == kNextInventorySerialOffset);
 static_assert(offsetof(layout::Object, inventoryItems) == kInventoryItemsOffset);
+static_assert(offsetof(layout::Object, inventoryChangeUnknown) == kInventoryChangeUnknownOffset);
+static_assert(offsetof(layout::Object, inventoryChanges) == kInventoryChangeHeaderOffset);
+static_assert(offsetof(layout::InventoryChangeList, records)
+                  + offsetof(layout::Object, inventoryChanges)
+              == kInventoryChangeRecordsOffset);
 static_assert(offsetof(layout::Object, equippedInstanceSoids) == kEquippedInstancesOffset);
 static_assert(offsetof(layout::Object, equipmentSummary) == kEquipmentSummaryOffset);
 static_assert(offsetof(layout::Object, inventoryGateDefinitionIndex)

+ 25 - 6
Sunrise/src/middleware/datagen/family4/character/character_encoder.cpp

@@ -54,7 +54,9 @@ constexpr std::int32_t kOccupiedRowWatermark = 1;
  */
 [[nodiscard]] bool valid(const loadout::ResolvedLoadout& resolvedLoadout) noexcept {
     if (resolvedLoadout.itemCount > resolvedLoadout.items.size()
-        || resolvedLoadout.nextInventorySerial != resolvedLoadout.itemCount) {
+        || resolvedLoadout.nextInventorySerial < resolvedLoadout.itemCount
+        || resolvedLoadout.nextInventorySerial
+               > static_cast<std::uint32_t>((std::numeric_limits<std::int32_t>::max)())) {
         return false;
     }
 
@@ -71,14 +73,20 @@ constexpr std::int32_t kOccupiedRowWatermark = 1;
             || itemInstance.bounds.itemDefinitionCount > instance::layout::kDefinitionIndexCapacity
             || itemInstance.baseDefinitionIndex == kEmptyDefinitionIndex
             || itemInstance.baseDefinitionIndex >= itemInstance.bounds.itemDefinitionCount
-            || occupiedRows[item.inventoryRow] || occupiedEquipmentSlots[item.equipmentSlot]
+            || item.mutationSerial < 0
+            || static_cast<std::uint32_t>(item.mutationSerial)
+                   >= resolvedLoadout.nextInventorySerial
+            || occupiedRows[item.inventoryRow]
+            || (item.equipped && occupiedEquipmentSlots[item.equipmentSlot])
             || std::find(instanceSoids.cbegin(), priorSoidsEnd, itemInstance.instanceSoid)
                    != priorSoidsEnd
             || (index != 0 && resolvedLoadout.items[index - 1].inventoryRow >= item.inventoryRow)) {
             return false;
         }
         occupiedRows[item.inventoryRow] = true;
-        occupiedEquipmentSlots[item.equipmentSlot] = true;
+        if (item.equipped) {
+            occupiedEquipmentSlots[item.equipmentSlot] = true;
+        }
         instanceSoids[index] = itemInstance.instanceSoid;
     }
     return true;
@@ -94,14 +102,21 @@ constexpr std::int32_t kOccupiedRowWatermark = 1;
 summary_matches_loadout(const loadout::ResolvedLoadout& resolvedLoadout,
                         const state::equipment::light::Evaluation& evaluation) noexcept {
     std::size_t summaryItemCount = 0;
+    for (std::size_t index = 0; index < resolvedLoadout.itemCount; ++index) {
+        summaryItemCount += static_cast<std::size_t>(resolvedLoadout.items[index].equipped);
+    }
+    std::size_t scoreCount = 0;
     for (const std::optional<state::equipment::light::ItemScore>& score : evaluation.character) {
-        summaryItemCount += static_cast<std::size_t>(score.has_value());
+        scoreCount += static_cast<std::size_t>(score.has_value());
     }
-    if (summaryItemCount != resolvedLoadout.itemCount) {
+    if (summaryItemCount != scoreCount) {
         return false;
     }
     for (std::size_t index = 0; index < resolvedLoadout.itemCount; ++index) {
         const loadout::ResolvedItem& item = resolvedLoadout.items[index];
+        if (!item.equipped) {
+            continue;
+        }
         const auto& score = evaluation.character[item.equipmentSlot];
         if (!score.has_value() || score->definitionIndex != item.instance.baseDefinitionIndex) {
             return false;
@@ -160,12 +175,16 @@ bool encode(const state::CharacterState& state,
         inventoryRow.definitionIndex = item.instance.baseDefinitionIndex;
         inventoryRow.instanceSoid = item.instance.instanceSoid;
         inventoryRow.quantity = item.quantity;
+        inventoryRow.mutationSerial = item.mutationSerial;
+        inventoryRow.flags = item.flags;
         // Both companion arrays are indexed by inventory row, not by equipment slot, and the
         // client's own producer marks every row it fills.
         object.newItemFlags[item.inventoryRow / kBitsPerFlagByte] |=
             std::byte{1U} << (item.inventoryRow % kBitsPerFlagByte);
         object.instanceProgressWatermarks[item.inventoryRow] = kOccupiedRowWatermark;
-        object.equippedInstanceSoids[item.equipmentSlot] = item.instance.instanceSoid;
+        if (item.equipped) {
+            object.equippedInstanceSoids[item.equipmentSlot] = item.instance.instanceSoid;
+        }
     }
 
     // Commit only after validation so callers never receive a partially initialized object.

+ 40 - 3
Sunrise/src/middleware/datagen/family4/character/layout.h

@@ -38,8 +38,10 @@ inline constexpr std::size_t kObjectiveValueCapacity = 768;
 inline constexpr std::size_t kCreationHeaderSize = 36;
 /** The next inventory serial is followed by 4 reserved alignment bytes. */
 inline constexpr std::size_t kInventorySerialPaddingSize = 4;
-/** 200 reserved bytes separate inventory rows from equipped instance keys. */
-inline constexpr std::size_t kInventoryEquipmentPaddingSize = 200;
+/** 4 opaque bytes precede the character inventory-change list. */
+inline constexpr std::size_t kInventoryChangeUnknownSize = 4;
+/** The character object carries at most 16 transient inventory-change records. */
+inline constexpr std::size_t kInventoryChangeRecordCapacity = 16;
 /** 52 reserved bytes separate the equipment summary from its validity gate. */
 inline constexpr std::size_t kSummaryGatePaddingSize = 52;
 /** 14 reserved bytes separate the two inventory validity gate fields. */
@@ -108,6 +110,29 @@ struct RosterMirrorEntry {
     std::array<std::byte, kRosterMirrorEntrySize> bytes{};
 };
 
+/** One transient inventory mutation consumed by the native character-object observer. */
+struct InventoryChangeRecord {
+    /** Rising record identity; the native observer prefers the greatest matching value. */
+    std::uint16_t sequence{};
+    std::uint16_t reserved{};
+    /** Mutation serial of the inventory row this record describes. */
+    std::int32_t mutationSerial{};
+    /** Nonzero mutation kind. Kind 1 follows the ordinary item-acquisition path. */
+    std::uint8_t kind{};
+    std::uint8_t reservedKind{};
+    /** Native observer policy bits; 0 enables the ordinary acquisition path. */
+    std::uint16_t flags{};
+};
+
+/** Header and fixed record bank occupying bytes 0x2BFC through 0x2CBF. */
+struct InventoryChangeList {
+    /** Ring slot the native producer will write next. */
+    std::uint16_t writeSlot{};
+    /** Sequence assigned to the next native record. */
+    std::uint16_t nextSequence{};
+    std::array<InventoryChangeRecord, kInventoryChangeRecordCapacity> records{};
+};
+
 /** Byte-exact selected-character Family-4 object generated from resolved loadout rows. */
 struct Object {
     std::uint64_t characterSoid{};
@@ -117,7 +142,8 @@ struct Object {
     std::uint32_t nextInventorySerial{};
     std::array<std::byte, kInventorySerialPaddingSize> inventorySerialPadding{};
     std::array<inventory::layout::Entry, kInventoryCapacity> inventoryItems{};
-    std::array<std::byte, kInventoryEquipmentPaddingSize> inventoryEquipmentPadding{};
+    std::array<std::byte, kInventoryChangeUnknownSize> inventoryChangeUnknown{};
+    InventoryChangeList inventoryChanges{};
     std::array<std::uint64_t, kEquipmentCapacity> equippedInstanceSoids{};
     EquipmentSummary equipmentSummary{};
     std::array<std::byte, kSummaryGatePaddingSize> summaryGatePadding{};
@@ -159,6 +185,17 @@ static_assert(sizeof(EquipmentSummary)
               == kSummaryArrayCount * kEquipmentCapacity * sizeof(EquipmentSummaryEntry)
                      + kSummaryIntegerCount * sizeof(std::int32_t)
                      + kSummaryScalarCount * sizeof(float));
+static_assert(sizeof(InventoryChangeRecord)
+              == 3 * sizeof(std::uint16_t) + sizeof(std::int32_t) + 2 * sizeof(std::uint8_t));
+static_assert(offsetof(InventoryChangeRecord, mutationSerial) == 2 * sizeof(std::uint16_t));
+static_assert(offsetof(InventoryChangeRecord, kind)
+              == 2 * sizeof(std::uint16_t) + sizeof(std::int32_t));
+static_assert(offsetof(InventoryChangeRecord, flags)
+              == 2 * sizeof(std::uint16_t) + sizeof(std::int32_t) + 2 * sizeof(std::uint8_t));
+static_assert(sizeof(InventoryChangeList)
+              == 2 * sizeof(std::uint16_t)
+                     + kInventoryChangeRecordCapacity * sizeof(InventoryChangeRecord));
+static_assert(offsetof(InventoryChangeList, records) == 2 * sizeof(std::uint16_t));
 static_assert(sizeof(Object) == kObjectSize);
 static_assert(std::is_trivially_copyable_v<Object>);
 

+ 9 - 2
Sunrise/src/middleware/datagen/family4/loadout/definition.h

@@ -9,14 +9,21 @@
 
 namespace sunrise::middleware::datagen::family4::loadout {
 
-/** One selected item can occupy each of the 16 authored semantic equipment slots. */
-inline constexpr std::size_t kItemCapacity = state::account::inventory::kEquipmentSlotCount;
+/** Equipped plus unequipped authored items one character may publish. */
+inline constexpr std::size_t kItemCapacity = state::account::inventory::kEquipmentSlotCount
+                                             + state::account::inventory::kCharacterItemCapacity;
 
 /** One installed-build-resolved item ready for character and instance encoding. */
 struct ResolvedItem {
     std::uint16_t inventoryRow{};
     std::uint8_t equipmentSlot{};
+    /** Only equipped items publish their instance SOID into the native equipment array. */
+    bool equipped{};
     std::int32_t quantity{};
+    /** Authored runtime generation copied into the native inventory row. */
+    std::int32_t mutationSerial{};
+    /** Accumulated native item-state bits copied into the native inventory row. */
+    std::uint32_t flags{};
     instance::ResolvedInstance instance{};
 };
 

+ 2 - 0
Sunrise/src/middleware/datagen/family4/loadout/loadout_item_resolver.cpp

@@ -153,6 +153,8 @@ bool resolve_item(const authored_inventory::Item& authored,
     Candidate candidate{};
     candidate.bucket = bucket;
     candidate.item.equipmentSlot = static_cast<std::uint8_t>(*itemDetail.equipmentSlot);
+    candidate.item.mutationSerial = authored.mutationSerial;
+    candidate.item.flags = authored.flags;
     if (!resolve_quantity(authored, itemDetail, candidate.item.quantity)
         || !resolve_ordinary_sockets(authored.sockets,
                                      itemDetail,

+ 73 - 10
Sunrise/src/middleware/datagen/family4/loadout/loadout_resolver.cpp

@@ -74,12 +74,11 @@ namespace build_buckets = state::build_data::inventory::buckets;
     return false;
 }
 
-} // namespace
-
-/** Resolves the item instances one character owns, selected or not. */
-bool resolve_instances(const state::AccountState& account,
-                       std::size_t characterIndex,
-                       ResolvedInstances& output) noexcept {
+/** Resolves equipped items, and optionally unequipped inventory, for one character. */
+[[nodiscard]] bool resolve_character_instances(const state::AccountState& account,
+                                               std::size_t characterIndex,
+                                               bool includeInventory,
+                                               ResolvedInstances& output) noexcept {
     output = {};
     if (account.characterCount > account.characters.size()
         || characterIndex >= account.characterCount || !state::build_data::item_definitions_ready()
@@ -111,6 +110,21 @@ bool resolve_instances(const state::AccountState& account,
         }
         ++itemCount;
     }
+    if (includeInventory) {
+        for (std::size_t index = 0; index < character.inventory.count; ++index) {
+            Candidate candidate{};
+            if (itemCount >= resolved.size()
+                || !resolve_item(character.inventory.values[index],
+                                 character,
+                                 itemDefinitionCount,
+                                 socketEntryListCount,
+                                 candidate)
+                || !place_item(candidate, occupied, resolved[itemCount])) {
+                return false;
+            }
+            ++itemCount;
+        }
+    }
     std::sort(resolved.begin(),
               resolved.begin() + static_cast<std::ptrdiff_t>(itemCount),
               [](const ResolvedItem& first, const ResolvedItem& second) {
@@ -126,6 +140,22 @@ bool resolve_instances(const state::AccountState& account,
     return true;
 }
 
+} // namespace
+
+/** Resolves the equipped item instances one character owns, selected or not. */
+bool resolve_instances(const state::AccountState& account,
+                       std::size_t characterIndex,
+                       ResolvedInstances& output) noexcept {
+    return resolve_character_instances(account, characterIndex, false, output);
+}
+
+/** Resolves every equipped and unequipped item instance one character owns. */
+bool resolve_owned_instances(const state::AccountState& account,
+                             std::size_t characterIndex,
+                             ResolvedInstances& output) noexcept {
+    return resolve_character_instances(account, characterIndex, true, output);
+}
+
 /** Resolves one selected character's authored equipment through installed build data. */
 bool resolve(const state::AccountState& account,
              std::size_t selectedCharacterIndex,
@@ -149,8 +179,10 @@ bool resolve(const state::AccountState& account,
         semanticToNative{};
     std::array<std::optional<std::size_t>, state::build_data::items::details::kEquipmentSlotCount>
         nativeToSemantic{};
-    std::array<Candidate, kItemCapacity> selectedCandidates{};
-    std::array<bool, kItemCapacity> selectedPresent{};
+    std::array<Candidate, authored_inventory::kEquipmentSlotCount> selectedCandidates{};
+    std::array<bool, authored_inventory::kEquipmentSlotCount> selectedPresent{};
+    std::array<Candidate, authored_inventory::kCharacterItemCapacity> selectedInventory{};
+    std::size_t selectedInventoryCount = 0;
     std::array<std::uint64_t, state::kCharacterCapacity * kItemCapacity> instanceSoids{};
     std::size_t instanceSoidCount = 0;
     // Every character contributes to one stable semantic-to-native slot contract.
@@ -184,11 +216,35 @@ bool resolve(const state::AccountState& account,
                                           nativeToSemantic)) {
                 return false;
             }
+            candidate.item.equipped = true;
             if (characterIndex == selectedCharacterIndex) {
                 selectedCandidates[semanticIndex] = candidate;
                 selectedPresent[semanticIndex] = true;
             }
         }
+        for (std::size_t itemIndex = 0; itemIndex < character.inventory.count; ++itemIndex) {
+            const authored_inventory::Item& authored = character.inventory.values[itemIndex];
+            const auto instanceSoidEnd =
+                instanceSoids.cbegin() + static_cast<std::ptrdiff_t>(instanceSoidCount);
+            if (instanceSoidCount >= instanceSoids.size()
+                || std::find(instanceSoids.cbegin(), instanceSoidEnd, authored.instanceSoid)
+                       != instanceSoidEnd) {
+                return false;
+            }
+            instanceSoids[instanceSoidCount++] = authored.instanceSoid;
+            if (characterIndex != selectedCharacterIndex) {
+                continue;
+            }
+            if (selectedInventoryCount >= selectedInventory.size()
+                || !resolve_item(authored,
+                                 character,
+                                 itemDefinitionCount,
+                                 socketEntryListCount,
+                                 selectedInventory[selectedInventoryCount])) {
+                return false;
+            }
+            ++selectedInventoryCount;
+        }
     }
 
     ResolvedLoadout staged{};
@@ -205,13 +261,20 @@ bool resolve(const state::AccountState& account,
         }
         ++staged.itemCount;
     }
+    for (std::size_t index = 0; index < selectedInventoryCount; ++index) {
+        // Equipment claims the first bucket row; unequipped items fill the remaining rows.
+        if (staged.itemCount >= staged.items.size()
+            || !place_item(selectedInventory[index], occupied, staged.items[staged.itemCount])) {
+            return false;
+        }
+        ++staged.itemCount;
+    }
     std::sort(staged.items.begin(),
               staged.items.begin() + static_cast<std::ptrdiff_t>(staged.itemCount),
               [](const ResolvedItem& first, const ResolvedItem& second) {
                   return first.inventoryRow < second.inventoryRow;
               });
-    for (std::size_t index = 0; index < staged.itemCount; ++index) {}
-    staged.nextInventorySerial = static_cast<std::uint32_t>(staged.itemCount);
+    staged.nextInventorySerial = account.characters[selectedCharacterIndex].nextInventorySerial;
 
     // Counts are the publication-stability gate for the mappings resolved above.
     if (state::build_data::item_definition_count() != itemDefinitionCount

+ 9 - 0
Sunrise/src/middleware/datagen/family4/loadout/loadout_resolver.h

@@ -31,4 +31,13 @@ namespace sunrise::middleware::datagen::family4::loadout {
                                      std::size_t characterIndex,
                                      ResolvedInstances& output) noexcept;
 
+/**
+ * Resolves every equipped and unequipped item instance one character owns.
+ * Family four must
+ * publish an instance object for every SOID referenced by the character inventory.
+ */
+[[nodiscard]] bool resolve_owned_instances(const state::AccountState& account,
+                                           std::size_t characterIndex,
+                                           ResolvedInstances& output) noexcept;
+
 } // namespace sunrise::middleware::datagen::family4::loadout

+ 29 - 0
Sunrise/src/middleware/web_service/messages/opcode1820.h

@@ -0,0 +1,29 @@
+#pragma once
+
+#include <cstdint>
+
+#include "../web_service_envelope.h"
+
+namespace sunrise::middleware::web_service::messages::opcode1820 {
+
+/** Web Service opcode used by Collections to create one item instance. */
+inline constexpr std::uint16_t kOpcode = 1820;
+
+/** The one logical field carried by the native Collections pull descriptor. */
+struct Request {
+    std::uint16_t collectibleIndex{};
+};
+
+/**
+ * Parses the exact reflected opcode-1820 Collections descriptor.
+ *
+ * The collectible is an optional native field, so the descriptor carries a presence bit before its
+ * fifteen-bit row. A request naming no collectible is not a pull and is refused here.
+ *
+ * @param message Parsed Web Service envelope.
+ * @param request Receives the named collectible row.
+ * @return True only for the complete canonical three-byte request naming a collectible.
+ */
+[[nodiscard]] bool parse_request(const Message& message, Request& request) noexcept;
+
+} // namespace sunrise::middleware::web_service::messages::opcode1820

+ 40 - 0
Sunrise/src/middleware/web_service/messages/opcode1820_codec.cpp

@@ -0,0 +1,40 @@
+#include <cstddef>
+
+#include "../../encoding/bit_reader.h"
+#include "opcode1820.h"
+
+namespace sunrise::middleware::web_service::messages::opcode1820 {
+namespace {
+
+/** The reflected Collections pull request occupies exactly 24 bits. */
+constexpr std::size_t kPayloadSize = 3;
+/** The optional collectible carries one presence bit before its row. */
+constexpr std::uint8_t kPresenceWidth = 1;
+/** Native collectible rows are addressed by a fifteen-bit index. */
+constexpr std::uint8_t kCollectibleIndexWidth = 15;
+/** The descriptor pads its two payload bytes out to three. */
+constexpr std::uint8_t kPaddingWidth = 8;
+
+} // namespace
+
+/** Parses the exact native Collections pull descriptor. */
+bool parse_request(const Message& message, Request& request) noexcept {
+    request = {};
+    if (message.opcode != kOpcode || message.payload.size() != kPayloadSize) {
+        return false;
+    }
+    encoding::bits::Reader reader(message.payload);
+    std::uint64_t present = 0;
+    std::uint64_t encodedCollectibleIndex = 0;
+    std::uint64_t padding = 0;
+    if (!reader.read(kPresenceWidth, present)
+        || !reader.read(kCollectibleIndexWidth, encodedCollectibleIndex)
+        || !reader.read(kPaddingWidth, padding) || reader.remaining_bits() != 0 || present == 0
+        || padding != 0) {
+        return false;
+    }
+    request.collectibleIndex = static_cast<std::uint16_t>(encodedCollectibleIndex);
+    return true;
+}
+
+} // namespace sunrise::middleware::web_service::messages::opcode1820

+ 71 - 0
Sunrise/src/middleware/web_service/messages/opcode1901.h

@@ -0,0 +1,71 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+
+#include "../web_service_envelope.h"
+
+namespace sunrise::middleware::web_service::messages::opcode1901 {
+
+/** Web Service opcode used by the equipped-item shader application action. */
+inline constexpr std::uint16_t kOpcode = 1901;
+
+/** The equipment selector carries only the low 62 bits of the item instance's identity. */
+inline constexpr std::uint64_t kInstanceIdentityMask = 0x3FFFFFFFFFFFFFFFULL;
+
+/** The native replacement array reserves twelve entries, which its 4-bit count can address. */
+inline constexpr std::size_t kReplacementCapacity = 12;
+
+/** One socket replacement: which plug goes into which socket of the named item. */
+struct Replacement {
+    std::uint16_t plugDefinitionIndex{};
+    std::uint8_t canonicalSocketKind{};
+    std::uint8_t modelSocketKind{};
+    std::uint32_t socketIndex{};
+    std::uint64_t auxiliary{};
+};
+
+/**
+ * Exact logical fields carried by the native equipped socket-action descriptor.
+ *
+ * The descriptor is a counted run of replacements followed by the one item they all apply to, so
+ * its width grows with the count rather than being fixed.
+ */
+struct Request {
+    std::array<Replacement, kReplacementCapacity> replacements{};
+    std::size_t replacementCount{};
+    /** Selector exactly as the descriptor carries it, kept for the request trace. */
+    std::uint64_t equipmentSelector{};
+    /** The item-instance identity that selector encodes, already decoded. */
+    std::uint64_t instanceIdentityToken{};
+};
+
+/**
+ * Answers whether one decoded selector names a given item instance.
+ *
+ * The selector carries only part of the instance identity, so naming an instance is a comparison
+ * against that part rather than an equality. Callers hold whole identities and must not have to
+ * know which part of one reaches the wire.
+ *
+ * @param instanceIdentityToken Decoded selector from a parsed request.
+ * @param instanceSoid Whole item-instance identity held by State.
+ * @return True when the selector names that instance.
+ */
+[[nodiscard]] bool identifies_instance(std::uint64_t instanceIdentityToken,
+                                       std::uint64_t instanceSoid) noexcept;
+
+/**
+ * Parses the exact reflected opcode-1901 descriptor.
+ *
+ * The native request is a bounded replacement array followed by the target's semantic equipment
+ * selector. The supported wire shape contains exactly one replacement and requires every optional
+ * identity carried by that replacement and target.
+ *
+ * @param message Parsed Web Service envelope.
+ * @param request Receives the plug, socket descriptor, auxiliary identity, and equipment slot.
+ * @return True only for the complete canonical 24-byte one-replacement request.
+ */
+[[nodiscard]] bool parse_request(const Message& message, Request& request) noexcept;
+
+} // namespace sunrise::middleware::web_service::messages::opcode1901

+ 124 - 0
Sunrise/src/middleware/web_service/messages/opcode1901_codec.cpp

@@ -0,0 +1,124 @@
+#include <cstddef>
+
+#include "../../encoding/bit_reader.h"
+#include "opcode1901.h"
+
+namespace sunrise::middleware::web_service::messages::opcode1901 {
+namespace {
+
+/** The native replacement array reserves twelve entries, so its count uses four bits. */
+constexpr std::uint8_t kReplacementCountWidth = 4;
+/** A payload is whole bytes, so at most this many bits of it can be trailing pad. */
+constexpr std::size_t kBitsPerByte = 8;
+/** Signed native definition indices use one presence bit followed by fifteen value bits. */
+constexpr std::uint8_t kDefinitionIndexWidth = 15;
+/** The canonical socket-kind byte is signed and biased from INT8_MIN. */
+constexpr std::uint8_t kCanonicalSocketKindWidth = 8;
+/** The compact model socket-kind discriminator occupies two bits. */
+constexpr std::uint8_t kModelSocketKindWidth = 2;
+/** The signed socket index is biased from INT32_MIN. */
+constexpr std::uint8_t kSocketIndexWidth = 32;
+/** The replacement auxiliary identity and semantic equipment selector are optional 64-bit fields.
+ */
+constexpr std::uint8_t kOptionalIdentityWidth = 64;
+/** Nonnegative signed 8-bit values have this bit set after native descriptor biasing. */
+constexpr std::uint64_t kCanonicalSocketKindBias = 0x80ULL;
+/** Nonnegative signed 32-bit values have this bit set after native descriptor biasing. */
+constexpr std::uint64_t kSocketIndexBias = 0x80000000ULL;
+/** Model socket-kind zero is encoded as one so wire zero remains the absent sentinel. */
+constexpr std::uint64_t kModelSocketKindBias = 1;
+/** Shader Apply targets an ordinary item socket model. */
+constexpr std::uint64_t kShaderModelSocketKind = 0;
+/** Collection-backed shader action sources carry no auxiliary instance identity. */
+constexpr std::uint64_t kShaderAuxiliary = 0;
+/** The character screen's selector is four times the instance identity it names. */
+constexpr std::uint64_t kSelectorStride = 4;
+
+} // namespace
+
+/** Compares a decoded selector against the part of an instance identity the wire carries. */
+bool identifies_instance(std::uint64_t instanceIdentityToken, std::uint64_t instanceSoid) noexcept {
+    return instanceIdentityToken != 0
+           && (instanceSoid & kInstanceIdentityMask) == instanceIdentityToken;
+}
+
+/** Parses the complete native equipped socket-action descriptor, batch or single. */
+bool parse_request(const Message& message, Request& request) noexcept {
+    request = {};
+    if (message.opcode != kOpcode) {
+        return false;
+    }
+
+    encoding::bits::Reader reader(message.payload);
+    std::uint64_t replacementCount = 0;
+    if (!reader.read(kReplacementCountWidth, replacementCount) || replacementCount == 0
+        || replacementCount > kReplacementCapacity) {
+        return false;
+    }
+
+    // The run is counted rather than fixed, so its width follows the count. Reading exactly that
+    // many and then requiring the payload to be spent is what proves the descriptor's length.
+    for (std::uint64_t index = 0; index < replacementCount; ++index) {
+        std::uint64_t plugDefinitionPresent = 0;
+        std::uint64_t encodedPlugDefinition = 0;
+        std::uint64_t encodedCanonicalSocketKind = 0;
+        std::uint64_t modelSocketKind = 0;
+        std::uint64_t encodedSocketIndex = 0;
+        std::uint64_t auxiliaryPresent = 0;
+        std::uint64_t auxiliary = 0;
+        if (!reader.read(1, plugDefinitionPresent)
+            || !reader.read(kDefinitionIndexWidth, encodedPlugDefinition)
+            || !reader.read(kCanonicalSocketKindWidth, encodedCanonicalSocketKind)
+            || !reader.read(kModelSocketKindWidth, modelSocketKind)
+            || !reader.read(kSocketIndexWidth, encodedSocketIndex)
+            || !reader.read(1, auxiliaryPresent) || !reader.read(kOptionalIdentityWidth, auxiliary)
+            || plugDefinitionPresent == 0 || auxiliaryPresent == 0
+            || encodedCanonicalSocketKind < kCanonicalSocketKindBias
+            || modelSocketKind < kModelSocketKindBias || encodedSocketIndex < kSocketIndexBias
+            || modelSocketKind - kModelSocketKindBias != kShaderModelSocketKind
+            || auxiliary != kShaderAuxiliary) {
+            request = {};
+            return false;
+        }
+        Replacement& replacement = request.replacements[static_cast<std::size_t>(index)];
+        replacement.plugDefinitionIndex = static_cast<std::uint16_t>(encodedPlugDefinition);
+        replacement.canonicalSocketKind =
+            static_cast<std::uint8_t>(encodedCanonicalSocketKind - kCanonicalSocketKindBias);
+        replacement.modelSocketKind =
+            static_cast<std::uint8_t>(modelSocketKind - kModelSocketKindBias);
+        replacement.socketIndex = static_cast<std::uint32_t>(encodedSocketIndex - kSocketIndexBias);
+        replacement.auxiliary = auxiliary;
+        if (replacement.canonicalSocketKind != replacement.socketIndex) {
+            request = {};
+            return false;
+        }
+    }
+    request.replacementCount = static_cast<std::size_t>(replacementCount);
+
+    // A replacement is 123 bits, so only a single one leaves the payload byte aligned. Every
+    // other count ends mid-byte and the descriptor pads out to the next boundary, which is why
+    // the payload has to be spent to within a byte rather than exactly.
+    std::uint64_t equipmentSelectorPresent = 0;
+    if (!reader.read(1, equipmentSelectorPresent)
+        || !reader.read(kOptionalIdentityWidth, request.equipmentSelector)
+        || reader.remaining_bits() >= kBitsPerByte || equipmentSelectorPresent == 0) {
+        const std::uint64_t selector = request.equipmentSelector;
+        request = {};
+        request.equipmentSelector = selector;
+        return false;
+    }
+
+    // The selector is a wire encoding of an instance identity, so it is decoded here rather than
+    // handed on as a raw number for a later layer to divide.
+    if (request.equipmentSelector == 0 || request.equipmentSelector % kSelectorStride != 0
+        || request.equipmentSelector / kSelectorStride > kInstanceIdentityMask) {
+        const std::uint64_t selector = request.equipmentSelector;
+        request = {};
+        request.equipmentSelector = selector;
+        return false;
+    }
+    request.instanceIdentityToken = request.equipmentSelector / kSelectorStride;
+    return true;
+}
+
+} // namespace sunrise::middleware::web_service::messages::opcode1901

+ 36 - 0
Sunrise/src/middleware/web_service/messages/opcode402.h

@@ -0,0 +1,36 @@
+#pragma once
+
+#include <cstdint>
+
+#include "../web_service_envelope.h"
+
+namespace sunrise::middleware::web_service::messages::opcode402 {
+
+/** Web Service opcode used by the Character screen's Dismantle action. */
+inline constexpr std::uint16_t kOpcode = 402;
+/** The descriptor only ever names a single unit, whatever the stack holds. */
+inline constexpr std::uint32_t kSingleQuantity = 1;
+
+/** Exact logical fields carried by the native 128-bit dismantle descriptor. */
+struct Request {
+    std::uint64_t instanceSoid{};
+    std::uint16_t definitionIndex{};
+};
+
+/**
+ * Parses the exact reflected opcode-402 dismantle descriptor.
+ *
+ * Both identities are optional native fields carrying a presence bit. The quantity, the required
+ * flag, and all three pad runs are fixed for this action, so a descriptor that differs in any of
+ * them is not the action this codec supports.
+ *
+ * Fields are filled as far as the parse reaches, so a refused request still carries what it
+ * decoded for the caller's trace.
+ *
+ * @param message Parsed Web Service envelope.
+ * @param request Receives the named instance and its definition row.
+ * @return True only for the complete canonical 16-byte single-unit request.
+ */
+[[nodiscard]] bool parse_request(const Message& message, Request& request) noexcept;
+
+} // namespace sunrise::middleware::web_service::messages::opcode402

+ 67 - 0
Sunrise/src/middleware/web_service/messages/opcode402_codec.cpp

@@ -0,0 +1,67 @@
+#include <cstddef>
+#include <limits>
+
+#include "../../encoding/bit_reader.h"
+#include "opcode402.h"
+
+namespace sunrise::middleware::web_service::messages::opcode402 {
+namespace {
+
+/** The reflected dismantle request occupies exactly 128 bits. */
+constexpr std::size_t kPayloadSize = 16;
+/** The optional instance identity fills one whole 64-bit field. */
+constexpr std::uint8_t kInstanceWidth = 64;
+/** Signed native definition indices use one presence bit followed by fifteen value bits. */
+constexpr std::uint8_t kDefinitionIndexWidth = 15;
+/** The quantity fills one signed 32-bit field. */
+constexpr std::uint8_t kQuantityWidth = 32;
+/** A single unit after the descriptor's INT32_MIN bias. */
+constexpr std::uint32_t kSingleQuantityWire = 0x80000001U;
+/** The action's own required flag, which is always set. */
+constexpr std::uint8_t kRequiredFlagWidth = 1;
+/** Pad runs closing the nested descriptor, its outer trailers, and the whole payload. */
+constexpr std::uint8_t kNestedPaddingWidth = 6;
+constexpr std::uint8_t kOuterTrailerWidth = 2;
+constexpr std::uint8_t kFinalPaddingWidth = 6;
+
+} // namespace
+
+/** Parses the exact native single-unit dismantle descriptor. */
+bool parse_request(const Message& message, Request& request) noexcept {
+    request = {};
+    if (message.opcode != kOpcode) {
+        return false;
+    }
+    encoding::bits::Reader reader(message.payload);
+    std::uint64_t instancePresent = 0;
+    std::uint64_t instanceSoid = 0;
+    std::uint64_t definitionPresent = 0;
+    std::uint64_t encodedDefinitionIndex = 0;
+    std::uint64_t encodedQuantity = 0;
+    std::uint64_t requiredFlag = 0;
+    std::uint64_t nestedPadding = 0;
+    std::uint64_t outerTrailers = 0;
+    std::uint64_t finalPadding = 0;
+    const bool read =
+        message.payload.size() == kPayloadSize && reader.read(1, instancePresent)
+        && reader.read(kInstanceWidth, instanceSoid) && reader.read(1, definitionPresent)
+        && reader.read(kDefinitionIndexWidth, encodedDefinitionIndex)
+        && reader.read(kQuantityWidth, encodedQuantity)
+        && reader.read(kRequiredFlagWidth, requiredFlag)
+        && reader.read(kNestedPaddingWidth, nestedPadding)
+        && reader.read(kOuterTrailerWidth, outerTrailers)
+        && reader.read(kFinalPaddingWidth, finalPadding) && reader.remaining_bits() == 0;
+
+    // Whatever the read reached is kept, so a refused request still describes itself.
+    request.instanceSoid = instanceSoid;
+    if (encodedDefinitionIndex <= (std::numeric_limits<std::uint16_t>::max)()) {
+        request.definitionIndex = static_cast<std::uint16_t>(encodedDefinitionIndex);
+    }
+
+    return read && instancePresent != 0 && definitionPresent != 0 && instanceSoid != 0
+           && nestedPadding == 0 && outerTrailers == 0 && finalPadding == 0
+           && static_cast<std::uint32_t>(encodedQuantity) == kSingleQuantityWire
+           && requiredFlag == 1;
+}
+
+} // namespace sunrise::middleware::web_service::messages::opcode402

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

@@ -0,0 +1,31 @@
+#pragma once
+
+#include <cstdint>
+
+#include "../web_service_envelope.h"
+
+namespace sunrise::middleware::web_service::messages::opcode403 {
+
+/** Web Service opcode used by the Character screen's Equip action. */
+inline constexpr std::uint16_t kOpcode = 403;
+/** Web Service opcode used by the Character screen's Unequip action. */
+inline constexpr std::uint16_t kUnequipOpcode = 404;
+
+/** The one logical field carried by the shared equip and unequip descriptor. */
+struct Request {
+    std::uint64_t instanceSoid{};
+};
+
+/**
+ * Parses the exact shared opcode-403 and opcode-404 item-instance descriptor.
+ *
+ * Both actions name one item instance and differ only in direction, so they share this
+ * descriptor. The trailing byte is a required alignment pad and has to be clear.
+ *
+ * @param message Parsed Web Service envelope.
+ * @param request Receives the named item-instance identity.
+ * @return True only for the complete canonical nine-byte request naming a nonzero instance.
+ */
+[[nodiscard]] bool parse_request(const Message& message, Request& request) noexcept;
+
+} // namespace sunrise::middleware::web_service::messages::opcode403

+ 32 - 0
Sunrise/src/middleware/web_service/messages/opcode403_codec.cpp

@@ -0,0 +1,32 @@
+#include <cstddef>
+#include <span>
+
+#include "../../encoding/byte_order.h"
+#include "opcode403.h"
+
+namespace sunrise::middleware::web_service::messages::opcode403 {
+namespace {
+
+/** The descriptor is one big-endian identity followed by a single alignment byte. */
+constexpr std::size_t kPayloadSize = encoding::kU64Size + 1U;
+
+} // namespace
+
+/** Parses the shared equip and unequip item-instance descriptor. */
+bool parse_request(const Message& message, Request& request) noexcept {
+    request = {};
+    if ((message.opcode != kOpcode && message.opcode != kUnequipOpcode)
+        || message.payload.size() != kPayloadSize
+        || message.payload[encoding::kU64Size] != std::byte{}) {
+        return false;
+    }
+    request.instanceSoid = encoding::read_u64_be(
+        std::span<const std::byte, encoding::kU64Size>{message.payload.data(), encoding::kU64Size});
+    if (request.instanceSoid == 0) {
+        request = {};
+        return false;
+    }
+    return true;
+}
+
+} // namespace sunrise::middleware::web_service::messages::opcode403

+ 35 - 0
Sunrise/src/middleware/web_service/messages/opcode406.h

@@ -0,0 +1,35 @@
+#pragma once
+
+#include <cstdint>
+
+#include "../web_service_envelope.h"
+
+namespace sunrise::middleware::web_service::messages::opcode406 {
+
+/** Web Service opcode used by item-state actions such as lock and finisher Favorite. */
+inline constexpr std::uint16_t kOpcode = 406;
+
+/** Exact logical fields carried by the native 120-bit item-state descriptor. */
+struct Request {
+    std::uint64_t instanceSoid{};
+    std::uint16_t definitionIndex{};
+    /** Accumulated item-state bits, already lifted off the descriptor's bias. */
+    std::uint32_t flags{};
+};
+
+/**
+ * Parses the exact reflected opcode-406 item-state descriptor.
+ *
+ * Both identities are optional native fields carrying a presence bit, and the state value is
+ * biased from INT32_MIN. Only the two supported state bits are accepted.
+ *
+ * Fields are filled as far as the parse reaches, so a refused request still carries what it
+ * decoded for the caller's trace.
+ *
+ * @param message Parsed Web Service envelope.
+ * @param request Receives the instance, definition row, and unbiased state bits.
+ * @return True only for the complete canonical 15-byte request.
+ */
+[[nodiscard]] bool parse_request(const Message& message, Request& request) noexcept;
+
+} // namespace sunrise::middleware::web_service::messages::opcode406

+ 62 - 0
Sunrise/src/middleware/web_service/messages/opcode406_codec.cpp

@@ -0,0 +1,62 @@
+#include <cstddef>
+#include <limits>
+
+#include "../../encoding/bit_reader.h"
+#include "opcode406.h"
+
+namespace sunrise::middleware::web_service::messages::opcode406 {
+namespace {
+
+/** The reflected item-state request occupies exactly 120 bits. */
+constexpr std::size_t kPayloadSize = 15;
+/** Signed native definition indices use one presence bit followed by fifteen value bits. */
+constexpr std::uint8_t kDefinitionIndexWidth = 15;
+/** The accumulated state value fills one signed 32-bit field. */
+constexpr std::uint8_t kValueWidth = 32;
+/** The descriptor pads its fields out to whole bytes. */
+constexpr std::uint8_t kPaddingWidth = 7;
+/** Nonnegative signed 32-bit values have this bit set after native descriptor biasing. */
+constexpr std::uint64_t kValueBias = 0x80000000ULL;
+/** Only the two lowest state bits are supported by this build. */
+constexpr std::uint64_t kSupportedStateBits = 0x3U;
+
+} // namespace
+
+/** Parses the exact native item-state descriptor. */
+bool parse_request(const Message& message, Request& request) noexcept {
+    request = {};
+    if (message.opcode != kOpcode) {
+        return false;
+    }
+    encoding::bits::Reader reader(message.payload);
+    std::uint64_t instancePresent = 0;
+    std::uint64_t instanceSoid = 0;
+    std::uint64_t definitionPresent = 0;
+    std::uint64_t definitionIndex = 0;
+    std::uint64_t encodedFlags = 0;
+    std::uint64_t padding = 0;
+    const bool read = message.payload.size() == kPayloadSize && reader.read(1, instancePresent)
+                      && reader.read(64, instanceSoid) && reader.read(1, definitionPresent)
+                      && reader.read(kDefinitionIndexWidth, definitionIndex)
+                      && reader.read(kValueWidth, encodedFlags)
+                      && reader.read(kPaddingWidth, padding) && reader.remaining_bits() == 0;
+
+    // Whatever the read reached is kept, so a refused request still describes itself.
+    request.instanceSoid = instanceSoid;
+    if (definitionIndex <= (std::numeric_limits<std::uint16_t>::max)()) {
+        request.definitionIndex = static_cast<std::uint16_t>(definitionIndex);
+    }
+    if (encodedFlags >= kValueBias) {
+        request.flags = static_cast<std::uint32_t>(encodedFlags - kValueBias);
+    }
+
+    if (!read || instancePresent == 0 || instanceSoid == 0 || definitionPresent == 0
+        || definitionIndex > (std::numeric_limits<std::uint16_t>::max)()
+        || encodedFlags < kValueBias || padding != 0
+        || encodedFlags - kValueBias > kSupportedStateBits) {
+        return false;
+    }
+    return true;
+}
+
+} // namespace sunrise::middleware::web_service::messages::opcode406

+ 35 - 0
Sunrise/src/middleware/web_service/messages/opcode903.h

@@ -0,0 +1,35 @@
+#pragma once
+
+#include <cstdint>
+
+#include "../web_service_envelope.h"
+
+namespace sunrise::middleware::web_service::messages::opcode903 {
+
+/** Web Service opcode used by an ordinary item socket-plug insertion. */
+inline constexpr std::uint16_t kOpcode = 903;
+
+/** Exact logical fields carried by the native 144-bit socket action descriptor. */
+struct Request {
+    std::uint64_t instanceSoid{};
+    std::uint32_t socketIndex{};
+    std::uint16_t targetDefinitionIndex{};
+    std::uint16_t plugDefinitionIndex{};
+    bool hasInstance{};
+    bool hasTargetDefinition{};
+    bool hasPlugDefinition{};
+};
+
+/**
+ * Parses the exact reflected opcode-903 descriptor, including both alignment layers.
+ *
+ * The native item identity can name either an instance or an uninstanced definition. State decides
+ * which identity shapes it supports after this codec has proved the wire representation.
+ *
+ * @param message Parsed Web Service envelope.
+ * @param request Receives the decoded target, socket lane, and plug definition.
+ * @return True only for the complete canonical 18-byte request.
+ */
+[[nodiscard]] bool parse_request(const Message& message, Request& request) noexcept;
+
+} // namespace sunrise::middleware::web_service::messages::opcode903

+ 81 - 0
Sunrise/src/middleware/web_service/messages/opcode903_codec.cpp

@@ -0,0 +1,81 @@
+#include <cstddef>
+#include <limits>
+
+#include "../../encoding/bit_reader.h"
+#include "opcode903.h"
+
+namespace sunrise::middleware::web_service::messages::opcode903 {
+namespace {
+
+/** The reflected opcode-903 request occupies exactly 144 bits. */
+constexpr std::size_t kPayloadSize = 18;
+/** Optional object identifiers carry one presence bit before the 64-bit SOID. */
+constexpr std::uint8_t kInstanceWidth = 64;
+/** Signed native definition indices are biased into one 16-bit wire field. */
+constexpr std::uint8_t kDefinitionIndexWidth = 15;
+/** A signed socket lane is biased from INT32_MIN into one 32-bit wire field. */
+constexpr std::uint8_t kSocketIndexWidth = 32;
+/** The reflected inner structure is padded to a byte before the outer request fields. */
+constexpr std::uint8_t kInnerPaddingWidth = 7;
+/** Two absent optional fields terminate the generic Web Service request descriptor. */
+constexpr std::uint8_t kOuterTrailerWidth = 2;
+/** The complete outer request is padded to its final byte. */
+constexpr std::uint8_t kFinalPaddingWidth = 6;
+/** Nonnegative signed 32-bit lanes have this bit set after native descriptor biasing. */
+constexpr std::uint64_t kSocketIndexBias = 0x80000000ULL;
+/** An absent signed 16-bit definition index serializes as biased -1. */
+constexpr std::uint64_t kAbsentDefinitionTail = 0x7FFFULL;
+
+} // namespace
+
+/** Parses the complete native socket-plug action descriptor. */
+bool parse_request(const Message& message, Request& request) noexcept {
+    request = {};
+    if (message.opcode != kOpcode || message.payload.size() != kPayloadSize) {
+        return false;
+    }
+
+    encoding::bits::Reader reader(message.payload);
+    std::uint64_t instancePresent = 0;
+    std::uint64_t targetDefinitionPresent = 0;
+    std::uint64_t encodedTargetDefinition = 0;
+    std::uint64_t encodedSocketIndex = 0;
+    std::uint64_t plugDefinitionPresent = 0;
+    std::uint64_t encodedPlugDefinition = 0;
+    std::uint64_t innerPadding = 0;
+    std::uint64_t outerTrailer = 0;
+    std::uint64_t finalPadding = 0;
+    if (!reader.read(1, instancePresent) || !reader.read(kInstanceWidth, request.instanceSoid)
+        || !reader.read(1, targetDefinitionPresent)
+        || !reader.read(kDefinitionIndexWidth, encodedTargetDefinition)
+        || !reader.read(kSocketIndexWidth, encodedSocketIndex)
+        || !reader.read(1, plugDefinitionPresent)
+        || !reader.read(kDefinitionIndexWidth, encodedPlugDefinition)
+        || !reader.read(kInnerPaddingWidth, innerPadding)
+        || !reader.read(kOuterTrailerWidth, outerTrailer)
+        || !reader.read(kFinalPaddingWidth, finalPadding) || reader.remaining_bits() != 0
+        || innerPadding != 0 || outerTrailer != 0 || finalPadding != 0
+        || encodedSocketIndex < kSocketIndexBias
+        || encodedSocketIndex - kSocketIndexBias > (std::numeric_limits<std::uint32_t>::max)()) {
+        request = {};
+        return false;
+    }
+
+    request.hasInstance = instancePresent != 0;
+    request.hasTargetDefinition = targetDefinitionPresent != 0;
+    request.hasPlugDefinition = plugDefinitionPresent != 0;
+    request.targetDefinitionIndex = static_cast<std::uint16_t>(encodedTargetDefinition);
+    request.plugDefinitionIndex = static_cast<std::uint16_t>(encodedPlugDefinition);
+    request.socketIndex = static_cast<std::uint32_t>(encodedSocketIndex - kSocketIndexBias);
+
+    // The signed-index descriptor writes -1 as a clear presence bit plus fifteen set value bits.
+    // Requiring that canonical tail prevents a second representation of an absent definition.
+    if ((!request.hasTargetDefinition && encodedTargetDefinition != kAbsentDefinitionTail)
+        || (!request.hasPlugDefinition && encodedPlugDefinition != kAbsentDefinitionTail)) {
+        request = {};
+        return false;
+    }
+    return true;
+}
+
+} // namespace sunrise::middleware::web_service::messages::opcode903

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

@@ -2,6 +2,8 @@
 
 #include <array>
 #include <atomic>
+#include <cstdio>
+#include <limits>
 
 #include "../../core/logging/log.h"
 #include "../../state/matchmaking/matchmaking_state.h"
@@ -14,6 +16,40 @@ namespace {
 SRWLOCK g_lock{SRWLOCK_INIT};
 std::array<Session, kSessionCount> g_sessions{};
 Scratch g_scratch{};
+std::uint64_t g_accountGeneration{};
+
+/** Arms every other active peer after one shared-account transaction is published. */
+void publish_account_mutation(Session& origin) noexcept {
+    origin.accountMutationPublished = false;
+    g_accountGeneration = g_accountGeneration == (std::numeric_limits<std::uint64_t>::max)()
+                              ? 1
+                              : g_accountGeneration + 1;
+    origin.accountGeneration = g_accountGeneration;
+    origin.accountResyncGeneration = g_accountGeneration;
+    origin.accountResyncArmed = false;
+    std::size_t armed = 0;
+    for (auto& peer : g_sessions) {
+        if (&peer == &origin || peer.id == 0 || !peer.authenticated || !peer.queuez.family4Active) {
+            continue;
+        }
+        peer.accountResyncGeneration = g_accountGeneration;
+        peer.accountResyncArmed = true;
+        ++armed;
+    }
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count = std::snprintf(line.data(),
+                                    line.size(),
+                                    "ev=queuez stage=peer_resync_arm result=ok generation=%llu "
+                                    "origin=%u peers=%zu",
+                                    static_cast<unsigned long long>(g_accountGeneration),
+                                    origin.id,
+                                    armed);
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+}
 
 /** @param id Nonzero connection id. @return Matching open session, or null. */
 [[nodiscard]] Session* session_for(std::uint32_t id) noexcept {
@@ -98,6 +134,10 @@ void clear_session(Session& session) noexcept {
     if (!handled) {
         return false;
     }
+    if (frame.frameType == middleware::bap::FrameType::encrypted
+        && session->accountMutationPublished) {
+        publish_account_mutation(*session);
+    }
     // A frame response can carry one already-due push in the same bounded socket write.
     bool touchesScratch = true;
     std::size_t deferred = 0;
@@ -178,6 +218,7 @@ void shutdown() noexcept {
     }
     SecureZeroMemory(g_sessions.data(), sizeof g_sessions);
     SecureZeroMemory(&g_scratch, sizeof g_scratch);
+    g_accountGeneration = 0;
     ReleaseSRWLockExclusive(&g_lock);
 }
 

+ 8 - 8
Sunrise/src/server/bap/encrypted/bap_connection_publication.cpp

@@ -20,20 +20,20 @@ constexpr std::uint64_t kTransitionWindowMs = 15'000;
 /** Captures the connection fields one service outcome carries. */
 ConnectionFields connection_fields(const ServiceOutcome& outcome) noexcept {
     ConnectionFields fields{};
-    if (!outcome.hasActivityTransaction) {
+    const auto* plan = transaction_if<activity_message::ActivityPlan>(outcome);
+    if (plan == nullptr) {
         return fields;
     }
-    const auto& plan = outcome.activityPlan;
-    if (plan.delivery == activity_message::Delivery::joinNotifications) {
-        fields.joinMemberKey = plan.entitySlotMutation.memberKey;
-        fields.joinCharacterSoid = plan.joinCharacterSoid;
+    if (plan->delivery == activity_message::Delivery::joinNotifications) {
+        fields.joinMemberKey = plan->entitySlotMutation.memberKey;
+        fields.joinCharacterSoid = plan->joinCharacterSoid;
         fields.joinsActivity = true;
     }
     // The initial load is a transition too, and its token does not arrive for several seconds.
     fields.opensTransitionWindow =
-        plan.delivery == activity_message::Delivery::joinNotifications || plan.transitionStarted;
-    if (plan.mutationDomain == activity_message::MutationDomain::patchEpoch) {
-        fields.patchEpoch = plan.patchEpoch;
+        plan->delivery == activity_message::Delivery::joinNotifications || plan->transitionStarted;
+    if (plan->mutationDomain == activity_message::MutationDomain::patchEpoch) {
+        fields.patchEpoch = plan->patchEpoch;
         fields.retainsPatchEpoch = true;
     }
     return fields;

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

@@ -100,16 +100,27 @@ bool process(const ServiceRoute& route,
         return middleware::bap::account_translation::encode_response(
             requestBody, soid, output, written);
     }
-    case BodyCodec::activityHostManagerResponse:
-        return activity_host_manager::encode_response(requestBody,
-                                                      output,
-                                                      written,
-                                                      outcome.activitySessionAllocation,
-                                                      outcome.hasActivitySessionAllocation);
-    case BodyCodec::activityMessageRequest:
+    case BodyCodec::activityHostManagerResponse: {
+        state::activity::PendingAllocation allocation{};
+        bool hasAllocation = false;
+        const bool encoded = activity_host_manager::encode_response(
+            requestBody, output, written, allocation, hasAllocation);
+        if (encoded && hasAllocation) {
+            outcome.transaction = allocation;
+        }
+        return encoded;
+    }
+    case BodyCodec::activityMessageRequest: {
         written = 0;
-        return activity_message::process(
-            activitySessionId, requestBody, outcome.activityPlan, outcome.hasActivityTransaction);
+        activity_message::ActivityPlan plan{};
+        bool hasTransaction = false;
+        const bool processed =
+            activity_message::process(activitySessionId, requestBody, plan, hasTransaction);
+        if (processed && hasTransaction) {
+            outcome.transaction = plan;
+        }
+        return processed;
+    }
     case BodyCodec::activityHostResponse: {
         const state::SignOnState& signOn = state::sign_on();
         return middleware::bap::activity_host::encode_response(
@@ -144,13 +155,16 @@ bool process(const ServiceRoute& route,
             middleware::bap::family_unsubscription::parse(requestBody, outcome.unsubscription);
         return outcome.hasUnsubscription;
     }
-    case BodyCodec::matchmakingResponse:
-        return matchmaking::encode_response(matchmakingContext,
-                                            requestBody,
-                                            output,
-                                            written,
-                                            outcome.matchmakingMutation,
-                                            outcome.hasMatchmakingMutation);
+    case BodyCodec::matchmakingResponse: {
+        state::matchmaking::PendingMutation mutation{};
+        bool hasMutation = false;
+        const bool encoded = matchmaking::encode_response(
+            matchmakingContext, requestBody, output, written, mutation, hasMutation);
+        if (encoded && hasMutation) {
+            outcome.transaction = mutation;
+        }
+        return encoded;
+    }
     case BodyCodec::steamCertificate:
         return middleware::bap::certificate::encode_response(requestBody, output, written);
     case BodyCodec::userMessageResponse:
@@ -185,6 +199,224 @@ bool process(const ServiceRoute& route,
         }
         outcome.hasSubscription = webOutcome.hasSubscription;
         outcome.subscription = webOutcome.subscription;
+        const auto* equipmentSwap =
+            web_service::mutation_if<state::PendingEquipmentSwap>(webOutcome);
+        const auto* socketPlug = web_service::mutation_if<state::PendingSocketPlug>(webOutcome);
+        const auto* itemState = web_service::mutation_if<state::PendingItemState>(webOutcome);
+        const auto* itemAcquisition =
+            web_service::mutation_if<state::PendingItemAcquisition>(webOutcome);
+        const auto* profileItemAcquisition =
+            web_service::mutation_if<state::PendingProfileItemAcquisition>(webOutcome);
+        const auto* itemDismantle =
+            web_service::mutation_if<state::PendingItemDismantle>(webOutcome);
+        if (equipmentSwap != nullptr) {
+            // Equip is an optimistic Character-screen action. Its status-pair value is the exact
+            // Family-4 revision whose following Queuez frame makes the action authoritative. Stage
+            // that revision before encoding the reply so the Client cannot complete the action
+            // against the old object store.
+            auto& transaction = outcome.transaction.emplace<EquipmentSwapTransaction>();
+            if (!queuez::stage_equipment_swap(
+                    queuezState, equipmentSwap->characterSoid, transaction.update)) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=ws403 stage=queuez_preflight result=fail");
+                // Keep the already-encoded sentinel response and publish no mutation, matching
+                // the change-character failure contract instead of dropping the correlated task.
+                outcome.transaction = std::monostate{};
+            } else {
+                middleware::web_service::StatusResponse status{};
+                status.value = transaction.update.after.family4Version;
+                if (!middleware::web_service::encode_response(
+                        message,
+                        middleware::web_service::ResponseShape::statusPair,
+                        status,
+                        output,
+                        written)) {
+                    core::log::write(core::log::Channel::server,
+                                     core::log::Level::warn,
+                                     "ev=ws403 stage=response result=fail");
+                    return false;
+                }
+                web_service::report_equip_response(message, status.value, output.first(written));
+                transaction.pending = *equipmentSwap;
+            }
+        }
+        if (socketPlug != nullptr) {
+            // Opcode 903 completes at the exact Family-4 revision carrying the changed resident
+            // item instance. The resident manifest and character placement remain unchanged.
+            auto& transaction = outcome.transaction.emplace<SocketPlugTransaction>();
+            if (!queuez::stage_socket_plug(queuezState,
+                                           socketPlug->accountSoid,
+                                           socketPlug->characterSoid,
+                                           socketPlug->targetInstanceSoid,
+                                           socketPlug->profileChanged,
+                                           transaction.update)) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=socket_plug stage=queuez_preflight result=fail");
+                outcome.transaction = std::monostate{};
+            } else {
+                middleware::web_service::StatusResponse status{};
+                status.value = transaction.update.after.family4Version;
+                if (!middleware::web_service::encode_response(
+                        message,
+                        middleware::web_service::ResponseShape::statusPair,
+                        status,
+                        output,
+                        written)) {
+                    core::log::write(core::log::Channel::server,
+                                     core::log::Level::warn,
+                                     "ev=socket_plug stage=response result=fail");
+                    return false;
+                }
+                web_service::report_socket_plug_response(message,
+                                                         status.value,
+                                                         socketPlug->targetInstanceSoid,
+                                                         socketPlug->socketLane,
+                                                         socketPlug->plugDefinitionIndex,
+                                                         output.first(written));
+                transaction.pending = *socketPlug;
+            }
+        }
+        if (itemState != nullptr) {
+            // Opcode 406 completes at the exact Family-4 revision carrying the changed inventory
+            // row flags. Placement and every resident item-instance body remain unchanged.
+            auto& transaction = outcome.transaction.emplace<ItemStateTransaction>();
+            if (!queuez::stage_equipment_swap(
+                    queuezState, itemState->characterSoid, transaction.update)) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=item_state stage=queuez_preflight result=fail");
+                outcome.transaction = std::monostate{};
+            } else {
+                middleware::web_service::StatusResponse status{};
+                status.value = transaction.update.after.family4Version;
+                if (!middleware::web_service::encode_response(
+                        message,
+                        middleware::web_service::ResponseShape::statusPair,
+                        status,
+                        output,
+                        written)) {
+                    core::log::write(core::log::Channel::server,
+                                     core::log::Level::warn,
+                                     "ev=item_state stage=response result=fail");
+                    return false;
+                }
+                transaction.pending = *itemState;
+            }
+        }
+        if (itemAcquisition != nullptr) {
+            // A Collections pull is complete only at the exact Family-4 revision that adds both
+            // the inventory row and its newly resident instance object. Stage that revision before
+            // re-encoding the correlated status pair, just like an equipment swap.
+            auto& transaction = outcome.transaction.emplace<ItemAcquisitionTransaction>();
+            if (!queuez::stage_item_acquisition(queuezState,
+                                                itemAcquisition->accountSoid,
+                                                itemAcquisition->characterSoid,
+                                                itemAcquisition->acquiredInstanceSoid,
+                                                itemAcquisition->profileChanged,
+                                                transaction.update)) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=acquire stage=queuez_preflight result=fail");
+                outcome.transaction = std::monostate{};
+            } else {
+                middleware::web_service::StatusResponse status{};
+                status.value = transaction.update.after.family4Version;
+                if (!middleware::web_service::encode_response(
+                        message,
+                        middleware::web_service::ResponseShape::statusPair,
+                        status,
+                        output,
+                        written)) {
+                    core::log::write(core::log::Channel::server,
+                                     core::log::Level::warn,
+                                     "ev=acquire stage=response result=fail");
+                    return false;
+                }
+                web_service::report_item_acquisition_response(message,
+                                                              status.value,
+                                                              itemAcquisition->acquiredInstanceSoid,
+                                                              output.first(written));
+                transaction.pending = *itemAcquisition;
+            }
+        }
+        if (profileItemAcquisition != nullptr) {
+            // Profile stacks live in the account body. Actionable shaders/modifications also name
+            // a Family-4 item resident: an existing stack must already own it, while a newly
+            // appended row adds it atomically at this exact +1 revision.
+            auto& transaction = outcome.transaction.emplace<ProfileItemAcquisitionTransaction>();
+            if (!queuez::stage_profile_item_acquisition(
+                    queuezState,
+                    profileItemAcquisition->accountSoid,
+                    profileItemAcquisition->acquiredInstanceSoid,
+                    profileItemAcquisition->actionSource,
+                    profileItemAcquisition->appended,
+                    transaction.update)) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=profile_acquire stage=queuez_preflight result=fail");
+                outcome.transaction = std::monostate{};
+            } else {
+                middleware::web_service::StatusResponse status{};
+                status.value = transaction.update.after.family4Version;
+                if (!middleware::web_service::encode_response(
+                        message,
+                        middleware::web_service::ResponseShape::statusPair,
+                        status,
+                        output,
+                        written)) {
+                    core::log::write(core::log::Channel::server,
+                                     core::log::Level::warn,
+                                     "ev=profile_acquire stage=response result=fail");
+                    return false;
+                }
+                web_service::report_profile_item_acquisition_response(
+                    message,
+                    status.value,
+                    profileItemAcquisition->acquiredDefinitionHash,
+                    profileItemAcquisition->acquiredQuantity,
+                    output.first(written));
+                transaction.pending = *profileItemAcquisition;
+            }
+        }
+        if (itemDismantle != nullptr) {
+            // Dismantle is another optimistic Character-screen action. Promise only the exact
+            // Family-4 revision that carries both the character after-image and the empty
+            // item-instance release descriptor; otherwise retain the generic sentinel reply and
+            // publish no removal.
+            auto& transaction = outcome.transaction.emplace<ItemDismantleTransaction>();
+            if (!queuez::stage_item_dismantle(queuezState,
+                                              itemDismantle->accountSoid,
+                                              itemDismantle->characterSoid,
+                                              itemDismantle->dismantledInstanceSoid,
+                                              itemDismantle->profileChanged,
+                                              transaction.update)) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=dismantle stage=queuez_preflight result=fail");
+                outcome.transaction = std::monostate{};
+            } else {
+                middleware::web_service::StatusResponse status{};
+                status.value = transaction.update.after.family4Version;
+                if (!middleware::web_service::encode_response(
+                        message,
+                        middleware::web_service::ResponseShape::statusPair,
+                        status,
+                        output,
+                        written)) {
+                    core::log::write(core::log::Channel::server,
+                                     core::log::Level::warn,
+                                     "ev=dismantle stage=response result=fail");
+                    return false;
+                }
+                web_service::report_item_dismantle_response(message,
+                                                            status.value,
+                                                            itemDismantle->dismantledInstanceSoid,
+                                                            output.first(written));
+                transaction.pending = *itemDismantle;
+            }
+        }
         // A pick that names the resident character moves nothing, so staging refuses it and the
         // reply still stands on its own.
         if (webOutcome.hasSelectedCharacter

+ 163 - 5
Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp

@@ -1,6 +1,8 @@
 #include <Windows.h>
 
 #include <algorithm>
+#include <array>
+#include <cstdio>
 
 #include "../../../core/logging/log.h"
 #include "../../../middleware/secure_channel/runtime.h"
@@ -42,6 +44,7 @@ bool consume(Session& session,
              std::span<std::byte> response,
              std::size_t& written) noexcept {
     written = 0;
+    session.accountMutationPublished = false;
     if (!session.authenticated) {
         // Staying silent here looks the same as a decode fault, and both look like a dead link.
         core::log::write(core::log::Channel::server,
@@ -107,7 +110,7 @@ bool consume(Session& session,
         // good and every later reply is rejected, which is worse than a thin reply.
         clear_prefix(scratch.responseBody, responseBodySize);
         responseBodySize = 0;
-        SecureZeroMemory(&outcome, sizeof outcome);
+        outcome = {};
         handled = sendsReply;
     }
     if (handled && sendsReply) {
@@ -145,14 +148,14 @@ bool consume(Session& session,
             diagnostics::report_failure(frame.messageId, "stage");
         }
     }
-    if (handled && outcome.hasActivityTransaction) {
-        const auto& activityPlan = outcome.activityPlan;
+    const auto* activityPlan = transaction_if<activity_message::ActivityPlan>(outcome);
+    if (handled && activityPlan != nullptr) {
         handled = route.responseMode == ResponseMode::uncorrelatedPush;
         if (!handled) {
             diagnostics::report_failure(frame.messageId, "route");
         } else if (!activity_transaction::stage_notifications(session,
                                                               scratch,
-                                                              activityPlan,
+                                                              *activityPlan,
                                                               bapState.sessionKey,
                                                               nextSendNonce,
                                                               scratch.framed,
@@ -162,6 +165,37 @@ bool consume(Session& session,
             diagnostics::report_failure(frame.messageId, "notify");
         }
     }
+    const bool mutatesAccount =
+        outcome.hasChangeCharacter || outcome.hasSelectCharacter
+        || transaction_if<EquipmentSwapTransaction>(outcome) != nullptr
+        || transaction_if<SocketPlugTransaction>(outcome) != nullptr
+        || transaction_if<ItemStateTransaction>(outcome) != nullptr
+        || transaction_if<ItemAcquisitionTransaction>(outcome) != nullptr
+        || transaction_if<ProfileItemAcquisitionTransaction>(outcome) != nullptr
+        || transaction_if<ItemDismantleTransaction>(outcome) != nullptr;
+    // State commits consume and clear their pending payloads. Retain only the small diagnostic
+    // fields needed after publication; QueueZ after-images stay owned by the transaction variant.
+    const auto* stagedSocket = transaction_if<SocketPlugTransaction>(outcome);
+    const std::uint8_t socketLane = stagedSocket == nullptr ? 0 : stagedSocket->pending.socketLane;
+    const std::uint16_t socketPlugDefinition =
+        stagedSocket == nullptr ? 0 : stagedSocket->pending.plugDefinitionIndex;
+    const std::uint8_t socketTargetBucket =
+        stagedSocket == nullptr ? 0 : stagedSocket->pending.targetBucketId;
+    const std::uint8_t socketPlugBucket =
+        stagedSocket == nullptr ? 0 : stagedSocket->pending.plugBucketId;
+    const auto* stagedItemState = transaction_if<ItemStateTransaction>(outcome);
+    const std::uint64_t itemStateInstance =
+        stagedItemState == nullptr ? 0 : stagedItemState->pending.targetInstanceSoid;
+    const std::uint32_t itemStateFlags =
+        stagedItemState == nullptr ? 0 : stagedItemState->pending.afterFlags;
+    const auto* stagedProfile = transaction_if<ProfileItemAcquisitionTransaction>(outcome);
+    const std::uint32_t profileDefinitionHash =
+        stagedProfile == nullptr ? 0 : stagedProfile->pending.acquiredDefinitionHash;
+    const std::int32_t profileQuantity =
+        stagedProfile == nullptr ? 0 : stagedProfile->pending.acquiredQuantity;
+    const bool profileActionSource =
+        stagedProfile != nullptr && stagedProfile->pending.actionSource;
+    const bool profileAppended = stagedProfile != nullptr && stagedProfile->pending.appended;
     // Committing the transaction clears the mutation the member key lives in, so the connection
     // fields are captured before the commit and published after it.
     const ConnectionFields connection = connection_fields(outcome);
@@ -183,6 +217,130 @@ bool consume(Session& session,
             publish_connection_fields(session, publication, connection);
             // The caller copy is done, so what the staged roster body owes is settled here.
             push::activity::commit_staged_roster(session);
+            session.accountMutationPublished = mutatesAccount;
+            if (transaction_if<EquipmentSwapTransaction>(outcome) != nullptr) {
+                std::array<char, core::log::kLineCapacity> line{};
+                const int count = std::snprintf(
+                    line.data(),
+                    line.size(),
+                    "ev=equip stage=output_publish result=ok framed_bytes=%zu queuez_published=%u "
+                    "family_version=%d family0_version=%d family3_version=%d",
+                    framedSize,
+                    static_cast<unsigned>(publishesQueuez),
+                    session.queuez.family4Version,
+                    session.queuez.family0Version,
+                    session.queuez.family3Version);
+                if (count > 0) {
+                    core::log::write(core::log::Channel::server,
+                                     core::log::Level::debug,
+                                     {line.data(), static_cast<std::size_t>(count)});
+                }
+            }
+            if (const auto* transaction = transaction_if<SocketPlugTransaction>(outcome)) {
+                std::array<char, core::log::kLineCapacity> line{};
+                const int count = std::snprintf(
+                    line.data(),
+                    line.size(),
+                    "ev=socket_plug stage=output_publish result=ok framed_bytes=%zu "
+                    "queuez_published=%u family_version=%d family0_version=%d "
+                    "family3_version=%d instance=0x%llX lane=%u "
+                    "plug_definition=%u target_bucket=%u plug_bucket=%u",
+                    framedSize,
+                    static_cast<unsigned>(publishesQueuez),
+                    session.queuez.family4Version,
+                    session.queuez.family0Version,
+                    session.queuez.family3Version,
+                    static_cast<unsigned long long>(transaction->update.targetInstanceSoid),
+                    static_cast<unsigned>(socketLane),
+                    static_cast<unsigned>(socketPlugDefinition),
+                    static_cast<unsigned>(socketTargetBucket),
+                    static_cast<unsigned>(socketPlugBucket));
+                if (count > 0) {
+                    core::log::write(core::log::Channel::server,
+                                     core::log::Level::debug,
+                                     {line.data(), static_cast<std::size_t>(count)});
+                }
+            }
+            if (const auto* transaction = transaction_if<ItemStateTransaction>(outcome)) {
+                std::array<char, core::log::kLineCapacity> line{};
+                const int count = std::snprintf(
+                    line.data(),
+                    line.size(),
+                    "ev=item_state stage=output_publish result=ok framed_bytes=%zu "
+                    "queuez_published=%u family_version=%d instance=0x%llX flags=0x%X",
+                    framedSize,
+                    static_cast<unsigned>(publishesQueuez),
+                    session.queuez.family4Version,
+                    static_cast<unsigned long long>(itemStateInstance),
+                    itemStateFlags);
+                if (count > 0) {
+                    core::log::write(core::log::Channel::server,
+                                     core::log::Level::debug,
+                                     {line.data(), static_cast<std::size_t>(count)});
+                }
+            }
+            if (const auto* transaction = transaction_if<ItemAcquisitionTransaction>(outcome)) {
+                std::array<char, core::log::kLineCapacity> line{};
+                const int count = std::snprintf(
+                    line.data(),
+                    line.size(),
+                    "ev=acquire stage=output_publish result=ok framed_bytes=%zu "
+                    "queuez_published=%u family_version=%d residents=%u instance=0x%llX",
+                    framedSize,
+                    static_cast<unsigned>(publishesQueuez),
+                    session.queuez.family4Version,
+                    static_cast<unsigned>(session.queuez.family4ResidentCount),
+                    static_cast<unsigned long long>(transaction->update.acquiredInstanceSoid));
+                if (count > 0) {
+                    core::log::write(core::log::Channel::server,
+                                     core::log::Level::debug,
+                                     {line.data(), static_cast<std::size_t>(count)});
+                }
+            }
+            if (const auto* transaction =
+                    transaction_if<ProfileItemAcquisitionTransaction>(outcome)) {
+                std::array<char, core::log::kLineCapacity> line{};
+                const int count = std::snprintf(
+                    line.data(),
+                    line.size(),
+                    "ev=profile_acquire stage=output_publish result=ok framed_bytes=%zu "
+                    "queuez_published=%u family_version=%d residents=%u definition_hash=0x%08X "
+                    "quantity=%d instance=0x%llX action_source=%u appended_row=%u "
+                    "appended_resident=%u",
+                    framedSize,
+                    static_cast<unsigned>(publishesQueuez),
+                    session.queuez.family4Version,
+                    static_cast<unsigned>(session.queuez.family4ResidentCount),
+                    profileDefinitionHash,
+                    profileQuantity,
+                    static_cast<unsigned long long>(transaction->update.acquiredInstanceSoid),
+                    static_cast<unsigned>(profileActionSource),
+                    static_cast<unsigned>(profileAppended),
+                    static_cast<unsigned>(transaction->update.appendedResident));
+                if (count > 0) {
+                    core::log::write(core::log::Channel::server,
+                                     core::log::Level::debug,
+                                     {line.data(), static_cast<std::size_t>(count)});
+                }
+            }
+            if (const auto* transaction = transaction_if<ItemDismantleTransaction>(outcome)) {
+                std::array<char, core::log::kLineCapacity> line{};
+                const int count = std::snprintf(
+                    line.data(),
+                    line.size(),
+                    "ev=dismantle stage=output_publish result=ok framed_bytes=%zu "
+                    "queuez_published=%u family_version=%d residents=%u instance=0x%llX",
+                    framedSize,
+                    static_cast<unsigned>(publishesQueuez),
+                    session.queuez.family4Version,
+                    static_cast<unsigned>(session.queuez.family4ResidentCount),
+                    static_cast<unsigned long long>(transaction->update.dismantledInstanceSoid));
+                if (count > 0) {
+                    core::log::write(core::log::Channel::server,
+                                     core::log::Level::debug,
+                                     {line.data(), static_cast<std::size_t>(count)});
+                }
+            }
         }
     }
     if (!handled) {
@@ -192,7 +350,7 @@ bool consume(Session& session,
     clear_prefix(scratch.plaintext, plaintextSize);
     clear_prefix(scratch.responseBody, responseBodySize);
     clear_prefix(scratch.framed, framedSize);
-    SecureZeroMemory(&outcome, sizeof outcome);
+    outcome = {};
     SecureZeroMemory(&publication, sizeof publication);
     SecureZeroMemory(&queuezPublication, sizeof queuezPublication);
     if (handled) {

+ 195 - 6
Sunrise/src/server/bap/encrypted/internal.h

@@ -5,6 +5,7 @@
 #include <cstdint>
 #include <span>
 #include <string_view>
+#include <variant>
 
 #include "../../../middleware/bap/family_unsubscription.h"
 #include "../../../middleware/bap/frame.h"
@@ -33,6 +34,42 @@ enum class BodyCodec : std::uint8_t {
     webService,
 };
 
+/** Equipment mutation and the exact QueueZ after-image promised by its response. */
+struct EquipmentSwapTransaction {
+    state::PendingEquipmentSwap pending{};
+    queuez::EquipmentSwap update{};
+};
+
+/** Socket mutation and the exact QueueZ after-image promised by its response. */
+struct SocketPlugTransaction {
+    state::PendingSocketPlug pending{};
+    queuez::SocketPlug update{};
+};
+
+/** Item-state mutation and the exact QueueZ character after-image promised by its response. */
+struct ItemStateTransaction {
+    state::PendingItemState pending{};
+    queuez::EquipmentSwap update{};
+};
+
+/** Character acquisition and its exact QueueZ after-image. */
+struct ItemAcquisitionTransaction {
+    state::PendingItemAcquisition pending{};
+    queuez::ItemAcquisition update{};
+};
+
+/** Profile acquisition and its exact account/resident QueueZ after-image. */
+struct ProfileItemAcquisitionTransaction {
+    state::PendingProfileItemAcquisition pending{};
+    queuez::ProfileItemAcquisition update{};
+};
+
+/** Dismantle mutation and its exact QueueZ after-image. */
+struct ItemDismantleTransaction {
+    state::PendingItemDismantle pending{};
+    queuez::ItemDismantle update{};
+};
+
 /** Optional side effect produced while decoding one authenticated service body. */
 struct ServiceOutcome {
     bool hasSubscription{};
@@ -43,14 +80,32 @@ struct ServiceOutcome {
     queuez::ChangeCharacter changeCharacter{};
     bool hasSelectCharacter{};
     queuez::SelectCharacter selectCharacter{};
-    bool hasActivitySessionAllocation{};
-    state::activity::PendingAllocation activitySessionAllocation{};
-    bool hasActivityTransaction{};
-    activity_message::ActivityPlan activityPlan{};
-    bool hasMatchmakingMutation{};
-    state::matchmaking::PendingMutation matchmakingMutation{};
+    /** One service owns at most one independently versioned transaction. */
+    using Transaction = std::variant<std::monostate,
+                                     state::activity::PendingAllocation,
+                                     activity_message::ActivityPlan,
+                                     state::matchmaking::PendingMutation,
+                                     EquipmentSwapTransaction,
+                                     SocketPlugTransaction,
+                                     ItemStateTransaction,
+                                     ItemAcquisitionTransaction,
+                                     ProfileItemAcquisitionTransaction,
+                                     ItemDismantleTransaction>;
+    Transaction transaction{};
 };
 
+/** @return The service transaction of the requested type, or null for another route. */
+template <typename Transaction>
+[[nodiscard]] Transaction* transaction_if(ServiceOutcome& outcome) noexcept {
+    return std::get_if<Transaction>(&outcome.transaction);
+}
+
+/** @return The service transaction of the requested type, or null for another route. */
+template <typename Transaction>
+[[nodiscard]] const Transaction* transaction_if(const ServiceOutcome& outcome) noexcept {
+    return std::get_if<Transaction>(&outcome.transaction);
+}
+
 /** Outbound delivery behavior picked for one authenticated request service. */
 enum class ResponseMode : std::uint8_t {
     none,
@@ -160,6 +215,16 @@ void append_queuez_notification(Scratch& scratch,
                                 bool& armsRepush,
                                 bool& armsBannerRepush) noexcept;
 
+/** Appends one next-version full Family-4 snapshot used to resynchronize another peer. */
+[[nodiscard]] bool
+append_account_resync_notification(Scratch& scratch,
+                                   const queuez::SessionState& before,
+                                   std::span<const std::byte, state::kAesKeySize> key,
+                                   std::array<std::byte, state::kBapNonceSize>& nonce,
+                                   std::span<std::byte> response,
+                                   std::size_t& written,
+                                   queuez::SessionState& after) noexcept;
+
 /**
  * Appends the family-zero banner pair as its own notification.
  * Sent twice per boot at the same version, which is what survives the state-1 DECLARED race.
@@ -241,6 +306,130 @@ append_select_character_notification(Scratch& scratch,
                                      std::span<std::byte> response,
                                      std::size_t& written) noexcept;
 
+/** Appends the opcode-403 Family-4 character upsert that exposes the equipped item swap. */
+[[nodiscard]] bool
+append_equipment_swap_notification(Scratch& scratch,
+                                   const queuez::EquipmentSwap& swap,
+                                   const state::PendingEquipmentSwap& mutation,
+                                   std::span<const std::byte, state::kAesKeySize> key,
+                                   std::span<const std::byte, state::kBapNonceSize> nonce,
+                                   std::span<std::byte> response,
+                                   std::size_t& written) noexcept;
+
+/** Appends the opcode-406 Family-4 character upsert carrying changed inventory-row flags. */
+[[nodiscard]] bool
+append_item_state_notification(Scratch& scratch,
+                               const queuez::EquipmentSwap& update,
+                               const state::PendingItemState& mutation,
+                               std::span<const std::byte, state::kAesKeySize> key,
+                               std::span<const std::byte, state::kBapNonceSize> nonce,
+                               std::span<std::byte> response,
+                               std::size_t& written) noexcept;
+
+/**
+ * Appends the same-character Family-0 appearance upsert paired with one equipment swap.
+ * The
+ * update owns its nonce advance only after the complete notification fits.
+ */
+[[nodiscard]] bool
+append_equipment_appearance_refresh_notification(Scratch& scratch,
+                                                 const queuez::CharacterAppearanceRefresh& refresh,
+                                                 const state::PendingEquipmentSwap& mutation,
+                                                 std::span<const std::byte, state::kAesKeySize> key,
+                                                 std::array<std::byte, state::kBapNonceSize>& nonce,
+                                                 std::span<std::byte> response,
+                                                 std::size_t& written) noexcept;
+
+/** Appends the Family-0 refresh owed by a socket change on an equipped item. */
+[[nodiscard]] bool
+append_socket_appearance_refresh_notification(Scratch& scratch,
+                                              const queuez::CharacterAppearanceRefresh& refresh,
+                                              const state::PendingSocketPlug& mutation,
+                                              std::span<const std::byte, state::kAesKeySize> key,
+                                              std::array<std::byte, state::kBapNonceSize>& nonce,
+                                              std::span<std::byte> response,
+                                              std::size_t& written) noexcept;
+
+/** Appends a Family-3 character record followed by the changed account roster after equip. */
+[[nodiscard]] bool
+append_equipment_roster_refresh_notification(Scratch& scratch,
+                                             const queuez::RosterAppearanceRefresh& refresh,
+                                             const state::PendingEquipmentSwap& mutation,
+                                             std::span<const std::byte, state::kAesKeySize> key,
+                                             std::array<std::byte, state::kBapNonceSize>& nonce,
+                                             std::span<std::byte> response,
+                                             std::size_t& written) noexcept;
+
+/** Appends a Family-3 character-only appearance refresh after an equipped socket change. */
+[[nodiscard]] bool
+append_socket_roster_refresh_notification(Scratch& scratch,
+                                          const queuez::RosterAppearanceRefresh& refresh,
+                                          const state::PendingSocketPlug& mutation,
+                                          std::span<const std::byte, state::kAesKeySize> key,
+                                          std::array<std::byte, state::kBapNonceSize>& nonce,
+                                          std::span<std::byte> response,
+                                          std::size_t& written) noexcept;
+
+/** Refreshes the selected character's complete Family-0 appearance from committed State. */
+[[nodiscard]] bool
+append_account_resync_appearance_notification(Scratch& scratch,
+                                              const queuez::SessionState& before,
+                                              std::span<const std::byte, state::kAesKeySize> key,
+                                              std::array<std::byte, state::kBapNonceSize>& nonce,
+                                              std::span<std::byte> response,
+                                              std::size_t& written,
+                                              queuez::SessionState& after) noexcept;
+
+/** Refreshes the selected character and account roster from committed State. */
+[[nodiscard]] bool
+append_account_resync_roster_notification(Scratch& scratch,
+                                          const queuez::SessionState& before,
+                                          std::span<const std::byte, state::kAesKeySize> key,
+                                          std::array<std::byte, state::kBapNonceSize>& nonce,
+                                          std::span<std::byte> response,
+                                          std::size_t& written,
+                                          queuez::SessionState& after) noexcept;
+
+/** Appends the opcode-903 Family-4 item-instance upsert exposing one socket selection. */
+[[nodiscard]] bool
+append_socket_plug_notification(Scratch& scratch,
+                                const queuez::SocketPlug& socketPlug,
+                                const state::PendingSocketPlug& mutation,
+                                std::span<const std::byte, state::kAesKeySize> key,
+                                std::span<const std::byte, state::kBapNonceSize> nonce,
+                                std::span<std::byte> response,
+                                std::size_t& written) noexcept;
+
+/** Appends a Family-4 character upsert plus newly acquired item-instance upsert. */
+[[nodiscard]] bool
+append_item_acquisition_notification(Scratch& scratch,
+                                     const queuez::ItemAcquisition& acquisition,
+                                     const state::PendingItemAcquisition& mutation,
+                                     std::span<const std::byte, state::kAesKeySize> key,
+                                     std::span<const std::byte, state::kBapNonceSize> nonce,
+                                     std::span<std::byte> response,
+                                     std::size_t& written) noexcept;
+
+/** Appends one full Family-4 account upsert for a profile-stack acquisition. */
+[[nodiscard]] bool
+append_profile_item_acquisition_notification(Scratch& scratch,
+                                             const queuez::ProfileItemAcquisition& acquisition,
+                                             const state::PendingProfileItemAcquisition& mutation,
+                                             std::span<const std::byte, state::kAesKeySize> key,
+                                             std::span<const std::byte, state::kBapNonceSize> nonce,
+                                             std::span<std::byte> response,
+                                             std::size_t& written) noexcept;
+
+/** Appends a Family-4 character upsert followed by one empty item-instance release. */
+[[nodiscard]] bool
+append_item_dismantle_notification(Scratch& scratch,
+                                   const queuez::ItemDismantle& dismantle,
+                                   const state::PendingItemDismantle& mutation,
+                                   std::span<const std::byte, state::kAesKeySize> key,
+                                   std::span<const std::byte, state::kBapNonceSize> nonce,
+                                   std::span<std::byte> response,
+                                   std::size_t& written) noexcept;
+
 } // namespace push
 
 } // namespace sunrise::server::bap::encrypted

+ 287 - 0
Sunrise/src/server/bap/encrypted/push/queuez/queuez_banner_push.cpp

@@ -3,8 +3,10 @@
 #include <span>
 
 #include "../../../../../core/logging/log.h"
+#include "../../../../../middleware/datagen/definitions.h"
 #include "../../../../../middleware/secure_channel/runtime.h"
 #include "../../../../../state/account/account_state.h"
+#include "../../../../../state/build_data/runtime.h"
 #include "../../../../../state/runtime/runtime.h"
 #include "../../queuez/queuez_state_validation.h"
 #include "../snapshot/snapshot.h"
@@ -34,6 +36,95 @@ void report_skip(const char* reason) noexcept {
     }
 }
 
+/** Validates and appends one same-character Family-0 appearance refresh. */
+[[nodiscard]] bool append_appearance_frame(Scratch& scratch,
+                                           const queuez::CharacterAppearanceRefresh& refresh,
+                                           snapshot::Prepared& prepared,
+                                           const char* stage,
+                                           std::span<const std::byte, state::kAesKeySize> key,
+                                           std::array<std::byte, state::kBapNonceSize>& nonce,
+                                           std::span<std::byte> response,
+                                           std::size_t& written) noexcept {
+    const std::size_t objectCount = prepared.family.objects.size();
+    const std::size_t beforeBytes = written;
+    const bool replacement =
+        objectCount >= 2
+        && prepared.family.objects.front().id == middleware::datagen::kBannerCharacterObjectId
+        && prepared.family.objects.front().version == refresh.characterSoid
+        && prepared.family.objects.front().payload.empty();
+    const std::size_t characterIndex = replacement ? 1U : 0U;
+    const bool hasAnchor = objectCount == characterIndex + 2U;
+    if ((objectCount != characterIndex + 1U && !hasAnchor)
+        || prepared.family.type != queuez::kBannerFamilyType
+        || prepared.family.rootSoid != refresh.after.family4RootSoid
+        || prepared.family.version != refresh.after.family0Version || prepared.family.flags != 0
+        || (replacement
+            && prepared.family.objects.front().encoding != middleware::queuez::Encoding::raw)
+        || prepared.family.objects[characterIndex].id
+               != middleware::datagen::kBannerCharacterObjectId
+        || prepared.family.objects[characterIndex].version != refresh.characterSoid
+        || prepared.family.objects[characterIndex].encoding != middleware::queuez::Encoding::oodle
+        || prepared.family.objects[characterIndex].payload.empty()
+        || (hasAnchor
+            && (prepared.family.objects.back().id != middleware::datagen::kBannerAnchorObjectId
+                || prepared.family.objects.back().version != refresh.after.family4RootSoid
+                || prepared.family.objects.back().encoding != middleware::queuez::Encoding::oodle
+                || prepared.family.objects.back().payload.empty()))
+        || !queuez_frame::append(scratch,
+                                 prepared.family,
+                                 prepared.rawClearSize,
+                                 prepared.compressedClearSize,
+                                 key,
+                                 nonce,
+                                 response,
+                                 written)) {
+        return false;
+    }
+    middleware::secure_channel::advance_nonce(nonce);
+    queuez_report::push(stage, queuez::kBannerFamilyType, objectCount, written - beforeBytes, 1);
+    return true;
+}
+
+/** Validates and appends one incremental Family-3 appearance frame. */
+[[nodiscard]] bool
+append_roster_appearance_frame(Scratch& scratch,
+                               const queuez::RosterAppearanceRefresh& refresh,
+                               snapshot::Prepared& prepared,
+                               const char* stage,
+                               std::span<const std::byte, state::kAesKeySize> key,
+                               std::array<std::byte, state::kBapNonceSize>& nonce,
+                               std::span<std::byte> response,
+                               std::size_t& written) noexcept {
+    const std::size_t expectedObjects = refresh.includeRoster ? 2U : 1U;
+    const std::size_t objectCount = prepared.family.objects.size();
+    const std::size_t beforeBytes = written;
+    if (objectCount != expectedObjects || prepared.family.type != queuez::kRosterFamilyType
+        || prepared.family.rootSoid != refresh.after.family3RootSoid
+        || prepared.family.version != refresh.after.family3Version || prepared.family.flags != 0
+        || prepared.family.objects.front().id != middleware::datagen::kRosterCharacterObjectId
+        || prepared.family.objects.front().version != refresh.characterSoid
+        || prepared.family.objects.front().encoding != middleware::queuez::Encoding::oodle
+        || prepared.family.objects.front().payload.empty()
+        || (refresh.includeRoster
+            && (prepared.family.objects.back().id != middleware::datagen::kRosterObjectId
+                || prepared.family.objects.back().version != refresh.after.family3RootSoid
+                || prepared.family.objects.back().encoding != middleware::queuez::Encoding::oodle
+                || prepared.family.objects.back().payload.empty()))
+        || !queuez_frame::append(scratch,
+                                 prepared.family,
+                                 prepared.rawClearSize,
+                                 prepared.compressedClearSize,
+                                 key,
+                                 nonce,
+                                 response,
+                                 written)) {
+        return false;
+    }
+    middleware::secure_channel::advance_nonce(nonce);
+    queuez_report::push(stage, queuez::kRosterFamilyType, objectCount, written - beforeBytes, 1);
+    return true;
+}
+
 } // namespace
 
 /**
@@ -188,4 +279,200 @@ bool append_banner_move_notification(Scratch& scratch,
     return true;
 }
 
+/** Appends one same-character Family-0 appearance-record upsert after an equipment swap. */
+bool append_equipment_appearance_refresh_notification(
+    Scratch& scratch,
+    const queuez::CharacterAppearanceRefresh& refresh,
+    const state::PendingEquipmentSwap& mutation,
+    std::span<const std::byte, state::kAesKeySize> key,
+    std::array<std::byte, state::kBapNonceSize>& nonce,
+    std::span<std::byte> response,
+    std::size_t& written) noexcept {
+    if (!mutation.prepared || mutation.characterSoid != refresh.characterSoid) {
+        return false;
+    }
+    snapshot::Prepared prepared{};
+    if (!snapshot::prepare_character_appearance_refresh(scratch,
+                                                        refresh,
+                                                        mutation.afterCharacter,
+                                                        mutation.characterIndex,
+                                                        mutation.nativeEquipmentSlot,
+                                                        false,
+                                                        prepared)) {
+        return false;
+    }
+    return append_appearance_frame(
+        scratch, refresh, prepared, "equip_appearance", key, nonce, response, written);
+}
+
+/** Appends one Family-0 record upsert after a socket change on an equipped item. */
+bool append_socket_appearance_refresh_notification(
+    Scratch& scratch,
+    const queuez::CharacterAppearanceRefresh& refresh,
+    const state::PendingSocketPlug& mutation,
+    std::span<const std::byte, state::kAesKeySize> key,
+    std::array<std::byte, state::kBapNonceSize>& nonce,
+    std::span<std::byte> response,
+    std::size_t& written) noexcept {
+    if (!mutation.prepared || !mutation.targetEquipped
+        || mutation.characterSoid != refresh.characterSoid
+        || mutation.itemIndex >= mutation.afterCharacter.equipment.slots.size()
+        || !mutation.afterCharacter.equipment.slots[mutation.itemIndex].has_value()) {
+        return false;
+    }
+    const state::account::inventory::Item& target =
+        *mutation.afterCharacter.equipment.slots[mutation.itemIndex];
+    if (target.instanceSoid != mutation.targetInstanceSoid) {
+        return false;
+    }
+    state::build_data::items::details::Definition detail{};
+    if (!state::build_data::find_configured_item_detail(mutation.targetDefinitionIndex, detail)
+        || detail.definitionIndex != mutation.targetDefinitionIndex
+        || detail.definitionHash != mutation.targetDefinitionHash
+        || detail.bucketId != mutation.targetBucketId || !detail.equipmentSlot.has_value()
+        || *detail.equipmentSlot < 0
+        || static_cast<std::size_t>(*detail.equipmentSlot)
+               >= state::build_data::items::details::kEquipmentSlotCount) {
+        return false;
+    }
+    snapshot::Prepared prepared{};
+    if (!snapshot::prepare_character_appearance_refresh(
+            scratch,
+            refresh,
+            mutation.afterCharacter,
+            mutation.characterIndex,
+            static_cast<std::uint8_t>(*detail.equipmentSlot),
+            true,
+            prepared)) {
+        return false;
+    }
+    return append_appearance_frame(
+        scratch, refresh, prepared, "socket_appearance", key, nonce, response, written);
+}
+
+/** Appends the Family-3 character-then-roster refresh owed by one equipment mutation. */
+bool append_equipment_roster_refresh_notification(
+    Scratch& scratch,
+    const queuez::RosterAppearanceRefresh& refresh,
+    const state::PendingEquipmentSwap& mutation,
+    std::span<const std::byte, state::kAesKeySize> key,
+    std::array<std::byte, state::kBapNonceSize>& nonce,
+    std::span<std::byte> response,
+    std::size_t& written) noexcept {
+    if (!mutation.prepared || !refresh.includeRoster
+        || mutation.characterSoid != refresh.characterSoid) {
+        return false;
+    }
+    snapshot::Prepared prepared{};
+    if (!snapshot::prepare_roster_appearance_refresh(
+            scratch, refresh, mutation.afterCharacter, mutation.characterIndex, prepared)) {
+        return false;
+    }
+    return append_roster_appearance_frame(
+        scratch, refresh, prepared, "equip_roster", key, nonce, response, written);
+}
+
+/** Appends the Family-3 character-only refresh owed by a socket change on equipped gear. */
+bool append_socket_roster_refresh_notification(Scratch& scratch,
+                                               const queuez::RosterAppearanceRefresh& refresh,
+                                               const state::PendingSocketPlug& mutation,
+                                               std::span<const std::byte, state::kAesKeySize> key,
+                                               std::array<std::byte, state::kBapNonceSize>& nonce,
+                                               std::span<std::byte> response,
+                                               std::size_t& written) noexcept {
+    if (!mutation.prepared || !mutation.targetEquipped || refresh.includeRoster
+        || mutation.characterSoid != refresh.characterSoid
+        || mutation.itemIndex >= mutation.afterCharacter.equipment.slots.size()
+        || !mutation.afterCharacter.equipment.slots[mutation.itemIndex].has_value()
+        || mutation.afterCharacter.equipment.slots[mutation.itemIndex]->instanceSoid
+               != mutation.targetInstanceSoid) {
+        return false;
+    }
+    snapshot::Prepared prepared{};
+    if (!snapshot::prepare_roster_appearance_refresh(
+            scratch, refresh, mutation.afterCharacter, mutation.characterIndex, prepared)) {
+        return false;
+    }
+    return append_roster_appearance_frame(
+        scratch, refresh, prepared, "socket_roster", key, nonce, response, written);
+}
+
+/** Refreshes the selected character's complete Family-0 appearance from committed State. */
+bool append_account_resync_appearance_notification(
+    Scratch& scratch,
+    const queuez::SessionState& before,
+    std::span<const std::byte, state::kAesKeySize> key,
+    std::array<std::byte, state::kBapNonceSize>& nonce,
+    std::span<std::byte> response,
+    std::size_t& written,
+    queuez::SessionState& after) noexcept {
+    after = before;
+    if (!before.family0Active) {
+        return true;
+    }
+    const state::AccountState account = state::account_snapshot();
+    const std::uint64_t selected = state::account::selected_character_soid(account);
+    if (selected == 0) {
+        return false;
+    }
+    if (before.family0Character != selected) {
+        return append_banner_move_notification(
+            scratch, before, selected, key, nonce, response, written, after);
+    }
+    std::size_t characterIndex = account.characterCount;
+    for (std::size_t index = 0; index < account.characterCount; ++index) {
+        if (account.characters[index].soid == selected) {
+            characterIndex = index;
+            break;
+        }
+    }
+    queuez::CharacterAppearanceRefresh refresh{};
+    snapshot::Prepared prepared{};
+    if (characterIndex >= account.characterCount
+        || !queuez::stage_character_appearance_refresh(before, selected, refresh)
+        || !snapshot::prepare_character_appearance_refresh(
+            scratch, refresh, account.characters[characterIndex], characterIndex, 0, true, prepared)
+        || !append_appearance_frame(
+            scratch, refresh, prepared, "peer_resync_appearance", key, nonce, response, written)) {
+        return false;
+    }
+    after = refresh.after;
+    return true;
+}
+
+/** Refreshes the selected character and its account roster from committed State. */
+bool append_account_resync_roster_notification(Scratch& scratch,
+                                               const queuez::SessionState& before,
+                                               std::span<const std::byte, state::kAesKeySize> key,
+                                               std::array<std::byte, state::kBapNonceSize>& nonce,
+                                               std::span<std::byte> response,
+                                               std::size_t& written,
+                                               queuez::SessionState& after) noexcept {
+    after = before;
+    if (!before.family3Active) {
+        return true;
+    }
+    const state::AccountState account = state::account_snapshot();
+    const std::uint64_t selected = state::account::selected_character_soid(account);
+    std::size_t characterIndex = account.characterCount;
+    for (std::size_t index = 0; index < account.characterCount; ++index) {
+        if (account.characters[index].soid == selected) {
+            characterIndex = index;
+            break;
+        }
+    }
+    queuez::RosterAppearanceRefresh refresh{};
+    snapshot::Prepared prepared{};
+    if (selected == 0 || characterIndex >= account.characterCount
+        || !queuez::stage_roster_appearance_refresh(before, selected, true, refresh)
+        || !snapshot::prepare_roster_appearance_refresh(
+            scratch, refresh, account.characters[characterIndex], characterIndex, prepared)
+        || !append_roster_appearance_frame(
+            scratch, refresh, prepared, "peer_resync_roster", key, nonce, response, written)) {
+        return false;
+    }
+    after = refresh.after;
+    return true;
+}
+
 } // namespace sunrise::server::bap::encrypted::push

+ 255 - 0
Sunrise/src/server/bap/encrypted/push/queuez/queuez_select_character.cpp

@@ -1,3 +1,7 @@
+#include <array>
+#include <cstdio>
+
+#include "../../../../../core/logging/log.h"
 #include "../../queuez/queuez_state_validation.h"
 #include "../snapshot/internal.h"
 #include "queuez_push_reporting.h"
@@ -42,4 +46,255 @@ bool append_select_character_notification(Scratch& scratch,
     return true;
 }
 
+/** Appends the opcode-403 character upsert as one increment above the current peer version. */
+bool append_equipment_swap_notification(Scratch& scratch,
+                                        const queuez::EquipmentSwap& swap,
+                                        const state::PendingEquipmentSwap& mutation,
+                                        std::span<const std::byte, state::kAesKeySize> key,
+                                        std::span<const std::byte, state::kBapNonceSize> nonce,
+                                        std::span<std::byte> response,
+                                        std::size_t& written) noexcept {
+    snapshot::Prepared prepared{};
+    if (!snapshot::prepare_equipment_swap(scratch, swap, mutation, prepared)) {
+        return false;
+    }
+    const std::size_t objectCount = prepared.family.objects.size();
+    if (objectCount == 1) {
+        const middleware::queuez::Object& object = prepared.family.objects.front();
+        std::array<char, core::log::kLineCapacity> line{};
+        const int count =
+            std::snprintf(line.data(),
+                          line.size(),
+                          "ev=equip stage=queuez_object result=ok family_version=%d object_id=%u "
+                          "object_version=0x%llX encoding=%u payload_bytes=%zu",
+                          prepared.family.version,
+                          object.id,
+                          static_cast<unsigned long long>(object.version),
+                          static_cast<unsigned>(object.encoding),
+                          object.payload.size());
+        if (count > 0) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::debug,
+                             {line.data(), static_cast<std::size_t>(count)});
+        }
+    }
+    const std::size_t beforeBytes = written;
+    if (!queuez_frame::append(scratch,
+                              prepared.family,
+                              prepared.rawClearSize,
+                              prepared.compressedClearSize,
+                              key,
+                              nonce,
+                              response,
+                              written)) {
+        return false;
+    }
+    queuez_report::push("equip", queuez::kAccountFamilyType, objectCount, written - beforeBytes, 1);
+    return true;
+}
+
+/** Appends one opcode-406 selected-character item-state upsert. */
+bool append_item_state_notification(Scratch& scratch,
+                                    const queuez::EquipmentSwap& update,
+                                    const state::PendingItemState& mutation,
+                                    std::span<const std::byte, state::kAesKeySize> key,
+                                    std::span<const std::byte, state::kBapNonceSize> nonce,
+                                    std::span<std::byte> response,
+                                    std::size_t& written) noexcept {
+    snapshot::Prepared prepared{};
+    if (!snapshot::prepare_item_state(scratch, update, mutation, prepared)) {
+        return false;
+    }
+    const std::size_t objectCount = prepared.family.objects.size();
+    const std::size_t beforeBytes = written;
+    if (objectCount != 1 || prepared.family.objects.front().id != update.characterDefinitionId
+        || prepared.family.objects.front().version != update.characterSoid
+        || prepared.family.objects.front().encoding != middleware::queuez::Encoding::oodle
+        || prepared.family.objects.front().payload.empty()
+        || !queuez_frame::append(scratch,
+                                 prepared.family,
+                                 prepared.rawClearSize,
+                                 prepared.compressedClearSize,
+                                 key,
+                                 nonce,
+                                 response,
+                                 written)) {
+        return false;
+    }
+    queuez_report::push(
+        "item_state", queuez::kAccountFamilyType, objectCount, written - beforeBytes, 1);
+    return true;
+}
+
+/** Appends one socket item upsert and its charged account balances when required. */
+bool append_socket_plug_notification(Scratch& scratch,
+                                     const queuez::SocketPlug& socketPlug,
+                                     const state::PendingSocketPlug& mutation,
+                                     std::span<const std::byte, state::kAesKeySize> key,
+                                     std::span<const std::byte, state::kBapNonceSize> nonce,
+                                     std::span<std::byte> response,
+                                     std::size_t& written) noexcept {
+    snapshot::Prepared prepared{};
+    if (!snapshot::prepare_socket_plug(scratch, socketPlug, mutation, prepared)) {
+        return false;
+    }
+    const std::size_t objectCount = prepared.family.objects.size();
+    const std::size_t expectedObjectCount = socketPlug.updatesAccount ? 2U : 1U;
+    const std::size_t beforeBytes = written;
+    if (objectCount != expectedObjectCount
+        || prepared.family.objects.front().id != socketPlug.itemInstanceDefinitionId
+        || prepared.family.objects.front().version != socketPlug.targetInstanceSoid
+        || prepared.family.objects.front().encoding != middleware::queuez::Encoding::oodle
+        || prepared.family.objects.front().payload.empty()
+        || (socketPlug.updatesAccount
+            && (prepared.family.objects[1].id != socketPlug.accountDefinitionId
+                || prepared.family.objects[1].version != socketPlug.accountSoid
+                || prepared.family.objects[1].encoding != middleware::queuez::Encoding::oodle
+                || prepared.family.objects[1].payload.empty()))
+        || !queuez_frame::append(scratch,
+                                 prepared.family,
+                                 prepared.rawClearSize,
+                                 prepared.compressedClearSize,
+                                 key,
+                                 nonce,
+                                 response,
+                                 written)) {
+        return false;
+    }
+    queuez_report::push(
+        "socket_plug", queuez::kAccountFamilyType, objectCount, written - beforeBytes, 1);
+    return true;
+}
+
+/** Appends one atomic new-instance-before-character Family-4 acquisition update. */
+bool append_item_acquisition_notification(Scratch& scratch,
+                                          const queuez::ItemAcquisition& acquisition,
+                                          const state::PendingItemAcquisition& mutation,
+                                          std::span<const std::byte, state::kAesKeySize> key,
+                                          std::span<const std::byte, state::kBapNonceSize> nonce,
+                                          std::span<std::byte> response,
+                                          std::size_t& written) noexcept {
+    snapshot::Prepared prepared{};
+    if (!snapshot::prepare_item_acquisition(scratch, acquisition, mutation, prepared)) {
+        return false;
+    }
+    const std::size_t objectCount = prepared.family.objects.size();
+    const std::size_t beforeBytes = written;
+    const std::size_t expectedObjectCount = acquisition.updatesAccount ? 3U : 2U;
+    if (objectCount != expectedObjectCount
+        || prepared.family.objects[0].id != acquisition.itemInstanceDefinitionId
+        || prepared.family.objects[0].version != acquisition.acquiredInstanceSoid
+        || prepared.family.objects[0].encoding != middleware::queuez::Encoding::oodle
+        || prepared.family.objects[0].payload.empty()
+        || prepared.family.objects[1].id != acquisition.characterDefinitionId
+        || prepared.family.objects[1].version != acquisition.characterSoid
+        || prepared.family.objects[1].encoding != middleware::queuez::Encoding::oodle
+        || prepared.family.objects[1].payload.empty()
+        || (acquisition.updatesAccount
+            && (prepared.family.objects[2].id != acquisition.accountDefinitionId
+                || prepared.family.objects[2].version != acquisition.accountSoid
+                || prepared.family.objects[2].encoding != middleware::queuez::Encoding::oodle
+                || prepared.family.objects[2].payload.empty()))
+        || !queuez_frame::append(scratch,
+                                 prepared.family,
+                                 prepared.rawClearSize,
+                                 prepared.compressedClearSize,
+                                 key,
+                                 nonce,
+                                 response,
+                                 written)) {
+        return false;
+    }
+    queuez_report::push(
+        "acquire", queuez::kAccountFamilyType, objectCount, written - beforeBytes, 1);
+    return true;
+}
+
+/** Appends an optional new profile resident followed by the full account after-image. */
+bool append_profile_item_acquisition_notification(
+    Scratch& scratch,
+    const queuez::ProfileItemAcquisition& acquisition,
+    const state::PendingProfileItemAcquisition& mutation,
+    std::span<const std::byte, state::kAesKeySize> key,
+    std::span<const std::byte, state::kBapNonceSize> nonce,
+    std::span<std::byte> response,
+    std::size_t& written) noexcept {
+    snapshot::Prepared prepared{};
+    if (!snapshot::prepare_profile_item_acquisition(scratch, acquisition, mutation, prepared)) {
+        return false;
+    }
+    const std::size_t objectCount = prepared.family.objects.size();
+    const std::size_t beforeBytes = written;
+    const std::size_t expectedObjectCount = acquisition.appendedResident ? 2U : 1U;
+    const std::size_t accountIndex = acquisition.appendedResident ? 1U : 0U;
+    if (prepared.family.type != queuez::kAccountFamilyType
+        || prepared.family.rootSoid != acquisition.after.family4RootSoid
+        || prepared.family.version != acquisition.after.family4Version || prepared.family.flags != 0
+        || objectCount != expectedObjectCount || accountIndex >= objectCount
+        || prepared.family.objects[accountIndex].id != acquisition.accountDefinitionId
+        || prepared.family.objects[accountIndex].version != acquisition.accountSoid
+        || prepared.family.objects[accountIndex].encoding != middleware::queuez::Encoding::oodle
+        || prepared.family.objects[accountIndex].payload.empty()
+        || (acquisition.appendedResident
+            && (prepared.family.objects.front().id != acquisition.itemInstanceDefinitionId
+                || prepared.family.objects.front().version != acquisition.acquiredInstanceSoid
+                || prepared.family.objects.front().encoding != middleware::queuez::Encoding::oodle
+                || prepared.family.objects.front().payload.empty()))
+        || !queuez_frame::append(scratch,
+                                 prepared.family,
+                                 prepared.rawClearSize,
+                                 prepared.compressedClearSize,
+                                 key,
+                                 nonce,
+                                 response,
+                                 written)) {
+        return false;
+    }
+    queuez_report::push(
+        "profile_acquire", queuez::kAccountFamilyType, objectCount, written - beforeBytes, 1);
+    return true;
+}
+
+/** Appends one atomic dismantle update, including any account-wide material payout. */
+bool append_item_dismantle_notification(Scratch& scratch,
+                                        const queuez::ItemDismantle& dismantle,
+                                        const state::PendingItemDismantle& mutation,
+                                        std::span<const std::byte, state::kAesKeySize> key,
+                                        std::span<const std::byte, state::kBapNonceSize> nonce,
+                                        std::span<std::byte> response,
+                                        std::size_t& written) noexcept {
+    snapshot::Prepared prepared{};
+    if (!snapshot::prepare_item_dismantle(scratch, dismantle, mutation, prepared)) {
+        return false;
+    }
+    const std::size_t objectCount = prepared.family.objects.size();
+    const std::size_t beforeBytes = written;
+    const std::size_t expectedObjectCount = dismantle.updatesAccount ? 3U : 2U;
+    if (objectCount != expectedObjectCount
+        || prepared.family.objects[0].id != dismantle.characterDefinitionId
+        || prepared.family.objects[0].version != dismantle.characterSoid
+        || prepared.family.objects[1].id != dismantle.itemInstanceDefinitionId
+        || prepared.family.objects[1].version != dismantle.dismantledInstanceSoid
+        || prepared.family.objects[1].encoding != middleware::queuez::Encoding::oodle
+        || !prepared.family.objects[1].payload.empty()
+        || (dismantle.updatesAccount
+            && (prepared.family.objects[2].id != dismantle.accountDefinitionId
+                || prepared.family.objects[2].version != dismantle.accountSoid
+                || prepared.family.objects[2].encoding != middleware::queuez::Encoding::oodle
+                || prepared.family.objects[2].payload.empty()))
+        || !queuez_frame::append(scratch,
+                                 prepared.family,
+                                 prepared.rawClearSize,
+                                 prepared.compressedClearSize,
+                                 key,
+                                 nonce,
+                                 response,
+                                 written)) {
+        return false;
+    }
+    queuez_report::push(
+        "dismantle", queuez::kAccountFamilyType, objectCount, written - beforeBytes, 1);
+    return true;
+}
+
 } // namespace sunrise::server::bap::encrypted::push

+ 53 - 1
Sunrise/src/server/bap/encrypted/push/queuez/queuez_subscription.cpp

@@ -1,6 +1,8 @@
 #include <array>
+#include <limits>
 
 #include "../../../../../core/logging/log.h"
+#include "../../../../../middleware/datagen/definitions.h"
 #include "../../../../../middleware/secure_channel/runtime.h"
 #include "../../../../../state/runtime/runtime.h"
 #include "../../queuez/queuez_state_validation.h"
@@ -79,6 +81,44 @@ namespace {
 
 } // namespace
 
+/** Appends one current full account snapshot at the peer's next Family-4 version. */
+bool append_account_resync_notification(Scratch& scratch,
+                                        const queuez::SessionState& before,
+                                        std::span<const std::byte, state::kAesKeySize> key,
+                                        std::array<std::byte, state::kBapNonceSize>& nonce,
+                                        std::span<std::byte> response,
+                                        std::size_t& written,
+                                        queuez::SessionState& after) noexcept {
+    after = before;
+    if (!queuez::valid(before) || !before.family4Active || before.family4RootSoid == 0
+        || before.family4Version == (std::numeric_limits<std::int32_t>::max)()) {
+        return false;
+    }
+    snapshot::Prepared prepared{};
+    if (!snapshot::prepare_family4_refresh(
+            scratch, before.family4RootSoid, before.family4Version + 1, prepared)
+        || !queuez::stage_family4_refresh(before, prepared.family, after)) {
+        return false;
+    }
+    const std::size_t objectCount = prepared.family.objects.size();
+    const std::size_t beforeBytes = written;
+    if (!queuez_frame::append(scratch,
+                              prepared.family,
+                              prepared.rawClearSize,
+                              prepared.compressedClearSize,
+                              key,
+                              nonce,
+                              response,
+                              written)) {
+        after = before;
+        return false;
+    }
+    middleware::secure_channel::advance_nonce(nonce);
+    queuez_report::push(
+        "peer_resync", queuez::kAccountFamilyType, objectCount, written - beforeBytes, 1);
+    return true;
+}
+
 /**
  * Stages the snapshots one subscription needs.
  * Every step reports and continues. The subscribe is answered whether or not a frame is built.
@@ -120,7 +160,9 @@ void append_queuez_notification(Scratch& scratch,
         && !queuez::stage_family3_subscription(before, subscription, publish, stagedAfter)) {
         queuez_report::subscription_state("stage_family3");
         stagedAfter = before;
-        publish = true;
+        // A failed mirror check must never send a version-zero roster into an active incremental
+        // ladder. The correlated subscription response still goes out without a stale snapshot.
+        publish = false;
     }
 
     snapshot::Prepared prepared{};
@@ -184,6 +226,16 @@ void append_queuez_notification(Scratch& scratch,
         after = stagedAfter;
         return;
     }
+    if (subscription.familyType == queuez::kRosterFamilyType
+        && (!stagedAfter.family3Active || stagedAfter.family3RootSoid != subscription.familyRootSoid
+            || stagedAfter.family3Version != queuez::kInitialFamilyVersion
+            || prepared.family.type != queuez::kRosterFamilyType
+            || prepared.family.rootSoid != stagedAfter.family3RootSoid
+            || prepared.family.version != stagedAfter.family3Version
+            || prepared.family.flags != middleware::queuez::kFullSnapshotFlag)) {
+        queuez_report::subscription_failure("family3_ladder");
+        return;
+    }
     const std::size_t objectCount = prepared.family.objects.size();
     const std::size_t beforeBytes = written;
     if (!queuez_frame::append(scratch,

+ 137 - 0
Sunrise/src/server/bap/encrypted/push/snapshot/banner_snapshot.cpp

@@ -1,6 +1,9 @@
 #include <algorithm>
+#include <array>
+#include <cstdio>
 #include <span>
 
+#include "../../../../../core/logging/log.h"
 #include "../../../../../middleware/datagen/character_record/character_record_encoder.h"
 #include "../../../../../middleware/datagen/definitions.h"
 #include "../../../../../middleware/datagen/family4/loadout/loadout_resolver.h"
@@ -125,4 +128,138 @@ bool prepare_banner(Scratch& scratch,
     return commit(staged, prepared);
 }
 
+/** Builds one in-place Family-0 character-record upsert from an uncommitted equipment after-image.
+ */
+bool prepare_character_appearance_refresh(Scratch& scratch,
+                                          const queuez::CharacterAppearanceRefresh& refresh,
+                                          const state::CharacterState& afterCharacter,
+                                          std::size_t characterIndex,
+                                          std::uint8_t nativeEquipmentSlot,
+                                          bool replaceCharacterRecord,
+                                          Prepared& prepared) noexcept {
+    const Reservation reservation = reserve_prior(scratch, prepared);
+    if (reservation.rawWriteOffset > scratch.plaintext.size()
+        || reservation.compressedWriteOffset > scratch.sealed.size()) {
+        return report_failure("equip_appearance_reservation");
+    }
+
+    state::AccountState account = state::account_snapshot();
+    if (refresh.characterSoid == 0 || afterCharacter.soid != refresh.characterSoid
+        || characterIndex >= account.characterCount
+        || account.characters[characterIndex].soid != refresh.characterSoid
+        || !refresh.after.family0Active || refresh.after.family0Character != refresh.characterSoid
+        || refresh.after.family4RootSoid == 0
+        || account.primarySoid != refresh.after.family4RootSoid) {
+        return report_failure("equip_appearance_mutation");
+    }
+    account.characters[characterIndex] = afterCharacter;
+    if (!state::account::valid(account)
+        || state::account::selected_character_soid(account) != refresh.characterSoid) {
+        return report_failure("equip_appearance_selection");
+    }
+
+    middleware::datagen::family4::loadout::ResolvedInstances instances{};
+    std::int32_t light = 0;
+    if (!middleware::datagen::family4::loadout::resolve_instances(
+            account, characterIndex, instances)
+        || !state::equipment::light::resolution::character_light(account, characterIndex, light)) {
+        return report_failure("equip_appearance_resolve");
+    }
+
+    // The banner-facing emblem consumers bind through the Family-0 anchor rather than directly
+    // observing the character record.  A normal equipment refresh can upsert the resident record
+    // alone, but an emblem move must touch the unchanged anchor as well so those consumers are
+    // dirtied without releasing either resident key.
+    constexpr std::uint8_t kEmblemEquipmentSlot = 13;
+    const bool refreshAnchor = nativeEquipmentSlot == kEmblemEquipmentSlot;
+    const std::size_t anchorSize = refreshAnchor ? character_record::kFamily0AnchorSize : 0U;
+    const std::size_t rawSize = anchorSize + character_record::kFamily0RecordSize;
+    const auto rawStorage = std::span(scratch.plaintext).subspan(reservation.rawWriteOffset);
+    if (rawSize > rawStorage.size()) {
+        return report_failure("equip_appearance_storage");
+    }
+    const auto anchor = rawStorage.first(anchorSize);
+    const auto record = rawStorage.subspan(anchorSize, character_record::kFamily0RecordSize);
+    if ((refreshAnchor
+         && !character_record::encode_family0_anchor(
+             account.primarySoid, refresh.characterSoid, anchor))
+        || !character_record::encode_family0(afterCharacter, instances, light, record)) {
+        return report_failure("equip_appearance_encode");
+    }
+
+    Prepared staged{};
+    staged.rawClearSize =
+        (std::max)(reservation.rawClearSize, reservation.rawWriteOffset + rawSize);
+    std::size_t compressedExtent = reservation.compressedWriteOffset;
+    std::size_t objectCount = 0;
+    if (replaceCharacterRecord) {
+        staged.objects[objectCount++] = middleware::queuez::Object{
+            middleware::datagen::kBannerCharacterObjectId,
+            refresh.characterSoid,
+            middleware::queuez::Encoding::raw,
+            {},
+        };
+    }
+    if (!append_object(scratch,
+                       record,
+                       middleware::datagen::kBannerCharacterObjectId,
+                       refresh.characterSoid,
+                       staged.objects[objectCount++],
+                       compressedExtent)) {
+        clear_after(scratch, reservation);
+        return report_failure("equip_appearance_object");
+    }
+    // On an incremental refresh the record is already resident.  Publish its new body first and
+    // touch the anchor second, so an anchor-driven banner observer resolves the new emblem rather
+    // than the prior record during the same family update.
+    if (refreshAnchor
+        && !append_object(scratch,
+                          anchor,
+                          middleware::datagen::kBannerAnchorObjectId,
+                          account.primarySoid,
+                          staged.objects[objectCount++],
+                          compressedExtent)) {
+        clear_after(scratch, reservation);
+        return report_failure("equip_appearance_anchor");
+    }
+    staged.compressedClearSize = (std::max)(reservation.compressedClearSize, compressedExtent);
+    staged.family = middleware::queuez::Family{
+        middleware::datagen::kBannerFamily,
+        refresh.after.family4RootSoid,
+        refresh.after.family0Version,
+        0,
+        std::span(staged.objects).first(objectCount),
+    };
+    if (!commit(staged, prepared)) {
+        clear_after(scratch, reservation);
+        return report_failure("equip_appearance_commit");
+    }
+
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count = std::snprintf(
+        line.data(),
+        line.size(),
+        "ev=equip stage=family0_object result=ok family_version=%d root=0x%llX "
+        "definition=%u character=0x%llX native_slot=%u items=%zu light=%d flags=0 objects=%zu "
+        "anchor=%u replace=%u order=%s",
+        refresh.after.family0Version,
+        static_cast<unsigned long long>(refresh.after.family4RootSoid),
+        middleware::datagen::kBannerCharacterObjectId,
+        static_cast<unsigned long long>(refresh.characterSoid),
+        static_cast<unsigned>(nativeEquipmentSlot),
+        instances.itemCount,
+        light,
+        objectCount,
+        refreshAnchor ? 1U : 0U,
+        replaceCharacterRecord ? 1U : 0U,
+        replaceCharacterRecord ? (refreshAnchor ? "release_character_anchor" : "release_character")
+                               : (refreshAnchor ? "character_anchor" : "character"));
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+    return true;
+}
+
 } // namespace sunrise::server::bap::encrypted::push::snapshot

+ 637 - 0
Sunrise/src/server/bap/encrypted/push/snapshot/family4_inventory_updates.cpp

@@ -0,0 +1,637 @@
+#include <algorithm>
+#include <array>
+#include <cstddef>
+#include <cstdio>
+#include <optional>
+#include <span>
+
+#include "../../../../../core/logging/log.h"
+#include "../../../../../middleware/datagen/family4/account/account_encoder.h"
+#include "../../../../../middleware/datagen/family4/account/layout.h"
+#include "../../../../../middleware/datagen/family4/character/character_encoder.h"
+#include "../../../../../middleware/datagen/family4/character/layout.h"
+#include "../../../../../middleware/datagen/family4/instance/instance_encoder.h"
+#include "../../../../../middleware/datagen/family4/instance/layout.h"
+#include "../../../../../state/runtime/runtime.h"
+#include "internal.h"
+#include "snapshot_storage.h"
+
+namespace sunrise::server::bap::encrypted::push::snapshot {
+
+namespace family4_datagen = middleware::datagen::family4;
+
+/** Builds a single full account-object upsert from an uncommitted profile-stack after-image. */
+bool prepare_profile_item_acquisition(Scratch& scratch,
+                                      const queuez::ProfileItemAcquisition& acquisition,
+                                      const state::PendingProfileItemAcquisition& mutation,
+                                      Prepared& prepared) noexcept {
+    const Reservation reservation = reserve_prior(scratch, prepared);
+    if (reservation.rawWriteOffset > scratch.plaintext.size()
+        || reservation.compressedWriteOffset > scratch.sealed.size()) {
+        return report_failure("profile_acquire_reservation");
+    }
+    if (!mutation.prepared || mutation.accountSoid == 0
+        || mutation.accountSoid != acquisition.accountSoid
+        || mutation.acquiredInstanceSoid != acquisition.acquiredInstanceSoid
+        || mutation.actionSource != acquisition.actionSource
+        || acquisition.appendedResident != (mutation.appended && mutation.actionSource)
+        || acquisition.accountSoid != acquisition.after.family4RootSoid
+        || acquisition.accountDefinitionId == 0
+        || (acquisition.appendedResident && acquisition.itemInstanceDefinitionId == 0)) {
+        return report_failure("profile_acquire_mutation");
+    }
+    state::AccountState account{};
+    if (!state::preview_profile_item_acquisition(mutation, account)
+        || account.primarySoid != acquisition.accountSoid || !state::account::valid(account)) {
+        return report_failure("profile_acquire_account");
+    }
+
+    const auto rawStorage = std::span(scratch.plaintext).subspan(reservation.rawWriteOffset);
+    if (family4_datagen::account::layout::kObjectSize > rawStorage.size()) {
+        return report_failure("profile_acquire_account_storage");
+    }
+    const auto accountBytes = rawStorage.first(family4_datagen::account::layout::kObjectSize);
+    if (!family4_datagen::account::encode(account, accountBytes)) {
+        return report_failure("profile_acquire_account_encode");
+    }
+
+    // The native account observer compares profile quantities but only emits pickup feedback when
+    // the changed row's mutation serial also appears in this transient 16-record bank at 0x6978.
+    // Keep the descriptor local to this one incremental upsert; ordinary snapshots encode an empty
+    // bank, while the row's rising mutation serial remains persistent State.
+    constexpr std::uint16_t kAcquisitionChangeSequence = 0;
+    constexpr std::uint16_t kAcquisitionChangeNextWriteSlot = 1;
+    constexpr std::uint16_t kAcquisitionChangeNextSequence = 1;
+    constexpr std::uint8_t kAcquisitionChangeKind = 1;
+    constexpr std::uint16_t kAcquisitionChangeFlags = 0;
+    auto& accountObject =
+        *reinterpret_cast<family4_datagen::account::layout::Object*>(accountBytes.data());
+    std::size_t acquiredRow = accountObject.profileItems.size();
+    for (std::size_t row = 0; row < accountObject.profileItems.size(); ++row) {
+        const auto& inventoryRow = accountObject.profileItems[row];
+        if (inventoryRow.mutationSerial != mutation.acquiredMutationSerial) {
+            continue;
+        }
+        if (acquiredRow != accountObject.profileItems.size()) {
+            clear_after(scratch, reservation);
+            return report_failure("profile_acquire_row_duplicate");
+        }
+        acquiredRow = row;
+    }
+    const auto recordIsZero =
+        [](const family4_datagen::account::layout::ProfileInventoryChangeRecord& record) noexcept {
+            return record.sequence == 0 && record.reserved == 0 && record.mutationSerial == 0
+                   && record.kind == 0 && record.reservedKind == 0 && record.flags == 0;
+        };
+    const bool recordsAreZero = std::all_of(accountObject.profileInventoryChanges.records.cbegin(),
+                                            accountObject.profileInventoryChanges.records.cend(),
+                                            recordIsZero);
+    if (acquiredRow >= accountObject.profileItems.size()
+        || accountObject.profileItems[acquiredRow].quantity != mutation.acquiredQuantity
+        || accountObject.profileInventoryChanges.writeSlot != 0
+        || accountObject.profileInventoryChanges.nextSequence != 0 || !recordsAreZero) {
+        clear_after(scratch, reservation);
+        return report_failure("profile_acquire_inventory_change_state");
+    }
+    accountObject.profileInventoryChanges.writeSlot = kAcquisitionChangeNextWriteSlot;
+    accountObject.profileInventoryChanges.nextSequence = kAcquisitionChangeNextSequence;
+    auto& acquisitionChange = accountObject.profileInventoryChanges.records.front();
+    acquisitionChange.sequence = kAcquisitionChangeSequence;
+    acquisitionChange.mutationSerial = mutation.acquiredMutationSerial;
+    acquisitionChange.kind = kAcquisitionChangeKind;
+    acquisitionChange.flags = kAcquisitionChangeFlags;
+
+    Prepared staged{};
+    staged.rawClearSize =
+        (std::max)(reservation.rawClearSize,
+                   reservation.rawWriteOffset + family4_datagen::account::layout::kObjectSize);
+    std::size_t compressedExtent = reservation.compressedWriteOffset;
+    const std::size_t accountObjectIndex = acquisition.appendedResident ? 1U : 0U;
+    if (!append_object(scratch,
+                       accountBytes,
+                       acquisition.accountDefinitionId,
+                       acquisition.accountSoid,
+                       staged.objects[accountObjectIndex],
+                       compressedExtent)) {
+        return report_failure("profile_acquire_account_object");
+    }
+    std::size_t objectCount = 1;
+    if (acquisition.appendedResident) {
+        if (mutation.profileIndex >= account.profileItemCount) {
+            return report_failure("profile_acquire_instance_index");
+        }
+        family4_datagen::instance::ResolvedInstance instance{};
+        const state::account::inventory::ProfileItem& profileItem =
+            account.profileItems[mutation.profileIndex];
+        const auto instanceBytes = rawStorage.first(family4_datagen::instance::layout::kObjectSize);
+        if (!resolve_profile_item_instance(profileItem, instance)
+            || instance.instanceSoid != acquisition.acquiredInstanceSoid
+            || !family4_datagen::instance::encode(instance, instanceBytes)
+            || !append_object(scratch,
+                              instanceBytes,
+                              acquisition.itemInstanceDefinitionId,
+                              acquisition.acquiredInstanceSoid,
+                              staged.objects.front(),
+                              compressedExtent)) {
+            return report_failure("profile_acquire_instance_object");
+        }
+        objectCount = 2;
+    }
+    staged.compressedClearSize = (std::max)(reservation.compressedClearSize, compressedExtent);
+    staged.family = middleware::queuez::Family{
+        kAccountFamilyType,
+        acquisition.after.family4RootSoid,
+        acquisition.after.family4Version,
+        0,
+        std::span(staged.objects).first(objectCount),
+    };
+    if (!commit(staged, prepared)) {
+        clear_after(scratch, reservation);
+        return report_failure("profile_acquire_commit");
+    }
+
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count = std::snprintf(
+        line.data(),
+        line.size(),
+        "ev=profile_acquire stage=account_object result=ok family_version=%d "
+        "account=0x%llX definition=%u item_count=%zu definition_hash=0x%08X quantity=%d "
+        "native_row=%zu mutation_serial=%d change_slot=%u change_next_sequence=%u "
+        "change_kind=%u account_payload_bytes=%zu objects=%zu object_order=%s",
+        acquisition.after.family4Version,
+        static_cast<unsigned long long>(acquisition.accountSoid),
+        acquisition.accountDefinitionId,
+        mutation.afterItemCount,
+        mutation.acquiredDefinitionHash,
+        mutation.acquiredQuantity,
+        acquiredRow,
+        mutation.acquiredMutationSerial,
+        static_cast<unsigned>(kAcquisitionChangeNextWriteSlot),
+        static_cast<unsigned>(kAcquisitionChangeNextSequence),
+        static_cast<unsigned>(kAcquisitionChangeKind),
+        prepared.family.objects[accountObjectIndex].payload.size(),
+        objectCount,
+        acquisition.appendedResident ? "item-account" : "account");
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+    return true;
+}
+
+/** Builds a new item-instance upsert before its character after-image. */
+bool prepare_item_acquisition(Scratch& scratch,
+                              const queuez::ItemAcquisition& acquisition,
+                              const state::PendingItemAcquisition& mutation,
+                              Prepared& prepared) noexcept {
+    const Reservation reservation = reserve_prior(scratch, prepared);
+    if (reservation.rawWriteOffset > scratch.plaintext.size()
+        || reservation.compressedWriteOffset > scratch.sealed.size()) {
+        return report_failure("acquire_reservation");
+    }
+
+    state::AccountState account{};
+    if (!mutation.prepared || mutation.characterSoid == 0 || mutation.acquiredInstanceSoid == 0
+        || mutation.accountSoid == 0 || mutation.accountSoid != acquisition.accountSoid
+        || mutation.characterSoid != acquisition.characterSoid
+        || mutation.acquiredInstanceSoid != acquisition.acquiredInstanceSoid
+        || mutation.profileChanged != acquisition.updatesAccount
+        || acquisition.accountSoid != acquisition.after.family4RootSoid
+        || acquisition.accountDefinitionId == 0 || acquisition.after.family4ResidentCount == 0
+        || acquisition.after.family4Residents[acquisition.after.family4ResidentCount - 1U]
+                   .objectSoid
+               != mutation.acquiredInstanceSoid
+        || acquisition.after.family4Residents[acquisition.after.family4ResidentCount - 1U]
+                   .definitionId
+               != acquisition.itemInstanceDefinitionId) {
+        return report_failure("acquire_mutation");
+    }
+    if (!state::preview_item_acquisition(mutation, account)
+        || mutation.characterIndex >= account.characterCount
+        || account.primarySoid != acquisition.accountSoid
+        || account.characters[mutation.characterIndex].soid != mutation.characterSoid) {
+        return report_failure("acquire_account");
+    }
+
+    Resolved selected{};
+    const std::optional<std::size_t> selectedIndex = find_character_index(account);
+    if (!state::account::valid(account) || !selectedIndex.has_value()
+        || *selectedIndex != mutation.characterIndex
+        || !resolve(account, mutation.characterIndex, selected)
+        || selected.characterObjectId != acquisition.characterDefinitionId
+        || selected.itemInstanceObjectId != acquisition.itemInstanceDefinitionId) {
+        return report_failure("acquire_selection");
+    }
+
+    family4_datagen::loadout::ResolvedInstances acquired{};
+    std::int32_t acquiredMutationSerial = -1;
+    for (std::size_t index = 0; index < selected.loadout.itemCount; ++index) {
+        const family4_datagen::loadout::ResolvedItem& item = selected.loadout.items[index];
+        if (item.instance.instanceSoid != mutation.acquiredInstanceSoid) {
+            continue;
+        }
+        if (acquired.itemCount != 0 || item.equipped || item.inventoryRow != mutation.inventoryRow
+            || item.equipmentSlot != mutation.equipmentSlot) {
+            return report_failure("acquire_item_row");
+        }
+        acquired.items[0] = {item.equipmentSlot, item.instance};
+        acquired.itemCount = 1;
+        acquiredMutationSerial = item.mutationSerial;
+    }
+    if (acquired.itemCount != 1 || acquiredMutationSerial < 0) {
+        return report_failure("acquire_item_missing");
+    }
+
+    const auto rawStorage = std::span(scratch.plaintext).subspan(reservation.rawWriteOffset);
+    if (family4_datagen::character::layout::kObjectSize > rawStorage.size()) {
+        return report_failure("acquire_character_storage");
+    }
+    const auto characterBytes = rawStorage.first(family4_datagen::character::layout::kObjectSize);
+    if (!family4_datagen::character::encode(account.characters[mutation.characterIndex],
+                                            selected.loadout,
+                                            selected.lightEvaluation,
+                                            characterBytes)) {
+        return report_failure("acquire_character_object");
+    }
+
+    // The native character-object observer requires a transient inventory-change record whose
+    // serial matches the newly filled row. Without it the row is accepted, but the observer never
+    // queues the acquisition feedback. Keep this record local to this one acquisition push; the
+    // canonical encoder leaves the bank empty on every later snapshot.
+    constexpr std::size_t kBitsPerFlagByte = 8;
+    constexpr std::int32_t kEncodedOccupiedRowWatermark = 1;
+    constexpr std::uint16_t kAcquisitionChangeSequence = 0;
+    constexpr std::uint16_t kAcquisitionChangeNextWriteSlot = 1;
+    constexpr std::uint16_t kAcquisitionChangeNextSequence = 1;
+    constexpr std::uint8_t kAcquisitionChangeKind = 1;
+    constexpr std::uint16_t kAcquisitionChangeFlags = 0;
+    auto& characterObject =
+        *reinterpret_cast<family4_datagen::character::layout::Object*>(characterBytes.data());
+    const std::size_t acquiredRow = mutation.inventoryRow;
+    if (acquiredRow >= characterObject.inventoryItems.size()
+        || acquiredRow >= characterObject.instanceProgressWatermarks.size()) {
+        clear_after(scratch, reservation);
+        return report_failure("acquire_new_item_row");
+    }
+    const std::size_t newItemFlagIndex = acquiredRow / kBitsPerFlagByte;
+    const std::byte newItemFlagMask = std::byte{1U} << (acquiredRow % kBitsPerFlagByte);
+    const auto recordIsZero =
+        [](const family4_datagen::character::layout::InventoryChangeRecord& record) noexcept {
+            return record.sequence == 0 && record.reserved == 0 && record.mutationSerial == 0
+                   && record.kind == 0 && record.reservedKind == 0 && record.flags == 0;
+        };
+    const bool unknownIsZero =
+        std::all_of(characterObject.inventoryChangeUnknown.cbegin(),
+                    characterObject.inventoryChangeUnknown.cend(),
+                    [](std::byte value) noexcept { return value == std::byte{}; });
+    const bool recordsAreZero = std::all_of(characterObject.inventoryChanges.records.cbegin(),
+                                            characterObject.inventoryChanges.records.cend(),
+                                            recordIsZero);
+    const auto& acquiredInventoryRow = characterObject.inventoryItems[acquiredRow];
+    if (newItemFlagIndex >= characterObject.newItemFlags.size()
+        || acquiredInventoryRow.instanceSoid != mutation.acquiredInstanceSoid
+        || acquiredInventoryRow.mutationSerial != acquiredMutationSerial
+        || (characterObject.newItemFlags[newItemFlagIndex] & newItemFlagMask) != newItemFlagMask
+        || characterObject.instanceProgressWatermarks[acquiredRow] != kEncodedOccupiedRowWatermark
+        || !unknownIsZero || characterObject.inventoryChanges.writeSlot != 0
+        || characterObject.inventoryChanges.nextSequence != 0 || !recordsAreZero) {
+        clear_after(scratch, reservation);
+        return report_failure("acquire_inventory_change_state");
+    }
+    characterObject.inventoryChanges.writeSlot = kAcquisitionChangeNextWriteSlot;
+    characterObject.inventoryChanges.nextSequence = kAcquisitionChangeNextSequence;
+    auto& acquisitionChange = characterObject.inventoryChanges.records.front();
+    acquisitionChange.sequence = kAcquisitionChangeSequence;
+    acquisitionChange.mutationSerial = acquiredMutationSerial;
+    acquisitionChange.kind = kAcquisitionChangeKind;
+    acquisitionChange.flags = kAcquisitionChangeFlags;
+    if (!std::all_of(characterObject.inventoryChanges.records.cbegin() + 1,
+                     characterObject.inventoryChanges.records.cend(),
+                     recordIsZero)) {
+        clear_after(scratch, reservation);
+        return report_failure("acquire_inventory_change_records");
+    }
+
+    Prepared staged{};
+    staged.rawClearSize =
+        (std::max)(reservation.rawClearSize,
+                   reservation.rawWriteOffset + family4_datagen::character::layout::kObjectSize);
+    std::size_t compressedExtent = reservation.compressedWriteOffset;
+    if (!append_object(scratch,
+                       characterBytes,
+                       acquisition.characterDefinitionId,
+                       acquisition.characterSoid,
+                       staged.objects[0],
+                       compressedExtent)) {
+        return report_failure("acquire_character_object");
+    }
+
+    std::size_t itemCursor = 0;
+    if (!append_items(scratch,
+                      rawStorage,
+                      acquisition.itemInstanceDefinitionId,
+                      acquired,
+                      1,
+                      staged,
+                      itemCursor,
+                      compressedExtent)
+        || itemCursor != 1) {
+        clear_after(scratch, reservation);
+        return report_failure("acquire_item_object");
+    }
+
+    const auto& acquiredObject =
+        *reinterpret_cast<const family4_datagen::instance::layout::Object*>(rawStorage.data());
+    if (acquiredObject.instanceSoid != mutation.acquiredInstanceSoid
+        || acquiredObject.roll.progress
+               != family4_datagen::instance::layout::kInitialInstanceProgress) {
+        clear_after(scratch, reservation);
+        return report_failure("acquire_item_progress");
+    }
+    const std::int32_t acquiredInstanceProgress = acquiredObject.roll.progress;
+
+    std::size_t objectCount = 2;
+    if (acquisition.updatesAccount) {
+        if (family4_datagen::account::layout::kObjectSize > rawStorage.size()) {
+            clear_after(scratch, reservation);
+            return report_failure("acquire_account_storage");
+        }
+        const auto accountBytes = rawStorage.first(family4_datagen::account::layout::kObjectSize);
+        if (!family4_datagen::account::encode(account, accountBytes)
+            || !append_object(scratch,
+                              accountBytes,
+                              acquisition.accountDefinitionId,
+                              acquisition.accountSoid,
+                              staged.objects[2],
+                              compressedExtent)) {
+            clear_after(scratch, reservation);
+            return report_failure("acquire_account_object");
+        }
+        staged.rawClearSize =
+            (std::max)(staged.rawClearSize,
+                       reservation.rawWriteOffset + family4_datagen::account::layout::kObjectSize);
+        objectCount = 3;
+    }
+
+    // Creation increments publish the dependency before the reference to it. Compression order is
+    // irrelevant because each descriptor already owns its sealed span, so exchange only the wire
+    // descriptors: new item first, then the character after-image. Dismantle deliberately uses the
+    // inverse dependency order (drop the character reference, then release the item).
+    std::swap(staged.objects[0], staged.objects[1]);
+
+    staged.compressedClearSize = (std::max)(reservation.compressedClearSize, compressedExtent);
+    staged.family = middleware::queuez::Family{
+        kAccountFamilyType,
+        acquisition.after.family4RootSoid,
+        acquisition.after.family4Version,
+        0,
+        std::span(staged.objects).first(objectCount),
+    };
+    if (!commit(staged, prepared)) {
+        clear_after(scratch, reservation);
+        return report_failure("acquire_commit");
+    }
+
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count = std::snprintf(
+        line.data(),
+        line.size(),
+        "ev=acquire stage=family4_objects result=ok family_version=%d root=0x%llX "
+        "character=0x%llX character_definition=%u instance=0x%llX item_definition=%u "
+        "definition_hash=0x%08X inventory_row=%u equipment_slot=%u next_serial=%u objects=%zu "
+        "order=%s new_item_flag=1 watermark=1 acquired_row_serial=%d "
+        "inventory_change_write_slot=%u inventory_change_next_sequence=%u "
+        "inventory_change_record=0 inventory_change_sequence=%u "
+        "inventory_change_serial=%d inventory_change_kind=%u inventory_change_flags=%u "
+        "instance_progress=%d",
+        acquisition.after.family4Version,
+        static_cast<unsigned long long>(acquisition.after.family4RootSoid),
+        static_cast<unsigned long long>(acquisition.characterSoid),
+        acquisition.characterDefinitionId,
+        static_cast<unsigned long long>(acquisition.acquiredInstanceSoid),
+        acquisition.itemInstanceDefinitionId,
+        mutation.acquiredDefinitionHash,
+        static_cast<unsigned>(mutation.inventoryRow),
+        static_cast<unsigned>(mutation.equipmentSlot),
+        mutation.afterCharacter.nextInventorySerial,
+        objectCount,
+        acquisition.updatesAccount ? "item_character_account" : "item_character",
+        acquiredMutationSerial,
+        static_cast<unsigned>(kAcquisitionChangeNextWriteSlot),
+        static_cast<unsigned>(kAcquisitionChangeNextSequence),
+        static_cast<unsigned>(kAcquisitionChangeSequence),
+        acquiredMutationSerial,
+        static_cast<unsigned>(kAcquisitionChangeKind),
+        static_cast<unsigned>(kAcquisitionChangeFlags),
+        acquiredInstanceProgress);
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+    return true;
+}
+
+/** Builds character removal, instance release, and any profile-reward pickup descriptors. */
+bool prepare_item_dismantle(Scratch& scratch,
+                            const queuez::ItemDismantle& dismantle,
+                            const state::PendingItemDismantle& mutation,
+                            Prepared& prepared) noexcept {
+    const Reservation reservation = reserve_prior(scratch, prepared);
+    if (reservation.rawWriteOffset > scratch.plaintext.size()
+        || reservation.compressedWriteOffset > scratch.sealed.size()) {
+        return report_failure("dismantle_reservation");
+    }
+
+    state::AccountState account{};
+    if (!mutation.prepared || mutation.characterSoid == 0 || mutation.dismantledInstanceSoid == 0
+        || mutation.dismantledItem.instanceSoid != mutation.dismantledInstanceSoid
+        || mutation.accountSoid != dismantle.accountSoid
+        || mutation.characterSoid != dismantle.characterSoid
+        || mutation.dismantledInstanceSoid != dismantle.dismantledInstanceSoid
+        || mutation.profileChanged != dismantle.updatesAccount
+        || mutation.rewardCount > state::kDismantleRewardCapacity
+        || mutation.profileChanged != (mutation.rewardCount != 0)
+        || dismantle.accountDefinitionId == 0 || dismantle.characterDefinitionId == 0
+        || dismantle.itemInstanceDefinitionId == 0
+        || !state::preview_item_dismantle(mutation, account)
+        || mutation.characterIndex >= account.characterCount
+        || account.primarySoid != dismantle.accountSoid
+        || account.characters[mutation.characterIndex].soid != mutation.characterSoid) {
+        return report_failure("dismantle_mutation");
+    }
+
+    Resolved selected{};
+    const std::optional<std::size_t> selectedIndex = find_character_index(account);
+    if (!state::account::valid(account) || !selectedIndex.has_value()
+        || *selectedIndex != mutation.characterIndex
+        || !resolve(account, mutation.characterIndex, selected)
+        || selected.characterObjectId != dismantle.characterDefinitionId
+        || selected.itemInstanceObjectId != dismantle.itemInstanceDefinitionId) {
+        return report_failure("dismantle_selection");
+    }
+    for (std::size_t index = 0; index < selected.loadout.itemCount; ++index) {
+        if (selected.loadout.items[index].instance.instanceSoid
+            == mutation.dismantledInstanceSoid) {
+            return report_failure("dismantle_item_present");
+        }
+    }
+
+    const auto rawStorage = std::span(scratch.plaintext).subspan(reservation.rawWriteOffset);
+    if (family4_datagen::character::layout::kObjectSize > rawStorage.size()) {
+        return report_failure("dismantle_character_storage");
+    }
+    const auto characterBytes = rawStorage.first(family4_datagen::character::layout::kObjectSize);
+    if (!family4_datagen::character::encode(account.characters[mutation.characterIndex],
+                                            selected.loadout,
+                                            selected.lightEvaluation,
+                                            characterBytes)) {
+        return report_failure("dismantle_character_object");
+    }
+
+    Prepared staged{};
+    staged.rawClearSize =
+        (std::max)(reservation.rawClearSize,
+                   reservation.rawWriteOffset + family4_datagen::character::layout::kObjectSize);
+    std::size_t compressedExtent = reservation.compressedWriteOffset;
+    if (!append_object(scratch,
+                       characterBytes,
+                       dismantle.characterDefinitionId,
+                       dismantle.characterSoid,
+                       staged.objects[0],
+                       compressedExtent)) {
+        return report_failure("dismantle_character_object");
+    }
+    // Queuez represents a release with the ordinary object key and an empty payload. The
+    // encoding selector is not read for empty descriptors; oodle matches the surrounding objects.
+    staged.objects[1] = middleware::queuez::Object{
+        dismantle.itemInstanceDefinitionId,
+        dismantle.dismantledInstanceSoid,
+        middleware::queuez::Encoding::oodle,
+        {},
+    };
+
+    std::size_t objectCount = 2;
+    if (dismantle.updatesAccount) {
+        if (family4_datagen::account::layout::kObjectSize > rawStorage.size()) {
+            clear_after(scratch, reservation);
+            return report_failure("dismantle_account_storage");
+        }
+        const auto accountBytes = rawStorage.first(family4_datagen::account::layout::kObjectSize);
+        if (!family4_datagen::account::encode(account, accountBytes)) {
+            clear_after(scratch, reservation);
+            return report_failure("dismantle_account_encode");
+        }
+
+        auto& accountObject =
+            *reinterpret_cast<family4_datagen::account::layout::Object*>(accountBytes.data());
+        const auto recordIsZero =
+            [](const family4_datagen::account::layout::ProfileInventoryChangeRecord&
+                   record) noexcept {
+                return record.sequence == 0 && record.reserved == 0 && record.mutationSerial == 0
+                       && record.kind == 0 && record.reservedKind == 0 && record.flags == 0;
+            };
+        if (accountObject.profileInventoryChanges.writeSlot != 0
+            || accountObject.profileInventoryChanges.nextSequence != 0
+            || !std::all_of(accountObject.profileInventoryChanges.records.cbegin(),
+                            accountObject.profileInventoryChanges.records.cend(),
+                            recordIsZero)
+            || mutation.rewardCount == 0
+            || mutation.rewardCount > accountObject.profileInventoryChanges.records.size()) {
+            clear_after(scratch, reservation);
+            return report_failure("dismantle_account_change_state");
+        }
+
+        // Kind 1 is the ordinary acquisition path, and clear policy bits leave it enabled. The
+        // native observer skips a record with any other pair.
+        constexpr std::uint8_t kRewardChangeKind = 1;
+        constexpr std::uint16_t kRewardChangeFlags = 0;
+        for (std::size_t rewardIndex = 0; rewardIndex < mutation.rewardCount; ++rewardIndex) {
+            const state::DismantleReward& reward = mutation.rewards[rewardIndex];
+            std::size_t matchedRows = 0;
+            for (const auto& row : accountObject.profileItems) {
+                if (row.mutationSerial != reward.mutationSerial) {
+                    continue;
+                }
+                if (row.quantity != reward.afterQuantity) {
+                    clear_after(scratch, reservation);
+                    return report_failure("dismantle_reward_quantity");
+                }
+                ++matchedRows;
+            }
+            if (matchedRows != 1) {
+                clear_after(scratch, reservation);
+                return report_failure("dismantle_reward_row");
+            }
+            auto& change = accountObject.profileInventoryChanges.records[rewardIndex];
+            change.sequence = static_cast<std::uint16_t>(rewardIndex);
+            change.mutationSerial = reward.mutationSerial;
+            change.kind = kRewardChangeKind;
+            change.flags = kRewardChangeFlags;
+        }
+        accountObject.profileInventoryChanges.writeSlot =
+            static_cast<std::uint16_t>(mutation.rewardCount);
+        accountObject.profileInventoryChanges.nextSequence =
+            static_cast<std::uint16_t>(mutation.rewardCount);
+
+        if (!append_object(scratch,
+                           accountBytes,
+                           dismantle.accountDefinitionId,
+                           dismantle.accountSoid,
+                           staged.objects[2],
+                           compressedExtent)) {
+            clear_after(scratch, reservation);
+            return report_failure("dismantle_account_object");
+        }
+        staged.rawClearSize =
+            (std::max)(staged.rawClearSize,
+                       reservation.rawWriteOffset + family4_datagen::account::layout::kObjectSize);
+        objectCount = 3;
+    }
+    staged.compressedClearSize = (std::max)(reservation.compressedClearSize, compressedExtent);
+    staged.family = middleware::queuez::Family{
+        kAccountFamilyType,
+        dismantle.after.family4RootSoid,
+        dismantle.after.family4Version,
+        0,
+        std::span(staged.objects).first(objectCount),
+    };
+    if (!commit(staged, prepared)) {
+        clear_after(scratch, reservation);
+        return report_failure("dismantle_commit");
+    }
+
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count = std::snprintf(
+        line.data(),
+        line.size(),
+        "ev=dismantle stage=family4_objects result=ok family_version=%d root=0x%llX "
+        "character=0x%llX character_definition=%u instance=0x%llX item_definition=%u "
+        "definition_hash=0x%08X inventory_index=%zu inventory_row=%u equipment_slot=%u "
+        "moved_items=%zu items_after=%zu next_serial=%u rewards=%zu objects=%zu "
+        "order=%s",
+        dismantle.after.family4Version,
+        static_cast<unsigned long long>(dismantle.after.family4RootSoid),
+        static_cast<unsigned long long>(dismantle.characterSoid),
+        dismantle.characterDefinitionId,
+        static_cast<unsigned long long>(dismantle.dismantledInstanceSoid),
+        dismantle.itemInstanceDefinitionId,
+        mutation.dismantledItem.definitionHash,
+        mutation.inventoryIndex,
+        static_cast<unsigned>(mutation.inventoryRow),
+        static_cast<unsigned>(mutation.equipmentSlot),
+        mutation.movedInventoryItemCount,
+        mutation.afterCharacter.inventory.count,
+        mutation.afterCharacter.nextInventorySerial,
+        mutation.rewardCount,
+        objectCount,
+        dismantle.updatesAccount ? "character_release_account" : "character_release");
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+    return true;
+}
+} // namespace sunrise::server::bap::encrypted::push::snapshot

+ 98 - 0
Sunrise/src/server/bap/encrypted/push/snapshot/family4_object_staging.cpp

@@ -4,6 +4,7 @@
 #include "../../../../../core/logging/log.h"
 #include "../../../../../middleware/datagen/family4/instance/instance_encoder.h"
 #include "../../../../../middleware/datagen/family4/instance/layout.h"
+#include "../../../../../state/build_data/runtime.h"
 #include "internal.h"
 
 namespace sunrise::server::bap::encrypted::push::snapshot {
@@ -75,4 +76,101 @@ bool append_items(Scratch& scratch,
     return true;
 }
 
+/** Resolves one source-backed profile stack into the shared Family-4 item-instance schema. */
+bool resolve_profile_item_instance(const state::account::inventory::ProfileItem& profileItem,
+                                   family4_datagen::instance::ResolvedInstance& output) noexcept {
+    output = {};
+    const std::size_t itemDefinitionCount = state::build_data::item_definition_count();
+    const std::size_t socketEntryListCount = state::build_data::socket_entry_list_count();
+    state::build_data::items::Definition item{};
+    state::build_data::items::details::Definition detail{};
+    state::build_data::inventory::buckets::Descriptor bucket{};
+    state::build_data::socket_entry_lists::Definition socketList{};
+    if (profileItem.instanceSoid == 0 || profileItem.quantity <= 0 || profileItem.mutationSerial < 0
+        || itemDefinitionCount == 0
+        || itemDefinitionCount > state::build_data::items::kDefinitionCapacity
+        || socketEntryListCount == 0
+        || socketEntryListCount > state::build_data::socket_entry_lists::kDefinitionCapacity
+        || !state::build_data::find_item_definition_hash(profileItem.definitionHash, item)
+        || item.definitionHash != profileItem.definitionHash
+        || !state::build_data::find_configured_item_detail(item.definitionIndex, detail)
+        || detail.definitionIndex != item.definitionIndex
+        || detail.definitionHash != item.definitionHash || detail.bucketId != item.bucketId
+        || detail.equipmentSlot.has_value()
+        || detail.instancedDefinitionState
+               != state::build_data::items::details::InstancedDefinitionState::stackable
+        || detail.ordinarySocketState
+               != state::build_data::items::details::OrdinarySocketState::absent
+        || detail.ordinarySocketCount != 0
+        || !state::build_data::find_inventory_bucket_descriptor(item.bucketId, bucket)
+        || bucket.arraySelector != state::build_data::inventory::buckets::ArraySelector::profile
+        || !state::build_data::is_profile_action_source(item.definitionIndex, item.bucketId)
+        || !state::build_data::find_socket_entry_list(detail.socketEntryListIndex, socketList)
+        || socketList.definitionIndex != detail.socketEntryListIndex || socketList.entryCount != 0
+        || static_cast<std::size_t>(item.definitionIndex) >= itemDefinitionCount
+        || static_cast<std::size_t>(socketList.definitionIndex) >= socketEntryListCount) {
+        return false;
+    }
+
+    family4_datagen::instance::ResolvedInstance candidate{};
+    candidate.instanceSoid = profileItem.instanceSoid;
+    candidate.bounds.itemDefinitionCount = static_cast<std::uint32_t>(itemDefinitionCount);
+    candidate.bounds.socketEntryListCount = static_cast<std::uint32_t>(socketEntryListCount);
+    candidate.baseDefinitionIndex = item.definitionIndex;
+    candidate.level = family4_datagen::instance::layout::kMinimumItemLevel;
+    candidate.curveSelector = family4_datagen::instance::layout::kInitialLevelCurveX;
+    candidate.capSelector = family4_datagen::instance::layout::kInitialLevelCapRow;
+    candidate.socketEntryListIndex = socketList.definitionIndex;
+    candidate.socketEntryCount = 0;
+    candidate.socketEntryContentsResolved = true;
+    candidate.ordinarySockets.state = family4_datagen::instance::OrdinarySocketBlockState::absent;
+    if (state::build_data::item_definition_count() != itemDefinitionCount
+        || state::build_data::socket_entry_list_count() != socketEntryListCount) {
+        return false;
+    }
+    output = candidate;
+    return true;
+}
+
+/** Appends every source-backed profile item after all character-owned item residents. */
+bool append_profile_items(Scratch& scratch,
+                          std::span<std::byte> rawStorage,
+                          std::uint32_t itemInstanceObjectId,
+                          const state::AccountState& account,
+                          std::size_t baseIndex,
+                          Prepared& staged,
+                          std::size_t& itemCursor,
+                          std::size_t& compressedExtent) noexcept {
+    if (account.profileItemCount > account.profileItems.size()
+        || family4_datagen::instance::layout::kObjectSize > rawStorage.size()) {
+        return false;
+    }
+    const auto encoded = rawStorage.first(family4_datagen::instance::layout::kObjectSize);
+    std::size_t appendedCount = 0;
+    for (std::size_t profileIndex = 0; profileIndex < account.profileItemCount; ++profileIndex) {
+        const state::account::inventory::ProfileItem& item = account.profileItems[profileIndex];
+        if (item.instanceSoid == 0) {
+            continue;
+        }
+        if (appendedCount >= state::account::inventory::kProfileActionSourceCapacity
+            || baseIndex + itemCursor >= staged.objects.size()) {
+            return false;
+        }
+        family4_datagen::instance::ResolvedInstance instance{};
+        if (!resolve_profile_item_instance(item, instance)
+            || !family4_datagen::instance::encode(instance, encoded)
+            || !append_object(scratch,
+                              encoded,
+                              itemInstanceObjectId,
+                              instance.instanceSoid,
+                              staged.objects[baseIndex + itemCursor],
+                              compressedExtent)) {
+            return false;
+        }
+        ++itemCursor;
+        ++appendedCount;
+    }
+    return true;
+}
+
 } // namespace sunrise::server::bap::encrypted::push::snapshot

+ 381 - 0
Sunrise/src/server/bap/encrypted/push/snapshot/family4_selection_move.cpp

@@ -1,14 +1,20 @@
 #include <algorithm>
+#include <array>
 #include <cstddef>
+#include <cstdio>
+#include <limits>
 #include <optional>
 #include <span>
 
+#include "../../../../../core/logging/log.h"
 #include "../../../../../middleware/datagen/family4/account/account_encoder.h"
 #include "../../../../../middleware/datagen/family4/account/layout.h"
 #include "../../../../../middleware/datagen/family4/account/selection_patch/\
 account_selection_patch_encoder.h"
 #include "../../../../../middleware/datagen/family4/character/character_encoder.h"
 #include "../../../../../middleware/datagen/family4/character/layout.h"
+#include "../../../../../middleware/datagen/family4/instance/instance_encoder.h"
+#include "../../../../../middleware/datagen/family4/instance/layout.h"
 #include "../../../../../state/runtime/runtime.h"
 #include "internal.h"
 #include "snapshot_storage.h"
@@ -19,6 +25,78 @@ namespace {
 namespace family4_datagen = middleware::datagen::family4;
 namespace selection_patch = middleware::datagen::family4::account::selection_patch;
 
+/** Logs the exact resolved and encoded fields used by the opcode-403 character upsert. */
+void report_equipment_object(const queuez::EquipmentSwap& swap,
+                             const state::PendingEquipmentSwap& mutation,
+                             const Resolved& selected,
+                             const family4_datagen::character::layout::Object& object) noexcept {
+    // Row sentinel for the log line only. Zero is a real row, so it cannot stand for "not found".
+    constexpr std::size_t kMissing = (std::numeric_limits<std::size_t>::max)();
+    std::size_t requestedRow = kMissing;
+    std::size_t previousRow = kMissing;
+    std::size_t nativeSlot = kMissing;
+    bool requestedEquipped = false;
+    bool previousEquipped = false;
+    for (std::size_t index = 0; index < selected.loadout.itemCount; ++index) {
+        const auto& item = selected.loadout.items[index];
+        if (item.instance.instanceSoid == mutation.requestedInstanceSoid) {
+            requestedRow = item.inventoryRow;
+            nativeSlot = item.equipmentSlot;
+            requestedEquipped = item.equipped;
+        } else if (item.instance.instanceSoid == mutation.previousInstanceSoid) {
+            previousRow = item.inventoryRow;
+            previousEquipped = item.equipped;
+        }
+    }
+    const std::uint64_t equippedSoid = nativeSlot < object.equippedInstanceSoids.size()
+                                           ? object.equippedInstanceSoids[nativeSlot]
+                                           : 0;
+    const std::uint64_t requestedRowSoid = requestedRow < object.inventoryItems.size()
+                                               ? object.inventoryItems[requestedRow].instanceSoid
+                                               : 0;
+    const std::uint64_t previousRowSoid = previousRow < object.inventoryItems.size()
+                                              ? object.inventoryItems[previousRow].instanceSoid
+                                              : 0;
+    const std::int32_t requestedSerial = requestedRow < object.inventoryItems.size()
+                                             ? object.inventoryItems[requestedRow].mutationSerial
+                                             : -1;
+    const std::int32_t previousSerial = previousRow < object.inventoryItems.size()
+                                            ? object.inventoryItems[previousRow].mutationSerial
+                                            : -1;
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count = std::snprintf(
+        line.data(),
+        line.size(),
+        "ev=equip stage=character_object result=ok family_version=%d root=0x%llX definition=%u "
+        "character=0x%llX items=%zu next_serial=%u requested=0x%llX requested_row=%zu "
+        "requested_row_soid=0x%llX requested_equipped=%u native_slot=%zu equipped_soid=0x%llX "
+        "requested_serial=%d previous=0x%llX previous_row=%zu previous_row_soid=0x%llX "
+        "previous_equipped=%u previous_serial=%d",
+        swap.after.family4Version,
+        static_cast<unsigned long long>(swap.after.family4RootSoid),
+        swap.characterDefinitionId,
+        static_cast<unsigned long long>(mutation.characterSoid),
+        selected.loadout.itemCount,
+        object.nextInventorySerial,
+        static_cast<unsigned long long>(mutation.requestedInstanceSoid),
+        requestedRow,
+        static_cast<unsigned long long>(requestedRowSoid),
+        static_cast<unsigned>(requestedEquipped),
+        nativeSlot,
+        static_cast<unsigned long long>(equippedSoid),
+        requestedSerial,
+        static_cast<unsigned long long>(mutation.previousInstanceSoid),
+        previousRow,
+        static_cast<unsigned long long>(previousRowSoid),
+        static_cast<unsigned>(previousEquipped),
+        previousSerial);
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+}
+
 } // namespace
 
 /** Builds the Family-4 increment that moves the character object to the picked character. */
@@ -137,4 +215,307 @@ bool prepare_selection_move(Scratch& scratch,
     return true;
 }
 
+/** Builds a single-character Family-4 upsert from an uncommitted equipment after-image. */
+bool prepare_equipment_swap(Scratch& scratch,
+                            const queuez::EquipmentSwap& swap,
+                            const state::PendingEquipmentSwap& mutation,
+                            Prepared& prepared) noexcept {
+    const Reservation reservation = reserve_prior(scratch, prepared);
+    if (reservation.rawWriteOffset > scratch.plaintext.size()
+        || reservation.compressedWriteOffset > scratch.sealed.size()) {
+        return report_failure("equip_reservation");
+    }
+    state::AccountState account = state::account_snapshot();
+    if (!mutation.prepared || mutation.characterSoid == 0
+        || mutation.characterSoid != swap.characterSoid
+        || mutation.characterIndex >= account.characterCount
+        || account.characters[mutation.characterIndex].soid != mutation.characterSoid) {
+        return report_failure("equip_mutation");
+    }
+    account.characters[mutation.characterIndex] = mutation.afterCharacter;
+    Resolved selected{};
+    const std::optional<std::size_t> selectedIndex = find_character_index(account);
+    if (!state::account::valid(account) || !selectedIndex.has_value()
+        || *selectedIndex != mutation.characterIndex
+        || !resolve(account, mutation.characterIndex, selected)
+        || selected.characterObjectId != swap.characterDefinitionId) {
+        return report_failure("equip_selection");
+    }
+
+    const auto rawStorage = std::span(scratch.plaintext).subspan(reservation.rawWriteOffset);
+    if (family4_datagen::character::layout::kObjectSize > rawStorage.size()) {
+        return report_failure("equip_character_storage");
+    }
+    const auto characterBytes = rawStorage.first(family4_datagen::character::layout::kObjectSize);
+    if (!family4_datagen::character::encode(account.characters[mutation.characterIndex],
+                                            selected.loadout,
+                                            selected.lightEvaluation,
+                                            characterBytes)) {
+        return report_failure("equip_character_object");
+    }
+    report_equipment_object(swap,
+                            mutation,
+                            selected,
+                            *reinterpret_cast<const family4_datagen::character::layout::Object*>(
+                                characterBytes.data()));
+
+    Prepared staged{};
+    staged.rawClearSize =
+        (std::max)(reservation.rawClearSize,
+                   reservation.rawWriteOffset + family4_datagen::character::layout::kObjectSize);
+    std::size_t compressedExtent = reservation.compressedWriteOffset;
+    if (!append_object(scratch,
+                       characterBytes,
+                       swap.characterDefinitionId,
+                       swap.characterSoid,
+                       staged.objects.front(),
+                       compressedExtent)) {
+        return report_failure("equip_character_object");
+    }
+    staged.compressedClearSize = (std::max)(reservation.compressedClearSize, compressedExtent);
+    staged.family = middleware::queuez::Family{
+        kAccountFamilyType,
+        swap.after.family4RootSoid,
+        swap.after.family4Version,
+        0,
+        std::span(staged.objects).first(1),
+    };
+    if (!commit(staged, prepared)) {
+        clear_after(scratch, reservation);
+        return report_failure("equip_commit");
+    }
+    return true;
+}
+
+/** Builds a single-character Family-4 upsert from an uncommitted item-state after-image. */
+bool prepare_item_state(Scratch& scratch,
+                        const queuez::EquipmentSwap& update,
+                        const state::PendingItemState& mutation,
+                        Prepared& prepared) noexcept {
+    const Reservation reservation = reserve_prior(scratch, prepared);
+    if (reservation.rawWriteOffset > scratch.plaintext.size()
+        || reservation.compressedWriteOffset > scratch.sealed.size()) {
+        return report_failure("item_state_reservation");
+    }
+    state::AccountState account = state::account_snapshot();
+    if (!mutation.prepared || mutation.characterSoid == 0
+        || mutation.characterSoid != update.characterSoid
+        || mutation.characterIndex >= account.characterCount
+        || account.characters[mutation.characterIndex].soid != mutation.characterSoid) {
+        return report_failure("item_state_mutation");
+    }
+    account.characters[mutation.characterIndex] = mutation.afterCharacter;
+    Resolved selected{};
+    const std::optional<std::size_t> selectedIndex = find_character_index(account);
+    if (!state::account::valid(account) || !selectedIndex.has_value()
+        || *selectedIndex != mutation.characterIndex
+        || !resolve(account, mutation.characterIndex, selected)
+        || selected.characterObjectId != update.characterDefinitionId) {
+        return report_failure("item_state_selection");
+    }
+
+    const auto rawStorage = std::span(scratch.plaintext).subspan(reservation.rawWriteOffset);
+    if (family4_datagen::character::layout::kObjectSize > rawStorage.size()) {
+        return report_failure("item_state_character_storage");
+    }
+    const auto characterBytes = rawStorage.first(family4_datagen::character::layout::kObjectSize);
+    if (!family4_datagen::character::encode(account.characters[mutation.characterIndex],
+                                            selected.loadout,
+                                            selected.lightEvaluation,
+                                            characterBytes)) {
+        return report_failure("item_state_character_object");
+    }
+    const auto& encoded =
+        *reinterpret_cast<const family4_datagen::character::layout::Object*>(characterBytes.data());
+    if (mutation.itemIndex >= mutation.afterCharacter.inventory.count && !mutation.targetEquipped) {
+        return report_failure("item_state_inventory_index");
+    }
+    std::size_t matchingRows = 0;
+    for (const auto& row : encoded.inventoryItems) {
+        matchingRows +=
+            static_cast<std::size_t>(row.instanceSoid == mutation.targetInstanceSoid
+                                     && row.definitionIndex == mutation.targetDefinitionIndex
+                                     && row.flags == mutation.afterFlags);
+    }
+    if (matchingRows != 1) {
+        return report_failure("item_state_character_shape");
+    }
+
+    Prepared staged{};
+    staged.rawClearSize =
+        (std::max)(reservation.rawClearSize,
+                   reservation.rawWriteOffset + family4_datagen::character::layout::kObjectSize);
+    std::size_t compressedExtent = reservation.compressedWriteOffset;
+    if (!append_object(scratch,
+                       characterBytes,
+                       update.characterDefinitionId,
+                       update.characterSoid,
+                       staged.objects.front(),
+                       compressedExtent)) {
+        return report_failure("item_state_character_object");
+    }
+    staged.compressedClearSize = (std::max)(reservation.compressedClearSize, compressedExtent);
+    staged.family = middleware::queuez::Family{
+        kAccountFamilyType,
+        update.after.family4RootSoid,
+        update.after.family4Version,
+        0,
+        std::span(staged.objects).first(1),
+    };
+    if (!commit(staged, prepared)) {
+        clear_after(scratch, reservation);
+        return report_failure("item_state_commit");
+    }
+    return true;
+}
+
+/** Builds a resident item upsert followed by charged account balances when the cost consumes. */
+bool prepare_socket_plug(Scratch& scratch,
+                         const queuez::SocketPlug& socketPlug,
+                         const state::PendingSocketPlug& mutation,
+                         Prepared& prepared) noexcept {
+    const Reservation reservation = reserve_prior(scratch, prepared);
+    if (reservation.rawWriteOffset > scratch.plaintext.size()
+        || reservation.compressedWriteOffset > scratch.sealed.size()) {
+        return report_failure("socket_plug_reservation");
+    }
+
+    state::AccountState account{};
+    if (!mutation.prepared || mutation.accountSoid == 0 || mutation.characterSoid == 0
+        || mutation.targetInstanceSoid == 0 || mutation.accountSoid != socketPlug.accountSoid
+        || mutation.characterSoid != socketPlug.characterSoid
+        || mutation.targetInstanceSoid != socketPlug.targetInstanceSoid
+        || mutation.profileChanged != socketPlug.updatesAccount
+        || mutation.accountSoid != socketPlug.after.family4RootSoid
+        || socketPlug.accountDefinitionId == 0 || !state::preview_socket_plug(mutation, account)
+        || mutation.characterIndex >= account.characterCount
+        || account.primarySoid != mutation.accountSoid
+        || account.characters[mutation.characterIndex].soid != mutation.characterSoid) {
+        return report_failure("socket_plug_mutation");
+    }
+
+    Resolved selected{};
+    const std::optional<std::size_t> selectedIndex = find_character_index(account);
+    if (!state::account::valid(account) || !selectedIndex.has_value()
+        || *selectedIndex != mutation.characterIndex
+        || !resolve(account, mutation.characterIndex, selected)
+        || selected.itemInstanceObjectId != socketPlug.itemInstanceDefinitionId) {
+        return report_failure("socket_plug_selection");
+    }
+
+    family4_datagen::loadout::ResolvedInstances changed{};
+    for (std::size_t index = 0; index < selected.loadout.itemCount; ++index) {
+        const family4_datagen::loadout::ResolvedItem& item = selected.loadout.items[index];
+        if (item.instance.instanceSoid != mutation.targetInstanceSoid) {
+            continue;
+        }
+        if (changed.itemCount != 0
+            || item.instance.baseDefinitionIndex != mutation.targetDefinitionIndex
+            || mutation.socketLane >= item.instance.ordinarySockets.plugs.size()
+            || item.instance.ordinarySockets.state
+                   != family4_datagen::instance::OrdinarySocketBlockState::present
+            || !item.instance.ordinarySockets.plugs[mutation.socketLane].has_value()
+            || *item.instance.ordinarySockets.plugs[mutation.socketLane]
+                   != mutation.plugDefinitionIndex) {
+            return report_failure("socket_plug_item_shape");
+        }
+        changed.items[0] = {item.equipmentSlot, item.instance};
+        changed.itemCount = 1;
+    }
+    if (changed.itemCount != 1) {
+        return report_failure("socket_plug_item_missing");
+    }
+
+    const auto rawStorage = std::span(scratch.plaintext).subspan(reservation.rawWriteOffset);
+    const std::size_t requiredRawSize = socketPlug.updatesAccount
+                                            ? family4_datagen::account::layout::kObjectSize
+                                            : family4_datagen::instance::layout::kObjectSize;
+    if (requiredRawSize > rawStorage.size()) {
+        return report_failure("socket_plug_item_storage");
+    }
+    Prepared staged{};
+    staged.rawClearSize =
+        (std::max)(reservation.rawClearSize,
+                   reservation.rawWriteOffset + family4_datagen::instance::layout::kObjectSize);
+    std::size_t compressedExtent = reservation.compressedWriteOffset;
+    std::size_t itemCursor = 0;
+    if (!append_items(scratch,
+                      rawStorage,
+                      socketPlug.itemInstanceDefinitionId,
+                      changed,
+                      0,
+                      staged,
+                      itemCursor,
+                      compressedExtent)
+        || itemCursor != 1) {
+        clear_after(scratch, reservation);
+        return report_failure("socket_plug_item_object");
+    }
+
+    std::size_t objectCount = 1;
+    if (socketPlug.updatesAccount) {
+        const auto accountBytes = rawStorage.first(family4_datagen::account::layout::kObjectSize);
+        if (!family4_datagen::account::encode(account, accountBytes)
+            || !append_object(scratch,
+                              accountBytes,
+                              socketPlug.accountDefinitionId,
+                              socketPlug.accountSoid,
+                              staged.objects[1],
+                              compressedExtent)) {
+            clear_after(scratch, reservation);
+            return report_failure("socket_plug_account_object");
+        }
+        staged.rawClearSize =
+            (std::max)(staged.rawClearSize,
+                       reservation.rawWriteOffset + family4_datagen::account::layout::kObjectSize);
+        objectCount = 2;
+    }
+
+    staged.compressedClearSize = (std::max)(reservation.compressedClearSize, compressedExtent);
+    staged.family = middleware::queuez::Family{
+        kAccountFamilyType,
+        socketPlug.after.family4RootSoid,
+        socketPlug.after.family4Version,
+        0,
+        std::span(staged.objects).first(objectCount),
+    };
+    if (!commit(staged, prepared)) {
+        clear_after(scratch, reservation);
+        return report_failure("socket_plug_commit");
+    }
+
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count = std::snprintf(
+        line.data(),
+        line.size(),
+        "ev=socket_plug stage=family4_objects result=ok family_version=%d root=0x%llX "
+        "character=0x%llX instance=0x%llX item_definition=%u target_definition=%u "
+        "target_bucket=%u lane=%u plug_definition=%u plug_bucket=%u equipped=%u "
+        "material_set=%u material_set_hash=0x%08X material_rows=%u account_update=%u objects=%zu "
+        "order=%s",
+        socketPlug.after.family4Version,
+        static_cast<unsigned long long>(socketPlug.after.family4RootSoid),
+        static_cast<unsigned long long>(socketPlug.characterSoid),
+        static_cast<unsigned long long>(socketPlug.targetInstanceSoid),
+        socketPlug.itemInstanceDefinitionId,
+        static_cast<unsigned>(mutation.targetDefinitionIndex),
+        static_cast<unsigned>(mutation.targetBucketId),
+        static_cast<unsigned>(mutation.socketLane),
+        static_cast<unsigned>(mutation.plugDefinitionIndex),
+        static_cast<unsigned>(mutation.plugBucketId),
+        static_cast<unsigned>(mutation.targetEquipped),
+        static_cast<unsigned>(mutation.materialRequirementSetIndex),
+        mutation.materialRequirementSetHash,
+        static_cast<unsigned>(mutation.materialRequirementCount),
+        static_cast<unsigned>(socketPlug.updatesAccount),
+        objectCount,
+        socketPlug.updatesAccount ? "item_account" : "item");
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+    return true;
+}
+
 } // namespace sunrise::server::bap::encrypted::push::snapshot

+ 21 - 1
Sunrise/src/server/bap/encrypted/push/snapshot/family4_snapshot_preparer.cpp

@@ -23,8 +23,10 @@ namespace family4_datagen = middleware::datagen::family4;
 /**
  * Publishes the staged family metadata once every needed object is done.
  * @param subscription Family id the Client picked.
+ * @param objectCount Descriptors staged for this family.
  * @param compressedExtent Size of the used prefix of the sealed buffer.
  * @param reservation Cleanup extent carried over from any prior live snapshot.
+ * @param staged Descriptors and clear extents built by the caller.
  * @param output Gets the family snapshot only on success.
  * @return True when the staged descriptors pass the ownership check.
  */
@@ -61,6 +63,11 @@ bool prepare(Scratch& scratch,
     if (family4_datagen::account::layout::kObjectSize > rawStorage.size()) {
         return report_failure("account_storage");
     }
+    // Package extraction may have completed after State initialization on a first cache build.
+    // Canonicalize profile action-source identities immediately before the first Family-4 image.
+    if (!state::ensure_profile_item_identities()) {
+        return report_failure("profile_identities");
+    }
     const state::AccountState account = state::account_snapshot();
     if (!state::account::valid(account)) {
         return report_failure("account_state");
@@ -128,7 +135,8 @@ bool prepare(Scratch& scratch,
     for (std::size_t characterIndex = 0; characterIndex < account.characterCount;
          ++characterIndex) {
         family4_datagen::loadout::ResolvedInstances instances{};
-        if (!family4_datagen::loadout::resolve_instances(account, characterIndex, instances)) {
+        if (!family4_datagen::loadout::resolve_owned_instances(
+                account, characterIndex, instances)) {
             return report_failure("loadout");
         }
         if (instances.itemCount != 0
@@ -143,6 +151,18 @@ bool prepare(Scratch& scratch,
             return report_failure("items");
         }
     }
+    // Profile rows with nonzero SOIDs are native action sources.  Their item residents follow all
+    // character-owned instances so the full-snapshot manifest remains deterministic.
+    if (!append_profile_items(scratch,
+                              rawStorage,
+                              itemInstanceObjectId,
+                              account,
+                              itemBaseIndex,
+                              staged,
+                              itemCursor,
+                              compressedExtent)) {
+        return report_failure("profile_items");
+    }
 
     const std::size_t objectCount = itemBaseIndex + itemCursor;
     if (itemCursor != 0) {

+ 20 - 0
Sunrise/src/server/bap/encrypted/push/snapshot/initial_snapshot.cpp

@@ -71,4 +71,24 @@ bool prepare_initial(Scratch& scratch,
     return true;
 }
 
+/** Rebuilds the account family at an explicitly staged nonzero version. */
+bool prepare_family4_refresh(Scratch& scratch,
+                             std::uint64_t familyRootSoid,
+                             std::int32_t version,
+                             Prepared& prepared) noexcept {
+    if (familyRootSoid == 0 || version <= kInitialFamilyVersion) {
+        return false;
+    }
+    middleware::queuez::Subscription subscription{};
+    subscription.familyType = kAccountFamilyType;
+    subscription.familyRootSoid = familyRootSoid;
+    if (!prepare_initial(scratch, subscription, prepared) || prepared.family.objects.empty()
+        || prepared.family.type != kAccountFamilyType || prepared.family.rootSoid != familyRootSoid
+        || prepared.family.flags != middleware::queuez::kFullSnapshotFlag) {
+        return false;
+    }
+    prepared.family.version = version;
+    return true;
+}
+
 } // namespace sunrise::server::bap::encrypted::push::snapshot

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

@@ -101,6 +101,90 @@ inline constexpr std::size_t kSingleObjectCount = 1;
                                           const queuez::SelectCharacter& select,
                                           Prepared& prepared) noexcept;
 
+/**
+ * Builds the Family-4 character upsert for one prepared equipment swap.
+ * @param scratch
+ * Object and compression storage owned by the lock.
+ * @param swap Checked queuez version
+ * after-image and resident character definition.
+ * @param mutation Checked State after-image that
+ * is not committed yet.
+ * @param prepared Gets the single character upsert descriptor.
+ * @return
+ * True when the after-image encodes and the complete object fits.
+ */
+[[nodiscard]] bool prepare_equipment_swap(Scratch& scratch,
+                                          const queuez::EquipmentSwap& swap,
+                                          const state::PendingEquipmentSwap& mutation,
+                                          Prepared& prepared) noexcept;
+
+/** Builds the Family-4 character upsert carrying one accumulated item-state change. */
+[[nodiscard]] bool prepare_item_state(Scratch& scratch,
+                                      const queuez::EquipmentSwap& update,
+                                      const state::PendingItemState& mutation,
+                                      Prepared& prepared) noexcept;
+
+/**
+ * Builds the Family-4 item-instance upsert for one prepared ordinary-socket selection.
+ * The
+ * character object is unchanged because item identity, placement, and mutation generation are
+ *
+ * preserved; the socket block lives entirely in the resident instance object.
+ */
+[[nodiscard]] bool prepare_socket_plug(Scratch& scratch,
+                                       const queuez::SocketPlug& socketPlug,
+                                       const state::PendingSocketPlug& mutation,
+                                       Prepared& prepared) noexcept;
+
+/**
+ * Builds one Family-4 increment containing the newly resident item object followed by the
+ *
+ * changed character that references it.
+ * @param scratch Object and compression storage owned by
+ * the lock.
+ * @param acquisition Exact queuez after-image promised by the correlated response.
+ *
+ * @param mutation Checked State after-image that remains uncommitted while output is staged.
+ *
+ * @param prepared Gets the two complete upsert descriptors in item-then-character dependency
+ *
+ * order.
+ * @return True when both after-image objects encode and fit atomically.
+ */
+[[nodiscard]] bool prepare_item_acquisition(Scratch& scratch,
+                                            const queuez::ItemAcquisition& acquisition,
+                                            const state::PendingItemAcquisition& mutation,
+                                            Prepared& prepared) noexcept;
+
+/**
+ * Builds one Family-4 increment containing the full account after-image for a profile stack.
+ *
+ * No resident is added: the account root is upserted at the exact staged +1 revision.
+ */
+[[nodiscard]] bool
+prepare_profile_item_acquisition(Scratch& scratch,
+                                 const queuez::ProfileItemAcquisition& acquisition,
+                                 const state::PendingProfileItemAcquisition& mutation,
+                                 Prepared& prepared) noexcept;
+
+/**
+ * Builds one Family-4 increment containing the changed character and released item instance.
+ *
+ * @param scratch Object and compression storage owned by the lock.
+ * @param dismantle Exact queuez
+ * after-image promised by the correlated response.
+ * @param mutation Checked State after-image
+ * that remains uncommitted while output is staged.
+ * @param prepared Gets the character upsert
+ * followed by the empty release descriptor.
+ * @return True when the character after-image and
+ * complete two-operation update fit atomically.
+ */
+[[nodiscard]] bool prepare_item_dismantle(Scratch& scratch,
+                                          const queuez::ItemDismantle& dismantle,
+                                          const state::PendingItemDismantle& mutation,
+                                          Prepared& prepared) noexcept;
+
 /** Selected-character mappings the character and item-instance encoders need. */
 struct Resolved {
     std::size_t characterIndex{};
@@ -171,4 +255,23 @@ append_items(Scratch& scratch,
              std::size_t& itemCursor,
              std::size_t& compressedExtent) noexcept;
 
+/** Resolves one source-backed profile stack into the shared Family-4 item-instance schema. */
+[[nodiscard]] bool resolve_profile_item_instance(
+    const state::account::inventory::ProfileItem& profileItem,
+    middleware::datagen::family4::instance::ResolvedInstance& output) noexcept;
+
+/**
+ * Appends every source-backed profile item after all character-owned item residents.
+ * Native
+ * currency/material/consumable rows have zero SOIDs and intentionally add no descriptor.
+ */
+[[nodiscard]] bool append_profile_items(Scratch& scratch,
+                                        std::span<std::byte> rawStorage,
+                                        std::uint32_t itemInstanceObjectId,
+                                        const state::AccountState& account,
+                                        std::size_t baseIndex,
+                                        Prepared& staged,
+                                        std::size_t& itemCursor,
+                                        std::size_t& compressedExtent) noexcept;
+
 } // namespace sunrise::server::bap::encrypted::push::snapshot

+ 95 - 0
Sunrise/src/server/bap/encrypted/push/snapshot/roster_snapshot.cpp

@@ -21,6 +21,7 @@ namespace character_record = middleware::datagen::character_record;
  * @param scratch Raw object storage owned by the lock.
  * @param account Account State read under the lock.
  * @param rawExtent First unused raw byte, advanced for every record.
+ * @param compressedExtent First unused sealed byte, advanced for every record.
  * @param staged Snapshot that takes one descriptor per character.
  * @param objectCount Descriptors already staged, advanced for every record.
  * @return True when every character is found and its record fits raw storage.
@@ -116,4 +117,98 @@ bool prepare_roster(Scratch& scratch,
     return commit(staged, prepared);
 }
 
+/** Builds one incremental Family-3 character record and optional changed account roster. */
+bool prepare_roster_appearance_refresh(Scratch& scratch,
+                                       const queuez::RosterAppearanceRefresh& refresh,
+                                       const state::CharacterState& afterCharacter,
+                                       std::size_t characterIndex,
+                                       Prepared& prepared) noexcept {
+    const Reservation reservation = reserve_prior(scratch, prepared);
+    if (reservation.rawWriteOffset > scratch.plaintext.size()
+        || reservation.compressedWriteOffset > scratch.sealed.size()) {
+        return false;
+    }
+
+    state::AccountState account = state::account_snapshot();
+    if (!refresh.after.family3Active || refresh.after.family3RootSoid == 0
+        || refresh.after.family3Version <= kInitialFamilyVersion || refresh.characterSoid == 0
+        || afterCharacter.soid != refresh.characterSoid || characterIndex >= account.characterCount
+        || account.characters[characterIndex].soid != refresh.characterSoid
+        || account.primarySoid != refresh.after.family3RootSoid) {
+        return false;
+    }
+    account.characters[characterIndex] = afterCharacter;
+    if (!state::account::valid(account)
+        || state::account::selected_character_soid(account) != refresh.characterSoid) {
+        return false;
+    }
+
+    middleware::datagen::family4::loadout::ResolvedInstances instances{};
+    std::int32_t light = 0;
+    if (!middleware::datagen::family4::loadout::resolve_instances(
+            account, characterIndex, instances)
+        || !state::equipment::light::resolution::character_light(account, characterIndex, light)) {
+        return false;
+    }
+
+    const std::size_t rosterSize =
+        refresh.includeRoster ? middleware::datagen::family3::kRosterSize : 0U;
+    const std::size_t rawSize = character_record::kFamily3RecordSize + rosterSize;
+    const auto rawStorage = std::span(scratch.plaintext).subspan(reservation.rawWriteOffset);
+    if (rawSize > rawStorage.size()) {
+        return false;
+    }
+    const auto characterBytes = rawStorage.first(character_record::kFamily3RecordSize);
+    if (!character_record::encode_family3(afterCharacter, instances, light, characterBytes)) {
+        return false;
+    }
+    std::span<std::byte> rosterBytes{};
+    if (refresh.includeRoster) {
+        rosterBytes = rawStorage.subspan(character_record::kFamily3RecordSize, rosterSize);
+        std::size_t encodedRosterSize = 0;
+        if (!middleware::datagen::family3::encode_roster(account, rosterBytes, encodedRosterSize)
+            || encodedRosterSize != rosterSize) {
+            return false;
+        }
+    }
+
+    Prepared staged{};
+    staged.rawClearSize =
+        (std::max)(reservation.rawClearSize, reservation.rawWriteOffset + rawSize);
+    std::size_t compressedExtent = reservation.compressedWriteOffset;
+    std::size_t objectCount = 0;
+    if (!append_object(scratch,
+                       characterBytes,
+                       middleware::datagen::kRosterCharacterObjectId,
+                       refresh.characterSoid,
+                       staged.objects[objectCount++],
+                       compressedExtent)) {
+        clear_after(scratch, reservation);
+        return false;
+    }
+    if (refresh.includeRoster
+        && !append_object(scratch,
+                          rosterBytes,
+                          middleware::datagen::kRosterObjectId,
+                          account.primarySoid,
+                          staged.objects[objectCount++],
+                          compressedExtent)) {
+        clear_after(scratch, reservation);
+        return false;
+    }
+    staged.compressedClearSize = (std::max)(reservation.compressedClearSize, compressedExtent);
+    staged.family = middleware::queuez::Family{
+        kRosterFamilyType,
+        refresh.after.family3RootSoid,
+        refresh.after.family3Version,
+        0,
+        std::span(staged.objects).first(objectCount),
+    };
+    if (!commit(staged, prepared)) {
+        clear_after(scratch, reservation);
+        return false;
+    }
+    return true;
+}
+
 } // namespace sunrise::server::bap::encrypted::push::snapshot

+ 53 - 4
Sunrise/src/server/bap/encrypted/push/snapshot/snapshot.h

@@ -14,13 +14,18 @@ namespace sunrise::server::bap::encrypted::push::snapshot {
 /** Account and selected-character identity take the first two family-four descriptors. */
 inline constexpr std::size_t kFamily4IdentityObjectCount = 2;
 /**
- * Family four carries both identity objects plus one item record per equipped item, for every
- * character. The equip-summary reader looks up an instance with no null check, so no character in
- * the roster may point at a record this snapshot leaves out.
+ * Family four carries both identity objects plus one record per equipped or unequipped character
+ *
+ * item and every resident-backed profile stack. The character inventory, equip-summary, and
+ *
+ * profile action-source readers all follow instance SOIDs, so every nonzero row key needs a
+ *
+ * published record. The fixed profile-row capacity also bounds future runtime acquisitions.
  */
 inline constexpr std::size_t kObjectCapacity =
     kFamily4IdentityObjectCount
-    + state::kCharacterCapacity * middleware::datagen::family4::loadout::kItemCapacity;
+    + state::kCharacterCapacity * middleware::datagen::family4::loadout::kItemCapacity
+    + state::account::inventory::kProfileActionSourceCapacity;
 
 /** Prepared descriptors and scratch extents owned until the update codec copies their bodies. */
 struct Prepared {
@@ -48,6 +53,16 @@ struct Prepared {
                                    const middleware::queuez::Subscription& subscription,
                                    Prepared& prepared) noexcept;
 
+/**
+ * Rebuilds the active account family as a full snapshot at the peer's next version.
+ * This is
+ * used only to repair another authenticated peer after shared State changes.
+ */
+[[nodiscard]] bool prepare_family4_refresh(Scratch& scratch,
+                                           std::uint64_t familyRootSoid,
+                                           std::int32_t version,
+                                           Prepared& prepared) noexcept;
+
 /**
  * Builds the family-zero banner anchor and the record for the character it names.
  * @param scratch Raw object storage owned by the lock.
@@ -64,4 +79,38 @@ struct Prepared {
                                   std::uint64_t previousCharacter,
                                   Prepared& prepared) noexcept;
 
+/**
+ * Builds the one-record Family-0 incremental that refreshes rendered equipment in place.
+ * The record is encoded from the prepared State after-image because the equipment transaction is
+ * not committed until both Family-4 and Family-0 frames fit.
+ * @param scratch Raw object storage owned by the lock.
+ * @param refresh Family-0 root, version, and resident the incremental is built against.
+ * @param afterCharacter Prepared State after-image the record is encoded from.
+ * @param characterIndex Position of that character in the account.
+ * @param nativeEquipmentSlot Native slot whose rendered item changed.
+ * @param replaceCharacterRecord Release and recreate the same resident record so same-instance
+ *        shader and ornament changes rebuild the live-world render binding.
+ * @param prepared Gets the descriptors and the scratch clear extent.
+ * @return True when the character resolves and every object fits raw storage.
+ */
+[[nodiscard]] bool
+prepare_character_appearance_refresh(Scratch& scratch,
+                                     const queuez::CharacterAppearanceRefresh& refresh,
+                                     const state::CharacterState& afterCharacter,
+                                     std::size_t characterIndex,
+                                     std::uint8_t nativeEquipmentSlot,
+                                     bool replaceCharacterRecord,
+                                     Prepared& prepared) noexcept;
+
+/**
+ * Builds one Family-3 appearance increment from an uncommitted character after-image.
+ * The
+ * character record is always first; when requested, the changed account roster follows it.
+ */
+[[nodiscard]] bool prepare_roster_appearance_refresh(Scratch& scratch,
+                                                     const queuez::RosterAppearanceRefresh& refresh,
+                                                     const state::CharacterState& afterCharacter,
+                                                     std::size_t characterIndex,
+                                                     Prepared& prepared) noexcept;
+
 } // namespace sunrise::server::bap::encrypted::push::snapshot

+ 93 - 4
Sunrise/src/server/bap/encrypted/queuez/definition.h

@@ -18,11 +18,17 @@ inline constexpr std::uint32_t kAccountFamilyType = 4;
 /** Initial and replayed full snapshots use version zero. */
 inline constexpr std::int32_t kInitialFamilyVersion = 0;
 /**
- * Family four holds account, character, and one id per equipped item on every character.
- * It matches the snapshot descriptor size, so a snapshot that builds always stages.
+ * Family four holds account, character, one id per character-owned item, and one id per
+ *
+ * resident-backed mod or shader stack. Profile currency rows deliberately carry no instance
+ *
+ * SOID; the fixed addition covers all 50 native rows in each supported action-source bucket.
+ * It
+ * matches the snapshot descriptor size, so a snapshot that builds always stages.
  */
 inline constexpr std::size_t kResidentCapacity =
-    2 + state::kCharacterCapacity * middleware::datagen::family4::loadout::kItemCapacity;
+    2 + state::kCharacterCapacity * middleware::datagen::family4::loadout::kItemCapacity
+    + state::account::inventory::kProfileActionSourceCapacity;
 /** Resident zero is the account object. The character object is found by its definition id. */
 
 /** When the roster is published after a change, as measured in the character-select flow. */
@@ -41,15 +47,21 @@ struct ResidentObject {
 /** Fixed queuez transaction state owned by one authenticated BAP peer. */
 struct SessionState {
     std::uint64_t family4RootSoid{};
+    /** Root whose Family-3 roster store has accepted its full snapshot. */
+    std::uint64_t family3RootSoid{};
     std::array<ResidentObject, kResidentCapacity> family4Residents{};
     /** Character the resident family-zero pair names. Only a change earns an incremental. */
     std::uint64_t family0Character{};
     std::int32_t family4Version{};
+    /** Version zero is the full roster; every accepted appearance refresh adds exactly one. */
+    std::int32_t family3Version{};
     /** Retail sets the full-snapshot flag once per family, then increments this by one. */
     std::int32_t family0Version{};
-    std::uint8_t family4ResidentCount{};
+    std::uint16_t family4ResidentCount{};
     Family3Phase family3Phase{Family3Phase::normal};
     bool family4Active{};
+    /** Set only after a complete Family-3 full-snapshot frame has been staged for publication. */
+    bool family3Active{};
     /** Set once the family-zero full snapshot has been published to this peer. */
     bool family0Active{};
     /**
@@ -84,6 +96,83 @@ struct SelectCharacter {
     bool patchAccount{};
 };
 
+/** Validated opcode-403 after-image for a single resident character upsert. */
+struct EquipmentSwap {
+    SessionState after{};
+    std::uint32_t characterDefinitionId{};
+    std::uint64_t characterSoid{};
+};
+
+/**
+ * Validated Family-0 after-image for the appearance record changed by one equipment swap.
+ *
+ * The resident character key does not move: the update upserts that one record in place.
+ */
+struct CharacterAppearanceRefresh {
+    SessionState after{};
+    std::uint64_t characterSoid{};
+};
+
+/** Validated Family-3 appearance after-image for one character and optional account roster. */
+struct RosterAppearanceRefresh {
+    SessionState after{};
+    std::uint64_t characterSoid{};
+    /** Equipment moves change the roster's slot definition; socket-only changes do not. */
+    bool includeRoster{};
+};
+
+/** Validated item-acquisition after-image for one character upsert and one new instance object. */
+struct ItemAcquisition {
+    SessionState after{};
+    std::uint32_t accountDefinitionId{};
+    std::uint32_t characterDefinitionId{};
+    std::uint32_t itemInstanceDefinitionId{};
+    std::uint64_t accountSoid{};
+    std::uint64_t characterSoid{};
+    std::uint64_t acquiredInstanceSoid{};
+    /** True when the same revision also publishes the charged profile-material balances. */
+    bool updatesAccount{};
+};
+
+/** Validated socket-action after-image for one item upsert and optional material balances. */
+struct SocketPlug {
+    SessionState after{};
+    std::uint32_t accountDefinitionId{};
+    std::uint32_t itemInstanceDefinitionId{};
+    std::uint64_t accountSoid{};
+    std::uint64_t characterSoid{};
+    std::uint64_t targetInstanceSoid{};
+    bool updatesAccount{};
+};
+
+/** Validated profile-stack acquisition after-image for an account upsert and optional resident. */
+struct ProfileItemAcquisition {
+    SessionState after{};
+    std::uint32_t accountDefinitionId{};
+    /** Family-4 slot-three schema used by a resident-backed shader or modification stack. */
+    std::uint32_t itemInstanceDefinitionId{};
+    std::uint64_t accountSoid{};
+    /** Stable profile-row identity, or zero for a non-actionable currency/material stack. */
+    std::uint64_t acquiredInstanceSoid{};
+    /** Shared installed-build classifier copied from the prepared State mutation. */
+    bool actionSource{};
+    /** True only when this revision appends the resident at the prior manifest tail. */
+    bool appendedResident{};
+};
+
+/** Validated item-dismantle after-image for one character upsert and one instance release. */
+struct ItemDismantle {
+    SessionState after{};
+    std::uint32_t accountDefinitionId{};
+    std::uint32_t characterDefinitionId{};
+    std::uint32_t itemInstanceDefinitionId{};
+    std::uint64_t accountSoid{};
+    std::uint64_t characterSoid{};
+    std::uint64_t dismantledInstanceSoid{};
+    /** True when the same revision also publishes credited profile materials. */
+    bool updatesAccount{};
+};
+
 /** Queuez fields published after every staged frame is copied to caller output. */
 struct StagedPublication {
     SessionState after{};

+ 81 - 0
Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp

@@ -19,6 +19,7 @@ constexpr std::size_t kRepushReportLimit = 96;
 
 /**
  * Logs one delayed re-push with its framed size, so it can be compared to the first copy.
+ * @param stage Point in the deferred push the line reports.
  * @param bytes Framed size of the published notification.
  */
 void report_repush(const char* stage, std::size_t bytes) noexcept {
@@ -32,6 +33,79 @@ void report_repush(const char* stage, std::size_t bytes) noexcept {
     }
 }
 
+/** Publishes the current account graph to a peer invalidated by another connection. */
+[[nodiscard]] bool consume_account_resync(Session& session,
+                                          Scratch& scratch,
+                                          std::span<std::byte> response,
+                                          std::size_t& written,
+                                          bool& touchesScratch) noexcept {
+    if (!session.accountResyncArmed || session.accountResyncGeneration == 0) {
+        return false;
+    }
+    touchesScratch = true;
+    auto nextSendNonce = session.sendNonce;
+    std::size_t framedSize = 0;
+    queuez::SessionState currentQueuez{};
+    if (!push::append_account_resync_notification(scratch,
+                                                  session.queuez,
+                                                  state::bap().sessionKey,
+                                                  nextSendNonce,
+                                                  scratch.framed,
+                                                  framedSize,
+                                                  currentQueuez)) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::warn,
+                         "ev=queuez stage=peer_resync result=fail reason=family4");
+        return false;
+    }
+    if (currentQueuez.family0Active) {
+        queuez::SessionState appearanceAfter{};
+        if (!push::append_account_resync_appearance_notification(scratch,
+                                                                 currentQueuez,
+                                                                 state::bap().sessionKey,
+                                                                 nextSendNonce,
+                                                                 scratch.framed,
+                                                                 framedSize,
+                                                                 appearanceAfter)) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             "ev=queuez stage=peer_resync result=fail reason=family0");
+            return false;
+        }
+        currentQueuez = appearanceAfter;
+    }
+    if (currentQueuez.family3Active) {
+        queuez::SessionState rosterAfter{};
+        if (!push::append_account_resync_roster_notification(scratch,
+                                                             currentQueuez,
+                                                             state::bap().sessionKey,
+                                                             nextSendNonce,
+                                                             scratch.framed,
+                                                             framedSize,
+                                                             rosterAfter)) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             "ev=queuez stage=peer_resync result=fail reason=family3");
+            return false;
+        }
+        currentQueuez = rosterAfter;
+    }
+    if (framedSize == 0 || framedSize > response.size() || !queuez::valid(currentQueuez)) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::warn,
+                         "ev=queuez stage=peer_resync result=fail reason=output");
+        return false;
+    }
+    std::copy_n(scratch.framed.begin(), framedSize, response.begin());
+    written = framedSize;
+    session.sendNonce = nextSendNonce;
+    session.queuez = currentQueuez;
+    session.accountGeneration = session.accountResyncGeneration;
+    session.accountResyncArmed = false;
+    report_repush("peer_resync", framedSize);
+    return true;
+}
+
 /**
  * Sends the owed banner re-push once its delay has passed.
  * The banner has no subscribe of its own, so the timer is its only second chance.
@@ -117,6 +191,13 @@ bool consume_deferred(Session& session,
     if (!session.authenticated) {
         return false;
     }
+    if (consume_account_resync(session, scratch, response, written, touchesScratch)) {
+        return true;
+    }
+    // A failed resync remains armed and blocks unrelated deferred output until it can be retried.
+    if (session.accountResyncArmed) {
+        return false;
+    }
     if (!session.family4RepushArmed || session.family4RepushRoot == 0
         || GetTickCount64() < session.family4RepushDueTick) {
         return consume_banner_repush(session, scratch, response, written, touchesScratch)

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

@@ -1,5 +1,7 @@
 #include "queuez_outcome_staging.h"
 
+#include <limits>
+
 #include "../../../../core/logging/log.h"
 #include "../../../../middleware/secure_channel/runtime.h"
 #include "queuez_state_validation.h"
@@ -20,6 +22,12 @@ bool stage_service_outcome(Scratch& scratch,
     bool armsRepush = false;
     bool armsBannerRepush = false;
     std::uint64_t bannerRoot = 0;
+    const auto* equipment = transaction_if<EquipmentSwapTransaction>(outcome);
+    const auto* itemState = transaction_if<ItemStateTransaction>(outcome);
+    const auto* socket = transaction_if<SocketPlugTransaction>(outcome);
+    const auto* itemAcquisition = transaction_if<ItemAcquisitionTransaction>(outcome);
+    const auto* profileAcquisition = transaction_if<ProfileItemAcquisitionTransaction>(outcome);
+    const auto* itemDismantle = transaction_if<ItemDismantleTransaction>(outcome);
     if (outcome.hasSubscription) {
         push::append_queuez_notification(scratch,
                                          before,
@@ -46,6 +54,296 @@ bool stage_service_outcome(Scratch& scratch,
         }
         middleware::secure_channel::advance_nonce(nonce);
         after = outcome.changeCharacter.after;
+    } else if (equipment != nullptr) {
+        // Body processing already staged this exact after-image so the correlated opcode-403
+        // response could promise its version. Reuse it here; staging a second revision would make
+        // the response and pushed Family-4 ladder disagree.
+        const EquipmentSwap& swap = equipment->update;
+        if (!valid(swap.after) || swap.characterSoid != equipment->pending.characterSoid
+            || swap.after.family4RootSoid != before.family4RootSoid
+            || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
+            || swap.after.family4Version != before.family4Version + 1
+            || !push::append_equipment_swap_notification(
+                scratch, swap, equipment->pending, key, nonce, response, written)) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             "ev=queuez stage=equip result=fail");
+            // The State transaction must not commit when the Client cannot receive its after-image.
+            return false;
+        }
+        middleware::secure_channel::advance_nonce(nonce);
+        after = swap.after;
+        // Family four drives inventory placement, while Family zero owns the rendered appearance
+        // consumed by the open cosmetic panels and world player. Its resident character record is
+        // updated in place: releasing and re-adding the same key tears down the ship/banner
+        // binding.
+        if (after.family0Active) {
+            CharacterAppearanceRefresh refresh{};
+            if (!stage_character_appearance_refresh(
+                    after, equipment->pending.characterSoid, refresh)
+                || !push::append_equipment_appearance_refresh_notification(
+                    scratch, refresh, equipment->pending, key, nonce, response, written)) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=queuez stage=equip_appearance result=fail");
+                // The State transaction and both peer ladders remain unpublished when either
+                // member of the paired Family-4/Family-0 delivery cannot fit.
+                return false;
+            }
+            after = refresh.after;
+        }
+        // Family three owns the orbit roster and a separate copy of the same appearance record.
+        // Equipment movement changes both bodies, so publish character first and roster second at
+        // one exact +1 Family-3 revision.  Failure keeps all three peer ladders and State private.
+        if (after.family3Active) {
+            RosterAppearanceRefresh refresh{};
+            if (!stage_roster_appearance_refresh(
+                    after, equipment->pending.characterSoid, true, refresh)
+                || !push::append_equipment_roster_refresh_notification(
+                    scratch, refresh, equipment->pending, key, nonce, response, written)) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=queuez stage=equip_roster result=fail");
+                return false;
+            }
+            after = refresh.after;
+        }
+    } else if (itemState != nullptr) {
+        // Item-state bits live in the selected-character inventory row. Publish only that
+        // resident character body; item-instance, appearance, roster and manifest are unchanged.
+        const EquipmentSwap& update = itemState->update;
+        bool preservedManifest = update.after.family4ResidentCount == before.family4ResidentCount;
+        for (std::size_t index = 0; preservedManifest && index < before.family4ResidentCount;
+             ++index) {
+            preservedManifest = update.after.family4Residents[index].objectSoid
+                                    == before.family4Residents[index].objectSoid
+                                && update.after.family4Residents[index].definitionId
+                                       == before.family4Residents[index].definitionId;
+        }
+        if (!valid(update.after) || !preservedManifest
+            || update.characterSoid != itemState->pending.characterSoid
+            || update.after.family4RootSoid != before.family4RootSoid
+            || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
+            || update.after.family4Version != before.family4Version + 1
+            || !push::append_item_state_notification(
+                scratch, update, itemState->pending, key, nonce, response, written)) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             "ev=queuez stage=item_state result=fail");
+            return false;
+        }
+        middleware::secure_channel::advance_nonce(nonce);
+        after = update.after;
+    } else if (socket != nullptr) {
+        // Body processing staged this exact +1 revision before encoding opcode 903's status pair.
+        // A socket selection changes only one already-resident item-instance body.
+        const SocketPlug& socketPlug = socket->update;
+        bool preservedManifest =
+            socketPlug.after.family4ResidentCount == before.family4ResidentCount;
+        std::size_t accountMatches = 0;
+        std::size_t targetMatches = 0;
+        for (std::size_t index = 0; preservedManifest && index < before.family4ResidentCount;
+             ++index) {
+            const ResidentObject& resident = before.family4Residents[index];
+            const ResidentObject& staged = socketPlug.after.family4Residents[index];
+            preservedManifest = staged.objectSoid == resident.objectSoid
+                                && staged.definitionId == resident.definitionId;
+            targetMatches += static_cast<std::size_t>(
+                resident.objectSoid == socketPlug.targetInstanceSoid
+                && resident.definitionId == socketPlug.itemInstanceDefinitionId);
+            accountMatches += static_cast<std::size_t>(resident.objectSoid == socketPlug.accountSoid
+                                                       && resident.definitionId
+                                                              == socketPlug.accountDefinitionId);
+        }
+        if (!valid(socketPlug.after) || !preservedManifest || accountMatches != 1
+            || targetMatches != 1 || socketPlug.accountSoid != socket->pending.accountSoid
+            || socketPlug.characterSoid != socket->pending.characterSoid
+            || socketPlug.targetInstanceSoid != socket->pending.targetInstanceSoid
+            || socketPlug.updatesAccount != socket->pending.profileChanged
+            || socketPlug.after.family4RootSoid != before.family4RootSoid
+            || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
+            || socketPlug.after.family4Version != before.family4Version + 1
+            || !push::append_socket_plug_notification(
+                scratch, socketPlug, socket->pending, key, nonce, response, written)) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             "ev=queuez stage=socket_plug result=fail");
+            return false;
+        }
+        middleware::secure_channel::advance_nonce(nonce);
+        after = socketPlug.after;
+        // Equipped plugs feed Family zero's material, overflow-hash and sandbox-perk banks. An
+        // inventory-only socket change has no rendered character record to refresh.
+        if (socket->pending.targetEquipped && after.family0Active) {
+            CharacterAppearanceRefresh refresh{};
+            if (!stage_character_appearance_refresh(after, socket->pending.characterSoid, refresh)
+                || !push::append_socket_appearance_refresh_notification(
+                    scratch, refresh, socket->pending, key, nonce, response, written)) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=queuez stage=socket_appearance result=fail");
+                return false;
+            }
+            after = refresh.after;
+        }
+        // A socket change can alter the rendered shader/perk banks in Family three, but it does not
+        // change the roster's base-definition references.  Only the character record is owed.
+        if (socket->pending.targetEquipped && after.family3Active) {
+            RosterAppearanceRefresh refresh{};
+            if (!stage_roster_appearance_refresh(
+                    after, socket->pending.characterSoid, false, refresh)
+                || !push::append_socket_roster_refresh_notification(
+                    scratch, refresh, socket->pending, key, nonce, response, written)) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=queuez stage=socket_roster result=fail");
+                return false;
+            }
+            after = refresh.after;
+        }
+    } else if (itemAcquisition != nullptr) {
+        // Body processing staged this exact manifest append before encoding the response version.
+        // The character and new item objects must both fit or the State insertion is not committed.
+        const ItemAcquisition& acquisition = itemAcquisition->update;
+        const std::size_t appendedIndex = before.family4ResidentCount;
+        bool preservedManifest = acquisition.after.family4ResidentCount == appendedIndex + 1U;
+        for (std::size_t index = 0; preservedManifest && index < appendedIndex; ++index) {
+            preservedManifest = acquisition.after.family4Residents[index].objectSoid
+                                    == before.family4Residents[index].objectSoid
+                                && acquisition.after.family4Residents[index].definitionId
+                                       == before.family4Residents[index].definitionId;
+        }
+        if (!valid(acquisition.after) || !preservedManifest
+            || acquisition.accountSoid != itemAcquisition->pending.accountSoid
+            || acquisition.characterSoid != itemAcquisition->pending.characterSoid
+            || acquisition.acquiredInstanceSoid != itemAcquisition->pending.acquiredInstanceSoid
+            || acquisition.updatesAccount != itemAcquisition->pending.profileChanged
+            || acquisition.accountSoid != before.family4RootSoid
+            || acquisition.after.family4RootSoid != before.family4RootSoid
+            || before.family4ResidentCount >= before.family4Residents.size()
+            || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
+            || acquisition.after.family4Version != before.family4Version + 1
+            || acquisition.after.family4Residents[appendedIndex].objectSoid
+                   != acquisition.acquiredInstanceSoid
+            || acquisition.after.family4Residents[appendedIndex].definitionId
+                   != acquisition.itemInstanceDefinitionId
+            || !push::append_item_acquisition_notification(
+                scratch, acquisition, itemAcquisition->pending, key, nonce, response, written)) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             "ev=queuez stage=acquire result=fail");
+            return false;
+        }
+        middleware::secure_channel::advance_nonce(nonce);
+        after = acquisition.after;
+    } else if (profileAcquisition != nullptr) {
+        // A source-backed profile append creates one dependency before the account starts naming
+        // it. Existing stacks and non-actionable currency rows preserve the complete manifest.
+        const ProfileItemAcquisition& acquisition = profileAcquisition->update;
+        const std::size_t priorResidentCount = before.family4ResidentCount;
+        const std::size_t expectedResidentCount =
+            priorResidentCount + static_cast<std::size_t>(acquisition.appendedResident);
+        bool validManifest = expectedResidentCount <= acquisition.after.family4Residents.size()
+                             && acquisition.after.family4ResidentCount == expectedResidentCount;
+        for (std::size_t index = 0; validManifest && index < priorResidentCount; ++index) {
+            validManifest = acquisition.after.family4Residents[index].objectSoid
+                                == before.family4Residents[index].objectSoid
+                            && acquisition.after.family4Residents[index].definitionId
+                                   == before.family4Residents[index].definitionId;
+        }
+        std::size_t priorProfileResidentMatches = 0;
+        for (std::size_t index = 0; index < priorResidentCount; ++index) {
+            const ResidentObject& resident = before.family4Residents[index];
+            priorProfileResidentMatches += static_cast<std::size_t>(
+                acquisition.acquiredInstanceSoid != 0
+                && resident.objectSoid == acquisition.acquiredInstanceSoid
+                && resident.definitionId == acquisition.itemInstanceDefinitionId);
+        }
+        const bool appendedResidentValid =
+            !acquisition.appendedResident
+            || (priorResidentCount < acquisition.after.family4Residents.size()
+                && acquisition.after.family4Residents[priorResidentCount].objectSoid
+                       == acquisition.acquiredInstanceSoid
+                && acquisition.after.family4Residents[priorResidentCount].definitionId
+                       == acquisition.itemInstanceDefinitionId
+                && priorProfileResidentMatches == 0);
+        const bool sourceIdentityValid =
+            acquisition.actionSource == (acquisition.acquiredInstanceSoid != 0)
+            && (acquisition.actionSource
+                    ? acquisition.itemInstanceDefinitionId != 0 && appendedResidentValid
+                          && (acquisition.appendedResident || priorProfileResidentMatches == 1)
+                    : acquisition.itemInstanceDefinitionId == 0 && !acquisition.appendedResident
+                          && priorProfileResidentMatches == 0);
+        if (!valid(acquisition.after) || !validManifest || !sourceIdentityValid
+            || acquisition.accountSoid != profileAcquisition->pending.accountSoid
+            || acquisition.acquiredInstanceSoid != profileAcquisition->pending.acquiredInstanceSoid
+            || acquisition.actionSource != profileAcquisition->pending.actionSource
+            || acquisition.appendedResident
+                   != (profileAcquisition->pending.appended
+                       && profileAcquisition->pending.actionSource)
+            || acquisition.accountSoid != before.family4RootSoid || before.family4ResidentCount == 0
+            || acquisition.accountDefinitionId != before.family4Residents.front().definitionId
+            || acquisition.after.family4RootSoid != before.family4RootSoid
+            || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
+            || acquisition.after.family4Version != before.family4Version + 1
+            || !push::append_profile_item_acquisition_notification(
+                scratch, acquisition, profileAcquisition->pending, key, nonce, response, written)) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             "ev=queuez stage=profile_acquire result=fail");
+            return false;
+        }
+        middleware::secure_channel::advance_nonce(nonce);
+        after = acquisition.after;
+    } else if (itemDismantle != nullptr) {
+        // A dismantle removes exactly one resident while preserving the relative order of every
+        // survivor. The character after-image and empty release descriptor must fit together or
+        // the State removal is not committed.
+        const ItemDismantle& dismantle = itemDismantle->update;
+        bool compactedManifest =
+            before.family4ResidentCount != 0
+            && dismantle.after.family4ResidentCount + 1U == before.family4ResidentCount;
+        std::size_t afterIndex = 0;
+        std::size_t removedCount = 0;
+        for (std::size_t beforeIndex = 0;
+             compactedManifest && beforeIndex < before.family4ResidentCount;
+             ++beforeIndex) {
+            const ResidentObject& resident = before.family4Residents[beforeIndex];
+            if (resident.objectSoid == dismantle.dismantledInstanceSoid) {
+                compactedManifest = resident.definitionId == dismantle.itemInstanceDefinitionId;
+                ++removedCount;
+                continue;
+            }
+            if (afterIndex >= dismantle.after.family4ResidentCount) {
+                compactedManifest = false;
+                break;
+            }
+            const ResidentObject& survivor = dismantle.after.family4Residents[afterIndex++];
+            compactedManifest = survivor.objectSoid == resident.objectSoid
+                                && survivor.definitionId == resident.definitionId;
+        }
+        compactedManifest = compactedManifest && removedCount == 1U
+                            && afterIndex == dismantle.after.family4ResidentCount;
+
+        if (!valid(dismantle.after) || !compactedManifest
+            || dismantle.accountSoid != itemDismantle->pending.accountSoid
+            || dismantle.characterSoid != itemDismantle->pending.characterSoid
+            || dismantle.dismantledInstanceSoid != itemDismantle->pending.dismantledInstanceSoid
+            || dismantle.updatesAccount != itemDismantle->pending.profileChanged
+            || dismantle.accountSoid != before.family4RootSoid || before.family4ResidentCount == 0
+            || dismantle.accountDefinitionId != before.family4Residents.front().definitionId
+            || dismantle.after.family4RootSoid != before.family4RootSoid
+            || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
+            || dismantle.after.family4Version != before.family4Version + 1
+            || !push::append_item_dismantle_notification(
+                scratch, dismantle, itemDismantle->pending, key, nonce, response, written)) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             "ev=queuez stage=dismantle result=fail");
+            return false;
+        }
+        middleware::secure_channel::advance_nonce(nonce);
+        after = dismantle.after;
     } else if (outcome.hasSelectCharacter) {
         // The reply is the Client's task completion and the move is a separate frame. A move that
         // cannot be built leaves the selection where it is, instead of holding back that reply.

+ 13 - 0
Sunrise/src/server/bap/encrypted/queuez/queuez_state_validation.cpp

@@ -18,6 +18,16 @@ bool valid(const SessionState& state) noexcept {
     if (!valid_phase(state.family3Phase)) {
         return false;
     }
+    // Family three has its own object store and version ladder.  It can become active one frame
+    // before the Family-4 companion during boot, but an active pair must always share one root.
+    if (state.family3Active) {
+        if (state.family3RootSoid == 0 || state.family3Version < kInitialFamilyVersion) {
+            return false;
+        }
+    } else if (state.family3RootSoid != 0 || state.family3Version != kInitialFamilyVersion
+               || state.family3Phase != Family3Phase::normal) {
+        return false;
+    }
     // Family zero holds no resident manifest, so its whole contract is the version ladder. An
     // inactive family carries nothing. An active one names a character and never goes back.
     if (state.family0Active) {
@@ -43,6 +53,9 @@ bool valid(const SessionState& state) noexcept {
         || state.family4ResidentCount > state.family4Residents.size()) {
         return false;
     }
+    if (state.family3Active && state.family3RootSoid != state.family4RootSoid) {
+        return false;
+    }
     // Version zero is the full snapshot, and the roster phase leaves normal only once an
     // increment has gone out. Above zero the ladder is open. Opcode 505 and every ws-504
     // character move add one, so there is no fixed top.

+ 118 - 0
Sunrise/src/server/bap/encrypted/queuez/queuez_state_validation.h

@@ -22,6 +22,11 @@ namespace sunrise::server::bap::encrypted::queuez {
                                           const middleware::queuez::Family& family,
                                           SessionState& after) noexcept;
 
+/** Replaces an active peer's Family-4 manifest with a full next-version snapshot. */
+[[nodiscard]] bool stage_family4_refresh(const SessionState& before,
+                                         const middleware::queuez::Family& family,
+                                         SessionState& after) noexcept;
+
 /**
  * Decides whether one family-zero subscription publishes, and as which kind of frame.
  * Retail sets the full-snapshot flag once per family and adds one to every later push, so a
@@ -42,6 +47,7 @@ namespace sunrise::server::bap::encrypted::queuez {
 /**
  * Decides whether one validated Family-3 subscription publishes a snapshot.
  * @param before Current queuez state owned by the peer.
+ * @param subscription Family selector the Client asked for.
  * @param publish Gets whether a svc-123 frame is needed.
  * @param after Gets the state published after the whole response transaction.
  * @return True when the selector belongs to the active post-change root, where that is needed.
@@ -71,6 +77,118 @@ namespace sunrise::server::bap::encrypted::queuez {
                                           std::uint64_t selectedCharacterSoid,
                                           SelectCharacter& select) noexcept;
 
+/**
+ * Stages one Family-4 version increment without changing its resident manifest.
+ * @param before Current queuez state owned by the peer.
+ * @param characterSoid Selected character whose equipment changed.
+ * @param swap Gets the checked version after-image and resident definition.
+ * @return True only when the named character object is resident in active Family 4.
+ */
+[[nodiscard]] bool stage_equipment_swap(const SessionState& before,
+                                        std::uint64_t characterSoid,
+                                        EquipmentSwap& swap) noexcept;
+
+/**
+ * Stages one same-character Family-0 appearance-record increment after an equipment swap.
+ * Family zero already owns the record, so the character key is preserved and only its version
+ * ladder advances.
+ *
+ * @param before Peer state after the corresponding Family-4 character upsert.
+ * @param characterSoid Selected character whose rendered equipment changed.
+ * @param refresh Gets the exact combined Family-4/Family-0 after-image.
+ * @return True only when active Family zero already names the same character.
+ */
+[[nodiscard]] bool stage_character_appearance_refresh(const SessionState& before,
+                                                      std::uint64_t characterSoid,
+                                                      CharacterAppearanceRefresh& refresh) noexcept;
+
+/**
+ * Advances the active Family-3 ladder for one appearance refresh.
+ * @param before Peer state after any paired Family-4 and Family-0 frames.
+ * @param characterSoid Character whose Family-3 record changes.
+ * @param includeRoster Whether the account roster body changes in the same increment.
+ * @param refresh Gets the exact +1 Family-3 after-image.
+ * @return True only for an active roster store rooted with the peer's account family.
+ */
+[[nodiscard]] bool stage_roster_appearance_refresh(const SessionState& before,
+                                                   std::uint64_t characterSoid,
+                                                   bool includeRoster,
+                                                   RosterAppearanceRefresh& refresh) noexcept;
+
+/**
+ * Stages one Family-4 version increment without changing its resident manifest.
+ * @param before Current active peer state.
+ * @param accountSoid Account root the peer's family is rooted with.
+ * @param characterSoid Selected resident character owning the changed item.
+ * @param targetInstanceSoid Existing resident item-instance key to upsert.
+ * @param updatesAccount True when the account object rides the same increment.
+ * @param socketPlug Gets the exact +1 version and item-instance schema id.
+ * @return True when both the selected character and target instance are resident exactly once.
+ */
+[[nodiscard]] bool stage_socket_plug(const SessionState& before,
+                                     std::uint64_t accountSoid,
+                                     std::uint64_t characterSoid,
+                                     std::uint64_t targetInstanceSoid,
+                                     bool updatesAccount,
+                                     SocketPlug& socketPlug) noexcept;
+
+/**
+ * Stages one Family-4 increment that adds a new resident item and updates its character.
+ *
+ * @param before Current active peer state.
+ * @param accountSoid Account root the peer's family is rooted with.
+ * @param characterSoid Selected resident character receiving the item.
+ * @param acquiredInstanceSoid Fresh item-instance SOID absent from the resident manifest.
+ * @param updatesAccount True when the account object rides the same increment.
+ * @param acquisition Gets the exact +1 version and appended resident after-image.
+ * @return True when both schemas resolve and the manifest has one free resident slot.
+ */
+[[nodiscard]] bool stage_item_acquisition(const SessionState& before,
+                                          std::uint64_t accountSoid,
+                                          std::uint64_t characterSoid,
+                                          std::uint64_t acquiredInstanceSoid,
+                                          bool updatesAccount,
+                                          ItemAcquisition& acquisition) noexcept;
+
+/**
+ * Stages one Family-4 version increment for a full resident account-object upsert.
+ * A profile row with a nonzero action-source SOID must already be resident when its stack grows,
+ * or is appended exactly once when Collections creates the row. Currency/material rows keep a
+ * zero SOID and preserve the manifest.
+ *
+ * @param before Current active peer state.
+ * @param accountSoid Account root receiving the profile stack.
+ * @param acquiredInstanceSoid Profile action-source key, or zero for a non-actionable stack.
+ * @param actionSource Shared installed-build classification from the prepared State mutation.
+ * @param appended True when the account mutation created a new profile row.
+ * @param acquisition Gets the exact +1 version, definitions, and optional manifest append.
+ * @return True when the account/root and optional profile resident transition are exact.
+ */
+[[nodiscard]] bool stage_profile_item_acquisition(const SessionState& before,
+                                                  std::uint64_t accountSoid,
+                                                  std::uint64_t acquiredInstanceSoid,
+                                                  bool actionSource,
+                                                  bool appended,
+                                                  ProfileItemAcquisition& acquisition) noexcept;
+
+/**
+ * Stages one Family-4 increment that removes an item resident and updates its character.
+ *
+ * @param before Current active peer state.
+ * @param accountSoid Account root the peer's family is rooted with.
+ * @param characterSoid Selected resident character losing the item.
+ * @param dismantledInstanceSoid Existing item-instance SOID to release.
+ * @param updatesAccount True when the account object rides the same increment.
+ * @param dismantle Gets the exact +1 version and compacted resident after-image.
+ * @return True when both schemas and both named residents exist exactly once.
+ */
+[[nodiscard]] bool stage_item_dismantle(const SessionState& before,
+                                        std::uint64_t accountSoid,
+                                        std::uint64_t characterSoid,
+                                        std::uint64_t dismantledInstanceSoid,
+                                        bool updatesAccount,
+                                        ItemDismantle& dismantle) noexcept;
+
 /** Clears state for the active root. Zero or another root leaves the state unchanged. */
 void stage_unsubscription(const SessionState& before,
                           std::uint64_t familyRootSoid,

+ 474 - 2
Sunrise/src/server/bap/encrypted/queuez/staging/queuez_character_staging.cpp

@@ -1,5 +1,9 @@
+#include <array>
 #include <cstddef>
+#include <cstdio>
+#include <limits>
 
+#include "../../../../../core/logging/log.h"
 #include "../../../../../middleware/datagen/definitions.h"
 #include "../queuez_state_validation.h"
 
@@ -8,8 +12,8 @@ namespace sunrise::server::bap::encrypted::queuez {
 /** Stages the account-selection patch without changing the resident manifest. */
 bool stage_change_character(const SessionState& before, ChangeCharacter& change) noexcept {
     change = {};
-    if (!valid(before) || !before.family4Active || before.family4RootSoid == 0
-        || before.family4ResidentCount == 0
+    if (!valid(before) || !before.family4Active || !before.family3Active
+        || before.family4RootSoid == 0 || before.family4ResidentCount == 0
         || before.family4ResidentCount > before.family4Residents.size()
         || before.family3Phase != Family3Phase::normal) {
         return false;
@@ -99,4 +103,472 @@ bool stage_select_character(const SessionState& before,
     return valid(select.after);
 }
 
+/** Stages a resident character upsert while preserving the Family-4 object manifest. */
+bool stage_equipment_swap(const SessionState& before,
+                          std::uint64_t characterSoid,
+                          EquipmentSwap& swap) noexcept {
+    swap = {};
+    std::uint32_t characterDefinitionId = 0;
+    if (!valid(before) || !before.family4Active || before.family4RootSoid == 0 || characterSoid == 0
+        || before.family4ResidentCount == 0
+        || before.family4ResidentCount > before.family4Residents.size()
+        || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
+        || !middleware::datagen::object_id(
+            kAccountFamilyType, middleware::datagen::kCharacterSlot, characterDefinitionId)) {
+        return false;
+    }
+    bool resident = false;
+    for (std::size_t index = 0; index < before.family4ResidentCount; ++index) {
+        const ResidentObject& object = before.family4Residents[index];
+        if (object.definitionId == characterDefinitionId && object.objectSoid == characterSoid) {
+            resident = true;
+            break;
+        }
+    }
+    if (!resident) {
+        return false;
+    }
+    swap.after = before;
+    ++swap.after.family4Version;
+    swap.characterDefinitionId = characterDefinitionId;
+    swap.characterSoid = characterSoid;
+    const bool staged = valid(swap.after);
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count = std::snprintf(
+        line.data(),
+        line.size(),
+        "ev=equip stage=queuez_version result=%s root=0x%llX before=%d after=%d residents=%u "
+        "character=0x%llX definition=%u",
+        staged ? "ok" : "fail",
+        static_cast<unsigned long long>(before.family4RootSoid),
+        before.family4Version,
+        swap.after.family4Version,
+        static_cast<unsigned>(before.family4ResidentCount),
+        static_cast<unsigned long long>(characterSoid),
+        characterDefinitionId);
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         staged ? core::log::Level::debug : core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+    return staged;
+}
+
+/** Stages a same-character Family-0 appearance-record upsert after an equipment swap. */
+bool stage_character_appearance_refresh(const SessionState& before,
+                                        std::uint64_t characterSoid,
+                                        CharacterAppearanceRefresh& refresh) noexcept {
+    refresh = {};
+    if (!valid(before) || !before.family4Active || before.family4RootSoid == 0
+        || !before.family0Active || characterSoid == 0 || before.family0Character != characterSoid
+        || before.family0Version == (std::numeric_limits<std::int32_t>::max)()) {
+        return false;
+    }
+    refresh.after = before;
+    ++refresh.after.family0Version;
+    refresh.characterSoid = characterSoid;
+    const bool staged = valid(refresh.after);
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count =
+        std::snprintf(line.data(),
+                      line.size(),
+                      "ev=equip stage=family0_version result=%s root=0x%llX before=%d after=%d "
+                      "character=0x%llX",
+                      staged ? "ok" : "fail",
+                      static_cast<unsigned long long>(before.family4RootSoid),
+                      before.family0Version,
+                      refresh.after.family0Version,
+                      static_cast<unsigned long long>(characterSoid));
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         staged ? core::log::Level::debug : core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+    return staged;
+}
+
+/** Stages one in-place Family-3 character refresh and its optional account roster upsert. */
+bool stage_roster_appearance_refresh(const SessionState& before,
+                                     std::uint64_t characterSoid,
+                                     bool includeRoster,
+                                     RosterAppearanceRefresh& refresh) noexcept {
+    refresh = {};
+    if (!valid(before) || !before.family3Active || before.family3RootSoid == 0 || characterSoid == 0
+        || before.family3Version == (std::numeric_limits<std::int32_t>::max)()
+        || (before.family4Active && before.family4RootSoid != before.family3RootSoid)) {
+        return false;
+    }
+    refresh.after = before;
+    ++refresh.after.family3Version;
+    refresh.characterSoid = characterSoid;
+    refresh.includeRoster = includeRoster;
+    const bool staged = valid(refresh.after);
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count = std::snprintf(
+        line.data(),
+        line.size(),
+        "ev=appearance stage=family3_version result=%s root=0x%llX before=%d after=%d "
+        "character=0x%llX roster=%u",
+        staged ? "ok" : "fail",
+        static_cast<unsigned long long>(before.family3RootSoid),
+        before.family3Version,
+        refresh.after.family3Version,
+        static_cast<unsigned long long>(characterSoid),
+        includeRoster ? 1U : 0U);
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         staged ? core::log::Level::debug : core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+    return staged;
+}
+
+/** Stages a resident item-instance upsert while preserving the Family-4 manifest. */
+bool stage_socket_plug(const SessionState& before,
+                       std::uint64_t accountSoid,
+                       std::uint64_t characterSoid,
+                       std::uint64_t targetInstanceSoid,
+                       bool updatesAccount,
+                       SocketPlug& socketPlug) noexcept {
+    socketPlug = {};
+    std::uint32_t accountDefinitionId = 0;
+    std::uint32_t characterDefinitionId = 0;
+    std::uint32_t itemInstanceDefinitionId = 0;
+    if (!valid(before) || !before.family4Active || before.family4RootSoid == 0 || accountSoid == 0
+        || accountSoid != before.family4RootSoid || characterSoid == 0 || targetInstanceSoid == 0
+        || characterSoid == targetInstanceSoid || before.family4ResidentCount == 0
+        || before.family4ResidentCount > before.family4Residents.size()
+        || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
+        || !middleware::datagen::object_id(
+            kAccountFamilyType, middleware::datagen::kAccountSlot, accountDefinitionId)
+        || !middleware::datagen::object_id(
+            kAccountFamilyType, middleware::datagen::kCharacterSlot, characterDefinitionId)
+        || !middleware::datagen::object_id(
+            kAccountFamilyType, middleware::datagen::kItemInstanceSlot, itemInstanceDefinitionId)) {
+        return false;
+    }
+
+    std::size_t accountMatches = 0;
+    std::size_t characterMatches = 0;
+    std::size_t targetMatches = 0;
+    for (std::size_t index = 0; index < before.family4ResidentCount; ++index) {
+        const ResidentObject& object = before.family4Residents[index];
+        accountMatches += static_cast<std::size_t>(object.objectSoid == accountSoid
+                                                   && object.definitionId == accountDefinitionId);
+        characterMatches += static_cast<std::size_t>(
+            object.objectSoid == characterSoid && object.definitionId == characterDefinitionId);
+        targetMatches +=
+            static_cast<std::size_t>(object.objectSoid == targetInstanceSoid
+                                     && object.definitionId == itemInstanceDefinitionId);
+    }
+    if (accountMatches != 1 || characterMatches != 1 || targetMatches != 1) {
+        return false;
+    }
+
+    socketPlug.after = before;
+    ++socketPlug.after.family4Version;
+    socketPlug.accountDefinitionId = accountDefinitionId;
+    socketPlug.itemInstanceDefinitionId = itemInstanceDefinitionId;
+    socketPlug.accountSoid = accountSoid;
+    socketPlug.characterSoid = characterSoid;
+    socketPlug.targetInstanceSoid = targetInstanceSoid;
+    socketPlug.updatesAccount = updatesAccount;
+    const bool staged = valid(socketPlug.after);
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count = std::snprintf(
+        line.data(),
+        line.size(),
+        "ev=socket_plug stage=queuez_version result=%s root=0x%llX before=%d after=%d "
+        "residents=%u account=0x%llX character=0x%llX instance=0x%llX "
+        "item_definition=%u account_update=%u",
+        staged ? "ok" : "fail",
+        static_cast<unsigned long long>(before.family4RootSoid),
+        before.family4Version,
+        socketPlug.after.family4Version,
+        static_cast<unsigned>(before.family4ResidentCount),
+        static_cast<unsigned long long>(accountSoid),
+        static_cast<unsigned long long>(characterSoid),
+        static_cast<unsigned long long>(targetInstanceSoid),
+        itemInstanceDefinitionId,
+        static_cast<unsigned>(updatesAccount));
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         staged ? core::log::Level::debug : core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+    return staged;
+}
+
+/** Stages the character upsert and appended resident required by one new item instance. */
+bool stage_item_acquisition(const SessionState& before,
+                            std::uint64_t accountSoid,
+                            std::uint64_t characterSoid,
+                            std::uint64_t acquiredInstanceSoid,
+                            bool updatesAccount,
+                            ItemAcquisition& acquisition) noexcept {
+    acquisition = {};
+    std::uint32_t accountDefinitionId = 0;
+    std::uint32_t characterDefinitionId = 0;
+    std::uint32_t itemInstanceDefinitionId = 0;
+    if (!valid(before) || !before.family4Active || before.family4RootSoid == 0 || accountSoid == 0
+        || accountSoid != before.family4RootSoid || characterSoid == 0 || acquiredInstanceSoid == 0
+        || before.family4ResidentCount == 0
+        || before.family4ResidentCount >= before.family4Residents.size()
+        || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
+        || !middleware::datagen::object_id(
+            kAccountFamilyType, middleware::datagen::kAccountSlot, accountDefinitionId)
+        || !middleware::datagen::object_id(
+            kAccountFamilyType, middleware::datagen::kCharacterSlot, characterDefinitionId)
+        || !middleware::datagen::object_id(
+            kAccountFamilyType, middleware::datagen::kItemInstanceSlot, itemInstanceDefinitionId)) {
+        return false;
+    }
+
+    bool accountResident = false;
+    bool characterResident = false;
+    for (std::size_t index = 0; index < before.family4ResidentCount; ++index) {
+        const ResidentObject& object = before.family4Residents[index];
+        if (object.objectSoid == acquiredInstanceSoid) {
+            return false;
+        }
+        accountResident =
+            accountResident
+            || (object.definitionId == accountDefinitionId && object.objectSoid == accountSoid);
+        characterResident =
+            characterResident
+            || (object.definitionId == characterDefinitionId && object.objectSoid == characterSoid);
+    }
+    if (!accountResident || !characterResident) {
+        return false;
+    }
+
+    acquisition.after = before;
+    ++acquisition.after.family4Version;
+    acquisition.after.family4Residents[before.family4ResidentCount] =
+        ResidentObject{acquiredInstanceSoid, itemInstanceDefinitionId};
+    ++acquisition.after.family4ResidentCount;
+    acquisition.accountDefinitionId = accountDefinitionId;
+    acquisition.characterDefinitionId = characterDefinitionId;
+    acquisition.itemInstanceDefinitionId = itemInstanceDefinitionId;
+    acquisition.accountSoid = accountSoid;
+    acquisition.characterSoid = characterSoid;
+    acquisition.acquiredInstanceSoid = acquiredInstanceSoid;
+    acquisition.updatesAccount = updatesAccount;
+    const bool staged = valid(acquisition.after);
+
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count =
+        std::snprintf(line.data(),
+                      line.size(),
+                      "ev=acquire stage=queuez_version result=%s root=0x%llX before=%d after=%d "
+                      "residents_before=%u residents_after=%u character=0x%llX instance=0x%llX "
+                      "character_definition=%u item_definition=%u account_update=%u",
+                      staged ? "ok" : "fail",
+                      static_cast<unsigned long long>(before.family4RootSoid),
+                      before.family4Version,
+                      acquisition.after.family4Version,
+                      static_cast<unsigned>(before.family4ResidentCount),
+                      static_cast<unsigned>(acquisition.after.family4ResidentCount),
+                      static_cast<unsigned long long>(characterSoid),
+                      static_cast<unsigned long long>(acquiredInstanceSoid),
+                      characterDefinitionId,
+                      itemInstanceDefinitionId,
+                      static_cast<unsigned>(updatesAccount));
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         staged ? core::log::Level::debug : core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+    return staged;
+}
+
+/** Stages an account upsert and, for a newly source-backed profile row, one manifest append. */
+bool stage_profile_item_acquisition(const SessionState& before,
+                                    std::uint64_t accountSoid,
+                                    std::uint64_t acquiredInstanceSoid,
+                                    bool actionSource,
+                                    bool appended,
+                                    ProfileItemAcquisition& acquisition) noexcept {
+    acquisition = {};
+    std::uint32_t accountDefinitionId = 0;
+    std::uint32_t itemInstanceDefinitionId = 0;
+    if (!valid(before) || actionSource != (acquiredInstanceSoid != 0) || !before.family4Active
+        || before.family4RootSoid == 0 || accountSoid == 0 || accountSoid != before.family4RootSoid
+        || before.family4ResidentCount == 0
+        || before.family4ResidentCount > before.family4Residents.size()
+        || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
+        || !middleware::datagen::object_id(
+            kAccountFamilyType, middleware::datagen::kAccountSlot, accountDefinitionId)) {
+        return false;
+    }
+    const ResidentObject& account = before.family4Residents.front();
+    if (account.objectSoid != accountSoid || account.definitionId != accountDefinitionId) {
+        return false;
+    }
+
+    std::size_t profileResidentMatches = 0;
+    if (actionSource) {
+        if (!middleware::datagen::object_id(kAccountFamilyType,
+                                            middleware::datagen::kItemInstanceSlot,
+                                            itemInstanceDefinitionId)) {
+            return false;
+        }
+        for (std::size_t index = 0; index < before.family4ResidentCount; ++index) {
+            const ResidentObject& resident = before.family4Residents[index];
+            if (resident.objectSoid != acquiredInstanceSoid) {
+                continue;
+            }
+            if (resident.definitionId != itemInstanceDefinitionId) {
+                return false;
+            }
+            ++profileResidentMatches;
+        }
+    }
+
+    const bool appendResident = appended && actionSource;
+    if ((appendResident
+         && (profileResidentMatches != 0
+             || before.family4ResidentCount >= before.family4Residents.size()))
+        || (!appendResident && acquiredInstanceSoid != 0 && profileResidentMatches != 1)) {
+        return false;
+    }
+
+    acquisition.after = before;
+    ++acquisition.after.family4Version;
+    if (appendResident) {
+        acquisition.after.family4Residents[before.family4ResidentCount] =
+            ResidentObject{acquiredInstanceSoid, itemInstanceDefinitionId};
+        ++acquisition.after.family4ResidentCount;
+    }
+    acquisition.accountDefinitionId = accountDefinitionId;
+    acquisition.itemInstanceDefinitionId = itemInstanceDefinitionId;
+    acquisition.accountSoid = accountSoid;
+    acquisition.acquiredInstanceSoid = acquiredInstanceSoid;
+    acquisition.actionSource = actionSource;
+    acquisition.appendedResident = appendResident;
+    const bool staged = valid(acquisition.after);
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count = std::snprintf(
+        line.data(),
+        line.size(),
+        "ev=profile_acquire stage=queuez_version result=%s root=0x%llX before=%d after=%d "
+        "residents_before=%u residents_after=%u account_definition=%u "
+        "instance=0x%llX item_definition=%u action_source=%u appended_row=%u "
+        "appended_resident=%u",
+        staged ? "ok" : "fail",
+        static_cast<unsigned long long>(before.family4RootSoid),
+        before.family4Version,
+        acquisition.after.family4Version,
+        static_cast<unsigned>(before.family4ResidentCount),
+        static_cast<unsigned>(acquisition.after.family4ResidentCount),
+        accountDefinitionId,
+        static_cast<unsigned long long>(acquiredInstanceSoid),
+        itemInstanceDefinitionId,
+        static_cast<unsigned>(actionSource),
+        static_cast<unsigned>(appended),
+        static_cast<unsigned>(appendResident));
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         staged ? core::log::Level::debug : core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+    return staged;
+}
+
+/** Stages the character upsert and resident release required by one item dismantle. */
+bool stage_item_dismantle(const SessionState& before,
+                          std::uint64_t accountSoid,
+                          std::uint64_t characterSoid,
+                          std::uint64_t dismantledInstanceSoid,
+                          bool updatesAccount,
+                          ItemDismantle& dismantle) noexcept {
+    dismantle = {};
+    std::uint32_t accountDefinitionId = 0;
+    std::uint32_t characterDefinitionId = 0;
+    std::uint32_t itemInstanceDefinitionId = 0;
+    if (!valid(before) || !before.family4Active || before.family4RootSoid == 0 || accountSoid == 0
+        || accountSoid != before.family4RootSoid || characterSoid == 0
+        || dismantledInstanceSoid == 0 || before.family4ResidentCount == 0
+        || before.family4ResidentCount > before.family4Residents.size()
+        || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
+        || !middleware::datagen::object_id(
+            kAccountFamilyType, middleware::datagen::kAccountSlot, accountDefinitionId)
+        || !middleware::datagen::object_id(
+            kAccountFamilyType, middleware::datagen::kCharacterSlot, characterDefinitionId)
+        || !middleware::datagen::object_id(
+            kAccountFamilyType, middleware::datagen::kItemInstanceSlot, itemInstanceDefinitionId)) {
+        return false;
+    }
+
+    bool accountResident = false;
+    bool characterResident = false;
+    std::size_t dismantledResidentIndex = before.family4Residents.size();
+    for (std::size_t index = 0; index < before.family4ResidentCount; ++index) {
+        const ResidentObject& object = before.family4Residents[index];
+        accountResident =
+            accountResident
+            || (object.definitionId == accountDefinitionId && object.objectSoid == accountSoid);
+        characterResident =
+            characterResident
+            || (object.definitionId == characterDefinitionId && object.objectSoid == characterSoid);
+        if (object.objectSoid != dismantledInstanceSoid) {
+            continue;
+        }
+        if (object.definitionId != itemInstanceDefinitionId
+            || dismantledResidentIndex != before.family4Residents.size()) {
+            return false;
+        }
+        dismantledResidentIndex = index;
+    }
+    if (!accountResident || !characterResident
+        || dismantledResidentIndex >= before.family4ResidentCount) {
+        return false;
+    }
+
+    dismantle.after = before;
+    ++dismantle.after.family4Version;
+    for (std::size_t index = dismantledResidentIndex + 1U; index < before.family4ResidentCount;
+         ++index) {
+        dismantle.after.family4Residents[index - 1U] = before.family4Residents[index];
+    }
+    --dismantle.after.family4ResidentCount;
+    dismantle.after.family4Residents[dismantle.after.family4ResidentCount] = {};
+    dismantle.accountDefinitionId = accountDefinitionId;
+    dismantle.characterDefinitionId = characterDefinitionId;
+    dismantle.itemInstanceDefinitionId = itemInstanceDefinitionId;
+    dismantle.accountSoid = accountSoid;
+    dismantle.characterSoid = characterSoid;
+    dismantle.dismantledInstanceSoid = dismantledInstanceSoid;
+    dismantle.updatesAccount = updatesAccount;
+    const bool staged = valid(dismantle.after);
+
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count =
+        std::snprintf(line.data(),
+                      line.size(),
+                      "ev=dismantle stage=queuez_version result=%s root=0x%llX before=%d after=%d "
+                      "residents_before=%u residents_after=%u resident_index=%zu character=0x%llX "
+                      "instance=0x%llX account_definition=%u character_definition=%u "
+                      "item_definition=%u account_update=%u",
+                      staged ? "ok" : "fail",
+                      static_cast<unsigned long long>(before.family4RootSoid),
+                      before.family4Version,
+                      dismantle.after.family4Version,
+                      static_cast<unsigned>(before.family4ResidentCount),
+                      static_cast<unsigned>(dismantle.after.family4ResidentCount),
+                      dismantledResidentIndex,
+                      static_cast<unsigned long long>(characterSoid),
+                      static_cast<unsigned long long>(dismantledInstanceSoid),
+                      accountDefinitionId,
+                      characterDefinitionId,
+                      itemInstanceDefinitionId,
+                      static_cast<unsigned>(updatesAccount));
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         staged ? core::log::Level::debug : core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+    return staged;
+}
+
 } // namespace sunrise::server::bap::encrypted::queuez

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików