Sfoglia il codice sorgente

Merge pull request #84 from Nyxaraa/triumph-work-code

Implement Triumphs, lore collectibles, titles, seasonal progression, Artifact perks, bonus power, and reward publication
stan 4 giorni fa
parent
commit
a57dc9a9e7
100 ha cambiato i file con 8638 aggiunte e 2675 eliminazioni
  1. 3 1
      .gitignore
  2. 9 0
      CMakeLists.txt
  3. 41 1
      Sunrise/Sunrise.vcxproj
  4. 9 9
      Sunrise/resources/default_settings.json
  5. 4 3
      Sunrise/src/client/content/handles/handle_resolver.cpp
  6. 7 0
      Sunrise/src/client/content/investment/internal.h
  7. 1 19
      Sunrise/src/client/content/investment/investment_refresh.cpp
  8. 220 2
      Sunrise/src/client/content/investment/investment_source.cpp
  9. 2 0
      Sunrise/src/client/content/investment/layout.h
  10. 3 0
      Sunrise/src/client/content/items/packages/build.h
  11. 26 26
      Sunrise/src/client/content/items/packages/internal.h
  12. 34 0
      Sunrise/src/client/content/items/packages/package_collectible_build.cpp
  13. 38 34
      Sunrise/src/client/content/items/packages/package_item_build.cpp
  14. 4 2
      Sunrise/src/client/content/items/packages/package_item_rows.cpp
  15. 314 0
      Sunrise/src/client/content/items/packages/package_node_build.cpp
  16. 177 0
      Sunrise/src/client/content/items/packages/package_record_build.cpp
  17. 66 0
      Sunrise/src/client/content/items/packages/package_socket_plug_build.cpp
  18. 9 0
      Sunrise/src/client/content/items/packages/package_socket_plug_build.h
  19. 42 0
      Sunrise/src/client/hooks/bootflow/spawn_hold.cpp
  20. 17 3
      Sunrise/src/client/hooks/network/investment/internal.h
  21. 42 7
      Sunrise/src/client/hooks/network/investment/investment_derived_rebuild.cpp
  22. 10 0
      Sunrise/src/client/hooks/network/investment/investment_derived_rebuild.h
  23. 106 4
      Sunrise/src/client/hooks/network/investment/investment_family5_rearm.cpp
  24. 170 0
      Sunrise/src/client/hooks/network/investment/investment_lore_visibility.cpp
  25. 952 0
      Sunrise/src/client/hooks/network/investment/investment_socket_menu_routing.cpp
  26. 87 0
      Sunrise/src/client/hooks/network/investment/lore_visibility_patch.h
  27. 122 0
      Sunrise/src/client/hooks/network/investment/socket_row_relocation.h
  28. 15 2
      Sunrise/src/client/hooks/retail_log/retail_log_enqueue_observer.cpp
  29. 3 0
      Sunrise/src/client/hooks/teleport/runtime.h
  30. 10 0
      Sunrise/src/client/hooks/teleport/teleport_move.cpp
  31. 2 0
      Sunrise/src/client/targets/game/content.h
  32. 1 0
      Sunrise/src/client/targets/game/game_content_targets.cpp
  33. 2 0
      Sunrise/src/core/settings/parser.h
  34. 76 4
      Sunrise/src/core/settings/state/account_rows_parser.cpp
  35. 9 0
      Sunrise/src/core/settings/state_settings.cpp
  36. 54 1
      Sunrise/src/core/ui/hud/overlays/ui_hud_status_overlay.cpp
  37. 3 0
      Sunrise/src/dllmain.cpp
  38. 11 29
      Sunrise/src/middleware/bap/activity_message/activity_incident_parser.cpp
  39. 2 13
      Sunrise/src/middleware/bap/activity_message/activity_membership_member_writer.cpp
  40. 0 20
      Sunrise/src/middleware/bap/activity_message/incident.h
  41. 90 1
      Sunrise/src/middleware/content/packages/tables/definition_index_table.h
  42. 87 0
      Sunrise/src/middleware/content/packages/tables/unlock_expression.h
  43. 17 0
      Sunrise/src/middleware/datagen/character_record/appearance/character_appearance_stats.cpp
  44. 15 4
      Sunrise/src/middleware/datagen/character_record/character_record_encoder.cpp
  45. 53 15
      Sunrise/src/middleware/datagen/family4/account/account_encoder.cpp
  46. 11 1
      Sunrise/src/middleware/datagen/family4/account/account_encoder.h
  47. 146 31
      Sunrise/src/middleware/datagen/family4/character/character_encoder.cpp
  48. 10 4
      Sunrise/src/middleware/datagen/family4/character/character_encoder.h
  49. 10 2
      Sunrise/src/middleware/datagen/family4/character/layout.h
  50. 3 0
      Sunrise/src/middleware/datagen/family4/loadout/definition.h
  51. 31 0
      Sunrise/src/middleware/web_service/messages/opcode1801.h
  52. 39 0
      Sunrise/src/middleware/web_service/messages/opcode1801_codec.cpp
  53. 24 0
      Sunrise/src/middleware/web_service/messages/opcode1821.h
  54. 38 0
      Sunrise/src/middleware/web_service/messages/opcode1821_codec.cpp
  55. 21 0
      Sunrise/src/middleware/web_service/messages/opcode2400.h
  56. 36 0
      Sunrise/src/middleware/web_service/messages/opcode2400_codec.cpp
  57. 238 47
      Sunrise/src/server/bap/bap_route.cpp
  58. 0 27
      Sunrise/src/server/bap/encrypted/activity_host_manager/activity_host_manager_route.cpp
  59. 5 1
      Sunrise/src/server/bap/encrypted/activity_message/activity_message_route.cpp
  60. 12 42
      Sunrise/src/server/bap/encrypted/activity_message/membership/activity_membership_route.cpp
  61. 525 28
      Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_message_receipts.cpp
  62. 21 6
      Sunrise/src/server/bap/encrypted/activity_transaction/activity_transaction_notifications.cpp
  63. 426 277
      Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp
  64. 164 149
      Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp
  65. 190 56
      Sunrise/src/server/bap/encrypted/internal.h
  66. 3 1
      Sunrise/src/server/bap/encrypted/push/activity/activity_keepalive_seed.cpp
  67. 4 4
      Sunrise/src/server/bap/encrypted/push/activity/activity_roster_push.cpp
  68. 19 10
      Sunrise/src/server/bap/encrypted/push/activity/activity_roster_snapshot.cpp
  69. 17 3
      Sunrise/src/server/bap/encrypted/push/activity/internal.h
  70. 26 37
      Sunrise/src/server/bap/encrypted/push/queuez/queuez_banner_push.cpp
  71. 0 33
      Sunrise/src/server/bap/encrypted/push/queuez/queuez_push_reporting.cpp
  72. 0 21
      Sunrise/src/server/bap/encrypted/push/queuez/queuez_push_reporting.h
  73. 296 82
      Sunrise/src/server/bap/encrypted/push/queuez/queuez_select_character.cpp
  74. 16 30
      Sunrise/src/server/bap/encrypted/push/queuez/queuez_subscription.cpp
  75. 0 28
      Sunrise/src/server/bap/encrypted/push/snapshot/banner_snapshot.cpp
  76. 659 129
      Sunrise/src/server/bap/encrypted/push/snapshot/family4_inventory_updates.cpp
  77. 356 189
      Sunrise/src/server/bap/encrypted/push/snapshot/family4_selection_move.cpp
  78. 5 0
      Sunrise/src/server/bap/encrypted/push/snapshot/family4_snapshot_preparer.cpp
  79. 16 10
      Sunrise/src/server/bap/encrypted/push/snapshot/initial_snapshot.cpp
  80. 84 21
      Sunrise/src/server/bap/encrypted/push/snapshot/internal.h
  81. 15 20
      Sunrise/src/server/bap/encrypted/push/snapshot/snapshot.h
  82. 25 1
      Sunrise/src/server/bap/encrypted/queuez/definition.h
  83. 377 68
      Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp
  84. 453 178
      Sunrise/src/server/bap/encrypted/queuez/queuez_outcome_staging.cpp
  85. 13 8
      Sunrise/src/server/bap/encrypted/queuez/queuez_outcome_staging.h
  86. 16 0
      Sunrise/src/server/bap/encrypted/queuez/queuez_state_validation.h
  87. 259 143
      Sunrise/src/server/bap/encrypted/queuez/staging/queuez_character_staging.cpp
  88. 3 0
      Sunrise/src/server/bap/encrypted/queuez/staging/queuez_family_staging.cpp
  89. 56 117
      Sunrise/src/server/bap/encrypted/routing/bap_service_routing.cpp
  90. 57 23
      Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp
  91. 62 1
      Sunrise/src/server/bap/internal.h
  92. 1 10
      Sunrise/src/server/bap/plaintext.cpp
  93. 404 433
      Sunrise/src/server/web_service/web_service_actions.cpp
  94. 10 0
      Sunrise/src/server/web_service/web_service_actions.h
  95. 269 149
      Sunrise/src/server/web_service/web_service_runtime.cpp
  96. 71 22
      Sunrise/src/server/web_service/web_service_runtime.h
  97. 59 26
      Sunrise/src/state/account/account_state.cpp
  98. 27 3
      Sunrise/src/state/account/account_state.h
  99. 27 0
      Sunrise/src/state/account/inventory/inventory_state.cpp
  100. 18 4
      Sunrise/src/state/account/inventory/inventory_state.h

+ 3 - 1
.gitignore

@@ -2,7 +2,7 @@
 /.idea/
 /.vscode/
 /.direnv/
-/.xwin-cache/
+/.xwin-cache
 /.cache/
 *.user
 *.suo
@@ -14,4 +14,6 @@
 
 # CMake build files
 /build/
+/build-merge/
+/build-resolved/
 /out/

+ 9 - 0
CMakeLists.txt

@@ -15,7 +15,13 @@ file(GLOB_RECURSE VENDOR_SOURCES CONFIGURE_DEPENDS
     "Sunrise/vendor/detours/*.h"
     "Sunrise/vendor/imgui/*.cpp"
     "Sunrise/vendor/imgui/*.h"
+    "Sunrise/vendor/lua/*.c"
+    "Sunrise/vendor/lua/*.h"
 )
+file(GLOB_RECURSE LUA_SOURCES CONFIGURE_DEPENDS
+    "Sunrise/vendor/lua/*.c"
+)
+set_source_files_properties(${LUA_SOURCES} PROPERTIES LANGUAGE CXX)
 file(GLOB_RECURSE RESOURCE_FILES CONFIGURE_DEPENDS
     "Sunrise/resources/*.rc"
     "Sunrise/resources/*.h"
@@ -52,6 +58,7 @@ target_include_directories(steam_api64 SYSTEM PRIVATE
     "${CMAKE_CURRENT_SOURCE_DIR}/Sunrise/vendor/detours"
     "${CMAKE_CURRENT_SOURCE_DIR}/Sunrise/vendor/imgui"
     "${CMAKE_CURRENT_SOURCE_DIR}/Sunrise/vendor/imgui/backends"
+    "${CMAKE_CURRENT_SOURCE_DIR}/Sunrise/vendor/lua"
 )
 
 if(DEFINED XWIN_DIR)
@@ -76,6 +83,8 @@ target_compile_definitions(steam_api64 PRIVATE
 )
 
 if(MSVC OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
+    target_compile_options(steam_api64 PRIVATE $<$<COMPILE_LANGUAGE:CXX>:/EHsc>)
+
     # Clang warnings are more strict the MSVC I guess, this only works on real clang
     if(CMAKE_CXX_COMPILER_ID MATCHES "Clang")
         list(APPEND STRICT_WARNING_FLAGS

+ 41 - 1
Sunrise/Sunrise.vcxproj

@@ -280,10 +280,10 @@
     <ClCompile Include="src\client\hooks\bootflow\profile_setup_skip.cpp" />
     <ClCompile Include="src\client\hooks\bootflow\world_step.cpp" />
     <ClCompile Include="src\client\hooks\bootflow\spawn_hold.cpp" />
+    <ClCompile Include="src\client\hooks\bootflow\fade_release.cpp" />
     <ClCompile Include="src\client\hooks\bootflow\spawn\spawn_gate_probe.cpp" />
     <ClCompile Include="src\client\hooks\bootflow\spawn\spawn_gate_targets.cpp" />
     <ClCompile Include="src\client\hooks\bootflow\spawn\spawn_gate_record_dump.cpp" />
-    <ClCompile Include="src\client\hooks\bootflow\fade_release.cpp" />
     <ClCompile Include="src\server\bap\encrypted\push\activity\activity_arrival.cpp" />
     <ClCompile Include="src\middleware\bap\activity_host_manager\request\selection\activity_manager_selection_bits.cpp" />
     <ClCompile Include="src\middleware\bap\activity_host_manager\request\selection\activity_manager_selection_snapshot.cpp" />
@@ -1155,6 +1155,22 @@
     <ClCompile Include="src\middleware\gameplay\group\migration_messages.cpp" />
     <ClCompile Include="src\middleware\gameplay\group\notice_messages.cpp" />
     <ClCompile Include="src\server\gameplay\group\group_migration_receipts.cpp" />
+    <ClCompile Include="src\client\content\items\packages\package_node_build.cpp" />
+    <ClCompile Include="src\client\content\items\packages\package_record_build.cpp" />
+    <ClCompile Include="src\middleware\web_service\messages\opcode1801_codec.cpp" />
+    <ClCompile Include="src\middleware\web_service\messages\opcode1821_codec.cpp" />
+    <ClCompile Include="src\middleware\web_service\messages\opcode2400_codec.cpp" />
+    <ClCompile Include="src\state\build_data\nodes\node_build_data_runtime.cpp" />
+    <ClCompile Include="src\state\build_data\nodes\node_catalog.cpp" />
+    <ClCompile Include="src\state\build_data\records\record_build_data_runtime.cpp" />
+    <ClCompile Include="src\state\build_data\records\record_catalog.cpp" />
+    <ClCompile Include="src\state\build_data\records\rewards\reward_build_data_runtime.cpp" />
+    <ClCompile Include="src\state\build_data\records\rewards\reward_catalog.cpp" />
+    <ClCompile Include="src\state\build_data\records\rewards\reward_persistence.cpp" />
+    <ClCompile Include="src\state\record_claims\record_claims.cpp" />
+    <ClCompile Include="src\state\progression\seasonal_experience.cpp" />
+    <ClCompile Include="src\state\build_data\sobjects\sobject_catalog.cpp" />
+    <ClCompile Include="src\state\lore\lore_grant.cpp" />
   </ItemGroup>
   <ItemGroup Condition="'$(SunriseRunClangTidy)'=='true'">
     <ClCompile Remove="vendor\detours\detours.cpp" />
@@ -1990,5 +2006,29 @@
     <ClInclude Include="src\server\gameplay\group\group_migration_receipts.h" />
     <ClInclude Include="src\core\runtime\server_clock.h" />
   </ItemGroup>
+  <ItemGroup>
+    <ClCompile Include="src\client\hooks\network\investment\investment_socket_menu_routing.cpp" />
+    <ClInclude Include="src\client\hooks\network\investment\socket_row_relocation.h" />
+    <ClInclude Include="src\middleware\content\packages\tables\unlock_expression.h" />
+    <ClInclude Include="src\middleware\web_service\messages\opcode1801.h" />
+    <ClInclude Include="src\middleware\web_service\messages\opcode1821.h" />
+    <ClInclude Include="src\middleware\web_service\messages\opcode2400.h" />
+    <ClInclude Include="src\state\build_data\nodes\definition.h" />
+    <ClInclude Include="src\state\build_data\nodes\node_catalog.h" />
+    <ClInclude Include="src\state\build_data\records\definition.h" />
+    <ClInclude Include="src\state\build_data\records\record_catalog.h" />
+    <ClInclude Include="src\state\build_data\records\rewards\definition.h" />
+    <ClInclude Include="src\state\build_data\records\rewards\reward_catalog.h" />
+    <ClInclude Include="src\state\build_data\records\rewards\reward_persistence.h" />
+    <ClInclude Include="src\state\build_data\sobjects\sobject_catalog.h" />
+    <ClInclude Include="src\state\lore\lore_grant.h" />
+    <ClInclude Include="src\state\progression\season_pass_reward_catalog.h" />
+    <ClInclude Include="src\state\progression\seasonal_experience.h" />
+    <ClInclude Include="src\state\record_claims\objective_slot_table.h" />
+    <ClInclude Include="src\state\record_claims\parent_bar_table.h" />
+    <ClInclude Include="src\client\hooks\network\investment\lore_visibility_patch.h" />
+    <ClCompile Include="src\client\hooks\network\investment\investment_lore_visibility.cpp" />
+    <ClInclude Include="src\state\record_claims\record_claims.h" />
+  </ItemGroup>
   <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
 </Project>

File diff suppressed because it is too large
+ 9 - 9
Sunrise/resources/default_settings.json


+ 4 - 3
Sunrise/src/client/content/handles/handle_resolver.cpp

@@ -128,9 +128,10 @@ bool resolve(const Source& source, std::uint32_t handle, std::uintptr_t& address
     const std::uint64_t extendedMask =
         static_cast<std::uint64_t>(static_cast<std::int64_t>(table.correctionMask));
     const std::uint64_t correction = record.correctionSource & extendedMask;
-    if (correction > recordAddress) {
-        return false;
-    }
+    // Match the native `sub rax, rcx` exactly. The correction source is a two's-complement
+    // relative value when the descriptor mask is -1, so a numerically large unsigned correction
+    // intentionally wraps the subtraction forward. Rejecting it as an underflow made every
+    // installed investment-globals handle appear unresolved.
     address = recordAddress - static_cast<std::uintptr_t>(correction);
     return address != 0;
 }

+ 7 - 0
Sunrise/src/client/content/investment/internal.h

@@ -5,6 +5,13 @@
 
 namespace sunrise::client::content::investment {
 
+/**
+ * Resolves the one installed investment-globals candidate backed by the live content tables.
+ * @param source Receives the checked runtime tag, handle tables, and bounded reader.
+ * @return True when the globals, root, and dense item table all resolve.
+ */
+[[nodiscard]] bool resolve_source(Source& source) noexcept;
+
 /** @return True when the next refresh slice needs one presented overlay before its package sweep.
  */
 [[nodiscard]] bool requires_package_sweep() noexcept;

+ 1 - 19
Sunrise/src/client/content/investment/investment_refresh.cpp

@@ -16,26 +16,8 @@ namespace {
 
 core::threading::SrwLock g_refreshLock{};
 
-/**
- * @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.
- * This is the only caller of the package pass, so a domain left out of this test stops being
- * extracted once the others finish, and the cache can then never be written.
- */
 [[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()
-           && state::build_data::socket_entry_buckets_ready()
-           && state::build_data::progression_definitions_ready()
-           && state::build_data::scenario_layouts_ready() && state::build_data::spawn_sets_ready()
-           && state::build_data::hash_names_ready()
-           && state::build_data::investment_constants_ready();
+    return state::build_data::named_catalog_ready() && items::packages::ready();
 }
 
 /**

+ 220 - 2
Sunrise/src/client/content/investment/investment_source.cpp

@@ -1,11 +1,17 @@
+#include <algorithm>
 #include <array>
+#include <atomic>
 #include <cstddef>
 #include <cstdint>
+#include <cstdio>
 
+#include "../../../core/logging/log.h"
 #include "../../../state/content/content_catalog.h"
 #include "../../memory/current_process_memory.h"
-#include "../../targets/game.h"
+#include "../../targets/game/content.h"
+#include "../handles/layout.h"
 #include "internal.h"
+#include "layout.h"
 
 namespace sunrise::client::content::investment {
 namespace {
@@ -13,8 +19,220 @@ namespace {
 /** FNV-1 hash of the investment-globals bootstrap name, so the name itself is not shipped. */
 constexpr std::uint32_t kInvestmentGlobalsNameHash = 0x6F7125CBU;
 /** The bootstrap name is not unique, so every match is collected. */
-constexpr std::size_t kBootstrapMatchCapacity = 8;
+// Keep this identical to the package extractor. The installed catalogue currently has more than
+// eight entries with this shared name; treating a truncated lookup as total failure made live
+// socket routing permanently defer even though the correct candidate was present.
+constexpr std::size_t kBootstrapMatchCapacity = 64;
+/** One native resolver prefix is enough to recover all descriptor field offsets. */
+constexpr std::size_t kResolverDiagnosticBytes = 128;
+/** Package handles name descriptor ids 1,024 slots above their package id. */
+constexpr std::uintptr_t kContentDescriptorBias = 1024;
+/** Package handles keep their package id above thirteen entry-index bits. */
+constexpr unsigned kPackageShift = 13;
+/** Installed definition tags begin at this package-handle base. */
+constexpr std::uint32_t kPackageTagBase = 0x80800000U;
+
+std::atomic_bool g_diagnosticsReported{false};
+
+/** Reads one complete scalar through the bounded live-process reader. */
+template <typename Value>
+[[nodiscard]] bool read(const Source& source, std::uintptr_t address, Value& value) noexcept {
+    return source.handles.read != nullptr
+           && source.handles.read(source.handles.context,
+                                  address,
+                                  std::span(reinterpret_cast<std::byte*>(&value), sizeof value));
+}
+
+/** Reads one scalar directly from the current process for layout diagnostics. */
+template <typename Value>
+[[nodiscard]] bool read_process(std::uintptr_t address, Value& value) noexcept {
+    return memory::read_current_process(
+        nullptr, address, std::span(reinterpret_cast<std::byte*>(&value), sizeof value));
+}
+
+/** Writes one bounded memory range as a single diagnostic line. */
+void report_bytes(const char* stage,
+                  std::uintptr_t address,
+                  std::span<const std::byte> bytes) noexcept {
+    std::array<char, core::log::kLineCapacity> line{};
+    const int prefix = std::snprintf(line.data(),
+                                     line.size(),
+                                     "ev=investment stage=%s address=0x%llX bytes=",
+                                     stage,
+                                     static_cast<unsigned long long>(address));
+    if (prefix <= 0) {
+        return;
+    }
+    std::size_t length = (std::min)(static_cast<std::size_t>(prefix), line.size() - 1U);
+    static_cast<void>(core::log::append_hex(line, length, bytes));
+    core::log::write(core::log::Channel::client, core::log::Level::warn, {line.data(), length});
+}
+
+/** Writes one candidate descriptor and its package identity as a diagnostic line. */
+void report_descriptor(unsigned depth,
+                       const state::content::Definition& candidate,
+                       std::uintptr_t address,
+                       std::span<const std::byte> bytes) noexcept {
+    std::array<char, core::log::kLineCapacity> line{};
+    const int prefix = std::snprintf(line.data(),
+                                     line.size(),
+                                     "ev=investment stage=descriptor depth=%u tag=0x%08X "
+                                     "class=0x%08X address=0x%llX bytes=",
+                                     depth,
+                                     candidate.tag,
+                                     candidate.classId,
+                                     static_cast<unsigned long long>(address));
+    if (prefix <= 0) {
+        return;
+    }
+    std::size_t length = (std::min)(static_cast<std::size_t>(prefix), line.size() - 1U);
+    static_cast<void>(core::log::append_hex(line, length, bytes));
+    core::log::write(core::log::Channel::client, core::log::Level::warn, {line.data(), length});
+}
+
+/** Captures the native resolver and every plausible descriptor base once, without mutation. */
+void report_layout_diagnostics(const targets::game::content::Targets& targets,
+                               std::span<const state::content::Definition> candidates) noexcept {
+    if (g_diagnosticsReported.exchange(true, std::memory_order_relaxed)) {
+        return;
+    }
+    std::array<std::byte, kResolverDiagnosticBytes> resolver{};
+    if (targets.queuezObjectResolver != nullptr
+        && memory::read_current_process(
+            nullptr, reinterpret_cast<std::uintptr_t>(targets.queuezObjectResolver), resolver)) {
+        report_bytes("resolver_bytes",
+                     reinterpret_cast<std::uintptr_t>(targets.queuezObjectResolver),
+                     resolver);
+    }
+
+    const std::uintptr_t slot = reinterpret_cast<std::uintptr_t>(targets.contentHandleTablesSlot);
+    std::array<std::uintptr_t, 3> bases{};
+    const bool base0 = read_process(slot, bases[0]);
+    const bool base1 = base0 && bases[0] != 0 && read_process(bases[0], bases[1]);
+    const bool base2 = base1 && bases[1] != 0 && read_process(bases[1], bases[2]);
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=investment stage=table_chain slot=0x%llX "
+                                      "read=%u/%u/%u base=0x%llX/0x%llX/0x%llX",
+                                      static_cast<unsigned long long>(slot),
+                                      base0 ? 1U : 0U,
+                                      base1 ? 1U : 0U,
+                                      base2 ? 1U : 0U,
+                                      static_cast<unsigned long long>(bases[0]),
+                                      static_cast<unsigned long long>(bases[1]),
+                                      static_cast<unsigned long long>(bases[2]));
+    if (written > 0) {
+        core::log::write(
+            core::log::Channel::client,
+            core::log::Level::warn,
+            {line.data(), (std::min)(static_cast<std::size_t>(written), line.size() - 1U)});
+    }
+
+    for (unsigned depth = 0; depth < bases.size(); ++depth) {
+        if (bases[depth] == 0) {
+            continue;
+        }
+        for (const state::content::Definition& candidate : candidates) {
+            if (candidate.tag < kPackageTagBase) {
+                continue;
+            }
+            const std::uintptr_t package =
+                static_cast<std::uintptr_t>(candidate.tag - kPackageTagBase) >> kPackageShift;
+            const std::uintptr_t descriptor =
+                bases[depth]
+                + (package + kContentDescriptorBias) * handles::layout::kTableDescriptorSize;
+            std::array<std::byte, handles::layout::kTableDescriptorSize> bytes{};
+            if (memory::read_current_process(nullptr, descriptor, bytes)) {
+                report_descriptor(depth, candidate, descriptor, bytes);
+            }
+        }
+    }
+}
 
 } // namespace
 
+/** Resolves the checked live investment source selected by its installed bootstrap name. */
+bool resolve_source(Source& source) noexcept {
+    source = {};
+    const auto& runtimeTargets = targets::game::content::get();
+    if (!targets::game::content::is_resolved()
+        || runtimeTargets.contentHandleTablesSlot == nullptr) {
+        return false;
+    }
+
+    std::array<state::content::Definition, kBootstrapMatchCapacity> candidates{};
+    std::size_t count = 0;
+    // A shared bootstrap name may have more installed matches than this bounded scratch array.
+    // Truncation is not a lookup failure: every copied candidate is still safe to validate, and
+    // rejecting the whole set is what kept socket-category routing permanently deferred.
+    if (!state::content::lookup_hash(kInvestmentGlobalsNameHash, candidates, count) && count == 0) {
+        return false;
+    }
+    report_layout_diagnostics(runtimeTargets, std::span(candidates).first(count));
+    std::size_t globalsResolved = 0;
+    std::size_t rootTagsRead = 0;
+    std::size_t rootsResolved = 0;
+    std::size_t tableTagsRead = 0;
+    std::size_t tablesResolved = 0;
+    for (std::size_t index = 0; index < count; ++index) {
+        Source candidate{};
+        candidate.investmentGlobalsTag = candidates[index].tag;
+        candidate.handles.tablesSlot =
+            reinterpret_cast<std::uintptr_t>(runtimeTargets.contentHandleTablesSlot);
+        candidate.handles.read = &memory::read_current_process;
+
+        std::uintptr_t globals = 0;
+        std::uintptr_t root = 0;
+        std::uintptr_t table = 0;
+        std::uint32_t rootTag = 0;
+        std::uint32_t tableTag = 0;
+        std::uint64_t rowCount = 0;
+        if (!handles::resolve(candidate.handles, candidate.investmentGlobalsTag, globals)) {
+            continue;
+        }
+        ++globalsResolved;
+        if (!read(candidate, globals + layout::kGlobalsRootTagOffset, rootTag)) {
+            continue;
+        }
+        ++rootTagsRead;
+        if (!handles::resolve(candidate.handles, rootTag, root)) {
+            continue;
+        }
+        ++rootsResolved;
+        if (!read(candidate, root + layout::kItemTableTagOffset, tableTag)) {
+            continue;
+        }
+        ++tableTagsRead;
+        if (!handles::resolve(candidate.handles, tableTag, table)) {
+            continue;
+        }
+        ++tablesResolved;
+        if (!read(candidate, table + 8U, rowCount) || rowCount == 0 || rowCount > 32768U) {
+            continue;
+        }
+        source = candidate;
+        return true;
+    }
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=investment stage=source result=deferred candidates=%llu "
+                                      "globals=%llu root_tags=%llu roots=%llu table_tags=%llu "
+                                      "tables=%llu",
+                                      static_cast<unsigned long long>(count),
+                                      static_cast<unsigned long long>(globalsResolved),
+                                      static_cast<unsigned long long>(rootTagsRead),
+                                      static_cast<unsigned long long>(rootsResolved),
+                                      static_cast<unsigned long long>(tableTagsRead),
+                                      static_cast<unsigned long long>(tablesResolved));
+    if (written > 0) {
+        core::log::write(
+            core::log::Channel::client,
+            core::log::Level::warn,
+            {line.data(), (std::min)(static_cast<std::size_t>(written), line.size() - 1U)});
+    }
+    return false;
+}
+
 } // namespace sunrise::client::content::investment

+ 2 - 0
Sunrise/src/client/content/investment/layout.h

@@ -12,6 +12,8 @@ inline constexpr std::size_t kGlobalsRootTagOffset = 16;
 inline constexpr std::size_t kInventoryBucketTableTagOffset = 280;
 /** The dense item-table handle sits at byte 776 of the investment root. */
 inline constexpr std::size_t kItemTableTagOffset = 776;
+/** The reusable/randomized plug-set table handle sits at byte 824 of the investment root. */
+inline constexpr std::size_t kPlugSetTableTagOffset = 824;
 /** The socket-entry-list table handle sits at byte 1,560 of the investment root. */
 inline constexpr std::size_t kSocketEntryListTableTagOffset = 1560;
 

+ 3 - 0
Sunrise/src/client/content/items/packages/build.h

@@ -2,6 +2,9 @@
 
 namespace sunrise::client::content::items::packages {
 
+/** @return True when every package-owned domain is published. */
+[[nodiscard]] bool ready() noexcept;
+
 /**
  * Publishes the dense item table from the installed packages, once.
  * @return True when State already holds the table or a full pass publishes it.

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

@@ -56,6 +56,13 @@ inline constexpr std::size_t kContainerCandidates = 16;
 /** Lock-owned storage kept off the caller stack, shared by every stage of the pass. */
 struct Storage {
     reader::Scratch scratch{};
+    /** Node rows held until the value slot and owned records are resolved. */
+    std::array<state::build_data::nodes::Definition, state::build_data::nodes::kDefinitionCapacity>
+        nodeRows{};
+    /** Record rows held until the completion flag mapping is resolved. */
+    std::array<state::build_data::records::Definition,
+               state::build_data::records::kDefinitionCapacity>
+        recordRows{};
     std::vector<std::byte> container{};
     std::vector<std::byte> child{};
     std::vector<std::byte> root{};
@@ -125,12 +132,7 @@ struct Storage {
 /** 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.
- */
+/** Adds one native definition index to the deduplicated request set. */
 void request(std::uint16_t definitionIndex, DetailRequests& requested) noexcept;
 
 /**
@@ -244,18 +246,23 @@ read_investment_constants(const reader::Source& source,
     std::array<std::uint8_t, state::build_data::socket_entry_lists::kEntryCapacity>&
         output) noexcept;
 
-/**
- * Reads the progression definition table and the object array each definition routes to.
- * The table is inline rows, not index rows. The scope byte in a row picks the replicated object
- * holding that progression, and the row's place among rows of that scope is its slot there.
- * @param source Package source.
- * @param scratch Reader scratch.
- * @param root Investment root bytes.
- * @param blob Scratch storage for the table.
- * @param output Row storage in native definition order.
- * @param count Receives the number of rows read.
- * @return True when the table reads and every row fits.
- */
+/** Reads nodes and resolves their value slots and owned records. */
+[[nodiscard]] bool build_nodes(const reader::Source& source,
+                               reader::Scratch& scratch,
+                               std::span<const std::byte> root,
+                               std::vector<std::byte>& blob,
+                               std::span<state::build_data::nodes::Definition> output,
+                               std::size_t& count) noexcept;
+
+/** Reads records and resolves their completion-flag indices. */
+[[nodiscard]] bool build_records(const reader::Source& source,
+                                 reader::Scratch& scratch,
+                                 std::span<const std::byte> root,
+                                 std::vector<std::byte>& blob,
+                                 std::span<state::build_data::records::Definition> output,
+                                 std::size_t& count) noexcept;
+
+/** Reads progression definitions and resolves their replicated-object slots. */
 [[nodiscard]] bool build_progressions(const reader::Source& source,
                                       reader::Scratch& scratch,
                                       std::span<const std::byte> root,
@@ -361,14 +368,7 @@ 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.
- */
+/** Reads and publishes the dense collectible-to-item mapping. */
 [[nodiscard]] bool build_collectibles(const reader::Source& source,
                                       Storage& storage,
                                       std::span<const std::byte> root,

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

@@ -2,6 +2,7 @@
 #include <limits>
 
 #include "../../../../state/build_data/runtime.h"
+#include "../../../../state/build_data/sobjects/sobject_catalog.h"
 #include "internal.h"
 
 namespace sunrise::client::content::items::packages {
@@ -12,6 +13,39 @@ bool build_collectibles(const reader::Source& source,
                         std::span<const std::byte> root,
                         std::uint64_t itemDefinitionCount) noexcept {
     namespace domain = state::build_data::collectibles;
+
+    // The definition table an incident target names. Read here because this pass already holds an
+    // open source, and because a collectible picked up in the world arrives as an incident: without
+    // this table its target is a bare number.
+    if (state::build_data::sobjects::count() == 0) {
+        namespace sobjects = state::build_data::sobjects;
+        // Count at +112; 40-byte rows at +128 hold the name hash, packed lane, and type.
+        constexpr std::size_t kCountOffset = 112;
+        constexpr std::size_t kRowBase = 128;
+        constexpr std::size_t kRowStride = 40;
+        for (const std::uint32_t tag : {0x81327CD4U, 0x80B9E5BFU}) {
+            std::vector<std::byte> blob{};
+            if (!reader::read_tag(source, storage.scratch, tag, blob) || blob.size() < kRowBase) {
+                continue;
+            }
+            std::uint64_t rowCount = 0;
+            std::memcpy(&rowCount, blob.data() + kCountOffset, sizeof rowCount);
+            if (rowCount == 0 || rowCount > sobjects::kDefinitionCapacity
+                || kRowBase + static_cast<std::size_t>(rowCount) * kRowStride > blob.size()) {
+                continue;
+            }
+            std::vector<sobjects::Definition> rows(static_cast<std::size_t>(rowCount));
+            for (std::size_t row = 0; row < rows.size(); ++row) {
+                const std::size_t at = kRowBase + row * kRowStride;
+                std::memcpy(&rows[row].nameHash, blob.data() + at, sizeof rows[row].nameHash);
+                std::memcpy(&rows[row].lane4, blob.data() + at + 16, sizeof rows[row].lane4);
+                std::memcpy(&rows[row].typeCode, blob.data() + at + 36, sizeof rows[row].typeCode);
+            }
+            (void)sobjects::replace(std::span<const sobjects::Definition>{rows});
+            break;
+        }
+    }
+
     if (state::build_data::collectible_definitions_ready()) {
         return true;
     }

+ 38 - 34
Sunrise/src/client/content/items/packages/package_item_build.cpp

@@ -8,19 +8,9 @@
 #include "../../../../core/settings/rule_text.h"
 #include "../../../../middleware/content/packages/reader/reader.h"
 #include "../../../../middleware/content/packages/tables/definition_index_table.h"
-#include "../../../../middleware/content/packages/tables/items.h"
-#include "../../../../state/account/account_state.h"
-#include "../../../../state/build_data/abilities/definition.h"
-#include "../../../../state/build_data/inventory/buckets/definition.h"
-#include "../../../../state/build_data/items/details/definition.h"
-#include "../../../../state/build_data/progressions/definition.h"
 #include "../../../../state/build_data/runtime.h"
-#include "../../../../state/build_data/socket_entry_lists/definition.h"
+#include "../../../../state/build_data/sobjects/sobject_catalog.h"
 #include "../../../../state/build_data/vendors/vendor_catalog.h"
-#include "../../../../state/content/content_catalog.h"
-#include "../../../../state/runtime/runtime.h"
-#include "../../../memory/current_process_memory.h"
-#include "../../../targets/game.h"
 #include "../../hash_names/hash_name_build.h"
 #include "../../scenarios/scenario_build.h"
 #include "../../spawn_sets/spawn_set_build.h"
@@ -80,24 +70,6 @@ void build_vendor_catalog(const reader::Source& source, reader::Scratch& scratch
     (void)content::vendors::build(source, scratch, std::span(named).first(namedCount));
 }
 
-/** @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()
-           && state::build_data::socket_entry_buckets_ready()
-           && state::build_data::progression_definitions_ready()
-           && state::build_data::scenario_layouts_ready() && state::build_data::spawn_sets_ready()
-           && state::build_data::hash_names_ready()
-           && state::build_data::investment_constants_ready()
-           && state::build_data::exotic_catalysts_ready();
-}
-
 /** @return True when every item and investment-root domain is published. */
 [[nodiscard]] bool root_domains_ready() noexcept {
     return state::build_data::item_definitions_ready()
@@ -110,12 +82,21 @@ void build_vendor_catalog(const reader::Source& source, reader::Scratch& scratch
            && state::build_data::ability_buckets_ready()
            && state::build_data::socket_entry_buckets_ready()
            && state::build_data::progression_definitions_ready()
+           && state::build_data::record_definitions_ready()
+           && state::build_data::node_definitions_ready()
+           && state::build_data::sobjects::count() != 0
            && state::build_data::investment_constants_ready()
            && state::build_data::exotic_catalysts_ready();
 }
 
 } // namespace
 
+/** @return True when every domain owned by the package pass is published. */
+bool ready() noexcept {
+    return root_domains_ready() && state::build_data::scenario_layouts_ready()
+           && state::build_data::spawn_sets_ready() && state::build_data::hash_names_ready();
+}
+
 /** Publishes the dense item table from the installed packages, once. */
 bool build() noexcept {
     static Storage storage{};
@@ -140,20 +121,19 @@ bool build() noexcept {
         (void)content::spawn_sets::build(packageSource, storage.scratch);
         (void)content::hash_names::build(packageSource, storage.scratch);
         build_vendor_catalog(packageSource, storage.scratch);
-        if (package_domains_ready()) {
+        if (ready()) {
             SecureZeroMemory(&keys, sizeof keys);
             return true;
         }
     }
     if (root_domains_ready()) {
         SecureZeroMemory(&keys, sizeof keys);
-        return true;
+        return ready();
     }
     reason = "tag";
     std::array<std::uint32_t, kContainerCandidates> candidates{};
     std::size_t candidateCount = 0;
-    const bool named = investment_globals_tags(candidates, candidateCount);
-    if (named) {
+    if (investment_globals_tags(candidates, candidateCount)) {
         const reader::Source source{directory.chars.data(), &keys};
         tables::Array table{};
         bool located = false;
@@ -228,6 +208,30 @@ bool build() noexcept {
                         std::span(storage.progressionRows).first(progressionCount));
                 }
             }
+            if (!state::build_data::node_definitions_ready()) {
+                std::size_t nodeCount = 0;
+                if (build_nodes(source,
+                                storage.scratch,
+                                std::span<const std::byte>{storage.root},
+                                storage.child,
+                                storage.nodeRows,
+                                nodeCount)) {
+                    (void)state::build_data::publish_node_definitions(
+                        std::span(storage.nodeRows).first(nodeCount));
+                }
+            }
+            if (!state::build_data::record_definitions_ready()) {
+                std::size_t recordCount = 0;
+                if (build_records(source,
+                                  storage.scratch,
+                                  std::span<const std::byte>{storage.root},
+                                  storage.child,
+                                  storage.recordRows,
+                                  recordCount)) {
+                    (void)state::build_data::publish_record_definitions(
+                        std::span(storage.recordRows).first(recordCount));
+                }
+            }
             if (!state::build_data::investment_constants_ready()) {
                 state::build_data::constants::InvestmentConstants extracted{};
                 if (read_investment_constants(source,
@@ -264,7 +268,7 @@ bool build() noexcept {
         }
     }
     SecureZeroMemory(&keys, sizeof keys);
-    const bool complete = package_domains_ready();
+    const bool complete = 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.

+ 4 - 2
Sunrise/src/client/content/items/packages/package_item_rows.cpp

@@ -90,6 +90,8 @@ bool build_item_rows(const reader::Source& source,
                                                item)) {
             continue;
         }
+        const std::uint32_t plugCategoryHash =
+            corrected_plug_category(item.definitionHash, item.plugCategoryHash);
         storage.rows[rowCount++] =
             state::build_data::items::Definition{item.definitionHash,
                                                  item.definitionIndex,
@@ -97,12 +99,12 @@ bool build_item_rows(const reader::Source& source,
                                                  item.insertionMaterialRequirementSetIndex,
                                                  item.enabledMaterialRequirementSetIndex,
                                                  item.tier,
-                                                 item.plugCategoryHash,
+                                                 plugCategoryHash,
                                                  item.rollSetIndex,
                                                  item.linkedPlugIndex};
         if (needSocketRows) {
             storage.specialPlugCategories[item.definitionIndex] =
-                special_plug_category(item.plugCategoryHash);
+                special_plug_category(plugCategoryHash);
         }
         if (needDetailRows) {
             request(item.definitionIndex, storage.detailRequests);

+ 314 - 0
Sunrise/src/client/content/items/packages/package_node_build.cpp

@@ -0,0 +1,314 @@
+#include <algorithm>
+#include <array>
+#include <cstdio>
+#include <unordered_map>
+#include <vector>
+
+#include "../../../../core/logging/log.h"
+#include "../../../../state/build_data/runtime.h"
+#include "../../../../middleware/content/packages/tables/unlock_expression.h"
+#include "internal.h"
+
+namespace sunrise::client::content::items::packages {
+namespace {
+
+/** Reports where the node pass stopped, so a silent miss cannot look like a stuck progress bar. */
+void report(const char* stage, unsigned long long detail) noexcept {
+    std::array<char, 128> line{};
+    const int count = std::snprintf(
+        line.data(), line.size(), "ev=pkg stage=nodes result=%s detail=%llu", stage, detail);
+    if (count > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+}
+
+} // namespace
+
+/**
+ * Reads the presentation node table and resolves each node's value slot and owned records.
+ *
+ * A node's progress bar shows a value slot named by its own expression, and the records it owns sit
+ * at row `+136` as a row and a gate. Both are read here so a claim never has to walk the node table.
+ */
+bool build_nodes(const reader::Source& source,
+                 reader::Scratch& scratch,
+                 std::span<const std::byte> root,
+                 std::vector<std::byte>& blob,
+                 std::span<state::build_data::nodes::Definition> output,
+                 std::size_t& count) noexcept {
+    namespace domain = state::build_data::nodes;
+    count = 0;
+
+    // The account flag mapping table, read first. A category's gate names a flag slot, and a slot
+    // is not an index: the byte that feeds it sits at the row whose destination is that slot.
+    std::uint32_t flagMapTag = 0;
+    tables::Array flagMapRows{};
+    std::unordered_map<std::int16_t, std::uint16_t> flagIndexBySlot{};
+    if (tables::slot_tag(root, tables::kUnlockFlagMapTableSlot, flagMapTag) && flagMapTag != 0
+        && tables::package_of(flagMapTag) != tables::kAbsentPackageId
+        && reader::read_tag(source, scratch, flagMapTag, blob)
+        && tables::find_array_at(
+            std::span<const std::byte>{blob}, tables::kAccountFlagMapDescriptor, flagMapRows)
+        && flagMapRows.count != 0
+        && flagMapRows.dataOffset
+                   + static_cast<std::size_t>(flagMapRows.count) * tables::kUnlockMapRowStride
+               <= blob.size()) {
+        for (std::uint64_t row = 0; row < flagMapRows.count && row <= domain::kUnavailableFlagIndex;
+             ++row) {
+            std::int16_t slot = 0;
+            std::memcpy(&slot,
+                        blob.data() + flagMapRows.dataOffset
+                            + static_cast<std::size_t>(row) * tables::kUnlockMapRowStride
+                            + tables::kUnlockMapDestinationSlotOffset,
+                        sizeof slot);
+            flagIndexBySlot.emplace(slot, static_cast<std::uint16_t>(row));
+        }
+    }
+
+
+    tables::Array characterFlagMapRows{};
+    std::unordered_map<std::int16_t, std::uint16_t> characterFlagIndexBySlot{};
+    if (flagMapTag != 0
+        && tables::find_array_at(std::span<const std::byte>{blob},
+                                 tables::kCharacterFlagMapDescriptor,
+                                 characterFlagMapRows)
+        && characterFlagMapRows.count != 0
+        && characterFlagMapRows.dataOffset
+                   + static_cast<std::size_t>(characterFlagMapRows.count)
+                         * tables::kUnlockMapRowStride
+               <= blob.size()) {
+        for (std::uint64_t row = 0;
+             row < characterFlagMapRows.count && row <= domain::kUnavailableFlagIndex; ++row) {
+            std::int16_t slot = 0;
+            std::memcpy(&slot,
+                        blob.data() + characterFlagMapRows.dataOffset
+                            + static_cast<std::size_t>(row) * tables::kUnlockMapRowStride
+                            + tables::kUnlockMapDestinationSlotOffset,
+                        sizeof slot);
+            characterFlagIndexBySlot.emplace(slot, static_cast<std::uint16_t>(row));
+        }
+    }
+
+    std::uint32_t mapTag = 0;
+    tables::Array mapRows{};
+    if (!tables::slot_tag(root, tables::kUnlockValueMapTableSlot, mapTag) || mapTag == 0
+        || tables::package_of(mapTag) == tables::kAbsentPackageId
+        || !reader::read_tag(source, scratch, mapTag, blob)
+        || !tables::find_array_at(std::span<const std::byte>{blob},
+                                  tables::kAccountValueMapDescriptor,
+                                  mapRows)
+        || mapRows.count == 0
+        || mapRows.dataOffset
+                   + static_cast<std::size_t>(mapRows.count) * tables::kUnlockMapRowStride
+               > blob.size()) {
+        report("value_map_fail", mapTag);
+        return false;
+    }
+    std::unordered_map<std::int16_t, std::uint16_t> indexBySlot{};
+    for (std::uint64_t row = 0; row < mapRows.count && row <= domain::kUnavailableValueIndex;
+         ++row) {
+        const std::size_t at =
+            mapRows.dataOffset + static_cast<std::size_t>(row) * tables::kUnlockMapRowStride;
+        std::int16_t slot = 0;
+        std::memcpy(&slot, blob.data() + at + tables::kUnlockMapDestinationSlotOffset, sizeof slot);
+        indexBySlot.emplace(slot, static_cast<std::uint16_t>(row));
+    }
+
+
+    tables::Array characterValueMapRows{};
+    std::unordered_map<std::int16_t, std::uint16_t> characterValueIndexBySlot{};
+    if (tables::find_array_at(std::span<const std::byte>{blob},
+                              tables::kCharacterValueMapDescriptor,
+                              characterValueMapRows)
+        && characterValueMapRows.count != 0
+        && characterValueMapRows.dataOffset
+                   + static_cast<std::size_t>(characterValueMapRows.count)
+                         * tables::kUnlockMapRowStride
+               <= blob.size()) {
+        for (std::uint64_t row = 0;
+             row < characterValueMapRows.count && row <= domain::kUnavailableValueIndex; ++row) {
+            std::int16_t slot = 0;
+            std::memcpy(&slot,
+                        blob.data() + characterValueMapRows.dataOffset
+                            + static_cast<std::size_t>(row) * tables::kUnlockMapRowStride
+                            + tables::kUnlockMapDestinationSlotOffset,
+                        sizeof slot);
+            characterValueIndexBySlot.emplace(slot, static_cast<std::uint16_t>(row));
+        }
+    }
+
+    std::uint32_t tableTag = 0;
+    tables::Array rows{};
+    if (!tables::slot_tag(root, tables::kPresentationNodeTableSlot, tableTag) || tableTag == 0
+        || tables::package_of(tableTag) == tables::kAbsentPackageId
+        || !reader::read_tag(source, scratch, tableTag, blob)
+        || !tables::find_array_at(
+            std::span<const std::byte>{blob}, tables::kTableArrayDescriptor, rows)
+        || rows.count == 0 || rows.count > output.size()
+        || rows.dataOffset + static_cast<std::size_t>(rows.count) * tables::kNodeRowStride
+               > blob.size()) {
+        report("node_table_fail", tableTag);
+        return false;
+    }
+
+    const std::span<const std::byte> table{blob};
+    std::size_t driving = 0;
+
+    // Pass 1: resolve every node's own value slot and children.
+    for (std::uint64_t row = 0; row < rows.count; ++row) {
+        const std::size_t at =
+            rows.dataOffset + static_cast<std::size_t>(row) * tables::kNodeRowStride;
+        domain::Definition& definition = output[static_cast<std::size_t>(row)];
+        definition = {};
+        definition.definitionIndex = static_cast<std::uint16_t>(row);
+
+        // The expression sits at one of two fields, and only one of them holds it on any node.
+        std::int16_t slot = 0;
+        const bool named =
+            tables::expression_value_slot(table, at, tables::kNodeExpressionFieldPrimary, slot)
+            || tables::expression_value_slot(table, at, tables::kNodeExpressionFieldAlternate, slot);
+        if (named) {
+            definition.valueSlot = slot;
+            const auto found = indexBySlot.find(slot);
+            if (found != indexBySlot.end()) {
+                definition.valueIndex = found->second;
+            }
+            // The same expression may resolve in the character scope: one lore book's bar reads a
+            // slot only the character table carries, and its parent has to be fed there too.
+            definition.characterValueSlot = slot;
+            const auto character_resolved = characterValueIndexBySlot.find(slot);
+            if (character_resolved != characterValueIndexBySlot.end()) {
+                definition.characterValueIndex = character_resolved->second;
+            }
+        }
+
+        // A category gated on a flag rather than on its own progress cannot reveal itself by being
+        // played: with no title shown there is nothing inside to claim, and nothing to claim leaves
+        // the gate shut. Resolve that flag so the gate can be satisfied.
+        std::int16_t gateSlot = 0;
+        if (tables::expression_flag_slot(table, at, tables::kNodeExpressionFieldPrimary, gateSlot)
+            || tables::expression_flag_slot(table, at, tables::kNodeExpressionFieldAlternate, gateSlot)) {
+            const auto gate = flagIndexBySlot.find(gateSlot);
+            if (gate != flagIndexBySlot.end()) {
+                definition.visibilityFlagIndex = gate->second;
+            }
+            const auto characterGate = characterFlagIndexBySlot.find(gateSlot);
+            if (characterGate != characterFlagIndexBySlot.end()) {
+                definition.visibilityCharacterFlagIndex = characterGate->second;
+            }
+        }
+
+
+
+        // Records the node owns, four bytes each as a row and a gate.
+        std::int64_t childCount = 0;
+        std::int64_t childRelative = 0;
+        std::memcpy(&childCount, table.data() + at + tables::kNodeChildRecordField,
+                    sizeof childCount);
+        std::memcpy(&childRelative, table.data() + at + tables::kNodeChildRecordField + 8,
+                    sizeof childRelative);
+        if (childCount >= 1 && childCount <= static_cast<std::int64_t>(domain::kChildCapacity)) {
+            const std::size_t pointerAt = at + tables::kNodeChildRecordField + 8;
+            const std::int64_t target = static_cast<std::int64_t>(pointerAt) + childRelative
+                                        + static_cast<std::int64_t>(tables::kHeaderSkip);
+            if (target >= 0
+                && static_cast<std::size_t>(target)
+                           + static_cast<std::size_t>(childCount) * tables::kNodeChildRecordStride
+                       <= table.size()) {
+                const auto base = static_cast<std::size_t>(target);
+                for (std::int64_t index = 0; index < childCount; ++index) {
+                    std::uint16_t childRow = 0;
+                    std::memcpy(&childRow,
+                                table.data() + base
+                                    + static_cast<std::size_t>(index)
+                                          * tables::kNodeChildRecordStride,
+                                sizeof childRow);
+                    definition.children[static_cast<std::size_t>(definition.childCount++)] =
+                        childRow;
+                }
+            }
+        }
+        if (definition.childCount != 0 && definition.valueIndex != domain::kUnavailableValueIndex) {
+            ++driving;
+        }
+        ++count;
+    }
+
+    // Pass 2: assign each lore book's parent-record bar slot from the shipped allocation.
+    //
+    // The naive rule (parent = category slot + 1) holds only when the slot above a category was
+    // free at allocation time. Categories were handed out in contiguous runs, and a run's parent
+    // slots were deferred to immediately after the run, assigned in reverse category order: the
+    // run's first book takes the last parent slot, its last book takes the first. Verified against
+    // four independent in-game marker readings; see kNodeParentSlotStep in definition_index_table.h.
+    //
+    // Both scopes are walked. Most books are account-scoped; one reads its category from the
+    // character table, and its parent sits in that scope too.
+    {
+        struct BookSlot {
+            std::int32_t slot;
+            std::size_t row;
+        };
+        std::vector<BookSlot> accountBooks;
+        std::vector<BookSlot> characterBooks;
+        for (std::size_t row = 0; row < count; ++row) {
+            const auto& d = output[row];
+            if (!domain::lore_category(d.definitionIndex)) {
+                continue;
+            }
+            if (d.valueSlot >= 0) {
+                accountBooks.push_back({d.valueSlot, row});
+            }
+            if (d.characterValueSlot >= 0) {
+                characterBooks.push_back({d.characterValueSlot, row});
+            }
+        }
+
+        auto assignRuns = [&](std::vector<BookSlot>& books,
+                              const std::unordered_map<std::int16_t, std::uint16_t>& indexBySlot,
+                              auto memberSetter) {
+            std::sort(books.begin(), books.end(),
+                      [](const BookSlot& a, const BookSlot& b) { return a.slot < b.slot; });
+            std::size_t i = 0;
+            while (i < books.size()) {
+                std::size_t j = i;
+                while (j + 1 < books.size() && books[j + 1].slot == books[j].slot + 1) {
+                    ++j;
+                }
+                const std::size_t runLength = j - i + 1;
+                const std::int32_t runEnd = books[j].slot;
+                for (std::size_t k = 0; k < runLength; ++k) {
+                    // Reverse order within the run: book k gets the slot after the run counting
+                    // back from its end.
+                    const std::int32_t parentSlot =
+                        runEnd + static_cast<std::int32_t>(runLength - k);
+                    const auto found = indexBySlot.find(static_cast<std::int16_t>(parentSlot));
+                    if (found != indexBySlot.end()) {
+                        memberSetter(output[books[i + k].row], found->second);
+                    }
+                }
+                i = j + 1;
+            }
+        };
+
+        assignRuns(accountBooks,
+                   indexBySlot,
+                   [](domain::Definition& d, std::uint16_t index) {
+                       d.parentValueIndex = index;
+                   });
+        assignRuns(characterBooks,
+                   characterValueIndexBySlot,
+                   [](domain::Definition& d, std::uint16_t index) {
+                       d.parentCharacterValueIndex = index;
+                   });
+    }
+
+    report("ok", static_cast<unsigned long long>(driving));
+
+
+    return count != 0;
+}
+
+} // namespace sunrise::client::content::items::packages

+ 177 - 0
Sunrise/src/client/content/items/packages/package_record_build.cpp

@@ -0,0 +1,177 @@
+#include <array>
+#include <cstdio>
+#include <cstring>
+
+#include "../../../../core/logging/log.h"
+
+#include "../../../../state/build_data/runtime.h"
+#include "../../../../middleware/content/packages/tables/unlock_expression.h"
+#include "internal.h"
+
+namespace sunrise::client::content::items::packages {
+namespace {
+
+/** Reports where the record pass stopped, so a silent miss cannot look like a working claim. */
+void report(const char* stage, unsigned long long detail) noexcept {
+    std::array<char, 128> line{};
+    const int count = std::snprintf(
+        line.data(), line.size(), "ev=pkg stage=records result=%s detail=%llu", stage, detail);
+    if (count > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+}
+
+/** A record with no completion flag carries a non-positive slot, which addresses nothing. */
+[[nodiscard]] constexpr bool addressable_slot(std::int16_t slot) noexcept {
+    return slot > 0;
+}
+
+
+} // namespace
+
+/**
+ * Reads the records table and resolves each record's completion flag to a bank index.
+ *
+ * A record row carries the unlock slot of its completion flag, and a slot is not an array index:
+ * the byte that feeds a slot sits at the row number of the mapping table whose destination is that
+ * slot. Both tables are walked here so a claim can go straight from a record row to the index it
+ * has to set.
+ */
+bool build_records(const reader::Source& source,
+                   reader::Scratch& scratch,
+                   std::span<const std::byte> root,
+                   std::vector<std::byte>& blob,
+                   std::span<state::build_data::records::Definition> output,
+                   std::size_t& count) noexcept {
+    namespace domain = state::build_data::records;
+    count = 0;
+
+    // The account flag mapping table, read first because the record rows are matched against it.
+    std::uint32_t mapTag = 0;
+    tables::Array mapRows{};
+    if (!tables::slot_tag(root, tables::kUnlockFlagMapTableSlot, mapTag) || mapTag == 0
+        || tables::package_of(mapTag) == tables::kAbsentPackageId
+        || !reader::read_tag(source, scratch, mapTag, blob)
+        || !tables::find_array_at(std::span<const std::byte>{blob},
+                                  tables::kAccountFlagMapDescriptor,
+                                  mapRows)
+        || mapRows.count == 0
+        || mapRows.dataOffset
+                   + static_cast<std::size_t>(mapRows.count) * tables::kUnlockMapRowStride
+               > blob.size()) {
+        report("flag_map_fail", mapTag);
+        return false;
+    }
+
+    // Destination slot to mapping row. The first row wins, matching how a bank is addressed.
+    constexpr std::size_t kSlotSpace = 32768;
+    static_assert(domain::kUnavailableFlagIndex == 0xFFFFU);
+    std::vector<std::uint16_t> indexBySlot{};
+    indexBySlot.assign(kSlotSpace, domain::kUnavailableFlagIndex);
+    for (std::uint64_t row = 0; row < mapRows.count; ++row) {
+        const std::size_t at =
+            mapRows.dataOffset + static_cast<std::size_t>(row) * tables::kUnlockMapRowStride;
+        std::int16_t slot = 0;
+        std::memcpy(&slot, blob.data() + at + tables::kUnlockMapDestinationSlotOffset, sizeof slot);
+        if (!addressable_slot(slot) || static_cast<std::size_t>(slot) >= kSlotSpace
+            || row > domain::kUnavailableFlagIndex) {
+            continue;
+        }
+        std::uint16_t& existing = indexBySlot[static_cast<std::size_t>(slot)];
+        if (existing == domain::kUnavailableFlagIndex) {
+            existing = static_cast<std::uint16_t>(row);
+        }
+    }
+
+    // The account value mapping table, read while the blob is still free. A record names its
+    // category's value slot, and that slot has to become an index the same way a flag slot does.
+    std::uint32_t valueMapTag = 0;
+    tables::Array valueMapRows{};
+    std::vector<std::uint16_t> valueIndexBySlot{};
+    if (tables::slot_tag(root, tables::kUnlockValueMapTableSlot, valueMapTag) && valueMapTag != 0
+        && tables::package_of(valueMapTag) != tables::kAbsentPackageId
+        && reader::read_tag(source, scratch, valueMapTag, blob)
+        && tables::find_array_at(std::span<const std::byte>{blob},
+                                 tables::kAccountValueMapDescriptor,
+                                 valueMapRows)
+        && valueMapRows.count != 0
+        && valueMapRows.dataOffset
+                   + static_cast<std::size_t>(valueMapRows.count) * tables::kUnlockMapRowStride
+               <= blob.size()) {
+        valueIndexBySlot.assign(kSlotSpace, domain::kUnavailableValueIndex);
+        for (std::uint64_t row = 0; row < valueMapRows.count; ++row) {
+            std::int16_t slot = 0;
+            std::memcpy(&slot,
+                        blob.data() + valueMapRows.dataOffset
+                            + static_cast<std::size_t>(row) * tables::kUnlockMapRowStride
+                            + tables::kUnlockMapDestinationSlotOffset,
+                        sizeof slot);
+            if (!addressable_slot(slot) || static_cast<std::size_t>(slot) >= kSlotSpace
+                || row > domain::kUnavailableValueIndex) {
+                continue;
+            }
+            std::uint16_t& existing = valueIndexBySlot[static_cast<std::size_t>(slot)];
+            if (existing == domain::kUnavailableValueIndex) {
+                existing = static_cast<std::uint16_t>(row);
+            }
+        }
+    }
+
+    std::uint32_t tableTag = 0;
+    tables::Array rows{};
+    if (!tables::slot_tag(root, tables::kRecordTableSlot, tableTag) || tableTag == 0
+        || tables::package_of(tableTag) == tables::kAbsentPackageId
+        || !reader::read_tag(source, scratch, tableTag, blob)
+        || !tables::find_array_at(
+            std::span<const std::byte>{blob}, tables::kTableArrayDescriptor, rows)
+        || rows.count == 0 || rows.count > output.size()
+        || rows.dataOffset + static_cast<std::size_t>(rows.count) * tables::kRecordRowStride
+               > blob.size()) {
+        report("record_table_fail", tableTag);
+        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::kRecordRowStride;
+        std::int16_t slot = 0;
+        std::memcpy(&slot,
+                    blob.data() + at + tables::kRecordCompletionFlagOffset,
+                    sizeof slot);
+        std::uint32_t score = 0;
+        std::memcpy(&score, blob.data() + at + tables::kRecordScoreOffset, sizeof score);
+        domain::Definition& definition = output[static_cast<std::size_t>(row)];
+        definition = {};
+        definition.definitionIndex = static_cast<std::uint16_t>(row);
+        std::memcpy(&definition.definitionHash, blob.data() + at + tables::kRecordHashOffset,
+                    sizeof definition.definitionHash);
+        // The lore row this record displays, or 0xFFFF for a book's parent triumph.
+        std::memcpy(&definition.loreRow, blob.data() + at + tables::kLoreRowOffset,
+                    sizeof definition.loreRow);
+        // The shipped table tops out at 500, so anything wider is not a score and is dropped.
+        definition.scoreValue = score <= 0xFFFFU ? static_cast<std::uint16_t>(score) : 0U;
+        std::uint32_t hasTitle = 0;
+        std::memcpy(&hasTitle, blob.data() + at + tables::kRecordHasTitleOffset, sizeof hasTitle);
+        definition.hasTitle = hasTitle != 0;
+        std::int16_t categorySlot = 0;
+        if (!valueIndexBySlot.empty()
+            && tables::expression_value_slot(std::span<const std::byte>{blob},
+                                     at,
+                                     tables::kRecordCategoryExpressionField,
+                                     categorySlot)
+            && addressable_slot(categorySlot)
+            && static_cast<std::size_t>(categorySlot) < kSlotSpace) {
+            definition.categoryValueIndex = valueIndexBySlot[static_cast<std::size_t>(categorySlot)];
+        }
+        if (addressable_slot(slot) && static_cast<std::size_t>(slot) < kSlotSpace) {
+            definition.completionFlagIndex = indexBySlot[static_cast<std::size_t>(slot)];
+        }
+        ++count;
+    }
+    report("ok", static_cast<unsigned long long>(count));
+    return count != 0;
+}
+
+} // namespace sunrise::client::content::items::packages

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

@@ -22,6 +22,17 @@ constexpr std::array<std::uint32_t, 3> kTrackerPlugHashes{
     2'302'094'943U,
     38'912'240U,
 };
+/** Enhanced Sword Scavenger already carries the correct Arrivals leg-armour socket relation. */
+constexpr std::uint32_t kArrivalsLegReferenceHash = 3'213'968'579U;
+/** Plug category declared by Enhanced Sword Scavenger and required by leg-armour sockets. */
+constexpr std::uint32_t kArrivalsLegCategoryHash = 0x7DDE0206U;
+/** Arrivals artifact records whose leg-armour label conflicts with their shipped general pool. */
+constexpr std::array<std::uint32_t, 4> kArrivalsLegModHashes{
+    3'465'659'109U, // Flourishing Blade
+    3'465'659'111U, // Automatic Prize
+    3'465'659'104U, // Dimensional Tithes
+    3'465'659'105U, // Ascendant Bounty
+};
 /** 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. */
@@ -157,6 +168,15 @@ std::uint8_t special_plug_category(std::uint32_t categoryHash) noexcept {
     return 0;
 }
 
+/** Makes the four mislabeled artifact definitions agree with their leg-armour presentation. */
+std::uint32_t corrected_plug_category(std::uint32_t definitionHash,
+                                      std::uint32_t categoryHash) noexcept {
+    return std::find(kArrivalsLegModHashes.begin(), kArrivalsLegModHashes.end(), definitionHash)
+                   != kArrivalsLegModHashes.end()
+               ? kArrivalsLegCategoryHash
+               : categoryHash;
+}
+
 /** Allocates the bounded build state and indexes expansion/tracker plug definitions. */
 bool SocketPlugBuild::prepare(
     std::span<const std::uint8_t> specialCategories,
@@ -191,6 +211,20 @@ bool SocketPlugBuild::prepare(
             }
             trackerMembers_[trackerCount_++] = static_cast<std::uint16_t>(item);
         }
+        if (itemDefinitions[item].definitionHash == kArrivalsLegReferenceHash) {
+            arrivalsLegReference_ = static_cast<std::uint16_t>(item);
+        }
+        for (std::size_t mod = 0; mod < kArrivalsLegModHashes.size(); ++mod) {
+            if (itemDefinitions[item].definitionHash == kArrivalsLegModHashes[mod]) {
+                arrivalsLegMembers_[mod] = static_cast<std::uint16_t>(item);
+            }
+        }
+    }
+    if (arrivalsLegReference_ == UINT16_MAX
+        || std::find(arrivalsLegMembers_.begin(), arrivalsLegMembers_.end(), UINT16_MAX)
+               != arrivalsLegMembers_.end()) {
+        release();
+        return false;
     }
     return true;
 }
@@ -207,6 +241,33 @@ bool SocketPlugBuild::add(std::uint32_t itemDefinitionIndex,
     return true;
 }
 
+/** Mirrors Enhanced Sword Scavenger's exact lane admission onto the four reclassified mods. */
+bool SocketPlugBuild::route_arrivals_leg_mods() noexcept {
+    const bool legLane = std::find(candidates_.data(),
+                                   candidates_.data() + candidateCount_,
+                                   arrivalsLegReference_)
+                         != candidates_.data() + candidateCount_;
+    const auto isReclassified = [this](socket_plugs::Member member) noexcept {
+        return std::find(arrivalsLegMembers_.begin(), arrivalsLegMembers_.end(), member)
+               != arrivalsLegMembers_.end();
+    };
+    candidateCount_ = static_cast<std::size_t>(
+        std::remove_if(candidates_.data(),
+                       candidates_.data() + candidateCount_,
+                       isReclassified)
+        - candidates_.data());
+    if (!legLane) {
+        return true;
+    }
+    for (const socket_plugs::Member member : arrivalsLegMembers_) {
+        if (candidateCount_ >= candidates_.size()) {
+            return false;
+        }
+        candidates_[candidateCount_++] = member;
+    }
+    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;
@@ -239,6 +300,9 @@ bool SocketPlugBuild::intern(std::uint32_t& poolIndex) noexcept {
         std::copy_n(first, categoryCounts_[family], candidates_.data() + candidateCount_);
         candidateCount_ += categoryCounts_[family];
     }
+    if (!route_arrivals_leg_mods()) {
+        return false;
+    }
     std::sort(candidates_.data(), candidates_.data() + candidateCount_);
     candidateCount_ = static_cast<std::size_t>(
         std::unique(candidates_.data(), candidates_.data() + candidateCount_) - candidates_.data());
@@ -378,6 +442,8 @@ void SocketPlugBuild::release() noexcept {
     lookup_.shrink_to_fit();
     categoryCounts_ = {};
     trackerMembers_ = {};
+    arrivalsLegReference_ = UINT16_MAX;
+    arrivalsLegMembers_.fill(UINT16_MAX);
     trackerCount_ = 0;
     ruleCount_ = 0;
     poolCount_ = 0;

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

@@ -127,6 +127,9 @@ private:
     std::vector<PoolLookup> lookup_{};
     std::array<std::size_t, kCategoryCount> categoryCounts_{};
     std::array<socket_plugs::Member, 3> trackerMembers_{};
+    socket_plugs::Member arrivalsLegReference_{UINT16_MAX};
+    std::array<socket_plugs::Member, 4> arrivalsLegMembers_{
+        UINT16_MAX, UINT16_MAX, UINT16_MAX, UINT16_MAX};
     std::size_t trackerCount_{};
     std::size_t ruleCount_{};
     std::size_t poolCount_{};
@@ -136,6 +139,8 @@ private:
 
     /** Expands native category families, canonicalizes, and interns the current candidate. */
     [[nodiscard]] bool intern(std::uint32_t& poolIndex) noexcept;
+    /** Replaces the four Worthy-era general memberships with the reference Arrivals leg set. */
+    [[nodiscard]] bool route_arrivals_leg_mods() noexcept;
     /** Releases every transient allocation and count. */
     void release() noexcept;
 };
@@ -146,4 +151,8 @@ private:
  */
 [[nodiscard]] std::uint8_t special_plug_category(std::uint32_t categoryHash) noexcept;
 
+/** Applies installed-season plug-category corrections absent from the Worthy package rows. */
+[[nodiscard]] std::uint32_t corrected_plug_category(std::uint32_t definitionHash,
+                                                    std::uint32_t categoryHash) noexcept;
+
 } // namespace sunrise::client::content::items::packages

+ 42 - 0
Sunrise/src/client/hooks/bootflow/spawn_hold.cpp

@@ -1,4 +1,9 @@
+#include <windows.h>
+
+#include <algorithm>
+#include <array>
 #include <atomic>
+#include <cstdio>
 #include <cstdint>
 #include <string_view>
 
@@ -30,6 +35,38 @@ using SpawnGate = bool(__fastcall*)(std::int32_t) noexcept;
 
 hooking::detour::Handle g_handle{};
 std::atomic<SpawnGate> g_original{nullptr};
+std::atomic<std::uint64_t> g_lastProbeTick{};
+std::atomic<spawn::Refusal> g_lastRefusal{spawn::Refusal::unknown};
+
+/** Reports a changed refusal immediately and a persistent refusal every five seconds. */
+void report_spawn_refusal(std::int32_t datum,
+                          state::activity::WorldPhase phase,
+                          std::uint64_t age) noexcept {
+    const spawn::Reading reading = spawn::examine(datum);
+    const std::uint64_t now = GetTickCount64();
+    const spawn::Refusal previous = g_lastRefusal.exchange(reading.refusal);
+    const std::uint64_t last = g_lastProbeTick.load(std::memory_order_relaxed);
+    if (reading.refusal == previous && now - last < 5'000U) {
+        return;
+    }
+    g_lastProbeTick.store(now, std::memory_order_relaxed);
+    std::array<char, core::log::kLineCapacity> fields{};
+    const std::size_t count = spawn::describe(reading, fields);
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=bootflow stage=spawn_gate result=blocked phase=%u age=%llu %.*s",
+                                      static_cast<unsigned>(phase),
+                                      static_cast<unsigned long long>(age),
+                                      static_cast<int>(count),
+                                      fields.data());
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         {line.data(),
+                          (std::min)(static_cast<std::size_t>(written), line.size() - 1U)});
+    }
+}
 
 /**
  * Puts the spawn after the world-transition fade is armed.
@@ -54,6 +91,9 @@ __declspec(noinline) bool __fastcall spawn_gate(std::int32_t datum) noexcept {
     if (phase == state::activity::WorldPhase::arrived) {
         release_world_fade();
     }
+    if (!allowed && transitioning) {
+        report_spawn_refusal(datum, phase, age);
+    }
     return allowed && loading ? kHeld : allowed;
 }
 
@@ -102,6 +142,8 @@ void uninstall_spawn_hold() noexcept {
         (void)hooking::detour::uninstall(g_handle);
     }
     g_original.store(nullptr, std::memory_order_release);
+    g_lastProbeTick.store(0, std::memory_order_relaxed);
+    g_lastRefusal.store(spawn::Refusal::unknown, std::memory_order_relaxed);
     spawn::forget();
 }
 

+ 17 - 3
Sunrise/src/client/hooks/network/investment/internal.h

@@ -21,10 +21,24 @@ using patterns::signature_length;
 /** @return True while the family-five commit rearm is attached. */
 [[nodiscard]] bool family5_rearm_is_installed() noexcept;
 
+/** Applies the armed set correction and services bounded category-readiness retries. */
+void apply_socket_menu_routing() noexcept;
+
+/** Reserves low-address storage before retail content occupies the compatible address domain. */
+void reserve_socket_menu_routing_storage() noexcept;
+
+/** Arms socket-menu correction at native content-table patch completion. */
+void arm_socket_menu_routing() noexcept;
+
+/** Restores owned category fields and plug-set descriptors without recycling published storage. */
+void restore_socket_menu_routing() noexcept;
+
+/** Reveals the specified lore entries without altering any account progress. */
+void apply_lore_visibility() noexcept;
+void restore_lore_visibility() noexcept;
+
 /**
- * Arms one derived-state rebuild, used up by the next freshness verdict. Armed twice: when the
- * family-four lookup first returns a real object, and after each family-five commit, which is the
- * first point the account's unlock overrides can be read back.
+ * Arms one derived-state rebuild after replicated investment state changes.
  */
 void arm_derived_rebuild() noexcept;
 

+ 42 - 7
Sunrise/src/client/hooks/network/investment/investment_derived_rebuild.cpp

@@ -49,7 +49,8 @@ std::atomic<Freshness> g_originalFreshness{nullptr};
 std::atomic<Family4Lookup> g_originalFamily4Lookup{nullptr};
 std::atomic_bool g_rebuildArmed{false};
 std::atomic_bool g_reportedRebuild{false};
-std::atomic_bool g_reportedFamily4{false};
+std::atomic<void*> g_committedFamily4{nullptr};
+std::atomic_uint32_t g_pendingFamily4Publications{0};
 
 /** @return True while either primary rebuild detour is attached. */
 [[nodiscard]] bool any_primary_attached() noexcept {
@@ -62,7 +63,8 @@ void clear_runtime() noexcept {
     g_originalFamily4Lookup.store(nullptr, std::memory_order_release);
     g_rebuildArmed.store(false, std::memory_order_release);
     g_reportedRebuild.store(false, std::memory_order_release);
-    g_reportedFamily4.store(false, std::memory_order_release);
+    g_committedFamily4.store(nullptr, std::memory_order_release);
+    g_pendingFamily4Publications.store(0, std::memory_order_release);
 }
 
 /**
@@ -71,6 +73,11 @@ void clear_runtime() noexcept {
  * @return Stale once while armed, otherwise the native verdict.
  */
 __declspec(noinline) char __fastcall freshness(void* accessor) noexcept {
+    const Freshness original = g_originalFreshness.load(std::memory_order_acquire);
+    // The native verdict performs the Family-4 lookup. That lookup is what arms the initial
+    // rebuild, so it must run before the arm is consumed; checking first left the arm stranded
+    // when sign-on made only one freshness query and every Triumph card kept its stale action.
+    const char nativeVerdict = original != nullptr ? original(accessor) : kStale;
     if (g_rebuildArmed.exchange(false, std::memory_order_acq_rel)) {
         if (!g_reportedRebuild.exchange(true, std::memory_order_relaxed)) {
             core::log::write(core::log::Channel::client,
@@ -79,23 +86,41 @@ __declspec(noinline) char __fastcall freshness(void* accessor) noexcept {
         }
         return kStale;
     }
-    const Freshness original = g_originalFreshness.load(std::memory_order_acquire);
-    return original != nullptr ? original(accessor) : kStale;
+    return nativeVerdict;
 }
 
 /**
- * Arms a rebuild when the state-three lookup first returns a real family-four object.
+ * Arms a rebuild whenever the state-three lookup observes a different committed Family-4 object.
+ * The freshness verdict itself performs this lookup, so a simple "nonnull" test would re-arm on
+ * every query and keep the derived state permanently stale. Object identity changes only when the
+ * queuez replacement has committed, which gives initial sign-on and later account after-images the
+ * same boundary without an unrelated Family-5 publication.
  * @param key Borrowed account key.
  * @return The native lookup result, unchanged.
  */
 __declspec(noinline) void* __fastcall family4_lookup(std::uint64_t* key) noexcept {
     const Family4Lookup original = g_originalFamily4Lookup.load(std::memory_order_acquire);
     void* const resolved = original != nullptr ? original(key) : nullptr;
-    if (resolved != nullptr && !g_reportedFamily4.exchange(true, std::memory_order_relaxed)) {
+    if (resolved != nullptr) {
+        std::uint32_t pending = g_pendingFamily4Publications.load(std::memory_order_acquire);
+        while (pending != 0
+               && !g_pendingFamily4Publications.compare_exchange_weak(
+                   pending, pending - 1, std::memory_order_acq_rel, std::memory_order_acquire)) {}
+        if (pending != 0) {
+            arm_derived_rebuild();
+            core::log::write(core::log::Channel::client,
+                             core::log::Level::info,
+                             "ev=investment stage=family4_commit result=armed source=publication");
+        }
+    }
+    void* previous = g_committedFamily4.load(std::memory_order_acquire);
+    if (resolved != nullptr && resolved != previous
+        && g_committedFamily4.compare_exchange_strong(
+            previous, resolved, std::memory_order_acq_rel, std::memory_order_acquire)) {
         arm_derived_rebuild();
         core::log::write(core::log::Channel::client,
                          core::log::Level::info,
-                         "ev=investment stage=family4 result=resolved");
+                         "ev=investment stage=family4_commit result=armed");
     }
     return resolved;
 }
@@ -107,6 +132,14 @@ void arm_derived_rebuild() noexcept {
     g_rebuildArmed.store(true, std::memory_order_release);
 }
 
+/** Carries an exact committed account publication to its next native Family-4 lookup. */
+void notify_family4_publication() noexcept {
+    g_pendingFamily4Publications.fetch_add(1, std::memory_order_release);
+    core::log::write(core::log::Channel::client,
+                     core::log::Level::info,
+                     "ev=investment stage=family4_publication result=pending");
+}
+
 /** @return True when freshness and both real-arrival rebuild arms are attached. */
 bool install() noexcept {
     if (is_installed()) {
@@ -175,6 +208,8 @@ bool install() noexcept {
 
 /** @return True when every investment rebuild detour is absent. */
 bool uninstall() noexcept {
+    restore_lore_visibility();
+    restore_socket_menu_routing();
     if (!uninstall_family5_rearm()) {
         core::log::write(core::log::Channel::client,
                          core::log::Level::warn,

+ 10 - 0
Sunrise/src/client/hooks/network/investment/investment_derived_rebuild.h

@@ -1,7 +1,17 @@
 #pragma once
 
+namespace sunrise::state {
+struct Family5State;
+}
+
 namespace sunrise::client::hooks::network::investment {
 
+/** Replaces the live Family-5 override lists and invalidates their derived evaluation. */
+[[nodiscard]] bool publish_live_family5(const state::Family5State& family) noexcept;
+
+/** Notes that a committed response will replace Family 4 before the next freshness query. */
+void notify_family4_publication() noexcept;
+
 /** @return True when freshness and both real-arrival rebuild arms are attached. */
 [[nodiscard]] bool install() noexcept;
 

+ 106 - 4
Sunrise/src/client/hooks/network/investment/investment_family5_rearm.cpp

@@ -8,10 +8,14 @@
 #include <atomic>
 #include <cstddef>
 #include <cstdint>
+#include <cstring>
+#include <limits>
 #include <string_view>
 
 #include "../../../../core/logging/log.h"
+#include "../../../../state/investment/investment.h"
 #include "../../../hooking/detour.h"
+#include "../../../targets/game/content.h"
 #include "internal.h"
 
 namespace sunrise::client::hooks::network::investment {
@@ -31,29 +35,66 @@ constexpr auto kCommitSignature =
 
 /** Result returned when the trampoline is gone, so no commit ran. */
 constexpr std::int64_t kNoCommit = 0;
-
 using CommitFamily5 = std::int64_t(__fastcall*)(void*, std::uint64_t*);
 
 hooking::detour::Handle g_handle{};
 std::atomic<CommitFamily5> g_original{nullptr};
+std::atomic<void*> g_manager{nullptr};
 std::atomic_bool g_reportedArm{false};
 
+constexpr std::size_t kObjectArraysOffset = 33'624;
+constexpr std::size_t kObjectArraysSize = 878'184;
+constexpr std::size_t kDescriptorSize = 16;
+constexpr std::size_t kFamily5Type = 5;
+constexpr std::size_t kFamily5Slot = 5;
+constexpr std::uint32_t kFamily5Stride = 1'712;
+constexpr std::uint64_t kFamily5Soid =
+    static_cast<std::uint64_t>((std::numeric_limits<std::int64_t>::max)());
+constexpr std::size_t kFlagListOffset = 124;
+constexpr std::size_t kValueListOffset = 528;
+
+struct SlotDescriptor {
+    std::uint32_t base{};
+    std::uint32_t count{};
+    std::uint32_t stride{};
+    std::uint32_t schemaId{};
+};
+
+struct FlagRow {
+    std::int16_t slot{};
+    std::int8_t value{};
+    std::uint8_t padding{};
+};
+
+struct ValueRow {
+    std::int16_t slot{};
+    std::array<std::uint8_t, 2> padding{};
+    std::int32_t value{};
+};
+
+static_assert(sizeof(SlotDescriptor) == 16);
+static_assert(sizeof(FlagRow) == 4);
+static_assert(sizeof(ValueRow) == 8);
+
+using ObjectStoreGetter = std::byte*(__fastcall*)();
+
 /**
  * Runs the family-five commit, then arms one derived-state rebuild. The two callers pass different
  * second arguments, so it is passed on unread. Arming twice is harmless, and the next freshness
  * verdict uses it up, so repeat commits need no latch.
- * @param primaryRecordBlock Borrowed record block the commit writes into.
+ * @param manager Borrowed queuez manager owning the Family-5 commit.
  * @param nested4 Borrowed caller-owned argument, passed on unread.
  * @return The commit's own result, or the no-commit result when the trampoline is gone.
  */
-__declspec(noinline) std::int64_t __fastcall commit(void* primaryRecordBlock,
+__declspec(noinline) std::int64_t __fastcall commit(void* manager,
                                                     std::uint64_t* nested4) noexcept {
     const CommitFamily5 original = g_original.load(std::memory_order_acquire);
     if (original == nullptr) {
         return kNoCommit;
     }
+    g_manager.store(manager, std::memory_order_release);
     // Arm on the way out: the overrides are in the object only once the commit has run.
-    const std::int64_t result = original(primaryRecordBlock, nested4);
+    const std::int64_t result = original(manager, nested4);
     arm_derived_rebuild();
     if (!g_reportedArm.exchange(true, std::memory_order_relaxed)) {
         core::log::write(core::log::Channel::client,
@@ -65,6 +106,66 @@ __declspec(noinline) std::int64_t __fastcall commit(void* primaryRecordBlock,
 
 } // namespace
 
+bool publish_live_family5(const state::Family5State& family) noexcept {
+    const auto& targets = client::targets::game::content::get();
+    if (!client::targets::game::content::is_resolved()
+        || targets.queuezObjectStoreGetter == nullptr
+        || family.objectSoid != kFamily5Soid || family.flagCount > family.flags.size()
+        || family.valueCount > family.values.size()) {
+        return false;
+    }
+    const auto getter = reinterpret_cast<ObjectStoreGetter>(targets.queuezObjectStoreGetter);
+    std::byte* const store = getter();
+    if (store == nullptr) {
+        return false;
+    }
+    const std::size_t descriptorIndex =
+        kFamily5Slot + 6U * (kFamily5Type + targets.queuezDescriptorFamilyBias);
+    SlotDescriptor descriptor{};
+    std::memcpy(&descriptor,
+                store + descriptorIndex * kDescriptorSize,
+                sizeof descriptor);
+    if (descriptor.base > kObjectArraysSize - kFamily5Stride || descriptor.count != 1
+        || descriptor.stride != kFamily5Stride) {
+        return false;
+    }
+    std::byte* const object = store + kObjectArraysOffset + descriptor.base;
+    std::uint64_t objectSoid = 0;
+    std::memcpy(&objectSoid, object, sizeof objectSoid);
+    if (objectSoid != kFamily5Soid) {
+        return false;
+    }
+
+    const CommitFamily5 original = g_original.load(std::memory_order_acquire);
+    void* const manager = g_manager.load(std::memory_order_acquire);
+    if (original == nullptr || manager == nullptr) {
+        return false;
+    }
+
+    alignas(16) std::array<std::byte, kFamily5Stride> updated{};
+    std::memcpy(updated.data(), object, updated.size());
+    std::array<FlagRow, state::kUnlockOverrideCapacity> flags{};
+    for (std::size_t index = 0; index < family.flagCount; ++index) {
+        flags[index].slot = static_cast<std::int16_t>(family.flags[index].slot);
+        flags[index].value = static_cast<std::int8_t>(family.flags[index].value);
+    }
+    std::array<ValueRow, state::kUnlockOverrideCapacity> values{};
+    for (std::size_t index = 0; index < family.valueCount; ++index) {
+        values[index].slot = static_cast<std::int16_t>(family.values[index].slot);
+        values[index].value = family.values[index].value;
+    }
+    const auto flagCount = static_cast<std::uint32_t>(family.flagCount);
+    const auto valueCount = static_cast<std::uint32_t>(family.valueCount);
+    std::memcpy(
+        updated.data() + kFlagListOffset + sizeof flagCount, flags.data(), sizeof flags);
+    std::memcpy(
+        updated.data() + kValueListOffset + sizeof valueCount, values.data(), sizeof values);
+    std::memcpy(updated.data() + kFlagListOffset, &flagCount, sizeof flagCount);
+    std::memcpy(updated.data() + kValueListOffset, &valueCount, sizeof valueCount);
+    (void)commit(manager, reinterpret_cast<std::uint64_t*>(updated.data()));
+    return true;
+}
+
 /**
  * Attaches the family-five commit rearm.
  * @return True when the target is found and the detour attaches.
@@ -100,6 +201,7 @@ bool uninstall_family5_rearm() noexcept {
         return false;
     }
     g_original.store(nullptr, std::memory_order_release);
+    g_manager.store(nullptr, std::memory_order_release);
     g_reportedArm.store(false, std::memory_order_release);
     return true;
 }

+ 170 - 0
Sunrise/src/client/hooks/network/investment/investment_lore_visibility.cpp

@@ -0,0 +1,170 @@
+#include <Windows.h>
+
+#include <array>
+#include <limits>
+
+#include "../../../../core/logging/log.h"
+#include "../../../content/handles/handle_resolver.h"
+#include "../../../memory/current_process_memory.h"
+#include "../../../targets/game/content.h"
+#include "internal.h"
+#include "lore_visibility_patch.h"
+
+namespace sunrise::client::hooks::network::investment {
+namespace {
+struct Descriptor {
+    std::uint64_t count;
+    std::int64_t relative;
+};
+struct Patch {
+    std::uintptr_t address;
+    lore::Instruction before, after;
+};
+std::array<Patch, lore::kTargets.size()> g_patches{};
+std::size_t g_count{};
+SRWLOCK g_lock = SRWLOCK_INIT;
+
+template <class T> bool read(std::uintptr_t address, T& value) noexcept {
+    return memory::read_current_process(
+        nullptr, address, std::as_writable_bytes(std::span(&value, 1)));
+}
+bool data_at(std::uintptr_t address, const Descriptor& desc, std::uintptr_t& data) noexcept {
+    if (address > static_cast<std::uintptr_t>(INT64_MAX) - 24) return false;
+    const auto base = static_cast<std::int64_t>(address) + 8;
+    if (desc.relative < -base || desc.relative > INT64_MAX - base - 16) return false;
+    const auto header = static_cast<std::uintptr_t>(base + desc.relative);
+    std::uint64_t count{};
+    std::uint32_t marker{}, type{};
+    if (header < 4 || !read(header, count) || count != desc.count || !read(header - 4, marker)
+        || !read(header + 8, type) || marker >> 16 != 0x8080 || type >> 16 != 0x8080)
+        return false;
+    data = header + 16;
+    return true;
+}
+bool write(const Patch& patch, bool restore) noexcept {
+    const auto expected = restore ? patch.after : patch.before;
+    const auto desired = restore ? patch.before : patch.after;
+    lore::Instruction current{};
+    if (!read(patch.address, current) || current != expected) return false;
+    auto* destination = reinterpret_cast<void*>(patch.address);
+    DWORD previous{};
+    if (!VirtualProtect(destination, sizeof desired, PAGE_READWRITE, &previous)) return false;
+    SIZE_T written{};
+    const bool copied =
+        WriteProcessMemory(GetCurrentProcess(), destination, &desired, sizeof desired, &written)
+        && written == sizeof desired;
+    DWORD ignored{};
+    const bool protectedAgain =
+        VirtualProtect(destination, sizeof desired, previous, &ignored) != FALSE;
+    return copied && protectedAgain && read(patch.address, current) && current == desired;
+}
+bool rollback() noexcept {
+    bool ok = true;
+    for (std::size_t i = g_count; i > 0; --i) {
+        lore::Instruction current{};
+        if (!read(g_patches[i - 1].address, current)) {
+            ok = false;
+            continue;
+        }
+        if (current == g_patches[i - 1].before) continue;
+        if (!write(g_patches[i - 1], true)) ok = false;
+    }
+    if (ok) g_count = 0;
+    return ok;
+}
+bool prepare(std::array<Patch, lore::kTargets.size()>& staged) noexcept {
+    content::handles::Source source{};
+    source.tablesSlot =
+        reinterpret_cast<std::uintptr_t>(targets::game::content::get().contentHandleTablesSlot);
+    source.read = &memory::read_current_process;
+    std::array<std::uintptr_t, 2> rows{};
+    constexpr std::array<std::uint32_t, 2> tags{0x81319339U, 0x8131933FU};
+    constexpr std::array<std::size_t, 2> counts{2242, 924}, strides{216, 168};
+    for (std::size_t i = 0; i < 2; ++i) {
+        std::uintptr_t table{};
+        Descriptor desc{};
+        if (!content::handles::resolve(source, tags[i], table) || !read(table + 8, desc)
+            || desc.count != counts[i] || !data_at(table + 8, desc, rows[i]))
+            return false;
+    }
+    for (std::size_t i = 0; i < staged.size(); ++i) {
+        const auto& target = lore::kTargets[i];
+        const std::size_t kind = target.node ? 1 : 0;
+        const auto row = rows[kind] + target.row * strides[kind];
+        std::uint32_t hash{};
+        Descriptor desc{};
+        std::uintptr_t data{};
+        std::array<lore::Instruction, 59> code{};
+        if (!read(row + 40, hash) || hash != target.hash || !read(row + target.field, desc)
+            || desc.count == 0 || desc.count > code.size()
+            || !data_at(row + target.field, desc, data)
+            || !memory::read_current_process(
+                nullptr,
+                data,
+                std::as_writable_bytes(std::span(code).first(static_cast<std::size_t>(desc.count))))
+            || !lore::replacement(target.shape,
+                                  std::span(code).first(static_cast<std::size_t>(desc.count)),
+                                  staged[i].after))
+            return false;
+        staged[i].address = data;
+        staged[i].before = code[0];
+    }
+    // Each edited instruction must be owned by exactly one presentation condition. Never mutate
+    // a constant shared with another record, even if that record is not in this repair's list.
+    std::array<unsigned, lore::kTargets.size()> references{};
+    for (std::size_t kind = 0; kind < 2; ++kind) {
+        for (std::size_t row = 0; row < counts[kind]; ++row) {
+            for (const std::size_t field : (kind == 0 ? std::array<std::size_t, 2>{120, 136}
+                                                      : std::array<std::size_t, 2>{48, 64})) {
+                const auto at = rows[kind] + row * strides[kind] + field;
+                Descriptor desc{};
+                std::uintptr_t data{};
+                if (!read(at, desc)) return false;
+                if (desc.count == 0) continue;
+                if (desc.count > 128 || !data_at(at, desc, data)) return false;
+                for (std::size_t i = 0; i < staged.size(); ++i)
+                    if (staged[i].address >= data && staged[i].address - data < desc.count * 8)
+                        ++references[i];
+            }
+        }
+    }
+    for (auto count : references)
+        if (count != 1) return false;
+    return true;
+}
+} // namespace
+
+void apply_lore_visibility() noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    if (g_count != 0) {
+        ReleaseSRWLockExclusive(&g_lock);
+        return;
+    }
+    std::array<Patch, lore::kTargets.size()> staged{};
+    bool ok = prepare(staged);
+    if (ok) {
+        for (const auto& patch : staged) {
+            g_patches[g_count++] = patch;
+            if (!write(patch, false)) {
+                ok = false;
+                break;
+            }
+        }
+    }
+    const bool restored = ok || rollback();
+    core::log::write(core::log::Channel::client,
+                     ok ? core::log::Level::info : core::log::Level::warn,
+                     ok ? "ev=lore_visibility result=applied conditions=36 progress_unchanged=1"
+                     : restored ? "ev=lore_visibility result=refused originals_retained=1"
+                                : "ev=lore_visibility result=rollback_failed");
+    ReleaseSRWLockExclusive(&g_lock);
+}
+void restore_lore_visibility() noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    if (!rollback())
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         "ev=lore_visibility result=restore_failed");
+    ReleaseSRWLockExclusive(&g_lock);
+}
+} // namespace sunrise::client::hooks::network::investment

+ 952 - 0
Sunrise/src/client/hooks/network/investment/investment_socket_menu_routing.cpp

@@ -0,0 +1,952 @@
+#include <Windows.h>
+
+#include <algorithm>
+#include <array>
+#include <atomic>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <cstring>
+#include <limits>
+#include <span>
+
+#include "../../../../core/logging/log.h"
+#include "../../../../state/build_data/runtime.h"
+#include "../../../../state/content/content_catalog.h"
+#include "../../../content/handles/handle_resolver.h"
+#include "../../../content/investment/internal.h"
+#include "../../../content/investment/layout.h"
+#include "../../../content/items/layout.h"
+#include "../../../memory/current_process_memory.h"
+#include "../../../targets/game/content.h"
+#include "internal.h"
+#include "socket_row_relocation.h"
+
+namespace sunrise::client::hooks::network::investment {
+namespace {
+
+namespace content_handles = client::content::handles;
+namespace content_investment = client::content::investment;
+namespace item_layout = client::content::items::layout;
+
+/** FNV-1 name hash shared by the installed investment-globals candidates. */
+constexpr std::uint32_t kInvestmentGlobalsNameHash = 0x6F7125CBU;
+constexpr std::size_t kBootstrapCandidateCapacity = 64;
+
+/** Exact installed item identities used to validate a candidate investment root. */
+constexpr std::uint32_t kLegArmorReferenceHash = 3'213'968'579U;
+constexpr std::array<std::uint32_t, 4> kArrivalsLegModHashes{
+    3'465'659'109U, // Flourishing Blade
+    3'465'659'111U, // Automatic Prize
+    3'465'659'104U, // Dimensional Tithes
+    3'465'659'105U, // Ascendant Bounty
+};
+
+/** Installed reusable-set layout and the two rows proved by package extraction. */
+constexpr std::size_t kTableArrayDescriptorOffset = 8;
+constexpr std::size_t kPlugSetRowStride = 24;
+constexpr std::size_t kPlugSetMemberDescriptorOffset = 8;
+constexpr std::size_t kPlugMemberStride = 32;
+constexpr std::size_t kArrayMarkerSize = 4;
+constexpr std::size_t kArrayHeaderSize = 16;
+constexpr std::size_t kGeneralSetIndex = 8;
+constexpr std::size_t kLegSetIndex = 14;
+constexpr std::uint64_t kGeneralMemberCount = 19;
+constexpr std::uint64_t kLegMemberCount = 52;
+constexpr std::size_t kMaximumSetCount = 4096;
+constexpr std::size_t kMaximumMemberCount = 4096;
+constexpr std::uintptr_t kMaximumLowAddress = UINT32_MAX;
+constexpr std::size_t kLowArenaSize = 64U * 1024U;
+constexpr std::size_t kArenaAlignment = 16U;
+constexpr std::size_t kPlugBlockOffset = 0x184;
+constexpr std::size_t kPlugCategoryOffset = 4;
+constexpr std::size_t kPlugBlockSize = 64;
+constexpr unsigned kCategoryAttemptLimit = 120;
+constexpr ULONGLONG kCategoryRetryIntervalMs = 250;
+constexpr ULONGLONG kCategoryRetryWindowMs = 30000;
+
+struct ArrayDescriptor {
+    std::uint64_t count{};
+    std::int64_t relative{};
+
+    friend bool operator==(const ArrayDescriptor&, const ArrayDescriptor&) = default;
+};
+
+struct ArrayView {
+    std::uintptr_t descriptor{};
+    std::uintptr_t header{};
+    std::uintptr_t data{};
+    std::uint64_t count{};
+    std::uint32_t elementClass{};
+};
+
+struct LocatedSets {
+    content_investment::Source source{};
+    ArrayView general{};
+    ArrayView legs{};
+};
+
+struct Allocation {
+    std::byte* base{};
+    std::size_t size{};
+
+    [[nodiscard]] explicit operator bool() const noexcept {
+        return base != nullptr && size != 0;
+    }
+};
+
+struct AppliedSet {
+    std::uintptr_t descriptor{};
+    ArrayDescriptor original{};
+    ArrayDescriptor replacement{};
+};
+
+struct CategoryPatch {
+    std::uintptr_t address{};
+    std::uint32_t original{};
+};
+constexpr std::uint32_t kGeneralCategory = 0x94493B9BU;
+constexpr std::uint32_t kLegCategory = 0x7DDE0206U;
+std::array<CategoryPatch, 4> g_categories{};
+std::size_t g_categoryCount{};
+
+enum class Failure : std::uint8_t {
+    none,
+    buildData,
+    targets,
+    source,
+    ambiguous,
+    allocation,
+    write,
+    verification,
+};
+
+SRWLOCK g_lock{SRWLOCK_INIT};
+std::atomic_bool g_armed{false};
+std::array<Allocation, 2> g_allocations{};
+std::array<AppliedSet, 2> g_applied{};
+std::size_t g_appliedCount{};
+Failure g_lastFailure{Failure::none};
+std::byte* g_lowArena{};
+std::size_t g_lowArenaUsed{};
+std::array<std::array<relocation::Row, relocation::kMaximumMembers>, 2> g_expected{};
+std::array<std::size_t, 2> g_expectedCounts{};
+content_investment::Source g_categorySource{};
+std::array<std::uint16_t, 4> g_categoryRoutes{};
+std::uint16_t g_categoryReference{};
+unsigned g_categoryAttempts{};
+ULONGLONG g_categoryNext{};
+ULONGLONG g_categoryDeadline{};
+const char* g_categoryFailure = "none";
+
+[[nodiscard]] const char* failure_name(Failure failure) noexcept {
+    switch (failure) {
+    case Failure::buildData:
+        return "build_data";
+    case Failure::targets:
+        return "targets";
+    case Failure::source:
+        return "source";
+    case Failure::ambiguous:
+        return "ambiguous";
+    case Failure::allocation:
+        return "allocation";
+    case Failure::write:
+        return "write";
+    case Failure::verification:
+        return "verification";
+    case Failure::none:
+        return "none";
+    }
+    return "unknown";
+}
+
+void report_failure(Failure failure, std::uint64_t detail = 0) noexcept {
+    if (failure == g_lastFailure) {
+        return;
+    }
+    g_lastFailure = failure;
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written =
+        std::snprintf(line.data(),
+                      line.size(),
+                      "ev=investment stage=arrivals_leg_sets result=deferred reason=%s detail=%llu",
+                      failure_name(failure),
+                      static_cast<unsigned long long>(detail));
+    if (written > 0) {
+        core::log::write(
+            core::log::Channel::client,
+            core::log::Level::warn,
+            {line.data(), (std::min)(static_cast<std::size_t>(written), line.size() - 1U)});
+    }
+}
+
+[[nodiscard]] bool read_bytes(std::uintptr_t address, std::span<std::byte> output) noexcept {
+    return !output.empty() && memory::read_current_process(nullptr, address, output);
+}
+
+template <typename Value> [[nodiscard]] bool read(std::uintptr_t address, Value& value) noexcept {
+    return read_bytes(address, std::span(reinterpret_cast<std::byte*>(&value), sizeof value));
+}
+
+[[nodiscard]] bool write_bytes(std::uintptr_t address, std::span<const std::byte> bytes) noexcept {
+    if (address == 0 || bytes.empty()) {
+        return false;
+    }
+    void* destination = reinterpret_cast<void*>(address);
+    DWORD previous = 0;
+    if (VirtualProtect(destination, bytes.size(), PAGE_READWRITE, &previous) == FALSE) {
+        return false;
+    }
+    std::memcpy(destination, bytes.data(), bytes.size());
+    DWORD ignored = 0;
+    const bool restored = VirtualProtect(destination, bytes.size(), previous, &ignored) != FALSE;
+    return restored;
+}
+
+template <typename Value>
+[[nodiscard]] bool write(std::uintptr_t address, const Value& value) noexcept {
+    return write_bytes(address,
+                       std::span(reinterpret_cast<const std::byte*>(&value), sizeof value));
+}
+
+[[nodiscard]] bool
+add_relative(std::uintptr_t base, std::int64_t relative, std::uintptr_t& output) noexcept {
+    if (relative >= 0) {
+        const auto distance = static_cast<std::uint64_t>(relative);
+        if (distance > (std::numeric_limits<std::uintptr_t>::max)() - base) {
+            return false;
+        }
+        output = base + static_cast<std::uintptr_t>(distance);
+        return true;
+    }
+    const auto distance = static_cast<std::uint64_t>(-(relative + 1)) + 1U;
+    if (distance > base) {
+        return false;
+    }
+    output = base - static_cast<std::uintptr_t>(distance);
+    return true;
+}
+
+/** Resolves one native count/self-relative array and validates its repeated header. */
+[[nodiscard]] bool resolve_array(std::uintptr_t descriptor,
+                                 std::size_t maximumCount,
+                                 std::size_t stride,
+                                 ArrayView& output) noexcept {
+    output = {};
+    ArrayDescriptor encoded{};
+    if (!read(descriptor, encoded) || encoded.count == 0 || encoded.count > maximumCount) {
+        return false;
+    }
+    std::uintptr_t header = 0;
+    if (!add_relative(descriptor + sizeof(std::uint64_t), encoded.relative, header)
+        || header < kArrayMarkerSize) {
+        return false;
+    }
+    std::uint32_t marker = 0;
+    std::uint64_t repeatedCount = 0;
+    std::uint32_t elementClass = 0;
+    if (!read(header - kArrayMarkerSize, marker) || !read(header, repeatedCount)
+        || !read(header + sizeof(std::uint64_t), elementClass) || repeatedCount != encoded.count
+        || (marker >> 16U) != 0x8080U || (elementClass >> 16U) != 0x8080U
+        || encoded.count
+               > ((std::numeric_limits<std::uintptr_t>::max)() - header - kArrayHeaderSize)
+                     / stride) {
+        return false;
+    }
+    const std::uintptr_t data = header + kArrayHeaderSize;
+    std::byte tail{};
+    if (!read(data + static_cast<std::uintptr_t>(encoded.count * stride) - 1U, tail)) {
+        return false;
+    }
+    output = {descriptor, header, data, encoded.count, elementClass};
+    return true;
+}
+
+[[nodiscard]] bool
+member_index(const ArrayView& array, std::size_t position, std::uint32_t& index) noexcept {
+    return position < array.count
+           && read(array.data + static_cast<std::uintptr_t>(position) * kPlugMemberStride, index);
+}
+
+[[nodiscard]] std::size_t count_member(const ArrayView& array, std::uint32_t index) noexcept {
+    std::size_t count = 0;
+    for (std::size_t position = 0; position < array.count; ++position) {
+        std::uint32_t current = 0;
+        if (!member_index(array, position, current)) {
+            return kMaximumMemberCount + 1U;
+        }
+        count += current == index ? 1U : 0U;
+    }
+    return count;
+}
+
+/** Validates the exact native pre-patch membership, not just its two row numbers. */
+[[nodiscard]] bool native_membership(const ArrayView& general,
+                                     const ArrayView& legs,
+                                     std::span<const std::uint16_t> routes,
+                                     std::uint16_t reference) noexcept {
+    if (general.count != kGeneralMemberCount || legs.count != kLegMemberCount
+        || general.elementClass != legs.elementClass || count_member(general, reference) != 0
+        || count_member(legs, reference) != 1) {
+        return false;
+    }
+    for (const std::uint16_t route : routes) {
+        if (count_member(general, route) != 1 || count_member(legs, route) != 0) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** Resolves one investment candidate and accepts only the exact installed item/set relation. */
+[[nodiscard]] bool resolve_candidate(const state::content::Definition& candidate,
+                                     std::span<const std::uint16_t> routes,
+                                     std::uint16_t reference,
+                                     LocatedSets& output) noexcept {
+    const auto& targets = targets::game::content::get();
+    content_investment::Source source{};
+    source.investmentGlobalsTag = candidate.tag;
+    source.handles.tablesSlot = reinterpret_cast<std::uintptr_t>(targets.contentHandleTablesSlot);
+    source.handles.read = &memory::read_current_process;
+
+    std::uintptr_t globals = 0;
+    std::uintptr_t root = 0;
+    std::uintptr_t itemTable = 0;
+    std::uintptr_t plugSetTable = 0;
+    std::uint32_t rootTag = 0;
+    std::uint32_t itemTableTag = 0;
+    std::uint32_t plugSetTableTag = 0;
+    std::uint64_t itemCount = 0;
+    if (!content_handles::resolve(source.handles, source.investmentGlobalsTag, globals)
+        || !read(globals + content_investment::layout::kGlobalsRootTagOffset, rootTag)
+        || !content_handles::resolve(source.handles, rootTag, root)
+        || !read(root + content_investment::layout::kItemTableTagOffset, itemTableTag)
+        || !content_handles::resolve(source.handles, itemTableTag, itemTable)
+        || !read(itemTable + item_layout::kTableRowCountOffset, itemCount) || itemCount == 0
+        || itemCount > state::build_data::items::kDefinitionCapacity
+        || !read(root + content_investment::layout::kPlugSetTableTagOffset, plugSetTableTag)
+        || !content_handles::resolve(source.handles, plugSetTableTag, plugSetTable)) {
+        return false;
+    }
+
+    const std::uintptr_t itemRows = itemTable + item_layout::kTableFirstRowOffset;
+    const auto matches_item = [&](std::uint16_t index, std::uint32_t hash) noexcept {
+        if (index >= itemCount) {
+            return false;
+        }
+        item_layout::ItemIndexRow row{};
+        return read(itemRows + static_cast<std::uintptr_t>(index) * sizeof row, row)
+               && row.definitionHash == hash;
+    };
+    if (!matches_item(reference, kLegArmorReferenceHash)) {
+        return false;
+    }
+    for (std::size_t index = 0; index < routes.size(); ++index) {
+        if (!matches_item(routes[index], kArrivalsLegModHashes[index])) {
+            return false;
+        }
+    }
+
+    ArrayView sets{};
+    if (!resolve_array(
+            plugSetTable + kTableArrayDescriptorOffset, kMaximumSetCount, kPlugSetRowStride, sets)
+        || sets.count <= kLegSetIndex) {
+        return false;
+    }
+    const std::uintptr_t generalDescriptor =
+        sets.data + kGeneralSetIndex * kPlugSetRowStride + kPlugSetMemberDescriptorOffset;
+    const std::uintptr_t legDescriptor =
+        sets.data + kLegSetIndex * kPlugSetRowStride + kPlugSetMemberDescriptorOffset;
+    ArrayView general{};
+    ArrayView legs{};
+    if (!resolve_array(generalDescriptor, kMaximumMemberCount, kPlugMemberStride, general)
+        || !resolve_array(legDescriptor, kMaximumMemberCount, kPlugMemberStride, legs)
+        || !native_membership(general, legs, routes, reference)) {
+        return false;
+    }
+    output = {source, general, legs};
+    return true;
+}
+
+/** Finds exactly one native pair among every registered investment-globals candidate. */
+[[nodiscard]] bool locate_sets(std::span<const std::uint16_t> routes,
+                               std::uint16_t reference,
+                               LocatedSets& output,
+                               Failure& failure,
+                               std::uint64_t& detail) noexcept {
+    output = {};
+    failure = Failure::source;
+    detail = 0;
+    if (!targets::game::content::is_resolved()
+        || targets::game::content::get().contentHandleTablesSlot == nullptr) {
+        failure = Failure::targets;
+        return false;
+    }
+    std::array<state::content::Definition, kBootstrapCandidateCapacity> candidates{};
+    std::size_t candidateCount = 0;
+    if (!state::content::lookup_hash(kInvestmentGlobalsNameHash, candidates, candidateCount)
+        && candidateCount == 0) {
+        return false;
+    }
+    std::size_t matches = 0;
+    for (std::size_t index = 0; index < candidateCount; ++index) {
+        LocatedSets candidateSets{};
+        if (!resolve_candidate(candidates[index], routes, reference, candidateSets)) {
+            continue;
+        }
+        if (matches != 0
+            && (candidateSets.general.descriptor != output.general.descriptor
+                || candidateSets.legs.descriptor != output.legs.descriptor)) {
+            failure = Failure::ambiguous;
+            detail = matches + 1U;
+            return false;
+        }
+        output = candidateSets;
+        ++matches;
+    }
+    detail = matches;
+    return matches != 0;
+}
+
+[[nodiscard]] std::uintptr_t align_up(std::uintptr_t value, std::uintptr_t alignment) noexcept {
+    const std::uintptr_t mask = alignment - 1U;
+    if (value > (std::numeric_limits<std::uintptr_t>::max)() - mask) {
+        return 0;
+    }
+    return (value + mask) & ~mask;
+}
+
+/** Reserves one process-lifetime arena while the low content address domain is still available. */
+[[nodiscard]] std::byte* reserve_low_arena() noexcept {
+    SYSTEM_INFO system{};
+    GetSystemInfo(&system);
+    const std::uintptr_t granularity = system.dwAllocationGranularity;
+    std::uintptr_t cursor =
+        align_up(reinterpret_cast<std::uintptr_t>(system.lpMinimumApplicationAddress), granularity);
+    while (cursor != 0 && cursor <= kMaximumLowAddress
+           && kLowArenaSize <= kMaximumLowAddress - cursor + 1U) {
+        MEMORY_BASIC_INFORMATION information{};
+        if (VirtualQuery(reinterpret_cast<const void*>(cursor), &information, sizeof information)
+            == 0) {
+            break;
+        }
+        const std::uintptr_t base = reinterpret_cast<std::uintptr_t>(information.BaseAddress);
+        const std::uintptr_t next =
+            information.RegionSize <= (std::numeric_limits<std::uintptr_t>::max)() - base
+                ? base + information.RegionSize
+                : 0;
+        if (information.State == MEM_FREE) {
+            const std::uintptr_t candidate = align_up((std::max)(cursor, base), granularity);
+            const std::uintptr_t offset = candidate >= base ? candidate - base : 0;
+            if (candidate != 0 && candidate <= kMaximumLowAddress
+                && kLowArenaSize <= kMaximumLowAddress - candidate + 1U && candidate >= base
+                && offset <= information.RegionSize
+                && kLowArenaSize <= information.RegionSize - offset) {
+                void* const allocated = VirtualAlloc(reinterpret_cast<void*>(candidate),
+                                                     kLowArenaSize,
+                                                     MEM_RESERVE | MEM_COMMIT,
+                                                     PAGE_READWRITE);
+                if (allocated != nullptr) {
+                    return static_cast<std::byte*>(allocated);
+                }
+            }
+        }
+        if (next == 0 || next <= cursor) {
+            break;
+        }
+        cursor = align_up(next, granularity);
+    }
+    return nullptr;
+}
+
+/** Carves one immutable array payload from the arena reserved at DLL process attach. */
+[[nodiscard]] Allocation allocate_array(std::size_t size) noexcept {
+    const std::size_t aligned = static_cast<std::size_t>(align_up(g_lowArenaUsed, kArenaAlignment));
+    if (g_lowArena == nullptr || aligned > kLowArenaSize || size > kLowArenaSize - aligned) {
+        return {};
+    }
+    g_lowArenaUsed = aligned + size;
+    return {g_lowArena + aligned, size};
+}
+
+void release(Allocation& allocation) noexcept {
+    // Storage belongs to the process-lifetime low arena, not to an individual array.
+    allocation = {};
+}
+
+[[nodiscard]] bool
+snapshot_row(const ArrayView& source, std::size_t position, relocation::Row& output) noexcept {
+    output = {};
+    const auto address = source.data + position * kPlugMemberStride;
+    if (position >= source.count || address % 8 != 0
+        || source.elementClass != relocation::kMemberClass || !read_bytes(address, output.bytes))
+        return false;
+    const auto count = relocation::get<std::uint64_t>(output.bytes.data() + 8);
+    if (count != 0) {
+        std::uintptr_t header = 0;
+        if (count != 1
+            || !add_relative(
+                address + 16, relocation::get<std::int64_t>(output.bytes.data() + 16), header)
+            || header < 4 || header % 8 != 0 || !read_bytes(header - 4, output.condition))
+            return false;
+    }
+    return relocation::valid(output);
+}
+
+[[nodiscard]] bool verify_owned(std::size_t set, const Allocation& allocation) noexcept {
+    return allocation
+           && relocation::verify(std::span(g_expected[set].data(), g_expectedCounts[set]),
+                                 std::span<const std::byte>(allocation.base, allocation.size));
+}
+
+/** Validates the exact plug blocks observed in this build before changing four category fields. */
+bool stage_categories(const content_investment::Source& source,
+                      std::span<const std::uint16_t> routes,
+                      std::uint16_t reference,
+                      std::array<CategoryPatch, 4>& output) noexcept {
+    std::uintptr_t globals = 0, root = 0, table = 0;
+    g_categoryFailure = "table";
+    std::uint32_t tag = 0;
+    if (!content_handles::resolve(source.handles, source.investmentGlobalsTag, globals)
+        || !read(globals + content_investment::layout::kGlobalsRootTagOffset, tag)
+        || !content_handles::resolve(source.handles, tag, root)
+        || !read(root + content_investment::layout::kItemTableTagOffset, tag)
+        || !content_handles::resolve(source.handles, tag, table))
+        return false;
+    const auto block = [&](std::uint16_t index,
+                           std::uint32_t hash,
+                           std::uintptr_t& address,
+                           std::array<std::byte, kPlugBlockSize>& bytes) noexcept {
+        item_layout::ItemIndexRow row{};
+        g_categoryFailure = "definition";
+        std::uintptr_t definition = 0;
+        if (!read(table + item_layout::kTableFirstRowOffset + index * sizeof row, row)
+            || row.definitionHash != hash
+            || !content_handles::resolve(source.handles, row.targetHandle, definition))
+            return false;
+        address = definition + kPlugBlockOffset + kPlugCategoryOffset;
+        g_categoryFailure = "block";
+        return read_bytes(definition + kPlugBlockOffset, bytes)
+               && relocation::get<std::uint32_t>(bytes.data()) == 0x808077E3U;
+    };
+    std::array<std::byte, kPlugBlockSize> referenceBlock{};
+    std::uintptr_t referenceAddress = 0;
+    if (!block(reference, kLegArmorReferenceHash, referenceAddress, referenceBlock)) return false;
+    g_categoryFailure = "reference_category";
+    if (relocation::get<std::uint32_t>(referenceBlock.data() + 4) != kLegCategory) return false;
+    for (std::size_t i = 0; i < routes.size(); ++i) {
+        std::array<std::byte, kPlugBlockSize> bytes{};
+        if (!block(routes[i], kArrivalsLegModHashes[i], output[i].address, bytes)) return false;
+        output[i].original = relocation::get<std::uint32_t>(bytes.data() + 4);
+        g_categoryFailure = "category_or_metadata";
+        if ((output[i].original != kGeneralCategory && output[i].original != kLegCategory)
+            || std::memcmp(bytes.data() + 8, referenceBlock.data() + 8, 56) != 0)
+            return false;
+    }
+    return true;
+}
+
+bool categories_current() noexcept {
+    if (g_categoryCount != g_categories.size()) return false;
+    for (const auto& category : g_categories) {
+        std::uint32_t value = 0;
+        if (!read(category.address, value) || value != kLegCategory) return false;
+    }
+    return true;
+}
+
+/** Restore only fields still owned by this patch; retain ownership if any restore fails. */
+bool restore_categories() noexcept {
+    bool restored = true;
+    for (std::size_t i = 0; i < g_categoryCount; ++i) {
+        const auto& category = g_categories[i];
+        std::uint32_t current = 0;
+        if (!read(category.address, current)
+            || (current != category.original
+                && (current != kLegCategory || !write(category.address, category.original)))) {
+            restored = false;
+        }
+    }
+    if (restored) g_categoryCount = 0;
+    return restored;
+}
+
+/** Bounded retry for item definitions that become available after set-table initialization. */
+void apply_pending_categories() noexcept {
+    if (g_categoryAttempts == 0) return;
+    const auto now = GetTickCount64();
+    if (now < g_categoryNext) return;
+    g_categoryNext = now + kCategoryRetryIntervalMs;
+    --g_categoryAttempts;
+    std::array<CategoryPatch, 4> categories{};
+    if (!stage_categories(g_categorySource, g_categoryRoutes, g_categoryReference, categories)) {
+        if (now >= g_categoryDeadline) g_categoryAttempts = 0;
+        if (g_categoryAttempts == kCategoryAttemptLimit - 1 || g_categoryAttempts == 0) {
+            std::array<char, 200> line{};
+            std::snprintf(line.data(),
+                          line.size(),
+                          "ev=investment stage=arrivals_leg_categories result=%s reason=%s",
+                          g_categoryAttempts == 0 ? "failed" : "pending",
+                          g_categoryFailure);
+            core::log::write(core::log::Channel::client, core::log::Level::warn, line.data());
+        }
+        return;
+    }
+    g_categoryAttempts = 0;
+    g_categories = categories;
+    g_categoryCount = categories.size();
+    bool written = true;
+    for (const auto& category : categories) {
+        if (!write(category.address, kLegCategory)) {
+            written = false;
+            break;
+        }
+    }
+    if (written && categories_current()) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         "ev=investment stage=arrivals_leg_categories result=applied verified=4");
+        return;
+    }
+    (void)restore_categories();
+    core::log::write(
+        core::log::Channel::client,
+        core::log::Level::warn,
+        "ev=investment stage=arrivals_leg_categories result=failed reason=write_or_verify");
+}
+
+[[nodiscard]] bool encode_descriptor(std::uintptr_t descriptor,
+                                     const Allocation& allocation,
+                                     std::uint64_t count,
+                                     ArrayDescriptor& output) noexcept {
+    const std::uintptr_t header = reinterpret_cast<std::uintptr_t>(allocation.base) + 8;
+    const std::uintptr_t relativeBase = descriptor + sizeof(std::uint64_t);
+    if (header >= relativeBase) {
+        const std::uintptr_t distance = header - relativeBase;
+        if (distance > static_cast<std::uintptr_t>((std::numeric_limits<std::int64_t>::max)())) {
+            return false;
+        }
+        output = {count, static_cast<std::int64_t>(distance)};
+        return true;
+    }
+    const std::uintptr_t distance = relativeBase - header;
+    if (distance > static_cast<std::uintptr_t>((std::numeric_limits<std::int64_t>::max)())) {
+        return false;
+    }
+    output = {count, -static_cast<std::int64_t>(distance)};
+    return true;
+}
+
+/** Builds the general array with the four Arrivals leg mods removed. */
+[[nodiscard]] bool build_general(const ArrayView& source,
+                                 std::span<const std::uint16_t> routes,
+                                 Allocation& allocation,
+                                 ArrayDescriptor& descriptor) noexcept {
+    const std::uint64_t newCount = source.count - routes.size();
+    if (newCount != kGeneralMemberCount - kArrivalsLegModHashes.size()) return false;
+    std::size_t written = 0;
+    for (std::size_t position = 0; position < source.count; ++position) {
+        std::uint32_t index = 0;
+        if (!member_index(source, position, index)) {
+            release(allocation);
+            return false;
+        }
+        if (std::find(routes.begin(), routes.end(), index) != routes.end()) {
+            continue;
+        }
+        if (written >= newCount || !snapshot_row(source, position, g_expected[0][written])) {
+            return false;
+        }
+        ++written;
+    }
+    if (written != newCount) {
+        release(allocation);
+        return false;
+    }
+    g_expectedCounts[0] = written;
+    allocation = allocate_array(relocation::capacity(written));
+    return allocation
+           && relocation::build(std::span(g_expected[0].data(), written),
+                                std::span(allocation.base, allocation.size))
+           && verify_owned(0, allocation)
+           && encode_descriptor(source.descriptor, allocation, newCount, descriptor);
+}
+
+/** Moves each target's OWN original row and condition data into the leg array. */
+[[nodiscard]] bool build_legs(const ArrayView& source,
+                              const ArrayView& general,
+                              std::span<const std::uint16_t> routes,
+                              std::uint16_t reference,
+                              Allocation& allocation,
+                              ArrayDescriptor& descriptor) noexcept {
+    const std::uint64_t newCount = source.count + routes.size();
+    if (newCount != kLegMemberCount + kArrivalsLegModHashes.size()) return false;
+    std::size_t referencePosition = source.count;
+    for (std::size_t position = 0; position < source.count; ++position) {
+        if (!snapshot_row(source, position, g_expected[1][position])) {
+            release(allocation);
+            return false;
+        }
+        std::uint32_t index = 0;
+        if (!member_index(source, position, index)) {
+            release(allocation);
+            return false;
+        }
+        if (index == reference) {
+            referencePosition = position;
+        }
+    }
+    if (referencePosition == source.count) {
+        release(allocation);
+        return false;
+    }
+    for (std::size_t route = 0; route < routes.size(); ++route) {
+        bool found = false;
+        for (std::size_t position = 0; position < general.count; ++position) {
+            std::uint32_t index = 0;
+            if (!member_index(general, position, index)) return false;
+            if (index != routes[route]) continue;
+            if (found || !snapshot_row(general, position, g_expected[1][source.count + route]))
+                return false;
+            found = true;
+        }
+        if (!found) return false;
+    }
+    g_expectedCounts[1] = static_cast<std::size_t>(newCount);
+    // Keep the native Empty Mod Socket first, followed by the four artifact mods.
+    std::rotate(g_expected[1].begin() + 1,
+                g_expected[1].begin() + source.count,
+                g_expected[1].begin() + newCount);
+    allocation = allocate_array(relocation::capacity(g_expectedCounts[1]));
+    return allocation
+           && relocation::build(std::span(g_expected[1].data(), g_expectedCounts[1]),
+                                std::span(allocation.base, allocation.size))
+           && verify_owned(1, allocation)
+           && encode_descriptor(source.descriptor, allocation, newCount, descriptor);
+}
+
+[[nodiscard]] bool patched_membership(const ArrayView& general,
+                                      const ArrayView& legs,
+                                      std::span<const std::uint16_t> routes,
+                                      std::uint16_t reference) noexcept {
+    if (general.count != kGeneralMemberCount - routes.size()
+        || legs.count != kLegMemberCount + routes.size() || count_member(legs, reference) != 1) {
+        return false;
+    }
+    for (const std::uint16_t route : routes) {
+        if (count_member(general, route) != 0 || count_member(legs, route) != 1) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** Fast steady-state check used by the callback fallback after synchronous application. */
+[[nodiscard]] bool descriptors_current() noexcept {
+    if (g_appliedCount != g_applied.size()) {
+        return false;
+    }
+    ArrayDescriptor generalDescriptor{};
+    ArrayDescriptor legDescriptor{};
+    if (!read(g_applied[0].descriptor, generalDescriptor)
+        || !read(g_applied[1].descriptor, legDescriptor)
+        || generalDescriptor != g_applied[0].replacement
+        || legDescriptor != g_applied[1].replacement) {
+        return false;
+    }
+    return true;
+}
+
+/** Full post-write proof, deliberately paid only once rather than on every callback. */
+[[nodiscard]] bool patched_sets_current(std::span<const std::uint16_t> routes,
+                                        std::uint16_t reference) noexcept {
+    if (!descriptors_current()) {
+        return false;
+    }
+    ArrayView general{};
+    ArrayView legs{};
+    return resolve_array(g_applied[0].descriptor, kMaximumMemberCount, kPlugMemberStride, general)
+           && resolve_array(g_applied[1].descriptor, kMaximumMemberCount, kPlugMemberStride, legs)
+           && patched_membership(general, legs, routes, reference)
+           && verify_owned(0, g_allocations[0]) && verify_owned(1, g_allocations[1]);
+}
+
+/** Restores descriptors only when they still name this module's allocations. */
+bool restore_applied() noexcept {
+    g_categoryAttempts = 0;
+    bool restoredAll = restore_categories();
+    for (std::size_t index = g_appliedCount; index > 0; --index) {
+        const AppliedSet& applied = g_applied[index - 1];
+        ArrayDescriptor current{};
+        if (!read(applied.descriptor, current)) {
+            restoredAll = false;
+        } else if (current == applied.original) {
+            continue;
+        } else if (current != applied.replacement || !write(applied.descriptor, applied.original)) {
+            restoredAll = false;
+        }
+    }
+    if (restoredAll) {
+        for (Allocation& allocation : g_allocations) {
+            release(allocation);
+        }
+        g_applied = {};
+        g_appliedCount = 0;
+        g_categoryCount = 0;
+    }
+    return restoredAll;
+}
+
+} // namespace
+
+void reserve_socket_menu_routing_storage() noexcept {
+    if (g_lowArena == nullptr) {
+        g_lowArena = reserve_low_arena();
+    }
+}
+
+/** Arms the one synchronous correction after native content-table patching completes. */
+void arm_socket_menu_routing() noexcept {
+    g_armed.store(true, std::memory_order_release);
+}
+
+/** Moves exactly four members between two validated native reusable plug sets. */
+void apply_socket_menu_routing() noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    if (!g_armed.load(std::memory_order_acquire)) {
+        apply_pending_categories();
+        ReleaseSRWLockExclusive(&g_lock);
+        return;
+    }
+    // One attempt per native patch-completion event. No callback-pump retry loop.
+    g_armed.store(false, std::memory_order_release);
+
+    std::array<std::uint16_t, kArrivalsLegModHashes.size()> routeIndices{};
+    std::uint16_t referenceIndex = UINT16_MAX;
+    state::build_data::items::Definition definition{};
+    bool mapped = state::build_data::find_item_definition_hash(kLegArmorReferenceHash, definition);
+    if (mapped) {
+        referenceIndex = definition.definitionIndex;
+    }
+    for (std::size_t route = 0; mapped && route < kArrivalsLegModHashes.size(); ++route) {
+        mapped =
+            state::build_data::find_item_definition_hash(kArrivalsLegModHashes[route], definition);
+        if (mapped) {
+            routeIndices[route] = definition.definitionIndex;
+        }
+    }
+    if (!mapped) {
+        report_failure(Failure::buildData);
+        ReleaseSRWLockExclusive(&g_lock);
+        return;
+    }
+    if (descriptors_current()) {
+        apply_pending_categories();
+        ReleaseSRWLockExclusive(&g_lock);
+        return;
+    }
+    if (g_appliedCount != 0 || g_categoryCount != 0) {
+        if (!restore_applied()) {
+            report_failure(Failure::write);
+            ReleaseSRWLockExclusive(&g_lock);
+            return;
+        }
+    }
+    // Never reuse published storage: native readers may still retain a pointer after restoration.
+
+    LocatedSets located{};
+    Failure failure = Failure::none;
+    std::uint64_t detail = 0;
+    if (!locate_sets(routeIndices, referenceIndex, located, failure, detail)) {
+        report_failure(failure, detail);
+        ReleaseSRWLockExclusive(&g_lock);
+        return;
+    }
+
+    std::array<Allocation, 2> allocations{};
+    std::array<ArrayDescriptor, 2> replacements{};
+    if (!build_general(located.general, routeIndices, allocations[0], replacements[0])
+        || !build_legs(located.legs,
+                       located.general,
+                       routeIndices,
+                       referenceIndex,
+                       allocations[1],
+                       replacements[1])) {
+        release(allocations[0]);
+        release(allocations[1]);
+        report_failure(Failure::allocation);
+        ReleaseSRWLockExclusive(&g_lock);
+        return;
+    }
+
+    ArrayDescriptor originalGeneral{};
+    ArrayDescriptor originalLegs{};
+    const bool originalsRead = read(located.general.descriptor, originalGeneral)
+                               && read(located.legs.descriptor, originalLegs);
+    if (!originalsRead) {
+        report_failure(Failure::write);
+        ReleaseSRWLockExclusive(&g_lock);
+        return;
+    }
+    // Record ownership BEFORE either write; a protection-restoration failure may follow a copy.
+    g_allocations = allocations;
+    g_applied = {{{located.general.descriptor, originalGeneral, replacements[0]},
+                  {located.legs.descriptor, originalLegs, replacements[1]}}};
+    g_appliedCount = g_applied.size();
+    const bool generalWritten = write(located.general.descriptor, replacements[0]);
+    const bool legsWritten = generalWritten && write(located.legs.descriptor, replacements[1]);
+    if (!legsWritten) {
+        restore_applied();
+        report_failure(Failure::write);
+        ReleaseSRWLockExclusive(&g_lock);
+        return;
+    }
+
+    if (!patched_sets_current(routeIndices, referenceIndex)) {
+        restore_applied();
+        report_failure(Failure::verification);
+        ReleaseSRWLockExclusive(&g_lock);
+        return;
+    }
+
+    g_lastFailure = Failure::none;
+    g_categorySource = located.source;
+    g_categoryRoutes = routeIndices;
+    g_categoryReference = referenceIndex;
+    g_categoryAttempts = kCategoryAttemptLimit;
+    g_categoryNext = 0;
+    g_categoryDeadline = GetTickCount64() + kCategoryRetryWindowMs;
+    apply_pending_categories();
+    g_armed.store(false, std::memory_order_release);
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(
+        line.data(),
+        line.size(),
+        "ev=investment stage=arrivals_leg_sets result=applied source_count=%llu "
+        "leg_count=%llu source=0x%llX legs=0x%llX general_data=0x%llX leg_data=0x%llX "
+        "validation=deep_copy_v3 rows_verified=71 order=empty_then_artifact",
+        static_cast<unsigned long long>(kGeneralMemberCount - routeIndices.size()),
+        static_cast<unsigned long long>(kLegMemberCount + routeIndices.size()),
+        static_cast<unsigned long long>(located.general.descriptor),
+        static_cast<unsigned long long>(located.legs.descriptor),
+        static_cast<unsigned long long>(reinterpret_cast<std::uintptr_t>(g_allocations[0].base)),
+        static_cast<unsigned long long>(reinterpret_cast<std::uintptr_t>(g_allocations[1].base)));
+    if (written > 0) {
+        core::log::write(
+            core::log::Channel::client,
+            core::log::Level::info,
+            {line.data(), (std::min)(static_cast<std::size_t>(written), line.size() - 1U)});
+    }
+    ReleaseSRWLockExclusive(&g_lock);
+}
+
+void restore_socket_menu_routing() noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    restore_applied();
+    g_armed.store(false, std::memory_order_release);
+    g_lastFailure = Failure::none;
+    ReleaseSRWLockExclusive(&g_lock);
+}
+
+} // namespace sunrise::client::hooks::network::investment

+ 87 - 0
Sunrise/src/client/hooks/network/investment/lore_visibility_patch.h

@@ -0,0 +1,87 @@
+#pragma once
+#include <array>
+#include <cstdint>
+#include <span>
+
+namespace sunrise::client::hooks::network::investment::lore {
+struct Instruction {
+    std::uint32_t opcode{}, operand{};
+    friend bool operator==(const Instruction&, const Instruction&) = default;
+};
+enum class Shape { constant, eva, confessions, chronicon };
+struct Target {
+    std::uint16_t row, field;
+    std::uint32_t hash;
+    Shape shape;
+    bool node;
+};
+inline constexpr auto kTargets = [] {
+    std::array<Target, 36> targets{};
+    std::size_t n = 0;
+    targets[n++] = {820, 64, 0x13F7E95CU, Shape::eva, true};
+    targets[n++] = {837, 64, 0x3FCE8988U, Shape::chronicon, true};
+    constexpr std::array<std::uint32_t, 15> wishes{0xFA360CA1U,
+                                                   0xFA360CA2U,
+                                                   0xFA360CA3U,
+                                                   0xFA360CA4U,
+                                                   0xFA360CA5U,
+                                                   0xFA360CA6U,
+                                                   0xFA360CA7U,
+                                                   0xFA360CA8U,
+                                                   0xFA360CA9U,
+                                                   0xFB360E13U,
+                                                   0xFB360E12U,
+                                                   0xFB360E11U,
+                                                   0xFB360E10U,
+                                                   0xFB360E17U,
+                                                   0xFB360E16U};
+    for (std::size_t i = 0; i < wishes.size(); ++i)
+        targets[n++] = {
+            static_cast<std::uint16_t>(825 + i), 120, wishes[i], Shape::constant, false};
+    targets[n++] = {1707, 136, 0xB337A52FU, Shape::confessions, false};
+    constexpr std::array<std::uint32_t, 9> chapters{0xB780F393U,
+                                                    0xB780F390U,
+                                                    0xB780F391U,
+                                                    0xB780F396U,
+                                                    0xB780F397U,
+                                                    0xB780F394U,
+                                                    0xB780F395U,
+                                                    0xB780F39AU,
+                                                    0xB780F39BU};
+    for (std::size_t i = 0; i < chapters.size(); ++i) {
+        targets[n++] = {
+            static_cast<std::uint16_t>(1708 + i), 120, chapters[i], Shape::confessions, false};
+        targets[n++] = {
+            static_cast<std::uint16_t>(1708 + i), 136, chapters[i], Shape::constant, false};
+    }
+    return targets;
+}();
+
+/** Validate the entire shipped expression before replacing just its first instruction. */
+[[nodiscard]] inline bool
+replacement(Shape shape, std::span<const Instruction> code, Instruction& output) noexcept {
+    if (shape == Shape::constant) {
+        if (code.size() != 1 || code[0] != Instruction{11, 1}) return false;
+        output = {11, 0}; // false
+        return true;
+    }
+    if (shape == Shape::eva || shape == Shape::confessions) {
+        const Instruction read =
+            shape == Shape::eva ? Instruction{10, 10343} : Instruction{1, 8702};
+        if (code.size() != 2 || code[0] != read || code[1] != Instruction{2, 0}) return false;
+        output = {11, 1}; // NOT true = false
+        return true;
+    }
+    if (code.size() != 59) return false;
+    for (std::size_t i = 0; i < 15; ++i) {
+        if (code[i * 3] != Instruction{10, static_cast<std::uint32_t>(10615 + i)}
+            || code[i * 3 + 1] != Instruction{11, 0}
+            || code[i * 3 + 2] != Instruction{8, UINT32_MAX})
+            return false;
+    }
+    for (std::size_t i = 45; i < 59; ++i)
+        if (code[i] != Instruction{4, UINT32_MAX}) return false;
+    output = {11, 1}; // First equality is 1 == 0, making the entire conjunction false.
+    return true;
+}
+} // namespace sunrise::client::hooks::network::investment::lore

+ 122 - 0
Sunrise/src/client/hooks/network/investment/socket_row_relocation.h

@@ -0,0 +1,122 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <cstring>
+#include <span>
+
+namespace sunrise::client::hooks::network::investment::relocation {
+
+inline constexpr std::uint32_t kMarker = 0x80809FBDU;
+inline constexpr std::uint32_t kMemberClass = 0x80802E03U;
+inline constexpr std::uint32_t kConditionClass = 0x80807D31U;
+inline constexpr std::size_t kDataOffset = 24;
+inline constexpr std::size_t kMaximumMembers = 56;
+inline constexpr std::size_t kMemberSize = 32;
+inline constexpr std::size_t kConditionSize = 32;
+
+/** A captured member and its own opaque, single-record condition allocation. */
+struct Row {
+    std::array<std::byte, kMemberSize> bytes{};
+    std::array<std::byte, kConditionSize> condition{};
+};
+
+template <typename T> T get(const std::byte* p) noexcept {
+    T value{};
+    std::memcpy(&value, p, sizeof value);
+    return value;
+}
+
+template <typename T> void put(std::byte* p, T value) noexcept {
+    std::memcpy(p, &value, sizeof value);
+}
+
+inline bool valid(const Row& row) noexcept {
+    const auto count = get<std::uint64_t>(row.bytes.data() + 8);
+    if (count == 0) {
+        return get<std::int64_t>(row.bytes.data() + 16) == 0;
+    }
+    return count == 1 && get<std::uint32_t>(row.condition.data()) == kMarker
+           && get<std::uint64_t>(row.condition.data() + 4) == 1
+           && get<std::uint32_t>(row.condition.data() + 12) == kConditionClass
+           && get<std::uint32_t>(row.condition.data() + 16) == 0;
+}
+
+inline std::size_t capacity(std::size_t count) noexcept {
+    return kDataOffset + count * (kMemberSize + kConditionSize) + 8;
+}
+
+/** Builds aligned, self-contained arrays; the original pointer bits are never reused. */
+inline bool build(std::span<const Row> rows, std::span<std::byte> output) noexcept {
+    if (rows.empty() || rows.size() > kMaximumMembers || output.size() < capacity(rows.size())
+        || reinterpret_cast<std::uintptr_t>(output.data()) % 8 != 0) {
+        return false;
+    }
+    for (std::size_t i = 0; i < rows.size(); ++i) {
+        if (!valid(rows[i])) {
+            return false;
+        }
+        for (std::size_t j = 0; j < i; ++j) {
+            if (get<std::uint32_t>(rows[i].bytes.data())
+                == get<std::uint32_t>(rows[j].bytes.data())) {
+                return false;
+            }
+        }
+    }
+    std::memset(output.data(), 0, output.size());
+    put(output.data() + 4, kMarker);
+    put(output.data() + 8, static_cast<std::uint64_t>(rows.size()));
+    put(output.data() + 16, kMemberClass);
+    std::size_t cursor = kDataOffset + rows.size() * 32 + 4;
+    for (std::size_t i = 0; i < rows.size(); ++i) {
+        const std::size_t at = kDataOffset + i * 32;
+        std::memcpy(output.data() + at, rows[i].bytes.data(), 32);
+        if (get<std::uint64_t>(rows[i].bytes.data() + 8) != 0) {
+            std::memcpy(output.data() + cursor, rows[i].condition.data(), 32);
+            put(output.data() + at + 16,
+                static_cast<std::int64_t>(cursor + 4) - static_cast<std::int64_t>(at + 16));
+            cursor += 32;
+        }
+    }
+    return true;
+}
+
+/** Independently follows every relocated reference and compares all original payload bytes. */
+inline bool verify(std::span<const Row> rows, std::span<const std::byte> blob) noexcept {
+    if (rows.empty() || rows.size() > kMaximumMembers
+        || blob.size() < kDataOffset + rows.size() * 32
+        || reinterpret_cast<std::uintptr_t>(blob.data()) % 8 != 0
+        || get<std::uint32_t>(blob.data() + 4) != kMarker
+        || get<std::uint64_t>(blob.data() + 8) != rows.size()
+        || get<std::uint32_t>(blob.data() + 16) != kMemberClass
+        || get<std::uint32_t>(blob.data() + 20) != 0) {
+        return false;
+    }
+    for (std::size_t i = 0; i < rows.size(); ++i) {
+        const std::size_t at = kDataOffset + i * 32;
+        const auto* member = blob.data() + at;
+        if (!valid(rows[i]) || std::memcmp(member, rows[i].bytes.data(), 16) != 0
+            || std::memcmp(member + 24, rows[i].bytes.data() + 24, 8) != 0) {
+            return false;
+        }
+        const auto relative = get<std::int64_t>(member + 16);
+        if (get<std::uint64_t>(member + 8) == 0) {
+            if (relative != 0) return false;
+            continue;
+        }
+        // Owned condition storage is after the member array, so all relocated offsets are positive.
+        if (relative <= 0 || static_cast<std::uint64_t>(relative) > blob.size() - at - 16) {
+            return false;
+        }
+        const std::size_t header = at + 16 + static_cast<std::size_t>(relative);
+        if (header % 8 != 0 || header < kDataOffset + rows.size() * 32 + 4
+            || blob.size() - header < 28
+            || std::memcmp(blob.data() + header - 4, rows[i].condition.data(), 32) != 0) {
+            return false;
+        }
+    }
+    return true;
+}
+
+} // namespace sunrise::client::hooks::network::investment::relocation

+ 15 - 2
Sunrise/src/client/hooks/retail_log/retail_log_enqueue_observer.cpp

@@ -9,6 +9,8 @@
 #include <string_view>
 
 #include "../../../core/logging/log.h"
+#include "../bootflow/bootflow_hook_lifecycle.h"
+#include "../network/investment/internal.h"
 #include "../../targets/game.h"
 
 namespace sunrise::client::hooks::retail_log {
@@ -30,6 +32,9 @@ constexpr std::uint64_t kReassertIntervalMs = 2'000;
 constexpr std::uint32_t kCategoryCount = 26;
 /** 0 is the game's loosest category threshold. A higher value logs less. */
 constexpr std::uint32_t kMostVerbose = 0;
+/** Native boundary after plug tables finish patching and before their derived views resume. */
+constexpr std::string_view kContentTablePatchingComplete =
+    "content_table_patching: patch contents have been cleared";
 
 thread_local bool g_inObserver{};
 /** Tick at which the next re-assert is due. Zero makes the first call assert. */
@@ -64,11 +69,19 @@ volatile LONG64 g_nextAssertTick{};
  * @param text Borrowed native buffer.
  */
 void capture_line(std::int32_t siteId, const char* text) noexcept {
+    std::array<char, kNativeTextSize> sanitized{};
+    const std::size_t textLength = sanitize(text, sanitized);
+    const std::string_view message{sanitized.data(), textLength};
+    if (message.find(kContentTablePatchingComplete) != std::string_view::npos) {
+        network::investment::arm_socket_menu_routing();
+        // This must happen before the native logger returns to investment initialization. A later
+        // callback tick races the socket-menu caches that consume these descriptors.
+        network::investment::apply_socket_menu_routing();
+        network::investment::apply_lore_visibility();
+    }
     if (!core::log::accepts(core::log::Channel::client, core::log::Level::info)) {
         return;
     }
-    std::array<char, kNativeTextSize> sanitized{};
-    const std::size_t textLength = sanitize(text, sanitized);
     std::array<char, kEventCapacity> line{};
     const int written = std::snprintf(line.data(),
                                       line.size(),

+ 3 - 0
Sunrise/src/client/hooks/teleport/runtime.h

@@ -93,6 +93,9 @@ void apply_pending(void* component) noexcept;
  */
 [[nodiscard]] bool owns_local_player(void* component) noexcept;
 
+/** @return True when the game currently publishes a controlled local-player object handle. */
+[[nodiscard]] bool controlled_player_present() noexcept;
+
 /**
  * Reads the world position of the body a physics component drives.
  * @param component Physics component.

+ 10 - 0
Sunrise/src/client/hooks/teleport/teleport_move.cpp

@@ -468,6 +468,16 @@ bool owns_local_player(void* component) noexcept {
            && owns_player(static_cast<std::byte*>(component));
 }
 
+/** Reports whether the native controlled-object accessor has published a local player. */
+bool controlled_player_present() noexcept {
+    if (g_controlledHandle == nullptr) {
+        return false;
+    }
+    std::uint32_t controlled = kInvalidHandle;
+    g_controlledHandle(&controlled);
+    return controlled != kInvalidHandle;
+}
+
 /** Reads the world position of the body a physics component drives. */
 bool read_position(void* component, Vector& position) noexcept {
     if (component == nullptr) {

+ 2 - 0
Sunrise/src/client/targets/game/content.h

@@ -11,6 +11,8 @@ namespace sunrise::client::targets::game::content {
 /** Unowned main-image entry points required only by runtime content extraction. */
 struct Targets {
     std::byte* queuezObjectStoreGetter{};
+    /** Native schema-object resolver retained for checked live-layout diagnostics. */
+    std::byte* queuezObjectResolver{};
     std::byte* contentHandleTablesSlot{};
     std::byte* getItemStatValue{};
     std::byte* lightValueToScalar{};

+ 1 - 0
Sunrise/src/client/targets/game/game_content_targets.cpp

@@ -62,6 +62,7 @@ bool derive(std::span<const patterns::ImageRange> image,
         || !relative::contains(image, resolved.queuezObjectStoreGetter)) {
         return false;
     }
+    resolved.queuezObjectResolver = objectResolver;
     std::memcpy(&resolved.queuezDescriptorFamilyBias,
                 objectResolver + kDescriptorFamilyBiasOffset,
                 sizeof resolved.queuezDescriptorFamilyBias);

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

@@ -96,6 +96,8 @@ private:
     [[nodiscard]] bool profile_items(state::AccountState& output) noexcept;
     /** Parses the definition-driven ordinary-gear dismantle payout. */
     [[nodiscard]] bool dismantle_rewards(state::AccountState& output) noexcept;
+    /** Parses Triumph reward overrides keyed by record index. */
+    [[nodiscard]] bool record_rewards(state::AccountState& output) noexcept;
     /** Parses one character identity. The object must contain one nonzero SOID. */
     [[nodiscard]] bool character(state::CharacterState& output) noexcept;
 

+ 76 - 4
Sunrise/src/core/settings/state/account_rows_parser.cpp

@@ -14,10 +14,6 @@ constexpr std::uint64_t kMaximumDestinationHash = (std::numeric_limits<std::uint
 constexpr std::uint64_t kMaximumTravellingActivityIndex =
     (std::numeric_limits<std::uint16_t>::max)();
 
-} // namespace
-
-namespace {
-
 /** Sets the tier bit one rarity name stands for. */
 [[nodiscard]] bool dismantle_tier_bit(std::string_view name, std::uint8_t& mask) noexcept {
     using Tier = state::build_data::items::Tier;
@@ -149,6 +145,82 @@ bool Parser::dismantle_rewards(state::AccountState& output) noexcept {
     }
 }
 
+/** Parses one optional item grant per record index. */
+bool Parser::record_rewards(state::AccountState& output) noexcept {
+    output.recordRewards = {};
+    output.recordRewardCount = 0;
+    if (!consume('[')) {
+        return false;
+    }
+    if (consume(']')) {
+        return true;
+    }
+    for (;;) {
+        if (output.recordRewardCount >= output.recordRewards.size() || !consume('{')) {
+            return false;
+        }
+        state::RecordRewardPolicy reward{};
+        bool hasRecordIndex = false;
+        bool hasItemIndex = false;
+        bool hasQuantity = false;
+        for (;;) {
+            std::string_view key;
+            if (!string(key) || !consume(':')) {
+                return false;
+            }
+            std::uint64_t value = 0;
+            if (key == "record_index") {
+                if (hasRecordIndex || !unsigned_integer(value)
+                    || value > (std::numeric_limits<std::uint16_t>::max)()) {
+                    return false;
+                }
+                reward.recordIndex = static_cast<std::uint16_t>(value);
+                hasRecordIndex = true;
+            } else if (key == "item_index") {
+                if (hasItemIndex || !unsigned_integer(value)
+                    || value > (std::numeric_limits<std::uint16_t>::max)()) {
+                    return false;
+                }
+                reward.itemIndex = static_cast<std::uint16_t>(value);
+                hasItemIndex = 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;
+            }
+        }
+        if (!hasRecordIndex || !hasItemIndex) {
+            return false;
+        }
+        if (!hasQuantity) {
+            reward.quantity = 1;
+        }
+        for (std::size_t index = 0; index < output.recordRewardCount; ++index) {
+            if (state::same_record_reward_key(output.recordRewards[index], reward)) {
+                return false;
+            }
+        }
+        output.recordRewards[output.recordRewardCount++] = 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;

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

@@ -174,6 +174,9 @@ bool Parser::unlocks(state::unlocks::Table& output) noexcept {
             parsed = progression_values(output.accountProgressions);
         } else if (key == "character_progressions") {
             parsed = progression_values(output.characterProgressions);
+        } else if (key == "reveal_all_lore_books") {
+            bool ignored = false;
+            parsed = boolean(ignored);
         } else {
             parsed = skip_value(0);
         }
@@ -264,6 +267,7 @@ bool Parser::account(state::AccountState& output) noexcept {
     bool hasProfileSetupCompleted = false;
     bool hasSettings = false;
     bool hasDismantleRewards = false;
+    bool hasRecordRewards = false;
     if (consume('}')) {
         return false;
     }
@@ -296,6 +300,11 @@ bool Parser::account(state::AccountState& output) noexcept {
                 return false;
             }
             hasDismantleRewards = true;
+        } else if (key == "record_rewards") {
+            if (hasRecordRewards || !record_rewards(output)) {
+                return false;
+            }
+            hasRecordRewards = true;
         } else if (!skip_value(0)) {
             return false;
         }

+ 54 - 1
Sunrise/src/core/ui/hud/overlays/ui_hud_status_overlay.cpp

@@ -17,7 +17,10 @@
 #include "../../../../middleware/content/packages/tables/region_reader.h"
 #include "../../../../middleware/content/packages/tables/spawn_reader.h"
 #include "../../../../server/bap/runtime.h"
+#include "../../../../state/activity/membership/activity_membership_query.h"
+#include "../../../../state/activity/runtime.h"
 #include "../../../../state/build_data/runtime.h"
+#include "../../../logging/log.h"
 #include "../overlay.h"
 
 namespace sunrise::core::ui::hud::overlays::status {
@@ -180,8 +183,58 @@ void build_spawn(std::string_view stem, Value& output) noexcept {
     const client::hooks::bootflow::CurrentSliceSet localSliceSet =
         client::hooks::bootflow::current_slice_set();
     server::bap::CurrentActivityLinkView link{};
-    const bool hasLink = server::bap::current_activity_link_view(
+    bool hasLink = server::bap::current_activity_link_view(
         localSliceSet.present ? localSliceSet.index : -1, link);
+    // Public ActivityClients are replaced during every citizen-host handoff. The destination and
+    // region are also committed in Activity State, so use the newest session that has actually
+    // reported a region when no connection-scoped link survives the handoff.
+    if (!hasLink) {
+        const std::uint64_t sessionId = activity::membership::live_region_session(
+            activity::kAbsentSessionId);
+        activity::SessionBinding binding{};
+        if (sessionId != activity::kAbsentSessionId
+            && activity::snapshot_binding(sessionId, binding)) {
+            link.binding = binding;
+            link.effectiveRegion = activity::membership::player_region(sessionId);
+            hasLink = true;
+        }
+    }
+    // Keep one transition-level diagnostic. These values otherwise exist only inside the HUD, so
+    // an `unknown` report cannot distinguish a missing local slice from invalid activity links.
+    static std::int32_t lastSlice = -3;
+    static std::size_t lastActive = static_cast<std::size_t>(-1);
+    static std::size_t lastMatching = static_cast<std::size_t>(-1);
+    static bool lastHasLink = false;
+    const std::int32_t diagnosticSlice = localSliceSet.present
+                                             ? localSliceSet.index
+                                             : localSliceSet.available ? -1 : -2;
+    if (diagnosticSlice != lastSlice || link.activeLinks != lastActive
+        || link.matchingRegions != lastMatching || hasLink != lastHasLink) {
+        std::array<char, core::log::kLineCapacity> line{};
+        const int written = std::snprintf(line.data(),
+                                          line.size(),
+                                          "ev=hud stage=activity_link result=%s slice=%d active=%zu "
+                                          "matching=%zu selected=%d public=%u package=%.*s",
+                                          hasLink ? "ok" : "missing",
+                                          diagnosticSlice,
+                                          link.activeLinks,
+                                          link.matchingRegions,
+                                          link.effectiveRegion,
+                                          link.publicTarget ? 1U : 0U,
+                                          static_cast<int>(link.binding.destination.packageNameLength),
+                                          reinterpret_cast<const char*>(
+                                              link.binding.destination.packageName.data()));
+        if (written > 0) {
+            core::log::write(core::log::Channel::client,
+                             core::log::Level::info,
+                             {line.data(),
+                              (std::min)(static_cast<std::size_t>(written), line.size() - 1U)});
+        }
+        lastSlice = diagnosticSlice;
+        lastActive = link.activeLinks;
+        lastMatching = link.matchingRegions;
+        lastHasLink = hasLink;
+    }
     // The client's own step, published every frame. The world phase only moves on the spawn gate,
     // which stops being polled once the player is in, so it stays `arrived` in orbit.
     status.inWorld = client::hooks::bootflow::in_world();

+ 3 - 0
Sunrise/src/dllmain.cpp

@@ -3,6 +3,7 @@
 #include <intrin.h>
 
 #include "client/hooks/egress/runtime.h"
+#include "client/hooks/network/investment/internal.h"
 #include "core/runtime/core_runtime.h"
 #include "steam/runtime/internal.h"
 #include "steam/runtime/runtime.h"
@@ -139,6 +140,8 @@ BOOL WINAPI DllMain(HINSTANCE instance, DWORD reason, LPVOID) {
     if (reason == DLL_PROCESS_ATTACH) {
         g_module = instance;
         DisableThreadLibraryCalls(instance);
+        // Preserve the tested low-address placement; reserve before boot content fills that space.
+        sunrise::client::hooks::network::investment::reserve_socket_menu_routing_storage();
     } else if (reason == DLL_PROCESS_DETACH) {
         g_module = nullptr;
     }

+ 11 - 29
Sunrise/src/middleware/bap/activity_message/activity_incident_parser.cpp

@@ -1,13 +1,6 @@
-/**
- * Incident targets index a 7,763-record table that the Client reads without a bound check, so a
- * bad index is a crash and not a decode error. Rows 795, 4690 and 5375 hold type code -1 and are
- * the same risk. This validator rejects both before anything acts on the body.
- * The compressed target selector carries its own 9-bit byte length, so the fields behind it are
- * located and the whole body is framed.
- */
+/** Validates incident framing and rejects targets unsafe for the Client's unbounded table read. */
 
 #include <algorithm>
-#include <climits>
 
 #include "../../encoding/bit_reader.h"
 #include "../../encoding/byte_order.h"
@@ -78,7 +71,7 @@ Verdict validate(std::span<const std::byte> payload, Incident& parsed) noexcept
         if (!reader.read(kTargetWidth, field)) {
             return Verdict::truncated;
         }
-        parsed.extraTargets[index] = static_cast<std::uint32_t>(field);
+        parsed.extraTargets[index] = static_cast<std::uint16_t>(field);
         if (!target_allowed(parsed.extraTargets[index], verdict)) {
             return verdict;
         }
@@ -87,8 +80,7 @@ Verdict validate(std::span<const std::byte> payload, Incident& parsed) noexcept
     if (!reader.read(kSelectorPresenceWidth, field)) {
         return Verdict::truncated;
     }
-    parsed.hasCompressedSelector = field != 0;
-    if (parsed.hasCompressedSelector) {
+    if (field != 0) {
         if (!reader.read(kSelectorLengthWidth, field)) {
             return Verdict::truncated;
         }
@@ -96,11 +88,9 @@ Verdict validate(std::span<const std::byte> payload, Incident& parsed) noexcept
         if (parsed.selectorLength > kSelectorMaximum) {
             return Verdict::selectorTooLong;
         }
-        for (std::uint32_t index = 0; index < parsed.selectorLength; ++index) {
-            if (!reader.read(CHAR_BIT, field)) {
-                return Verdict::truncated;
-            }
-            parsed.selector[index] = static_cast<std::byte>(field);
+        if (!reader.skip(static_cast<std::size_t>(parsed.selectorLength)
+                         * encoding::kBitsPerByte)) {
+            return Verdict::truncated;
         }
     }
 
@@ -108,13 +98,9 @@ Verdict validate(std::span<const std::byte> payload, Incident& parsed) noexcept
         return Verdict::truncated;
     }
     parsed.hasOptionalBlock = field != 0;
-    if (parsed.hasOptionalBlock) {
-        std::uint64_t wordB = 0;
-        if (!reader.read(kOptionalWordWidth, field) || !reader.read(kOptionalWordWidth, wordB)) {
-            return Verdict::truncated;
-        }
-        parsed.optionalWordA = static_cast<std::uint32_t>(field);
-        parsed.optionalWordB = static_cast<std::uint32_t>(wordB);
+    if (parsed.hasOptionalBlock
+        && !reader.skip(static_cast<std::size_t>(kOptionalWordWidth) * 2U)) {
+        return Verdict::truncated;
     }
 
     if (!reader.read(kPayloadLengthWidth, field)) {
@@ -124,13 +110,9 @@ Verdict validate(std::span<const std::byte> payload, Incident& parsed) noexcept
     if (parsed.payloadLength > kPayloadMaximum) {
         return Verdict::payloadTooLong;
     }
-    for (std::uint32_t index = 0; index < parsed.payloadLength; ++index) {
-        if (!reader.read(CHAR_BIT, field)) {
-            return Verdict::truncated;
-        }
-        parsed.payload[index] = static_cast<std::byte>(field);
+    if (!reader.skip(static_cast<std::size_t>(parsed.payloadLength) * encoding::kBitsPerByte)) {
+        return Verdict::truncated;
     }
-    parsed.hasPayload = true;
     parsed.consumedBits = static_cast<std::uint32_t>(payload.size() * encoding::kBitsPerByte
                                                      - reader.remaining_bits());
     return Verdict::accepted;

+ 2 - 13
Sunrise/src/middleware/bap/activity_message/activity_membership_member_writer.cpp

@@ -1,6 +1,5 @@
 #include <algorithm>
 #include <bit>
-#include <limits>
 
 #include "../../encoding/bit_raw.h"
 #include "replicate_membership.h"
@@ -77,12 +76,7 @@ template <std::size_t Size>
     return true;
 }
 
-/**
- * Writes one 8-element key, low byte first.
- * @param writer Fixed-buffer MSB-first writer.
- * @param key Host-order key to split into byte elements.
- * @return True when all 8 elements fit.
- */
+/** Writes one 8-byte key, low byte first. */
 [[nodiscard]] bool write_member_key(encoding::bits::Writer& writer, std::uint64_t key) noexcept {
     for (std::size_t index = 0; index < kMemberKeyByteCount; ++index) {
         if (!writer.write((key >> (index * 8U)) & 0xFFU, 8)) {
@@ -92,12 +86,7 @@ template <std::size_t Size>
     return true;
 }
 
-/**
- * Writes the nested 18-byte player blob with matching identity values.
- * @param writer Fixed-buffer writer sitting after the 14-bit byte count.
- * @param identity Identity values repeated inside the nested player record.
- * @return True when all 144 blob bits fit.
- */
+/** Writes the nested 18-byte player blob. */
 [[nodiscard]] bool write_player_blob(encoding::bits::Writer& writer,
                                      const client_identity::ClientIdentity& identity) noexcept {
     return writer.write(1, 3) && writer.write(0, 1) && writer.write(0, 10) && writer.write(1, 1)

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

@@ -12,32 +12,21 @@ namespace sunrise::middleware::bap::activity_message::incident {
 /** Activity message type 19 carries one incident. Both sides can send it. */
 inline constexpr std::uint32_t kMessageType = 19;
 
-/** Target indices are 13 bits and resolve through the 7,763-record global table. */
 inline constexpr std::uint8_t kTargetWidth = 13;
 /** The highest valid target index. Above it the Client indexes handler tables unbounded. */
 inline constexpr std::uint32_t kTargetMaximum = 7'762;
 /** These three rows carry type code -1 and are a crash risk, so they never pass. */
 inline constexpr std::array<std::uint32_t, 3> kPoisonTargets{795, 4'690, 5'375};
 
-/** The extra-target count is 5 bits, so the wire can ask for more than the limit allows. */
 inline constexpr std::uint8_t kExtraCountWidth = 5;
-/** At most 25 extra targets follow the primary one. */
 inline constexpr std::uint32_t kExtraTargetMaximum = 25;
-/** One bit says whether a compressed target selector follows. */
 inline constexpr std::uint8_t kSelectorPresenceWidth = 1;
-/** The selector byte length is 9 bits, so the wire can ask for more than the limit allows. */
 inline constexpr std::uint8_t kSelectorLengthWidth = 9;
-/** At most 260 selector bytes follow. Their meaning stays opaque. */
 inline constexpr std::uint32_t kSelectorMaximum = 260;
-/** One bit says whether optional field K follows. */
 inline constexpr std::uint8_t kOptionalPresenceWidth = 1;
-/** Optional field K is two 32-bit words. */
 inline constexpr std::uint8_t kOptionalFieldWidth = 64;
-/** Each optional word is a full 32-bit field. */
 inline constexpr std::uint8_t kOptionalWordWidth = 32;
-/** The payload byte length is 9 bits, so the wire can ask for more than the limit allows. */
 inline constexpr std::uint8_t kPayloadLengthWidth = 9;
-/** At most 500 payload bytes follow. */
 inline constexpr std::uint32_t kPayloadMaximum = 500;
 /** The smallest body is the five fixed fields with every count zero. */
 inline constexpr std::size_t kMinimumBodyBits = kTargetWidth + kExtraCountWidth
@@ -54,17 +43,11 @@ inline constexpr std::size_t kMaximumBodyBytes = (kMaximumBodyBits + 7) / 8;
 /** Why one incident did not pass validation. */
 enum class Verdict : std::uint8_t {
     accepted,
-    /** The body is shorter than the fields it declares. */
     truncated,
-    /** A target index is above 7,762. */
     targetOutOfRange,
-    /** A target index is one of the three type-code -1 rows. */
     targetPoisoned,
-    /** More than 25 extra targets were declared. */
     tooManyTargets,
-    /** More than 500 payload bytes were declared. */
     payloadTooLong,
-    /** More than 260 selector bytes were declared. */
     selectorTooLong,
 };
 
@@ -77,16 +60,13 @@ struct Incident {
     std::uint32_t extraTargetCount{};
     std::uint32_t selectorLength{};
     std::uint32_t payloadLength{};
-    /** Both optional words, read only when the optional block is present. */
     std::uint32_t optionalWordA{};
     std::uint32_t optionalWordB{};
     /** Bits the body used. Below the payload's own bit count means trailing padding. */
     std::uint32_t consumedBits{};
-    /** Set when a compressed selector is present. Its bytes stay opaque. */
     bool hasCompressedSelector{};
     /** Set when the two optional words are present. */
     bool hasOptionalBlock{};
-    /** Set when the payload length and its bytes were reached and checked. */
     bool hasPayload{};
 };
 

+ 90 - 1
Sunrise/src/middleware/content/packages/tables/definition_index_table.h

@@ -31,6 +31,95 @@ 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. */
+/** Investment root slot of the unlock flag mapping tables. */
+/** The investment root holds the progression definition table at this slot. */
+/** Investment root slot of the records and lore table. */
+inline constexpr std::size_t kRecordTableSlot = 72;
+/** One record row, wider than any field this pass reads. */
+inline constexpr std::size_t kRecordRowStride = 216;
+/** Unlock slot of the record's completion flag, or a non-positive value when it has none. */
+inline constexpr std::size_t kRecordCompletionFlagOffset = 100;
+/**
+ * Authored DestinyRecordDefinition hash inside one record row.
+ * Measured, not divided out: offset +0 holds degenerate values, and +0x28 was confirmed by joining
+ * 48 rows to Bungie's manifest by name.
+ */
+inline constexpr std::size_t kRecordHashOffset = 0x28;
+/**
+ * Lore row a record names, or 0xFFFF when it names none.
+ *
+ * A collectible row carries the same field at the same offset, which is what joins the two: a
+ * collectible and the record it unlocks name one lore row. A record with no lore is a book's parent
+ * triumph rather than one of its chapters.
+ */
+inline constexpr std::size_t kLoreRowOffset = 0x2C;
+/** Investment root slot of the lore table: 1425 rows of sixteen bytes. */
+inline constexpr std::size_t kLoreTableSlot = 52;
+/** One lore row, and the definition hash inside it. */
+inline constexpr std::size_t kLoreRowStride = 16;
+inline constexpr std::size_t kLoreHashOffset = 8;
+/** Points the record is worth. Zero for lore and for the interval records that score per step. */
+inline constexpr std::size_t kRecordScoreOffset = 92;
+/** A record names its category's value slot here. The record's own bar reads the next slot up. */
+inline constexpr std::size_t kRecordCategoryExpressionField = 120;
+/** Nonzero when the record's completion grants a character-equippable title. */
+inline constexpr std::size_t kRecordHasTitleOffset = 0xB8;
+/** Investment root slot of the four unlock value mapping tables. */
+inline constexpr std::size_t kUnlockValueMapTableSlot = 113;
+/** Array descriptor of the account object's value mapping table. */
+inline constexpr std::size_t kAccountValueMapDescriptor = 8;
+/** One unlock expression instruction: an opcode then its operand. */
+inline constexpr std::size_t kUnlockInstructionStride = 8;
+/** Opcodes run to fifteen; anything wider means the field is not an expression. */
+inline constexpr std::uint32_t kUnlockOpcodeCeiling = 20;
+/** The opcode that reads a value slot. */
+inline constexpr std::uint32_t kUnlockReadValueOpcode = 10;
+/** The opcode that tests a flag. */
+inline constexpr std::uint32_t kUnlockReadFlagOpcode = 1;
+/** Array payloads begin after a sixteen byte header. */
+inline constexpr std::size_t kHeaderSkip = 16;
+/**
+ * Upper bound on how many instructions a node expression may hold.
+ *
+ * This was thirty-two, which was a guess rather than a measurement, and it silently hid a real gate:
+ * one lore book carries fifty-nine instructions, so its expression was rejected before it was read
+ * and the category looked as though nothing gated it at all. The bound exists only to stop a wild
+ * count being walked as if it were an expression, so it is set well above anything observed.
+ */
+inline constexpr std::int64_t kNodeExpressionCapacity = 128;
+/** Investment root slot of the presentation node table. */
+inline constexpr std::size_t kPresentationNodeTableSlot = 63;
+/** One node row. Measured from the spacing of four known node hashes, not divided out of the blob. */
+inline constexpr std::size_t kNodeRowStride = 168;
+/** A node's expression sits at one of these two fields, never both. */
+inline constexpr std::size_t kNodeExpressionFieldPrimary = 64;
+inline constexpr std::size_t kNodeExpressionFieldAlternate = 48;
+/** Records a node owns, four bytes each as a row then a gate. */
+inline constexpr std::size_t kNodeChildRecordField = 136;
+inline constexpr std::size_t kNodeChildRecordStride = 4;
+/**
+ * How a lore book's parent-record bar slot relates to the category's own.
+ *
+ * The naive rule — parent = category slot + 1 — holds only when the slot above a category is free.
+ * Categories were allocated in contiguous runs, so for those books slot+1 is the next book's
+ * category. The shipped allocation instead defers every run's parent slots to immediately after
+ * the run, assigning them in reverse category order: the first book of a run gets the last parent
+ * slot and vice versa. Verified against four independent in-game marker readings (Pigeon 7060->7061,
+ * Exegete 7284->7285 isolated; Aunor -> 5230 and Ecdysis -> 5229 inside the 5222-5227 run).
+ */
+inline constexpr std::int32_t kNodeParentSlotStep = 1;
+/** Array descriptor of the character object's flag mapping table, sized to that bank. */
+inline constexpr std::size_t kCharacterFlagMapDescriptor = 40;
+/** Array descriptor of the character object's value mapping table, sized to that bank. */
+inline constexpr std::size_t kCharacterValueMapDescriptor = 24;
+inline constexpr std::size_t kUnlockFlagMapTableSlot = 111;
+/** Array descriptor of the account object's flag mapping table. */
+inline constexpr std::size_t kAccountFlagMapDescriptor = 8;
+/** One mapping row: a source and the slot it feeds. */
+inline constexpr std::size_t kUnlockMapRowStride = 8;
+/** The destination slot within one mapping row. */
+inline constexpr std::size_t kUnlockMapDestinationSlotOffset = 4;
+
 inline constexpr std::size_t kCollectibleTableSlot = 19;
 /** Definition class recorded for the installed investment root tag. */
 inline constexpr std::uint32_t kInvestmentRootClass = 0x80807D84U;
@@ -87,7 +176,7 @@ inline constexpr std::uint32_t kSandboxPerkClass = 0x808077BCU;
 inline constexpr std::size_t kStatBlockOffset = 112;
 /** The investment root holds the constants blob at this slot. */
 inline constexpr std::size_t kInvestmentConstantsSlot = 11;
-/** The investment root holds the progression definition table at this slot. */
+
 inline constexpr std::size_t kProgressionTableSlot = 68;
 /** Element class of the progression definition table. */
 inline constexpr std::uint32_t kProgressionTableClass = 0x80807CDDU;

+ 87 - 0
Sunrise/src/middleware/content/packages/tables/unlock_expression.h

@@ -0,0 +1,87 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <cstring>
+#include <span>
+
+#include "definition_index_table.h"
+
+namespace sunrise::middleware::content::packages::tables {
+
+/**
+ * Reading unlock expressions out of definition rows.
+ *
+ * A row field holds a count and, eight bytes on, a self-relative offset to a run of instructions.
+ * Each instruction is an opcode then an operand. Which field a row uses varies, and so does what it
+ * carries: the same field holds a value read on one row and a flag test on another, so both readers
+ * are tried against the same fields rather than each field being treated as fixed-purpose.
+ */
+
+/**
+ * Reads the first operand of a given opcode out of one expression field.
+ * @param table Blob the row sits in.
+ * @param rowAt Byte offset of the row.
+ * @param field Byte offset of the expression field within the row.
+ * @param opcode Opcode to look for: kUnlockReadValueOpcode or kUnlockReadFlagOpcode.
+ * @param slot Receives the operand when one is found.
+ * @return True when the field parses as an expression and names that opcode.
+ */
+[[nodiscard]] inline bool expression_operand(std::span<const std::byte> table,
+                                             std::size_t rowAt,
+                                             std::size_t field,
+                                             std::uint32_t opcode,
+                                             std::int16_t& slot) noexcept {
+    std::int64_t count = 0;
+    std::int64_t relative = 0;
+    if (rowAt + field + 16 > table.size()) {
+        return false;
+    }
+    std::memcpy(&count, table.data() + rowAt + field, sizeof count);
+    std::memcpy(&relative, table.data() + rowAt + field + 8, sizeof relative);
+    if (count < 1 || count > kNodeExpressionCapacity) {
+        return false;
+    }
+    const std::size_t pointerAt = rowAt + field + 8;
+    const std::int64_t target = static_cast<std::int64_t>(pointerAt) + relative
+                                + static_cast<std::int64_t>(kHeaderSkip);
+    if (target < 0
+        || static_cast<std::size_t>(target) + static_cast<std::size_t>(count) * kUnlockInstructionStride
+               > table.size()) {
+        return false;
+    }
+    const auto base = static_cast<std::size_t>(target);
+    for (std::int64_t index = 0; index < count; ++index) {
+        std::uint32_t instruction = 0;
+        std::uint32_t operand = 0;
+        const std::size_t at = base + static_cast<std::size_t>(index) * kUnlockInstructionStride;
+        std::memcpy(&instruction, table.data() + at, sizeof instruction);
+        std::memcpy(&operand, table.data() + at + 4, sizeof operand);
+        if (instruction > kUnlockOpcodeCeiling) {
+            return false;
+        }
+        if (instruction == opcode && operand <= static_cast<std::uint32_t>(INT16_MAX)) {
+            slot = static_cast<std::int16_t>(operand);
+            return true;
+        }
+    }
+    return false;
+}
+
+/** Reads the value slot one expression field names, or reports that it names none. */
+[[nodiscard]] inline bool expression_value_slot(std::span<const std::byte> table,
+                                                std::size_t rowAt,
+                                                std::size_t field,
+                                                std::int16_t& slot) noexcept {
+    return expression_operand(table, rowAt, field, kUnlockReadValueOpcode, slot);
+}
+
+/** Reads the flag slot one expression field tests, or reports that it tests none. */
+[[nodiscard]] inline bool expression_flag_slot(std::span<const std::byte> table,
+                                               std::size_t rowAt,
+                                               std::size_t field,
+                                               std::int16_t& slot) noexcept {
+    return expression_operand(table, rowAt, field, kUnlockReadFlagOpcode, slot);
+}
+
+} // namespace sunrise::middleware::content::packages::tables

+ 17 - 0
Sunrise/src/middleware/datagen/character_record/appearance/character_appearance_stats.cpp

@@ -4,12 +4,14 @@
 #include "../../../../core/logging/log.h"
 #include "../../../../state/build_data/runtime.h"
 #include "../../../../state/equipment/light/definition.h"
+#include "../../../../state/progression/seasonal_experience.h"
 #include "internal.h"
 
 namespace sunrise::middleware::datagen::character_record::appearance {
 namespace {
 
 namespace constants = state::build_data::constants;
+namespace seasonal = state::progression::seasonal_experience;
 
 /**
  * Sums one definition's declared contribution to a single stat row.
@@ -167,6 +169,21 @@ bool apply_stats(const family4::loadout::ResolvedInstances& instances,
 
     std::size_t written = 0;
     append(named.lightStatRow, light, appearance.characterStats, written);
+    for (std::size_t index = 0; index < instances.itemCount; ++index) {
+        details::Definition detail{};
+        Equipped equipped{};
+        if (!resolve_equipped(instances.items[index], detail, equipped)
+            || detail.definitionHash != seasonal::kSeedOfSilverWingsHash
+            || detail.statCount == 0
+            || detail.stats.front().row == details::kEmptyStatRow) {
+            continue;
+        }
+        append(detail.stats.front().row,
+               seasonal::artifact_power_bonus(),
+               appearance.characterStats,
+               written);
+        break;
+    }
     for (const std::uint8_t row : rows) {
         std::int32_t total = 0;
         for (std::size_t index = 0; index < instances.itemCount; ++index) {

+ 15 - 4
Sunrise/src/middleware/datagen/character_record/character_record_encoder.cpp

@@ -62,10 +62,12 @@ constexpr std::size_t kCardFlagOffset = 8;
 }
 
 /** @param light Equipment light. @return The trailing summary block both records carry. */
-[[nodiscard]] layout::Summary build_summary(std::int32_t light) noexcept {
+[[nodiscard]] layout::Summary build_summary(std::int32_t light,
+                                            std::uint16_t titleRecordIndex) noexcept {
     layout::Summary summary{};
     summary.light = light;
     summary.hashA = layout::kNoHash;
+    summary.indexD = titleRecordIndex;
     return summary;
 }
 
@@ -109,8 +111,12 @@ bool encode_family3(const state::CharacterState& character,
         return false;
     }
     const auto record = output.first(kFamily3RecordSize);
-    copy_record(
-        identity, block, build_summary(light), kFamily3ReservedSize, kFamily3TailSize, record);
+    copy_record(identity,
+                block,
+                build_summary(light, character.equippedTitleRecordIndex),
+                kFamily3ReservedSize,
+                kFamily3TailSize,
+                record);
     // Both stamps are the last reset before sign-in. Zero would make the client run a daily and
     // a weekly rollover as soon as it accepts the record.
     layout::PeriodicReset reset{};
@@ -136,7 +142,12 @@ bool encode_family0(const state::CharacterState& character,
         return false;
     }
     const auto record = output.first(kFamily0RecordSize);
-    copy_record(identity, block, build_summary(light), 0, kFamily0TailSize, record);
+    copy_record(identity,
+                block,
+                build_summary(light, character.equippedTitleRecordIndex),
+                0,
+                kFamily0TailSize,
+                record);
     const layout::Family0Tail tail{};
     std::memcpy(record.data() + kFamily0RecordSize - kFamily0TailSize, &tail, sizeof tail);
     return true;

+ 53 - 15
Sunrise/src/middleware/datagen/family4/account/account_encoder.cpp

@@ -1,10 +1,17 @@
 #include "account_encoder.h"
 
 #include <algorithm>
+#include <array>
 #include <cstring>
 #include <limits>
+#include <optional>
+#include <span>
 
+#include "../../../../state/build_data/nodes/node_catalog.h"
 #include "../../../../state/build_data/runtime.h"
+#include "../../../../state/progression/season_pass_reward_catalog.h"
+#include "../../../../state/progression/seasonal_experience.h"
+#include "../../../../state/record_claims/record_claims.h"
 #include "../../../../state/unlocks/unlocks_runtime.h"
 #include "../progression/progression_bank_keys.h"
 #include "layout.h"
@@ -13,6 +20,8 @@
 namespace sunrise::middleware::datagen::family4::account {
 namespace {
 
+namespace seasonal = state::progression::seasonal_experience;
+
 /** Every bit set is the native empty biased 16-bit definition index. */
 constexpr std::uint16_t kEmptyDefinitionIndex = (std::numeric_limits<std::uint16_t>::max)();
 /** Signed 32-bit maximum keeps publicity deadlines beyond a normal session clock. */
@@ -23,20 +32,10 @@ constexpr std::byte kSeenMessageByte{0xFF};
 /** A native inventory bucket id is 1 byte, so this covers every bucket. */
 constexpr std::size_t kBucketIdentityCapacity = 256;
 
-/**
- * Places one authored profile item in the slot run its inventory bucket owns.
- * The slot is not authored: the bucket descriptor names the first slot of its run, and items
- * sharing a bucket take consecutive slots in configuration order.
- * @param item Authored account-wide item.
- * @param taken Slots already claimed inside each bucket, indexed by bucket id.
- * @param rows Profile inventory rows.
- * @return True when the item resolves to a free profile slot.
- */
+/** Places one profile item in the next free row of its inventory bucket. */
 [[nodiscard]] bool place_profile_item(const state::account::inventory::ProfileItem& item,
                                       std::array<std::uint16_t, kBucketIdentityCapacity>& taken,
                                       std::span<inventory::layout::Entry> rows) noexcept {
-    // 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{};
@@ -76,7 +75,10 @@ constexpr std::size_t kBucketIdentityCapacity = 256;
 } // namespace
 
 /** Encodes a sentinel-correct account object from authored State. */
-bool encode(const state::AccountState& state, std::span<std::byte> output) noexcept {
+bool encode(const state::AccountState& state,
+            std::span<std::byte> output,
+            std::optional<std::uint16_t> pendingSeasonReward,
+            const state::record_claims::PendingClaim* pendingRecordClaim) noexcept {
     if (state.primarySoid == 0 || !state::account::valid(state)
         || output.size() < layout::kMinimumSize) {
         return false;
@@ -91,7 +93,6 @@ bool encode(const state::AccountState& state, std::span<std::byte> output) noexc
         return false;
     }
 
-    // Acquired flags and objective progress are authored policy, published once per process.
     const state::unlocks::Table& unlocks = state::unlocks::get();
     object.acquiredFlags = unlocks.accountFlags;
     object.profileUnlockFlags = unlocks.profileFlags;
@@ -99,6 +100,24 @@ bool encode(const state::AccountState& state, std::span<std::byte> output) noexc
     if (!state::build_data::complete_exotic_catalyst_objectives(object.objectiveValues)) {
         return false;
     }
+    // Season claims map to account flags; pending claims overlay the same response.
+    if (!seasonal::apply_reward_claims(object.acquiredFlags)) {
+        return false;
+    }
+    if (pendingSeasonReward.has_value()) {
+        const std::size_t flag =
+            state::progression::season_pass::claim_account_flag_index(*pendingSeasonReward);
+        if (state::progression::season_pass::find(*pendingSeasonReward) == nullptr
+            || flag >= object.acquiredFlags.size()) {
+            return false;
+        }
+        object.acquiredFlags[flag] = state::unlocks::kFlagSet;
+    }
+    state::record_claims::apply_account_projection(
+        object.acquiredFlags, object.objectiveValues, pendingRecordClaim);
+    // Value-gated categories run last so their sentinel cannot replace real progress.
+    (void)state::build_data::nodes::apply_category_gates(object.objectiveValues);
+
     for (layout::CharacterUnlockBlock& block : object.characterUnlocks) {
         block.flags = unlocks.characterFlags;
     }
@@ -114,6 +133,26 @@ bool encode(const state::AccountState& state, std::span<std::byte> output) noexc
                                object.progressions)) {
         return false;
     }
+    const std::int32_t earnedExperience = seasonal::earned();
+    for (std::size_t slot = 0; slot < object.progressions.size(); ++slot) {
+        progression::layout::Entry& entry = object.progressions[slot];
+        std::int32_t projectedExperience = earnedExperience;
+        if (entry.definitionIndex == state::progression::season_pass::kProgressionDefinitionIndex) {
+            projectedExperience = (std::min)(earnedExperience, seasonal::kMaximumPassExperience);
+        } else if (entry.definitionIndex
+                   == state::progression::season_pass::kHudProgressionDefinitionIndex) {
+            projectedExperience =
+                earnedExperience < seasonal::kMaximumPassExperience
+                    ? earnedExperience % seasonal::kExperiencePerRank
+                    : earnedExperience - seasonal::kMaximumPassExperience;
+        } else if (entry.definitionIndex
+                       != seasonal::kArtifactPowerProgressionDefinitionIndex
+                   && entry.definitionIndex
+                          != seasonal::kArtifactUnlockProgressionDefinitionIndex) {
+            continue;
+        }
+        entry.values[0] = (std::max)(entry.values[0], projectedExperience);
+    }
     // Profile rows are sentinelled above, so placement only has to claim its own slots.
     std::array<std::uint16_t, kBucketIdentityCapacity> takenSlots{};
     for (std::size_t index = 0; index < state.profileItemCount; ++index) {
@@ -123,8 +162,7 @@ bool encode(const state::AccountState& state, std::span<std::byte> output) noexc
     }
     object.profileItemCount = static_cast<std::uint32_t>(state.profileItemCount);
 
-    // Commit only after every fallible conversion succeeds so callers never receive a partial
-    // account object.
+    // Publish only after every fallible conversion succeeds.
     std::fill(output.begin(), output.end(), std::byte{});
     std::memcpy(output.data(), &object, sizeof object);
     return true;

+ 11 - 1
Sunrise/src/middleware/datagen/family4/account/account_encoder.h

@@ -1,9 +1,15 @@
 #pragma once
 
+#include <cstdint>
+#include <optional>
 #include <span>
 
 #include "../../../../state/account/account_state.h"
 
+namespace sunrise::state::record_claims {
+struct PendingClaim;
+}
+
 namespace sunrise::middleware::datagen::family4::account {
 
 /**
@@ -12,6 +18,10 @@ namespace sunrise::middleware::datagen::family4::account {
  * @param output Exact State-mapped account-object storage.
  * @return True when State is valid and every required fixed region fits.
  */
-[[nodiscard]] bool encode(const state::AccountState& state, std::span<std::byte> output) noexcept;
+[[nodiscard]] bool
+encode(const state::AccountState& state,
+       std::span<std::byte> output,
+       std::optional<std::uint16_t> pendingSeasonReward = std::nullopt,
+       const state::record_claims::PendingClaim* pendingRecordClaim = nullptr) noexcept;
 
 } // namespace sunrise::middleware::datagen::family4::account

+ 146 - 31
Sunrise/src/middleware/datagen/family4/character/character_encoder.cpp

@@ -6,11 +6,14 @@
 #include <limits>
 #include <optional>
 
+#include "../../../../state/build_data/nodes/node_catalog.h"
+#include "../../../../state/build_data/runtime.h"
+#include "../../../../state/progression/seasonal_experience.h"
+#include "../../../../state/record_claims/record_claims.h"
 #include "../../../../state/unlocks/unlocks_runtime.h"
 #include "../../character_record/layout.h"
 #include "../instance/layout.h"
 #include "../progression/progression_bank_keys.h"
-#include "abi.h"
 #include "equipment_summary_builder.h"
 #include "layout.h"
 
@@ -29,17 +32,25 @@ constexpr std::int16_t kEmptyItemStackSelector = -1;
 constexpr std::uint8_t kNativeTrue = 1;
 /** Native 1-byte booleans encode false as 0. */
 constexpr std::uint8_t kNativeFalse = 0;
+/** Stackable quest items needed by the collectible interactions currently supported. */
+struct CollectibleQuest {
+    std::uint32_t definitionHash{};
+    /** Lore completion flag which consumes this quest; zero keeps the prerequisite authored. */
+    std::uint16_t completionFlag{};
+};
+constexpr std::array<CollectibleQuest, 4> kCollectibleQuests{{
+    {0x57C4540AU, 0U},
+    {0x85CC476EU, 10762U},
+    {0xB099029AU, 10766U},
+    {0xC3535D63U, 10769U},
+}};
 /** One character has one customisation header, so both records carry the same 36 bytes. */
 static_assert(character_record::layout::kHeaderBlockBytes.size()
               == layout::kCustomisationHeaderSize);
 /** Character object B repeats the family-three periodic-reset block byte for byte. */
 static_assert(sizeof(character_record::layout::PeriodicReset) == layout::kPeriodicResetRecordSize);
 
-/**
- * Validates the authored fields consumed by the selected-character encoder.
- * @param state Candidate character identity and policy state.
- * @return True when every encoded scalar fits its stable State domain.
- */
+/** Validates the authored fields consumed by the character encoder. */
 [[nodiscard]] bool valid(const state::CharacterState& state) noexcept {
     return state.soid != 0 && state.race <= state::CharacterRace::exo
            && state.gender <= state::CharacterGender::female
@@ -48,18 +59,113 @@ static_assert(sizeof(character_record::layout::PeriodicReset) == layout::kPeriod
 
 /** One new-item flag byte covers 8 consecutive inventory rows. */
 constexpr std::size_t kBitsPerFlagByte = 8;
-/**
- * The watermark an occupied inventory row carries; an empty row keeps 0.
- * This is the character object's own per-row field, not the item instance's roll progress, so it
- * does not share that constant.
- */
+/** Character-object watermark for an occupied inventory row. */
 constexpr std::int32_t kOccupiedRowWatermark = 1;
 
+[[nodiscard]] bool place_character_stacks(const state::CharacterState& state,
+                                          layout::Object& object) noexcept {
+    if (!state::account::inventory::valid(state.stacks)) {
+        return false;
+    }
+    for (std::size_t index = 0; index < state.stacks.count; ++index) {
+        const auto& stack = state.stacks.values[index];
+        state::build_data::items::Definition item{};
+        state::build_data::items::details::Definition detail{};
+        state::build_data::inventory::buckets::Descriptor bucket{};
+        if (stack.quantity <= 0 || stack.mutationSerial < 0
+            || static_cast<std::uint32_t>(stack.mutationSerial) >= state.nextInventorySerial
+            || !state::build_data::find_item_definition_hash(stack.definitionHash, item)
+            || !state::build_data::find_configured_item_detail(item.definitionIndex, detail)
+            || detail.definitionIndex != item.definitionIndex
+            || detail.definitionHash != item.definitionHash || detail.bucketId != item.bucketId
+            || detail.instancedDefinitionState
+                   != state::build_data::items::details::InstancedDefinitionState::stackable
+            || detail.equipmentSlot.has_value() || stack.quantity > detail.maxStackSize
+            || !state::build_data::find_inventory_bucket_descriptor(item.bucketId, bucket)
+            || bucket.arraySelector
+                   != state::build_data::inventory::buckets::ArraySelector::character
+            || bucket.slotCount == 0 || bucket.firstSlot >= object.inventoryItems.size()
+            || bucket.slotCount > object.inventoryItems.size() - bucket.firstSlot) {
+            return false;
+        }
+        const std::size_t end = static_cast<std::size_t>(bucket.firstSlot) + bucket.slotCount;
+        std::size_t rowIndex = bucket.firstSlot;
+        while (rowIndex < end
+               && object.inventoryItems[rowIndex].definitionIndex != kEmptyDefinitionIndex) {
+            ++rowIndex;
+        }
+        if (rowIndex == end) {
+            return false;
+        }
+        auto& row = object.inventoryItems[rowIndex];
+        row.definitionIndex = item.definitionIndex;
+        row.quantity = stack.quantity;
+        row.mutationSerial = stack.mutationSerial;
+        object.newItemFlags[rowIndex / kBitsPerFlagByte] |= std::byte{1U}
+                                                            << (rowIndex % kBitsPerFlagByte);
+        object.instanceProgressWatermarks[rowIndex] = kOccupiedRowWatermark;
+    }
+    return true;
+}
+
 /**
- * Validates every inventory and equipment field consumed by the character object.
- * @param resolvedLoadout Candidate row-sorted installed mapping.
- * @return True when rows, slots, SOIDs, indices, quantities, and serials are canonical.
+ * Places collectible prerequisites in the character quest bucket. These stackable rows need no
+ * item-instance resident.
  */
+[[nodiscard]] bool place_collectible_quest_items(layout::Object& object) noexcept {
+    std::optional<std::uint8_t> questBucketId;
+    std::size_t nextRow = 0;
+    std::size_t rowLimit = 0;
+    for (const CollectibleQuest& quest : kCollectibleQuests) {
+        if (quest.completionFlag != 0
+            && (state::record_claims::claimed(quest.completionFlag)
+                || state::record_claims::claimable(quest.completionFlag))) {
+            continue;
+        }
+        state::build_data::items::Definition item{};
+        state::build_data::items::details::Definition detail{};
+        state::build_data::inventory::buckets::Descriptor bucket{};
+        if (!state::build_data::find_item_definition_hash(quest.definitionHash, item)
+            || !state::build_data::find_configured_item_detail(item.definitionIndex, detail)
+            || detail.definitionIndex != item.definitionIndex
+            || detail.definitionHash != item.definitionHash || detail.bucketId != item.bucketId
+            || detail.instancedDefinitionState
+                   != state::build_data::items::details::InstancedDefinitionState::stackable
+            || detail.maxStackSize < 1 || detail.equipmentSlot.has_value()
+            || !state::build_data::find_inventory_bucket_descriptor(item.bucketId, bucket)
+            || bucket.arraySelector
+                   != state::build_data::inventory::buckets::ArraySelector::character
+            || bucket.slotCount == 0 || bucket.firstSlot >= object.inventoryItems.size()
+            || bucket.slotCount > object.inventoryItems.size() - bucket.firstSlot) {
+            return false;
+        }
+        if (!questBucketId.has_value()) {
+            questBucketId = item.bucketId;
+            nextRow = bucket.firstSlot;
+            rowLimit = bucket.firstSlot + bucket.slotCount;
+        } else if (*questBucketId != item.bucketId || nextRow >= rowLimit) {
+            return false;
+        }
+        while (nextRow < rowLimit
+               && object.inventoryItems[nextRow].definitionIndex != kEmptyDefinitionIndex) {
+            ++nextRow;
+        }
+        if (nextRow >= rowLimit) {
+            return false;
+        }
+
+        inventory::layout::Entry& row = object.inventoryItems[nextRow];
+        row.definitionIndex = item.definitionIndex;
+        row.quantity = 1;
+        object.newItemFlags[nextRow / kBitsPerFlagByte] |= std::byte{1U}
+                                                           << (nextRow % kBitsPerFlagByte);
+        object.instanceProgressWatermarks[nextRow] = kOccupiedRowWatermark;
+        ++nextRow;
+    }
+    return true;
+}
+
+/** Validates the row-sorted loadout consumed by the character object. */
 [[nodiscard]] bool valid(const loadout::ResolvedLoadout& resolvedLoadout) noexcept {
     if (resolvedLoadout.itemCount > resolvedLoadout.items.size()
         || resolvedLoadout.nextInventorySerial
@@ -67,15 +173,13 @@ constexpr std::int32_t kOccupiedRowWatermark = 1;
         return false;
     }
 
-    std::array<bool, layout::kInventoryCapacity> occupiedRows{};
     std::array<bool, layout::kEquipmentCapacity> occupiedEquipmentSlots{};
     std::array<std::uint64_t, loadout::kItemCapacity> instanceSoids{};
     for (std::size_t index = 0; index < resolvedLoadout.itemCount; ++index) {
         const loadout::ResolvedItem& item = resolvedLoadout.items[index];
         const instance::ResolvedInstance& itemInstance = item.instance;
-        const auto priorSoidsEnd = instanceSoids.cbegin() + static_cast<std::ptrdiff_t>(index);
-        if (item.inventoryRow >= occupiedRows.size()
-            || item.equipmentSlot >= occupiedEquipmentSlots.size() || item.quantity <= 0
+        const bool validEquipmentSlot = item.equipmentSlot < occupiedEquipmentSlots.size();
+        if (item.inventoryRow >= layout::kInventoryCapacity || item.quantity <= 0
             || itemInstance.instanceSoid == 0 || itemInstance.bounds.itemDefinitionCount == 0
             || itemInstance.bounds.itemDefinitionCount > instance::layout::kDefinitionIndexCapacity
             || itemInstance.baseDefinitionIndex == kEmptyDefinitionIndex
@@ -83,28 +187,22 @@ constexpr std::int32_t kOccupiedRowWatermark = 1;
             || 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
+            || (item.equipped
+                && (!validEquipmentSlot || occupiedEquipmentSlots[item.equipmentSlot]))
             || (index != 0 && resolvedLoadout.items[index - 1].inventoryRow >= item.inventoryRow)) {
             return false;
         }
-        occupiedRows[item.inventoryRow] = true;
         if (item.equipped) {
             occupiedEquipmentSlots[item.equipmentSlot] = true;
         }
         instanceSoids[index] = itemInstance.instanceSoid;
     }
-    return true;
+    auto end = instanceSoids.begin() + static_cast<std::ptrdiff_t>(resolvedLoadout.itemCount);
+    std::sort(instanceSoids.begin(), end);
+    return std::adjacent_find(instanceSoids.begin(), end) == end;
 }
 
-/**
- * Confirms selected-character summary rows describe exactly the resolved equipped instances.
- * @param resolvedLoadout Canonical row-sorted inventory and equipment mappings.
- * @param evaluation Complete semantic equipment-light evaluation.
- * @return True when every equipped native slot carries the same definition index once.
- */
+/** Confirms the light summary describes exactly the resolved equipped instances. */
 [[nodiscard]] bool
 summary_matches_loadout(const loadout::ResolvedLoadout& resolvedLoadout,
                         const state::equipment::light::Evaluation& evaluation) noexcept {
@@ -138,7 +236,8 @@ summary_matches_loadout(const loadout::ResolvedLoadout& resolvedLoadout,
 bool encode(const state::CharacterState& state,
             const loadout::ResolvedLoadout& resolvedLoadout,
             const state::equipment::light::Evaluation& lightEvaluation,
-            std::span<std::byte> output) noexcept {
+            std::span<std::byte> output,
+            const state::record_claims::PendingClaim* pendingClaim) noexcept {
     if (!valid(state) || !valid(resolvedLoadout)
         || !summary_matches_loadout(resolvedLoadout, lightEvaluation)
         || output.size() < layout::kObjectSize) {
@@ -161,6 +260,7 @@ bool encode(const state::CharacterState& state,
     object.currentActivityIndex = state.currentActivityIndex;
     object.previewMirrors.fill(state.previewAvailable ? kNativeTrue : kNativeFalse);
     object.contentBypass = state.contentBypass ? kNativeTrue : kNativeFalse;
+    object.equippedTitleRecordIndex = state.equippedTitleRecordIndex;
     object.seenMessages.fill(kSeenMessageByte);
     // Both stamps are the last reset before sign-in. Zero would make the client run a daily and
     // a weekly rollover as soon as it accepts the object.
@@ -182,10 +282,22 @@ bool encode(const state::CharacterState& state,
             index < unlocks.characterObjectFlags.size() ? unlocks.characterObjectFlags[index]
                                                         : std::uint8_t{});
     }
+    // The authored bank is laid down first. It used to be copied in after the node pass below,
+    // which overwrote every element the pass had just written -- so the character-scoped node
+    // progress never reached the client at all.
     for (std::size_t index = 0; index < object.objectiveValues.size(); ++index) {
         object.objectiveValues[index] =
             index < unlocks.characterObjectValues.size() ? unlocks.characterObjectValues[index] : 0;
     }
+    if (!state::progression::seasonal_experience::apply_artifact_character_state(
+            object.acquiredFlags, object.objectiveValues)) {
+        return false;
+    }
+    // One lore book counts in the character bank rather than the account one.
+    (void)state::record_claims::apply_character_node_progress(object.objectiveValues, pendingClaim);
+
+    // One lore book's gate is character scoped rather than account scoped.
+    (void)state::build_data::nodes::apply_character_visibility(object.acquiredFlags);
     if (!build_equipment_summary(lightEvaluation, object.equipmentSummary)) {
         return false;
     }
@@ -216,6 +328,9 @@ bool encode(const state::CharacterState& state,
             object.equippedInstanceSoids[item.equipmentSlot] = item.instance.instanceSoid;
         }
     }
+    if (!place_character_stacks(state, object) || !place_collectible_quest_items(object)) {
+        return false;
+    }
 
     // Commit only after validation so callers never receive a partially initialized object.
     std::fill(output.begin(), output.end(), std::byte{});

+ 10 - 4
Sunrise/src/middleware/datagen/family4/character/character_encoder.h

@@ -6,6 +6,10 @@
 #include "../../../../state/equipment/light/definition.h"
 #include "../loadout/definition.h"
 
+namespace sunrise::state::record_claims {
+struct PendingClaim;
+}
+
 namespace sunrise::middleware::datagen::family4::character {
 
 /**
@@ -16,9 +20,11 @@ namespace sunrise::middleware::datagen::family4::character {
  * @param output Exact runtime-mapped character-object storage.
  * @return True when State, mappings, and the mapped object span fit the native layout.
  */
-[[nodiscard]] bool encode(const state::CharacterState& state,
-                          const loadout::ResolvedLoadout& resolvedLoadout,
-                          const state::equipment::light::Evaluation& lightEvaluation,
-                          std::span<std::byte> output) noexcept;
+[[nodiscard]] bool
+encode(const state::CharacterState& state,
+       const loadout::ResolvedLoadout& resolvedLoadout,
+       const state::equipment::light::Evaluation& lightEvaluation,
+       std::span<std::byte> output,
+       const state::record_claims::PendingClaim* pendingClaim = nullptr) noexcept;
 
 } // namespace sunrise::middleware::datagen::family4::character

+ 10 - 2
Sunrise/src/middleware/datagen/family4/character/layout.h

@@ -43,7 +43,9 @@ 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;
+inline constexpr std::size_t kSummaryTitlePaddingSize = 36;
+/** Reserved bytes between the equipped-title row and the inventory validity gate. */
+inline constexpr std::size_t kTitleGatePaddingSize = 14;
 /** 14 reserved bytes separate the two inventory validity gate fields. */
 inline constexpr std::size_t kGateStatePaddingSize = 14;
 /** 16 reserved bytes separate the second gate from seen-message storage. */
@@ -86,6 +88,8 @@ inline constexpr std::size_t kSummaryDefinitionWordCount = 2;
 inline constexpr std::size_t kSummaryArrayCount = 2;
 /** Every set bit is the native absent definition index in an equipment-summary slot. */
 inline constexpr std::uint16_t kEmptySummaryDefinitionIndex = 0xFFFF;
+/** The Seals screen compares its record row against this field. */
+inline constexpr std::size_t kEquippedTitleRecordIndexOffset = 11'992;
 
 #pragma pack(push, 1)
 
@@ -167,7 +171,10 @@ struct Object {
     InventoryChangeList inventoryChanges{};
     std::array<std::uint64_t, kEquipmentCapacity> equippedInstanceSoids{};
     EquipmentSummary equipmentSummary{};
-    std::array<std::byte, kSummaryGatePaddingSize> summaryGatePadding{};
+    std::array<std::byte, kSummaryTitlePaddingSize> summaryTitlePadding{};
+    /** Native DestinyRecordDefinition row of the equipped title, or 0xFFFF. */
+    std::uint16_t equippedTitleRecordIndex{kEmptySummaryDefinitionIndex};
+    std::array<std::byte, kTitleGatePaddingSize> titleGatePadding{};
     /** 0 is a valid definition index and keeps the inventory-present gate open. */
     std::uint16_t inventoryGateDefinitionIndex{};
     std::array<std::byte, kGateStatePaddingSize> gateStatePadding{};
@@ -241,6 +248,7 @@ static_assert(sizeof(InventoryChangeList)
 static_assert(offsetof(InventoryChangeList, records) == 2 * sizeof(std::uint16_t));
 static_assert(sizeof(ItemStackRow) == kItemStackRowSize);
 static_assert(sizeof(Object) == kObjectSize);
+static_assert(offsetof(Object, equippedTitleRecordIndex) == kEquippedTitleRecordIndexOffset);
 static_assert(std::is_trivially_copyable_v<Object>);
 
 } // namespace sunrise::middleware::datagen::family4::character::layout

+ 3 - 0
Sunrise/src/middleware/datagen/family4/loadout/definition.h

@@ -9,6 +9,9 @@
 
 namespace sunrise::middleware::datagen::family4::loadout {
 
+/** Character inventory items without an equipment slot retain an out-of-range native sentinel. */
+inline constexpr std::uint8_t kUnavailableEquipmentSlot = 0xFFU;
+
 /** Equipped plus unequipped authored items one character may publish. */
 inline constexpr std::size_t kItemCapacity = state::account::inventory::kEquipmentSlotCount
                                              + state::account::inventory::kCharacterItemCapacity;

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

@@ -0,0 +1,31 @@
+#pragma once
+
+#include <cstdint>
+
+#include "../web_service_envelope.h"
+
+namespace sunrise::middleware::web_service::messages::opcode1801 {
+
+/** Web Service opcode the Triumphs screen uses to claim one completed record. */
+inline constexpr std::uint16_t kOpcode = 1801;
+
+/** The one logical field carried by the native record claim descriptor. */
+struct Request {
+    std::uint16_t recordIndex{};
+};
+
+/**
+ * Parses the exact reflected opcode-1801 record claim descriptor.
+ *
+ * The record is an optional native field, so the descriptor carries a presence bit before its
+ * fifteen-bit row, exactly as the Collections pull does. A request naming no record is not a claim
+ * and is refused here. The row is not range-checked against the installed record table: that is the
+ * caller's decision, not the codec's.
+ *
+ * @param message Parsed Web Service envelope.
+ * @param request Receives the named record row.
+ * @return True only for the complete canonical three-byte request naming a record.
+ */
+[[nodiscard]] bool parse_request(const Message& message, Request& request) noexcept;
+
+} // namespace sunrise::middleware::web_service::messages::opcode1801

+ 39 - 0
Sunrise/src/middleware/web_service/messages/opcode1801_codec.cpp

@@ -0,0 +1,39 @@
+#include <cstddef>
+
+#include "../../encoding/bit_reader.h"
+#include "opcode1801.h"
+
+namespace sunrise::middleware::web_service::messages::opcode1801 {
+namespace {
+
+/** The reflected record claim request occupies exactly 24 bits. */
+constexpr std::size_t kPayloadSize = 3;
+/** The optional record carries one presence bit before its row. */
+constexpr std::uint8_t kPresenceWidth = 1;
+/** Native record rows are addressed by a fifteen-bit index. */
+constexpr std::uint8_t kRecordIndexWidth = 15;
+/** The descriptor pads its two payload bytes out to three. */
+constexpr std::uint8_t kPaddingWidth = 8;
+
+} // namespace
+
+/** Parses the exact native record claim 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 encodedRecordIndex = 0;
+    std::uint64_t padding = 0;
+    if (!reader.read(kPresenceWidth, present) || !reader.read(kRecordIndexWidth, encodedRecordIndex)
+        || !reader.read(kPaddingWidth, padding) || reader.remaining_bits() != 0 || present == 0
+        || padding != 0) {
+        return false;
+    }
+    request.recordIndex = static_cast<std::uint16_t>(encodedRecordIndex);
+    return true;
+}
+
+} // namespace sunrise::middleware::web_service::messages::opcode1801

+ 24 - 0
Sunrise/src/middleware/web_service/messages/opcode1821.h

@@ -0,0 +1,24 @@
+#pragma once
+
+#include <cstdint>
+
+#include "../web_service_envelope.h"
+
+namespace sunrise::middleware::web_service::messages::opcode1821 {
+
+/** Web Service opcode the Seals screen uses to equip one earned title. */
+inline constexpr std::uint16_t kOpcode = 1821;
+
+struct Request {
+    std::uint16_t recordIndex{};
+};
+
+/**
+ * Parses the exact biased signed title row carried by opcode 1821.
+ * Logical -1 becomes kUnequippedRecordIndex and clears the current title.
+ */
+[[nodiscard]] bool parse_request(const Message& message, Request& request) noexcept;
+
+inline constexpr std::uint16_t kUnequippedRecordIndex = 0xFFFFU;
+
+} // namespace sunrise::middleware::web_service::messages::opcode1821

+ 38 - 0
Sunrise/src/middleware/web_service/messages/opcode1821_codec.cpp

@@ -0,0 +1,38 @@
+#include <cstddef>
+
+#include "../../encoding/bit_reader.h"
+#include "opcode1821.h"
+
+namespace sunrise::middleware::web_service::messages::opcode1821 {
+namespace {
+
+constexpr std::size_t kPayloadSize = 3;
+constexpr std::uint8_t kRecordIndexWidth = 16;
+constexpr std::uint8_t kPaddingWidth = 8;
+constexpr std::uint64_t kRecordBias = 1ULL << 15U;
+
+} // namespace
+
+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 encodedRecordIndex = 0;
+    std::uint64_t padding = 0;
+    if (!reader.read(kRecordIndexWidth, encodedRecordIndex)
+        || !reader.read(kPaddingWidth, padding) || reader.remaining_bits() != 0 || padding != 0) {
+        return false;
+    }
+    const std::int64_t logicalRecord = static_cast<std::int64_t>(encodedRecordIndex)
+                                       - static_cast<std::int64_t>(kRecordBias);
+    if (logicalRecord < -1 || logicalRecord > 0x7FFF) {
+        return false;
+    }
+    request.recordIndex = logicalRecord == -1 ? kUnequippedRecordIndex
+                                              : static_cast<std::uint16_t>(logicalRecord);
+    return true;
+}
+
+} // namespace sunrise::middleware::web_service::messages::opcode1821

+ 21 - 0
Sunrise/src/middleware/web_service/messages/opcode2400.h

@@ -0,0 +1,21 @@
+#pragma once
+
+#include <cstdint>
+
+#include "../web_service_envelope.h"
+
+namespace sunrise::middleware::web_service::messages::opcode2400 {
+
+/** Web Service opcode used to claim one reward from a progression reward list. */
+inline constexpr std::uint16_t kOpcode = 2400;
+
+/** Exact pair carried by the native fixed two-element request array. */
+struct Request {
+    std::uint16_t progressionIndex{};
+    std::uint16_t rewardIndex{};
+};
+
+/** Parses the complete native opcode-2400 progression/reward pair. */
+[[nodiscard]] bool parse_request(const Message& message, Request& request) noexcept;
+
+} // namespace sunrise::middleware::web_service::messages::opcode2400

+ 36 - 0
Sunrise/src/middleware/web_service/messages/opcode2400_codec.cpp

@@ -0,0 +1,36 @@
+#include <cstddef>
+
+#include "../../encoding/bit_reader.h"
+#include "opcode2400.h"
+
+namespace sunrise::middleware::web_service::messages::opcode2400 {
+namespace {
+
+/** Two 16-bit array elements plus the descriptor's final zero padding byte. */
+constexpr std::size_t kPayloadSize = 5;
+constexpr std::uint8_t kIndexWidth = 16;
+constexpr std::uint8_t kPaddingWidth = 8;
+constexpr std::uint16_t kIndexBias = 0x8000U;
+
+} // namespace
+
+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 storedProgression = 0;
+    std::uint64_t storedReward = 0;
+    std::uint64_t padding = 0;
+    if (!reader.read(kIndexWidth, storedProgression) || !reader.read(kIndexWidth, storedReward)
+        || !reader.read(kPaddingWidth, padding) || reader.remaining_bits() != 0 || padding != 0
+        || storedProgression < kIndexBias || storedReward < kIndexBias) {
+        return false;
+    }
+    request.progressionIndex = static_cast<std::uint16_t>(storedProgression - kIndexBias);
+    request.rewardIndex = static_cast<std::uint16_t>(storedReward - kIndexBias);
+    return true;
+}
+
+} // namespace sunrise::middleware::web_service::messages::opcode2400

+ 238 - 47
Sunrise/src/server/bap/bap_route.cpp

@@ -1,18 +1,19 @@
 #include <Windows.h>
 
+#include <algorithm>
 #include <array>
-#include <atomic>
-#include <cstdio>
 #include <limits>
 #include <mutex>
 #include <shared_mutex>
 #include <string_view>
 
+#include "../../client/hooks/network/investment/investment_derived_rebuild.h"
 #include "../../core/logging/log.h"
 #include "../../middleware/content/packages/tables/region_reader.h"
 #include "../../state/activity/runtime.h"
 #include "../../state/build_data/runtime.h"
 #include "../../state/matchmaking/matchmaking_state.h"
+#include "../../state/progression/seasonal_experience.h"
 #include "../activity/host_runtime.h"
 #include "activity_authority_query_owner.h"
 #include "activity_authority_reset_owner.h"
@@ -34,39 +35,69 @@ namespace tables = middleware::content::packages::tables;
 core::threading::SrwLock g_lock{};
 std::array<Session, kSessionCount> g_sessions{};
 Scratch g_scratch{};
-std::uint64_t g_accountGeneration{};
+std::array<WorldRewardRequest, kWorldRewardQueueCapacity> g_worldRewards{};
+std::size_t g_worldRewardHead{};
+std::size_t g_worldRewardCount{};
+/** Measured lifetime of the native item-acquisition flyout. */
+constexpr std::uint64_t kAcquisitionPresentationHoldMs = 8'000;
+constexpr std::uint8_t kWorldRewardFailureLimit = 8;
+
+[[nodiscard]] bool has_active_family4_peer() noexcept {
+    return std::any_of(g_sessions.begin(), g_sessions.end(), [](const Session& session) {
+        return session.id != 0 && session.authenticated && session.queuez.family4Active;
+    });
+}
+
+void pop_world_reward() noexcept {
+    g_worldRewards[g_worldRewardHead] = {};
+    g_worldRewardHead = (g_worldRewardHead + 1) % g_worldRewards.size();
+    --g_worldRewardCount;
+}
+
+[[nodiscard]] bool commit_world_reward(const WorldRewardRequest& request) noexcept {
+    if (request.kind == WorldRewardKind::item) {
+        state::PendingItemAcquisition acquisition{};
+        return state::prepare_item_acquisition_for_item(request.itemDefinitionIndex, acquisition)
+               && state::commit_item_acquisition(acquisition);
+    }
+    state::PendingProfileItemAcquisition acquisition{};
+    return state::prepare_profile_item_acquisition_for_item(
+               request.itemDefinitionIndex, request.quantity, acquisition)
+           && state::commit_profile_item_acquisition(acquisition);
+}
+
+[[nodiscard]] bool enqueue_world_reward(WorldRewardRequest request) noexcept {
+    if (!has_active_family4_peer()) {
+        while (g_worldRewardCount != 0) {
+            if (!commit_world_reward(g_worldRewards[g_worldRewardHead])) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=world_reward stage=direct result=drop");
+            }
+            pop_world_reward();
+        }
+        return commit_world_reward(request);
+    }
+    if (g_worldRewardCount == g_worldRewards.size()) {
+        const bool committed = commit_world_reward(g_worldRewards[g_worldRewardHead]);
+        if (committed) {
+            arm_account_resync_everywhere();
+        }
+        pop_world_reward();
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::warn,
+                         committed ? "ev=world_reward stage=queue_full result=direct"
+                                   : "ev=world_reward stage=queue_full result=drop");
+    }
+    g_worldRewards[(g_worldRewardHead + g_worldRewardCount) % g_worldRewards.size()] = request;
+    ++g_worldRewardCount;
+    return true;
+}
 
 /** 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)});
-    }
+    arm_account_resync_elsewhere(origin);
 }
 /** @param id Nonzero connection id. @return Matching open session, or null. */
 [[nodiscard]] Session* session_for(std::uint32_t id) noexcept {
@@ -104,6 +135,19 @@ same_destination(const state::activity::destination::DestinationSelection& left,
            && left.hasDescriptorName == right.hasDescriptorName;
 }
 
+/**
+ * Tests the world-package identity used by the HUD and generated scenario layout.
+ * Public targets are separate selections and may legitimately carry a different reason, nonce,
+ * activity index or descriptor while still naming the same loaded destination. Those fields are
+ * binding identity, not evidence that two ActivityClients belong to different maps.
+ */
+[[nodiscard]] bool
+same_destination_package(const state::activity::destination::DestinationSelection& left,
+                         const state::activity::destination::DestinationSelection& right) noexcept {
+    return left.packageNameLength != 0 && left.packageNameLength == right.packageNameLength
+           && left.packageName == right.packageName;
+}
+
 /** Finds one exact authenticated ActivityClient while the caller owns the BAP lock. */
 [[nodiscard]] const Session*
 unique_activity_link_locked(const state::activity::SessionBinding& binding,
@@ -803,19 +847,119 @@ void clear_session(Session& session) noexcept {
 [[nodiscard]] bool consume_poll(const client::network::BapRequest& request,
                                 client::network::BapResponse& response,
                                 bool& touchesScratch) noexcept {
-    static std::atomic_bool reported{false};
-    if (!reported.exchange(true, std::memory_order_relaxed)) {
-        core::log::write(
-            core::log::Channel::server, core::log::Level::info, "ev=queuez stage=poll result=ok");
-    }
     auto* session = session_for(request.connectionId);
-    return session != nullptr
-           && encrypted::consume_deferred(
-               *session, g_scratch, request.response, response.size, touchesScratch);
+    if (session == nullptr) {
+        return false;
+    }
+    // The purchase response carries the Family-4 ownership rows. Refresh Family 5 only after the
+    // client has consumed that response, so derived artifact state never mixes adjacent purchases.
+    if (session->artifactRefreshArmed) {
+        state::InvestmentState investment{};
+        if (state::investment_snapshot(investment)
+            && client::hooks::network::investment::publish_live_family5(investment.family5)) {
+            session->artifactRefreshArmed = false;
+        }
+    }
+    return encrypted::consume_deferred(
+        *session, g_scratch, request.response, response.size, touchesScratch);
 }
 
 } // namespace
 
+void arm_account_resync_elsewhere(Session& origin) noexcept {
+    for (auto& peer : g_sessions) {
+        if (&peer != &origin && peer.id != 0 && peer.authenticated && peer.queuez.family4Active) {
+            peer.accountResyncArmed = true;
+        }
+    }
+}
+
+/** Arms every active peer to re-read the account, including the origin. */
+void arm_account_resync_everywhere() noexcept {
+    for (auto& peer : g_sessions) {
+        if (peer.id == 0 || !peer.authenticated || !peer.queuez.family4Active) {
+            continue;
+        }
+        peer.accountResyncArmed = true;
+    }
+}
+
+void arm_acquisition_presentation_hold(Session& session) noexcept {
+    const std::uint64_t now = GetTickCount64();
+    if (now >= session.acquisitionPresentationUntilTick) {
+        session.acquisitionPresentationRows = {};
+        session.acquisitionPresentationRowCount = 0;
+    }
+    session.acquisitionPresentationUntilTick =
+        (std::max)(session.acquisitionPresentationUntilTick, now + kAcquisitionPresentationHoldMs);
+}
+
+bool arm_world_item_acquisition(std::uint16_t itemDefinitionIndex) noexcept {
+    return enqueue_world_reward({1, itemDefinitionIndex, WorldRewardKind::item});
+}
+
+bool arm_world_profile_item_acquisition(std::uint16_t itemDefinitionIndex,
+                                        std::int32_t quantity) noexcept {
+    if (quantity <= 0) {
+        return false;
+    }
+    return enqueue_world_reward({quantity, itemDefinitionIndex, WorldRewardKind::profileItem});
+}
+
+bool current_world_reward(WorldRewardRequest& request) noexcept {
+    if (g_worldRewardCount == 0) {
+        request = {};
+        return false;
+    }
+    request = g_worldRewards[g_worldRewardHead];
+    return true;
+}
+
+void complete_world_reward() noexcept {
+    if (g_worldRewardCount == 0) {
+        return;
+    }
+    pop_world_reward();
+}
+
+void fail_world_reward_attempt() noexcept {
+    if (g_worldRewardCount == 0
+        || ++g_worldRewards[g_worldRewardHead].failures < kWorldRewardFailureLimit) {
+        return;
+    }
+    const bool committed = commit_world_reward(g_worldRewards[g_worldRewardHead]);
+    pop_world_reward();
+    if (committed) {
+        arm_account_resync_everywhere();
+    }
+    core::log::write(core::log::Channel::server,
+                     core::log::Level::warn,
+                     committed ? "ev=world_reward stage=retry_limit result=direct"
+                               : "ev=world_reward stage=retry_limit result=drop");
+}
+
+bool arm_seasonal_experience_presentation(std::int32_t amount) noexcept {
+    if (amount <= 0) {
+        return false;
+    }
+    for (auto& peer : g_sessions) {
+        if (peer.id == 0 || !peer.authenticated || !peer.queuez.family4Active
+            || peer.pendingSeasonalExperienceAmount
+                   > (std::numeric_limits<std::int32_t>::max)() - amount) {
+            continue;
+        }
+        if (!state::progression::seasonal_experience::grant(amount)) {
+            return false;
+        }
+        peer.pendingSeasonalExperienceAmount += amount;
+        peer.pendingSeasonalExperienceFailures = 0;
+        return true;
+    }
+    return false;
+}
+
+/** Applies one serialized BAP connection lifecycle event. */
+
 /** Finds one unambiguous registry identity in a committed connection-local roster map. */
 const RosterDecodeEntry* find_roster_decode_entry(const RosterDecodeMap& map,
                                                   std::uint64_t expectedBindingGeneration,
@@ -1021,26 +1165,63 @@ bool current_activity_link_view(std::int32_t localSliceSet,
     output = {};
     const Session* only = nullptr;
     const Session* matched = nullptr;
+    const Session* coherent = nullptr;
+    const Session* privateCurrent = nullptr;
+    bool oneDestination = true;
     const std::shared_lock lock(g_lock);
     for (const Session& session : g_sessions) {
         if (session.id == 0 || !session.authenticated
             || session.activity.role == ActivityClientRole::none
             || session.activity.bindingGeneration == 0
-            || !state::activity::binding_matches(session.activity.session)
-            || !state::activity::binding_matches(session.activity.source)) {
+            || !state::activity::binding_matches(session.activity.session)) {
             continue;
         }
         only = &session;
         ++output.activeLinks;
+        if (session.activity.role == ActivityClientRole::privateCurrent
+            && (privateCurrent == nullptr
+                || session.activity.bindingGeneration
+                       > privateCurrent->activity.bindingGeneration)) {
+            privateCurrent = &session;
+        }
+        if (coherent == nullptr) {
+            coherent = &session;
+        } else {
+            oneDestination =
+                oneDestination
+                && same_destination_package(coherent->activity.session.destination,
+                                            session.activity.session.destination);
+            // Prefer the persistent private-current link for destination metadata. Public targets
+            // are disposable region views and can overlap while the client changes bubbles.
+            if ((session.activity.role == ActivityClientRole::privateCurrent
+                 && coherent->activity.role != ActivityClientRole::privateCurrent)
+                || (session.activity.role == coherent->activity.role
+                    && session.activity.bindingGeneration
+                           > coherent->activity.bindingGeneration)) {
+                coherent = &session;
+            }
+        }
         if (localSliceSet >= 0 && selected_region_locked(session).index == localSliceSet) {
-            matched = &session;
             ++output.matchingRegions;
+            // A public region may be represented by several overlapping links. Prefer the stable
+            // private-current owner; within one role, use the newest binding.
+            const bool moreSpecific =
+                matched == nullptr
+                || (session.activity.role == ActivityClientRole::privateCurrent
+                    && matched->activity.role != ActivityClientRole::privateCurrent)
+                || (session.activity.role == matched->activity.role
+                    && session.activity.bindingGeneration
+                           > matched->activity.bindingGeneration);
+            if (moreSpecific) {
+                matched = &session;
+            }
         }
     }
-    const Session* const selected = output.matchingRegions == 1 ? matched
-                                    : output.matchingRegions == 0 && output.activeLinks == 1
-                                        ? only
-                                        : nullptr;
+    const Session* const selected = privateCurrent != nullptr ? privateCurrent
+                                    : matched != nullptr       ? matched
+                                    : output.activeLinks == 1 ? only
+                                    : oneDestination          ? coherent
+                                                              : nullptr;
     if (selected != nullptr) {
         output.binding = selected->activity.session;
         output.activityClientGeneration = selected->activity.bindingGeneration;
@@ -1674,6 +1855,14 @@ bool session_channel(std::uint32_t connectionId,
 /** Securely erases every connection-owned nonce and transform buffer. */
 void shutdown() noexcept {
     const std::lock_guard lock(g_lock);
+    while (g_worldRewardCount != 0) {
+        if (!commit_world_reward(g_worldRewards[g_worldRewardHead])) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             "ev=world_reward stage=shutdown result=drop");
+        }
+        pop_world_reward();
+    }
     for (auto& session : g_sessions) {
         if (session.id != 0
             && session.matchmakingContext.generation != state::matchmaking::kInvalidGeneration) {
@@ -1685,8 +1874,10 @@ void shutdown() noexcept {
         }
     }
     SecureZeroMemory(g_sessions.data(), sizeof g_sessions);
+    g_worldRewards = {};
+    g_worldRewardHead = 0;
+    g_worldRewardCount = 0;
     SecureZeroMemory(&g_scratch, sizeof g_scratch);
-    g_accountGeneration = 0;
 }
 
 } // namespace sunrise::server::bap

+ 0 - 27
Sunrise/src/server/bap/encrypted/activity_host_manager/activity_host_manager_route.cpp

@@ -53,32 +53,6 @@ choose_copy(const request_selection::ActivityManagerSelectionResult& parsed) noe
     return !primaryKnown && secondaryKnown ? parsed.secondary : parsed.selection;
 }
 
-/**
- * Reports the destination the client asked for.
- * Without it a wrong destination only shows up several messages later, as a wrong roster.
- * @param source Parsed selection carrying a package name.
- */
-void report_selection(const request_selection::ActivityManagerSelection& source) noexcept {
-    std::array<char, core::log::kLineCapacity> line{};
-    const int written =
-        std::snprintf(line.data(),
-                      line.size(),
-                      "ev=bap svc=6 stage=selection result=ok name=%.*s activity=%d "
-                      "from_activity=%d reason=%d bubble=0x%X spawn=0x%X",
-                      static_cast<int>(source.packageNameLength),
-                      reinterpret_cast<const char*>(source.packageName.data()),
-                      static_cast<int>(source.activityIndex),
-                      static_cast<int>(source.sourceActivityIndex),
-                      static_cast<int>(source.reason),
-                      source.hasArrivalBubbleHash ? source.arrivalBubbleHash : 0U,
-                      source.hasSpawnSetHash ? source.spawnSetHash : 0U);
-    if (written > 0) {
-        core::log::write(core::log::Channel::server,
-                         core::log::Level::info,
-                         {line.data(), static_cast<std::size_t>(written)});
-    }
-}
-
 /**
  * Reports the destination an operator forced, so a run shows where the client was sent.
  * @param forced Destination the forced selection produced.
@@ -134,7 +108,6 @@ prepare_allocation(const request_selection::ActivityManagerSelectionResult& pars
         }
         return state::activity::prepare_session(sessionId, allocation);
     }
-    report_selection(source);
     state::activity::destination::DestinationSelection destination{};
     destination.packageName = source.packageName;
     destination.packageNameLength = source.packageNameLength;

+ 5 - 1
Sunrise/src/server/bap/encrypted/activity_message/activity_message_route.cpp

@@ -9,6 +9,7 @@
 
 #include "../../../../core/logging/log.h"
 #include "../../../../core/settings/settings.h"
+#include "../../../../client/hooks/bootflow/bootflow_hook_lifecycle.h"
 #include "../../../../middleware/bap/activity_message/activity_client_identity_parser.h"
 #include "../../../../middleware/bap/activity_message/activity_join_request_parser.h"
 #include "../../../../middleware/bap/activity_message/activity_membership_acknowledgement_parser.h"
@@ -431,7 +432,10 @@ void report_message(std::uint32_t messageType,
     } else if (plan.bindingIntent == BindingIntent::preserveCurrent) {
         // A private join's burst carries the seed membership; the commit lands the same seed.
         static_cast<void>(push::activity::prepare_join_seed_snapshot(
-            parsed.memberKey, parsed.characterSoid, plan.membershipMutation));
+            plan.targetBinding.createdRevision,
+            parsed.memberKey,
+            parsed.characterSoid,
+            plan.membershipMutation));
     }
     return true;
 }

+ 12 - 42
Sunrise/src/server/bap/encrypted/activity_message/membership/activity_membership_route.cpp

@@ -76,33 +76,6 @@ make_identity(const service::client_identity::ClientIdentity& parsed) noexcept {
     return update;
 }
 
-/**
- * Reports one client identity update.
- * Without it, a run where the client sent its own identity looks the same as one where message 12
- * shipped the seeded fallback all the way.
- * @param parsed Typed identity the client sent.
- */
-void report_identity(const service::client_identity::ClientIdentity& parsed) noexcept {
-    std::array<char, core::log::kLineCapacity> line{};
-    const int written =
-        std::snprintf(line.data(),
-                      line.size(),
-                      "ev=activity stage=identity result=ok key=0x%llX field1=%d field2=%d "
-                      "field3=0x%llX acct=0x%llX character=0x%llX field6=0x%llX",
-                      static_cast<unsigned long long>(parsed.memberKey),
-                      static_cast<int>(parsed.field1),
-                      static_cast<int>(parsed.field2),
-                      static_cast<unsigned long long>(parsed.field3),
-                      static_cast<unsigned long long>(parsed.accountSoid),
-                      static_cast<unsigned long long>(parsed.field5),
-                      static_cast<unsigned long long>(parsed.field6));
-    if (written > 0) {
-        core::log::write(core::log::Channel::server,
-                         core::log::Level::info,
-                         {line.data(), static_cast<std::size_t>(written)});
-    }
-}
-
 } // namespace
 
 /** Stages every accepted identity with its exact reflected membership snapshot. */
@@ -111,7 +84,6 @@ bool prepare_identity(const service::Request& request, ActivityPlan& plan) noexc
     if (!service::client_identity::parse_client_identity(request.payload, parsed)) {
         return false;
     }
-    report_identity(parsed);
     if (!membership_state::prepare_identity(
             request.sessionId, make_identity(parsed), plan.membershipMutation)) {
         core::log::write(core::log::Channel::server,
@@ -207,21 +179,19 @@ bool prepare_start_activity(const service::Request& request, ActivityPlan& plan)
     const bool routed =
         parsed.destinationActivityIndex >= 0
         && parsed.destinationActivityIndex <= state::activity::destination::kMaximumActivityIndex;
-    std::array<char, core::log::kLineCapacity> line{};
-    const int written = std::snprintf(line.data(),
-                                      line.size(),
-                                      "ev=activity stage=start_activity result=%s from=%d to=%d "
-                                      "tail=%u",
-                                      routed ? "accepted" : "out_of_range",
-                                      parsed.sourceActivityIndex,
-                                      parsed.destinationActivityIndex,
-                                      parsed.tailBits);
-    if (written > 0) {
-        core::log::write(core::log::Channel::server,
-                         routed ? core::log::Level::info : core::log::Level::warn,
-                         {line.data(), static_cast<std::size_t>(written)});
-    }
     if (!routed) {
+        std::array<char, core::log::kLineCapacity> line{};
+        const int written =
+            std::snprintf(line.data(),
+                          line.size(),
+                          "ev=activity stage=start_activity result=out_of_range from=%d to=%d",
+                          parsed.sourceActivityIndex,
+                          parsed.destinationActivityIndex);
+        if (written > 0) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             {line.data(), static_cast<std::size_t>(written)});
+        }
         return false;
     }
     // The request carries no revision or bubble of its own. So the refresh guard is built from

+ 525 - 28
Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_message_receipts.cpp

@@ -1,4 +1,4 @@
-/**
+/**
  * Framing handlers for every activity message that changes no State.
  * Each one reads as much of its body as the known grammar reaches, and reports what it saw.
  * It returns how completely the body was read, so the caller can record one arrival receipt.
@@ -13,7 +13,10 @@
 #include <cstddef>
 #include <cstdint>
 #include <cstdio>
+#include <span>
+#include <string_view>
 
+#include "../../../../../client/player/player_position.h"
 #include "../../../../../core/logging/log.h"
 #include "../../../../../middleware/bap/activity_message/activity_client_keepalive_validator.h"
 #include "../../../../../middleware/bap/activity_message/entity_authority.h"
@@ -22,7 +25,17 @@
 #include "../../../../../middleware/bap/activity_message/sense_update.h"
 #include "../../../../../middleware/bap/activity_message/start_activity.h"
 #include "../../../../../middleware/bap/activity_message/telemetry.h"
+#include "../../../../../middleware/crypto/random_bytes.h"
 #include "../../../../../middleware/encoding/byte_order.h"
+#include "../../../../../state/activity/destination/activity_destination_snapshot.h"
+#include "../../../../../state/activity/membership/activity_membership_query.h"
+#include "../../../../../state/build_data/runtime.h"
+#include "../../../../../state/build_data/sobjects/sobject_catalog.h"
+#include "../../../../../state/lore/lore_grant.h"
+#include "../../../../../state/progression/seasonal_experience.h"
+#include "../../../../../state/record_claims/record_claims.h"
+#include "../../../../../state/runtime/runtime.h"
+#include "../../../../bap/internal.h"
 
 namespace sunrise::server::bap::encrypted::activity_message::receipts {
 namespace {
@@ -138,6 +151,498 @@ void report(core::log::Level level, const char* format, ...) noexcept {
     return Verdict::malformed;
 }
 
+struct PositionGrant {
+    std::array<float, 3> position;
+    std::uint16_t record;
+};
+
+struct LoreOrdinalRange {
+    std::uint16_t firstOrdinal;
+    std::uint16_t lastOrdinal;
+    std::uint16_t firstRecord;
+};
+
+constexpr std::string_view kDerelictPackage = "pandora_freeroam";
+constexpr std::string_view kMenageriePackage = "caluseum_experience";
+constexpr std::string_view kTributeHallPackage = "trophy_hall_freeroam";
+constexpr std::string_view kDreamingCityPackage = "dreaming_city_freeroam";
+constexpr std::string_view kMoonPackage = "luna_freeroam";
+constexpr std::uint32_t kGenericInteractionTarget = 3539U;
+constexpr std::array<PositionGrant, 9> kDerelictGrants{{
+    {{-40.861F, 147.410F, -2312.313F}, 1571U},   // The Bone
+    {{-681.393F, -859.831F, -8.590F}, 1575U},    // The Declaration
+    {{2.871F, 236.277F, -2306.442F}, 1569U},     // The Red Box
+    {{7.362F, 237.020F, -2306.704F}, 1572U},     // The Kell
+    {{9.792F, 149.124F, -2319.657F}, 1570U},     // The Stacks
+    {{-205.802F, -80.132F, -11.900F}, 1574U},    // The Gate
+    {{-249.759F, 6.664F, -17.853F}, 1573U},      // The Leviathan
+    {{-876.144F, -874.570F, 11.781F}, 1576U},    // The Nine
+    {{-1323.416F, -534.063F, -298.928F}, 1577U}, // The Witch
+}};
+constexpr std::array<PositionGrant, 8> kMenagerieGrants{{
+    {{30.559F, 31.233F, -2.185F}, 1708U},
+    {{57.683F, 8.844F, -43.121F}, 1709U},
+    {{61.939F, 220.947F, 2.439F}, 1710U},
+    {{143.082F, -23.093F, 11.629F}, 1711U},
+    {{109.207F, 211.327F, -144.309F}, 1712U},
+    {{403.643F, -5.111F, 6.669F}, 1713U},
+    {{947.203F, 2.456F, 131.591F}, 1714U},
+    {{1138.881F, 89.454F, 94.136F}, 1715U},
+}};
+constexpr std::array<PositionGrant, 1> kTributeHallGrants{{
+    {{25.642F, 0.012F, 5.922F}, 1716U},
+}};
+
+constexpr std::uint16_t kDroneFirstOrdinal = 2455U;
+constexpr std::array<std::uint16_t, 16> kDroneRecords{
+    740U,
+    741U,
+    742U,
+    744U,
+    746U,
+    747U,
+    748U,
+    749U,
+    750U,
+    751U,
+    752U,
+    753U,
+    754U,
+    755U,
+    756U,
+    757U,
+};
+constexpr std::array<LoreOrdinalRange, 4> kLoreOrdinalRanges{{
+    {2471U, 2493U, 802U},  // Dead Ghosts
+    {2494U, 2516U, 778U},  // Awoken crystals
+    {2517U, 2532U, 759U},  // Ahamkara bones
+    {3310U, 3319U, 1841U}, // Luna's Lost ghosts
+}};
+constexpr std::uint16_t kMoonGhostFirstOrdinal = 3310U;
+constexpr std::uint16_t kMoonGhostLastOrdinal = 3319U;
+constexpr std::uint16_t kMoonDestinationLastOrdinal = 3318U;
+constexpr std::uint16_t kLunasLostAreFoundFlag = 10698U;
+
+[[nodiscard]] constexpr bool grant_changed(state::lore::GrantOutcome outcome) noexcept {
+    return outcome == state::lore::GrantOutcome::granted
+           || outcome == state::lore::GrantOutcome::progressed;
+}
+
+[[nodiscard]] constexpr bool
+progress_changed(state::record_claims::ObjectiveAdvance outcome) noexcept {
+    return outcome == state::record_claims::ObjectiveAdvance::advanced
+           || outcome == state::record_claims::ObjectiveAdvance::completed;
+}
+
+[[nodiscard]] constexpr bool record_resolved(state::lore::GrantOutcome outcome) noexcept {
+    return outcome != state::lore::GrantOutcome::recordNotFound
+           && outcome != state::lore::GrantOutcome::notAChapter;
+}
+
+[[nodiscard]] bool lore_record_for_ordinal(std::uint16_t ordinal, std::uint16_t& record) noexcept {
+    if (ordinal >= kDroneFirstOrdinal) {
+        const auto index = static_cast<std::size_t>(ordinal - kDroneFirstOrdinal);
+        if (index < kDroneRecords.size()) {
+            record = kDroneRecords[index];
+            return true;
+        }
+    }
+    for (const LoreOrdinalRange& range : kLoreOrdinalRanges) {
+        if (ordinal >= range.firstOrdinal && ordinal <= range.lastOrdinal) {
+            record = static_cast<std::uint16_t>(range.firstRecord + ordinal - range.firstOrdinal);
+            return true;
+        }
+    }
+    return false;
+}
+
+/** Resolves the shared generic interaction target by package and measured position. */
+void resolve_position_grant(const client::player::position::Snapshot& player,
+                            std::string_view package) noexcept {
+    constexpr float kRadiusSquared = 36.0F;
+    if (!player.present) {
+        return;
+    }
+    std::span<const PositionGrant> grants;
+    if (package == kDerelictPackage) {
+        grants = kDerelictGrants;
+    } else if (package == kMenageriePackage) {
+        grants = kMenagerieGrants;
+    } else if (package == kTributeHallPackage) {
+        grants = kTributeHallGrants;
+    } else {
+        return;
+    }
+    for (const PositionGrant& grant : grants) {
+        const float dx = player.position[0] - grant.position[0];
+        const float dy = player.position[1] - grant.position[1];
+        const float dz = player.position[2] - grant.position[2];
+        if (dx * dx + dy * dy + dz * dz > kRadiusSquared) {
+            continue;
+        }
+        const auto outcome = state::lore::grant_record(grant.record);
+        if (grant_changed(outcome)) {
+            bap::arm_account_resync_everywhere();
+        }
+        return;
+    }
+}
+
+/** Grants the nine Phantasmal Fragments paid by one completed Lost Ghost search. */
+void grant_lost_ghost_reward() noexcept {
+    constexpr std::uint32_t kPhantasmalFragmentHash = 443031982U;
+    constexpr std::int32_t kRewardQuantity = 9;
+    state::build_data::items::Definition definition{};
+    if (!state::build_data::find_item_definition_hash(kPhantasmalFragmentHash, definition)
+        || !bap::arm_world_profile_item_acquisition(definition.definitionIndex, kRewardQuantity)) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::warn,
+                         "ev=world_reward kind=lost_ghost result=fail");
+    }
+}
+
+/** Grants one installed world weapon or active-class armour piece from the supplied pool. */
+void grant_random_world_loot(std::span<const std::uint32_t> weapons,
+                             std::span<const std::uint32_t> titanArmour,
+                             std::span<const std::uint32_t> hunterArmour,
+                             std::span<const std::uint32_t> warlockArmour) noexcept {
+    if (weapons.empty() || titanArmour.size() != hunterArmour.size()
+        || titanArmour.size() != warlockArmour.size()) {
+        return;
+    }
+    const state::AccountState account = state::account_snapshot();
+    std::span<const std::uint32_t> armour;
+    for (std::size_t index = 0; index < account.characterCount; ++index) {
+        if (!account.characters[index].selected) {
+            continue;
+        }
+        switch (account.characters[index].characterClass) {
+        case state::CharacterClass::hunter:
+            armour = hunterArmour;
+            break;
+        case state::CharacterClass::warlock:
+            armour = warlockArmour;
+            break;
+        case state::CharacterClass::titan:
+        default:
+            armour = titanArmour;
+            break;
+        }
+        break;
+    }
+    const std::size_t hashCount = weapons.size() + armour.size();
+    std::array<std::byte, sizeof(std::uint32_t)> randomBytes{};
+    if (!middleware::crypto::random::fill(randomBytes)) {
+        return;
+    }
+    const std::uint32_t randomValue = middleware::encoding::read_u32_le(randomBytes);
+    const std::size_t first = randomValue % hashCount;
+    for (std::size_t offset = 0; offset < hashCount; ++offset) {
+        const std::size_t index = (first + offset) % hashCount;
+        const std::uint32_t hash =
+            index < weapons.size() ? weapons[index] : armour[index - weapons.size()];
+        state::build_data::items::Definition definition{};
+        if (!state::build_data::find_item_definition_hash(hash, definition)) {
+            continue;
+        }
+        if (!bap::arm_world_item_acquisition(definition.definitionIndex)) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             "ev=world_reward kind=item result=fail");
+        }
+        return;
+    }
+}
+
+/** Grants one installed Dreaming City weapon or active-class Reverie Dawn armour piece. */
+void grant_random_dreaming_city_loot() noexcept {
+    static constexpr std::array<std::uint32_t, 7> kWeapons{
+        640114618U,
+        334171687U,
+        346136302U,
+        3242168339U,
+        3297863558U,
+        3740842661U,
+        1644160541U,
+    };
+    static constexpr std::array<std::uint32_t, 5> kTitanArmour{
+        4097166900U,
+        2503434573U,
+        4070309619U,
+        3174233615U,
+        1980768298U,
+    };
+    static constexpr std::array<std::uint32_t, 5> kHunterArmour{
+        2824453288U,
+        1705856569U,
+        1593474975U,
+        344548395U,
+        3306564654U,
+    };
+    static constexpr std::array<std::uint32_t, 5> kWarlockArmour{
+        185695659U,
+        2761343386U,
+        2859583726U,
+        188778964U,
+        3602032567U,
+    };
+    grant_random_world_loot(kWeapons, kTitanArmour, kHunterArmour, kWarlockArmour);
+}
+
+/** Grants one installed Moon weapon or active-class Dreambane armour piece. */
+void grant_random_moon_loot() noexcept {
+    static constexpr std::array<std::uint32_t, 9> kWeapons{
+        2723909519U,
+        2931957300U,
+        3924212056U,
+        1016668089U,
+        1645386487U,
+        3325778512U,
+        4277547616U,
+        3870811754U,
+        3690523502U,
+    };
+    static constexpr std::array<std::uint32_t, 5> kTitanArmour{
+        925079356U,
+        2568538788U,
+        3312368889U,
+        272413517U,
+        310888006U,
+    };
+    static constexpr std::array<std::uint32_t, 5> kHunterArmour{
+        3571441640U,
+        883769696U,
+        193805725U,
+        659922705U,
+        377813570U,
+    };
+    static constexpr std::array<std::uint32_t, 5> kWarlockArmour{
+        682780965U,
+        3692187003U,
+        2048903186U,
+        1528483180U,
+        1030110631U,
+    };
+    grant_random_world_loot(kWeapons, kTitanArmour, kHunterArmour, kWarlockArmour);
+}
+
+/** Identifies the shared generic target as a Dreaming City cat-statue interaction. */
+[[nodiscard]] bool is_dreaming_city_cat(bool definitionFound,
+                                        const state::build_data::sobjects::Definition& definition,
+                                        std::string_view packageName) noexcept {
+    constexpr std::uint32_t kCatNameHash = 0x7A0FD954U;
+    constexpr std::uint32_t kCatLane4 = 0x0011FFFFU;
+    if (!definitionFound || definition.typeCode != 2 || definition.nameHash != kCatNameHash
+        || definition.lane4 != kCatLane4) {
+        return false;
+    }
+    return packageName == kDreamingCityPackage;
+}
+
+/** Identifies a Jade Rabbit interaction by its generic target, statue ordinal, and Moon package. */
+[[nodiscard]] bool is_moon_rabbit(const message::incident::Incident& incident,
+                                  bool primaryFound,
+                                  const state::build_data::sobjects::Definition& primary,
+                                  std::string_view packageName) noexcept {
+    constexpr std::uint32_t kGenericNameHash = 0x7A0FD954U;
+    constexpr std::uint32_t kGenericLane4 = 0x0011FFFFU;
+    // The nine statues have different target indices and name hashes, but their type-code-2 world
+    // object ordinals form one dense run immediately before Luna's Lost ghosts.
+    constexpr std::uint16_t kFirstRabbitOrdinal = 3297U;
+    constexpr std::uint16_t kLastRabbitOrdinal = 3305U;
+    if (!primaryFound || primary.typeCode != 2 || primary.nameHash != kGenericNameHash
+        || primary.lane4 != kGenericLane4) {
+        return false;
+    }
+
+    bool hasRabbitTarget = false;
+    for (std::uint32_t index = 0; index < incident.extraTargetCount; ++index) {
+        const std::uint32_t target = incident.extraTargets[index];
+        state::build_data::sobjects::Definition rabbit{};
+        if (!state::build_data::sobjects::find(static_cast<std::uint16_t>(target), rabbit)
+            || rabbit.typeCode != 2 || rabbit.recordRow() != 0xFFFFU) {
+            continue;
+        }
+        const std::uint16_t ordinal = rabbit.loreObjectOrdinal();
+        hasRabbitTarget = ordinal >= kFirstRabbitOrdinal && ordinal <= kLastRabbitOrdinal;
+        if (hasRabbitTarget) {
+            break;
+        }
+    }
+    if (!hasRabbitTarget) {
+        return false;
+    }
+
+    return packageName == kMoonPackage;
+}
+
+/** Resolves the egg whose incident carries no per-object identity. */
+[[nodiscard]] state::lore::GrantOutcome
+resolve_egg_context(const client::player::position::Snapshot& player,
+                    std::string_view packageName) noexcept {
+    state::build_data::scenarios::Definition layout{};
+    const bool hasLayout = state::build_data::find_scenario_layout(packageName, layout);
+    const std::string_view stem{layout.spawnStem.data(), layout.spawnStemLength};
+    state::build_data::spawn_sets::Point point{};
+    float distance = 0.0F;
+    const bool hasSpawn =
+        player.present && hasLayout
+        && state::build_data::find_nearest_spawn_point(stem, player.position, point, distance);
+
+    // This egg reports no identity; its stable nearest spawn distinguishes it across loads.
+    constexpr std::uint32_t kDivalianSpawnHash = 0xE3D5F2D5U;
+    constexpr float kDivalianSpawnRadius = 16.0F;
+    constexpr std::uint16_t kImponentTwoRecord = 40;
+    if (player.present && packageName == kDreamingCityPackage && hasSpawn
+        && point.nameHash == kDivalianSpawnHash && distance <= kDivalianSpawnRadius) {
+        return state::lore::advance_record(kImponentTwoRecord);
+    }
+    return state::lore::GrantOutcome::recordNotFound;
+}
+
+/** Resolves an authored lore target and applies Moon ghost side effects once. */
+[[nodiscard]] state::lore::GrantOutcome resolve_lore_target(std::uint32_t target) noexcept {
+    state::build_data::sobjects::Definition definition{};
+    if (!state::build_data::sobjects::find(static_cast<std::uint16_t>(target), definition)) {
+        return state::lore::GrantOutcome::recordNotFound;
+    }
+
+    std::uint16_t record = 0;
+    std::uint16_t ordinal = 0;
+    if (definition.typeCode == 10) {
+        record = definition.recordRow();
+    } else if (definition.typeCode == 2) {
+        ordinal = definition.loreObjectOrdinal();
+        if (!lore_record_for_ordinal(ordinal, record)) {
+            return state::lore::GrantOutcome::recordNotFound;
+        }
+    } else {
+        return state::lore::GrantOutcome::recordNotFound;
+    }
+
+    const auto outcome = state::lore::grant_record(record);
+    if (outcome == state::lore::GrantOutcome::granted && ordinal >= kMoonGhostFirstOrdinal
+        && ordinal <= kMoonGhostLastOrdinal) {
+        if (ordinal <= kMoonDestinationLastOrdinal) {
+            (void)state::record_claims::advance_single_objective(kLunasLostAreFoundFlag);
+        }
+        grant_lost_ghost_reward();
+        constexpr std::int32_t kBaseExperienceReward = 2500;
+        const bool queued = bap::arm_seasonal_experience_presentation(kBaseExperienceReward);
+        if (!queued) {
+            (void)state::progression::seasonal_experience::grant(kBaseExperienceReward);
+        }
+    }
+    return outcome;
+}
+
+/** Applies collectible and gift side effects only after the incident's complete outer body passed. */
+void apply_incident_grants(const message::incident::Incident& incident) noexcept {
+    // Preserve the common-header size gate before acting on the incident.
+    if (incident.payloadLength < 13) {
+        return;
+    }
+
+    // Resolve exact identity first; location is reserved for objects carrying no identity.
+    constexpr std::uint32_t kCorruptedEggTarget = 693U;
+    constexpr std::uint32_t kCorruptedEggNameHash = 0x179A5E15U;
+    constexpr std::uint32_t kCorruptedEggLane4 = 0x0A06FFFFU;
+    state::build_data::sobjects::Definition primary{};
+    const bool primaryFound = state::build_data::sobjects::find(
+        static_cast<std::uint16_t>(incident.primaryTarget), primary);
+    const bool isCorruptedEgg =
+        incident.primaryTarget == kCorruptedEggTarget && primaryFound && primary.typeCode == 3
+        && primary.nameHash == kCorruptedEggNameHash && primary.lane4 == kCorruptedEggLane4;
+
+    std::uint32_t loreTarget = incident.primaryTarget;
+    state::lore::GrantOutcome lore = resolve_lore_target(loreTarget);
+    if (!record_resolved(lore)) {
+        for (std::uint32_t index = 0; index < incident.extraTargetCount; ++index) {
+            loreTarget = incident.extraTargets[index];
+            lore = resolve_lore_target(loreTarget);
+            if (record_resolved(lore)) {
+                break;
+            }
+        }
+    }
+
+    report(core::log::Level::info,
+           "ev=activity stage=incident_lore target=%u selected=%u extra=%u first=%u second=%u "
+           "third=%u fourth=%u outcome=%u",
+           incident.primaryTarget,
+           loreTarget,
+           incident.extraTargetCount,
+           incident.extraTargetCount > 0 ? incident.extraTargets[0] : 0U,
+           incident.extraTargetCount > 1 ? incident.extraTargets[1] : 0U,
+           incident.extraTargetCount > 2 ? incident.extraTargets[2] : 0U,
+           incident.extraTargetCount > 3 ? incident.extraTargets[3] : 0U,
+           static_cast<unsigned>(lore));
+
+    if (grant_changed(lore)) {
+        bap::arm_account_resync_everywhere();
+    }
+    if (record_resolved(lore)) {
+        if (isCorruptedEgg) {
+            grant_random_dreaming_city_loot();
+        }
+        return;
+    }
+    if (!isCorruptedEgg && incident.primaryTarget != kGenericInteractionTarget) {
+        return;
+    }
+
+    const auto player = client::player::position::snapshot();
+    // Incidents can arrive through the private activity while their object belongs to the public
+    // region. The pre-merge grant path intentionally followed the instantiated region instead.
+    const std::uint64_t destinationSession =
+        state::activity::membership::live_region_session(state::activity::kAbsentSessionId);
+    state::activity::destination::DestinationSelection destination{};
+    static_cast<void>(state::activity::destination::snapshot(destinationSession, destination));
+    const std::size_t packageLength =
+        (std::min)(static_cast<std::size_t>(destination.packageNameLength),
+                   destination.packageName.size());
+    const std::string_view packageName(
+        reinterpret_cast<const char*>(destination.packageName.data()), packageLength);
+
+    report(core::log::Level::info,
+           "ev=activity stage=incident_grant target=%u extra=%u lore=%u type=%d lane=0x%08X "
+           "egg=%u destination=0x%016llX package=%.*s player=%u",
+           incident.primaryTarget,
+           incident.extraTargetCount,
+           static_cast<unsigned>(lore),
+           primaryFound ? primary.typeCode : state::build_data::sobjects::kAbsentTypeCode,
+           primaryFound ? primary.lane4 : 0U,
+           static_cast<unsigned>(isCorruptedEgg),
+           static_cast<unsigned long long>(destinationSession),
+           static_cast<int>(packageName.size()),
+           packageName.data(),
+           static_cast<unsigned>(player.present));
+
+    if (isCorruptedEgg) {
+        const auto egg = resolve_egg_context(player, packageName);
+        grant_random_dreaming_city_loot();
+        if (grant_changed(egg)) {
+            bap::arm_account_resync_everywhere();
+        }
+    } else if (is_dreaming_city_cat(primaryFound, primary, packageName)) {
+        grant_random_dreaming_city_loot();
+        constexpr std::uint16_t kRememberYourMannersFlag = 9448U;
+        if (progress_changed(
+                state::record_claims::advance_single_objective(kRememberYourMannersFlag))) {
+            bap::arm_account_resync_everywhere();
+        }
+    } else if (is_moon_rabbit(incident, primaryFound, primary, packageName)) {
+        grant_random_moon_loot();
+        constexpr std::uint16_t kLetThemEatRiceCakesFlag = 10696U;
+        if (progress_changed(
+                state::record_claims::advance_single_objective(kLetThemEatRiceCakesFlag))) {
+            bap::arm_account_resync_everywhere();
+        }
+    } else if (incident.primaryTarget == kGenericInteractionTarget) {
+        resolve_position_grant(player, packageName);
+    }
+}
+
 } // namespace
 
 /** Records the synchronous schema-selected sensor decode. */
@@ -294,11 +799,6 @@ Framed frame_start_activity(const message::Request& request) noexcept {
     if (!start::parse_start_activity(request.payload, parsed, consumed)) {
         return {report_malformed("start_activity", request), consumed};
     }
-    report(core::log::Level::info,
-           "ev=activity stage=start_activity result=read from=%d to=%d tail=%u",
-           parsed.sourceActivityIndex,
-           parsed.destinationActivityIndex,
-           parsed.tailBits);
     return {parsed.tailBits == 0 ? Verdict::framed : Verdict::partial, consumed};
 }
 
@@ -323,9 +823,6 @@ Framed frame_reservation_release(const message::Request& request) noexcept {
     if (!ledger::parse_release(request.payload, release, consumed)) {
         return {report_malformed("reservation_release", request), consumed};
     }
-    report(core::log::Level::debug,
-           "ev=activity stage=reservation_release result=read peer=0x%016llX",
-           static_cast<unsigned long long>(release.peerKey));
     return {Verdict::framed, consumed};
 }
 
@@ -368,11 +865,8 @@ Framed frame_connectivity_failure(const message::Request& request) noexcept {
 }
 
 /** Records a client heartbeat as a bounded body. */
-Framed frame_heartbeat(const message::Request& request) noexcept {
+Framed frame_heartbeat(const message::Request&) noexcept {
     // One runtime-selected nested definition, so the declared service length is the only bound.
-    report(core::log::Level::debug,
-           "ev=activity stage=heartbeat result=bounded bytes=%zu",
-           request.payload.size());
     return {Verdict::partial, 0};
 }
 
@@ -425,8 +919,6 @@ Framed frame_high_water(const message::Request& request) noexcept {
     if (!telemetry::parse_high_water(request.payload, block, consumed)) {
         return {report_malformed("high_water", request), consumed};
     }
-    report(
-        core::log::Level::debug, "ev=activity stage=high_water result=framed bits=%zu", consumed);
     return {Verdict::framed, consumed};
 }
 
@@ -437,10 +929,6 @@ Framed frame_opaque_scalar(const message::Request& request) noexcept {
     if (!telemetry::parse_opaque_scalar(request.payload, value, consumed)) {
         return {report_malformed("scalar", request), consumed};
     }
-    report(core::log::Level::debug,
-           "ev=activity stage=scalar result=read type=%u value=%d",
-           request.messageType,
-           value);
     return {Verdict::framed, consumed};
 }
 
@@ -462,16 +950,16 @@ static Framed frame_incident_impl(const message::Request& request,
     incident::Incident parsed{};
     const incident::Verdict verdict = incident::validate(request.payload, parsed);
     const bool accepted = verdict == incident::Verdict::accepted;
-    report(accepted ? core::log::Level::debug : core::log::Level::warn,
-           "ev=activity stage=incident result=%s target=%u extra=%u selector=%u "
-           "optional=%u payload=%u",
-           incident::verdict_name(verdict),
-           parsed.primaryTarget,
-           parsed.extraTargetCount,
-           parsed.selectorLength,
-           static_cast<unsigned>(parsed.hasOptionalBlock),
-           parsed.payloadLength);
     if (!accepted) {
+        report(core::log::Level::warn,
+               "ev=activity stage=incident result=%s target=%u extra=%u selector=%u "
+               "optional=%u payload=%u",
+               incident::verdict_name(verdict),
+               parsed.primaryTarget,
+               parsed.extraTargetCount,
+               parsed.selectorLength,
+               static_cast<unsigned>(parsed.hasOptionalBlock),
+               parsed.payloadLength);
         // A refused target index would index the consumer's table unbounded, so the body is kept
         // and never relayed.
         const Verdict outcome = verdict == incident::Verdict::targetPoisoned
@@ -483,6 +971,15 @@ static Framed frame_incident_impl(const message::Request& request,
     if (output != nullptr) {
         *output = parsed;
     }
+    apply_incident_grants(parsed);
+    report(core::log::Level::debug,
+           "ev=activity stage=incident result=accepted target=%u extra=%u selector=%u "
+           "optional=%u payload=%u",
+           parsed.primaryTarget,
+           parsed.extraTargetCount,
+           parsed.selectorLength,
+           static_cast<unsigned>(parsed.hasOptionalBlock),
+           parsed.payloadLength);
     return {Verdict::framed, parsed.consumedBits};
 }
 

+ 21 - 6
Sunrise/src/server/bap/encrypted/activity_transaction/activity_transaction_notifications.cpp

@@ -1,6 +1,5 @@
 #include "activity_transaction_notifications.h"
 
-#include "../../../../core/logging/log.h"
 #include "../../../gameplay/gameplay_advertisement.h"
 #include "../push/activity/activity_arrival.h"
 #include "../push/activity/activity_global_state_push.h"
@@ -29,15 +28,13 @@ namespace {
     // Take the delta's region, not the committed one. Staging runs before the commit, so the
     // committed value still names the region the player has left.
     const push::activity::EffectiveRegion region =
-        push::activity::planned_region(activity.membershipMutation, session.activity.source);
+        push::activity::private_planned_region(activity.membershipMutation,
+                                               session.activity.source);
     const server::gameplay::AdvertisementState state =
         push::activity::region_advertisement(session, region.index);
     if (state != server::gameplay::AdvertisementState::pending) {
         return false;
     }
-    core::log::write(core::log::Channel::server,
-                     core::log::Level::debug,
-                     "ev=gameplay stage=membership result=held reason=no_host_session");
     return true;
 }
 
@@ -126,11 +123,29 @@ namespace {
         // must use the prepared move, never the old committed msg-22 region.
         const push::activity::EffectiveRegion region =
             push::activity::planned_region(activity.membershipMutation, session.activity.source);
+        // This response is staged before the authoritative transaction commits. Carry the
+        // incoming current leg into readiness evaluation as well as the selected roster region;
+        // otherwise the body repeats the stale loading lifetime even though this very report says
+        // the slice set is now instantiated. No later edge is guaranteed to republish that field.
+        push::activity::RefreshReport report{};
+        report.currentRegion =
+            activity.membershipMutation.authoritativeInput.currentRegion.index;
+        report.hasCurrentRegion =
+            activity.membershipMutation.authoritativeInput.hasCurrentRegion;
         // The client reports the region it now holds once its slice set is instantiated, and the
         // roster is that report's answer. It is solicited, so it is never skipped as a repeat,
         // including while the slice set is still instantiating.
         staged = push::activity::append_roster_notification(
-                     session, scratch, key, nonce, response, written, nullptr, &region, true)
+                     session,
+                     scratch,
+                     key,
+                     nonce,
+                     response,
+                     written,
+                     nullptr,
+                     &region,
+                     true,
+                     &report)
                  || staged;
     }
     // A delta that owed nothing is not a failure, and a public link never owes the block above.

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

@@ -1,6 +1,7 @@
-#include <array>
 #include <atomic>
+#include <array>
 #include <cstdio>
+#include <limits>
 
 #include "../../../../core/logging/log.h"
 #include "../../../../middleware/bap/account_translation/account_translation_response.h"
@@ -25,21 +26,20 @@
 namespace sunrise::server::bap::encrypted::body {
 namespace {
 
-/** One line carries the family and the root soid and nothing else. */
-constexpr std::size_t kSubscribeReportLimit = 96;
-/**
- * Identity already paired with the account soid, or zero before the first pairing.
- * There is one account, so this is process-wide rather than per connection.
- */
+/** Process-wide identity paired with the single account SOID. */
 std::atomic<std::uint64_t> g_translatedIdentity{0};
 
-/**
- * Reports whether one svc-23 request may be paired with the account soid.
- * The reply writes the soid into a queuez roster member. Two identities on one soid put two
- * family-zero source entries on it, and every lookup then resolves only the first.
- * @param identity Validated nonzero svc-23 identity.
- * @return True when this identity is the one paired, or the first to ask.
- */
+/** Replaces an optimistic Web Service reply when its deferred transaction cannot be staged. */
+[[nodiscard]] bool refuse_web_action(const middleware::web_service::Message& message,
+                                     std::span<std::byte> output,
+                                     std::size_t& written) noexcept {
+    middleware::web_service::StatusResponse status{};
+    status.code = 1;
+    return middleware::web_service::encode_response(
+        message, middleware::web_service::ResponseShape::statusPair, status, output, written);
+}
+
+/** Accepts only the first account-translation identity and its retries. */
 [[nodiscard]] bool pairs_identity(std::uint64_t identity) noexcept {
     std::uint64_t claimed = 0;
     // A repeat of the same identity still pairs: the peer re-asks until the flag sticks.
@@ -55,7 +55,6 @@ std::atomic<std::uint64_t> g_translatedIdentity{0};
  * @param route Service route data found earlier.
  * @param queuezState Queuez versions and residents set up by this BAP peer.
  * @param activity Exact ActivityClient generation owned by this BAP session.
- * @param rosterDecode Last complete msg-5 identity map delivered on this connection.
  * @param matchmakingContext State-owned logical context for this BAP session.
  * @param requestBody Borrowed decrypted request body.
  * @param output Caller-owned response-body storage.
@@ -83,9 +82,8 @@ bool process(const ServiceRoute& route,
         server::gameplay::group::HostSessionBinding host{};
         const bool parsed =
             middleware::bap::account_translation::request_identity(requestBody, identity);
-        // A host peer has no platform handle, so the client asks by identity zero on the primary
-        // link. The answer is the live region session id, a non-zero soid the peer watcher accepts.
-        // A remote msg-12 row is asked by activity-session id and answers with the row's soid.
+        // The primary host asks with identity zero and receives the live region session. Remote
+        // activity rows ask by activity-session id. Only other nonzero identities pair to account.
         const std::uint64_t liveSession =
             state::activity::membership::live_region_session(state::activity::kAbsentSessionId);
         const bool zeroHandle =
@@ -96,7 +94,6 @@ bool process(const ServiceRoute& route,
             && host.target.sessionId == identity;
         const bool accountIdentity =
             parsed && identity != 0 && !logicalHost && pairs_identity(identity);
-        // A zero SOID writes the valid zero-entry reply, so an unknown identity never stalls.
         const std::uint64_t soid = zeroHandle        ? liveSession
                                    : logicalHost     ? host.target.sessionId
                                    : accountIdentity ? account.primarySoid
@@ -121,23 +118,29 @@ bool process(const ServiceRoute& route,
             requestBody, soid, output, written);
     }
     case BodyCodec::activityHostManagerResponse: {
-        state::activity::PendingAllocation allocation{};
+        auto* allocation = emplace_transaction<state::activity::PendingAllocation>(outcome);
+        if (allocation == nullptr) {
+            return false;
+        }
         bool hasAllocation = false;
         const bool encoded = activity_host_manager::encode_response(
-            requestBody, output, written, allocation, hasAllocation);
-        if (encoded && hasAllocation) {
-            outcome.transaction = allocation;
+            requestBody, output, written, *allocation, hasAllocation);
+        if (!encoded || !hasAllocation) {
+            clear_transaction(outcome);
         }
         return encoded;
     }
     case BodyCodec::activityMessageRequest: {
         written = 0;
-        activity_message::ActivityPlan plan{};
+        auto* plan = emplace_transaction<activity_message::ActivityPlan>(outcome);
+        if (plan == nullptr) {
+            return false;
+        }
         bool hasTransaction = false;
         const bool processed =
-            activity_message::process(activity, rosterDecode, requestBody, plan, hasTransaction);
-        if (processed && hasTransaction) {
-            outcome.transaction = plan;
+            activity_message::process(activity, rosterDecode, requestBody, *plan, hasTransaction);
+        if (!processed || !hasTransaction) {
+            clear_transaction(outcome);
         }
         return processed;
     }
@@ -152,21 +155,6 @@ bool process(const ServiceRoute& route,
         written = 0;
         outcome.hasSubscription =
             middleware::bap::family_subscription::parse(requestBody, outcome.subscription);
-        // The subscribe names the record now ready for a snapshot. The family and root are the
-        // only way to tell one record's cycle from several records interleaving.
-        std::array<char, kSubscribeReportLimit> line{};
-        const int count =
-            std::snprintf(line.data(),
-                          line.size(),
-                          "ev=queuez stage=subscribe result=%s family=%u root=0x%016llX",
-                          outcome.hasSubscription ? "ok" : "unreadable",
-                          static_cast<unsigned>(outcome.subscription.familyType),
-                          static_cast<unsigned long long>(outcome.subscription.familyRootSoid));
-        if (count > 0) {
-            core::log::write(core::log::Channel::server,
-                             core::log::Level::info,
-                             {line.data(), static_cast<std::size_t>(count)});
-        }
         return outcome.hasSubscription;
     }
     case BodyCodec::familyUnsubscription: {
@@ -176,30 +164,34 @@ bool process(const ServiceRoute& route,
         return outcome.hasUnsubscription;
     }
     case BodyCodec::matchmakingResponse: {
-        state::matchmaking::PendingMutation mutation{};
+        auto* mutation = emplace_transaction<state::matchmaking::PendingMutation>(outcome);
+        if (mutation == nullptr) {
+            return false;
+        }
         bool hasMutation = false;
         state::PendingCurrentActivity currentActivity{};
-        const bool encoded = matchmaking::encode_response(matchmakingContext,
-                                                          requestBody,
-                                                          output,
-                                                          written,
-                                                          mutation,
-                                                          hasMutation,
-                                                          currentActivity);
-        if (encoded && hasMutation) {
-            outcome.transaction = mutation;
-        } else if (encoded && currentActivity.prepared) {
-            // The reply completes the client's task either way. A character update that cannot
-            // be staged is dropped, not turned into a missing reply.
-            auto& transaction = outcome.transaction.emplace<CurrentActivityTransaction>();
-            if (!queuez::stage_current_activity_character(
-                    queuezState, currentActivity.characterSoid, transaction.update)) {
+        const bool encoded = matchmaking::encode_response(
+            matchmakingContext,
+            requestBody,
+            output,
+            written,
+            *mutation,
+            hasMutation,
+            currentActivity);
+        if (!hasMutation) {
+            clear_transaction(outcome);
+        }
+        if (encoded && !hasMutation && currentActivity.prepared) {
+            auto* transaction = emplace_transaction<CurrentActivityTransaction>(outcome);
+            if (transaction == nullptr
+                || !queuez::stage_current_activity_character(
+                    queuezState, currentActivity.characterSoid, transaction->update)) {
                 core::log::write(core::log::Channel::server,
                                  core::log::Level::warn,
                                  "ev=current_activity stage=queuez_preflight result=fail");
-                outcome.transaction = std::monostate{};
+                clear_transaction(outcome);
             } else {
-                transaction.pending = currentActivity;
+                transaction->pending = currentActivity;
             }
         }
         return encoded;
@@ -212,26 +204,17 @@ bool process(const ServiceRoute& route,
         middleware::web_service::Message message;
         if (middleware::web_service::parse_request(requestBody, message)
             && message.opcode == middleware::web_service::messages::opcode505::kOpcode) {
+            auto* changeCharacter = emplace_transaction<queuez::ChangeCharacter>(outcome);
             if (!middleware::web_service::messages::opcode505::parse_request(message)
-                || !queuez::stage_change_character(queuezState, outcome.changeCharacter)
+                || changeCharacter == nullptr
+                || !queuez::stage_change_character(queuezState, *changeCharacter)
                 || !middleware::web_service::messages::opcode505::encode_response(
-                    message, outcome.changeCharacter.after.family4Version, output, written)) {
+                    message, changeCharacter->after.family4Version, output, written)) {
                 core::log::write(core::log::Channel::server,
                                  core::log::Level::warn,
                                  "ev=ws505 stage=change result=fail");
-                // The plain status pair still goes out. The Client's Change Character waits on the
-                // echoed transaction id, so a missing reply hangs it for the rest of the run. It
-                // reports the refusal, because no revision publishes the change.
-                outcome.changeCharacter = {};
-                middleware::web_service::StatusResponse refusal{};
-                refusal.code = middleware::web_service::kRefusedStatusCode;
-                refusal.value = middleware::web_service::kNoFamily4Publication;
-                return middleware::web_service::encode_response(
-                    message,
-                    middleware::web_service::ResponseShape::statusPair,
-                    refusal,
-                    output,
-                    written);
+                clear_transaction(outcome);
+                return refuse_web_action(message, output, written);
             }
             outcome.hasChangeCharacter = true;
             return true;
@@ -240,7 +223,30 @@ bool process(const ServiceRoute& route,
         if (!sunrise::server::web_service::consume(requestBody, output, written, webOutcome)) {
             return false;
         }
+        if (webOutcome.hasTitleEquip) {
+            // Promise the revision that refreshes the Seals widget's action binding.
+            if (queuezState.family4Version != (std::numeric_limits<std::int32_t>::max)()) {
+                middleware::web_service::StatusResponse status{};
+                status.value = queuezState.family4Version + 1;
+                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=title_equip stage=response result=fail");
+                    sunrise::server::bap::arm_account_resync_everywhere();
+                    return false;
+                }
+            }
+            sunrise::server::bap::arm_account_resync_everywhere();
+        }
         outcome.hasSubscription = webOutcome.hasSubscription;
+        outcome.hasRecordClaim = webOutcome.hasRecordClaim;
+        outcome.hasArtifactReset = webOutcome.hasArtifactReset;
+        outcome.artifactReset = webOutcome.artifactReset;
         outcome.subscription = webOutcome.subscription;
         const auto* equipmentSwap =
             web_service::mutation_if<state::PendingEquipmentSwap>(webOutcome);
@@ -248,247 +254,318 @@ bool process(const ServiceRoute& route,
             web_service::mutation_if<state::PendingSubclassSelection>(webOutcome);
         const auto* socketPlug = web_service::mutation_if<state::PendingSocketPlug>(webOutcome);
         const auto* itemState = web_service::mutation_if<state::PendingItemState>(webOutcome);
+        const auto* artifactPurchase =
+            web_service::mutation_if<state::PendingArtifactPurchase>(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);
+        const auto* recordRewardGrant =
+            web_service::mutation_if<state::PendingRecordRewardGrant>(webOutcome);
+        auto* seasonPassReward =
+            web_service::mutation_if<state::PendingSeasonPassReward>(webOutcome);
+        if (webOutcome.hasArtifactReset) {
+            if (queuezState.family4Version == (std::numeric_limits<std::int32_t>::max)()) {
+                return refuse_web_action(message, output, written);
+            }
+            middleware::web_service::StatusResponse status{};
+            status.value = queuezState.family4Version + 1;
+            if (!middleware::web_service::encode_response(
+                    message,
+                    middleware::web_service::ResponseShape::statusPairWithBool,
+                    status,
+                    output,
+                    written)) {
+                return false;
+            }
+        }
+        if (artifactPurchase != nullptr) {
+            auto* transaction = emplace_transaction<ArtifactPurchaseTransaction>(outcome);
+            if (transaction == nullptr
+                || !queuez::stage_equipment_swap(
+                    queuezState, artifactPurchase->characterSoid, transaction->update)) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=ws901 stage=queuez_preflight result=fail");
+                clear_transaction(outcome);
+                return refuse_web_action(message, output, written);
+            }
+            middleware::web_service::StatusResponse status{};
+            status.value = transaction->update.after.family4Version;
+            status.trailingBool = true;
+            if (!middleware::web_service::encode_response(
+                    message,
+                    middleware::web_service::ResponseShape::statusPairWithBool,
+                    status,
+                    output,
+                    written)) {
+                clear_transaction(outcome);
+                return refuse_web_action(message, output, written);
+            }
+            transaction->pending =
+                web_service::take_mutation<state::PendingArtifactPurchase>(webOutcome);
+        }
         const auto* settingsUpdate =
             web_service::mutation_if<state::PendingSettingsUpdate>(webOutcome);
         if (settingsUpdate != nullptr) {
             // WS-701 promises no immediate QueueZ after-image, so State alone is delayed.
-            outcome.transaction.emplace<state::PendingSettingsUpdate>(*settingsUpdate);
+            if (emplace_transaction<state::PendingSettingsUpdate>(outcome, *settingsUpdate)
+                == nullptr) {
+                return refuse_web_action(message, output, written);
+            }
         }
         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 it authoritative. Stage that
-            // revision before encoding the reply, or the Client completes against the old store.
-            auto& transaction = outcome.transaction.emplace<EquipmentSwapTransaction>();
-            if (!queuez::stage_equipment_swap(
-                    queuezState, equipmentSwap->characterSoid, transaction.update)) {
+            // Promise the Family-4 revision carrying this optimistic equip.
+            auto* transaction = emplace_transaction<EquipmentSwapTransaction>(outcome);
+            if (transaction == nullptr
+                || !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");
-                // The reply is already encoded as a success. Nothing moves now, so it is rewritten
-                // as a refusal rather than dropped, which would hang the correlated task.
-                outcome.transaction = std::monostate{};
-                (void)web_service::encode_staging_refusal(message, output, written);
-            } 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;
+                clear_transaction(outcome);
+                return refuse_web_action(message, output, written);
             }
+            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");
+                clear_transaction(outcome);
+                return refuse_web_action(message, output, written);
+            }
+            transaction->pending =
+                web_service::take_mutation<state::PendingEquipmentSwap>(webOutcome);
         }
         if (subclassSelection != nullptr) {
-            // Opcode 801 completes at the exact Family-4 revision carrying the selected subclass
-            // socket entry. The resident manifest and equipped subclass identity stay unchanged.
-            auto& transaction = outcome.transaction.emplace<SubclassSelectionTransaction>();
-            if (!queuez::stage_subclass_selection(queuezState,
-                                                  subclassSelection->accountSoid,
-                                                  subclassSelection->characterSoid,
-                                                  subclassSelection->subclassInstanceSoid,
-                                                  transaction.update)) {
+            // Promise the Family-4 revision carrying the subclass socket change.
+            auto* transaction = emplace_transaction<SubclassSelectionTransaction>(outcome);
+            if (transaction == nullptr
+                || !queuez::stage_subclass_selection(queuezState,
+                                                     subclassSelection->accountSoid,
+                                                     subclassSelection->characterSoid,
+                                                     subclassSelection->subclassInstanceSoid,
+                                                     transaction->update)) {
                 core::log::write(core::log::Channel::server,
                                  core::log::Level::warn,
                                  "ev=subclass_select stage=queuez_preflight result=fail");
-                outcome.transaction = std::monostate{};
-                (void)web_service::encode_staging_refusal(message, output, written);
-            } 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=subclass_select stage=response result=fail");
-                    return false;
-                }
-                web_service::report_subclass_selection_response(
-                    message, status.value, *subclassSelection, output.first(written));
-                transaction.pending = *subclassSelection;
+                clear_transaction(outcome);
+                return refuse_web_action(message, output, written);
+            }
+            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=subclass_select stage=response result=fail");
+                clear_transaction(outcome);
+                return refuse_web_action(message, output, written);
             }
+            transaction->pending =
+                web_service::take_mutation<state::PendingSubclassSelection>(webOutcome);
         }
         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)) {
+            // Promise the Family-4 revision carrying the changed item instance.
+            auto* transaction = emplace_transaction<SocketPlugTransaction>(outcome);
+            if (transaction == nullptr
+                || !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{};
-                (void)web_service::encode_staging_refusal(message, output, written);
-            } 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;
+                clear_transaction(outcome);
+                return refuse_web_action(message, output, written);
             }
+            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");
+                clear_transaction(outcome);
+                return refuse_web_action(message, output, written);
+            }
+            transaction->pending = web_service::take_mutation<state::PendingSocketPlug>(webOutcome);
         }
         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)) {
+            // Promise the Family-4 revision carrying the changed row flags.
+            auto* transaction = emplace_transaction<ItemStateTransaction>(outcome);
+            if (transaction == nullptr
+                || !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{};
-                (void)web_service::encode_staging_refusal(message, output, written);
-            } 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;
+                clear_transaction(outcome);
+                return refuse_web_action(message, output, written);
             }
+            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");
+                clear_transaction(outcome);
+                return refuse_web_action(message, output, written);
+            }
+            transaction->pending = web_service::take_mutation<state::PendingItemState>(webOutcome);
         }
         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)) {
+            // Promise the revision adding both the inventory row and resident instance.
+            auto* transaction = emplace_transaction<ItemAcquisitionTransaction>(outcome);
+            if (transaction == nullptr
+                || !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{};
-                (void)web_service::encode_staging_refusal(message, output, written);
-            } 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;
-                transaction.answeredVendor = webOutcome.answeredVendor;
+                clear_transaction(outcome);
+                return refuse_web_action(message, output, written);
+            }
+            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");
+                clear_transaction(outcome);
+                return refuse_web_action(message, output, written);
             }
+            transaction->pending =
+                web_service::take_mutation<state::PendingItemAcquisition>(webOutcome);
+            transaction->answeredVendor = webOutcome.answeredVendor;
         }
         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(
+            // Actionable profile stacks may add a resident in the same revision.
+            auto* transaction = emplace_transaction<ProfileItemAcquisitionTransaction>(outcome);
+            if (transaction == nullptr
+                || !queuez::stage_profile_item_acquisition(
                     queuezState,
                     profileItemAcquisition->accountSoid,
                     profileItemAcquisition->acquiredInstanceSoid,
                     profileItemAcquisition->actionSource,
                     profileItemAcquisition->appended,
-                    transaction.update)) {
+                    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{};
-                (void)web_service::encode_staging_refusal(message, output, written);
-            } 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(
+                clear_transaction(outcome);
+                return refuse_web_action(message, output, written);
+            }
+            middleware::web_service::StatusResponse status{};
+            status.value = transaction->update.after.family4Version;
+            if (!middleware::web_service::encode_response(
                     message,
-                    status.value,
-                    profileItemAcquisition->acquiredDefinitionHash,
-                    profileItemAcquisition->acquiredQuantity,
-                    output.first(written));
-                transaction.pending = *profileItemAcquisition;
-                transaction.answeredVendor = webOutcome.answeredVendor;
+                    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");
+                clear_transaction(outcome);
+                return refuse_web_action(message, output, written);
             }
+            transaction->pending =
+                web_service::take_mutation<state::PendingProfileItemAcquisition>(webOutcome);
+            transaction->answeredVendor = webOutcome.answeredVendor;
         }
         if (itemDismantle != nullptr) {
-            // Dismantle is another optimistic Character-screen action. Promise only the exact
-            // Family-4 revision carrying both the character after-image and the empty release
-            // descriptor; otherwise keep 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)) {
+            // Promise the revision carrying the character update and resident release.
+            auto* transaction = emplace_transaction<ItemDismantleTransaction>(outcome);
+            if (transaction == nullptr
+                || !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{};
-                (void)web_service::encode_staging_refusal(message, output, written);
+                clear_transaction(outcome);
+                return refuse_web_action(message, output, written);
+            }
+            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");
+                clear_transaction(outcome);
+                return refuse_web_action(message, output, written);
+            }
+            transaction->pending =
+                web_service::take_mutation<state::PendingItemDismantle>(webOutcome);
+        }
+        if (recordRewardGrant != nullptr) {
+            auto* transaction = emplace_transaction<RecordRewardGrantTransaction>(outcome);
+            std::array<std::uint64_t, state::kRecordRewardGrantCapacity> residents{};
+            std::size_t residentCount = 0;
+            for (std::size_t index = 0; index < recordRewardGrant->rewardCount; ++index) {
+                const auto& reward = recordRewardGrant->rewards[index];
+                if (reward.kind == state::RecordRewardKind::characterInstance
+                    || reward.appendedProfileResident) {
+                    residents[residentCount++] = reward.instanceSoid;
+                }
+            }
+            const bool staged =
+                transaction != nullptr
+                && queuez::stage_record_reward_grant(queuezState,
+                                                     recordRewardGrant->accountSoid,
+                                                     recordRewardGrant->characterSoid,
+                                                     std::span(residents).first(residentCount),
+                                                     transaction->update);
+            if (!staged) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=ws1801 stage=reward_preflight result=fail");
+                clear_transaction(outcome);
+                return refuse_web_action(message, output, written);
             } else {
                 middleware::web_service::StatusResponse status{};
-                status.value = transaction.update.after.family4Version;
+                status.value = transaction->update.after.family4Version;
                 if (!middleware::web_service::encode_response(
                         message,
                         middleware::web_service::ResponseShape::statusPair,
@@ -497,27 +574,89 @@ bool process(const ServiceRoute& route,
                         written)) {
                     core::log::write(core::log::Channel::server,
                                      core::log::Level::warn,
-                                     "ev=dismantle stage=response result=fail");
-                    return false;
+                                     "ev=ws1801 stage=reward_response result=fail");
+                    clear_transaction(outcome);
+                    return refuse_web_action(message, output, written);
+                } else {
+                    transaction->pending =
+                        web_service::take_mutation<state::PendingRecordRewardGrant>(webOutcome);
                 }
-                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
-            && queuez::stage_select_character(
-                queuezState, webOutcome.selectedCharacterSoid, outcome.selectCharacter)) {
-            outcome.hasSelectCharacter = true;
-            // Same rule as every other mutating opcode: the reply names the revision whose next
-            // queuez frame makes the pick authoritative. Without it the Client completes the pick
-            // against the store it already had.
+        if (seasonPassReward != nullptr) {
+            auto* transaction = emplace_transaction<SeasonPassRewardTransaction>(outcome);
+            if (transaction == nullptr) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=ws2400 stage=reward_preflight result=fail");
+                return refuse_web_action(message, output, written);
+            }
+            bool staged = false;
+            std::int32_t stagedVersion = 0;
+            if (const auto* itemGrant =
+                    std::get_if<state::PendingItemAcquisition>(&seasonPassReward->grant)) {
+                auto& update = transaction->update.emplace<queuez::ItemAcquisition>();
+                staged = queuez::stage_item_acquisition(queuezState,
+                                                        itemGrant->accountSoid,
+                                                        itemGrant->characterSoid,
+                                                        itemGrant->acquiredInstanceSoid,
+                                                        true,
+                                                        update);
+                stagedVersion = update.after.family4Version;
+            } else if (const auto* profileGrant = std::get_if<state::PendingProfileItemAcquisition>(
+                           &seasonPassReward->grant)) {
+                auto& update = transaction->update.emplace<queuez::ProfileItemAcquisition>();
+                staged = queuez::stage_profile_item_acquisition(queuezState,
+                                                                profileGrant->accountSoid,
+                                                                profileGrant->acquiredInstanceSoid,
+                                                                profileGrant->actionSource,
+                                                                profileGrant->appended,
+                                                                update);
+                stagedVersion = update.after.family4Version;
+            } else if (const auto* bundle =
+                           std::get_if<state::PendingDirectItemBundle>(&seasonPassReward->grant)) {
+                staged = queuez::stage_direct_item_bundle(queuezState,
+                                                          bundle->accountSoid,
+                                                          bundle->characterSoid,
+                                                          bundle->firstInstanceSoid,
+                                                          bundle->itemCount,
+                                                          stagedVersion);
+            } else if (const auto* resources =
+                           std::get_if<state::PendingRecordRewardGrant>(&seasonPassReward->grant)) {
+                auto& update = transaction->update.emplace<queuez::RecordRewardGrant>();
+                staged = queuez::stage_record_reward_grant(
+                    queuezState, resources->accountSoid, resources->characterSoid, {}, update);
+                stagedVersion = update.after.family4Version;
+            }
+            if (!staged) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=ws2400 stage=reward_preflight result=fail");
+                clear_transaction(outcome);
+                return refuse_web_action(message, output, written);
+            }
+            middleware::web_service::StatusResponse status{};
+            status.value = stagedVersion;
+            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=ws2400 stage=response result=fail");
+                clear_transaction(outcome);
+                return refuse_web_action(message, output, written);
+            }
+            transaction->pending =
+                web_service::take_mutation<state::PendingSeasonPassReward>(webOutcome);
+        }
+        if (webOutcome.hasRecordClaim && !has_transaction(outcome)
+            && queuezState.family4Version != (std::numeric_limits<std::int32_t>::max)()) {
+            // A plain claim is made authoritative by the following account resync.
             middleware::web_service::StatusResponse status{};
-            status.value = outcome.selectCharacter.after.family4Version;
+            status.value = queuezState.family4Version + 1;
             if (!middleware::web_service::encode_response(
                     message,
                     middleware::web_service::ResponseShape::statusPair,
@@ -526,11 +665,21 @@ bool process(const ServiceRoute& route,
                     written)) {
                 core::log::write(core::log::Channel::server,
                                  core::log::Level::warn,
-                                 "ev=ws504 stage=response result=fail");
+                                 "ev=ws1801 stage=response result=fail");
+                sunrise::server::bap::arm_account_resync_everywhere();
                 return false;
             }
-        } else {
-            outcome.selectCharacter = {};
+        }
+        if (webOutcome.hasSelectedCharacter && webOutcome.selectedCharacterChanged) {
+            auto* selectCharacter = emplace_transaction<queuez::SelectCharacter>(outcome);
+            if (selectCharacter != nullptr
+                && queuez::stage_select_character(
+                    queuezState, webOutcome.selectedCharacterSoid, *selectCharacter)) {
+                outcome.hasSelectCharacter = true;
+            } else {
+                clear_transaction(outcome);
+                sunrise::server::bap::arm_account_resync_everywhere();
+            }
         }
         return true;
     }

+ 164 - 149
Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp

@@ -5,9 +5,11 @@
 #include <cstdio>
 
 #include "../../../core/logging/log.h"
+#include "../../../middleware/datagen/definitions.h"
 #include "../../../middleware/encoding/byte_order.h"
 #include "../../../middleware/secure_channel/runtime.h"
 #include "../../../state/runtime/runtime.h"
+#include "../../../client/hooks/network/investment/investment_derived_rebuild.h"
 #include "../../activity/host_runtime.h"
 #include "../activity_authority_query_owner.h"
 #include "../activity_authority_reset_owner.h"
@@ -22,6 +24,27 @@
 namespace sunrise::server::bap::encrypted {
 namespace {
 
+/** Records decoded service traffic while reconstructing the investment-signin contract. */
+void report_service_traffic(const middleware::bap::RequestFrame& frame,
+                            const ServiceRoute& route,
+                            std::size_t responseBodySize) noexcept {
+    std::array<char, core::log::kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=bap stage=service svc=%u task=%u request_bytes=%zu "
+                                      "response_svc=%u response_bytes=%zu",
+                                      static_cast<unsigned>(frame.serviceId),
+                                      static_cast<unsigned>(frame.taskId),
+                                      frame.body.size(),
+                                      static_cast<unsigned>(route.response),
+                                      responseBodySize);
+    if (written > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::debug,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
 /**
  * Wipes the part of one scratch buffer that may hold written bytes.
  * @param buffer Lock-owned scratch storage.
@@ -31,6 +54,59 @@ void clear_prefix(std::span<std::byte> buffer, std::size_t size) noexcept {
     SecureZeroMemory(buffer.data(), (std::min)(buffer.size(), size));
 }
 
+/** Rejects stale peer mutations until every current instance object is resident. */
+[[nodiscard]] bool
+manifest_covers_account_instances(const queuez::SessionState& queuezState) noexcept {
+    if (!queuez::valid(queuezState) || !queuezState.family4Active) {
+        return false;
+    }
+    const state::AccountState account = state::account_snapshot();
+    if (!state::account::valid(account) || account.primarySoid != queuezState.family4RootSoid) {
+        return false;
+    }
+    const auto resident = [&](std::uint64_t soid, std::uint32_t definitionId) noexcept {
+        return std::count_if(
+                   queuezState.family4Residents.cbegin(),
+                   queuezState.family4Residents.cbegin() + queuezState.family4ResidentCount,
+                   [&](const queuez::ResidentObject& object) noexcept {
+                       return object.objectSoid == soid && object.definitionId == definitionId;
+                   })
+               == 1;
+    };
+    if (!resident(account.primarySoid, middleware::datagen::kAccountObjectId)) {
+        return false;
+    }
+    const std::uint64_t selectedCharacter = state::account::selected_character_soid(account);
+    if (selectedCharacter != 0
+        && !resident(selectedCharacter, middleware::datagen::kCharacterObjectId)) {
+        return false;
+    }
+    for (std::size_t characterIndex = 0; characterIndex < account.characterCount;
+         ++characterIndex) {
+        const state::CharacterState& character = account.characters[characterIndex];
+        for (const auto& equipped : character.equipment.slots) {
+            if (equipped.has_value()
+                && !resident(equipped->instanceSoid, middleware::datagen::kItemInstanceObjectId)) {
+                return false;
+            }
+        }
+        for (std::size_t itemIndex = 0; itemIndex < character.inventory.count; ++itemIndex) {
+            if (!resident(character.inventory.values[itemIndex].instanceSoid,
+                          middleware::datagen::kItemInstanceObjectId)) {
+                return false;
+            }
+        }
+    }
+    for (std::size_t itemIndex = 0; itemIndex < account.profileItemCount; ++itemIndex) {
+        const std::uint64_t instanceSoid = account.profileItems[itemIndex].instanceSoid;
+        if (instanceSoid != 0
+            && !resident(instanceSoid, middleware::datagen::kItemInstanceObjectId)) {
+            return false;
+        }
+    }
+    return true;
+}
+
 /** Appends one join only after its exact ActivityClient binding has committed and published. */
 void record_committed_join(Session& session, const ConnectionFields& fields) noexcept {
     if (!fields.joinsActivity || !fields.joinIngress.prepared) {
@@ -213,8 +289,21 @@ bool consume(Session& session,
     }
     const bool processesBody = handled && route.responseMode != ResponseMode::none;
     const bool sendsReply = handled && route.responseMode == ResponseMode::reply;
+    bool staleWebAction = false;
+    if (processesBody && route.bodyCodec == BodyCodec::webService && session.accountResyncArmed
+        && !manifest_covers_account_instances(session.queuez)
+        && !web_service::encode_resident_dependent_refusal(
+            frame.body, scratch.responseBody, responseBodySize, staleWebAction)) {
+        handled = false;
+        diagnostics::report_failure(frame.serviceId, "stale_refusal");
+    }
+    if (staleWebAction) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::warn,
+                         "ev=bap stage=web_service result=refuse reason=stale_manifest");
+    }
     // Pure one-way services consume only the authenticated receive nonce.
-    if (processesBody
+    if (!staleWebAction && processesBody
         && !body::process(route,
                           session.queuez,
                           session.activity,
@@ -233,6 +322,9 @@ bool consume(Session& session,
         outcome = {};
         handled = sendsReply;
     }
+    if (handled) {
+        report_service_traffic(frame, route, responseBodySize);
+    }
     if (handled && sendsReply) {
         handled = reply::encode(scratch,
                                 route,
@@ -252,9 +344,20 @@ bool consume(Session& session,
     }
     queuez::StagedPublication queuezPublication{};
     if (handled) {
+        const std::uint64_t now = GetTickCount64();
+        if (now >= session.acquisitionPresentationUntilTick) {
+            session.acquisitionPresentationRows = {};
+            session.acquisitionPresentationRowCount = 0;
+        }
+        const auto acquisitionPresentationRows =
+            std::span(session.acquisitionPresentationRows)
+                .first(session.acquisitionPresentationRowCount);
+        const bool preserveAcquisitionPresentation = now < session.acquisitionPresentationUntilTick;
         handled = queuez::stage_service_outcome(scratch,
                                                 session.queuez,
                                                 outcome,
+                                                preserveAcquisitionPresentation,
+                                                acquisitionPresentationRows,
                                                 session.sessionKey,
                                                 nextSendNonce,
                                                 scratch.framed,
@@ -269,6 +372,10 @@ bool consume(Session& session,
         }
     }
     const auto* activityPlan = transaction_if<activity_message::ActivityPlan>(outcome);
+    const bool publishesFamily4 =
+        publishesQueuez
+        && (queuezPublication.after.family4Active != session.queuez.family4Active
+            || queuezPublication.after.family4Version != session.queuez.family4Version);
     if (handled && activityPlan != nullptr) {
         handled = route.responseMode == ResponseMode::uncorrelatedPush;
         if (!handled) {
@@ -285,41 +392,32 @@ bool consume(Session& session,
             diagnostics::report_failure(frame.serviceId, "notify");
         }
     }
+    const bool artifactPurchase = transaction_if<ArtifactPurchaseTransaction>(outcome) != nullptr;
     const bool mutatesAccount =
-        outcome.hasChangeCharacter || outcome.hasSelectCharacter
+        outcome.hasSelectCharacter || outcome.hasRecordClaim || outcome.hasArtifactReset
         || transaction_if<EquipmentSwapTransaction>(outcome) != nullptr
+        || transaction_if<SubclassSelectionTransaction>(outcome) != nullptr
         || transaction_if<SocketPlugTransaction>(outcome) != nullptr
         || transaction_if<ItemStateTransaction>(outcome) != nullptr
+        || artifactPurchase
         || transaction_if<CurrentActivityTransaction>(outcome) != nullptr
         || transaction_if<ItemAcquisitionTransaction>(outcome) != nullptr
         || transaction_if<ProfileItemAcquisitionTransaction>(outcome) != nullptr
         || transaction_if<ItemDismantleTransaction>(outcome) != nullptr
+        || transaction_if<RecordRewardGrantTransaction>(outcome) != nullptr
+        || transaction_if<SeasonPassRewardTransaction>(outcome) != nullptr
         || transaction_if<state::PendingSettingsUpdate>(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 bool presentsAcquisition =
+        transaction_if<ItemAcquisitionTransaction>(outcome) != nullptr
+        || transaction_if<ProfileItemAcquisitionTransaction>(outcome) != nullptr
+        || transaction_if<RecordRewardGrantTransaction>(outcome) != nullptr
+        || transaction_if<SeasonPassRewardTransaction>(outcome) != nullptr;
+    const bool invalidatesAcquisitionPresentation =
+        outcome.hasChangeCharacter || outcome.hasSelectCharacter || outcome.hasArtifactReset
+        || transaction_if<ItemDismantleTransaction>(outcome) != nullptr;
+    const bool hasPrecommittedAccountAction =
+        outcome.hasRecordClaim || outcome.hasSelectCharacter || outcome.hasArtifactReset;
+    // Commit consumes pending payloads, so retain the connection fields first.
     const ConnectionFields connection = connection_fields(outcome);
     if (handled && processesBody) {
         // State changes become visible only after every requested frame and caller byte fit.
@@ -340,6 +438,21 @@ bool consume(Session& session,
                 session.queuez = nextQueuez;
             }
             arm_repushes(session, queuezPublication);
+            if (invalidatesAcquisitionPresentation) {
+                session.acquisitionPresentationRows = {};
+                session.acquisitionPresentationRowCount = 0;
+                session.acquisitionPresentationUntilTick = 0;
+            } else if (queuezPublication.updatesAcquisitionPresentationRows) {
+                session.acquisitionPresentationRows = queuezPublication.acquisitionPresentationRows;
+                session.acquisitionPresentationRowCount =
+                    queuezPublication.acquisitionPresentationRowCount;
+                if (session.acquisitionPresentationRowCount == 0) {
+                    session.acquisitionPresentationUntilTick = 0;
+                }
+            }
+            if (presentsAcquisition && publishesQueuez) {
+                bap::arm_acquisition_presentation_hold(session);
+            }
             publish_connection_fields(session, publication, connection);
             record_committed_join(session, connection);
             // The caller copy is done, so what the staged roster body owes is settled here.
@@ -360,133 +473,38 @@ bool consume(Session& session,
             if (defersKeepalive) {
                 session.activityKeepaliveDueTick = GetTickCount64() + kActivityKeepaliveIntervalMs;
             }
-            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)});
-                }
+            const bool resyncsCommittedAccount =
+                hasPrecommittedAccountAction && !queuezPublication.hasState;
+            if (resyncsCommittedAccount) {
+                bap::arm_account_resync_everywhere();
             }
-            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)});
-                }
+            const bool refreshesDerivedInvestment = artifactPurchase || outcome.hasArtifactReset;
+            if (refreshesDerivedInvestment) {
+                // Artifact overrides really live in Family 5. Record claims and their rewards do
+                // not: their committed Family-4 replacement now rearms the client rebuild itself.
+                session.artifactRefreshArmed = true;
             }
-            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 (artifactPurchase || outcome.hasArtifactReset) {
+                session.artifactFamily4RefreshDueTick = GetTickCount64() + 100;
+                session.artifactFamily4RefreshArmed = true;
             }
-            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 (outcome.hasArtifactReset) {
+                session.artifactResetRefresh = outcome.artifactReset;
+                session.artifactResetRefreshCursor = 0;
             }
-            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)});
-                }
+            session.accountMutationPublished = mutatesAccount && !resyncsCommittedAccount;
+            if (publishesFamily4) {
+                // The Family-4 store is updated in place, so pointer identity cannot detect its
+                // initial population or later revisions. Carry the exact committed publication
+                // across to the next native lookup, after the client has consumed this frame.
+                client::hooks::network::investment::notify_family4_publication();
             }
         }
     }
     if (!handled) {
+        if (hasPrecommittedAccountAction) {
+            bap::arm_account_resync_everywhere();
+        }
         // The staged body is dropped, so its grant and its state byte go back for the next push.
         push::activity::discard_staged_roster(session);
         discard_staged_advertisement(session);
@@ -497,9 +515,6 @@ bool consume(Session& session,
     outcome = {};
     SecureZeroMemory(&publication, sizeof publication);
     SecureZeroMemory(&queuezPublication, sizeof queuezPublication);
-    if (handled) {
-        core::log::write(core::log::Channel::server, core::log::Level::info, route.successEvent);
-    }
     return handled;
 }
 

+ 190 - 56
Sunrise/src/server/bap/encrypted/internal.h

@@ -3,8 +3,13 @@
 #include <array>
 #include <cstddef>
 #include <cstdint>
+#include <memory>
+#include <new>
+#include <optional>
 #include <span>
 #include <string_view>
+#include <type_traits>
+#include <utility>
 #include <variant>
 
 #include "../../../middleware/bap/family_unsubscription.h"
@@ -36,25 +41,31 @@ enum class BodyCodec : std::uint8_t {
 
 /** Equipment mutation and the exact QueueZ after-image promised by its response. */
 struct EquipmentSwapTransaction {
-    state::PendingEquipmentSwap pending{};
+    std::unique_ptr<state::PendingEquipmentSwap> pending{};
     queuez::EquipmentSwap update{};
 };
 
 /** Socket mutation and the exact QueueZ after-image promised by its response. */
 struct SocketPlugTransaction {
-    state::PendingSocketPlug pending{};
+    std::unique_ptr<state::PendingSocketPlug> pending{};
     queuez::SocketPlug update{};
 };
 
 /** Subclass ability selection and the exact QueueZ after-image promised by its response. */
 struct SubclassSelectionTransaction {
-    state::PendingSubclassSelection pending{};
+    std::unique_ptr<state::PendingSubclassSelection> pending{};
     queuez::SubclassSelection update{};
 };
 
 /** Item-state mutation and the exact QueueZ character after-image promised by its response. */
 struct ItemStateTransaction {
-    state::PendingItemState pending{};
+    std::unique_ptr<state::PendingItemState> pending{};
+    queuez::EquipmentSwap update{};
+};
+
+/** Artifact purchase and the exact selected-character after-image promised by opcode 901. */
+struct ArtifactPurchaseTransaction {
+    std::unique_ptr<state::PendingArtifactPurchase> pending{};
     queuez::EquipmentSwap update{};
 };
 
@@ -66,7 +77,7 @@ struct CurrentActivityTransaction {
 
 /** Character acquisition and its exact QueueZ after-image. */
 struct ItemAcquisitionTransaction {
-    state::PendingItemAcquisition pending{};
+    std::unique_ptr<state::PendingItemAcquisition> pending{};
     queuez::ItemAcquisition update{};
     /** Vendor whose shown interaction this grant answers once it commits, or `kAbsentIndex`. */
     std::uint16_t answeredVendor{state::vendors::kAbsentIndex};
@@ -74,7 +85,7 @@ struct ItemAcquisitionTransaction {
 
 /** Profile acquisition and its exact account/resident QueueZ after-image. */
 struct ProfileItemAcquisitionTransaction {
-    state::PendingProfileItemAcquisition pending{};
+    std::unique_ptr<state::PendingProfileItemAcquisition> pending{};
     queuez::ProfileItemAcquisition update{};
     /** Vendor whose shown interaction this grant answers once it commits, or `kAbsentIndex`. */
     std::uint16_t answeredVendor{state::vendors::kAbsentIndex};
@@ -82,47 +93,95 @@ struct ProfileItemAcquisitionTransaction {
 
 /** Dismantle mutation and its exact QueueZ after-image. */
 struct ItemDismantleTransaction {
-    state::PendingItemDismantle pending{};
+    std::unique_ptr<state::PendingItemDismantle> pending{};
     queuez::ItemDismantle update{};
 };
 
+/** Record-claim reward batch and its exact Queuez after-image. */
+struct RecordRewardGrantTransaction {
+    std::unique_ptr<state::PendingRecordRewardGrant> pending{};
+    queuez::RecordRewardGrant update{};
+};
+
+/** Season reward grant plus the exact Queuez after-image promised by opcode 2400. */
+struct SeasonPassRewardTransaction {
+    std::unique_ptr<state::PendingSeasonPassReward> pending{};
+    std::variant<std::monostate,
+                 queuez::ItemAcquisition,
+                 queuez::ProfileItemAcquisition,
+                 queuez::RecordRewardGrant>
+        update{};
+};
+
 /** Optional side effect produced while decoding one authenticated service body. */
 struct ServiceOutcome {
     bool hasSubscription{};
+    /** A Triumph claim changed the account flag bank and its image has to follow. */
+    bool hasRecordClaim{};
+    bool hasArtifactReset{};
+    state::ArtifactResetResult artifactReset{};
     middleware::queuez::Subscription subscription{};
     bool hasUnsubscription{};
     middleware::bap::family_unsubscription::Request unsubscription{};
     bool hasChangeCharacter{};
-    queuez::ChangeCharacter changeCharacter{};
     bool hasSelectCharacter{};
-    queuez::SelectCharacter selectCharacter{};
-    /** One service owns at most one independently versioned transaction. */
+    /** One service owns at most one independently versioned, exact-sized transaction. */
     using Transaction = std::variant<std::monostate,
-                                     state::activity::PendingAllocation,
-                                     activity_message::ActivityPlan,
-                                     state::matchmaking::PendingMutation,
-                                     EquipmentSwapTransaction,
-                                     SubclassSelectionTransaction,
-                                     SocketPlugTransaction,
-                                     ItemStateTransaction,
-                                     CurrentActivityTransaction,
-                                     ItemAcquisitionTransaction,
-                                     ProfileItemAcquisitionTransaction,
-                                     ItemDismantleTransaction,
-                                     state::PendingSettingsUpdate>;
+                                     std::unique_ptr<state::activity::PendingAllocation>,
+                                     std::unique_ptr<activity_message::ActivityPlan>,
+                                     std::unique_ptr<state::matchmaking::PendingMutation>,
+                                     std::unique_ptr<queuez::ChangeCharacter>,
+                                     std::unique_ptr<queuez::SelectCharacter>,
+                                     std::unique_ptr<EquipmentSwapTransaction>,
+                                     std::unique_ptr<SubclassSelectionTransaction>,
+                                     std::unique_ptr<SocketPlugTransaction>,
+                                     std::unique_ptr<ItemStateTransaction>,
+                                     std::unique_ptr<ArtifactPurchaseTransaction>,
+                                     std::unique_ptr<CurrentActivityTransaction>,
+                                     std::unique_ptr<ItemAcquisitionTransaction>,
+                                     std::unique_ptr<ProfileItemAcquisitionTransaction>,
+                                     std::unique_ptr<ItemDismantleTransaction>,
+                                     std::unique_ptr<RecordRewardGrantTransaction>,
+                                     std::unique_ptr<SeasonPassRewardTransaction>,
+                                     std::unique_ptr<state::PendingSettingsUpdate>>;
     Transaction transaction{};
 };
 
+/** Allocates only the selected transaction outside the request's already deep call stack. */
+template <typename Transaction, typename... Args>
+[[nodiscard]] Transaction* emplace_transaction(ServiceOutcome& outcome, Args&&... args) noexcept {
+    static_assert(std::is_nothrow_constructible_v<Transaction, Args...>);
+    outcome.transaction.template emplace<std::monostate>();
+    auto storage =
+        std::unique_ptr<Transaction>{new (std::nothrow) Transaction(std::forward<Args>(args)...)};
+    if (storage == nullptr) {
+        return nullptr;
+    }
+    auto* transaction = storage.get();
+    outcome.transaction.template emplace<std::unique_ptr<Transaction>>(std::move(storage));
+    return 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);
+    auto* storage = std::get_if<std::unique_ptr<Transaction>>(&outcome.transaction);
+    return storage == nullptr ? nullptr : storage->get();
 }
 
 /** @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);
+    const auto* storage = std::get_if<std::unique_ptr<Transaction>>(&outcome.transaction);
+    return storage == nullptr ? nullptr : storage->get();
+}
+
+[[nodiscard]] inline bool has_transaction(const ServiceOutcome& outcome) noexcept {
+    return outcome.transaction.index() != 0;
+}
+
+inline void clear_transaction(ServiceOutcome& outcome) noexcept {
+    outcome.transaction.template emplace<std::monostate>();
 }
 
 /** Outbound delivery behavior picked for one authenticated request service. */
@@ -254,14 +313,41 @@ void append_queuez_notification(Scratch& scratch,
                                 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;
+[[nodiscard]] bool append_account_resync_notification(
+    Scratch& scratch,
+    const queuez::SessionState& before,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    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;
+
+/** Publishes one Season package as new residents plus their acquisition descriptors. */
+[[nodiscard]] bool append_season_pass_package_notification(
+    Scratch& scratch,
+    const queuez::SessionState& before,
+    const state::PendingDirectItemBundle& mutation,
+    std::uint16_t rewardIndex,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    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,
+    queuez::SessionState& after) noexcept;
+
+/** Publishes every item and the claimed record in one Family-4 revision. */
+[[nodiscard]] bool append_record_reward_notification(
+    Scratch& scratch,
+    const queuez::SessionState& before,
+    const queuez::RecordRewardGrant& update,
+    const state::PendingRecordRewardGrant& mutation,
+    std::optional<std::uint16_t> pendingSeasonReward,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    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 family-zero banner pair as its own notification.
@@ -346,14 +432,15 @@ append_select_character_notification(Scratch& scratch,
                                      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;
+[[nodiscard]] bool append_equipment_swap_notification(
+    Scratch& scratch,
+    const queuez::EquipmentSwap& swap,
+    const state::PendingEquipmentSwap& mutation,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    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 Family-4 character upsert carrying the character's new current activity. */
 [[nodiscard]] bool
@@ -366,14 +453,45 @@ append_current_activity_notification(Scratch& scratch,
                                      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;
+[[nodiscard]] bool append_item_state_notification(
+    Scratch& scratch,
+    const queuez::EquipmentSwap& update,
+    const state::PendingItemState& mutation,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    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 selected-character upsert carrying one artifact ownership transition. */
+[[nodiscard]] bool append_artifact_purchase_notification(
+    Scratch& scratch,
+    const queuez::EquipmentSwap& update,
+    const state::PendingArtifactPurchase& mutation,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    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 account and selected-character state changed by an artifact reset. */
+[[nodiscard]] bool append_artifact_reset_notification(
+    Scratch& scratch,
+    const queuez::EquipmentSwap& update,
+    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 current item resident after artifact reset cleared an authored socket. */
+[[nodiscard]] bool append_artifact_item_refresh_notification(
+    Scratch& scratch,
+    const queuez::EquipmentSwap& update,
+    std::uint64_t instanceSoid,
+    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.
@@ -479,25 +597,41 @@ append_subclass_selection_notification(Scratch& scratch,
                                        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;
+[[nodiscard]] bool append_item_acquisition_notification(
+    Scratch& scratch,
+    const queuez::ItemAcquisition& acquisition,
+    const state::PendingItemAcquisition& mutation,
+    std::optional<std::uint16_t> pendingSeasonReward,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    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::optional<std::uint16_t> pendingSeasonReward,
                                              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 character/account increment that makes one seasonal XP gain visible in the HUD. */
+[[nodiscard]] bool append_seasonal_experience_notification(
+    Scratch& scratch,
+    const queuez::SessionState& before,
+    std::int32_t amount,
+    std::int32_t mutationSerial,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    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,
+    queuez::SessionState& after) noexcept;
+
 /** Appends a Family-4 character upsert followed by one empty item-instance release. */
 [[nodiscard]] bool
 append_item_dismantle_notification(Scratch& scratch,

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

@@ -50,7 +50,8 @@ bool prepare_seed_identity(std::uint64_t sessionId,
 }
 
 /** Builds the membership snapshot a first join commits, without reading State. */
-bool prepare_join_seed_snapshot(std::uint64_t memberKey,
+bool prepare_join_seed_snapshot(std::uint64_t createdRevision,
+                                std::uint64_t memberKey,
                                 std::uint64_t characterSoid,
                                 state::activity::membership::PendingMutation& mutation) noexcept {
     mutation = {};
@@ -61,6 +62,7 @@ bool prepare_join_seed_snapshot(std::uint64_t memberKey,
     // the seed identity over cleared state at the initial revision, epoch, and token.
     mutation.snapshot.identity = seed_identity(memberKey, characterSoid);
     mutation.snapshot.revision = state::activity::membership::kInitialRevision;
+    mutation.snapshot.epoch = state::activity::membership::session_epoch(createdRevision);
     mutation.snapshot.transitionToken = state::activity::membership::kInitialTransitionToken;
     mutation.hasSnapshot = true;
     return true;

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

@@ -286,10 +286,10 @@ bool append_roster_notification(
     const bool lifetimePending =
         hasScriptablePending && singleScriptableLink
         && scriptablePending.kind == server::activity::host::ScriptableOverrideKind::lifetime;
-    // The lifetime answers the client's reported load state. The published state shows the
-    // loading presentation and the spawn gate holds, until the client reports holding its region
-    // or while a region change waits for arrival. An explicit lifetime request wins.
-    const bool clientLoading = !client_in_world(session, refresh);
+    // The loading lifetime holds only until the destination region is instantiated. The stricter
+    // in-world state is reported after spawning and would make this field wait on its own result.
+    // An explicit lifetime request still wins.
+    const bool clientLoading = !client_region_ready(session, refresh);
     const bool bodyPending = hasScriptablePending && singleScriptableLink && !lifetimePending;
     // Bodies committed behind the head share its push, so they are installed on this same body.
     std::array<server::activity::host::PendingScriptableOverride,

+ 19 - 10
Sunrise/src/server/bap/encrypted/push/activity/activity_roster_snapshot.cpp

@@ -840,15 +840,15 @@ client_placement(const Session& session, const RefreshReport* refresh) noexcept
     if (refresh != nullptr) {
         placement.bubble = refresh->bubble;
         placement.bubbleRevision = refresh->revision;
+        if (refresh->hasCurrentRegion) {
+            placement.currentRegion = refresh->currentRegion;
+        }
     }
     return placement;
 }
 
-/** Tests whether the client holds a slice set, has entered the world and no host move is due. */
-bool client_in_world(const Session& session, const RefreshReport* refresh) noexcept {
-    // The ws-702 write-back's world-state field reads 8 only after `activity:in_world`, so the
-    // spawn waits for that value. A bubble crossing moves the current region without tearing the
-    // world down, so entry holds across it.
+/** Tests whether the client holds a slice set and no host move is due. */
+bool client_region_ready(const Session& session, const RefreshReport* refresh) noexcept {
     const state::activity::membership::ClientPlacement placement =
         client_placement(session, refresh);
     const std::int32_t held = state::activity::membership::instantiated_region(placement);
@@ -860,7 +860,16 @@ bool client_in_world(const Session& session, const RefreshReport* refresh) noexc
                              && lease.bindingGeneration == session.activity.bindingGeneration
                              && lease.regionArrivalPending
                              && static_cast<std::int64_t>(lease.plan.effectiveRegion) != held;
-    return !movePending && held >= 0 && placement.entered;
+    return !movePending && held >= 0;
+}
+
+/** Tests whether the client has completed spawning into its instantiated region. */
+bool client_in_world(const Session& session, const RefreshReport* refresh) noexcept {
+    // World-state 8 is a post-spawn signal. It remains the readiness boundary for gameplay work,
+    // but must not be used by the roster fields that decide whether the spawn itself may run.
+    const state::activity::membership::ClientPlacement placement =
+        client_placement(session, refresh);
+    return placement.entered && client_region_ready(session, refresh);
 }
 
 /** Merges one staged squad body after the complete cumulative frame reached transport output. */
@@ -1363,10 +1372,10 @@ build_roster_snapshot(Session& session,
     // carries matches nothing.
     snapshot.playerKey = published_player_key(session);
     snapshot.lifetime = lifetimeState;
-    // The host orders the spawn. `awaiting_client_sync` holds the native spawn gate while the
-    // client loads, and clears once it reports holding its region. The spawn then always lands
-    // after the fade arms and the native fade release in the spawn picker runs.
-    snapshot.awaitClientSync = !client_in_world(session, refresh);
+    // The host orders the spawn. `awaiting_client_sync` holds the native spawn gate only until the
+    // region is instantiated. World-state 8 is written after spawning, so waiting for it here
+    // deadlocks the proper spawn path until the client's unavailable-state timeout fires.
+    snapshot.awaitClientSync = !client_region_ready(session, refresh);
     // Player_BindComponents walks every type-13 reference and the player datum can name any one of
     // them. So every participation record carries the same player key. Selecting the first slot
     // leaves the authored cinematic participant unbound whenever it names another record.

+ 17 - 3
Sunrise/src/server/bap/encrypted/push/activity/internal.h

@@ -50,13 +50,15 @@ prepare_seed_identity(std::uint64_t sessionId,
  * Builds the membership snapshot a first join commits, without reading State.
  * The join burst stages this body before the join commit; the commit then lands the same
  * seed identity, so the body and the record agree at the initial revision.
+ * @param createdRevision Activity record generation used as the replacement-world epoch.
  * @param memberKey Client member key from the join request.
  * @param characterSoid Character the join request named, or zero.
  * @param mutation Cleared, then receives the snapshot alone; nothing here is committable.
  * @return True when the key is usable.
  */
 [[nodiscard]] bool
-prepare_join_seed_snapshot(std::uint64_t memberKey,
+prepare_join_seed_snapshot(std::uint64_t createdRevision,
+                           std::uint64_t memberKey,
                            std::uint64_t characterSoid,
                            state::activity::membership::PendingMutation& mutation) noexcept;
 
@@ -154,16 +156,18 @@ select_activity_client_region(ActivityClientRole role,
     return region;
 }
 
-/** The client's message-18 refresh, read from its mutation while the commit is still pending. */
+/** Client placement fields read from a mutation while its State commit is still pending. */
 struct RefreshReport final {
     std::int32_t bubble{};
     std::uint32_t revision{};
+    std::int32_t currentRegion{state::activity::membership::kAbsentRegionIndex};
+    bool hasCurrentRegion{};
 };
 
 /**
  * Reads where the client says it is.
  * @param session Connection whose activity session the client reports on.
- * @param refresh Refresh being answered, which stands in for its uncommitted bubble, or null.
+ * @param refresh Report being answered, which stands in for its uncommitted placement, or null.
  */
 [[nodiscard]] state::activity::membership::ClientPlacement
 client_placement(const Session& session, const RefreshReport* refresh) noexcept;
@@ -176,6 +180,16 @@ client_placement(const Session& session, const RefreshReport* refresh) noexcept;
  */
 [[nodiscard]] bool client_in_world(const Session& session, const RefreshReport* refresh) noexcept;
 
+/**
+ * Tests whether the client's destination region is instantiated far enough for the native spawn.
+ * Unlike `client_in_world`, this deliberately does not require the post-spawn world-state 8
+ * write-back: requiring that signal to release the spawn creates a circular wait.
+ * @param session Connection whose activity session the client reports on.
+ * @param refresh Refresh being answered, or null.
+ */
+[[nodiscard]] bool client_region_ready(const Session& session,
+                                       const RefreshReport* refresh) noexcept;
+
 /**
  * Resolves the exact region one selected BAP ActivityClient would put in msg 5.
  * @param session Lock-owned authenticated connection state.

+ 26 - 37
Sunrise/src/server/bap/encrypted/push/queuez/queuez_banner_push.cpp

@@ -1,5 +1,6 @@
 #include <array>
 #include <cstdio>
+#include <limits>
 #include <span>
 
 #include "../../../../../core/logging/log.h"
@@ -10,7 +11,6 @@
 #include "../../../../../state/runtime/runtime.h"
 #include "../../queuez/queuez_state_validation.h"
 #include "../snapshot/snapshot.h"
-#include "queuez_push_reporting.h"
 #include "queuez_update_frame.h"
 
 namespace sunrise::server::bap::encrypted::push {
@@ -56,13 +56,12 @@ void report_fail(const char* stage, const char* reason) noexcept {
 [[nodiscard]] bool append_appearance_frame(Scratch& scratch,
                                            const queuez::CharacterAppearanceRefresh& refresh,
                                            snapshot::Prepared& prepared,
-                                           const char* stage,
+                                           [[maybe_unused]] 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
@@ -74,8 +73,10 @@ void report_fail(const char* stage, const char* reason) noexcept {
         || prepared.family.type != queuez::kBannerFamilyType
         || prepared.family.rootSoid != refresh.after.family4RootSoid
         || prepared.family.version != refresh.after.family0Version || prepared.family.flags != 0
+        // The replacement begins with upstream's codec-free delete, not a raw upsert.
+        // Rejecting that marker drops the entire appearance refresh (including titles).
         || (replacement
-            && prepared.family.objects.front().encoding != middleware::queuez::Encoding::raw)
+            && prepared.family.objects.front().encoding != middleware::queuez::Encoding::none)
         || prepared.family.objects[characterIndex].id
                != middleware::datagen::kBannerCharacterObjectId
         || prepared.family.objects[characterIndex].version != refresh.characterSoid
@@ -97,7 +98,6 @@ void report_fail(const char* stage, const char* reason) noexcept {
         return false;
     }
     middleware::secure_channel::advance_nonce(nonce);
-    queuez_report::push(stage, queuez::kBannerFamilyType, objectCount, written - beforeBytes, 1);
     return true;
 }
 
@@ -106,14 +106,13 @@ void report_fail(const char* stage, const char* reason) noexcept {
 append_roster_appearance_frame(Scratch& scratch,
                                const queuez::RosterAppearanceRefresh& refresh,
                                snapshot::Prepared& prepared,
-                               const char* stage,
+                               [[maybe_unused]] 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
@@ -137,7 +136,6 @@ append_roster_appearance_frame(Scratch& scratch,
         return false;
     }
     middleware::secure_channel::advance_nonce(nonce);
-    queuez_report::push(stage, queuez::kRosterFamilyType, objectCount, written - beforeBytes, 1);
     return true;
 }
 
@@ -171,9 +169,6 @@ bool append_banner_notification(Scratch& scratch,
     // accepts a snapshot for about ten seconds, then clears the family and refuses every later
     // one, so holding the pair for the pick spends that window and the subscription times out.
     if (state::account::banner_character_soid(state::account_snapshot()) == 0) {
-        core::log::write(core::log::Channel::server,
-                         core::log::Level::info,
-                         "ev=queuez stage=banner result=skip reason=nocharacter");
         return false;
     }
     snapshot::Prepared prepared{};
@@ -185,8 +180,6 @@ bool append_banner_notification(Scratch& scratch,
                          "ev=queuez stage=banner result=fail reason=prepare");
         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,
@@ -210,11 +203,6 @@ bool append_banner_notification(Scratch& scratch,
         after.family0Character = delivered;
         after.family0Version = queuez::kInitialFamilyVersion;
     }
-    queuez_report::push("banner",
-                        prepared.family.type,
-                        objectCount,
-                        written - beforeBytes,
-                        queuez_report::kNoRecordOutcome);
     return true;
 }
 
@@ -262,6 +250,11 @@ bool append_banner_move_notification(Scratch& scratch,
     // because deleting the key the same frame re-adds tears the family down.
     const bool republish = !publish;
     if (republish) {
+        if (before.family0Version == (std::numeric_limits<std::int32_t>::max)()) {
+            report_skip("version_exhausted");
+            after = before;
+            return false;
+        }
         incremental = false;
         after.family0Version = before.family0Version + 1;
     }
@@ -278,8 +271,6 @@ bool append_banner_move_notification(Scratch& scratch,
         after = before;
         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,
@@ -293,11 +284,6 @@ bool append_banner_move_notification(Scratch& scratch,
         return false;
     }
     middleware::secure_channel::advance_nonce(nonce);
-    queuez_report::push(stage,
-                        prepared.family.type,
-                        objectCount,
-                        written - beforeBytes,
-                        queuez_report::kNoRecordOutcome);
     return true;
 }
 
@@ -386,10 +372,11 @@ bool append_subclass_appearance_refresh_notification(
     constexpr std::size_t kSubclassSlot =
         static_cast<std::size_t>(state::account::inventory::EquipmentSlot::subclass);
     if (!mutation.prepared || mutation.characterSoid != refresh.characterSoid
-        || kSubclassSlot >= mutation.afterCharacter.equipment.slots.size()
-        || !mutation.afterCharacter.equipment.slots[kSubclassSlot].has_value()
-        || mutation.afterCharacter.equipment.slots[kSubclassSlot]->instanceSoid
-               != mutation.subclassInstanceSoid) {
+        || kSubclassSlot >= mutation.afterCharacter.equipment.slots.size()) {
+        return false;
+    }
+    const auto& subclass = mutation.afterCharacter.equipment.slots[kSubclassSlot];
+    if (!subclass.has_value() || subclass->instanceSoid != mutation.subclassInstanceSoid) {
         return false;
     }
     state::build_data::items::details::Definition detail{};
@@ -448,10 +435,11 @@ bool append_socket_roster_refresh_notification(Scratch& scratch,
                                                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) {
+        || mutation.itemIndex >= mutation.afterCharacter.equipment.slots.size()) {
+        return false;
+    }
+    const auto& target = mutation.afterCharacter.equipment.slots[mutation.itemIndex];
+    if (!target.has_value() || target->instanceSoid != mutation.targetInstanceSoid) {
         return false;
     }
     snapshot::Prepared prepared{};
@@ -476,10 +464,11 @@ bool append_subclass_roster_refresh_notification(Scratch& scratch,
         static_cast<std::size_t>(state::account::inventory::EquipmentSlot::subclass);
     if (!mutation.prepared || refresh.includeRoster
         || mutation.characterSoid != refresh.characterSoid
-        || kSubclassSlot >= mutation.afterCharacter.equipment.slots.size()
-        || !mutation.afterCharacter.equipment.slots[kSubclassSlot].has_value()
-        || mutation.afterCharacter.equipment.slots[kSubclassSlot]->instanceSoid
-               != mutation.subclassInstanceSoid) {
+        || kSubclassSlot >= mutation.afterCharacter.equipment.slots.size()) {
+        return false;
+    }
+    const auto& subclass = mutation.afterCharacter.equipment.slots[kSubclassSlot];
+    if (!subclass.has_value() || subclass->instanceSoid != mutation.subclassInstanceSoid) {
         return false;
     }
     snapshot::Prepared prepared{};

+ 0 - 33
Sunrise/src/server/bap/encrypted/push/queuez/queuez_push_reporting.cpp

@@ -8,8 +8,6 @@
 namespace sunrise::server::bap::encrypted::push::queuez_report {
 namespace {
 
-/** Widest push report, sized for every field below. */
-constexpr std::size_t kReportLimit = 144;
 /** Every step report fits one short line. */
 constexpr std::size_t kStepLimit = 96;
 
@@ -30,37 +28,6 @@ void report_step(const char* result, const char* step) noexcept {
 
 } // namespace
 
-/** Reports one appended push with the fields a reference capture can be compared against. */
-void push(const char* stage,
-          unsigned family,
-          std::size_t objects,
-          std::size_t bytes,
-          int recorded) noexcept {
-    std::array<char, kReportLimit> line{};
-    const int written =
-        recorded < 0 ? std::snprintf(line.data(),
-                                     line.size(),
-                                     "ev=queuez stage=%s result=ok family=%u objects=%zu bytes=%zu",
-                                     stage,
-                                     family,
-                                     objects,
-                                     bytes)
-                     : std::snprintf(line.data(),
-                                     line.size(),
-                                     "ev=queuez stage=%s result=ok family=%u objects=%zu bytes=%zu "
-                                     "recorded=%d",
-                                     stage,
-                                     family,
-                                     objects,
-                                     bytes,
-                                     recorded);
-    if (written > 0) {
-        core::log::write(core::log::Channel::server,
-                         core::log::Level::info,
-                         {line.data(), static_cast<std::size_t>(written)});
-    }
-}
-
 /** Reports one subscription step that produced no frame. */
 void subscription_failure(const char* step) noexcept {
     report_step("fail", step);

+ 0 - 21
Sunrise/src/server/bap/encrypted/push/queuez/queuez_push_reporting.h

@@ -1,28 +1,7 @@
 #pragma once
 
-#include <cstddef>
-
 namespace sunrise::server::bap::encrypted::push::queuez_report {
 
-/** Passed as the recorded flag when the stage has no record outcome to report. */
-inline constexpr int kNoRecordOutcome = -1;
-
-/**
- * Reports one appended push with the fields a reference capture can be compared against.
- * Object count and framed size tell a full snapshot from a stub, and neither can be read from the
- * frame once it is encrypted.
- * @param stage Push stage name.
- * @param family Family type the frame carries.
- * @param objects Object count in the frame.
- * @param bytes Bytes the frame added to the response, framing included.
- * @param recorded Record outcome, or a negative value when the stage has none.
- */
-void push(const char* stage,
-          unsigned family,
-          std::size_t objects,
-          std::size_t bytes,
-          int recorded) noexcept;
-
 /** Reports one subscription step that produced no frame. */
 void subscription_failure(const char* step) noexcept;
 

+ 296 - 82
Sunrise/src/server/bap/encrypted/push/queuez/queuez_select_character.cpp

@@ -1,7 +1,9 @@
 #include <array>
 #include <cstdio>
+#include <optional>
 
 #include "../../../../../core/logging/log.h"
+#include "../../../../../middleware/datagen/definitions.h"
 #include "../../queuez/queuez_state_validation.h"
 #include "../snapshot/internal.h"
 #include "queuez_push_reporting.h"
@@ -9,16 +11,7 @@
 
 namespace sunrise::server::bap::encrypted::push {
 
-/**
- * Appends the opcode-504 Family-4 move as one increment above the peer's current version.
- * @param scratch Lock-owned transform buffers.
- * @param select Staged after-image, object definitions, and both character keys.
- * @param key Active AES-GCM session key.
- * @param nonce Push-direction nonce after the correlated svc-11 response.
- * @param response Caller-owned output containing the existing response prefix.
- * @param written Existing byte count, updated after the complete push is appended.
- * @return True when the 3 object operations and the whole svc-123 frame fit.
- */
+/** Appends the opcode-504 Family-4 character-selection update. */
 bool append_select_character_notification(Scratch& scratch,
                                           const queuez::SelectCharacter& select,
                                           std::span<const std::byte, state::kAesKeySize> key,
@@ -29,8 +22,6 @@ bool append_select_character_notification(Scratch& scratch,
     if (!snapshot::prepare_selection_move(scratch, select, prepared)) {
         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,
@@ -41,44 +32,24 @@ bool append_select_character_notification(Scratch& scratch,
                               written)) {
         return false;
     }
-    queuez_report::push(
-        "select", queuez::kAccountFamilyType, objectCount, written - beforeBytes, 1);
     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 {
+bool append_equipment_swap_notification(
+    Scratch& scratch,
+    const queuez::EquipmentSwap& swap,
+    const state::PendingEquipmentSwap& mutation,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    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)) {
+    if (!snapshot::prepare_equipment_swap(
+            scratch, swap, mutation, acquisitionPresentationRows, 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,
@@ -89,7 +60,6 @@ bool append_equipment_swap_notification(Scratch& scratch,
                               written)) {
         return false;
     }
-    queuez_report::push("equip", queuez::kAccountFamilyType, objectCount, written - beforeBytes, 1);
     return true;
 }
 
@@ -106,7 +76,6 @@ bool append_current_activity_notification(Scratch& scratch,
         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 != swap.characterDefinitionId
         || prepared.family.objects.front().version != swap.characterSoid
         || prepared.family.objects.front().payload.empty()
@@ -134,25 +103,25 @@ bool append_current_activity_notification(Scratch& scratch,
                          core::log::Level::info,
                          {line.data(), static_cast<std::size_t>(count)});
     }
-    queuez_report::push(
-        "current_activity", 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 {
+bool append_item_state_notification(
+    Scratch& scratch,
+    const queuez::EquipmentSwap& update,
+    const state::PendingItemState& mutation,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    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)) {
+    if (!snapshot::prepare_item_state(
+            scratch, update, mutation, acquisitionPresentationRows, 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
@@ -167,11 +136,91 @@ bool append_item_state_notification(Scratch& scratch,
                                  written)) {
         return false;
     }
-    queuez_report::push(
-        "item_state", queuez::kAccountFamilyType, objectCount, written - beforeBytes, 1);
     return true;
 }
 
+/** Appends one opcode-901 selected-character artifact ownership upsert. */
+bool append_artifact_purchase_notification(
+    Scratch& scratch,
+    const queuez::EquipmentSwap& update,
+    const state::PendingArtifactPurchase& mutation,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    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_artifact_purchase(
+            scratch, update, mutation, acquisitionPresentationRows, prepared)) {
+        return false;
+    }
+    return prepared.family.objects.size() == 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);
+}
+
+/** Appends a reset increment without the full-snapshot acquisition semantics. */
+bool append_artifact_reset_notification(
+    Scratch& scratch,
+    const queuez::EquipmentSwap& update,
+    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_artifact_reset(scratch, update, prepared)) {
+        return false;
+    }
+    return prepared.family.objects.size() == 2
+           && prepared.family.objects[1].id == update.characterDefinitionId
+           && prepared.family.objects[1].version == update.characterSoid
+           && queuez_frame::append(scratch,
+                                   prepared.family,
+                                   prepared.rawClearSize,
+                                   prepared.compressedClearSize,
+                                   key,
+                                   nonce,
+                                   response,
+                                   written);
+}
+
+/** Appends one exact resident upsert after artifact reset. */
+bool append_artifact_item_refresh_notification(
+    Scratch& scratch,
+    const queuez::EquipmentSwap& update,
+    std::uint64_t instanceSoid,
+    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_artifact_item_refresh(scratch, update, instanceSoid, prepared)) {
+        return false;
+    }
+    return prepared.family.objects.size() == 1
+           && prepared.family.objects.front().version == instanceSoid
+           && 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);
+}
+
 /** Appends one socket item upsert and its charged account balances when required. */
 bool append_socket_plug_notification(Scratch& scratch,
                                      const queuez::SocketPlug& socketPlug,
@@ -186,7 +235,6 @@ bool append_socket_plug_notification(Scratch& scratch,
     }
     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
@@ -207,8 +255,6 @@ bool append_socket_plug_notification(Scratch& scratch,
                                  written)) {
         return false;
     }
-    queuez_report::push(
-        "socket_plug", queuez::kAccountFamilyType, objectCount, written - beforeBytes, 1);
     return true;
 }
 
@@ -225,7 +271,6 @@ bool append_subclass_selection_notification(Scratch& scratch,
         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 != selection.itemInstanceDefinitionId
         || prepared.family.objects.front().version != selection.subclassInstanceSoid
         || prepared.family.objects.front().encoding != middleware::queuez::Encoding::oodle
@@ -240,25 +285,30 @@ bool append_subclass_selection_notification(Scratch& scratch,
                                  written)) {
         return false;
     }
-    queuez_report::push(
-        "subclass_select", 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 {
+bool append_item_acquisition_notification(
+    Scratch& scratch,
+    const queuez::ItemAcquisition& acquisition,
+    const state::PendingItemAcquisition& mutation,
+    std::optional<std::uint16_t> pendingSeasonReward,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    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)) {
+    if (!snapshot::prepare_item_acquisition(scratch,
+                                            acquisition,
+                                            mutation,
+                                            pendingSeasonReward,
+                                            acquisitionPresentationRows,
+                                            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
@@ -284,8 +334,6 @@ bool append_item_acquisition_notification(Scratch& scratch,
                                  written)) {
         return false;
     }
-    queuez_report::push(
-        "acquire", queuez::kAccountFamilyType, objectCount, written - beforeBytes, 1);
     return true;
 }
 
@@ -294,16 +342,17 @@ bool append_profile_item_acquisition_notification(
     Scratch& scratch,
     const queuez::ProfileItemAcquisition& acquisition,
     const state::PendingProfileItemAcquisition& mutation,
+    std::optional<std::uint16_t> pendingSeasonReward,
     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)) {
+    if (!snapshot::prepare_profile_item_acquisition(
+            scratch, acquisition, mutation, pendingSeasonReward, 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
@@ -329,11 +378,179 @@ bool append_profile_item_acquisition_notification(
                                  written)) {
         return false;
     }
-    queuez_report::push(
-        "profile_acquire", queuez::kAccountFamilyType, objectCount, written - beforeBytes, 1);
     return true;
 }
 
+/** Publishes the transient XP row and its matching account progression in one increment. */
+bool append_seasonal_experience_notification(
+    Scratch& scratch,
+    const queuez::SessionState& before,
+    std::int32_t amount,
+    std::int32_t mutationSerial,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    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,
+    queuez::SessionState& after) noexcept {
+    after = before;
+    snapshot::Prepared prepared{};
+    if (!snapshot::prepare_seasonal_experience_presentation(
+            scratch, before, amount, mutationSerial, acquisitionPresentationRows, prepared)) {
+        return false;
+    }
+    const std::size_t objectCount = prepared.family.objects.size();
+    if (objectCount != 2 || prepared.family.type != queuez::kAccountFamilyType
+        || prepared.family.rootSoid != before.family4RootSoid
+        || prepared.family.version != before.family4Version + 1 || prepared.family.flags != 0
+        || prepared.family.objects[0].version != before.family4RootSoid
+        || prepared.family.objects[0].id != before.family4Residents.front().definitionId
+        || prepared.family.objects[0].payload.empty()
+        || prepared.family.objects[1].version == before.family4RootSoid
+        || prepared.family.objects[1].payload.empty()
+        || !queuez_frame::append(scratch,
+                                 prepared.family,
+                                 prepared.rawClearSize,
+                                 prepared.compressedClearSize,
+                                 key,
+                                 nonce,
+                                 response,
+                                 written)) {
+        return false;
+    }
+    after.family4Version = prepared.family.version;
+    if (!queuez::valid(after)) {
+        after = before;
+        return false;
+    }
+    return true;
+}
+
+/** Publishes every item in one rank-one class package as an ordinary acquisition. */
+bool append_season_pass_package_notification(
+    Scratch& scratch,
+    const queuez::SessionState& before,
+    const state::PendingDirectItemBundle& mutation,
+    std::uint16_t rewardIndex,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    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,
+    queuez::SessionState& after) noexcept {
+    after = before;
+    snapshot::Prepared prepared{};
+    const std::size_t itemCount = mutation.itemCount;
+    if (!snapshot::prepare_season_pass_package(
+            scratch, before, mutation, rewardIndex, acquisitionPresentationRows, prepared)
+        || itemCount == 0 || prepared.family.objects.size() != itemCount + 2U
+        || prepared.family.type != queuez::kAccountFamilyType
+        || prepared.family.rootSoid != before.family4RootSoid
+        || prepared.family.version != before.family4Version + 1 || prepared.family.flags != 0
+        || before.family4ResidentCount + itemCount > before.family4Residents.size()) {
+        return false;
+    }
+
+    std::uint32_t itemDefinitionId = 0;
+    if (!middleware::datagen::object_id(
+            queuez::kAccountFamilyType, middleware::datagen::kItemInstanceSlot, itemDefinitionId)) {
+        return false;
+    }
+    for (std::size_t index = 0; index < itemCount; ++index) {
+        const middleware::queuez::Object& object = prepared.family.objects[index];
+        if (object.id != itemDefinitionId || object.version != mutation.firstInstanceSoid + index
+            || object.payload.empty()) {
+            return false;
+        }
+        for (std::size_t residentIndex = 0; residentIndex < before.family4ResidentCount;
+             ++residentIndex) {
+            if (before.family4Residents[residentIndex].objectSoid == object.version) {
+                return false;
+            }
+        }
+        after.family4Residents[after.family4ResidentCount++] =
+            queuez::ResidentObject{object.version, object.id};
+    }
+    const middleware::queuez::Object& characterObject = prepared.family.objects[itemCount];
+    const middleware::queuez::Object& accountObject = prepared.family.objects[itemCount + 1U];
+    std::size_t characterMatches = 0;
+    for (std::size_t index = 0; index < before.family4ResidentCount; ++index) {
+        const queuez::ResidentObject& resident = before.family4Residents[index];
+        characterMatches +=
+            static_cast<std::size_t>(resident.objectSoid == characterObject.version
+                                     && resident.definitionId == characterObject.id);
+    }
+    if (characterMatches != 1 || characterObject.payload.empty()
+        || accountObject.version != before.family4RootSoid
+        || accountObject.id != before.family4Residents.front().definitionId
+        || accountObject.payload.empty()) {
+        return false;
+    }
+    after.family4Version = prepared.family.version;
+    if (!queuez::valid(after)
+        || !queuez_frame::append(scratch,
+                                 prepared.family,
+                                 prepared.rawClearSize,
+                                 prepared.compressedClearSize,
+                                 key,
+                                 nonce,
+                                 response,
+                                 written)) {
+        after = before;
+        return false;
+    }
+    return true;
+}
+
+/** Publishes one prepared record-reward batch. */
+bool append_record_reward_notification(
+    Scratch& scratch,
+    const queuez::SessionState& before,
+    const queuez::RecordRewardGrant& update,
+    const state::PendingRecordRewardGrant& mutation,
+    std::optional<std::uint16_t> pendingSeasonReward,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    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_record_reward_grant(scratch,
+                                               before,
+                                               update,
+                                               mutation,
+                                               pendingSeasonReward,
+                                               acquisitionPresentationRows,
+                                               prepared)
+        || prepared.family.type != queuez::kAccountFamilyType
+        || prepared.family.rootSoid != before.family4RootSoid
+        || prepared.family.version != before.family4Version + 1 || prepared.family.flags != 0
+        || prepared.family.objects.size() != update.appendedResidentCount + 2U) {
+        return false;
+    }
+    for (std::size_t index = 0; index < update.appendedResidentCount; ++index) {
+        const auto& object = prepared.family.objects[index];
+        const auto& resident = update.after.family4Residents[before.family4ResidentCount + index];
+        if (object.id != update.itemInstanceDefinitionId || object.version != resident.objectSoid
+            || object.payload.empty()) {
+            return false;
+        }
+    }
+    const auto& character = prepared.family.objects[update.appendedResidentCount];
+    const auto& account = prepared.family.objects[update.appendedResidentCount + 1U];
+    return character.id == update.characterDefinitionId && character.version == update.characterSoid
+           && !character.payload.empty() && account.id == update.accountDefinitionId
+           && account.version == update.accountSoid && !account.payload.empty()
+           && queuez_frame::append(scratch,
+                                   prepared.family,
+                                   prepared.rawClearSize,
+                                   prepared.compressedClearSize,
+                                   key,
+                                   nonce,
+                                   response,
+                                   written);
+}
+
 /** Appends one atomic dismantle update, including any account-wide material payout. */
 bool append_item_dismantle_notification(Scratch& scratch,
                                         const queuez::ItemDismantle& dismantle,
@@ -347,7 +564,6 @@ bool append_item_dismantle_notification(Scratch& scratch,
         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
@@ -371,8 +587,6 @@ bool append_item_dismantle_notification(Scratch& scratch,
                                  written)) {
         return false;
     }
-    queuez_report::push(
-        "dismantle", queuez::kAccountFamilyType, objectCount, written - beforeBytes, 1);
     return true;
 }
 

+ 16 - 30
Sunrise/src/server/bap/encrypted/push/queuez/queuez_subscription.cpp

@@ -2,7 +2,6 @@
 #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"
@@ -38,7 +37,7 @@ namespace {
     companion.familyRootSoid = familyRootSoid;
 
     snapshot::Prepared prepared{};
-    if (!snapshot::prepare_initial(scratch, companion, prepared)) {
+    if (!snapshot::prepare_initial(scratch, companion, {}, prepared)) {
         core::log::write(core::log::Channel::server,
                          core::log::Level::warn,
                          "ev=queuez stage=companion result=fail reason=prepare");
@@ -54,8 +53,6 @@ namespace {
     if (!recorded) {
         staged = before;
     }
-    const std::size_t objectCount = prepared.family.objects.size();
-    const std::size_t beforeBytes = written;
     if (!queuez_frame::append(scratch,
                               prepared.family,
                               prepared.rawClearSize,
@@ -71,24 +68,21 @@ namespace {
     }
     middleware::secure_channel::advance_nonce(nonce);
     after = staged;
-    queuez_report::push("companion",
-                        queuez::kAccountFamilyType,
-                        objectCount,
-                        written - beforeBytes,
-                        recorded ? 1 : 0);
     return true;
 }
 
 } // 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 {
+bool append_account_resync_notification(
+    Scratch& scratch,
+    const queuez::SessionState& before,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    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;
     ensure_account_canonical();
     if (!queuez::valid(before) || !before.family4Active || before.family4RootSoid == 0
@@ -96,13 +90,14 @@ bool append_account_resync_notification(Scratch& scratch,
         return false;
     }
     snapshot::Prepared prepared{};
-    if (!snapshot::prepare_family4_refresh(
-            scratch, before.family4RootSoid, before.family4Version + 1, prepared)
+    if (!snapshot::prepare_family4_refresh(scratch,
+                                           before.family4RootSoid,
+                                           before.family4Version + 1,
+                                           acquisitionPresentationRows,
+                                           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,
@@ -115,8 +110,6 @@ bool append_account_resync_notification(Scratch& scratch,
         return false;
     }
     middleware::secure_channel::advance_nonce(nonce);
-    queuez_report::push(
-        "peer_resync", queuez::kAccountFamilyType, objectCount, written - beforeBytes, 1);
     return true;
 }
 
@@ -204,7 +197,7 @@ void append_queuez_notification(Scratch& scratch,
             queuez_report::subscription_failure("prepare_banner");
             return;
         }
-    } else if (!snapshot::prepare_initial(scratch, subscription, prepared)) {
+    } else if (!snapshot::prepare_initial(scratch, subscription, {}, prepared)) {
         queuez_report::subscription_failure("prepare");
         return;
     }
@@ -241,8 +234,6 @@ void append_queuez_notification(Scratch& scratch,
         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,
                               prepared.family,
                               prepared.rawClearSize,
@@ -255,11 +246,6 @@ void append_queuez_notification(Scratch& scratch,
         return;
     }
     middleware::secure_channel::advance_nonce(nonce);
-    queuez_report::push("snapshot",
-                        subscription.familyType,
-                        objectCount,
-                        written - beforeBytes,
-                        queuez_report::kNoRecordOutcome);
     after = stagedAfter;
     // The client sends its subscribe just before it writes the record state, so this first copy
     // arrives while the record still reads its previous state and is refused. Family zero has

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

@@ -1,9 +1,6 @@
 #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"
@@ -232,31 +229,6 @@ bool prepare_character_appearance_refresh(Scratch& scratch,
         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;
 }
 

+ 659 - 129
Sunrise/src/server/bap/encrypted/push/snapshot/family4_inventory_updates.cpp

@@ -1,18 +1,21 @@
 #include <algorithm>
 #include <array>
 #include <cstddef>
-#include <cstdio>
+#include <limits>
 #include <optional>
 #include <span>
 
-#include "../../../../../core/logging/log.h"
+#include "../../../../../middleware/datagen/definitions.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/build_data/runtime.h"
+#include "../../../../../state/progression/season_pass_reward_catalog.h"
 #include "../../../../../state/runtime/runtime.h"
+#include "../../queuez/queuez_state_validation.h"
 #include "internal.h"
 #include "snapshot_storage.h"
 
@@ -20,6 +23,10 @@ namespace sunrise::server::bap::encrypted::push::snapshot {
 
 namespace family4_datagen = middleware::datagen::family4;
 
+constexpr auto kChangeRecordIsZero = [](const auto& record) noexcept {
+    return record.sequence == 0 && record.reserved == 0 && record.mutationSerial == 0
+           && record.kind == 0 && record.reservedKind == 0 && record.flags == 0;
+};
 namespace {
 
 namespace account_layout = middleware::datagen::family4::account::layout;
@@ -140,6 +147,7 @@ void name_row(account_layout::ProfileInventoryChangeRecord& record,
 bool prepare_profile_item_acquisition(Scratch& scratch,
                                       const queuez::ProfileItemAcquisition& acquisition,
                                       const state::PendingProfileItemAcquisition& mutation,
+                                      std::optional<std::uint16_t> pendingSeasonReward,
                                       Prepared& prepared) noexcept {
     const Reservation reservation = reserve_prior(scratch, prepared);
     if (reservation.rawWriteOffset > scratch.plaintext.size()
@@ -167,7 +175,7 @@ bool prepare_profile_item_acquisition(Scratch& scratch,
         return report_failure("profile_acquire_account_storage");
     }
     const auto accountBytes = rawStorage.first(family4_datagen::account::layout::kObjectSize);
-    if (!family4_datagen::account::encode(account, accountBytes)) {
+    if (!family4_datagen::account::encode(account, accountBytes, pendingSeasonReward)) {
         return report_failure("profile_acquire_account_encode");
     }
 
@@ -184,9 +192,6 @@ bool prepare_profile_item_acquisition(Scratch& scratch,
         clear_after(scratch, reservation);
         return report_failure(ringFailure);
     }
-    const std::uint16_t reportedChangeSlot = accountObject.profileInventoryChanges.writeSlot;
-    const std::uint16_t reportedChangeSequence =
-        accountObject.profileInventoryChanges.nextSequence;
 
     Prepared staged{};
     staged.rawClearSize =
@@ -237,41 +242,644 @@ bool prepare_profile_item_acquisition(Scratch& scratch,
         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>(reportedChangeSlot),
-        static_cast<unsigned>(reportedChangeSequence),
-        static_cast<unsigned>(kChangeKind),
-        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 the native XP pickup signal without making its one-slot reward item persistent. */
+bool prepare_seasonal_experience_presentation(
+    Scratch& scratch,
+    const queuez::SessionState& before,
+    std::int32_t amount,
+    std::int32_t mutationSerial,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    Prepared& prepared) noexcept {
+    constexpr std::uint32_t kExperienceItemHash = 2211488305U;
+    constexpr std::size_t kBitsPerFlagByte = 8;
+    constexpr std::int32_t kOccupiedRowWatermark = 1;
+    constexpr std::uint16_t kChangeSequence = 0;
+    constexpr std::uint16_t kChangeNextWriteSlot = 1;
+    constexpr std::uint16_t kChangeNextSequence = 1;
+
+    if (amount <= 0 || mutationSerial < 0 || !queuez::valid(before) || !before.family4Active
+        || before.family4RootSoid == 0 || before.family4ResidentCount == 0
+        || before.family4Version == (std::numeric_limits<std::int32_t>::max)()) {
+        return report_failure("season_xp_session");
+    }
+
+    const state::AccountState account = state::account_snapshot();
+    const std::optional<std::size_t> selectedIndex = find_character_index(account);
+    Resolved selected{};
+    state::build_data::items::Definition item{};
+    state::build_data::items::details::Definition detail{};
+    state::build_data::inventory::buckets::Descriptor bucket{};
+    if (!state::account::valid(account) || account.primarySoid != before.family4RootSoid
+        || !selectedIndex.has_value() || !resolve(account, *selectedIndex, selected)
+        || !state::build_data::find_item_definition_hash(kExperienceItemHash, item)
+        || !state::build_data::find_configured_item_detail(item.definitionIndex, detail)
+        || detail.definitionIndex != item.definitionIndex
+        || detail.definitionHash != item.definitionHash || detail.bucketId != item.bucketId
+        || detail.instancedDefinitionState
+               != state::build_data::items::details::InstancedDefinitionState::stackable
+        || detail.maxStackSize < 1 || detail.equipmentSlot.has_value()
+        || !state::build_data::find_inventory_bucket_descriptor(item.bucketId, bucket)
+        || bucket.arraySelector != state::build_data::inventory::buckets::ArraySelector::character
+        || bucket.slotCount != 1
+        || bucket.firstSlot
+               >= middleware::datagen::family4::character::layout::kInventoryCapacity) {
+        return report_failure("season_xp_definition");
+    }
+
+    const state::CharacterState& character = account.characters[*selectedIndex];
+    std::size_t characterResidentMatches = 0;
+    for (std::size_t index = 0; index < before.family4ResidentCount; ++index) {
+        const queuez::ResidentObject& resident = before.family4Residents[index];
+        characterResidentMatches +=
+            static_cast<std::size_t>(resident.objectSoid == character.soid
+                                     && resident.definitionId == selected.characterObjectId);
+    }
+    if (before.family4Residents.front().objectSoid != account.primarySoid
+        || characterResidentMatches != 1
+        || static_cast<std::uint32_t>(mutationSerial) >= character.nextInventorySerial) {
+        return report_failure("season_xp_manifest");
+    }
+
+    const Reservation reservation = reserve_prior(scratch, prepared);
+    const auto rawStorage = std::span(scratch.plaintext).subspan(reservation.rawWriteOffset);
+    if (middleware::datagen::family4::character::layout::kObjectSize > rawStorage.size()) {
+        return report_failure("season_xp_character_storage");
+    }
+    const auto characterBytes =
+        rawStorage.first(middleware::datagen::family4::character::layout::kObjectSize);
+    if (!middleware::datagen::family4::character::encode(
+            character, selected.loadout, selected.lightEvaluation, characterBytes)) {
+        return report_failure("season_xp_character_encode");
+    }
+
+    namespace character_layout = middleware::datagen::family4::character::layout;
+    auto& characterObject = *reinterpret_cast<character_layout::Object*>(characterBytes.data());
+    const std::size_t rowIndex = bucket.firstSlot;
+    auto& row = characterObject.inventoryItems[rowIndex];
+    if (row.definitionIndex != (std::numeric_limits<std::uint16_t>::max)()
+        || characterObject.inventoryChanges.writeSlot != 0
+        || characterObject.inventoryChanges.nextSequence != 0
+        || !std::all_of(characterObject.inventoryChanges.records.cbegin(),
+                        characterObject.inventoryChanges.records.cend(),
+                        kChangeRecordIsZero)) {
+        clear_after(scratch, reservation);
+        return report_failure("season_xp_character_state");
+    }
+
+    row.definitionIndex = item.definitionIndex;
+    // The virtual item carries the gain; the account object carries cumulative XP.
+    row.quantity = amount;
+    row.mutationSerial = mutationSerial;
+    characterObject.newItemFlags[rowIndex / kBitsPerFlagByte] |= std::byte{1U}
+                                                                 << (rowIndex % kBitsPerFlagByte);
+    characterObject.instanceProgressWatermarks[rowIndex] = kOccupiedRowWatermark;
+    characterObject.inventoryChanges.writeSlot = kChangeNextWriteSlot;
+    characterObject.inventoryChanges.nextSequence = kChangeNextSequence;
+    auto& change = characterObject.inventoryChanges.records.front();
+    change.sequence = kChangeSequence;
+    change.mutationSerial = mutationSerial;
+    change.kind = kChangeKind;
+    change.flags = kChangeFlags;
+    if (!apply_acquisition_presentation(
+            characterBytes, selected.loadout, acquisitionPresentationRows)) {
+        clear_after(scratch, reservation);
+        return report_failure("season_xp_presentation");
+    }
+
+    Prepared staged{};
+    staged.rawClearSize = (std::max)(reservation.rawClearSize,
+                                     reservation.rawWriteOffset + character_layout::kObjectSize);
+    std::size_t compressedExtent = reservation.compressedWriteOffset;
+    if (!append_object(scratch,
+                       characterBytes,
+                       selected.characterObjectId,
+                       character.soid,
+                       staged.objects[1],
+                       compressedExtent)) {
+        clear_after(scratch, reservation);
+        return report_failure("season_xp_character_object");
+    }
+
+    if (middleware::datagen::family4::account::layout::kObjectSize > rawStorage.size()) {
+        clear_after(scratch, reservation);
+        return report_failure("season_xp_account_storage");
+    }
+    const auto accountBytes =
+        rawStorage.first(middleware::datagen::family4::account::layout::kObjectSize);
+    // Publish cumulative account XP before the transient delta row triggers the HUD.
+    if (!middleware::datagen::family4::account::encode(account, accountBytes)
+        || !append_object(scratch,
+                          accountBytes,
+                          before.family4Residents.front().definitionId,
+                          account.primarySoid,
+                          staged.objects[0],
+                          compressedExtent)) {
+        clear_after(scratch, reservation);
+        return report_failure("season_xp_account_object");
+    }
+
+    staged.rawClearSize =
+        (std::max)(staged.rawClearSize,
+                   reservation.rawWriteOffset
+                       + middleware::datagen::family4::account::layout::kObjectSize);
+    staged.compressedClearSize = (std::max)(reservation.compressedClearSize, compressedExtent);
+    staged.family = middleware::queuez::Family{
+        queuez::kAccountFamilyType,
+        account.primarySoid,
+        before.family4Version + 1,
+        0,
+        std::span(staged.objects).first(2),
+    };
+    if (!commit(staged, prepared)) {
+        clear_after(scratch, reservation);
+        return report_failure("season_xp_commit");
+    }
+    return true;
+}
+
+/** Builds one atomic Season package update with one acquisition record per granted item. */
+bool prepare_season_pass_package(
+    Scratch& scratch,
+    const queuez::SessionState& before,
+    const state::PendingDirectItemBundle& mutation,
+    std::uint16_t rewardIndex,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    Prepared& prepared) noexcept {
+    namespace character_layout = family4_datagen::character::layout;
+    constexpr std::uint8_t kAcquisitionChangeKind = 1;
+    constexpr std::uint16_t kAcquisitionChangeFlags = 0;
+    const std::size_t itemCount = mutation.itemCount;
+    const auto* reward = state::progression::season_pass::find(rewardIndex);
+    if (itemCount == 0 || itemCount > character_layout::kInventoryChangeRecordCapacity
+        || !mutation.prepared || reward == nullptr || reward->quantity != 1
+        || reward->itemHash != mutation.sourceDefinitionHash
+        || state::progression::season_pass::find_premium_class_package(reward->itemHash) == nullptr
+        || !queuez::valid(before) || !before.family4Active || before.family4ResidentCount == 0
+        || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
+        || before.family4ResidentCount + itemCount > before.family4Residents.size()) {
+        return report_failure("season_package_session");
+    }
+
+    state::AccountState account{};
+    if (!state::preview_direct_item_bundle(mutation, account)) {
+        return report_failure("season_package_preview");
+    }
+    const std::optional<std::size_t> selectedIndex = find_character_index(account);
+    Resolved selected{};
+    if (!state::account::valid(account) || account.primarySoid != before.family4RootSoid
+        || account.primarySoid != mutation.accountSoid || !selectedIndex.has_value()
+        || *selectedIndex != mutation.characterIndex
+        || !resolve(account, *selectedIndex, selected)) {
+        return report_failure("season_package_account");
+    }
+    const state::CharacterState& character = account.characters[*selectedIndex];
+    if (character.soid != mutation.characterSoid
+        || mutation.expectedInventoryCount > character.inventory.count
+        || character.inventory.count != mutation.expectedInventoryCount + itemCount) {
+        return report_failure("season_package_count");
+    }
+
+    std::uint32_t itemInstanceObjectId = 0;
+    if (!middleware::datagen::object_id(queuez::kAccountFamilyType,
+                                        middleware::datagen::kItemInstanceSlot,
+                                        itemInstanceObjectId)
+        || before.family4Residents.front().objectSoid != account.primarySoid
+        || before.family4Residents.front().definitionId == 0) {
+        return report_failure("season_package_definitions");
+    }
+    std::size_t characterResidentMatches = 0;
+    for (std::size_t index = 0; index < before.family4ResidentCount; ++index) {
+        const queuez::ResidentObject& resident = before.family4Residents[index];
+        characterResidentMatches +=
+            static_cast<std::size_t>(resident.objectSoid == character.soid
+                                     && resident.definitionId == selected.characterObjectId);
+    }
+    if (characterResidentMatches != 1) {
+        return report_failure("season_package_character_resident");
+    }
+
+    family4_datagen::loadout::ResolvedInstances acquired{};
+    const std::size_t firstGranted = mutation.expectedInventoryCount;
+    for (std::size_t grantIndex = 0; grantIndex < itemCount; ++grantIndex) {
+        const auto& granted = character.inventory.values[firstGranted + grantIndex];
+        if (granted.instanceSoid != mutation.firstInstanceSoid + grantIndex
+            || granted.mutationSerial < 0) {
+            return report_failure("season_package_item_identity");
+        }
+        for (std::size_t residentIndex = 0; residentIndex < before.family4ResidentCount;
+             ++residentIndex) {
+            if (before.family4Residents[residentIndex].objectSoid == granted.instanceSoid) {
+                return report_failure("season_package_item_resident");
+            }
+        }
+        std::size_t matches = 0;
+        for (std::size_t loadoutIndex = 0; loadoutIndex < selected.loadout.itemCount;
+             ++loadoutIndex) {
+            const auto& resolvedItem = selected.loadout.items[loadoutIndex];
+            if (resolvedItem.instance.instanceSoid != granted.instanceSoid) {
+                continue;
+            }
+            acquired.items[grantIndex].equipmentSlot = resolvedItem.equipmentSlot;
+            acquired.items[grantIndex].instance = resolvedItem.instance;
+            if (resolvedItem.mutationSerial != granted.mutationSerial) {
+                return report_failure("season_package_item_serial");
+            }
+            ++matches;
+        }
+        if (matches != 1) {
+            return report_failure("season_package_item_loadout");
+        }
+    }
+    acquired.itemCount = itemCount;
+
+    const Reservation reservation = reserve_prior(scratch, prepared);
+    const auto rawStorage = std::span(scratch.plaintext).subspan(reservation.rawWriteOffset);
+    if (character_layout::kObjectSize > rawStorage.size()
+        || family4_datagen::account::layout::kObjectSize > rawStorage.size()) {
+        return report_failure("season_package_storage");
+    }
+
+    Prepared staged{};
+    std::size_t compressedExtent = reservation.compressedWriteOffset;
+    std::size_t itemCursor = 0;
+    if (!append_items(scratch,
+                      rawStorage,
+                      itemInstanceObjectId,
+                      acquired,
+                      0,
+                      staged,
+                      itemCursor,
+                      compressedExtent)
+        || itemCursor != itemCount) {
+        clear_after(scratch, reservation);
+        return report_failure("season_package_item_objects");
+    }
+
+    const auto characterBytes = rawStorage.first(character_layout::kObjectSize);
+    if (!family4_datagen::character::encode(
+            character, selected.loadout, selected.lightEvaluation, characterBytes)) {
+        clear_after(scratch, reservation);
+        return report_failure("season_package_character_encode");
+    }
+    auto& characterObject = *reinterpret_cast<character_layout::Object*>(characterBytes.data());
+    if (characterObject.inventoryChanges.writeSlot != 0
+        || characterObject.inventoryChanges.nextSequence != 0
+        || !std::all_of(characterObject.inventoryChanges.records.cbegin(),
+                        characterObject.inventoryChanges.records.cend(),
+                        kChangeRecordIsZero)) {
+        clear_after(scratch, reservation);
+        return report_failure("season_package_change_state");
+    }
+    for (std::size_t index = 0; index < itemCount; ++index) {
+        auto& change = characterObject.inventoryChanges.records[index];
+        change.sequence = static_cast<std::uint16_t>(index);
+        change.mutationSerial = character.inventory.values[firstGranted + index].mutationSerial;
+        change.kind = kAcquisitionChangeKind;
+        change.flags = kAcquisitionChangeFlags;
+    }
+    characterObject.inventoryChanges.writeSlot = static_cast<std::uint16_t>(itemCount);
+    characterObject.inventoryChanges.nextSequence = static_cast<std::uint16_t>(itemCount);
+    if (!apply_acquisition_presentation(
+            characterBytes, selected.loadout, acquisitionPresentationRows)) {
+        clear_after(scratch, reservation);
+        return report_failure("season_package_presentation");
+    }
+    if (!append_object(scratch,
+                       characterBytes,
+                       selected.characterObjectId,
+                       character.soid,
+                       staged.objects[itemCount],
+                       compressedExtent)) {
+        clear_after(scratch, reservation);
+        return report_failure("season_package_character_object");
+    }
+
+    const auto accountBytes = rawStorage.first(family4_datagen::account::layout::kObjectSize);
+    if (!family4_datagen::account::encode(account, accountBytes, rewardIndex)
+        || !append_object(scratch,
+                          accountBytes,
+                          before.family4Residents.front().definitionId,
+                          account.primarySoid,
+                          staged.objects[itemCount + 1U],
+                          compressedExtent)) {
+        clear_after(scratch, reservation);
+        return report_failure("season_package_account_object");
+    }
+
+    staged.rawClearSize =
+        (std::max)(reservation.rawClearSize,
+                   reservation.rawWriteOffset
+                       + (std::max)(character_layout::kObjectSize,
+                                    family4_datagen::account::layout::kObjectSize));
+    staged.compressedClearSize = (std::max)(reservation.compressedClearSize, compressedExtent);
+    staged.family = middleware::queuez::Family{queuez::kAccountFamilyType,
+                                               account.primarySoid,
+                                               before.family4Version + 1,
+                                               0,
+                                               std::span(staged.objects).first(itemCount + 2U)};
+    if (!commit(staged, prepared)) {
+        clear_after(scratch, reservation);
+        return report_failure("season_package_commit");
+    }
+    return true;
+}
+
+/** Builds all record rewards and the pending claim into one Family-4 revision. */
+bool prepare_record_reward_grant(
+    Scratch& scratch,
+    const queuez::SessionState& before,
+    const queuez::RecordRewardGrant& update,
+    const state::PendingRecordRewardGrant& mutation,
+    std::optional<std::uint16_t> pendingSeasonReward,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    Prepared& prepared) noexcept {
+    namespace account_layout = family4_datagen::account::layout;
+    namespace character_layout = family4_datagen::character::layout;
+    if (!mutation.prepared || mutation.rewardCount == 0
+        || mutation.rewardCount > mutation.rewards.size() || !queuez::valid(before)
+        || !queuez::valid(update.after) || !before.family4Active || before.family4ResidentCount == 0
+        || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
+        || update.accountSoid != mutation.accountSoid
+        || update.characterSoid != mutation.characterSoid
+        || update.accountSoid != before.family4RootSoid
+        || update.after.family4Version != before.family4Version + 1
+        || update.appendedResidentCount > mutation.rewardCount
+        || update.after.family4ResidentCount
+               != before.family4ResidentCount + update.appendedResidentCount
+        || update.accountDefinitionId != before.family4Residents.front().definitionId
+        || update.characterDefinitionId == 0 || update.itemInstanceDefinitionId == 0) {
+        return report_failure("record_reward_session");
+    }
+
+    state::AccountState account{};
+    Resolved selected{};
+    if (!state::preview_record_reward_grant(mutation, account)
+        || mutation.characterIndex >= account.characterCount
+        || account.primarySoid != update.accountSoid
+        || account.characters[mutation.characterIndex].soid != update.characterSoid
+        || !resolve(account, mutation.characterIndex, selected)
+        || selected.characterObjectId != update.characterDefinitionId
+        || selected.itemInstanceObjectId != update.itemInstanceDefinitionId) {
+        return report_failure("record_reward_account");
+    }
+
+    family4_datagen::loadout::ResolvedInstances residents{};
+    for (std::size_t rewardIndex = 0; rewardIndex < mutation.rewardCount; ++rewardIndex) {
+        const state::PreparedRecordReward& reward = mutation.rewards[rewardIndex];
+        if (reward.kind == state::RecordRewardKind::characterInstance) {
+            std::size_t matches = 0;
+            for (std::size_t itemIndex = 0; itemIndex < selected.loadout.itemCount; ++itemIndex) {
+                const auto& item = selected.loadout.items[itemIndex];
+                if (item.instance.instanceSoid != reward.instanceSoid) {
+                    continue;
+                }
+                if (item.equipped || item.inventoryRow != reward.inventoryRow
+                    || item.mutationSerial != reward.mutationSerial) {
+                    return report_failure("record_reward_character_item");
+                }
+                residents.items[residents.itemCount++] = {item.equipmentSlot, item.instance};
+                ++matches;
+            }
+            if (matches != 1) {
+                return report_failure("record_reward_character_instance");
+            }
+        } else if (reward.kind == state::RecordRewardKind::profileStack
+                   && reward.appendedProfileResident) {
+            if (reward.stateIndex >= account.profileItemCount
+                || residents.itemCount >= residents.items.size()
+                || !resolve_profile_item_instance(account.profileItems[reward.stateIndex],
+                                                  residents.items[residents.itemCount].instance)) {
+                return report_failure("record_reward_profile_instance");
+            }
+            ++residents.itemCount;
+        } else if (reward.kind == state::RecordRewardKind::profileStack
+                   && reward.instanceSoid != 0) {
+            std::size_t matches = 0;
+            for (std::size_t residentIndex = 0; residentIndex < before.family4ResidentCount;
+                 ++residentIndex) {
+                const auto& resident = before.family4Residents[residentIndex];
+                matches += static_cast<std::size_t>(resident.objectSoid == reward.instanceSoid
+                                                    && resident.definitionId
+                                                           == update.itemInstanceDefinitionId);
+            }
+            if (matches != 1) {
+                return report_failure("record_reward_profile_resident");
+            }
+        }
+    }
+    if (residents.itemCount != update.appendedResidentCount) {
+        return report_failure("record_reward_resident_count");
+    }
+    for (std::size_t index = 0; index < residents.itemCount; ++index) {
+        const auto& expected = update.after.family4Residents[before.family4ResidentCount + index];
+        if (expected.objectSoid != residents.items[index].instance.instanceSoid
+            || expected.definitionId != update.itemInstanceDefinitionId) {
+            return report_failure("record_reward_resident_order");
+        }
+    }
+
+    const Reservation reservation = reserve_prior(scratch, prepared);
+    if (reservation.rawWriteOffset > scratch.plaintext.size()
+        || reservation.compressedWriteOffset > scratch.sealed.size()) {
+        return report_failure("record_reward_reservation");
+    }
+    const auto rawStorage = std::span(scratch.plaintext).subspan(reservation.rawWriteOffset);
+    if (account_layout::kObjectSize > rawStorage.size()
+        || character_layout::kObjectSize > rawStorage.size()) {
+        return report_failure("record_reward_storage");
+    }
+
+    Prepared staged{};
+    std::size_t compressedExtent = reservation.compressedWriteOffset;
+    std::size_t residentCursor = 0;
+    if (!append_items(scratch,
+                      rawStorage,
+                      update.itemInstanceDefinitionId,
+                      residents,
+                      0,
+                      staged,
+                      residentCursor,
+                      compressedExtent)
+        || residentCursor != residents.itemCount) {
+        clear_after(scratch, reservation);
+        return report_failure("record_reward_residents");
+    }
+
+    const auto characterBytes = rawStorage.first(character_layout::kObjectSize);
+    const state::CharacterState& character = account.characters[mutation.characterIndex];
+    if (!family4_datagen::character::encode(character,
+                                            selected.loadout,
+                                            selected.lightEvaluation,
+                                            characterBytes,
+                                            pendingSeasonReward.has_value() ? nullptr
+                                                                            : &mutation.claim)) {
+        clear_after(scratch, reservation);
+        return report_failure("record_reward_character_encode");
+    }
+    auto& characterObject = *reinterpret_cast<character_layout::Object*>(characterBytes.data());
+    if (characterObject.inventoryChanges.writeSlot != 0
+        || characterObject.inventoryChanges.nextSequence != 0
+        || !std::all_of(characterObject.inventoryChanges.records.cbegin(),
+                        characterObject.inventoryChanges.records.cend(),
+                        kChangeRecordIsZero)) {
+        clear_after(scratch, reservation);
+        return report_failure("record_reward_character_changes");
+    }
+    std::size_t characterChanges = 0;
+    for (std::size_t rewardIndex = 0; rewardIndex < mutation.rewardCount; ++rewardIndex) {
+        const state::PreparedRecordReward& reward = mutation.rewards[rewardIndex];
+        if (reward.kind == state::RecordRewardKind::profileStack) {
+            continue;
+        }
+        state::build_data::items::Definition definition{};
+        if (characterChanges >= characterObject.inventoryChanges.records.size()
+            || !state::build_data::find_item_definition_hash(reward.definitionHash, definition)) {
+            clear_after(scratch, reservation);
+            return report_failure("record_reward_character_definition");
+        }
+        std::size_t found = characterObject.inventoryItems.size();
+        for (std::size_t row = 0; row < characterObject.inventoryItems.size(); ++row) {
+            const auto& item = characterObject.inventoryItems[row];
+            const bool matches = reward.kind == state::RecordRewardKind::characterInstance
+                                     ? item.instanceSoid == reward.instanceSoid
+                                     : item.instanceSoid == 0
+                                           && item.definitionIndex == definition.definitionIndex
+                                           && item.mutationSerial == reward.mutationSerial;
+            if (!matches) {
+                continue;
+            }
+            if (found != characterObject.inventoryItems.size()
+                || item.quantity != reward.afterQuantity) {
+                clear_after(scratch, reservation);
+                return report_failure("record_reward_character_row");
+            }
+            found = row;
+        }
+        if (found == characterObject.inventoryItems.size()) {
+            clear_after(scratch, reservation);
+            return report_failure("record_reward_character_row_missing");
+        }
+        auto& change = characterObject.inventoryChanges.records[characterChanges];
+        change.sequence = static_cast<std::uint16_t>(characterChanges);
+        change.mutationSerial = reward.mutationSerial;
+        change.kind = kChangeKind;
+        change.flags = kChangeFlags;
+        ++characterChanges;
+    }
+    characterObject.inventoryChanges.writeSlot = static_cast<std::uint16_t>(characterChanges);
+    characterObject.inventoryChanges.nextSequence = static_cast<std::uint16_t>(characterChanges);
+    if (!apply_acquisition_presentation(
+            characterBytes, selected.loadout, acquisitionPresentationRows)) {
+        clear_after(scratch, reservation);
+        return report_failure("record_reward_presentation");
+    }
+    if (!append_object(scratch,
+                       characterBytes,
+                       update.characterDefinitionId,
+                       update.characterSoid,
+                       staged.objects[residentCursor],
+                       compressedExtent)) {
+        clear_after(scratch, reservation);
+        return report_failure("record_reward_character_object");
+    }
+
+    const auto accountBytes = rawStorage.first(account_layout::kObjectSize);
+    if (!family4_datagen::account::encode(account,
+                                          accountBytes,
+                                          pendingSeasonReward,
+                                          pendingSeasonReward.has_value() ? nullptr
+                                                                          : &mutation.claim)) {
+        clear_after(scratch, reservation);
+        return report_failure("record_reward_account_encode");
+    }
+    auto& accountObject = *reinterpret_cast<account_layout::Object*>(accountBytes.data());
+    if (accountObject.profileInventoryChanges.writeSlot != 0
+        || accountObject.profileInventoryChanges.nextSequence != 0
+        || !std::all_of(accountObject.profileInventoryChanges.records.cbegin(),
+                        accountObject.profileInventoryChanges.records.cend(),
+                        kChangeRecordIsZero)) {
+        clear_after(scratch, reservation);
+        return report_failure("record_reward_profile_changes");
+    }
+    std::size_t profileChanges = 0;
+    for (std::size_t rewardIndex = 0; rewardIndex < mutation.rewardCount; ++rewardIndex) {
+        const state::PreparedRecordReward& reward = mutation.rewards[rewardIndex];
+        if (reward.kind != state::RecordRewardKind::profileStack) {
+            continue;
+        }
+        state::build_data::items::Definition definition{};
+        if (profileChanges >= accountObject.profileInventoryChanges.records.size()
+            || !state::build_data::find_item_definition_hash(reward.definitionHash, definition)) {
+            clear_after(scratch, reservation);
+            return report_failure("record_reward_profile_definition");
+        }
+        std::size_t found = accountObject.profileItems.size();
+        for (std::size_t row = 0; row < accountObject.profileItems.size(); ++row) {
+            const auto& item = accountObject.profileItems[row];
+            if (item.definitionIndex != definition.definitionIndex
+                || item.mutationSerial != reward.mutationSerial) {
+                continue;
+            }
+            if (found != accountObject.profileItems.size()
+                || item.quantity != reward.afterQuantity) {
+                clear_after(scratch, reservation);
+                return report_failure("record_reward_profile_row");
+            }
+            found = row;
+        }
+        if (found == accountObject.profileItems.size()) {
+            clear_after(scratch, reservation);
+            return report_failure("record_reward_profile_row_missing");
+        }
+        auto& change = accountObject.profileInventoryChanges.records[profileChanges];
+        change.sequence = static_cast<std::uint16_t>(profileChanges);
+        change.mutationSerial = reward.mutationSerial;
+        change.kind = kChangeKind;
+        change.flags = kChangeFlags;
+        ++profileChanges;
+    }
+    accountObject.profileInventoryChanges.writeSlot = static_cast<std::uint16_t>(profileChanges);
+    accountObject.profileInventoryChanges.nextSequence = static_cast<std::uint16_t>(profileChanges);
+    if (!append_object(scratch,
+                       accountBytes,
+                       update.accountDefinitionId,
+                       update.accountSoid,
+                       staged.objects[residentCursor + 1U],
+                       compressedExtent)) {
+        clear_after(scratch, reservation);
+        return report_failure("record_reward_account_object");
+    }
+
+    staged.rawClearSize =
+        (std::max)(reservation.rawClearSize,
+                   reservation.rawWriteOffset
+                       + (std::max)(account_layout::kObjectSize, character_layout::kObjectSize));
+    staged.compressedClearSize = (std::max)(reservation.compressedClearSize, compressedExtent);
+    staged.family = middleware::queuez::Family{
+        queuez::kAccountFamilyType,
+        update.accountSoid,
+        update.after.family4Version,
+        0,
+        std::span(staged.objects).first(residentCursor + 2U),
+    };
+    if (!commit(staged, prepared)) {
+        clear_after(scratch, reservation);
+        return report_failure("record_reward_commit");
     }
     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 {
+bool prepare_item_acquisition(
+    Scratch& scratch,
+    const queuez::ItemAcquisition& acquisition,
+    const state::PendingItemAcquisition& mutation,
+    std::optional<std::uint16_t> pendingSeasonReward,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    Prepared& prepared) noexcept {
     const Reservation reservation = reserve_prior(scratch, prepared);
     if (reservation.rawWriteOffset > scratch.plaintext.size()
         || reservation.compressedWriteOffset > scratch.sealed.size()) {
@@ -283,7 +891,8 @@ bool prepare_item_acquisition(Scratch& scratch,
         || mutation.accountSoid == 0 || mutation.accountSoid != acquisition.accountSoid
         || mutation.characterSoid != acquisition.characterSoid
         || mutation.acquiredInstanceSoid != acquisition.acquiredInstanceSoid
-        || mutation.profileChanged != acquisition.updatesAccount
+        || acquisition.updatesAccount
+               != (mutation.profileChanged || pendingSeasonReward.has_value())
         || acquisition.accountSoid != acquisition.after.family4RootSoid
         || acquisition.accountDefinitionId == 0 || acquisition.after.family4ResidentCount == 0
         || acquisition.after.family4Residents[acquisition.after.family4ResidentCount - 1U]
@@ -342,10 +951,8 @@ bool prepare_item_acquisition(Scratch& scratch,
         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.
+    // Acquisition feedback requires a transient change record matching the newly filled row.
+    // Keep it local to this push; later snapshots encode an empty bank.
     constexpr std::size_t kBitsPerFlagByte = 8;
     constexpr std::int32_t kEncodedOccupiedRowWatermark = 1;
     constexpr std::uint16_t kAcquisitionChangeSequence = 0;
@@ -363,18 +970,13 @@ bool prepare_item_acquisition(Scratch& scratch,
     }
     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);
+                                            kChangeRecordIsZero);
     const auto& acquiredInventoryRow = characterObject.inventoryItems[acquiredRow];
     if (newItemFlagIndex >= characterObject.newItemFlags.size()
         || acquiredInventoryRow.instanceSoid != mutation.acquiredInstanceSoid
@@ -395,10 +997,15 @@ bool prepare_item_acquisition(Scratch& scratch,
     acquisitionChange.flags = kAcquisitionChangeFlags;
     if (!std::all_of(characterObject.inventoryChanges.records.cbegin() + 1,
                      characterObject.inventoryChanges.records.cend(),
-                     recordIsZero)) {
+                     kChangeRecordIsZero)) {
         clear_after(scratch, reservation);
         return report_failure("acquire_inventory_change_records");
     }
+    if (!apply_acquisition_presentation(
+            characterBytes, selected.loadout, acquisitionPresentationRows)) {
+        clear_after(scratch, reservation);
+        return report_failure("acquire_presentation");
+    }
 
     Prepared staged{};
     staged.rawClearSize =
@@ -436,8 +1043,6 @@ bool prepare_item_acquisition(Scratch& scratch,
         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()) {
@@ -445,7 +1050,7 @@ bool prepare_item_acquisition(Scratch& scratch,
             return report_failure("acquire_account_storage");
         }
         const auto accountBytes = rawStorage.first(family4_datagen::account::layout::kObjectSize);
-        if (!family4_datagen::account::encode(account, accountBytes)
+        if (!family4_datagen::account::encode(account, accountBytes, pendingSeasonReward)
             || !append_object(scratch,
                               accountBytes,
                               acquisition.accountDefinitionId,
@@ -461,10 +1066,8 @@ bool prepare_item_acquisition(Scratch& scratch,
         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).
+    // Publish the new item before the character that references it. Dismantle uses the inverse
+    // order, dropping the character reference before releasing the item.
     std::swap(staged.objects[0], staged.objects[1]);
 
     staged.compressedClearSize = (std::max)(reservation.compressedClearSize, compressedExtent);
@@ -480,43 +1083,6 @@ bool prepare_item_acquisition(Scratch& scratch,
         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;
 }
 
@@ -613,17 +1179,11 @@ bool prepare_item_dismantle(Scratch& scratch,
 
         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)
+                            kChangeRecordIsZero)
             || mutation.rewardCount == 0
             || mutation.rewardCount > accountObject.profileInventoryChanges.records.size()) {
             clear_after(scratch, reservation);
@@ -689,36 +1249,6 @@ bool prepare_item_dismantle(Scratch& scratch,
         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

+ 356 - 189
Sunrise/src/server/bap/encrypted/push/snapshot/family4_selection_move.cpp

@@ -1,12 +1,11 @@
 #include <algorithm>
 #include <array>
 #include <cstddef>
-#include <cstdio>
 #include <limits>
 #include <optional>
 #include <span>
 
-#include "../../../../../core/logging/log.h"
+#include "../../../../../middleware/datagen/definitions.h"
 #include "../../../../../middleware/datagen/family4/account/account_encoder.h"
 #include "../../../../../middleware/datagen/family4/account/layout.h"
 #include "../../../../../middleware/datagen/family4/account/selection_patch/\
@@ -16,89 +15,88 @@ account_selection_patch_encoder.h"
 #include "../../../../../middleware/datagen/family4/instance/instance_encoder.h"
 #include "../../../../../middleware/datagen/family4/instance/layout.h"
 #include "../../../../../state/runtime/runtime.h"
+#include "../../../../../state/progression/seasonal_experience.h"
 #include "internal.h"
 #include "snapshot_storage.h"
 
 namespace sunrise::server::bap::encrypted::push::snapshot {
-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;
+bool apply_acquisition_presentation(
+    std::span<std::byte> characterBytes,
+    const family4_datagen::loadout::ResolvedLoadout& loadout,
+    std::span<const queuez::AcquisitionPresentationRow> presentationRows) noexcept {
+    namespace character_layout = family4_datagen::character::layout;
+    constexpr std::size_t kBitsPerFlagByte = 8;
+    constexpr std::uint16_t kEmptyDefinitionIndex = (std::numeric_limits<std::uint16_t>::max)();
+    if (presentationRows.empty()) {
+        return true;
+    }
+    if (characterBytes.size() < character_layout::kObjectSize
+        || presentationRows.size() > queuez::kAcquisitionPresentationRowCapacity) {
+        return false;
+    }
+
+    auto& characterObject = *reinterpret_cast<character_layout::Object*>(characterBytes.data());
+    std::array<const family4_datagen::loadout::ResolvedItem*,
+               queuez::kAcquisitionPresentationRowCapacity>
+        resolvedItems{};
+    std::array<bool, character_layout::kInventoryCapacity> occupiedRows{};
+    for (std::size_t presentationIndex = 0; presentationIndex < presentationRows.size();
+         ++presentationIndex) {
+        const auto& presentation = presentationRows[presentationIndex];
+        if (presentation.instanceSoid == 0
+            || presentation.inventoryRow >= characterObject.inventoryItems.size()
+            || occupiedRows[presentation.inventoryRow]) {
+            return false;
+        }
+        occupiedRows[presentation.inventoryRow] = true;
+        for (std::size_t itemIndex = 0; itemIndex < loadout.itemCount; ++itemIndex) {
+            const auto& item = loadout.items[itemIndex];
+            if (item.instance.instanceSoid != presentation.instanceSoid) {
+                continue;
+            }
+            if (resolvedItems[presentationIndex] != nullptr
+                || item.inventoryRow >= characterObject.inventoryItems.size()) {
+                return false;
+            }
+            resolvedItems[presentationIndex] = &item;
+        }
+        if (resolvedItems[presentationIndex] == nullptr) {
+            return false;
         }
     }
-    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,
-        selected.loadout.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)});
+
+    for (const auto* item : std::span(resolvedItems).first(presentationRows.size())) {
+        const std::size_t row = item->inventoryRow;
+        characterObject.inventoryItems[row] = {};
+        characterObject.inventoryItems[row].definitionIndex = kEmptyDefinitionIndex;
+        characterObject.newItemFlags[row / kBitsPerFlagByte] &=
+            ~(std::byte{1U} << (row % kBitsPerFlagByte));
+        characterObject.instanceProgressWatermarks[row] = 0;
+    }
+    for (std::size_t presentationIndex = 0; presentationIndex < presentationRows.size();
+         ++presentationIndex) {
+        const auto& presentation = presentationRows[presentationIndex];
+        const auto* item = resolvedItems[presentationIndex];
+        auto& row = characterObject.inventoryItems[presentation.inventoryRow];
+        if (row.definitionIndex != kEmptyDefinitionIndex) {
+            return false;
+        }
+        row.definitionIndex = item->instance.baseDefinitionIndex;
+        row.instanceSoid = item->instance.instanceSoid;
+        row.quantity = item->quantity;
+        row.mutationSerial = item->mutationSerial;
+        row.flags = item->flags;
+        characterObject.newItemFlags[presentation.inventoryRow / kBitsPerFlagByte] |=
+            std::byte{1U} << (presentation.inventoryRow % kBitsPerFlagByte);
+        characterObject.instanceProgressWatermarks[presentation.inventoryRow] = 1;
     }
+    return true;
 }
 
-} // namespace
-
 /** Builds the Family-4 increment that moves the character object to the picked character. */
 bool prepare_selection_move(Scratch& scratch,
                             const queuez::SelectCharacter& select,
@@ -123,20 +121,16 @@ bool prepare_selection_move(Scratch& scratch,
     const auto rawStorage = std::span(scratch.plaintext).subspan(reservation.rawWriteOffset);
     std::size_t compressedExtent = reservation.compressedWriteOffset;
     std::size_t objectCount = 0;
-    // The first select resends the character object even when the pick names the current one. With
-    // no pending change all three operations go out. Only a pending change may skip it, and only
-    // when the character did not move.
+    // A first select resends the character even when it names the current one. A pending change
+    // may skip the resend only when the character did not move.
     if (!select.patchAccount || select.previousCharacterSoid != select.selectedCharacterSoid) {
-        // A zero previous key means the snapshot published no character object. There is no slot
-        // to release, so the pick adds one. Releasing a key the Client never held raises queuez
-        // error 4.
+        // A zero previous key means there is no published character slot to release.
         if (select.previousCharacterSoid != 0) {
-            // The release carries no payload. The encoding field is never read for an empty
-            // object, so it just matches its neighbours.
+            // Empty releases do not use the encoding field; keep it aligned with neighboring rows.
             staged.objects[objectCount++] = middleware::queuez::Object{
                 select.characterDefinitionId,
                 select.previousCharacterSoid,
-                middleware::queuez::Encoding::none,
+                middleware::queuez::Encoding::oodle,
                 {},
             };
         }
@@ -164,8 +158,7 @@ bool prepare_selection_move(Scratch& scratch,
                                              + family4_datagen::character::layout::kObjectSize);
     }
 
-    // The character object is already compressed into sealed storage, so the raw span is free
-    // for whichever account body this move carries.
+    // The compressed character no longer occupies the raw span needed for the account body.
     if (select.patchAccount) {
         std::size_t patchSize = 0;
         if (!selection_patch::encode(select.selectedCharacterSoid, rawStorage, patchSize)
@@ -215,11 +208,47 @@ bool prepare_selection_move(Scratch& scratch,
     return true;
 }
 
+/** Compresses and commits one selected-character-only Family-4 increment. */
+[[nodiscard]] bool finish_character_upsert(Scratch& scratch,
+                                           const Reservation& reservation,
+                                           std::span<std::byte> characterBytes,
+                                           const queuez::EquipmentSwap& update,
+                                           const char* objectFailure,
+                                           const char* commitFailure,
+                                           Prepared& prepared) noexcept {
+    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(objectFailure);
+    }
+    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(commitFailure);
+    }
+    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 {
+bool prepare_equipment_swap(
+    Scratch& scratch,
+    const queuez::EquipmentSwap& swap,
+    const state::PendingEquipmentSwap& mutation,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    Prepared& prepared) noexcept {
     const Reservation reservation = reserve_prior(scratch, prepared);
     if (reservation.rawWriteOffset > scratch.plaintext.size()
         || reservation.compressedWriteOffset > scratch.sealed.size()) {
@@ -253,41 +282,47 @@ bool prepare_equipment_swap(Scratch& scratch,
                                             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)) {
+    // An equipment update must not present the displaced item as another acquisition.
+    constexpr std::size_t kBitsPerNewItemFlagByte = 8;
+    const std::uint64_t movedIntoInventory = mutation.kind == state::EquipmentMutationKind::equip
+                                                 ? mutation.previousInstanceSoid
+                                                 : mutation.requestedInstanceSoid;
+    auto& characterObject =
+        *reinterpret_cast<family4_datagen::character::layout::Object*>(characterBytes.data());
+
+    if (!apply_acquisition_presentation(
+            characterBytes, selected.loadout, acquisitionPresentationRows)) {
         clear_after(scratch, reservation);
-        return report_failure("equip_commit");
+        return report_failure("equip_presentation");
+    }
+
+    std::size_t movedRowMatches = 0;
+    if (movedIntoInventory != 0) {
+        for (std::size_t row = 0; row < characterObject.inventoryItems.size(); ++row) {
+            if (characterObject.inventoryItems[row].instanceSoid != movedIntoInventory) {
+                continue;
+            }
+            const std::size_t flagByte = row / kBitsPerNewItemFlagByte;
+            const std::byte flagMask = std::byte{1U} << (row % kBitsPerNewItemFlagByte);
+            characterObject.newItemFlags[flagByte] &= ~flagMask;
+            ++movedRowMatches;
+        }
+        if (movedRowMatches != 1) {
+            clear_after(scratch, reservation);
+            return report_failure("equip_moved_inventory_row");
+        }
     }
-    return true;
+    return finish_character_upsert(scratch,
+                                   reservation,
+                                   characterBytes,
+                                   swap,
+                                   "equip_character_object",
+                                   "equip_commit",
+                                   prepared);
 }
 
-/** Builds the single-character upsert from an uncommitted current-activity after-image. */
+/** Builds a single-character Family-4 upsert from an uncommitted item-state after-image. */
 bool prepare_current_activity_character(Scratch& scratch,
                                         const queuez::EquipmentSwap& update,
                                         const state::PendingCurrentActivity& mutation,
@@ -355,10 +390,13 @@ bool prepare_current_activity_character(Scratch& scratch,
 }
 
 /** 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 {
+
+bool prepare_item_state(
+    Scratch& scratch,
+    const queuez::EquipmentSwap& update,
+    const state::PendingItemState& mutation,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    Prepared& prepared) noexcept {
     const Reservation reservation = reserve_prior(scratch, prepared);
     if (reservation.rawWriteOffset > scratch.plaintext.size()
         || reservation.compressedWriteOffset > scratch.sealed.size()) {
@@ -407,31 +445,211 @@ bool prepare_item_state(Scratch& scratch,
     if (matchingRows != 1) {
         return report_failure("item_state_character_shape");
     }
+    if (!apply_acquisition_presentation(
+            characterBytes, selected.loadout, acquisitionPresentationRows)) {
+        clear_after(scratch, reservation);
+        return report_failure("item_state_presentation");
+    }
+
+    return finish_character_upsert(scratch,
+                                   reservation,
+                                   characterBytes,
+                                   update,
+                                   "item_state_character_object",
+                                   "item_state_commit",
+                                   prepared);
+}
+
+/** Builds a single-character Family-4 upsert from an uncommitted artifact mask. */
+bool prepare_artifact_purchase(
+    Scratch& scratch,
+    const queuez::EquipmentSwap& update,
+    const state::PendingArtifactPurchase& mutation,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    Prepared& prepared) noexcept {
+    const Reservation reservation = reserve_prior(scratch, prepared);
+    if (reservation.rawWriteOffset > scratch.plaintext.size()
+        || reservation.compressedWriteOffset > scratch.sealed.size()) {
+        return report_failure("artifact_reservation");
+    }
+    const state::AccountState account = state::account_snapshot();
+    if (!mutation.prepared || mutation.accountSoid == 0 || mutation.characterSoid == 0
+        || mutation.accountSoid != account.primarySoid
+        || mutation.characterSoid != update.characterSoid
+        || mutation.characterIndex >= account.characterCount
+        || account.characters[mutation.characterIndex].soid != mutation.characterSoid
+        || !account.characters[mutation.characterIndex].selected) {
+        return report_failure("artifact_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 != update.characterDefinitionId) {
+        return report_failure("artifact_selection");
+    }
+    const auto rawStorage = std::span(scratch.plaintext).subspan(reservation.rawWriteOffset);
+    if (family4_datagen::character::layout::kObjectSize > rawStorage.size()) {
+        return report_failure("artifact_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("artifact_character_object");
+    }
+    auto& object =
+        *reinterpret_cast<family4_datagen::character::layout::Object*>(characterBytes.data());
+    if (!state::progression::seasonal_experience::apply_artifact_character_state(
+            mutation.afterMask, object.acquiredFlags, object.objectiveValues)
+        || !apply_acquisition_presentation(
+            characterBytes, selected.loadout, acquisitionPresentationRows)) {
+        clear_after(scratch, reservation);
+        return report_failure("artifact_projection");
+    }
+
+    return finish_character_upsert(scratch,
+                                   reservation,
+                                   characterBytes,
+                                   update,
+                                   "artifact_character_object",
+                                   "artifact_commit",
+                                   prepared);
+}
+
+/** Builds an incremental reset image without re-announcing every resident item. */
+bool prepare_artifact_reset(Scratch& scratch,
+                            const queuez::EquipmentSwap& update,
+                            Prepared& prepared) noexcept {
+    const Reservation reservation = reserve_prior(scratch, prepared);
+    const state::AccountState account = state::account_snapshot();
+    const std::optional<std::size_t> selectedIndex = find_character_index(account);
+    Resolved selected{};
+    std::uint32_t accountDefinitionId = 0;
+    if (!state::account::valid(account) || !selectedIndex.has_value()
+        || !resolve(account, *selectedIndex, selected)
+        || !middleware::datagen::object_id(
+            kAccountFamilyType, middleware::datagen::kAccountSlot, accountDefinitionId)
+        || account.primarySoid != update.after.family4RootSoid
+        || account.characters[*selectedIndex].soid != update.characterSoid
+        || selected.characterObjectId != update.characterDefinitionId) {
+        return report_failure("artifact_reset_state");
+    }
+    bool accountResident = false;
+    for (std::size_t index = 0; index < update.after.family4ResidentCount; ++index) {
+        const auto& resident = update.after.family4Residents[index];
+        accountResident = accountResident
+                          || (resident.definitionId == accountDefinitionId
+                              && resident.objectSoid == account.primarySoid);
+    }
+    const auto rawStorage = std::span(scratch.plaintext).subspan(reservation.rawWriteOffset);
+    const std::size_t required =
+        (std::max)(family4_datagen::account::layout::kObjectSize,
+                   family4_datagen::character::layout::kObjectSize);
+    if (!accountResident || required > rawStorage.size()) {
+        return report_failure("artifact_reset_storage");
+    }
 
     Prepared staged{};
     staged.rawClearSize =
-        (std::max)(reservation.rawClearSize,
-                   reservation.rawWriteOffset + family4_datagen::character::layout::kObjectSize);
+        (std::max)(reservation.rawClearSize, reservation.rawWriteOffset + required);
     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");
+    const auto accountBytes = rawStorage.first(family4_datagen::account::layout::kObjectSize);
+    if (!family4_datagen::account::encode(account, accountBytes)
+        || !append_object(scratch,
+                          accountBytes,
+                          accountDefinitionId,
+                          account.primarySoid,
+                          staged.objects[0],
+                          compressedExtent)) {
+        clear_after(scratch, reservation);
+        return report_failure("artifact_reset_account");
+    }
+    const auto characterBytes = rawStorage.first(family4_datagen::character::layout::kObjectSize);
+    if (!family4_datagen::character::encode(account.characters[*selectedIndex],
+                                            selected.loadout,
+                                            selected.lightEvaluation,
+                                            characterBytes)
+        || !append_object(scratch,
+                          characterBytes,
+                          update.characterDefinitionId,
+                          update.characterSoid,
+                          staged.objects[1],
+                          compressedExtent)) {
+        clear_after(scratch, reservation);
+        return report_failure("artifact_reset_character");
     }
     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),
-    };
+    staged.family = middleware::queuez::Family{kAccountFamilyType,
+                                                update.after.family4RootSoid,
+                                                update.after.family4Version,
+                                                0,
+                                                std::span(staged.objects).first(2)};
     if (!commit(staged, prepared)) {
         clear_after(scratch, reservation);
-        return report_failure("item_state_commit");
+        return report_failure("artifact_reset_commit");
+    }
+    return true;
+}
+
+/** Builds one exact current item-resident upsert after artifact reset. */
+bool prepare_artifact_item_refresh(Scratch& scratch,
+                                   const queuez::EquipmentSwap& update,
+                                   std::uint64_t instanceSoid,
+                                   Prepared& prepared) noexcept {
+    const Reservation reservation = reserve_prior(scratch, prepared);
+    const state::AccountState account = state::account_snapshot();
+    const std::optional<std::size_t> selectedIndex = find_character_index(account);
+    Resolved selected{};
+    if (instanceSoid == 0 || !state::account::valid(account) || !selectedIndex.has_value()
+        || !resolve(account, *selectedIndex, selected)
+        || selected.characterObjectId != update.characterDefinitionId
+        || update.characterSoid != account.characters[*selectedIndex].soid) {
+        return report_failure("artifact_item_refresh_selection");
+    }
+    family4_datagen::loadout::ResolvedInstances changed{};
+    for (const auto& item : selected.loadout.items) {
+        if (item.instance.instanceSoid != instanceSoid) {
+            continue;
+        }
+        if (changed.itemCount != 0) {
+            return report_failure("artifact_item_refresh_duplicate");
+        }
+        changed.items[changed.itemCount++] = {item.equipmentSlot, item.instance};
+    }
+    if (changed.itemCount != 1) {
+        return report_failure("artifact_item_refresh_missing");
+    }
+    const auto rawStorage = std::span(scratch.plaintext).subspan(reservation.rawWriteOffset);
+    Prepared staged{};
+    std::size_t itemCursor = 0;
+    std::size_t compressedExtent = reservation.compressedWriteOffset;
+    if (!append_items(scratch,
+                      rawStorage,
+                      selected.itemInstanceObjectId,
+                      changed,
+                      0,
+                      staged,
+                      itemCursor,
+                      compressedExtent)
+        || itemCursor != 1) {
+        clear_after(scratch, reservation);
+        return report_failure("artifact_item_refresh_encode");
+    }
+    staged.rawClearSize = (std::max)(reservation.rawClearSize,
+                                     reservation.rawWriteOffset
+                                         + family4_datagen::instance::layout::kObjectSize);
+    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("artifact_item_refresh_commit");
     }
     return true;
 }
@@ -551,37 +769,6 @@ bool prepare_socket_plug(Scratch& scratch,
         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;
 }
 
@@ -675,26 +862,6 @@ bool prepare_subclass_selection(Scratch& scratch,
         return report_failure("subclass_select_commit");
     }
 
-    std::array<char, core::log::kLineCapacity> line{};
-    const int count = std::snprintf(
-        line.data(),
-        line.size(),
-        "ev=subclass_select stage=family4_object result=ok family_version=%d root=0x%llX "
-        "character=0x%llX instance=0x%llX item_definition=%u target_definition=%u "
-        "socket_list=%u requested_entry=%u objects=1",
-        selection.after.family4Version,
-        static_cast<unsigned long long>(selection.after.family4RootSoid),
-        static_cast<unsigned long long>(selection.characterSoid),
-        static_cast<unsigned long long>(selection.subclassInstanceSoid),
-        selection.itemInstanceDefinitionId,
-        static_cast<unsigned>(mutation.subclassDefinitionIndex),
-        static_cast<unsigned>(mutation.socketEntryListIndex),
-        static_cast<unsigned>(mutation.requestedEntry));
-    if (count > 0) {
-        core::log::write(core::log::Channel::server,
-                         core::log::Level::debug,
-                         {line.data(), static_cast<std::size_t>(count)});
-    }
     return true;
 }
 

+ 5 - 0
Sunrise/src/server/bap/encrypted/push/snapshot/family4_snapshot_preparer.cpp

@@ -54,6 +54,7 @@ bool prepare(Scratch& scratch,
              const middleware::queuez::Subscription& subscription,
              std::uint32_t accountObjectId,
              const Reservation& reservation,
+             std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
              Prepared& prepared) noexcept {
     if (reservation.rawWriteOffset > scratch.plaintext.size()
         || reservation.compressedWriteOffset > scratch.sealed.size()) {
@@ -119,6 +120,10 @@ bool prepare(Scratch& scratch,
                 selectedCharacter, selected.loadout, selected.lightEvaluation, characterBytes)) {
             return report_failure("character_encode");
         }
+        if (!apply_acquisition_presentation(
+                characterBytes, selected.loadout, acquisitionPresentationRows)) {
+            return report_failure("character_presentation");
+        }
         if (!append_object(scratch,
                            characterBytes,
                            selected.characterObjectId,

+ 16 - 10
Sunrise/src/server/bap/encrypted/push/snapshot/initial_snapshot.cpp

@@ -15,9 +15,11 @@ constexpr std::size_t kEmptyReportCapacity = 64;
 } // namespace
 
 /** Builds one full family snapshot at the initial version from State and build mappings. */
-bool prepare_initial(Scratch& scratch,
-                     const middleware::queuez::Subscription& subscription,
-                     Prepared& prepared) noexcept {
+bool prepare_initial(
+    Scratch& scratch,
+    const middleware::queuez::Subscription& subscription,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    Prepared& prepared) noexcept {
     // Family zero never reaches here. It carries the banner pair, and its version and flags come
     // from the peer's own state, so the subscription path builds it directly.
     const Reservation reservation = reserve_prior(scratch, prepared);
@@ -38,7 +40,8 @@ bool prepare_initial(Scratch& scratch,
     } else if (subscription.familyType == kRosterFamilyType && hasDefinition) {
         success = prepare_roster(scratch, subscription, objectId, reservation, staged);
     } else if (subscription.familyType == kAccountFamilyType && hasDefinition) {
-        success = prepare(scratch, subscription, objectId, reservation, staged);
+        success = prepare(
+            scratch, subscription, objectId, reservation, acquisitionPresentationRows, staged);
     }
     // A family with no generated objects falls back to an empty full snapshot.
     if (!success) {
@@ -76,18 +79,21 @@ bool prepare_initial(Scratch& scratch,
 }
 
 /** 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 {
+bool prepare_family4_refresh(
+    Scratch& scratch,
+    std::uint64_t familyRootSoid,
+    std::int32_t version,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    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
+    if (!prepare_initial(scratch, subscription, acquisitionPresentationRows, prepared)
+        || prepared.family.objects.empty() || prepared.family.type != kAccountFamilyType
+        || prepared.family.rootSoid != familyRootSoid
         || prepared.family.flags != middleware::queuez::kFullSnapshotFlag) {
         return false;
     }

+ 84 - 21
Sunrise/src/server/bap/encrypted/push/snapshot/internal.h

@@ -1,5 +1,8 @@
 #pragma once
 
+#include <optional>
+#include <span>
+
 #include "../../../../../middleware/datagen/family4/loadout/definition.h"
 #include "../../../../../middleware/datagen/family4/loadout/loadout_resolver.h"
 #include "../../../../../state/account/account_state.h"
@@ -26,8 +29,6 @@ inline constexpr std::uint32_t kAccountDefinitionSlotIndex = 0;
 inline constexpr std::uint32_t kCharacterDefinitionSlotIndex = 1;
 /** Descriptor slot three supplies the schema shared by every family-four item instance. */
 inline constexpr std::uint32_t kItemDefinitionSlotIndex = 3;
-/** Prepared descriptor zero carries the family-three roster object. */
-inline constexpr std::size_t kRosterObjectIndex = 0;
 /** Prepared descriptor zero carries the family-four account object. */
 inline constexpr std::size_t kAccountObjectIndex = 0;
 /** Prepared descriptor one carries the selected family-four character object. */
@@ -40,8 +41,12 @@ inline constexpr std::size_t kFirstItemObjectIndex = kFamily4IdentityObjectCount
  * The account object is then the only descriptor ahead of them.
  */
 inline constexpr std::size_t kFirstItemObjectIndexUnselected = kAccountObjectIndex + 1;
-/** Roster and account-only snapshots each contain one object. */
-inline constexpr std::size_t kSingleObjectCount = 1;
+
+/** Pins feed-referenced item identities to their published character rows. */
+[[nodiscard]] bool apply_acquisition_presentation(
+    std::span<std::byte> characterBytes,
+    const middleware::datagen::family4::loadout::ResolvedLoadout& loadout,
+    std::span<const queuez::AcquisitionPresentationRow> presentationRows) noexcept;
 
 /**
  * Builds the family-three account roster snapshot.
@@ -104,11 +109,13 @@ inline constexpr std::size_t kSingleObjectCount = 1;
  * @param prepared Gets the compressed object descriptors and scratch clear extents.
  * @return True when State, mappings, layouts and the installed compression all fit.
  */
-[[nodiscard]] bool prepare(Scratch& scratch,
-                           const middleware::queuez::Subscription& subscription,
-                           std::uint32_t accountObjectId,
-                           const Reservation& reservation,
-                           Prepared& prepared) noexcept;
+[[nodiscard]] bool
+prepare(Scratch& scratch,
+        const middleware::queuez::Subscription& subscription,
+        std::uint32_t accountObjectId,
+        const Reservation& reservation,
+        std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+        Prepared& prepared) noexcept;
 
 /**
  * Builds the Family-4 increment that moves the character object to the picked character.
@@ -126,13 +133,16 @@ inline constexpr std::size_t kSingleObjectCount = 1;
  * @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 acquisitionPresentationRows Item identities pinned to feed-referenced rows.
  * @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;
+[[nodiscard]] bool prepare_equipment_swap(
+    Scratch& scratch,
+    const queuez::EquipmentSwap& swap,
+    const state::PendingEquipmentSwap& mutation,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    Prepared& prepared) noexcept;
 
 /**
  * Builds the Family-4 character upsert carrying one prepared current-activity change.
@@ -148,10 +158,31 @@ inline constexpr std::size_t kSingleObjectCount = 1;
                                                       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;
+[[nodiscard]] bool
+prepare_item_state(Scratch& scratch,
+                   const queuez::EquipmentSwap& update,
+                   const state::PendingItemState& mutation,
+                   std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+                   Prepared& prepared) noexcept;
+
+/** Builds the selected-character upsert for one uncommitted artifact purchase. */
+[[nodiscard]] bool prepare_artifact_purchase(
+    Scratch& scratch,
+    const queuez::EquipmentSwap& update,
+    const state::PendingArtifactPurchase& mutation,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    Prepared& prepared) noexcept;
+
+/** Builds only the current account and selected-character objects after an artifact reset. */
+[[nodiscard]] bool prepare_artifact_reset(Scratch& scratch,
+                                          const queuez::EquipmentSwap& update,
+                                          Prepared& prepared) noexcept;
+
+/** Builds one current item-resident upsert after an artifact reset cleared its socket. */
+[[nodiscard]] bool prepare_artifact_item_refresh(Scratch& scratch,
+                                                 const queuez::EquipmentSwap& update,
+                                                 std::uint64_t instanceSoid,
+                                                 Prepared& prepared) noexcept;
 
 /**
  * Builds the Family-4 item-instance upsert for one prepared ordinary-socket selection.
@@ -178,10 +209,13 @@ inline constexpr std::size_t kSingleObjectCount = 1;
  * @param prepared Gets the two 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;
+[[nodiscard]] bool prepare_item_acquisition(
+    Scratch& scratch,
+    const queuez::ItemAcquisition& acquisition,
+    const state::PendingItemAcquisition& mutation,
+    std::optional<std::uint16_t> pendingSeasonReward,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    Prepared& prepared) noexcept;
 
 /**
  * Builds one Family-4 increment containing the full account after-image for a profile stack.
@@ -192,8 +226,37 @@ inline constexpr std::size_t kSingleObjectCount = 1;
 prepare_profile_item_acquisition(Scratch& scratch,
                                  const queuez::ProfileItemAcquisition& acquisition,
                                  const state::PendingProfileItemAcquisition& mutation,
+                                 std::optional<std::uint16_t> pendingSeasonReward,
                                  Prepared& prepared) noexcept;
 
+/** Builds a transient XP inventory-row acquisition and the account progression after-image. */
+[[nodiscard]] bool prepare_seasonal_experience_presentation(
+    Scratch& scratch,
+    const queuez::SessionState& before,
+    std::int32_t amount,
+    std::int32_t mutationSerial,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    Prepared& prepared) noexcept;
+
+/** Builds one package increment containing its new instances, character, and account objects. */
+[[nodiscard]] bool prepare_season_pass_package(
+    Scratch& scratch,
+    const queuez::SessionState& before,
+    const state::PendingDirectItemBundle& mutation,
+    std::uint16_t rewardIndex,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    Prepared& prepared) noexcept;
+
+/** Builds one atomic record-reward batch in resident, character, account order. */
+[[nodiscard]] bool prepare_record_reward_grant(
+    Scratch& scratch,
+    const queuez::SessionState& before,
+    const queuez::RecordRewardGrant& update,
+    const state::PendingRecordRewardGrant& mutation,
+    std::optional<std::uint16_t> pendingSeasonReward,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    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.

+ 15 - 20
Sunrise/src/server/bap/encrypted/push/snapshot/snapshot.h

@@ -11,16 +11,11 @@
 
 namespace sunrise::server::bap::encrypted::push::snapshot {
 
-/** Account and selected-character identity take the first two family-four descriptors. */
+/** Account and selected-character identity occupy the first two Family-4 descriptors. */
 inline constexpr std::size_t kFamily4IdentityObjectCount = 2;
 /**
- * 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.
+ * Identity objects plus every character instance and resident-backed profile stack.
+ * Every nonzero inventory SOID needs a published object.
  */
 inline constexpr std::size_t kObjectCapacity =
     kFamily4IdentityObjectCount
@@ -49,19 +44,19 @@ struct Prepared {
  * @param prepared Gets the object descriptors and scratch clear extents.
  * @return True when the asked-for snapshot is valid for the current State and mappings.
  */
-[[nodiscard]] bool prepare_initial(Scratch& scratch,
-                                   const middleware::queuez::Subscription& subscription,
-                                   Prepared& prepared) noexcept;
+[[nodiscard]] bool
+prepare_initial(Scratch& scratch,
+                const middleware::queuez::Subscription& subscription,
+                std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+                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;
+/** Rebuilds the active account family at the peer's next version. */
+[[nodiscard]] bool prepare_family4_refresh(
+    Scratch& scratch,
+    std::uint64_t familyRootSoid,
+    std::int32_t version,
+    std::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    Prepared& prepared) noexcept;
 
 /**
  * Builds the family-zero banner anchor and the record for the character it names.

+ 25 - 1
Sunrise/src/server/bap/encrypted/queuez/definition.h

@@ -27,7 +27,14 @@ inline constexpr std::int32_t kInitialFamilyVersion = 0;
 inline constexpr std::size_t kResidentCapacity =
     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. */
+/** Maximum inventory-row identities retained while an acquisition feed is visible. */
+inline constexpr std::size_t kAcquisitionPresentationRowCapacity = 16;
+
+/** One item identity pinned to the inventory row referenced by the active acquisition feed. */
+struct AcquisitionPresentationRow {
+    std::uint64_t instanceSoid{};
+    std::uint16_t inventoryRow{};
+};
 
 /** When the roster is published after a change, as measured in the character-select flow. */
 enum class Family3Phase : std::uint8_t {
@@ -47,6 +54,7 @@ struct SessionState {
     std::uint64_t family4RootSoid{};
     /** Root whose Family-3 roster store has accepted its full snapshot. */
     std::uint64_t family3RootSoid{};
+    /** Resident zero is the account; the character is found by definition id. */
     std::array<ResidentObject, kResidentCapacity> family4Residents{};
     /** Character the resident family-zero pair names. Only a change earns an incremental. */
     std::uint64_t family0Character{};
@@ -167,6 +175,17 @@ struct ProfileItemAcquisition {
     bool appendedResident{};
 };
 
+/** One Family-4 increment for every item row and the accompanying record claim. */
+struct RecordRewardGrant {
+    SessionState after{};
+    std::uint32_t accountDefinitionId{};
+    std::uint32_t characterDefinitionId{};
+    std::uint32_t itemInstanceDefinitionId{};
+    std::uint64_t accountSoid{};
+    std::uint64_t characterSoid{};
+    std::size_t appendedResidentCount{};
+};
+
 /** Validated item-dismantle after-image for one character upsert and one instance release. */
 struct ItemDismantle {
     SessionState after{};
@@ -217,6 +236,11 @@ struct StagedPublication {
     bool rearmsSocialRosterRepush{};
     /** A subclass selection just staged and owes a delayed ability-icon refresh. */
     bool armsAbilityRefresh{};
+    /** Complete row-identity overlay to retain after this equipment transaction commits. */
+    std::array<AcquisitionPresentationRow, kAcquisitionPresentationRowCapacity>
+        acquisitionPresentationRows{};
+    std::uint8_t acquisitionPresentationRowCount{};
+    bool updatesAcquisitionPresentationRows{};
 };
 
 } // namespace sunrise::server::bap::encrypted::queuez

+ 377 - 68
Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp

@@ -1,8 +1,6 @@
 #include <Windows.h>
 
 #include <algorithm>
-#include <array>
-#include <cstdio>
 
 #include "../../../../core/logging/log.h"
 #include "../../../../middleware/secure_channel/runtime.h"
@@ -10,6 +8,7 @@
 #include "../../../../state/activity/destination/definition.h"
 #include "../../../../state/activity/runtime.h"
 #include "../../../../state/runtime/runtime.h"
+#include "../../../../state/progression/seasonal_experience.h"
 #include "../internal.h"
 #include "../push/activity/activity_keepalive_push.h"
 #include "queuez_state_validation.h"
@@ -17,23 +16,228 @@
 namespace sunrise::server::bap::encrypted {
 namespace {
 
-/** Widest re-push report, sized for the fields below. */
-constexpr std::size_t kRepushReportLimit = 96;
+constexpr std::uint8_t kSeasonalExperiencePresentationFailureLimit = 8;
 
-/**
- * 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 {
-    std::array<char, kRepushReportLimit> line{};
-    const int count = std::snprintf(
-        line.data(), line.size(), "ev=queuez stage=%s result=ok bytes=%zu", stage, bytes);
-    if (count > 0) {
+[[nodiscard]] std::span<const queuez::AcquisitionPresentationRow>
+active_acquisition_presentation_rows(const Session& session) noexcept {
+    if (GetTickCount64() >= session.acquisitionPresentationUntilTick
+        || session.acquisitionPresentationRowCount > session.acquisitionPresentationRows.size()) {
+        return {};
+    }
+    return std::span(session.acquisitionPresentationRows)
+        .first(session.acquisitionPresentationRowCount);
+}
+
+/** Drops only the visual XP notification after repeated failures; the XP is already durable. */
+void fail_seasonal_experience_presentation(Session& session) noexcept {
+    if (++session.pendingSeasonalExperienceFailures < kSeasonalExperiencePresentationFailureLimit) {
+        return;
+    }
+    session.pendingSeasonalExperienceAmount = 0;
+    session.pendingSeasonalExperienceMutationSerial = 0;
+    session.pendingSeasonalExperienceFailures = 0;
+    bap::arm_account_resync_everywhere();
+    core::log::write(core::log::Channel::server,
+                     core::log::Level::warn,
+                     "ev=season_xp stage=deferred_presentation result=drop reason=retry_limit");
+}
+
+[[nodiscard]] const state::CharacterState*
+selected_character(const state::AccountState& account) noexcept {
+    if (!state::account::valid(account)) {
+        return nullptr;
+    }
+    for (std::size_t index = 0; index < account.characterCount; ++index) {
+        if (account.characters[index].selected) {
+            return &account.characters[index];
+        }
+    }
+    return nullptr;
+}
+
+/** Publishes and commits one character-inventory world reward. */
+[[nodiscard]] bool consume_world_item_acquisition(const WorldRewardRequest& request,
+                                                  Session& session,
+                                                  Scratch& scratch,
+                                                  std::span<std::byte> response,
+                                                  std::size_t& written,
+                                                  bool& touchesScratch) noexcept {
+    state::PendingItemAcquisition pending{};
+    if (!state::prepare_item_acquisition_for_item(request.itemDefinitionIndex, pending)) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::warn,
+                         "ev=queuez stage=world_acquisition result=fail reason=prepare");
+        bap::fail_world_reward_attempt();
+        return false;
+    }
+    touchesScratch = true;
+    queuez::ItemAcquisition acquisition{};
+    if (!queuez::stage_item_acquisition(session.queuez,
+                                        pending.accountSoid,
+                                        pending.characterSoid,
+                                        pending.acquiredInstanceSoid,
+                                        pending.profileChanged,
+                                        acquisition)) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::warn,
+                         "ev=queuez stage=world_acquisition result=fail reason=stage");
+        bap::fail_world_reward_attempt();
+        return false;
+    }
+    auto nextSendNonce = session.sendNonce;
+    std::size_t framedSize = 0;
+    if (!push::append_item_acquisition_notification(scratch,
+                                                    acquisition,
+                                                    pending,
+                                                    std::nullopt,
+                                                    active_acquisition_presentation_rows(session),
+                                                    session.sessionKey,
+                                                    nextSendNonce,
+                                                    scratch.framed,
+                                                    framedSize)
+        || framedSize == 0 || framedSize > response.size()) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::warn,
+                         "ev=queuez stage=world_acquisition result=fail reason=encode");
+        bap::fail_world_reward_attempt();
+        return false;
+    }
+    if (!state::commit_item_acquisition(pending)) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::warn,
+                         "ev=queuez stage=world_acquisition result=fail reason=commit");
+        bap::fail_world_reward_attempt();
+        return false;
+    }
+    std::copy_n(scratch.framed.begin(), framedSize, response.begin());
+    written = framedSize;
+    middleware::secure_channel::advance_nonce(nextSendNonce);
+    session.sendNonce = nextSendNonce;
+    session.queuez = acquisition.after;
+    bap::complete_world_reward();
+    bap::arm_account_resync_elsewhere(session);
+    bap::arm_acquisition_presentation_hold(session);
+    return true;
+}
+
+/** Publishes and commits one profile-inventory world reward. */
+[[nodiscard]] bool consume_world_profile_item_acquisition(const WorldRewardRequest& request,
+                                                          Session& session,
+                                                          Scratch& scratch,
+                                                          std::span<std::byte> response,
+                                                          std::size_t& written,
+                                                          bool& touchesScratch) noexcept {
+    state::PendingProfileItemAcquisition pending{};
+    if (!state::prepare_profile_item_acquisition_for_item(
+            request.itemDefinitionIndex, request.quantity, pending)) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::warn,
+                         "ev=queuez stage=world_profile_acquisition result=fail reason=prepare");
+        bap::fail_world_reward_attempt();
+        return false;
+    }
+    touchesScratch = true;
+    queuez::ProfileItemAcquisition acquisition{};
+    if (!queuez::stage_profile_item_acquisition(session.queuez,
+                                                pending.accountSoid,
+                                                pending.acquiredInstanceSoid,
+                                                pending.actionSource,
+                                                pending.appended,
+                                                acquisition)) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::warn,
+                         "ev=queuez stage=world_profile_acquisition result=fail reason=stage");
+        bap::fail_world_reward_attempt();
+        return false;
+    }
+    auto nextSendNonce = session.sendNonce;
+    std::size_t framedSize = 0;
+    if (!push::append_profile_item_acquisition_notification(scratch,
+                                                            acquisition,
+                                                            pending,
+                                                            std::nullopt,
+                                                            session.sessionKey,
+                                                            nextSendNonce,
+                                                            scratch.framed,
+                                                            framedSize)
+        || framedSize == 0 || framedSize > response.size()) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::warn,
+                         "ev=queuez stage=world_profile_acquisition result=fail reason=encode");
+        bap::fail_world_reward_attempt();
+        return false;
+    }
+    if (!state::commit_profile_item_acquisition(pending)) {
         core::log::write(core::log::Channel::server,
-                         core::log::Level::info,
-                         {line.data(), static_cast<std::size_t>(count)});
+                         core::log::Level::warn,
+                         "ev=queuez stage=world_profile_acquisition result=fail reason=commit");
+        bap::fail_world_reward_attempt();
+        return false;
+    }
+    std::copy_n(scratch.framed.begin(), framedSize, response.begin());
+    written = framedSize;
+    middleware::secure_channel::advance_nonce(nextSendNonce);
+    session.sendNonce = nextSendNonce;
+    session.queuez = acquisition.after;
+    bap::complete_world_reward();
+    bap::arm_account_resync_elsewhere(session);
+    bap::arm_acquisition_presentation_hold(session);
+    return true;
+}
+
+/** Publishes one non-persistent XP reward row so the native seasonal XP HUD animates. */
+[[nodiscard]] bool consume_seasonal_experience_presentation(Session& session,
+                                                            Scratch& scratch,
+                                                            std::span<std::byte> response,
+                                                            std::size_t& written,
+                                                            bool& touchesScratch) noexcept {
+    if (session.pendingSeasonalExperienceAmount <= 0) {
+        return false;
+    }
+    touchesScratch = true;
+    if (session.pendingSeasonalExperienceMutationSerial == 0) {
+        std::int32_t mutationSerial = 0;
+        if (!state::reserve_selected_character_inventory_serial(mutationSerial)) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             "ev=season_xp stage=deferred_presentation result=fail reason=serial");
+            fail_seasonal_experience_presentation(session);
+            return false;
+        }
+        session.pendingSeasonalExperienceMutationSerial =
+            static_cast<std::uint32_t>(mutationSerial) + 1U;
+    }
+    auto nextSendNonce = session.sendNonce;
+    std::size_t framedSize = 0;
+    queuez::SessionState after{};
+    if (!push::append_seasonal_experience_notification(
+            scratch,
+            session.queuez,
+            session.pendingSeasonalExperienceAmount,
+            static_cast<std::int32_t>(session.pendingSeasonalExperienceMutationSerial - 1U),
+            active_acquisition_presentation_rows(session),
+            session.sessionKey,
+            nextSendNonce,
+            scratch.framed,
+            framedSize,
+            after)
+        || framedSize == 0 || framedSize > response.size()) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::warn,
+                         "ev=season_xp stage=deferred_presentation result=fail");
+        fail_seasonal_experience_presentation(session);
+        return false;
     }
+    std::copy_n(scratch.framed.begin(), framedSize, response.begin());
+    written = framedSize;
+    middleware::secure_channel::advance_nonce(nextSendNonce);
+    session.sendNonce = nextSendNonce;
+    session.queuez = after;
+    session.pendingSeasonalExperienceAmount = 0;
+    session.pendingSeasonalExperienceMutationSerial = 0;
+    session.pendingSeasonalExperienceFailures = 0;
+    bap::arm_account_resync_elsewhere(session);
+    return true;
 }
 
 /** Publishes the current account graph to a peer invalidated by another connection. */
@@ -42,7 +246,7 @@ void report_repush(const char* stage, std::size_t bytes) noexcept {
                                           std::span<std::byte> response,
                                           std::size_t& written,
                                           bool& touchesScratch) noexcept {
-    if (!session.accountResyncArmed || session.accountResyncGeneration == 0) {
+    if (!session.accountResyncArmed) {
         return false;
     }
     touchesScratch = true;
@@ -51,6 +255,7 @@ void report_repush(const char* stage, std::size_t bytes) noexcept {
     queuez::SessionState currentQueuez{};
     if (!push::append_account_resync_notification(scratch,
                                                   session.queuez,
+                                                  active_acquisition_presentation_rows(session),
                                                   session.sessionKey,
                                                   nextSendNonce,
                                                   scratch.framed,
@@ -61,6 +266,7 @@ void report_repush(const char* stage, std::size_t bytes) noexcept {
                          "ev=queuez stage=peer_resync result=fail reason=family4");
         return false;
     }
+    bool auxiliaryRefreshFailed = false;
     if (currentQueuez.family0Active) {
         queuez::SessionState appearanceAfter{};
         if (!push::append_account_resync_appearance_notification(scratch,
@@ -73,9 +279,10 @@ void report_repush(const char* stage, std::size_t bytes) noexcept {
             core::log::write(core::log::Channel::server,
                              core::log::Level::warn,
                              "ev=queuez stage=peer_resync result=fail reason=family0");
-            return false;
+            auxiliaryRefreshFailed = true;
+        } else {
+            currentQueuez = appearanceAfter;
         }
-        currentQueuez = appearanceAfter;
     }
     if (currentQueuez.family3Active) {
         queuez::SessionState rosterAfter{};
@@ -89,9 +296,10 @@ void report_repush(const char* stage, std::size_t bytes) noexcept {
             core::log::write(core::log::Channel::server,
                              core::log::Level::warn,
                              "ev=queuez stage=peer_resync result=fail reason=family3");
-            return false;
+            auxiliaryRefreshFailed = true;
+        } else {
+            currentQueuez = rosterAfter;
         }
-        currentQueuez = rosterAfter;
     }
     if (framedSize == 0 || framedSize > response.size() || !queuez::valid(currentQueuez)) {
         core::log::write(core::log::Channel::server,
@@ -103,22 +311,18 @@ void report_repush(const char* stage, std::size_t bytes) noexcept {
     written = framedSize;
     session.sendNonce = nextSendNonce;
     session.queuez = currentQueuez;
-    session.accountGeneration = session.accountResyncGeneration;
     session.accountResyncArmed = false;
-    report_repush("peer_resync", framedSize);
+    if (auxiliaryRefreshFailed) {
+        // Family 4 is the authoritative account update and has already produced a complete frame.
+        // Appearance/roster are independent derived views: retry them through their own deferred
+        // lane instead of withholding claims, lore, rewards, and objective progress behind them.
+        session.abilityRefreshDueTick = GetTickCount64();
+        session.abilityRefreshArmed = true;
+    }
     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.
- * @param session Auth, nonce and queuez state owned by the connection.
- * @param scratch Transform buffers owned by the lock.
- * @param response Whole-frame storage owned by the caller.
- * @param written Gets the encoded notification size in bytes.
- * @param touchesScratch Set before any scratch buffer is used.
- * @return True when a whole banner notification is published.
- */
+/** Sends the owed banner retry after its delay. */
 [[nodiscard]] bool consume_banner_repush(Session& session,
                                          Scratch& scratch,
                                          std::span<std::byte> response,
@@ -128,15 +332,13 @@ void report_repush(const char* stage, std::size_t bytes) noexcept {
         || GetTickCount64() < session.bannerRepushDueTick) {
         return false;
     }
-    // Nothing is owed while the account owns no character to name. The arm stays set, because it
-    // is the banner's only second chance.
+    // Retain the arm until the account has a character to name.
     if (state::account::banner_character_soid(state::account_snapshot()) == 0) {
         return false;
     }
     touchesScratch = true;
 
-    // The same body the subscribe answer builds, so the version and this host's mirror stay in
-    // step. `append_banner_notification` fixes the version at zero and a pick has moved past it.
+    // Reuse the subscription path so its version and the host mirror stay aligned.
     middleware::queuez::Subscription subscription{};
     subscription.familyType = queuez::kBannerFamilyType;
     subscription.familyRootSoid = session.bannerRepushRoot;
@@ -170,7 +372,6 @@ void report_repush(const char* stage, std::size_t bytes) noexcept {
         session.queuez = bannerAfter;
     }
     session.bannerRepushArmed = false;
-    report_repush("banner_repush", framedSize);
     return true;
 }
 
@@ -221,7 +422,7 @@ void report_repush(const char* stage, std::size_t bytes) noexcept {
     push::append_queuez_notification(scratch,
                                      session.queuez,
                                      subscription,
-                                     state::bap().sessionKey,
+                                     session.sessionKey,
                                      nextSendNonce,
                                      scratch.framed,
                                      framedSize,
@@ -240,21 +441,10 @@ void report_repush(const char* stage, std::size_t bytes) noexcept {
     if (valid(rosterAfter)) {
         session.queuez = rosterAfter;
     }
-    report_repush("social_roster_repush", framedSize);
     return true;
 }
 
-/**
- * Re-derives the selected character's appearance and roster once the ability-bucket rebuild owed
- * by a subclass selection has landed. The refresh sent inline with the opcode-801 response can
- * still carry empty buckets, because that rebuild runs off the Client content-extraction pump.
- * @param session Auth, nonce and queuez state owned by the connection.
- * @param scratch Transform buffers owned by the lock.
- * @param response Whole-frame storage owned by the caller.
- * @param written Gets the encoded notification size in bytes.
- * @param touchesScratch Set before any scratch buffer is used.
- * @return True when at least one owed record refreshes.
- */
+/** Refreshes appearance and roster after an asynchronous ability-bucket rebuild. */
 [[nodiscard]] bool consume_ability_refresh(Session& session,
                                            Scratch& scratch,
                                            std::span<std::byte> response,
@@ -263,8 +453,7 @@ void report_repush(const char* stage, std::size_t bytes) noexcept {
     if (!session.abilityRefreshArmed || GetTickCount64() < session.abilityRefreshDueTick) {
         return false;
     }
-    // Nothing is owed until a family that reads abilities is subscribed. The arm stays set, the
-    // same way the banner re-push below keeps its own.
+    // Retain the arm until a family that reads abilities is active.
     if (!session.queuez.family0Active && !session.queuez.family3Active) {
         return false;
     }
@@ -310,24 +499,117 @@ void report_repush(const char* stage, std::size_t bytes) noexcept {
     written = framedSize;
     session.sendNonce = nextSendNonce;
     session.queuez = current;
-    // The frame is committed here, so the arm is committed with it. Disarming any earlier drops
-    // the owed refresh on a transient encode failure.
+    // Clear the arm only after publication.
     session.abilityRefreshArmed = false;
-    report_repush("ability_refresh", framedSize);
+    return true;
+}
+
+/** Re-publishes only the selected character after an artifact purchase. */
+[[nodiscard]] bool consume_artifact_family4_refresh(Session& session,
+                                                    Scratch& scratch,
+                                                    std::span<std::byte> response,
+                                                    std::size_t& written,
+                                                    bool& touchesScratch) noexcept {
+    if (!session.artifactFamily4RefreshArmed
+        || GetTickCount64() < session.artifactFamily4RefreshDueTick) {
+        return false;
+    }
+    const state::AccountState account = state::account_snapshot();
+    const state::CharacterState* selected = selected_character(account);
+    if (selected == nullptr) {
+        return false;
+    }
+    state::PendingArtifactPurchase refresh{};
+    refresh.accountSoid = account.primarySoid;
+    refresh.characterSoid = selected->soid;
+    refresh.characterIndex = static_cast<std::size_t>(selected - account.characters.data());
+    refresh.beforeMask = state::progression::seasonal_experience::artifact_mod_mask();
+    refresh.afterMask = refresh.beforeMask;
+    refresh.prepared = true;
+
+    queuez::EquipmentSwap update{};
+    auto nextSendNonce = session.sendNonce;
+    std::size_t framedSize = 0;
+    touchesScratch = true;
+    if (!queuez::stage_equipment_swap(session.queuez, refresh.characterSoid, update)
+        || !push::append_artifact_purchase_notification(scratch,
+                                                        update,
+                                                        refresh,
+                                                        active_acquisition_presentation_rows(session),
+                                                        session.sessionKey,
+                                                        nextSendNonce,
+                                                        scratch.framed,
+                                                        framedSize)
+        || framedSize == 0 || framedSize > response.size()) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::warn,
+                         "ev=queuez stage=artifact_refresh result=fail");
+        return false;
+    }
+    std::copy_n(scratch.framed.begin(), framedSize, response.begin());
+    written = framedSize;
+    middleware::secure_channel::advance_nonce(nextSendNonce);
+    session.sendNonce = nextSendNonce;
+    session.queuez = update.after;
+    session.artifactFamily4RefreshArmed = false;
+    session.artifactFamily4RefreshDueTick = 0;
+    return true;
+}
+
+/** Publishes one reset-affected item resident per poll using the proven socket-update shape. */
+[[nodiscard]] bool consume_artifact_item_refresh(Session& session,
+                                                 Scratch& scratch,
+                                                 std::span<std::byte> response,
+                                                 std::size_t& written,
+                                                 bool& touchesScratch) noexcept {
+    if (session.artifactResetRefreshCursor >= session.artifactResetRefresh.instanceCount) {
+        session.artifactResetRefresh = {};
+        session.artifactResetRefreshCursor = 0;
+        return false;
+    }
+    const state::AccountState account = state::account_snapshot();
+    const state::CharacterState* selected = selected_character(account);
+    if (selected == nullptr) {
+        return false;
+    }
+    const std::uint64_t instanceSoid =
+        session.artifactResetRefresh.instanceSoids[session.artifactResetRefreshCursor];
+    queuez::EquipmentSwap update{};
+    auto nextSendNonce = session.sendNonce;
+    std::size_t framedSize = 0;
+    touchesScratch = true;
+    if (!queuez::stage_equipment_swap(session.queuez, selected->soid, update)
+        || !push::append_artifact_item_refresh_notification(scratch,
+                                                            update,
+                                                            instanceSoid,
+                                                            session.sessionKey,
+                                                            nextSendNonce,
+                                                            scratch.framed,
+                                                            framedSize)
+        || framedSize == 0 || framedSize > response.size()) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::warn,
+                         "ev=queuez stage=artifact_item_refresh result=fail");
+        return false;
+    }
+    std::copy_n(scratch.framed.begin(), framedSize, response.begin());
+    written = framedSize;
+    middleware::secure_channel::advance_nonce(nextSendNonce);
+    session.sendNonce = nextSendNonce;
+    session.queuez = update.after;
+    ++session.artifactResetRefreshCursor;
+    if (session.artifactResetRefreshCursor >= session.artifactResetRefresh.instanceCount) {
+        // Equipped sockets feed Family 0/3's derived perk banks. Refresh them once every changed
+        // item resident has landed so reset cannot leave the previous champion effect cached.
+        session.abilityRefreshDueTick = GetTickCount64();
+        session.abilityRefreshArmed = true;
+    }
     return true;
 }
 
 } // namespace
 
-/**
- * Sends the owed Family-4 re-push once its delay has passed.
- * @param session Auth, nonce and queuez state owned by the connection.
- * @param scratch Transform buffers owned by the lock.
- * @param response Whole-frame storage owned by the caller.
- * @param written Gets the encoded notification size in bytes.
- * @param touchesScratch Set before any scratch buffer is used.
- * @return True when a whole Family-4 notification is published.
- */
+/** Publishes the next due reward, refresh, retry, or keepalive. */
 bool consume_deferred(Session& session,
                       Scratch& scratch,
                       std::span<std::byte> response,
@@ -337,18 +619,46 @@ bool consume_deferred(Session& session,
     if (!session.authenticated) {
         return false;
     }
+    if (consume_artifact_family4_refresh(session, scratch, response, written, touchesScratch)) {
+        return true;
+    }
+    if (consume_artifact_item_refresh(session, scratch, response, written, touchesScratch)) {
+        return true;
+    }
     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.
+    // A failed resync blocks every incremental that could depend on its missing objects.
     if (session.accountResyncArmed) {
         return false;
     }
+    WorldRewardRequest reward{};
+    if (session.queuez.family4Active && bap::current_world_reward(reward)) {
+        bool published = false;
+        switch (reward.kind) {
+        case WorldRewardKind::item:
+            published = consume_world_item_acquisition(
+                reward, session, scratch, response, written, touchesScratch);
+            break;
+        case WorldRewardKind::profileItem:
+            published = consume_world_profile_item_acquisition(
+                reward, session, scratch, response, written, touchesScratch);
+            break;
+        }
+        if (published) {
+            return true;
+        }
+    }
+    if (consume_seasonal_experience_presentation(
+            session, scratch, response, written, touchesScratch)) {
+        return true;
+    }
     if (consume_ability_refresh(session, scratch, response, written, touchesScratch)) {
         return true;
     }
     if (!session.family4RepushArmed || session.family4RepushRoot == 0
-        || GetTickCount64() < session.family4RepushDueTick) {
+        || GetTickCount64() < session.family4RepushDueTick
+        || GetTickCount64() < session.acquisitionPresentationUntilTick) {
         return consume_social_roster_repush(session, scratch, response, written, touchesScratch)
                || consume_banner_repush(session, scratch, response, written, touchesScratch)
                || push::activity::consume_activity_keepalive(
@@ -393,7 +703,6 @@ bool consume_deferred(Session& session,
         session.queuez = after;
     }
     session.family4RepushArmed = false;
-    report_repush("repush", framedSize);
     return true;
 }
 

+ 453 - 178
Sunrise/src/server/bap/encrypted/queuez/queuez_outcome_staging.cpp

@@ -1,15 +1,190 @@
 #include "queuez_outcome_staging.h"
 
+#include <algorithm>
 #include <limits>
+#include <optional>
 
 #include "../../../../core/logging/log.h"
+#include "../../../../middleware/datagen/family4/loadout/loadout_resolver.h"
 #include "../../../../middleware/secure_channel/runtime.h"
-#include "../../../../state/runtime/runtime.h"
 #include "queuez_state_validation.h"
 
 namespace sunrise::server::bap::encrypted::queuez {
 namespace {
 
+/** Extends the active feed overlay with the two identities touched by this equip. */
+[[nodiscard]] bool merge_equipment_presentation_rows(
+    std::span<const AcquisitionPresentationRow> existing,
+    const state::PendingEquipmentSwap& mutation,
+    std::array<AcquisitionPresentationRow, kAcquisitionPresentationRowCapacity>& output,
+    std::uint8_t& count) noexcept {
+    if (existing.size() > output.size()) {
+        return false;
+    }
+    std::copy(existing.begin(), existing.end(), output.begin());
+    std::size_t used = existing.size();
+
+    const state::AccountState account = state::account_snapshot();
+    middleware::datagen::family4::loadout::ResolvedLoadout loadout{};
+    if (!mutation.prepared || mutation.characterIndex >= account.characterCount
+        || account.characters[mutation.characterIndex].soid != mutation.characterSoid
+        || !middleware::datagen::family4::loadout::resolve(
+            account, mutation.characterIndex, loadout)) {
+        return false;
+    }
+
+    const auto add = [&](std::uint64_t instanceSoid) noexcept -> bool {
+        if (instanceSoid == 0) {
+            return true;
+        }
+        for (std::size_t index = 0; index < used; ++index) {
+            if (output[index].instanceSoid == instanceSoid) {
+                return true;
+            }
+        }
+        if (used >= output.size()) {
+            return false;
+        }
+        for (std::size_t index = 0; index < loadout.itemCount; ++index) {
+            const auto& item = loadout.items[index];
+            if (item.instance.instanceSoid != instanceSoid) {
+                continue;
+            }
+            for (std::size_t prior = 0; prior < used; ++prior) {
+                if (output[prior].inventoryRow == item.inventoryRow) {
+                    return false;
+                }
+            }
+            output[used++] = AcquisitionPresentationRow{
+                instanceSoid, static_cast<std::uint16_t>(item.inventoryRow)};
+            return true;
+        }
+        return false;
+    };
+
+    if (!add(mutation.requestedInstanceSoid) || !add(mutation.previousInstanceSoid)) {
+        return false;
+    }
+    count = static_cast<std::uint8_t>(used);
+    return true;
+}
+
+/** Stages the character upsert and appended resident an item acquisition promised. */
+[[nodiscard]] bool
+stage_item_acquisition_push(Scratch& scratch,
+                            const SessionState& before,
+                            const ItemAcquisition& acquisition,
+                            const state::PendingItemAcquisition& pending,
+                            std::optional<std::uint16_t> pendingSeasonReward,
+                            std::span<const AcquisitionPresentationRow> presentationRows,
+                            std::span<const std::byte, state::kAesKeySize> key,
+                            std::array<std::byte, state::kBapNonceSize>& nonce,
+                            std::span<std::byte> response,
+                            std::size_t& written,
+                            SessionState& after) noexcept {
+    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 != pending.accountSoid
+        || acquisition.characterSoid != pending.characterSoid
+        || acquisition.acquiredInstanceSoid != pending.acquiredInstanceSoid
+        || acquisition.updatesAccount != (pending.profileChanged || pendingSeasonReward.has_value())
+        || 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,
+                                                       pending,
+                                                       pendingSeasonReward,
+                                                       presentationRows,
+                                                       key,
+                                                       nonce,
+                                                       response,
+                                                       written)) {
+        return false;
+    }
+    middleware::secure_channel::advance_nonce(nonce);
+    after = acquisition.after;
+    return true;
+}
+
+/** Stages the account upsert and optional manifest append a profile acquisition promised. */
+[[nodiscard]] bool
+stage_profile_item_acquisition_push(Scratch& scratch,
+                                    const SessionState& before,
+                                    const ProfileItemAcquisition& acquisition,
+                                    const state::PendingProfileItemAcquisition& pending,
+                                    std::optional<std::uint16_t> pendingSeasonReward,
+                                    std::span<const std::byte, state::kAesKeySize> key,
+                                    std::array<std::byte, state::kBapNonceSize>& nonce,
+                                    std::span<std::byte> response,
+                                    std::size_t& written,
+                                    SessionState& after) noexcept {
+    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 != pending.accountSoid
+        || acquisition.acquiredInstanceSoid != pending.acquiredInstanceSoid
+        || acquisition.actionSource != pending.actionSource
+        || acquisition.appendedResident != (pending.appended && 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, pending, pendingSeasonReward, key, nonce, response, written)) {
+        return false;
+    }
+    middleware::secure_channel::advance_nonce(nonce);
+    after = acquisition.after;
+    return true;
+}
+
 /**
  * Says whether equipping into one slot changes what the family-two member record publishes.
  *
@@ -37,6 +212,8 @@ namespace {
 bool stage_service_outcome(Scratch& scratch,
                            const SessionState& before,
                            const ServiceOutcome& outcome,
+                           bool preserveAcquisitionPresentation,
+                           std::span<const AcquisitionPresentationRow> acquisitionPresentationRows,
                            std::span<const std::byte, state::kAesKeySize> key,
                            std::array<std::byte, state::kBapNonceSize>& nonce,
                            std::span<std::byte> response,
@@ -49,20 +226,28 @@ bool stage_service_outcome(Scratch& scratch,
     std::uint64_t bannerRoot = 0;
     std::uint64_t socialRosterRoot = 0;
     bool armsAbilityRefresh = false;
+    const auto* changeCharacter = transaction_if<ChangeCharacter>(outcome);
+    const auto* selectCharacter = transaction_if<SelectCharacter>(outcome);
     const auto* equipment = transaction_if<EquipmentSwapTransaction>(outcome);
     const auto* subclassSelection = transaction_if<SubclassSelectionTransaction>(outcome);
     const auto* itemState = transaction_if<ItemStateTransaction>(outcome);
     const auto* currentActivity = transaction_if<CurrentActivityTransaction>(outcome);
+    const auto* artifactPurchase = transaction_if<ArtifactPurchaseTransaction>(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);
-    const auto* allocation = transaction_if<state::activity::PendingAllocation>(outcome);
+    const auto* recordRewardGrant = transaction_if<RecordRewardGrantTransaction>(outcome);
+    const auto* seasonPassReward = transaction_if<SeasonPassRewardTransaction>(outcome);
+    const auto presentationRows = preserveAcquisitionPresentation
+                                      ? acquisitionPresentationRows
+                                      : std::span<const AcquisitionPresentationRow>{};
     // Set before the branch chain rather than inside the equipment arm. That arm returns early
     // when the staged after-image fails validation, and the equip has already moved State by
     // then -- so the published record is stale on exactly the path the arm never finishes.
     publication.rearmsSocialRosterRepush =
-        equipment != nullptr && moves_social_roster(equipment->pending.equipmentSlotIndex);
+        equipment != nullptr && equipment->pending != nullptr
+        && moves_social_roster(equipment->pending->equipmentSlotIndex);
     if (outcome.hasSubscription) {
         push::append_queuez_notification(scratch,
                                          before,
@@ -89,26 +274,48 @@ bool stage_service_outcome(Scratch& scratch,
     } else if (outcome.hasChangeCharacter) {
         // The reply already carries the version this patch promises. A patch that cannot be built
         // leaves the ladder where it is, instead of holding back that reply.
-        if (!push::append_change_character_notification(
-                scratch, outcome.changeCharacter, key, nonce, response, written)) {
+        if (changeCharacter == nullptr
+            || !push::append_change_character_notification(
+                scratch, *changeCharacter, key, nonce, response, written)) {
             core::log::write(core::log::Channel::server,
                              core::log::Level::warn,
                              "ev=queuez stage=change result=fail");
             return true;
         }
         middleware::secure_channel::advance_nonce(nonce);
-        after = outcome.changeCharacter.after;
+        after = 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.
+        if (equipment->pending == nullptr) {
+            return false;
+        }
+        const auto& pending = *equipment->pending;
         const EquipmentSwap& swap = equipment->update;
-        if (!valid(swap.after) || swap.characterSoid != equipment->pending.characterSoid
+        std::array<AcquisitionPresentationRow, kAcquisitionPresentationRowCapacity>
+            mergedPresentationRows{};
+        std::uint8_t mergedPresentationRowCount = 0;
+        const bool hasPresentation =
+            preserveAcquisitionPresentation
+            && merge_equipment_presentation_rows(acquisitionPresentationRows,
+                                                 pending,
+                                                 mergedPresentationRows,
+                                                 mergedPresentationRowCount);
+        if (preserveAcquisitionPresentation && !hasPresentation) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             "ev=queuez stage=equip_presentation result=fail");
+        }
+        const auto equipmentPresentationRows =
+            hasPresentation ? std::span(mergedPresentationRows).first(mergedPresentationRowCount)
+                            : std::span<const AcquisitionPresentationRow>{};
+        if (!valid(swap.after) || swap.characterSoid != 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)) {
+                scratch, swap, pending, equipmentPresentationRows, key, nonce, response, written)) {
             core::log::write(core::log::Channel::server,
                              core::log::Level::warn,
                              "ev=queuez stage=equip result=fail");
@@ -117,10 +324,17 @@ bool stage_service_outcome(Scratch& scratch,
         }
         middleware::secure_channel::advance_nonce(nonce);
         after = swap.after;
-        // Swapping the subclass slot invalidates the published ability buckets, the same way an
-        // opcode-801 pick does. The rebuild is likewise asynchronous, so this owes the same
-        // delayed re-derivation rather than racing whatever refresh runs below.
-        if (equipment->pending.equipmentSlotIndex
+        if (preserveAcquisitionPresentation) {
+            if (hasPresentation) {
+                publication.acquisitionPresentationRows = mergedPresentationRows;
+                publication.acquisitionPresentationRowCount = mergedPresentationRowCount;
+            }
+            publication.updatesAcquisitionPresentationRows = true;
+        }
+        // Swapping the subclass slot invalidates the published ability buckets the same way an
+        // opcode-801 pick does; the rebuild is likewise asynchronous, so this owes the same
+        // delayed re-derivation rather than risking a race with whatever refresh runs below.
+        if (pending.equipmentSlotIndex
             == static_cast<std::size_t>(state::account::inventory::EquipmentSlot::subclass)) {
             armsAbilityRefresh = true;
         }
@@ -129,10 +343,9 @@ bool stage_service_outcome(Scratch& scratch,
         // 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)
+            if (!stage_character_appearance_refresh(after, pending.characterSoid, refresh)
                 || !push::append_equipment_appearance_refresh_notification(
-                    scratch, refresh, equipment->pending, key, nonce, response, written)) {
+                    scratch, refresh, pending, key, nonce, response, written)) {
                 core::log::write(core::log::Channel::server,
                                  core::log::Level::warn,
                                  "ev=queuez stage=equip_appearance result=fail");
@@ -144,13 +357,12 @@ bool stage_service_outcome(Scratch& scratch,
         }
         // 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.
+        // 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)
+            if (!stage_roster_appearance_refresh(after, pending.characterSoid, true, refresh)
                 || !push::append_equipment_roster_refresh_notification(
-                    scratch, refresh, equipment->pending, key, nonce, response, written)) {
+                    scratch, refresh, pending, key, nonce, response, written)) {
                 core::log::write(core::log::Channel::server,
                                  core::log::Level::warn,
                                  "ev=queuez stage=equip_roster result=fail");
@@ -158,9 +370,41 @@ bool stage_service_outcome(Scratch& scratch,
             }
             after = refresh.after;
         }
+    } else if (artifactPurchase != nullptr) {
+        if (artifactPurchase->pending == nullptr) {
+            return false;
+        }
+        const auto& pending = *artifactPurchase->pending;
+        const EquipmentSwap& update = artifactPurchase->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 != pending.characterSoid
+            || update.after.family4RootSoid != before.family4RootSoid
+            || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
+            || update.after.family4Version != before.family4Version + 1
+            || !push::append_artifact_purchase_notification(
+                scratch, update, pending, presentationRows, key, nonce, response, written)) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             "ev=queuez stage=artifact result=fail");
+            return false;
+        }
+        middleware::secure_channel::advance_nonce(nonce);
+        after = update.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.
+        if (itemState->pending == nullptr) {
+            return false;
+        }
+        const auto& pending = *itemState->pending;
         const EquipmentSwap& update = itemState->update;
         bool preservedManifest = update.after.family4ResidentCount == before.family4ResidentCount;
         for (std::size_t index = 0; preservedManifest && index < before.family4ResidentCount;
@@ -171,12 +415,12 @@ bool stage_service_outcome(Scratch& scratch,
                                        == before.family4Residents[index].definitionId;
         }
         if (!valid(update.after) || !preservedManifest
-            || update.characterSoid != itemState->pending.characterSoid
+            || update.characterSoid != 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)) {
+                scratch, update, pending, presentationRows, key, nonce, response, written)) {
             core::log::write(core::log::Channel::server,
                              core::log::Level::warn,
                              "ev=queuez stage=item_state result=fail");
@@ -214,6 +458,10 @@ bool stage_service_outcome(Scratch& scratch,
         // Body processing already staged the exact +1 revision opcode 801 promised. The instance
         // upsert goes first, then the appearance and roster refreshes, so gameplay reads the new
         // selection now rather than on the next unrelated poll.
+        if (subclassSelection->pending == nullptr) {
+            return false;
+        }
+        const auto& pending = *subclassSelection->pending;
         const SubclassSelection& selection = subclassSelection->update;
         bool preservedManifest =
             selection.after.family4ResidentCount == before.family4ResidentCount;
@@ -229,14 +477,14 @@ bool stage_service_outcome(Scratch& scratch,
                 && resident.definitionId == selection.itemInstanceDefinitionId);
         }
         if (!valid(selection.after) || !preservedManifest || targetMatches != 1
-            || selection.accountSoid != subclassSelection->pending.accountSoid
-            || selection.characterSoid != subclassSelection->pending.characterSoid
-            || selection.subclassInstanceSoid != subclassSelection->pending.subclassInstanceSoid
+            || selection.accountSoid != pending.accountSoid
+            || selection.characterSoid != pending.characterSoid
+            || selection.subclassInstanceSoid != pending.subclassInstanceSoid
             || selection.after.family4RootSoid != before.family4RootSoid
             || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
             || selection.after.family4Version != before.family4Version + 1
             || !push::append_subclass_selection_notification(
-                scratch, selection, subclassSelection->pending, key, nonce, response, written)) {
+                scratch, selection, pending, key, nonce, response, written)) {
             core::log::write(core::log::Channel::server,
                              core::log::Level::warn,
                              "ev=queuez stage=subclass_select result=fail");
@@ -244,41 +492,17 @@ bool stage_service_outcome(Scratch& scratch,
         }
         middleware::secure_channel::advance_nonce(nonce);
         after = selection.after;
-        // The ability-bucket rebuild runs off the Client content-extraction pump, so the two
-        // refreshes below can race it and carry empty buckets. The delayed re-derivation is owed
-        // either way.
+        // Family zero and Family three no longer fit beside the enlarged upstream Family-four
+        // selection notification in this bounded reply. Publish them through the already-proven
+        // deferred refresh channel after the primary mutation commits.
         armsAbilityRefresh = true;
-        // A subclass is always equipped, so both the appearance and roster ability reads are
-        // always owed a refresh once one is active.
-        if (after.family0Active) {
-            CharacterAppearanceRefresh refresh{};
-            if (!stage_character_appearance_refresh(
-                    after, subclassSelection->pending.characterSoid, refresh)
-                || !push::append_subclass_appearance_refresh_notification(
-                    scratch, refresh, subclassSelection->pending, key, nonce, response, written)) {
-                core::log::write(core::log::Channel::server,
-                                 core::log::Level::warn,
-                                 "ev=queuez stage=subclass_appearance result=fail");
-                return false;
-            }
-            after = refresh.after;
-        }
-        if (after.family3Active) {
-            RosterAppearanceRefresh refresh{};
-            if (!stage_roster_appearance_refresh(
-                    after, subclassSelection->pending.characterSoid, false, refresh)
-                || !push::append_subclass_roster_refresh_notification(
-                    scratch, refresh, subclassSelection->pending, key, nonce, response, written)) {
-                core::log::write(core::log::Channel::server,
-                                 core::log::Level::warn,
-                                 "ev=queuez stage=subclass_roster result=fail");
-                return false;
-            }
-            after = refresh.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.
+        if (socket->pending == nullptr) {
+            return false;
+        }
+        const auto& pending = *socket->pending;
         const SocketPlug& socketPlug = socket->update;
         bool preservedManifest =
             socketPlug.after.family4ResidentCount == before.family4ResidentCount;
@@ -298,15 +522,15 @@ bool stage_service_outcome(Scratch& scratch,
                                                               == 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
+            || targetMatches != 1 || socketPlug.accountSoid != pending.accountSoid
+            || socketPlug.characterSoid != pending.characterSoid
+            || socketPlug.targetInstanceSoid != pending.targetInstanceSoid
+            || socketPlug.updatesAccount != 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)) {
+                scratch, socketPlug, pending, key, nonce, response, written)) {
             core::log::write(core::log::Channel::server,
                              core::log::Level::warn,
                              "ev=queuez stage=socket_plug result=fail");
@@ -314,133 +538,186 @@ bool stage_service_outcome(Scratch& scratch,
         }
         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;
+        // Equipped plugs alter Family-zero and Family-three presentation. Those enlarged records
+        // are published separately so this mutation cannot fail after its primary frame is built.
+        if (pending.targetEquipped) {
+            armsAbilityRefresh = true;
         }
     } 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)) {
+        if (itemAcquisition->pending == nullptr
+            || !stage_item_acquisition_push(scratch,
+                                            before,
+                                            itemAcquisition->update,
+                                            *itemAcquisition->pending,
+                                            std::nullopt,
+                                            presentationRows,
+                                            key,
+                                            nonce,
+                                            response,
+                                            written,
+                                            after)) {
             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)) {
+        if (profileAcquisition->pending == nullptr
+            || !stage_profile_item_acquisition_push(scratch,
+                                                    before,
+                                                    profileAcquisition->update,
+                                                    *profileAcquisition->pending,
+                                                    std::nullopt,
+                                                    key,
+                                                    nonce,
+                                                    response,
+                                                    written,
+                                                    after)) {
             core::log::write(core::log::Channel::server,
                              core::log::Level::warn,
                              "ev=queuez stage=profile_acquire result=fail");
             return false;
         }
+    } else if (recordRewardGrant != nullptr) {
+        if (recordRewardGrant->pending == nullptr) {
+            return false;
+        }
+        if (!push::append_record_reward_notification(scratch,
+                                                     before,
+                                                     recordRewardGrant->update,
+                                                     *recordRewardGrant->pending,
+                                                     std::nullopt,
+                                                     presentationRows,
+                                                     key,
+                                                     nonce,
+                                                     response,
+                                                     written)) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             "ev=queuez stage=record_reward result=fail");
+            return false;
+        }
         middleware::secure_channel::advance_nonce(nonce);
-        after = acquisition.after;
+        after = recordRewardGrant->update.after;
+    } else if (seasonPassReward != nullptr) {
+        if (seasonPassReward->pending == nullptr) {
+            return false;
+        }
+        const auto& pending = *seasonPassReward->pending;
+        bool staged = false;
+        if (const auto* itemUpdate = std::get_if<ItemAcquisition>(&seasonPassReward->update)) {
+            if (const auto* itemPending =
+                    std::get_if<state::PendingItemAcquisition>(&pending.grant)) {
+                staged = stage_item_acquisition_push(scratch,
+                                                     before,
+                                                     *itemUpdate,
+                                                     *itemPending,
+                                                     pending.rewardIndex,
+                                                     presentationRows,
+                                                     key,
+                                                     nonce,
+                                                     response,
+                                                     written,
+                                                     after);
+            }
+        } else if (const auto* profileUpdate =
+                       std::get_if<ProfileItemAcquisition>(&seasonPassReward->update)) {
+            if (const auto* profilePending =
+                    std::get_if<state::PendingProfileItemAcquisition>(&pending.grant)) {
+                staged = stage_profile_item_acquisition_push(scratch,
+                                                             before,
+                                                             *profileUpdate,
+                                                             *profilePending,
+                                                             pending.rewardIndex,
+                                                             key,
+                                                             nonce,
+                                                             response,
+                                                             written,
+                                                             after);
+            }
+        } else if (const auto* bundle =
+                       std::get_if<state::PendingDirectItemBundle>(&pending.grant)) {
+            staged = push::append_season_pass_package_notification(scratch,
+                                                                   before,
+                                                                   *bundle,
+                                                                   pending.rewardIndex,
+                                                                   presentationRows,
+                                                                   key,
+                                                                   nonce,
+                                                                   response,
+                                                                   written,
+                                                                   after);
+            if (staged) {
+                middleware::secure_channel::advance_nonce(nonce);
+            }
+        } else if (const auto* resourceUpdate =
+                       std::get_if<RecordRewardGrant>(&seasonPassReward->update)) {
+            if (const auto* resources =
+                    std::get_if<state::PendingRecordRewardGrant>(&pending.grant)) {
+                staged = push::append_record_reward_notification(scratch,
+                                                                 before,
+                                                                 *resourceUpdate,
+                                                                 *resources,
+                                                                 pending.rewardIndex,
+                                                                 presentationRows,
+                                                                 key,
+                                                                 nonce,
+                                                                 response,
+                                                                 written);
+                if (staged) {
+                    middleware::secure_channel::advance_nonce(nonce);
+                    after = resourceUpdate->after;
+                }
+            }
+        }
+        if (!staged) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             "ev=ws2400 stage=queuez_reward result=fail");
+            return false;
+        }
+    } else if (outcome.hasArtifactReset) {
+        const state::AccountState account = state::account_snapshot();
+        std::uint64_t selectedCharacter = 0;
+        for (std::size_t index = 0; index < account.characterCount; ++index) {
+            if (account.characters[index].selected) {
+                selectedCharacter = account.characters[index].soid;
+                break;
+            }
+        }
+        EquipmentSwap reset{};
+        if (selectedCharacter == 0 || !stage_equipment_swap(before, selectedCharacter, reset)
+            || !push::append_artifact_reset_notification(
+                scratch, reset, key, nonce, response, written)) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             "ev=ws901 stage=artifact_reset_resync result=fail");
+            return true;
+        }
+        middleware::secure_channel::advance_nonce(nonce);
+        after = reset.after;
+    } else if (outcome.hasRecordClaim) {
+        // A claim rewrites one byte of the account flag bank and leaves the manifest alone, so a
+        // full account snapshot at the next version carries it with no other staging.
+        if (!push::append_account_resync_notification(
+                scratch, before, presentationRows, key, nonce, response, written, after)) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             "ev=ws1801 stage=queuez_resync result=fail");
+            return true;
+        }
     } 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.
+        if (itemDismantle->pending == nullptr) {
+            return false;
+        }
+        const auto& pending = *itemDismantle->pending;
         const ItemDismantle& dismantle = itemDismantle->update;
         bool compactedManifest =
             before.family4ResidentCount != 0
@@ -468,17 +745,17 @@ bool stage_service_outcome(Scratch& scratch,
                             && 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 != pending.accountSoid
+            || dismantle.characterSoid != pending.characterSoid
+            || dismantle.dismantledInstanceSoid != pending.dismantledInstanceSoid
+            || dismantle.updatesAccount != 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)) {
+                scratch, dismantle, pending, key, nonce, response, written)) {
             core::log::write(core::log::Channel::server,
                              core::log::Level::warn,
                              "ev=queuez stage=dismantle result=fail");
@@ -489,22 +766,23 @@ bool stage_service_outcome(Scratch& scratch,
     } 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.
-        if (!push::append_select_character_notification(
-                scratch, outcome.selectCharacter, key, nonce, response, written)) {
+        if (selectCharacter == nullptr
+            || !push::append_select_character_notification(
+                scratch, *selectCharacter, key, nonce, response, written)) {
             core::log::write(core::log::Channel::server,
                              core::log::Level::warn,
                              "ev=queuez stage=select result=fail");
             return true;
         }
         middleware::secure_channel::advance_nonce(nonce);
-        after = outcome.selectCharacter.after;
+        after = selectCharacter->after;
         // The banner pair follows the family-four move, so it is sent against the state that move
         // made. A pair that cannot be built leaves the emblem where it is.
         const SessionState& bannerBefore = after;
         SessionState bannerAfter{};
         if (push::append_banner_move_notification(scratch,
                                                   bannerBefore,
-                                                  outcome.selectCharacter.selectedCharacterSoid,
+                                                  selectCharacter->selectedCharacterSoid,
                                                   key,
                                                   nonce,
                                                   response,
@@ -540,9 +818,6 @@ bool stage_service_outcome(Scratch& scratch,
                 bannerRoot = held.familyRootSoid;
             }
         }
-    } else if (allocation != nullptr) {
-        // The launch arrives on the activity link, which holds no family-4 store and owes none.
-        return true;
     } else {
         return true;
     }

+ 13 - 8
Sunrise/src/server/bap/encrypted/queuez/queuez_outcome_staging.h

@@ -15,6 +15,8 @@ namespace sunrise::server::bap::encrypted::queuez {
  * @param scratch Transform buffers owned by the lock.
  * @param before Queuez state the current BAP peer can see.
  * @param outcome Body outcome carrying at most one queuez action.
+ * @param preserveAcquisitionPresentation Whether the current feed window is still active.
+ * @param acquisitionPresentationRows Item identities already pinned to feed-referenced rows.
  * @param key Active AES-GCM session key.
  * @param nonce Local send nonce, advanced only by whole staged frames.
  * @param response Whole-frame staging storage owned by the lock.
@@ -22,13 +24,16 @@ namespace sunrise::server::bap::encrypted::queuez {
  * @param publication Gets a queuez after-image when an action records state.
  * @return True when every asked-for frame fits. Unsubscription is idempotent on its own.
  */
-[[nodiscard]] bool stage_service_outcome(Scratch& scratch,
-                                         const SessionState& before,
-                                         const ServiceOutcome& outcome,
-                                         std::span<const std::byte, state::kAesKeySize> key,
-                                         std::array<std::byte, state::kBapNonceSize>& nonce,
-                                         std::span<std::byte> response,
-                                         std::size_t& written,
-                                         StagedPublication& publication) noexcept;
+[[nodiscard]] bool
+stage_service_outcome(Scratch& scratch,
+                      const SessionState& before,
+                      const ServiceOutcome& outcome,
+                      bool preserveAcquisitionPresentation,
+                      std::span<const AcquisitionPresentationRow> acquisitionPresentationRows,
+                      std::span<const std::byte, state::kAesKeySize> key,
+                      std::array<std::byte, state::kBapNonceSize>& nonce,
+                      std::span<std::byte> response,
+                      std::size_t& written,
+                      StagedPublication& publication) noexcept;
 
 } // namespace sunrise::server::bap::encrypted::queuez

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

@@ -1,6 +1,7 @@
 #pragma once
 
 #include <cstdint>
+#include <span>
 
 #include "../../../../middleware/queuez/queuez_update.h"
 #include "../../../../middleware/queuez/subscription.h"
@@ -169,6 +170,14 @@ namespace sunrise::server::bap::encrypted::queuez {
                                           bool updatesAccount,
                                           ItemAcquisition& acquisition) noexcept;
 
+/** Validates one same-version bundle append and returns the revision its response may promise. */
+[[nodiscard]] bool stage_direct_item_bundle(const SessionState& before,
+                                            std::uint64_t accountSoid,
+                                            std::uint64_t characterSoid,
+                                            std::uint64_t firstInstanceSoid,
+                                            std::size_t itemCount,
+                                            std::int32_t& family4Version) 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,
@@ -188,6 +197,13 @@ namespace sunrise::server::bap::encrypted::queuez {
                                                   bool appended,
                                                   ProfileItemAcquisition& acquisition) noexcept;
 
+/** Stages one version containing a complete multi-row record reward and claim. */
+[[nodiscard]] bool stage_record_reward_grant(const SessionState& before,
+                                             std::uint64_t accountSoid,
+                                             std::uint64_t characterSoid,
+                                             std::span<const std::uint64_t> appendedResidents,
+                                             RecordRewardGrant& grant) noexcept;
+
 /**
  * Stages one Family-4 increment that removes an item resident and updates its character.
  *

+ 259 - 143
Sunrise/src/server/bap/encrypted/queuez/staging/queuez_character_staging.cpp

@@ -1,7 +1,9 @@
+#include <algorithm>
 #include <array>
 #include <cstddef>
 #include <cstdio>
 #include <limits>
+#include <span>
 
 #include "../../../../../core/logging/log.h"
 #include "../../../../../middleware/datagen/definitions.h"
@@ -192,21 +194,22 @@ bool stage_character_appearance_refresh(const SessionState& 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)});
+    if (!staged) {
+        std::array<char, core::log::kLineCapacity> line{};
+        const int count = std::snprintf(
+            line.data(),
+            line.size(),
+            "ev=equip stage=family0_version result=fail root=0x%llX before=%d after=%d "
+            "character=0x%llX",
+            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,
+                             core::log::Level::warn,
+                             {line.data(), static_cast<std::size_t>(count)});
+        }
     }
     return staged;
 }
@@ -227,22 +230,23 @@ bool stage_roster_appearance_refresh(const SessionState& before,
     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)});
+    if (!staged) {
+        std::array<char, core::log::kLineCapacity> line{};
+        const int count = std::snprintf(
+            line.data(),
+            line.size(),
+            "ev=appearance stage=family3_version result=fail root=0x%llX before=%d after=%d "
+            "character=0x%llX roster=%u",
+            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,
+                             core::log::Level::warn,
+                             {line.data(), static_cast<std::size_t>(count)});
+        }
     }
     return staged;
 }
@@ -298,27 +302,28 @@ bool stage_socket_plug(const SessionState& before,
     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)});
+    if (!staged) {
+        std::array<char, core::log::kLineCapacity> line{};
+        const int count = std::snprintf(
+            line.data(),
+            line.size(),
+            "ev=socket_plug stage=queuez_version result=fail root=0x%llX before=%d after=%d "
+            "residents=%u account=0x%llX character=0x%llX instance=0x%llX "
+            "item_definition=%u account_update=%u",
+            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,
+                             core::log::Level::warn,
+                             {line.data(), static_cast<std::size_t>(count)});
+        }
     }
     return staged;
 }
@@ -371,24 +376,25 @@ bool stage_subclass_selection(const SessionState& before,
     selection.characterSoid = characterSoid;
     selection.subclassInstanceSoid = subclassInstanceSoid;
     const bool staged = valid(selection.after);
-    std::array<char, core::log::kLineCapacity> line{};
-    const int count = std::snprintf(
-        line.data(),
-        line.size(),
-        "ev=subclass_select stage=queuez_version result=%s root=0x%llX before=%d after=%d "
-        "residents=%u character=0x%llX instance=0x%llX item_definition=%u",
-        staged ? "ok" : "fail",
-        static_cast<unsigned long long>(before.family4RootSoid),
-        before.family4Version,
-        selection.after.family4Version,
-        static_cast<unsigned>(before.family4ResidentCount),
-        static_cast<unsigned long long>(characterSoid),
-        static_cast<unsigned long long>(subclassInstanceSoid),
-        itemInstanceDefinitionId);
-    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)});
+    if (!staged) {
+        std::array<char, core::log::kLineCapacity> line{};
+        const int count = std::snprintf(
+            line.data(),
+            line.size(),
+            "ev=subclass_select stage=queuez_version result=fail root=0x%llX before=%d after=%d "
+            "residents=%u character=0x%llX instance=0x%llX item_definition=%u",
+            static_cast<unsigned long long>(before.family4RootSoid),
+            before.family4Version,
+            selection.after.family4Version,
+            static_cast<unsigned>(before.family4ResidentCount),
+            static_cast<unsigned long long>(characterSoid),
+            static_cast<unsigned long long>(subclassInstanceSoid),
+            itemInstanceDefinitionId);
+        if (count > 0) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             {line.data(), static_cast<std::size_t>(count)});
+        }
     }
     return staged;
 }
@@ -449,33 +455,142 @@ bool stage_item_acquisition(const SessionState& before,
     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)});
+    if (!staged) {
+        std::array<char, core::log::kLineCapacity> line{};
+        const int count = std::snprintf(
+            line.data(),
+            line.size(),
+            "ev=acquire stage=queuez_version result=fail 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",
+            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,
+                             core::log::Level::warn,
+                             {line.data(), static_cast<std::size_t>(count)});
+        }
     }
     return staged;
 }
 
+/** Checks that a fixed bundle can append all residents in one Family-4 increment. */
+bool stage_direct_item_bundle(const SessionState& before,
+                              std::uint64_t accountSoid,
+                              std::uint64_t characterSoid,
+                              std::uint64_t firstInstanceSoid,
+                              std::size_t itemCount,
+                              std::int32_t& family4Version) noexcept {
+    family4Version = 0;
+    std::uint32_t accountDefinitionId = 0;
+    std::uint32_t characterDefinitionId = 0;
+    std::uint32_t itemDefinitionId = 0;
+    if (!valid(before) || !before.family4Active || accountSoid == 0 || characterSoid == 0
+        || firstInstanceSoid == 0 || itemCount == 0 || accountSoid != before.family4RootSoid
+        || before.family4ResidentCount == 0
+        || itemCount > before.family4Residents.size() - before.family4ResidentCount
+        || itemCount - 1U > (std::numeric_limits<std::uint64_t>::max)() - firstInstanceSoid
+        || 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, itemDefinitionId)) {
+        return false;
+    }
+
+    std::size_t accountMatches = 0;
+    std::size_t characterMatches = 0;
+    for (std::size_t residentIndex = 0; residentIndex < before.family4ResidentCount;
+         ++residentIndex) {
+        const ResidentObject& resident = before.family4Residents[residentIndex];
+        accountMatches += static_cast<std::size_t>(resident.objectSoid == accountSoid
+                                                   && resident.definitionId == accountDefinitionId);
+        characterMatches += static_cast<std::size_t>(
+            resident.objectSoid == characterSoid && resident.definitionId == characterDefinitionId);
+        for (std::size_t itemIndex = 0; itemIndex < itemCount; ++itemIndex) {
+            if (resident.objectSoid == firstInstanceSoid + itemIndex) {
+                return false;
+            }
+        }
+    }
+    if (accountMatches != 1 || characterMatches != 1) {
+        return false;
+    }
+    family4Version = before.family4Version + 1;
+    return true;
+}
+
+/** Stages one atomic record-reward manifest update. */
+bool stage_record_reward_grant(const SessionState& before,
+                               std::uint64_t accountSoid,
+                               std::uint64_t characterSoid,
+                               std::span<const std::uint64_t> appendedResidents,
+                               RecordRewardGrant& grant) noexcept {
+    grant = {};
+    std::uint32_t accountDefinitionId = 0;
+    std::uint32_t characterDefinitionId = 0;
+    std::uint32_t itemDefinitionId = 0;
+    if (!valid(before) || !before.family4Active || accountSoid == 0 || characterSoid == 0
+        || accountSoid != before.family4RootSoid || before.family4ResidentCount == 0
+        || appendedResidents.size() > before.family4Residents.size() - before.family4ResidentCount
+        || 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, itemDefinitionId)) {
+        return false;
+    }
+
+    std::size_t accountMatches = 0;
+    std::size_t characterMatches = 0;
+    for (std::size_t index = 0; index < before.family4ResidentCount; ++index) {
+        const ResidentObject& resident = before.family4Residents[index];
+        accountMatches += static_cast<std::size_t>(resident.objectSoid == accountSoid
+                                                   && resident.definitionId == accountDefinitionId);
+        characterMatches += static_cast<std::size_t>(
+            resident.objectSoid == characterSoid && resident.definitionId == characterDefinitionId);
+        if (std::find(appendedResidents.begin(), appendedResidents.end(), resident.objectSoid)
+            != appendedResidents.end()) {
+            return false;
+        }
+    }
+    if (accountMatches != 1 || characterMatches != 1) {
+        return false;
+    }
+    for (std::size_t index = 0; index < appendedResidents.size(); ++index) {
+        const auto prior = appendedResidents.first(index);
+        if (appendedResidents[index] == 0
+            || std::find(prior.begin(), prior.end(), appendedResidents[index]) != prior.end()) {
+            return false;
+        }
+    }
+
+    grant.after = before;
+    ++grant.after.family4Version;
+    for (const std::uint64_t soid : appendedResidents) {
+        grant.after.family4Residents[grant.after.family4ResidentCount++] = {soid, itemDefinitionId};
+    }
+    grant.accountDefinitionId = accountDefinitionId;
+    grant.characterDefinitionId = characterDefinitionId;
+    grant.itemInstanceDefinitionId = itemDefinitionId;
+    grant.accountSoid = accountSoid;
+    grant.characterSoid = characterSoid;
+    grant.appendedResidentCount = appendedResidents.size();
+    return valid(grant.after);
+}
+
 /** 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,
@@ -541,30 +656,31 @@ bool stage_profile_item_acquisition(const SessionState& before,
     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)});
+    if (!staged) {
+        std::array<char, core::log::kLineCapacity> line{};
+        const int count = std::snprintf(
+            line.data(),
+            line.size(),
+            "ev=profile_acquire stage=queuez_version result=fail 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",
+            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,
+                             core::log::Level::warn,
+                             {line.data(), static_cast<std::size_t>(count)});
+        }
     }
     return staged;
 }
@@ -635,32 +751,32 @@ bool stage_item_dismantle(const SessionState& before,
     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)});
+    if (!staged) {
+        std::array<char, core::log::kLineCapacity> line{};
+        const int count = std::snprintf(
+            line.data(),
+            line.size(),
+            "ev=dismantle stage=queuez_version result=fail 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",
+            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,
+                             core::log::Level::warn,
+                             {line.data(), static_cast<std::size_t>(count)});
+        }
     }
     return staged;
 }

+ 3 - 0
Sunrise/src/server/bap/encrypted/queuez/staging/queuez_family_staging.cpp

@@ -108,6 +108,9 @@ bool stage_family0_subscription(const SessionState& before,
     if (before.family0Character == selectedCharacter) {
         return true;
     }
+    if (before.family0Version == (std::numeric_limits<std::int32_t>::max)()) {
+        return false;
+    }
     publish = true;
     incremental = true;
     after.family0Character = selectedCharacter;

+ 56 - 117
Sunrise/src/server/bap/encrypted/routing/bap_service_routing.cpp

@@ -3,107 +3,68 @@
 namespace sunrise::server::bap::encrypted::routing {
 
 /**
- * Maps an authenticated request service to its response codec and metadata.
+ * Maps an authenticated request service to its response codec.
  * @param request Numeric request service from the decrypted inner header.
- * @param route Gets the response and structured-log metadata.
+ * @param route Gets the response contract.
  * @return True when the encrypted service is implemented.
  */
 bool resolve(std::uint16_t request, ServiceRoute& route) noexcept {
-    switch (static_cast<middleware::bap::RequestService>(request)) {
-    case middleware::bap::RequestService::activityHostManager:
+    using Request = middleware::bap::RequestService;
+    using Response = middleware::bap::ResponseService;
+    switch (static_cast<Request>(request)) {
+    case Request::activityHostManager:
         route = {ResponseMode::reply,
-                 middleware::bap::ResponseService::activityHostManager,
-                 BodyCodec::activityHostManagerResponse,
-                 "ev=bap svc=6 rsp=7 result=ok"};
+                 Response::activityHostManager,
+                 BodyCodec::activityHostManagerResponse};
         return true;
-    case middleware::bap::RequestService::activityMessage:
-        route = {ResponseMode::uncorrelatedPush,
-                 {},
-                 BodyCodec::activityMessageRequest,
-                 "ev=bap svc=8 rsp=none result=ok"};
+    case Request::activityMessage:
+        route = {ResponseMode::uncorrelatedPush, {}, BodyCodec::activityMessageRequest};
         return true;
-    case middleware::bap::RequestService::webService:
-        route = {ResponseMode::reply,
-                 middleware::bap::ResponseService::webService,
-                 BodyCodec::webService,
-                 "ev=bap svc=10 rsp=11 result=ok"};
+    case Request::webService:
+        route = {ResponseMode::reply, Response::webService, BodyCodec::webService};
         return true;
-    case middleware::bap::RequestService::webServiceServer:
-        route = {ResponseMode::reply,
-                 middleware::bap::ResponseService::webServiceServer,
-                 BodyCodec::webService,
-                 "ev=bap svc=110 rsp=112 result=ok"};
+    case Request::webServiceServer:
+        route = {ResponseMode::reply, Response::webServiceServer, BodyCodec::webService};
         return true;
-    case middleware::bap::RequestService::notification29:
-        route = {ResponseMode::none, {}, BodyCodec::empty, "ev=bap svc=29 rsp=none result=ok"};
+    case Request::notification29:
+        route = {ResponseMode::none, {}, BodyCodec::empty};
         return true;
-    case middleware::bap::RequestService::subscribeFamily:
-        route = {ResponseMode::reply,
-                 middleware::bap::ResponseService::subscribeFamily,
-                 BodyCodec::familySubscription,
-                 "ev=bap svc=12 rsp=13 result=ok"};
+    case Request::subscribeFamily:
+        route = {ResponseMode::reply, Response::subscribeFamily, BodyCodec::familySubscription};
         return true;
-    case middleware::bap::RequestService::unsubscribeFamily:
-        route = {ResponseMode::reply,
-                 middleware::bap::ResponseService::unsubscribeFamily,
-                 BodyCodec::familyUnsubscription,
-                 "ev=bap svc=14 rsp=15 result=ok"};
+    case Request::unsubscribeFamily:
+        route = {ResponseMode::reply, Response::unsubscribeFamily, BodyCodec::familyUnsubscription};
         return true;
-    case middleware::bap::RequestService::activityHost:
-        route = {ResponseMode::reply,
-                 middleware::bap::ResponseService::activityHost,
-                 BodyCodec::activityHostResponse,
-                 "ev=bap svc=16 rsp=17 result=ok"};
+    case Request::activityHost:
+        route = {ResponseMode::reply, Response::activityHost, BodyCodec::activityHostResponse};
         return true;
-    case middleware::bap::RequestService::clientConfig:
-        route = {ResponseMode::reply,
-                 middleware::bap::ResponseService::clientConfig,
-                 BodyCodec::clientConfigResponse,
-                 "ev=bap svc=18 rsp=19 result=ok"};
+    case Request::clientConfig:
+        route = {ResponseMode::reply, Response::clientConfig, BodyCodec::clientConfigResponse};
         return true;
-    case middleware::bap::RequestService::purchasedOffers:
-        route = {ResponseMode::reply,
-                 middleware::bap::ResponseService::purchasedOffers,
-                 BodyCodec::empty,
-                 "ev=bap svc=21 rsp=22 result=ok"};
+    case Request::purchasedOffers:
+        route = {ResponseMode::reply, Response::purchasedOffers, BodyCodec::empty};
         return true;
-    case middleware::bap::RequestService::accountTranslation:
+    case Request::accountTranslation:
         route = {ResponseMode::reply,
-                 middleware::bap::ResponseService::accountTranslation,
-                 BodyCodec::accountTranslationResponse,
-                 "ev=bap svc=23 rsp=24 result=ok"};
+                 Response::accountTranslation,
+                 BodyCodec::accountTranslationResponse};
         return true;
     // These five need a reply: each holds the head of the Client's pending queue until one comes.
     // Every field of their response bodies is optional, so an empty body is valid.
-    case middleware::bap::RequestService::skill:
-        route = {ResponseMode::reply,
-                 middleware::bap::ResponseService::skill,
-                 BodyCodec::empty,
-                 "ev=bap svc=34 rsp=35 result=ok"};
+    case Request::skill:
+        route = {ResponseMode::reply, Response::skill, BodyCodec::empty};
         return true;
-    case middleware::bap::RequestService::request36:
-        route = {ResponseMode::reply,
-                 middleware::bap::ResponseService::response37,
-                 BodyCodec::empty,
-                 "ev=bap svc=36 rsp=37 result=ok"};
+    case Request::request36:
+        route = {ResponseMode::reply, Response::response37, BodyCodec::empty};
         return true;
-    case middleware::bap::RequestService::request38:
-        route = {ResponseMode::reply,
-                 middleware::bap::ResponseService::response39,
-                 BodyCodec::empty,
-                 "ev=bap svc=38 rsp=39 result=ok"};
+    case Request::request38:
+        route = {ResponseMode::reply, Response::response39, BodyCodec::empty};
         return true;
-    case middleware::bap::RequestService::request40:
-        route = {ResponseMode::reply,
-                 middleware::bap::ResponseService::response41,
-                 BodyCodec::empty,
-                 "ev=bap svc=40 rsp=41 result=ok"};
+    case Request::request40:
+        route = {ResponseMode::reply, Response::response41, BodyCodec::empty};
         return true;
-    case middleware::bap::RequestService::request48:
-        route = {ResponseMode::reply,
-                 middleware::bap::ResponseService::response49,
-                 BodyCodec::empty,
-                 "ev=bap svc=48 rsp=49 result=ok"};
+    case Request::request48:
+        route = {ResponseMode::reply, Response::response49, BodyCodec::empty};
         return true;
     case middleware::bap::RequestService::request50:
         route = {ResponseMode::reply,
@@ -117,56 +78,34 @@ bool resolve(std::uint16_t request, ServiceRoute& route) noexcept {
                  BodyCodec::matchmakingResponse,
                  "ev=bap svc=42 rsp=43 result=ok"};
         return true;
-    case middleware::bap::RequestService::clan:
-        route = {ResponseMode::reply,
-                 middleware::bap::ResponseService::clan,
-                 BodyCodec::empty,
-                 "ev=bap svc=44 rsp=45 result=ok"};
+    case Request::clan:
+        route = {ResponseMode::reply, Response::clan, BodyCodec::empty};
         return true;
-    case middleware::bap::RequestService::registerSubscriber:
-        route = {ResponseMode::reply,
-                 middleware::bap::ResponseService::registerSubscriber,
-                 BodyCodec::empty,
-                 "ev=bap svc=121 rsp=122 result=ok"};
+    case Request::registerSubscriber:
+        route = {ResponseMode::reply, Response::registerSubscriber, BodyCodec::empty};
         return true;
-    case middleware::bap::RequestService::notification171:
-        route = {ResponseMode::none, {}, BodyCodec::empty, "ev=bap svc=171 rsp=none result=ok"};
+    case Request::notification171:
+        route = {ResponseMode::none, {}, BodyCodec::empty};
         return true;
-    case middleware::bap::RequestService::echo:
-        route = {ResponseMode::reply,
-                 middleware::bap::ResponseService::echo,
-                 BodyCodec::empty,
-                 "ev=bap svc=250 rsp=251 result=ok"};
+    case Request::echo:
+        route = {ResponseMode::reply, Response::echo, BodyCodec::empty};
         return true;
-    case middleware::bap::RequestService::registerRelayClient:
-        route = {ResponseMode::reply,
-                 middleware::bap::ResponseService::registerRelayClient,
-                 BodyCodec::empty,
-                 "ev=bap svc=302 rsp=303 result=ok"};
+    case Request::registerRelayClient:
+        route = {ResponseMode::reply, Response::registerRelayClient, BodyCodec::empty};
         return true;
-    case middleware::bap::RequestService::signSteamCertificate:
-        route = {ResponseMode::reply,
-                 middleware::bap::ResponseService::signSteamCertificate,
-                 BodyCodec::steamCertificate,
-                 "ev=bap svc=304 rsp=305 result=ok"};
+    case Request::signSteamCertificate:
+        route = {ResponseMode::reply, Response::signSteamCertificate, BodyCodec::steamCertificate};
         return true;
-    case middleware::bap::RequestService::accountFromMembership:
-        route = {ResponseMode::reply,
-                 middleware::bap::ResponseService::accountFromMembership,
-                 BodyCodec::empty,
-                 "ev=bap svc=306 rsp=307 result=ok"};
+    case Request::accountFromMembership:
+        route = {ResponseMode::reply, Response::accountFromMembership, BodyCodec::empty};
         return true;
-    case middleware::bap::RequestService::userMessage:
-        route = {ResponseMode::reply,
-                 middleware::bap::ResponseService::userMessage,
-                 BodyCodec::userMessageResponse,
-                 "ev=bap svc=32 rsp=33 result=ok"};
+    case Request::userMessage:
+        route = {ResponseMode::reply, Response::userMessage, BodyCodec::userMessageResponse};
         return true;
     default:
         // Unknown services stay quiet. Failing the send would drop the whole BAP link.
         // Quiet is only safe when the service has no response id. A request needs a case above.
-        route = {
-            ResponseMode::none, {}, BodyCodec::empty, "ev=bap svc=unhandled rsp=none result=ok"};
+        route = {ResponseMode::none, {}, BodyCodec::empty};
         return true;
     }
 }

+ 57 - 23
Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp

@@ -1,4 +1,4 @@
-#include "service_outcome_commit.h"
+#include "service_outcome_commit.h"
 
 #include <array>
 #include <cstdio>
@@ -89,6 +89,9 @@ static void discard_activity_publication(Publication& publication) noexcept {
  * @param committed Whether the commit succeeded.
  */
 static void report_lease(const slots::PendingMutation& mutation, bool committed) noexcept {
+    if (committed) {
+        return;
+    }
     std::size_t held = 0;
     std::size_t reserved = 0;
     const bool known = slots::lease_counts(mutation.sessionId, held, reserved);
@@ -97,9 +100,8 @@ static void report_lease(const slots::PendingMutation& mutation, bool committed)
     const int written =
         std::snprintf(line.data(),
                       line.size(),
-                      "ev=activity stage=entity_slots result=%s kind=%s soid=0x%llX "
+                      "ev=activity stage=entity_slots result=fail kind=%s soid=0x%llX "
                       "requested=%zu picked=%zu held=%zu reserved=%zu returned=%zu known=%u",
-                      committed ? "ok" : "fail",
                       kind < kLeaseKinds.size() ? kLeaseKinds[kind] : "bad",
                       static_cast<unsigned long long>(mutation.sessionId),
                       mutation.requestedCount,
@@ -113,11 +115,18 @@ static void report_lease(const slots::PendingMutation& mutation, bool committed)
                       known ? 1U : 0U);
     if (written > 0) {
         core::log::write(core::log::Channel::server,
-                         committed ? core::log::Level::debug : core::log::Level::warn,
+                         core::log::Level::warn,
                          {line.data(), static_cast<std::size_t>(written)});
     }
 }
 
+[[nodiscard]] static bool report_commit(bool committed, const char* failure) noexcept {
+    if (!committed) {
+        core::log::write(core::log::Channel::server, core::log::Level::warn, failure);
+    }
+    return committed;
+}
+
 /**
  * Commits at most one delayed State transaction.
  * @param outcome Checked service result whose pending transaction is used up.
@@ -218,10 +227,14 @@ bool commit(ServiceOutcome& outcome, Publication& publication, const char*& reas
         return committed;
     }
     if (auto* transaction = transaction_if<EquipmentSwapTransaction>(outcome)) {
+        if (transaction->pending == nullptr) {
+            return false;
+        }
+        auto& pending = *transaction->pending;
         const bool isSubclassSlot =
-            transaction->pending.equipmentSlotIndex
+            pending.equipmentSlotIndex
             == static_cast<std::size_t>(state::account::inventory::EquipmentSlot::subclass);
-        const bool committed = state::commit_equipment_swap(transaction->pending);
+        const bool committed = state::commit_equipment_swap(*transaction->pending);
         core::log::write(core::log::Channel::server,
                          committed ? core::log::Level::debug : core::log::Level::warn,
                          committed ? "ev=equip stage=transaction_commit result=ok"
@@ -233,25 +246,24 @@ bool commit(ServiceOutcome& outcome, Publication& publication, const char*& reas
             // character screen stops showing the previous subclass's resolution.
             client::content::investment::worker::request_slice();
         }
-        return committed;
+        return true;
     }
     if (auto* transaction = transaction_if<SubclassSelectionTransaction>(outcome)) {
-        const bool committed = state::commit_subclass_selection(transaction->pending);
-        core::log::write(core::log::Channel::server,
-                         committed ? core::log::Level::debug : core::log::Level::warn,
-                         committed ? "ev=subclass_select stage=transaction_commit result=ok"
-                                   : "ev=subclass_select stage=transaction_commit result=fail");
+        if (transaction->pending == nullptr) {
+            return false;
+        }
         reason = "subclass_select";
-        if (committed) {
-            // The published ability buckets are keyed off the selection that just changed. Wake
-            // the investment worker so its next pump rebuilds them, instead of waiting on
-            // whatever cadence would otherwise trigger a fresh slice.
-            client::content::investment::worker::request_slice();
+        if (!report_commit(state::commit_subclass_selection(*transaction->pending),
+                           "ev=subclass_select stage=transaction_commit result=fail")) {
+            return false;
         }
-        return committed;
+        // Rebuild the ability buckets keyed by the changed selection.
+        client::content::investment::worker::request_slice();
+        return true;
     }
     if (auto* transaction = transaction_if<ItemAcquisitionTransaction>(outcome)) {
-        const bool committed = state::commit_item_acquisition(transaction->pending);
+        const bool committed = transaction->pending != nullptr
+                               && state::commit_item_acquisition(*transaction->pending);
         core::log::write(core::log::Channel::server,
                          committed ? core::log::Level::debug : core::log::Level::warn,
                          committed ? "ev=acquire stage=transaction_commit result=ok"
@@ -265,7 +277,8 @@ bool commit(ServiceOutcome& outcome, Publication& publication, const char*& reas
         return committed;
     }
     if (auto* transaction = transaction_if<SocketPlugTransaction>(outcome)) {
-        const bool committed = state::commit_socket_plug(transaction->pending);
+        const bool committed = transaction->pending != nullptr
+                               && state::commit_socket_plug(*transaction->pending);
         core::log::write(core::log::Channel::server,
                          committed ? core::log::Level::debug : core::log::Level::warn,
                          committed ? "ev=socket_plug stage=transaction_commit result=ok"
@@ -274,7 +287,8 @@ bool commit(ServiceOutcome& outcome, Publication& publication, const char*& reas
         return committed;
     }
     if (auto* transaction = transaction_if<ItemStateTransaction>(outcome)) {
-        const bool committed = state::commit_item_state(transaction->pending);
+        const bool committed = transaction->pending != nullptr
+                               && state::commit_item_state(*transaction->pending);
         core::log::write(core::log::Channel::server,
                          committed ? core::log::Level::debug : core::log::Level::warn,
                          committed ? "ev=item_state stage=transaction_commit result=ok"
@@ -292,7 +306,8 @@ bool commit(ServiceOutcome& outcome, Publication& publication, const char*& reas
         return committed;
     }
     if (auto* transaction = transaction_if<ProfileItemAcquisitionTransaction>(outcome)) {
-        const bool committed = state::commit_profile_item_acquisition(transaction->pending);
+        const bool committed = transaction->pending != nullptr
+                               && state::commit_profile_item_acquisition(*transaction->pending);
         core::log::write(core::log::Channel::server,
                          committed ? core::log::Level::debug : core::log::Level::warn,
                          committed ? "ev=profile_acquire stage=transaction_commit result=ok"
@@ -304,7 +319,8 @@ bool commit(ServiceOutcome& outcome, Publication& publication, const char*& reas
         return committed;
     }
     if (auto* transaction = transaction_if<ItemDismantleTransaction>(outcome)) {
-        const bool committed = state::commit_item_dismantle(transaction->pending);
+        const bool committed = transaction->pending != nullptr
+                               && state::commit_item_dismantle(*transaction->pending);
         core::log::write(core::log::Channel::server,
                          committed ? core::log::Level::debug : core::log::Level::warn,
                          committed ? "ev=dismantle stage=transaction_commit result=ok"
@@ -312,6 +328,24 @@ bool commit(ServiceOutcome& outcome, Publication& publication, const char*& reas
         reason = "dismantle";
         return committed;
     }
+    if (auto* transaction = transaction_if<ArtifactPurchaseTransaction>(outcome)) {
+        reason = "artifact_purchase";
+        return transaction->pending != nullptr
+               && report_commit(state::commit_artifact_mod_unlock(*transaction->pending),
+                                "ev=ws901 stage=transaction_commit result=fail");
+    }
+    if (auto* transaction = transaction_if<SeasonPassRewardTransaction>(outcome)) {
+        reason = "season_pass_reward";
+        return transaction->pending != nullptr
+               && report_commit(state::commit_season_pass_reward(*transaction->pending),
+                                "ev=ws2400 stage=transaction_commit result=fail");
+    }
+    if (auto* transaction = transaction_if<RecordRewardGrantTransaction>(outcome)) {
+        reason = "record_reward";
+        return transaction->pending != nullptr
+               && report_commit(state::commit_record_reward(*transaction->pending),
+                                "ev=record_reward stage=transaction_commit result=fail");
+    }
     return true;
 }
 

+ 62 - 1
Sunrise/src/server/bap/internal.h

@@ -15,7 +15,7 @@
 #include "../../state/activity/definition.h"
 #include "../../state/activity_sdk/runtime.h"
 #include "../../state/build_data/scenarios/definition.h"
-#include "../../state/runtime/state.h"
+#include "../../state/runtime/runtime.h"
 #include "../activity/host_runtime.h"
 #include "activity_authority_query_owner.h"
 #include "activity_authority_reset_owner.h"
@@ -284,10 +284,43 @@ struct ReplicationEpochPublication {
     bool staged{};
 };
 
+/** Compact world reward retained until an active Family-4 peer can publish it. */
+enum class WorldRewardKind : std::uint8_t {
+    item,
+    profileItem,
+};
+
+struct WorldRewardRequest {
+    std::int32_t quantity{};
+    std::uint16_t itemDefinitionIndex{};
+    WorldRewardKind kind{};
+    std::uint8_t failures{};
+};
+static_assert(sizeof(WorldRewardRequest) == 8);
+
+inline constexpr std::size_t kWorldRewardQueueCapacity = 64;
+
 /** Mutable transport state owned by one BAP connection. */
 struct Session {
+    // PR-specific publication state retained around upstream's newer activity session model.
+    std::uint64_t activityAdvertisementHostGeneration{};
+    std::uint64_t acquisitionPresentationUntilTick{};
+    std::array<encrypted::queuez::AcquisitionPresentationRow,
+               encrypted::queuez::kAcquisitionPresentationRowCapacity>
+        acquisitionPresentationRows{};
     std::uint32_t id{};
+    std::uint32_t activityRosterGroups{};
+    std::int32_t pendingSeasonalExperienceAmount{};
+    std::uint32_t pendingSeasonalExperienceMutationSerial{};
+    std::uint8_t pendingSeasonalExperienceFailures{};
     bool authenticated{};
+    /** Publishes changed artifact overrides after its Web Service reply has left this call. */
+    bool artifactRefreshArmed{};
+    bool artifactFamily4RefreshArmed{};
+    std::uint64_t artifactFamily4RefreshDueTick{};
+    state::ArtifactResetResult artifactResetRefresh{};
+    std::size_t artifactResetRefreshCursor{};
+    std::uint8_t acquisitionPresentationRowCount{};
     std::array<std::byte, state::kBapNonceSize> sendNonce{};
     std::array<std::byte, state::kBapNonceSize> receiveNonce{};
     /** This connection's own AES-GCM key. Sharing one across links reuses key and nonce pairs. */
@@ -429,6 +462,34 @@ struct Session {
     bool cinematicHeld{};
 };
 
+/** Arms every other Family-4 peer after the origin publishes a complete account mutation. */
+void arm_account_resync_elsewhere(Session& origin) noexcept;
+
+/** Arms every Family-4 peer, including the origin, for a full account resync. */
+void arm_account_resync_everywhere() noexcept;
+
+/** Holds this peer's full Family-4 refreshes until its acquisition flyout has finished. */
+void arm_acquisition_presentation_hold(Session& session) noexcept;
+
+/** Queues one character item for normal acquisition feedback. */
+[[nodiscard]] bool arm_world_item_acquisition(std::uint16_t itemDefinitionIndex) noexcept;
+
+/** Queues one profile material for normal acquisition feedback. */
+[[nodiscard]] bool arm_world_profile_item_acquisition(std::uint16_t itemDefinitionIndex,
+                                                      std::int32_t quantity) noexcept;
+
+/** Reads the oldest queued world reward without removing it. */
+[[nodiscard]] bool current_world_reward(WorldRewardRequest& request) noexcept;
+
+/** Removes the world reward returned by current_world_reward. */
+void complete_world_reward() noexcept;
+
+/** Records one failed publication and settles a repeatedly failing reward. */
+void fail_world_reward_attempt() noexcept;
+
+/** Queues one transient XP item update so the native HUD presents a seasonal XP gain. */
+[[nodiscard]] bool arm_seasonal_experience_presentation(std::int32_t amount) noexcept;
+
 /**
  * Finds one unambiguous registry identity in a committed connection-local roster map.
  * @param map Last complete msg-5 map delivered on one BAP connection.

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

@@ -141,7 +141,6 @@ void arm_encryption(Session& session, const state::BapState& bap) noexcept {
     encrypted::ServiceRoute route;
     (void)encrypted::routing::resolve(request.serviceId, route);
     if (route.responseMode != encrypted::ResponseMode::reply) {
-        core::log::write(core::log::Channel::server, core::log::Level::info, route.successEvent);
         return true;
     }
     encrypted::ServiceOutcome outcome{};
@@ -167,12 +166,11 @@ void arm_encryption(Session& session, const state::BapState& bap) noexcept {
                                          response,
                                          written);
     SecureZeroMemory(scratch.responseBody.data(), bodySize);
-    SecureZeroMemory(&outcome, sizeof outcome);
+    outcome = {};
     if (!encoded) {
         report_refusal(session, request.serviceId, "encode");
         return false;
     }
-    core::log::write(core::log::Channel::server, core::log::Level::info, route.successEvent);
     return true;
 }
 
@@ -207,11 +205,6 @@ bool consume(Session& session,
                                              frame.body,
                                              response,
                                              written);
-        if (encoded) {
-            core::log::write(core::log::Channel::server,
-                             core::log::Level::info,
-                             "ev=bap svc=30 rsp=31 result=ok");
-        }
         return encoded;
     }
     if (frame.serviceId
@@ -271,8 +264,6 @@ bool consume(Session& session,
     // was not free publishes as invalid instead of costing the reply.
     session.matchmakingContext = matchmakingContext;
     arm_encryption(session, bapState);
-    core::log::write(
-        core::log::Channel::server, core::log::Level::info, "ev=bap svc=25 rsp=26 result=ok");
     return true;
 }
 

File diff suppressed because it is too large
+ 404 - 433
Sunrise/src/server/web_service/web_service_actions.cpp


+ 10 - 0
Sunrise/src/server/web_service/web_service_actions.h

@@ -25,4 +25,14 @@ void acquire_item(const middleware::web_service::Message& message, Outcome& outc
 void purchase_item(const middleware::web_service::Message& message, Outcome& outcome) noexcept;
 void acquire_quest(const middleware::web_service::Message& message, Outcome& outcome) noexcept;
 
+/** Persists an opcode-1801 Triumph claim and prepares its optional reward. */
+void claim_record(const middleware::web_service::Message& message, Outcome& outcome) noexcept;
+
+/** Decodes and prepares one opcode-2400 active-season reward claim. */
+void claim_season_pass_reward(const middleware::web_service::Message& message,
+                              Outcome& outcome) noexcept;
+
+/** Decodes and applies one opcode-1821 earned-title selection. */
+void equip_title(const middleware::web_service::Message& message, Outcome& outcome) noexcept;
+
 } // namespace sunrise::server::web_service

+ 269 - 149
Sunrise/src/server/web_service/web_service_runtime.cpp

@@ -3,21 +3,17 @@
 #include <algorithm>
 #include <array>
 #include <atomic>
+#include <chrono>
 #include <cstdio>
-#include <cstring>
-#include <string_view>
 
 #include "../../core/logging/log.h"
-#include "../../core/runtime/server_clock.h"
 #include "../../middleware/encoding/bit_reader.h"
-#include "../../middleware/encoding/byte_order.h"
-#include "../../middleware/web_service/messages/opcode1820.h"
+#include "../../middleware/web_service/messages/opcode1801.h"
+#include "../../middleware/web_service/messages/opcode1821.h"
 #include "../../middleware/web_service/messages/opcode1901.h"
 #include "../../middleware/web_service/messages/opcode205.h"
 #include "../../middleware/web_service/messages/opcode206.h"
-#include "../../middleware/web_service/messages/opcode402.h"
-#include "../../middleware/web_service/messages/opcode403.h"
-#include "../../middleware/web_service/messages/opcode406.h"
+#include "../../middleware/web_service/messages/opcode2400.h"
 #include "../../middleware/web_service/messages/opcode501_codec.h"
 #include "../../middleware/web_service/messages/opcode503.h"
 #include "../../middleware/web_service/messages/opcode504.h"
@@ -31,59 +27,210 @@
 #include "../../middleware/web_service/web_service_envelope.h"
 #include "../../state/account/account_state.h"
 #include "../../state/activity/membership/activity_membership_query.h"
-#include "../../state/build_data/runtime.h"
+#include "../../state/progression/seasonal_experience.h"
 #include "../../state/runtime/runtime.h"
-#include "internal.h"
 #include "opcode_routes.h"
 #include "web_service_actions.h"
 
 namespace sunrise::server::web_service {
-namespace {
 
-namespace messages = middleware::web_service::messages;
+/** Web Service opcode used by the Character screen's Equip action. */
+constexpr std::uint16_t kEquipOpcode = 403;
+/** Web Service opcode used by the Character screen's Unequip action. */
+constexpr std::uint16_t kUnequipOpcode = 404;
+/** Web Service opcode used by item-state actions such as finisher Favorite. */
+constexpr std::uint16_t kItemStateOpcode = 406;
+/** Web Service opcode used by the Character screen's Dismantle action. */
+constexpr std::uint16_t kItemDismantleOpcode = 402;
+/** Web Service opcode used by Collections to create one item instance. */
+constexpr std::uint16_t kItemAcquisitionOpcode = 1820;
+/**
+ * Logical status of a refused action. The descriptor biases logical zero to the wire success the
+ * Client expects, so any other logical value reports a refusal. Its five bits hold no error
+ * taxonomy, so one code covers every reason and the log line names the actual one.
+ */
+constexpr std::int32_t kRefusedStatus = 1;
 
-/** One ordinary event line carries an opcode and its fixed prefix. */
-constexpr std::size_t kOpcodeLineCapacity = 64;
-/** A request trace keeps enough payload to identify an item-action descriptor. */
-constexpr std::size_t kRequestPayloadTraceBytes = 192;
-/** Marks a trace that stopped at the cap, so a short hex string is not read as a short payload. */
-constexpr std::string_view kTruncated = " truncated=1";
-/** The mutation variant's first alternative is the empty one, so index zero prepared nothing. */
-constexpr std::size_t kNoMutation = 0;
+constexpr auto kResidentDependentOpcodes =
+    std::to_array<std::uint16_t>({402, 403, 404, 406, 504, 903, 1801, 1820, 1901, 2400});
 
+/** One refusal line carries both request indices, the clock presence, and the clock verdict. */
+constexpr std::size_t kPurchaseLineCapacity = 128;
+constexpr std::size_t kEchoLineCapacity = 64;
 /**
- * Logs the Web Service opcode and a bounded payload trace.
- * One svc-10 frame looks like any other, and the opcode drives the client's queuez state machine.
- * @param message Parsed request envelope and borrowed payload.
+ * Status code answered to a purchase request.
+ * Any non-zero value refuses. Zero is the success code, so it must not be used here.
  */
-void report_request(const middleware::web_service::Message& message) noexcept {
+constexpr std::int32_t kPurchaseRefusedCode = 1;
+/** Season of Arrivals artifact vendor row in the installed build's vendor index. */
+constexpr std::int16_t kArtifactVendorIndex = 430;
+constexpr std::int32_t kArtifactResetGlimmerCost = 20'000;
+
+/**
+ * Reads the server's own clock for the purchase clock rule.
+ * The system clock counts from the Unix epoch, which is the same base the request field uses.
+ * @return Current time in Unix seconds.
+ */
+[[nodiscard]] std::int64_t server_clock_seconds() noexcept {
+    const auto sinceEpoch = std::chrono::system_clock::now().time_since_epoch();
+    return std::chrono::duration_cast<std::chrono::seconds>(sinceEpoch).count();
+}
+
+/** Issues a strictly increasing family-5 clock, including multiple requests in one second. */
+[[nodiscard]] std::uint64_t next_family5_clock() noexcept {
+    static std::atomic<std::uint64_t> issued{0};
+    const auto wall = static_cast<std::uint64_t>(server_clock_seconds());
+    std::uint64_t previous = issued.load(std::memory_order_relaxed);
+    std::uint64_t next = 0;
+    do {
+        next = wall > previous ? wall : previous + 1;
+    } while (!issued.compare_exchange_weak(previous, next, std::memory_order_relaxed));
+    return next;
+}
+
+/** Records the authoritative world state carried by the client's character write-back. */
+void note_character_writeback(const middleware::web_service::Message& message) noexcept {
+    namespace writeback = middleware::web_service::messages::opcode702;
+    writeback::Request request{};
+    const bool parsed = writeback::parse_request(message, request);
     std::array<char, core::log::kLineCapacity> line{};
-    const int prefix =
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=activity stage=writeback result=%s world_state=%u",
+                                      parsed ? "ok" : "unparsed",
+                                      static_cast<unsigned>(request.worldState));
+    if (written > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+    if (parsed) {
+        state::activity::membership::note_client_writeback(request.worldState
+                                                           == writeback::kInWorld);
+    }
+}
+
+/** @return True when a purchase names the seasonal artifact vendor, which is answered here. */
+[[nodiscard]] bool names_artifact_vendor(const middleware::web_service::Message& message) noexcept {
+    namespace purchase_codec = middleware::web_service::messages::opcode901;
+    purchase_codec::Request purchase{};
+    return purchase_codec::parse_request(message, purchase)
+           && purchase.vendorIndex == kArtifactVendorIndex;
+}
+
+/**
+ * Refuses one vendor purchase and answers it.
+ * No award, cost or stock rule exists yet, so no purchase can succeed. The refusal must still be
+ * answered, because no answer holds the head of the client's pending queue.
+ * @param message Parsed purchase request.
+ * @param response Response-body storage owned by the caller.
+ * @param written Receives the encoded response size.
+ * @return True when the refusal was encoded.
+ */
+[[nodiscard]] bool refuse_purchase(const middleware::web_service::Message& message,
+                                   std::span<std::byte> response,
+                                   std::size_t& written) noexcept {
+    namespace purchase_codec = middleware::web_service::messages::opcode901;
+    purchase_codec::Request purchase;
+    const bool parsed = purchase_codec::parse_request(message, purchase);
+    std::array<char, kPurchaseLineCapacity> line{};
+    const int length =
+        parsed ? std::snprintf(line.data(),
+                               line.size(),
+                               "ev=ws901 stage=purchase result=refuse vendor=%d sale=%d present=%u",
+                               static_cast<int>(purchase.vendorIndex),
+                               static_cast<int>(purchase.saleIndex),
+                               purchase.hasClock ? 1U : 0U)
+               : std::snprintf(line.data(),
+                               line.size(),
+                               "ev=ws901 stage=purchase result=refuse reason=parse");
+    if (length > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::error,
+                         {line.data(), static_cast<std::size_t>(length)});
+    }
+    middleware::web_service::StatusResponse status{};
+    status.code = kPurchaseRefusedCode;
+    // The trailing bool drives a local action effect on the client, so it stays clear.
+    status.trailingBool = false;
+    return middleware::web_service::encode_response(
+        message,
+        middleware::web_service::ResponseShape::statusPairWithBool,
+        status,
+        response,
+        written);
+}
+
+/** Accepts one affordable, unlocked-tier artifact mod and reports the local purchase effect. */
+[[nodiscard]] bool purchase_artifact_mod(const middleware::web_service::Message& message,
+                                         std::span<std::byte> response,
+                                         std::size_t& written,
+                                         Outcome& outcome) noexcept {
+    namespace purchase_codec = middleware::web_service::messages::opcode901;
+    purchase_codec::Request purchase{};
+    if (!purchase_codec::parse_request(message, purchase)
+        || purchase.vendorIndex != kArtifactVendorIndex || purchase.saleIndex < 0
+        || purchase.saleIndex
+               >= static_cast<std::int16_t>(
+                   state::progression::seasonal_experience::kArtifactSaleCount)) {
+        return false;
+    }
+    const auto saleIndex = static_cast<std::uint16_t>(purchase.saleIndex);
+    if (saleIndex == 5) {
+        state::ArtifactResetResult reset{};
+        if (!state::reset_artifact(kArtifactResetGlimmerCost, reset)) {
+            return false;
+        }
+        middleware::web_service::StatusResponse status{};
+        status.trailingBool = true;
+        const bool encoded = middleware::web_service::encode_response(
+            message,
+            middleware::web_service::ResponseShape::statusPairWithBool,
+            status,
+            response,
+            written);
+        outcome.hasArtifactReset = encoded;
+        if (encoded) {
+            outcome.artifactReset = reset;
+        }
+        return encoded;
+    }
+    auto* mutation = emplace_mutation<state::PendingArtifactPurchase>(outcome);
+    if (mutation == nullptr || !state::prepare_artifact_mod_unlock(saleIndex, *mutation)) {
+        clear_mutation(outcome);
+        return false;
+    }
+    std::array<char, kPurchaseLineCapacity> line{};
+    const int length =
         std::snprintf(line.data(),
                       line.size(),
-                      "ev=ws stage=request opcode=%u transaction=%u payload_bytes=%zu payload_hex=",
-                      static_cast<unsigned>(message.opcode),
-                      static_cast<unsigned>(message.transactionId),
-                      message.payload.size());
-    if (prefix <= 0 || static_cast<std::size_t>(prefix) >= line.size()) {
-        return;
-    }
-
-    std::size_t length = static_cast<std::size_t>(prefix);
-    const std::size_t traced =
-        (std::min)(message.payload.size(), static_cast<std::size_t>(kRequestPayloadTraceBytes));
-    (void)core::log::append_hex(line, length, message.payload.first(traced));
-    if (traced != message.payload.size() && length + kTruncated.size() < line.size()) {
-        std::memcpy(line.data() + length, kTruncated.data(), kTruncated.size());
-        length += kTruncated.size();
-    }
-    core::log::write(core::log::Channel::server, core::log::Level::info, {line.data(), length});
+                      "ev=ws901 stage=artifact result=ok vendor=%d sale=%d",
+                      static_cast<int>(purchase.vendorIndex),
+                      static_cast<int>(purchase.saleIndex));
+    if (length > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(length)});
+    }
+    middleware::web_service::StatusResponse status{};
+    status.trailingBool = true;
+    const bool encoded = middleware::web_service::encode_response(
+        message,
+        middleware::web_service::ResponseShape::statusPairWithBool,
+        status,
+        response,
+        written);
+    if (!encoded) {
+        clear_mutation(outcome);
+        return false;
+    }
+    return true;
 }
 
 /**
  * Answers a request whose own codec refused with the bare correlated echo.
- * The Client matches on the echoed transaction id. A missing body under-runs its decoder and
- * takes the BAP connection down, so a thin body is always sent.
+ * The Client matches on the echoed transaction id. A missing body is worse than a thin one. It
+ * under-runs the decoder and takes the BAP connection down.
  * @param message Parsed request whose correlation fields are echoed.
  * @param response Svc-11 response-body storage owned by the caller.
  * @param written Gets the encoded response-body size in bytes.
@@ -92,10 +239,14 @@ void report_request(const middleware::web_service::Message& message) noexcept {
 bool encode_echo(const middleware::web_service::Message& message,
                  std::span<std::byte> response,
                  std::size_t& written) noexcept {
-    std::array<char, kOpcodeLineCapacity> line{};
+    std::array<char, kEchoLineCapacity> line{};
     const int count = std::snprintf(
         line.data(), line.size(), "ev=ws stage=body result=echo opcode=%u", message.opcode);
-    report_line(core::log::Level::warn, line, count);
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
     namespace ws = middleware::web_service;
     return ws::encode_response(
         message, ws::ResponseShape::generic, ws::StatusResponse{}, response, written);
@@ -135,7 +286,7 @@ struct ProfileSetupMarker {
 [[nodiscard]] bool parse_profile_setup_marker(const middleware::web_service::Message& message,
                                               ProfileSetupMarker& output) noexcept {
     output = {};
-    if (message.opcode != messages::opcode701::kOpcode) {
+    if (message.opcode != middleware::web_service::messages::opcode701::kOpcode) {
         return false;
     }
 
@@ -217,81 +368,36 @@ struct ProfileSetupMarker {
     return true;
 }
 
-/**
- * Issues the family-5 server clock the Client extrapolates its family-5 time from.
- * The wire field counts whole seconds. A repeated value reads as no change and stalls the
- * Client's family-5 boot task, so the issued count must strictly increase.
- * @return Unix seconds, always greater than the previous call's result.
- */
-[[nodiscard]] std::uint64_t next_family5_clock() noexcept {
-    static std::atomic<std::uint64_t> issued{0};
-    const auto wall = static_cast<std::uint64_t>(core::runtime::server_clock_seconds());
-    std::uint64_t previous = issued.load(std::memory_order_relaxed);
-    std::uint64_t next = 0;
-    do {
-        next = wall > previous ? wall : previous + 1;
-    } while (!issued.compare_exchange_weak(previous, next, std::memory_order_relaxed));
-    return next;
-}
-
-/**
- * Records the world state the character write-back reports.
- * The body is client-owned state; the world-state field is the one value the host acts on.
- * @param message Parsed ws-702 envelope.
- */
-void note_character_writeback(const middleware::web_service::Message& message) noexcept {
-    messages::opcode702::Request request;
-    const bool parsed = messages::opcode702::parse_request(message, request);
-    std::array<char, core::log::kLineCapacity> line{};
-    const int written = std::snprintf(line.data(),
-                                      line.size(),
-                                      "ev=activity stage=writeback result=%s world_state=%u",
-                                      parsed ? "ok" : "unparsed",
-                                      static_cast<unsigned>(request.worldState));
-    if (written > 0) {
-        core::log::write(core::log::Channel::server,
-                         core::log::Level::info,
-                         {line.data(), static_cast<std::size_t>(written)});
-    }
-    if (parsed) {
-        state::activity::membership::note_client_writeback(request.worldState
-                                                           == messages::opcode702::kInWorld);
-    }
-}
-
-/**
- * Applies the report that carries no answer of its own beyond the shared status pair.
- * @param message Parsed request envelope and borrowed payload.
- */
-void note_reports(const middleware::web_service::Message& message) noexcept {
-    if (message.opcode == messages::opcode702::kOpcode) {
-        note_character_writeback(message);
+bool encode_resident_dependent_refusal(std::span<const std::byte> request,
+                                       std::span<std::byte> response,
+                                       std::size_t& written,
+                                       bool& refused) noexcept {
+    written = 0;
+    refused = false;
+    middleware::web_service::Message message;
+    if (!middleware::web_service::parse_request(request, message)
+        || !std::binary_search(
+            kResidentDependentOpcodes.begin(), kResidentDependentOpcodes.end(), message.opcode)) {
+        return true;
     }
-}
-
-} // namespace
-
-/** Re-encodes a prepared reply as a refusal after its Queuez staging failed. */
-bool encode_staging_refusal(const middleware::web_service::Message& message,
-                            std::span<std::byte> response,
-                            std::size_t& written) noexcept {
+    refused = true;
     middleware::web_service::ResponseShape shape{};
     resolve_response_shape(message.opcode, shape);
     middleware::web_service::StatusResponse status{};
-    status.code = middleware::web_service::kRefusedStatusCode;
-    status.value = middleware::web_service::kNoFamily4Publication;
-    return middleware::web_service::encode_response(message, shape, status, response, written);
+    status.code = kRefusedStatus;
+    return middleware::web_service::encode_response(message, shape, status, response, written)
+           || encode_echo(message, response, written);
 }
 
-/** Answers one Web Service request when its caller has no action to publish. */
-bool consume(std::span<const std::byte> request,
-             std::span<std::byte> response,
-             std::size_t& written) noexcept {
-    Outcome outcome;
-    return consume(request, response, written, outcome);
-}
-
-/** Parses one request, prepares any action it names, and encodes the reply that reports it. */
+/**
+ * Parses one request, prepares any action it names, and encodes the reply that reports it.
+ * @param request Whole decrypted svc-10 body.
+ * @param response Svc-11 response-body storage owned by the caller.
+ * @param written Gets the encoded response-body size, or zero when the header does not parse.
+ * @param outcome Gets the prepared action for the caller to publish, and is left empty when
+ * the action was refused or the reply could not be encoded.
+ * @return False only when the envelope header does not parse.
+ */
 bool consume(std::span<const std::byte> request,
              std::span<std::byte> response,
              std::size_t& written,
@@ -304,20 +410,21 @@ bool consume(std::span<const std::byte> request,
             core::log::Channel::server, core::log::Level::warn, "ev=ws stage=parse result=fail");
         return false;
     }
-    report_request(message);
-    note_reports(message);
-
-    if (message.opcode == messages::opcode205::kOpcode) {
+    if (message.opcode == middleware::web_service::messages::opcode702::kOpcode) {
+        note_character_writeback(message);
+    }
+    if (message.opcode == middleware::web_service::messages::opcode205::kOpcode) {
         state::InvestmentState investment{};
         return (state::investment_snapshot(investment)
-                && messages::opcode205::encode_response(
+                && middleware::web_service::messages::opcode205::encode_response(
                     message, investment, next_family5_clock(), response, written))
                || encode_echo(message, response, written);
     }
 
-    if (message.opcode == messages::opcode503::kOpcode) {
-        messages::opcode503::Request bootstrap;
-        const bool parsed = messages::opcode503::parse_request(message, bootstrap);
+    if (message.opcode == middleware::web_service::messages::opcode503::kOpcode) {
+        middleware::web_service::messages::opcode503::Request bootstrap;
+        const bool parsed =
+            middleware::web_service::messages::opcode503::parse_request(message, bootstrap);
         // The request's own key is echoed and adopted. An authored id here costs the ship and the
         // banner.
         if (!bootstrap.hasPrimarySoid) {
@@ -325,7 +432,7 @@ bool consume(std::span<const std::byte> request,
         }
         state::InvestmentState investment{};
         if (!parsed || !state::investment_snapshot(investment)
-            || !messages::opcode503::encode_response(
+            || !middleware::web_service::messages::opcode503::encode_response(
                 message, bootstrap, investment, next_family5_clock(), response, written)) {
             return encode_echo(message, response, written);
         }
@@ -337,26 +444,36 @@ bool consume(std::span<const std::byte> request,
         return true;
     }
 
-    if (message.opcode == messages::opcode501::kOpcode) {
+    if (message.opcode == middleware::web_service::messages::opcode501::kOpcode) {
         // Returns a SOID family three already publishes. The request body is not parsed.
         const std::uint64_t characterSoid =
             state::account::selected_character_soid(state::account_snapshot());
-        return messages::opcode501::encode_response(message, characterSoid, response, written)
+        return middleware::web_service::messages::opcode501::encode_response(
+                   message, characterSoid, response, written)
                || encode_echo(message, response, written);
     }
 
-    // Vendor purchases fall through to the shared response-shape path, which runs the action and
-    // answers its status: an action that prepared no mutation is answered with the refused code.
+    // The artifact vendor is answered here. Every other vendor purchase falls through to the
+    // shared response-shape path, which runs the action and answers its status: an action that
+    // prepared no mutation is answered with the refused code.
+    if (message.opcode == middleware::web_service::messages::opcode901::kOpcode
+        && names_artifact_vendor(message)) {
+        return purchase_artifact_mod(message, response, written, outcome)
+               || refuse_purchase(message, response, written)
+               || encode_echo(message, response, written);
+    }
 
-    if (message.opcode == messages::opcode601::kOpcode) {
-        return messages::opcode601::encode_response(message, response, written)
+    if (message.opcode == middleware::web_service::messages::opcode601::kOpcode) {
+        return middleware::web_service::messages::opcode601::encode_response(
+                   message, response, written)
                || encode_echo(message, response, written);
     }
 
     // A subscribe whose body does not parse is still answered; only the subscription is dropped.
     middleware::queuez::Subscription subscription;
-    const bool subscribes = message.opcode == messages::opcode206::kOpcode
-                            && messages::opcode206::parse_request(message, subscription);
+    const bool subscribes =
+        message.opcode == middleware::web_service::messages::opcode206::kOpcode
+        && middleware::web_service::messages::opcode206::parse_request(message, subscription);
 
     // The action runs before its reply is encoded, because the reply reports whether it worked.
     // Most actions fill the outcome only after preparing a whole transition. WS-701 also accepts
@@ -364,23 +481,27 @@ bool consume(std::span<const std::byte> request,
     bool dispatched = true;
     bool acceptedWithoutMutation = false;
     bool profileSetupRefused = false;
-    if (message.opcode == messages::opcode504::kOpcode) {
+    if (message.opcode == middleware::web_service::messages::opcode1801::kOpcode) {
+        claim_record(message, outcome);
+    } else if (message.opcode == middleware::web_service::messages::opcode504::kOpcode) {
         select_character(message, outcome);
-    } else if (message.opcode == messages::opcode402::kOpcode) {
+    } else if (message.opcode == kItemDismantleOpcode) {
         dismantle_item(message, outcome);
-    } else if (message.opcode == messages::opcode403::kOpcode) {
+    } else if (message.opcode == kEquipOpcode) {
         mutate_equipment(message, false, outcome);
-    } else if (message.opcode == messages::opcode403::kUnequipOpcode) {
+    } else if (message.opcode == kUnequipOpcode) {
         mutate_equipment(message, true, outcome);
-    } else if (message.opcode == messages::opcode801::kOpcode) {
+    } else if (message.opcode == middleware::web_service::messages::opcode801::kOpcode) {
         mutate_subclass_selection(message, outcome);
-    } else if (message.opcode == messages::opcode903::kOpcode) {
+    } else if (message.opcode == middleware::web_service::messages::opcode1821::kOpcode) {
+        equip_title(message, outcome);
+    } else if (message.opcode == middleware::web_service::messages::opcode903::kOpcode) {
         mutate_socket_plug(message, outcome);
-    } else if (message.opcode == messages::opcode1901::kOpcode) {
+    } else if (message.opcode == middleware::web_service::messages::opcode1901::kOpcode) {
         mutate_equipped_socket_plug(message, outcome);
-    } else if (message.opcode == messages::opcode406::kOpcode) {
+    } else if (message.opcode == kItemStateOpcode) {
         mutate_item_state(message, outcome);
-    } else if (message.opcode == messages::opcode701::kOpcode) {
+    } else if (message.opcode == middleware::web_service::messages::opcode701::kOpcode) {
         const state::SettingsUpdateDisposition disposition = mutate_settings(message, outcome);
         acceptedWithoutMutation = disposition == state::SettingsUpdateDisposition::acceptedNoChange;
         // The completion marker is applied here. The shared status path below reports the result.
@@ -401,8 +522,10 @@ bool consume(std::span<const std::byte> request,
                                  "ev=ws701 stage=profile_setup result=complete marker=1");
             }
         }
-    } else if (message.opcode == messages::opcode1820::kOpcode) {
+    } else if (message.opcode == kItemAcquisitionOpcode) {
         acquire_item(message, outcome);
+    } else if (message.opcode == middleware::web_service::messages::opcode2400::kOpcode) {
+        claim_season_pass_reward(message, outcome);
     } else if (message.opcode == middleware::web_service::messages::opcode901::kOpcode) {
         purchase_item(message, outcome);
     } else if (message.opcode == middleware::web_service::messages::opcode904::kOpcode) {
@@ -410,17 +533,14 @@ bool consume(std::span<const std::byte> request,
     } else {
         dispatched = false;
     }
-    const bool prepared = outcome.hasSelectedCharacter || outcome.mutation.index() != kNoMutation;
+    const bool prepared = outcome.hasSelectedCharacter || outcome.hasTitleEquip
+                          || outcome.hasRecordClaim || has_mutation(outcome);
 
     middleware::web_service::ResponseShape shape{};
     resolve_response_shape(message.opcode, shape);
     middleware::web_service::StatusResponse status{};
-    if (awaits_family4_version(message.opcode)) {
-        // Nothing is published from here. A staged mutation re-encodes this with its own revision.
-        status.value = middleware::web_service::kNoFamily4Publication;
-    }
     if ((dispatched && !prepared && !acceptedWithoutMutation) || profileSetupRefused) {
-        status.code = middleware::web_service::kRefusedStatusCode;
+        status.code = kRefusedStatus;
     }
     if (!middleware::web_service::encode_response(message, shape, status, response, written)) {
         // The echo carries no status, so nothing may be published against it.

+ 71 - 22
Sunrise/src/server/web_service/web_service_runtime.h

@@ -2,7 +2,11 @@
 
 #include <cstddef>
 #include <cstdint>
+#include <memory>
+#include <new>
 #include <span>
+#include <type_traits>
+#include <utility>
 #include <variant>
 
 #include "../../middleware/web_service/messages/opcode206.h"
@@ -15,22 +19,30 @@ namespace sunrise::server::web_service {
 struct Outcome {
     bool hasSubscription{};
     middleware::queuez::Subscription subscription{};
+    /** A claim changed the account flag bank, so a fresh account image has to follow. */
+    bool hasRecordClaim{};
+    /** An earned title changed on the selected character; roster and banner must be republished. */
+    bool hasTitleEquip{};
     /** An opcode-504 pick moved the selection and its Family-4 object still has to follow. */
     bool hasSelectedCharacter{};
+    bool selectedCharacterChanged{};
     std::uint64_t selectedCharacterSoid{};
-    /**
-     * A request prepares at most one State mutation; the alternative owns only that payload.
-     * Rejected actions and accepted no-op writebacks both leave this empty.
-     */
+    /** Reset is precommitted because it changes persistence and account currency together. */
+    bool hasArtifactReset{};
+    state::ArtifactResetResult artifactReset{};
+    /** A request prepares at most one State mutation and allocates only that exact payload. */
     using Mutation = std::variant<std::monostate,
-                                  state::PendingEquipmentSwap,
-                                  state::PendingSubclassSelection,
-                                  state::PendingItemAcquisition,
-                                  state::PendingProfileItemAcquisition,
-                                  state::PendingItemDismantle,
-                                  state::PendingSocketPlug,
-                                  state::PendingItemState,
-                                  state::PendingSettingsUpdate>;
+                                  std::unique_ptr<state::PendingEquipmentSwap>,
+                                  std::unique_ptr<state::PendingSubclassSelection>,
+                                  std::unique_ptr<state::PendingItemAcquisition>,
+                                  std::unique_ptr<state::PendingProfileItemAcquisition>,
+                                  std::unique_ptr<state::PendingItemDismantle>,
+                                  std::unique_ptr<state::PendingSocketPlug>,
+                                  std::unique_ptr<state::PendingItemState>,
+                                  std::unique_ptr<state::PendingArtifactPurchase>,
+                                  std::unique_ptr<state::PendingRecordRewardGrant>,
+                                  std::unique_ptr<state::PendingSeasonPassReward>,
+                                  std::unique_ptr<state::PendingSettingsUpdate>>;
     Mutation mutation{};
     /**
      * Vendor whose shown interaction this request answers once its mutation commits, or
@@ -42,22 +54,53 @@ struct Outcome {
     std::uint16_t answeredVendor{state::vendors::kAbsentIndex};
 };
 
+/** Allocates only the selected mutation outside the request's already deep stack. */
+template <typename Mutation, typename... Args>
+[[nodiscard]] Mutation* emplace_mutation(Outcome& outcome, Args&&... args) noexcept {
+    static_assert(std::is_nothrow_constructible_v<Mutation, Args...>);
+    outcome.mutation.template emplace<std::monostate>();
+    auto storage =
+        std::unique_ptr<Mutation>{new (std::nothrow) Mutation(std::forward<Args>(args)...)};
+    if (storage == nullptr) {
+        return nullptr;
+    }
+    auto* mutation = storage.get();
+    outcome.mutation.template emplace<std::unique_ptr<Mutation>>(std::move(storage));
+    return mutation;
+}
+
+/** @return The prepared mutation of the requested type, or null when another route ran. */
+template <typename Mutation> [[nodiscard]] Mutation* mutation_if(Outcome& outcome) noexcept {
+    auto* storage = std::get_if<std::unique_ptr<Mutation>>(&outcome.mutation);
+    return storage == nullptr ? nullptr : storage->get();
+}
+
 /** @return The prepared mutation of the requested type, or null when another route ran. */
 template <typename Mutation>
 [[nodiscard]] const Mutation* mutation_if(const Outcome& outcome) noexcept {
-    return std::get_if<Mutation>(&outcome.mutation);
+    const auto* storage = std::get_if<std::unique_ptr<Mutation>>(&outcome.mutation);
+    return storage == nullptr ? nullptr : storage->get();
 }
 
-/** Records the final opcode-403/404 reply after its paired Family-4 version is known. */
-void report_equip_response(const middleware::web_service::Message& message,
-                           std::int32_t family4Version,
-                           std::span<const std::byte> response) noexcept;
+/** Transfers the selected mutation without copying its snapshot. */
+template <typename Mutation>
+[[nodiscard]] std::unique_ptr<Mutation> take_mutation(Outcome& outcome) noexcept {
+    auto* storage = std::get_if<std::unique_ptr<Mutation>>(&outcome.mutation);
+    if (storage == nullptr) {
+        return {};
+    }
+    auto mutation = std::move(*storage);
+    outcome.mutation.template emplace<std::monostate>();
+    return mutation;
+}
+
+[[nodiscard]] inline bool has_mutation(const Outcome& outcome) noexcept {
+    return outcome.mutation.index() != 0;
+}
 
-/** Records an item-creation reply after its exact Family-4 version and instance are known. */
-void report_item_acquisition_response(const middleware::web_service::Message& message,
-                                      std::int32_t family4Version,
-                                      std::uint64_t acquiredInstanceSoid,
-                                      std::span<const std::byte> response) noexcept;
+inline void clear_mutation(Outcome& outcome) noexcept {
+    outcome.mutation.template emplace<std::monostate>();
+}
 
 /** Records the final profile-stack creation reply and exact Family-4 account revision. */
 void report_profile_item_acquisition_response(const middleware::web_service::Message& message,
@@ -123,4 +166,10 @@ void report_subclass_selection_response(const middleware::web_service::Message&
                            std::size_t& written,
                            Outcome& outcome) noexcept;
 
+/** Encodes the normal refusal shape for a request that may publish resident references. */
+[[nodiscard]] bool encode_resident_dependent_refusal(std::span<const std::byte> request,
+                                                     std::span<std::byte> response,
+                                                     std::size_t& written,
+                                                     bool& refused) noexcept;
+
 } // namespace sunrise::server::web_service

+ 59 - 26
Sunrise/src/state/account/account_state.cpp

@@ -35,14 +35,8 @@ constexpr std::uint8_t kDismantleClassMaskBits =
     if (state.dismantleRewardCount > state.dismantleRewards.size()) {
         return false;
     }
-    for (std::size_t index = 0; index < state.dismantleRewards.size(); ++index) {
+    for (std::size_t index = 0; index < state.dismantleRewardCount; ++index) {
         const DismantleRewardPolicy& reward = state.dismantleRewards[index];
-        if (index >= state.dismantleRewardCount) {
-            if (!empty_dismantle_reward(reward)) {
-                return false;
-            }
-            continue;
-        }
         if (reward.definitionHash == inventory::kNoDefinitionHash || reward.quantity <= 0
             || (reward.tierMask & ~kDismantleTierMaskBits) != 0
             || (reward.classMask & ~kDismantleClassMaskBits) != 0
@@ -55,20 +49,42 @@ constexpr std::uint8_t kDismantleClassMaskBits =
             }
         }
     }
-    return true;
+    const auto tail =
+        state.dismantleRewards.cbegin() + static_cast<std::ptrdiff_t>(state.dismantleRewardCount);
+    return std::all_of(tail, state.dismantleRewards.cend(), empty_dismantle_reward);
+}
+
+[[nodiscard]] bool empty_record_reward(const RecordRewardPolicy& reward) noexcept {
+    return reward.recordIndex == 0 && reward.itemIndex == 0 && reward.quantity == 0;
+}
+
+[[nodiscard]] bool valid_record_rewards(const AccountState& state) noexcept {
+    if (state.recordRewardCount > state.recordRewards.size()) {
+        return false;
+    }
+    for (std::size_t index = 0; index < state.recordRewardCount; ++index) {
+        const RecordRewardPolicy& reward = state.recordRewards[index];
+        if (reward.quantity <= 0) {
+            return false;
+        }
+        for (std::size_t prior = 0; prior < index; ++prior) {
+            if (same_record_reward_key(state.recordRewards[prior], reward)) {
+                return false;
+            }
+        }
+    }
+    const auto tail =
+        state.recordRewards.cbegin() + static_cast<std::ptrdiff_t>(state.recordRewardCount);
+    return std::all_of(tail, state.recordRewards.cend(), empty_record_reward);
 }
 
-/** Adds one nonzero globally unique key to a bounded identity set. */
+/** Adds one nonzero key to the bounded identity buffer. */
 [[nodiscard]] bool append_identity(std::array<std::uint64_t, kIdentityCapacity>& identities,
                                    std::size_t& count,
                                    std::uint64_t soid) noexcept {
     if (soid == 0 || count >= identities.size()) {
         return false;
     }
-    const auto end = identities.cbegin() + static_cast<std::ptrdiff_t>(count);
-    if (std::find(identities.cbegin(), end, soid) != end) {
-        return false;
-    }
     identities[count++] = soid;
     return true;
 }
@@ -81,17 +97,20 @@ constexpr std::uint8_t kDismantleClassMaskBits =
     }
     if (state.primarySoid == 0) {
         if (state.profileItemCount != 0 || state.characterCount != 0
-            || state.dismantleRewardCount != 0 || state.settings.configured
-            || state.settings.keyBindings.configured) {
+            || state.dismantleRewardCount != 0 || state.recordRewardCount != 0
+            || state.settings.configured || state.settings.keyBindings.configured) {
             return false;
         }
         return std::all_of(
                    state.profileItems.cbegin(), state.profileItems.cend(), empty_profile_item)
                && std::all_of(state.dismantleRewards.cbegin(),
                               state.dismantleRewards.cend(),
-                              empty_dismantle_reward);
+                              empty_dismantle_reward)
+               && std::all_of(
+                   state.recordRewards.cbegin(), state.recordRewards.cend(), empty_record_reward);
     }
-    if (!settings::valid(state.settings) || !valid_dismantle_rewards(state)) {
+    if (!settings::valid(state.settings) || !valid_dismantle_rewards(state)
+        || !valid_record_rewards(state)) {
         return false;
     }
 
@@ -100,14 +119,8 @@ constexpr std::uint8_t kDismantleClassMaskBits =
     if (!append_identity(identities, identityCount, state.primarySoid)) {
         return false;
     }
-    for (std::size_t index = 0; index < state.profileItems.size(); ++index) {
+    for (std::size_t index = 0; index < state.profileItemCount; ++index) {
         const inventory::ProfileItem& item = state.profileItems[index];
-        if (index >= state.profileItemCount) {
-            if (!empty_profile_item(item)) {
-                return false;
-            }
-            continue;
-        }
         if (item.definitionHash == inventory::kNoDefinitionHash || item.quantity <= 0
             || item.mutationSerial < 0
             || (item.instanceSoid != 0
@@ -115,6 +128,11 @@ constexpr std::uint8_t kDismantleClassMaskBits =
             return false;
         }
     }
+    const auto profileTail =
+        state.profileItems.cbegin() + static_cast<std::ptrdiff_t>(state.profileItemCount);
+    if (!std::all_of(profileTail, state.profileItems.cend(), empty_profile_item)) {
+        return false;
+    }
 
     bool selected = false;
     for (std::size_t index = 0; index < state.characterCount; ++index) {
@@ -124,7 +142,7 @@ constexpr std::uint8_t kDismantleClassMaskBits =
             || character.gender > CharacterGender::female
             || character.characterClass > CharacterClass::warlock
             || !std::isfinite(character.appearanceValue) || !inventory::valid(character.equipment)
-            || !inventory::valid(character.inventory)) {
+            || !inventory::valid(character.inventory) || !inventory::valid(character.stacks)) {
             return false;
         }
         selected = selected || character.selected;
@@ -142,7 +160,9 @@ constexpr std::uint8_t kDismantleClassMaskBits =
             }
         }
     }
-    return true;
+    auto end = identities.begin() + static_cast<std::ptrdiff_t>(identityCount);
+    std::sort(identities.begin(), end);
+    return std::adjacent_find(identities.begin(), end) == end;
 }
 
 } // namespace
@@ -190,4 +210,17 @@ std::uint64_t banner_character_soid(const AccountState& state) noexcept {
     return state.characterCount == 0 ? 0 : state.characters[0].soid;
 }
 
+bool find_record_reward(const AccountState& state,
+                        std::uint16_t recordIndex,
+                        RecordRewardPolicy& reward) noexcept {
+    const std::size_t count = (std::min)(state.recordRewardCount, state.recordRewards.size());
+    for (std::size_t index = 0; index < count; ++index) {
+        if (state.recordRewards[index].recordIndex == recordIndex) {
+            reward = state.recordRewards[index];
+            return true;
+        }
+    }
+    return false;
+}
+
 } // namespace sunrise::state::account

+ 27 - 3
Sunrise/src/state/account/account_state.h

@@ -13,6 +13,10 @@ namespace sunrise::state {
 inline constexpr std::size_t kCharacterCapacity = 3;
 /** A server-authored dismantle policy: a few rows per rarity and gear class. */
 inline constexpr std::size_t kDismantleRewardPolicyCapacity = 32;
+/** Maximum authored Triumph reward overrides. */
+inline constexpr std::size_t kRecordRewardPolicyCapacity = 256;
+/** Native sentinel used when a character has no title equipped. */
+inline constexpr std::uint16_t kUnequippedTitleRecordIndex = 0xFFFFU;
 
 /** Gear classes a dismantle payout row can be limited to. */
 enum class DismantleGearClass : std::uint8_t {
@@ -49,6 +53,18 @@ same_dismantle_policy_key(const DismantleRewardPolicy& left,
            && left.classMask == right.classMask && left.masterwork == right.masterwork;
 }
 
+/** Settings override for one Triumph claim reward. */
+struct RecordRewardPolicy {
+    std::uint16_t recordIndex{};
+    std::uint16_t itemIndex{};
+    std::int32_t quantity{};
+};
+
+[[nodiscard]] constexpr bool same_record_reward_key(const RecordRewardPolicy& left,
+                                                    const RecordRewardPolicy& right) noexcept {
+    return left.recordIndex == right.recordIndex;
+}
+
 /** Stable character race values authored independently of package definition mappings. */
 enum class CharacterRace : std::uint8_t {
     /** Wire value 0 is a Human character. */
@@ -79,9 +95,6 @@ enum class CharacterClass : std::uint8_t {
 
 /** Default movement entry. Each subclass offers 3, as entries 4, 5 and 6 of its group. */
 inline constexpr std::uint8_t kDefaultMovementAbilityEntry = 4;
-/** No socket entry list declares more entries than this, so a larger value is not an entry. */
-inline constexpr std::uint8_t kMaximumMovementAbilityEntry = 63;
-
 /**
  * Socket entries of the other abilities a subclass lets the player choose. Each names one entry
  * of that ability's group. The subclass offers several and the character picks one. These
@@ -149,6 +162,8 @@ struct CharacterState {
     std::uint16_t currentActivityIndex{};
     /** Server policy that arms content checks only with the matching family-5 flag. */
     bool contentBypass{};
+    /** Native DestinyRecordDefinition row of the equipped earned title. */
+    std::uint16_t equippedTitleRecordIndex{kUnequippedTitleRecordIndex};
     /**
      * Unix seconds the account signed in, from one clock read shared by every character.
      * The character records publish it as their last applied daily and weekly reset; zero
@@ -165,6 +180,8 @@ struct CharacterState {
     account::inventory::Equipment equipment;
     /** Unequipped items routed into their installed character-inventory bucket ranges. */
     account::inventory::CharacterItems inventory;
+    /** Non-instanced character materials granted at runtime. */
+    account::inventory::CharacterStacks stacks;
     /** Next row generation; equip transactions consume two values for the two moved items. */
     std::uint32_t nextInventorySerial{};
 };
@@ -175,6 +192,8 @@ struct AccountState {
     /** Economy policy comes from configuration, never from item-specific runtime constants. */
     std::array<DismantleRewardPolicy, kDismantleRewardPolicyCapacity> dismantleRewards{};
     std::size_t dismantleRewardCount{};
+    std::array<RecordRewardPolicy, kRecordRewardPolicyCapacity> recordRewards{};
+    std::size_t recordRewardCount{};
     /** Account-wide currencies and materials, placed by bucket rather than by authored slot. */
     std::array<account::inventory::ProfileItem, account::inventory::kProfileItemCapacity>
         profileItems{};
@@ -204,6 +223,11 @@ namespace account {
  */
 [[nodiscard]] std::uint64_t banner_character_soid(const AccountState& state) noexcept;
 
+/** Finds the settings override for a record claim. */
+[[nodiscard]] bool find_record_reward(const AccountState& state,
+                                      std::uint16_t recordIndex,
+                                      RecordRewardPolicy& reward) noexcept;
+
 } // namespace account
 
 } // namespace sunrise::state

+ 27 - 0
Sunrise/src/state/account/inventory/inventory_state.cpp

@@ -31,6 +31,7 @@ constexpr std::array<SlotName, kEquipmentSlotCount> kSlotNames{{
     {"emblem", EquipmentSlot::emblem},
     {"emote", EquipmentSlot::emote},
     {"finisher", EquipmentSlot::finisher},
+    {"artifact", EquipmentSlot::artifact},
 }};
 
 } // namespace
@@ -122,4 +123,30 @@ bool valid(const CharacterItems& items) noexcept {
     return true;
 }
 
+/** Checks a dense, definition-unique character stack list. */
+bool valid(const CharacterStacks& items) noexcept {
+    if (items.count > items.values.size()) {
+        return false;
+    }
+    for (std::size_t index = 0; index < items.values.size(); ++index) {
+        const CharacterStack& item = items.values[index];
+        if (index >= items.count) {
+            if (item.definitionHash != 0 || item.quantity != 0 || item.mutationSerial != 0) {
+                return false;
+            }
+            continue;
+        }
+        if (item.definitionHash == kNoDefinitionHash || item.quantity <= 0
+            || item.mutationSerial < 0) {
+            return false;
+        }
+        for (std::size_t prior = 0; prior < index; ++prior) {
+            if (items.values[prior].definitionHash == item.definitionHash) {
+                return false;
+            }
+        }
+    }
+    return true;
+}
+
 } // namespace sunrise::state::account::inventory

+ 18 - 4
Sunrise/src/state/account/inventory/inventory_state.h

@@ -10,7 +10,7 @@
 
 namespace sunrise::state::account::inventory {
 
-/** Authored equipment exposes the 16 named slots the first State supports. */
+/** Authored equipment exposes every named slot currently represented by State. */
 enum class EquipmentSlot : std::uint8_t {
     kinetic,
     energy,
@@ -28,6 +28,7 @@ enum class EquipmentSlot : std::uint8_t {
     emblem,
     emote,
     finisher,
+    artifact,
     count,
 };
 
@@ -58,11 +59,11 @@ inline constexpr std::size_t kProfileActionSourceCapacity = 100;
 /** Runtime-owned SOIDs for profile stacks use a namespace separate from created item instances. */
 inline constexpr std::uint64_t kFirstProfileItemInstanceSoid = 0x5000000000000001ULL;
 /**
- * The 16 supported character equipment buckets reserve 151 native rows in this build. One row
-
- * * per semantic slot can be equipped, leaving at most 135 simultaneously unequipped instances.
+ * 151 native rows minus the 16 equipped rows leaves 135 unequipped item rows.
  */
 inline constexpr std::size_t kCharacterItemCapacity = 135;
+/** Runtime-owned non-instanced character stacks. */
+inline constexpr std::size_t kCharacterStackCapacity = 32;
 
 /**
  * Definition hash of the real, non-equippable "Emotes" collection item. The Client opens its own
@@ -138,6 +139,17 @@ struct CharacterItems {
     std::size_t count{};
 };
 
+struct CharacterStack {
+    std::uint32_t definitionHash{};
+    std::int32_t quantity{};
+    std::int32_t mutationSerial{};
+};
+
+struct CharacterStacks {
+    std::array<CharacterStack, kCharacterStackCapacity> values{};
+    std::size_t count{};
+};
+
 /** One optional authored item for every semantic equipment slot. */
 struct Equipment {
     std::array<std::optional<Item>, kEquipmentSlotCount> slots{};
@@ -171,4 +183,6 @@ struct Equipment {
 /** Checks the used prefix and empty tail of one character's unequipped item array. */
 [[nodiscard]] bool valid(const CharacterItems& items) noexcept;
 
+[[nodiscard]] bool valid(const CharacterStacks& items) noexcept;
+
 } // namespace sunrise::state::account::inventory

Some files were not shown because too many files changed in this diff