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

Consolidate progression and reward handling

Millie 1 hete
szülő
commit
11bea3f942
100 módosított fájl, 4293 hozzáadás és 5746 törlés
  1. 5 8
      Sunrise/Sunrise.vcxproj
  2. 6 6
      Sunrise/resources/default_settings.json
  3. 1 32
      Sunrise/src/client/content/investment/investment_refresh.cpp
  4. 3 0
      Sunrise/src/client/content/items/packages/build.h
  5. 8 59
      Sunrise/src/client/content/items/packages/internal.h
  6. 6 21
      Sunrise/src/client/content/items/packages/package_collectible_build.cpp
  7. 18 52
      Sunrise/src/client/content/items/packages/package_item_build.cpp
  8. 1 1
      Sunrise/src/core/settings/parser.h
  9. 6 18
      Sunrise/src/core/settings/state/account_rows_parser.cpp
  10. 2 1
      Sunrise/src/core/settings/state_settings.cpp
  11. 0 72
      Sunrise/src/middleware/bap/activity_message/activity_incident_encoder.cpp
  12. 11 82
      Sunrise/src/middleware/bap/activity_message/activity_incident_parser.cpp
  13. 3 20
      Sunrise/src/middleware/bap/activity_message/activity_membership_member_writer.cpp
  14. 3 65
      Sunrise/src/middleware/bap/activity_message/incident.h
  15. 28 101
      Sunrise/src/middleware/datagen/family4/account/account_encoder.cpp
  16. 11 1
      Sunrise/src/middleware/datagen/family4/account/account_encoder.h
  17. 65 54
      Sunrise/src/middleware/datagen/family4/character/character_encoder.cpp
  18. 10 4
      Sunrise/src/middleware/datagen/family4/character/character_encoder.h
  19. 21 0
      Sunrise/src/middleware/web_service/messages/opcode2400.h
  20. 36 0
      Sunrise/src/middleware/web_service/messages/opcode2400_codec.cpp
  21. 137 69
      Sunrise/src/server/bap/bap_route.cpp
  22. 0 32
      Sunrise/src/server/bap/encrypted/activity_host_manager/activity_host_manager_route.cpp
  23. 0 22
      Sunrise/src/server/bap/encrypted/activity_message/activity_message_route.cpp
  24. 12 42
      Sunrise/src/server/bap/encrypted/activity_message/membership/activity_membership_route.cpp
  25. 311 652
      Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_message_receipts.cpp
  26. 0 4
      Sunrise/src/server/bap/encrypted/activity_transaction/activity_transaction_notifications.cpp
  27. 325 299
      Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp
  28. 116 176
      Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp
  29. 139 63
      Sunrise/src/server/bap/encrypted/internal.h
  30. 21 34
      Sunrise/src/server/bap/encrypted/push/queuez/queuez_banner_push.cpp
  31. 0 33
      Sunrise/src/server/bap/encrypted/push/queuez/queuez_push_reporting.cpp
  32. 0 21
      Sunrise/src/server/bap/encrypted/push/queuez/queuez_push_reporting.h
  33. 208 83
      Sunrise/src/server/bap/encrypted/push/queuez/queuez_select_character.cpp
  34. 16 30
      Sunrise/src/server/bap/encrypted/push/queuez/queuez_subscription.cpp
  35. 3 37
      Sunrise/src/server/bap/encrypted/push/snapshot/banner_snapshot.cpp
  36. 661 136
      Sunrise/src/server/bap/encrypted/push/snapshot/family4_inventory_updates.cpp
  37. 120 146
      Sunrise/src/server/bap/encrypted/push/snapshot/family4_selection_move.cpp
  38. 5 0
      Sunrise/src/server/bap/encrypted/push/snapshot/family4_snapshot_preparer.cpp
  39. 16 10
      Sunrise/src/server/bap/encrypted/push/snapshot/initial_snapshot.cpp
  40. 64 21
      Sunrise/src/server/bap/encrypted/push/snapshot/internal.h
  41. 15 20
      Sunrise/src/server/bap/encrypted/push/snapshot/snapshot.h
  42. 25 1
      Sunrise/src/server/bap/encrypted/queuez/definition.h
  43. 146 92
      Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp
  44. 280 105
      Sunrise/src/server/bap/encrypted/queuez/queuez_outcome_staging.cpp
  45. 13 8
      Sunrise/src/server/bap/encrypted/queuez/queuez_outcome_staging.h
  46. 16 0
      Sunrise/src/server/bap/encrypted/queuez/queuez_state_validation.h
  47. 280 161
      Sunrise/src/server/bap/encrypted/queuez/staging/queuez_character_staging.cpp
  48. 3 0
      Sunrise/src/server/bap/encrypted/queuez/staging/queuez_family_staging.cpp
  49. 58 122
      Sunrise/src/server/bap/encrypted/routing/bap_service_routing.cpp
  50. 68 66
      Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp
  51. 69 85
      Sunrise/src/server/bap/internal.h
  52. 1 10
      Sunrise/src/server/bap/plaintext.cpp
  53. 306 640
      Sunrise/src/server/web_service/web_service_actions.cpp
  54. 5 11
      Sunrise/src/server/web_service/web_service_actions.h
  55. 31 69
      Sunrise/src/server/web_service/web_service_runtime.cpp
  56. 61 56
      Sunrise/src/server/web_service/web_service_runtime.h
  57. 19 39
      Sunrise/src/state/account/account_state.cpp
  58. 7 25
      Sunrise/src/state/account/account_state.h
  59. 26 0
      Sunrise/src/state/account/inventory/inventory_state.cpp
  60. 16 5
      Sunrise/src/state/account/inventory/inventory_state.h
  61. 0 22
      Sunrise/src/state/activity/current_activity.cpp
  62. 0 29
      Sunrise/src/state/activity/current_activity.h
  63. 5 2
      Sunrise/src/state/build_data/build_data_runtime.cpp
  64. 6 2
      Sunrise/src/state/build_data/cache/read/cache_file_reader.cpp
  65. 12 0
      Sunrise/src/state/build_data/cache/read/cache_payload_reader.cpp
  66. 6 3
      Sunrise/src/state/build_data/cache/records/cache_domain_validation.cpp
  67. 61 4
      Sunrise/src/state/build_data/cache/records/cache_investment_records.cpp
  68. 8 232
      Sunrise/src/state/build_data/cache/records/codec.h
  69. 8 0
      Sunrise/src/state/build_data/cache/records/domains.h
  70. 33 6
      Sunrise/src/state/build_data/cache/records/format.h
  71. 6 2
      Sunrise/src/state/build_data/cache/write/cache_file_writer.cpp
  72. 4 0
      Sunrise/src/state/build_data/cache/write/cache_payload_writer.cpp
  73. 2 0
      Sunrise/src/state/build_data/cache/write/temporary/temporary_cache_file.cpp
  74. 0 16
      Sunrise/src/state/build_data/collectibles/collectible_catalog.cpp
  75. 0 21
      Sunrise/src/state/build_data/collectibles/collectible_catalog.h
  76. 12 65
      Sunrise/src/state/build_data/nodes/definition.h
  77. 20 34
      Sunrise/src/state/build_data/nodes/node_catalog.cpp
  78. 9 49
      Sunrise/src/state/build_data/nodes/node_catalog.h
  79. 0 150
      Sunrise/src/state/build_data/nodes/node_persistence.cpp
  80. 0 44
      Sunrise/src/state/build_data/nodes/node_persistence.h
  81. 15 20
      Sunrise/src/state/build_data/records/record_catalog.cpp
  82. 0 11
      Sunrise/src/state/build_data/records/record_catalog.h
  83. 0 150
      Sunrise/src/state/build_data/records/record_persistence.cpp
  84. 0 44
      Sunrise/src/state/build_data/records/record_persistence.h
  85. 8 0
      Sunrise/src/state/build_data/records/rewards/definition.h
  86. 25 26
      Sunrise/src/state/build_data/records/rewards/reward_build_data_runtime.cpp
  87. 0 12
      Sunrise/src/state/build_data/records/rewards/reward_catalog.cpp
  88. 3 24
      Sunrise/src/state/build_data/records/rewards/reward_catalog.h
  89. 26 71
      Sunrise/src/state/build_data/records/rewards/reward_persistence.cpp
  90. 2 22
      Sunrise/src/state/build_data/records/rewards/reward_persistence.h
  91. 9 36
      Sunrise/src/state/build_data/runtime.h
  92. 6 13
      Sunrise/src/state/build_data/runtime/build_data_catalog_runtime.cpp
  93. 20 9
      Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.cpp
  94. 5 0
      Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.h
  95. 6 0
      Sunrise/src/state/build_data/sobjects/sobject_catalog.cpp
  96. 8 35
      Sunrise/src/state/build_data/sobjects/sobject_catalog.h
  97. 0 247
      Sunrise/src/state/lore/bubble_record_table.h
  98. 24 104
      Sunrise/src/state/lore/lore_grant.cpp
  99. 0 17
      Sunrise/src/state/lore/lore_grant.h
  100. 101 202
      Sunrise/src/state/progression/season_pass_reward_catalog.h

+ 5 - 8
Sunrise/Sunrise.vcxproj

@@ -578,7 +578,6 @@
     <ClCompile Include="src\middleware\bap\activity_message\activity_entity_slot_request_parser.cpp" />
     <ClCompile Include="src\middleware\bap\activity_message\activity_entity_slots_decoder.cpp" />
     <ClCompile Include="src\middleware\bap\activity_message\activity_entity_authority_parser.cpp" />
-    <ClCompile Include="src\middleware\bap\activity_message\activity_incident_encoder.cpp" />
     <ClCompile Include="src\middleware\bap\activity_message\activity_incident_parser.cpp" />
     <ClCompile Include="src\middleware\bap\activity_message\activity_entity_slots_encoder.cpp" />
     <ClCompile Include="src\middleware\bap\certificate.cpp" />
@@ -826,17 +825,16 @@
     <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\nodes\node_persistence.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\activity\current_activity.cpp" />
-    <ClCompile Include="src\state\build_data\records\record_persistence.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>
@@ -1442,13 +1440,13 @@
     <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\middleware\web_service\messages\opcode1820.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode402.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode403.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode406.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\nodes\node_persistence.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" />
@@ -1457,10 +1455,9 @@
     <ClInclude Include="src\state\record_claims\objective_slot_table.h" />
     <ClInclude Include="src\state\record_claims\parent_bar_table.h" />
     <ClInclude Include="src\state\record_claims\record_claims.h" />
-    <ClInclude Include="src\state\activity\current_activity.h" />
-    <ClInclude Include="src\state\build_data\records\record_persistence.h" />
+    <ClInclude Include="src\state\progression\season_pass_reward_catalog.h" />
+    <ClInclude Include="src\state\progression\seasonal_experience.h" />
     <ClInclude Include="src\state\build_data\sobjects\sobject_catalog.h" />
-    <ClInclude Include="src\state\lore\bubble_record_table.h" />
     <ClInclude Include="src\state\lore\lore_grant.h" />
   </ItemGroup>
   <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />

+ 6 - 6
Sunrise/resources/default_settings.json

@@ -3,13 +3,13 @@
   "core": {
     "logging": {
       "debugger_sink": true,
-      "file_sink": true,
+      "file_sink": false,
       "levels": {
-        "core": "debug",
-        "client": "debug",
-        "state": "debug",
-        "server": "debug",
-        "middleware": "debug"
+        "core": "warn",
+        "client": "warn",
+        "state": "warn",
+        "server": "warn",
+        "middleware": "warn"
       }
     }
   },

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

@@ -1,6 +1,3 @@
-#include <atomic>
-
-#include "../../../state/build_data/nodes/node_persistence.h"
 #include <Windows.h>
 
 #include "../../../core/ui/busy/busy.h"
@@ -16,26 +13,8 @@ namespace {
 
 SRWLOCK g_refreshLock{SRWLOCK_INIT};
 
-/**
- * @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();
 }
 
 } // namespace
@@ -47,16 +26,6 @@ bool requires_package_sweep() noexcept {
 
 /** Publishes every installed equipment mapping domain. */
 bool refresh() noexcept {
-    // The node table is kept in its own file and is not part of the build data cache. Publishing it
-    // here, before the gate below decides whether the package pass runs, is what lets a warm start
-    // count categories: the gate itself is left exactly as it was, so no pass can be made to repeat.
-    // Latches on success, not on the attempt: an early call is refused rather than failed, so
-    // giving up after one try is what left the table empty.
-    static std::atomic<bool> nodesPublished{false};
-    if (!nodesPublished.load(std::memory_order_relaxed)
-        && state::build_data::nodes::load_and_publish()) {
-        nodesPublished.store(true, std::memory_order_relaxed);
-    }
     if (ready()) {
         // The same lock as the extraction path. A cache write holds its own lock across file
         // calls, so a held thread stopped inside one would deadlock the freeze below.

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

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

@@ -56,8 +56,7 @@ inline constexpr std::size_t kContainerCandidates = 16;
 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>
+    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,
@@ -92,7 +91,8 @@ struct Storage {
         entryBucketRows{};
     std::array<state::build_data::progressions::Definition,
                state::build_data::progressions::kDefinitionCapacity>
-        progressionRows{};    std::array<state::build_data::collectibles::Definition,
+        progressionRows{};
+    std::array<state::build_data::collectibles::Definition,
                state::build_data::collectibles::kDefinitionCapacity>
         collectibleRows{};
     std::array<state::build_data::material_requirements::Definition,
@@ -124,12 +124,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;
 
 /**
@@ -243,38 +238,7 @@ 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 the records table and resolves each record's completion flag to a bank index.
- * @param source Package source.
- * @param scratch Reader scratch.
- * @param root Investment root bytes.
- * @param blob Scratch storage for the tables.
- * @param output Row storage in native record order.
- * @param count Receives the number of rows read.
- * @return True when both tables read and every row fits.
- */
-/**
- * Reads the presentation node table and resolves each node's value slot and owned records.
- * @param source Package source.
- * @param scratch Reader scratch.
- * @param root Investment root bytes.
- * @param blob Scratch storage for the tables.
- * @param output Row storage in native node order.
- * @param count Receives the number of rows read.
- * @return True when both tables read 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,
@@ -282,6 +246,7 @@ read_investment_constants(const reader::Source& source,
                                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,
@@ -289,6 +254,7 @@ read_investment_constants(const reader::Source& source,
                                  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,
@@ -386,24 +352,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 the records table and resolves each record's completion flag to a bank index.
- * @param source Package source.
- * @param scratch Reader scratch.
- * @param root Investment root bytes.
- * @param blob Scratch storage for the tables.
- * @param output Row storage in native record order.
- * @param count Receives the number of rows read.
- * @return True when both tables read and every row fits.
- */
+/** 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,

+ 6 - 21
Sunrise/src/client/content/items/packages/package_collectible_build.cpp

@@ -1,11 +1,8 @@
-#include <array>
-#include <cstdio>
-#include "../../../../core/logging/log.h"
-#include "../../../../state/build_data/sobjects/sobject_catalog.h"
 #include <cstring>
 #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 {
@@ -22,16 +19,13 @@ bool build_collectibles(const reader::Source& source,
     // this table its target is a bare number.
     if (state::build_data::sobjects::count() == 0) {
         namespace sobjects = state::build_data::sobjects;
-        // Geometry is documented and asserted rather than divided out: count at +112, rows at +128,
-        // forty bytes each, name hash at +0, lane 4 at +16, selector group at +32, ordinal at +34,
-        // type at +36.
+        // 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) {
+            if (!reader::read_tag(source, storage.scratch, tag, blob) || blob.size() < kRowBase) {
                 continue;
             }
             std::uint64_t rowCount = 0;
@@ -43,13 +37,9 @@ bool build_collectibles(const reader::Source& source,
             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].lanes.data(), blob.data() + at,
-                            rows[row].lanes.size() * sizeof(std::uint32_t));
-                std::memcpy(&rows[row].nameHash, blob.data() + at, sizeof(std::uint32_t));
-                std::memcpy(&rows[row].lane4, blob.data() + at + 16, sizeof(std::uint32_t));
-                std::memcpy(&rows[row].selectorGroup, blob.data() + at + 32, sizeof(std::uint16_t));
-                std::memcpy(&rows[row].nodeOrdinal, blob.data() + at + 34, sizeof(std::uint16_t));
-                std::memcpy(&rows[row].typeCode, blob.data() + at + 36, sizeof(std::int32_t));
+                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;
@@ -131,11 +121,6 @@ bool build_collectibles(const reader::Source& source,
         output.collectibleHash = collectibleHash;
         output.collectibleIndex = static_cast<std::uint16_t>(row);
         output.itemDefinitionIndex = itemDefinitionIndex;
-        // The lore row this collectible unlocks. The record displaying the same row is the chapter
-        // it completes, so the two tables join on the row rather than on any hash. Read after the
-        // reset above, or it would be cleared.
-        std::memcpy(&output.loreRow, table.data() + at + tables::kLoreRowOffset,
-                    sizeof output.loreRow);
         if (requirementSetIndex == domain::kUnavailableMaterialRequirementSetIndex) {
             continue;
         }

+ 18 - 52
Sunrise/src/client/content/items/packages/package_item_build.cpp

@@ -1,25 +1,12 @@
-#include "../../../../state/build_data/records/record_persistence.h"
-#include "../../../../state/build_data/nodes/node_persistence.h"
 #include <Windows.h>
 
 #include <array>
 
 #include "../../../../core/filesystem/path.h"
-#include "../../../../core/logging/log.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/content/content_catalog.h"
-#include "../../../../state/runtime/runtime.h"
-#include "../../../memory/current_process_memory.h"
-#include "../../../targets/game.h"
+#include "../../../../state/build_data/sobjects/sobject_catalog.h"
 #include "../../hash_names/hash_name_build.h"
 #include "../../scenarios/scenario_build.h"
 #include "../../spawn_sets/spawn_set_build.h"
@@ -29,23 +16,6 @@
 namespace sunrise::client::content::items::packages {
 namespace {
 
-/** @return True when every domain owned by the package pass is published. */
-[[nodiscard]] bool package_domains_ready() noexcept {
-    return state::build_data::item_definitions_ready()
-           && state::build_data::collectible_definitions_ready()
-           && state::build_data::material_requirement_sets_ready()
-           && state::build_data::configured_item_details_ready()
-           && state::build_data::socket_plug_rules_ready()
-           && state::build_data::inventory_bucket_descriptors_ready()
-           && state::build_data::socket_entry_lists_ready()
-           && state::build_data::ability_buckets_ready()
-           && 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 True when every item and investment-root domain is published. */
 [[nodiscard]] bool root_domains_ready() noexcept {
     return state::build_data::item_definitions_ready()
@@ -58,14 +28,23 @@ namespace {
            && 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();
 }
 
 } // 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 {
-    if (package_domains_ready()) {
+    if (ready()) {
         return true;
     }
     static Storage storage{};
@@ -92,13 +71,12 @@ bool build() noexcept {
     }
     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;
@@ -165,14 +143,8 @@ bool build() noexcept {
                                 storage.child,
                                 storage.nodeRows,
                                 nodeCount)) {
-                    if (state::build_data::publish_node_definitions(
-                            std::span(storage.nodeRows).first(nodeCount))) {
-                        // Kept in its own file: the build data cache does not carry this domain, so
-                        // without it a warm start runs with no node table and nothing can be
-                        // granted.
-                        (void)state::build_data::nodes::store(
-                            std::span(storage.nodeRows).first(nodeCount));
-                    }
+                    (void)state::build_data::publish_node_definitions(
+                        std::span(storage.nodeRows).first(nodeCount));
                 }
             }
             if (!state::build_data::record_definitions_ready()) {
@@ -183,14 +155,8 @@ bool build() noexcept {
                                   storage.child,
                                   storage.recordRows,
                                   recordCount)) {
-                    if (state::build_data::publish_record_definitions(
-                            std::span(storage.recordRows).first(recordCount))) {
-                        // Kept beside the node table and for the same reason: the build data cache
-                        // does not carry this domain, so a warm start would have no records and
-                        // nothing could resolve a chapter.
-                        (void)state::build_data::records::store(
-                            std::span(storage.recordRows).first(recordCount));
-                    }
+                    (void)state::build_data::publish_record_definitions(
+                        std::span(storage.recordRows).first(recordCount));
                 }
             }
             if (!state::build_data::investment_constants_ready()) {
@@ -229,7 +195,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.

+ 1 - 1
Sunrise/src/core/settings/parser.h

@@ -161,7 +161,7 @@ private:
     [[nodiscard]] bool profile_items(state::AccountState& output) noexcept;
     /** Parses the server-authored, definition-driven ordinary-gear dismantle payout. */
     [[nodiscard]] bool dismantle_rewards(state::AccountState& output) noexcept;
-    /** Parses the server-authored record-claim reward table, keyed by record index. */
+    /** Parses Triumph reward overrides keyed by record index. */
     [[nodiscard]] bool record_rewards(state::AccountState& output) noexcept;
     /**
      * Parses one authored character identity.

+ 6 - 18
Sunrise/src/core/settings/state/account_rows_parser.cpp

@@ -11,10 +11,6 @@ constexpr std::uint64_t kMaximumCharacterLevel = (std::numeric_limits<std::uint8
 /** A destination definition hash is one unsigned 32-bit field. */
 constexpr std::uint64_t kMaximumDestinationHash = (std::numeric_limits<std::uint32_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;
@@ -42,8 +38,7 @@ namespace {
 
 } // namespace
 
-/** Parses the materials credited by ordinary gear dismantles, with optional rarity/class filters.
- */
+/** Parses materials credited by gear dismantles, with optional rarity and class filters. */
 bool Parser::dismantle_rewards(state::AccountState& output) noexcept {
     output.dismantleRewards = {};
     output.dismantleRewardCount = 0;
@@ -147,10 +142,7 @@ bool Parser::dismantle_rewards(state::AccountState& output) noexcept {
     }
 }
 
-/**
- * Parses the record-claim reward table: one item, optionally with a quantity, per record index.
- * Empty by default, so behavior is unchanged unless the operator populates it.
- */
+/** Parses one optional item grant per record index. */
 bool Parser::record_rewards(state::AccountState& output) noexcept {
     output.recordRewards = {};
     output.recordRewardCount = 0;
@@ -189,12 +181,8 @@ bool Parser::record_rewards(state::AccountState& output) noexcept {
                 reward.itemIndex = static_cast<std::uint16_t>(value);
                 hasItemIndex = true;
             } else if (key == "quantity") {
-                // Halved so a grant added to a stack already at its maximum cannot overflow
-                // before maxStackSize is checked. The real ceiling is the item's own stack size,
-                // enforced where the grant is applied; this only keeps a mis-authored table from
-                // reaching signed overflow.
                 if (hasQuantity || !unsigned_integer(value) || value == 0
-                    || value > (std::numeric_limits<std::int32_t>::max)() / 2) {
+                    || value > (std::numeric_limits<std::int32_t>::max)()) {
                     return false;
                 }
                 reward.quantity = static_cast<std::int32_t>(value);
@@ -209,12 +197,12 @@ bool Parser::record_rewards(state::AccountState& output) noexcept {
                 return false;
             }
         }
-        if (!hasQuantity) {
-            reward.quantity = 1;
-        }
         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;

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

@@ -175,7 +175,8 @@ bool Parser::unlocks(state::unlocks::Table& output) noexcept {
         } else if (key == "character_progressions") {
             parsed = progression_values(output.characterProgressions);
         } else if (key == "reveal_all_lore_books") {
-            parsed = boolean(output.revealAllLoreBooks);
+            bool ignored = false;
+            parsed = boolean(ignored);
         } else {
             parsed = skip_value(0);
         }

+ 0 - 72
Sunrise/src/middleware/bap/activity_message/activity_incident_encoder.cpp

@@ -1,72 +0,0 @@
-#include <algorithm>
-#include <climits>
-
-#include "incident.h"
-
-namespace sunrise::middleware::bap::activity_message::incident {
-namespace {
-
-[[nodiscard]] bool target_allowed(std::uint32_t target) noexcept {
-    return target <= kTargetMaximum
-           && std::find(kPoisonTargets.begin(), kPoisonTargets.end(), target)
-                  == kPoisonTargets.end();
-}
-
-/** @return True when every bounded incident field can be represented on the wire. */
-[[nodiscard]] bool valid(const Incident& incident) noexcept {
-    if (!target_allowed(incident.primaryTarget) || incident.extraTargetCount > kExtraTargetMaximum
-        || incident.selectorLength > kSelectorMaximum || incident.payloadLength > kPayloadMaximum
-        || (!incident.hasCompressedSelector && incident.selectorLength != 0)
-        || (!incident.hasOptionalBlock
-            && (incident.optionalWordA != 0 || incident.optionalWordB != 0))) {
-        return false;
-    }
-    for (std::uint32_t index = 0; index < incident.extraTargetCount; ++index) {
-        if (!target_allowed(incident.extraTargets[index])) {
-            return false;
-        }
-    }
-    return true;
-}
-
-[[nodiscard]] bool write_bytes(encoding::bits::Writer& writer,
-                               std::span<const std::byte> bytes) noexcept {
-    for (const std::byte value : bytes) {
-        if (!writer.write(std::to_integer<std::uint8_t>(value), CHAR_BIT)) {
-            return false;
-        }
-    }
-    return true;
-}
-
-/** Writes fields after the caller has established semantic validity. */
-[[nodiscard]] bool write_fields(encoding::bits::Writer& writer, const Incident& incident) noexcept {
-    bool written = writer.write(incident.primaryTarget, kTargetWidth)
-                   && writer.write(incident.extraTargetCount, kExtraCountWidth);
-    for (std::uint32_t index = 0; written && index < incident.extraTargetCount; ++index) {
-        written = writer.write(incident.extraTargets[index], kTargetWidth);
-    }
-    written =
-        written && writer.write(incident.hasCompressedSelector ? 1U : 0U, kSelectorPresenceWidth);
-    if (written && incident.hasCompressedSelector) {
-        written =
-            writer.write(incident.selectorLength, kSelectorLengthWidth)
-            && write_bytes(writer, std::span(incident.selector).first(incident.selectorLength));
-    }
-    written = written && writer.write(incident.hasOptionalBlock ? 1U : 0U, kOptionalPresenceWidth);
-    if (written && incident.hasOptionalBlock) {
-        written = writer.write(incident.optionalWordA, kOptionalWordWidth)
-                  && writer.write(incident.optionalWordB, kOptionalWordWidth);
-    }
-    return written && writer.write(incident.payloadLength, kPayloadLengthWidth)
-           && write_bytes(writer, std::span(incident.payload).first(incident.payloadLength));
-}
-
-} // namespace
-
-/** Writes one bounded incident body after a complete semantic preflight. */
-bool write(encoding::bits::Writer& writer, const Incident& incident) noexcept {
-    return valid(incident) && write_fields(writer, incident);
-}
-
-} // namespace sunrise::middleware::bap::activity_message::incident

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

@@ -1,15 +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 <bit>
-#include <climits>
-#include <cmath>
 
 #include "../../encoding/bit_reader.h"
 #include "../../encoding/byte_order.h"
@@ -18,15 +9,6 @@
 namespace sunrise::middleware::bap::activity_message::incident {
 namespace {
 
-/** Only this header shape has a validated payload layout for the decode below. */
-inline constexpr std::uint32_t kPositionHeaderBits = 29;
-/** Bit offset of the position triple, MSB-first from the start of the raw message body. */
-inline constexpr std::size_t kPositionBitOffset = 518;
-/** Three consecutive 32-bit fields: x, y, z. */
-inline constexpr std::size_t kPositionBitWidth = 96;
-/** A decoded axis magnitude at or above this is treated as garbage rather than a real position. */
-inline constexpr float kPositionSanityLimit = 100'000.0F;
-
 /** @return True when one target index is safe to hand to the Client's table lookup. */
 [[nodiscard]] bool target_allowed(std::uint32_t target, Verdict& verdict) noexcept {
     if (target > kTargetMaximum) {
@@ -40,39 +22,6 @@ inline constexpr float kPositionSanityLimit = 100'000.0F;
     return true;
 }
 
-/** @return True when a decoded axis is finite and inside the sanity bound. */
-[[nodiscard]] bool position_axis_sane(float value) noexcept {
-    return std::isfinite(value) && std::fabs(value) < kPositionSanityLimit;
-}
-
-/**
- * Decodes the validated position triple directly from the raw message body, independent of the
- * header reader's cursor. Only called once the headerBits == 29 shape and buffer length are
- * confirmed by the caller.
- * @param body The exact span validate() was given.
- * @param parsed Receives x/y/z and hasPosition when the read and sanity gate both succeed.
- */
-void decode_position(std::span<const std::byte> body, Incident& parsed) noexcept {
-    encoding::bits::Reader positionReader(body);
-    std::uint64_t xWord = 0;
-    std::uint64_t yWord = 0;
-    std::uint64_t zWord = 0;
-    if (!positionReader.skip(kPositionBitOffset) || !positionReader.read(32, xWord)
-        || !positionReader.read(32, yWord) || !positionReader.read(32, zWord)) {
-        return;
-    }
-    const float x = std::bit_cast<float>(static_cast<std::uint32_t>(xWord));
-    const float y = std::bit_cast<float>(static_cast<std::uint32_t>(yWord));
-    const float z = std::bit_cast<float>(static_cast<std::uint32_t>(zWord));
-    if (!position_axis_sane(x) || !position_axis_sane(y) || !position_axis_sane(z)) {
-        return;
-    }
-    parsed.x = x;
-    parsed.y = y;
-    parsed.z = z;
-    parsed.hasPosition = true;
-}
-
 } // namespace
 
 /** @return A short stable name for one verdict, for the log line. */
@@ -122,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;
         }
@@ -131,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;
         }
@@ -140,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;
         }
     }
 
@@ -152,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)) {
@@ -168,22 +110,9 @@ Verdict validate(std::span<const std::byte> payload, Incident& parsed) noexcept
     if (parsed.payloadLength > kPayloadMaximum) {
         return Verdict::payloadTooLong;
     }
-    // The header is everything decoded up to here; the payload bytes read below are opaque.
-    parsed.headerBits = static_cast<std::uint32_t>(payload.size() * encoding::kBitsPerByte
-                                                    - reader.remaining_bits());
-    // The position triple is only validated for this one header shape, and only when the body is
-    // long enough to actually hold bits 518..613.
-    if (parsed.headerBits == kPositionHeaderBits
-        && payload.size() * encoding::kBitsPerByte >= kPositionBitOffset + kPositionBitWidth) {
-        decode_position(payload, parsed);
-    }
-    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;

+ 3 - 20
Sunrise/src/middleware/bap/activity_message/activity_membership_member_writer.cpp

@@ -1,5 +1,4 @@
 #include <bit>
-#include <limits>
 
 #include "replicate_membership.h"
 
@@ -12,7 +11,6 @@ constexpr std::size_t kMemberKeyByteCount = 8;
 constexpr std::size_t kMemberCount = 32;
 /** Each absent member adds 3 clear presence bits. */
 constexpr std::uint8_t kAbsentMemberBitCount = 3;
-/** Member field 1 uses 10 bits with a bias of 1. */
 /**
  * Member field 1 is a skip test, not a value. The client drops the member when the stored value
  * read as unsigned is at or below 0x1FF, so the only usable wire value is the one that stores -1.
@@ -28,12 +26,7 @@ constexpr std::size_t kIdentityPresenceFieldCount = 15;
 /** The minimal nested player blob is 18 bytes, including one zero pad bit. */
 constexpr std::uint16_t kPlayerBlobByteCount = 18;
 
-/**
- * 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)) {
@@ -43,12 +36,7 @@ constexpr std::uint16_t kPlayerBlobByteCount = 18;
     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)
@@ -56,12 +44,7 @@ constexpr std::uint16_t kPlayerBlobByteCount = 18;
            && writer.write(0, 1);
 }
 
-/**
- * Writes the present fields of the nested player identity block.
- * @param writer Fixed-buffer writer sitting at identity field zero.
- * @param identity Values mirrored from the client identity update.
- * @return True when fields 3, 5 and 14 and all presence bits fit.
- */
+/** Writes fields 3, 5, and 14 of the nested player identity block. */
 [[nodiscard]] bool write_player_identity(encoding::bits::Writer& writer,
                                          const client_identity::ClientIdentity& identity) noexcept {
     for (std::size_t field = 0; field < kIdentityPresenceFieldCount; ++field) {

+ 3 - 65
Sunrise/src/middleware/bap/activity_message/incident.h

@@ -5,116 +5,54 @@
 #include <cstdint>
 #include <span>
 
-#include "../../encoding/bit_writer.h"
-
 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
-                                                + kSelectorPresenceWidth + kOptionalPresenceWidth
-                                                + kPayloadLengthWidth;
-
 /** 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,
 };
 
 /** One validated incident, framed to the end of its payload. */
 struct Incident {
-    std::array<std::byte, kSelectorMaximum> selector{};
-    std::array<std::byte, kPayloadMaximum> payload{};
     std::uint32_t primaryTarget{};
-    std::uint32_t extraTargets[kExtraTargetMaximum]{};
+    std::array<std::uint16_t, kExtraTargetMaximum> extraTargets{};
     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. */
+    /** Bits the body used. Below the payload bit count means trailing padding. */
     std::uint32_t consumedBits{};
-    /**
-     * Bit cursor position where the decoded header ends, i.e. immediately before the opaque
-     * payload bytes. Also the shape gate for the decoded position below: only the headerBits == 29
-     * message shape has a validated field layout inside the opaque payload.
-     */
-    std::uint32_t headerBits{};
-    /**
-     * World position, decoded only for the one message shape whose payload layout is validated
-     * (headerBits == 29). Meaningless unless hasPosition is set.
-     */
-    float x{};
-    float y{};
-    float z{};
-    /** 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{};
-    /** Set when x/y/z were decoded from the validated shape and passed the sanity gate. */
-    bool hasPosition{};
 };
 
 /** @return A short stable name for one verdict, for the log line. */
 [[nodiscard]] const char* verdict_name(Verdict verdict) noexcept;
 
-/**
- * Validates one incident body from its first target to the end of its payload.
- * Every target index is range and poison checked before anything else, because an out-of-range
- * index is a crash in the consumer rather than a decode error.
- * @param payload Activity message payload after the envelope.
- * @param parsed Cleared first. Receives every field reached before the verdict.
- * @return accepted, or the first rule the body broke.
- */
+/** Validates framing and every target before the Client can consume the incident. */
 [[nodiscard]] Verdict validate(std::span<const std::byte> payload, Incident& parsed) noexcept;
 
-/**
- * Writes one bounded incident body after a complete semantic preflight.
- * TODO: no sender yet. The outbound gameplay-event route has to open before this is called.
- */
-[[nodiscard]] bool write(encoding::bits::Writer& writer, const Incident& incident) noexcept;
-
 } // namespace sunrise::middleware::bap::activity_message::incident

+ 28 - 101
Sunrise/src/middleware/datagen/family4/account/account_encoder.cpp

@@ -1,23 +1,17 @@
-#include "../../../../state/build_data/records/record_persistence.h"
-#include "../../../../state/build_data/records/record_catalog.h"
-#include "../../../../state/build_data/nodes/node_persistence.h"
-#include <atomic>
-#include "../../../../core/logging/log.h"
-#include "../../../../state/build_data/records/definition.h"
-#include <cstdio>
-#include <array>
-#include <span>
-#include <vector>
-#include "../../../../state/build_data/nodes/node_catalog.h"
-#include "../../../../state/record_claims/record_claims.h"
 #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"
@@ -36,20 +30,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{};
@@ -89,7 +73,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;
@@ -103,64 +90,27 @@ 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;
     object.objectiveValues = unlocks.objectiveValues;
-    // The node and record tables are not in the build data cache, so on a warm start the package
-    // pass is skipped and both are empty. The account image needs them: without nodes no book gate
-    // is satisfied and every book reads as unnamed. Publishing here, latched on success, is what
-    // makes a warm start look like a cold one.
-    {
-        static std::atomic<bool> published{false};
-        if (!published.load(std::memory_order_relaxed)) {
-            const bool haveNodes = state::build_data::nodes::count() != 0
-                                   || state::build_data::nodes::load_and_publish();
-            const bool haveRecords = state::build_data::records::count() != 0
-                                     || state::build_data::records::load_and_publish();
-            if (haveNodes && haveRecords) {
-                published.store(true, std::memory_order_relaxed);
-            }
-        }
-    }
-
-    // Settings predate collectible persistence and author many lore objectives at completion.
-    // Clear every lore-owned record first; claims and collected progress are overlaid below.
-    (void)state::record_claims::clear_lore_objectives(object.objectiveValues);
-    // Claims are laid over the authored bank on the way out, so a claimed record reads Acquired on
-    // the next image. The authored policy itself is immutable and is never edited.
-    (void)state::record_claims::apply(object.acquiredFlags);
-    // Season Pass reward rows carry their own claimed-unlock keys. Publish the persisted claim into
-    // the exact mapped account byte so the tile becomes disabled and reports "Already claimed".
+    // Season claims map to account flags; pending claims overlay the same response.
     if (!state::progression::seasonal_experience::apply_reward_claims(object.acquiredFlags)) {
         return false;
     }
-    // A lore book's category is gated: some read a flag, some test their own progress value. A book
-    // gated on progress cannot open by being played, since with no title shown there is nothing
-    // inside to collect. Satisfying the gate is what makes the book readable at all.
-    (void)state::build_data::nodes::apply_visibility(object.acquiredFlags);
-    // Triumph Score is a plain replicated value the client never derives, so total the claims made
-    // this session over whatever the policy authored and publish the sum.
-    if (state::build_data::records::kTriumphScoreValueIndex < object.objectiveValues.size()) {
-        auto& score = object.objectiveValues[state::build_data::records::kTriumphScoreValueIndex];
-        score += static_cast<std::int32_t>(state::record_claims::total_score());
+    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;
     }
-    // A node's progress bar reads a value slot and shows whatever it holds, so the claimed children
-    // have to be counted into it here or the bar never moves.
-    (void)state::record_claims::apply_node_progress(object.objectiveValues);
-    // Early lore chapters have a second visibility value separate from their completion objective.
-    // Publish it only for chapters the account actually holds, leaving undiscovered entries secret.
-    (void)state::record_claims::apply_chapter_visibility_gates(object.objectiveValues);
-    // A record reads claimable when its objective equals completionValue and its flag is clear --
-    // the flag alone can never carry that state, so its objective value(s) are written here instead.
-    (void)state::record_claims::apply_claimable_objectives(object.objectiveValues);
-    // Eighteen lore books gate on a value slot instead of a flag. Run this after every other value
-    // pass so an empty shared gate/bar receives its sentinel without overwriting a real count.
-    (void)state::build_data::nodes::apply_category_gates(object.objectiveValues,
-                                                        unlocks.revealAllLoreBooks);
-    // Undiscovered chapters keep their authored visibility state. A former blanket gate fill made
-    // whole books look claimable on a clean account, which bypassed collectible progression.
+    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;
@@ -177,37 +127,15 @@ bool encode(const state::AccountState& state, std::span<std::byte> output) noexc
                                object.progressions)) {
         return false;
     }
-    // Arrivals uses progression 40 for its finite reward track and repeating progression 41 for
-    // the gameplay XP bar. Both consume the same cumulative XP through lane zero.
-    constexpr std::uint16_t kSeasonalExperienceDefinitionIndex = 40U;
-    constexpr std::uint16_t kSeasonalPrestigeDefinitionIndex = 41U;
     const std::int32_t earnedExperience = state::progression::seasonal_experience::earned();
     for (std::size_t slot = 0; slot < object.progressions.size(); ++slot) {
         progression::layout::Entry& entry = object.progressions[slot];
-        if (entry.definitionIndex != kSeasonalExperienceDefinitionIndex
-            && entry.definitionIndex != kSeasonalPrestigeDefinitionIndex) {
+        if (entry.definitionIndex != state::progression::season_pass::kProgressionDefinitionIndex
+            && entry.definitionIndex
+                   != state::progression::season_pass::kHudProgressionDefinitionIndex) {
             continue;
         }
         entry.values[0] = (std::max)(entry.values[0], earnedExperience);
-        if (entry.definitionIndex != kSeasonalExperienceDefinitionIndex) {
-            continue;
-        }
-        static std::atomic<bool> reported{false};
-        if (!reported.exchange(true, std::memory_order_relaxed)) {
-            std::array<char, 160> line{};
-            const int written = std::snprintf(line.data(),
-                                              line.size(),
-                                              "ev=season_xp stage=encode definition=40 slot=%zu "
-                                              "progress=%d",
-                                              slot,
-                                              entry.values[0]);
-            if (written > 0) {
-                core::log::write(core::log::Channel::middleware,
-                                 core::log::Level::info,
-                                 {line.data(),
-                                  (std::min)(static_cast<std::size_t>(written), line.size() - 1)});
-            }
-        }
     }
     // Profile rows are sentinelled above, so placement only has to claim its own slots.
     std::array<std::uint16_t, kBucketIdentityCapacity> takenSlots{};
@@ -218,8 +146,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

+ 65 - 54
Sunrise/src/middleware/datagen/family4/character/character_encoder.cpp

@@ -1,8 +1,3 @@
-#include "../../../../state/record_claims/record_claims.h"
-#include "../../../../state/build_data/nodes/node_catalog.h"
-#include <atomic>
-
-#include "../../../../state/build_data/nodes/node_persistence.h"
 #include "character_encoder.h"
 
 #include <algorithm>
@@ -12,11 +7,12 @@
 #include <limits>
 #include <optional>
 
+#include "../../../../state/build_data/nodes/node_catalog.h"
 #include "../../../../state/build_data/runtime.h"
+#include "../../../../state/record_claims/record_claims.h"
 #include "../../../../state/unlocks/unlocks_runtime.h"
 #include "../instance/layout.h"
 #include "../progression/progression_bank_keys.h"
-#include "abi.h"
 #include "equipment_summary_builder.h"
 #include "layout.h"
 
@@ -34,7 +30,7 @@ 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, or zero for a permanent test item. */
+    /** Lore completion flag which consumes this quest; zero keeps the prerequisite authored. */
     std::uint16_t completionFlag{};
 };
 constexpr std::array<CollectibleQuest, 4> kCollectibleQuests{{
@@ -44,11 +40,7 @@ constexpr std::array<CollectibleQuest, 4> kCollectibleQuests{{
     {0xC3535D63U, 10769U}, // A Loyal Friend -> Vell Tarlowe's Ghost
 }};
 
-/**
- * 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
@@ -58,16 +50,58 @@ constexpr std::array<CollectibleQuest, 4> kCollectibleQuests{{
 
 /** 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;
+}
+
 /**
- * Publishes collectible prerequisites in their installed character-inventory quest bucket.
- * Stackable quest rows have no instance SOID and therefore need no Family-4 item residents.
+ * 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;
@@ -114,19 +148,15 @@ constexpr std::int32_t kOccupiedRowWatermark = 1;
         inventory::layout::Entry& row = object.inventoryItems[nextRow];
         row.definitionIndex = item.definitionIndex;
         row.quantity = 1;
-        object.newItemFlags[nextRow / kBitsPerFlagByte] |=
-            std::byte{1U} << (nextRow % kBitsPerFlagByte);
+        object.newItemFlags[nextRow / kBitsPerFlagByte] |= std::byte{1U}
+                                                           << (nextRow % kBitsPerFlagByte);
         object.instanceProgressWatermarks[nextRow] = kOccupiedRowWatermark;
         ++nextRow;
     }
     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.
- */
+/** 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 < resolvedLoadout.itemCount
@@ -135,14 +165,12 @@ 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()
+        if (item.inventoryRow >= layout::kInventoryCapacity
             || item.equipmentSlot >= occupiedEquipmentSlots.size() || item.quantity <= 0
             || itemInstance.instanceSoid == 0 || itemInstance.bounds.itemDefinitionCount == 0
             || itemInstance.bounds.itemDefinitionCount > instance::layout::kDefinitionIndexCapacity
@@ -151,28 +179,21 @@ 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
             || (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 {
@@ -206,7 +227,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) {
@@ -233,17 +255,6 @@ bool encode(const state::CharacterState& state,
             index < unlocks.characterObjectFlags.size() ? unlocks.characterObjectFlags[index]
                                                         : std::uint8_t{});
     }
-    // A character image is only built once a character is selected, which is later than any
-    // account or content path. The node table is published from here on a warm start, where the
-    // package pass is skipped and every earlier attempt is refused. Latches on success.
-    {
-        static std::atomic<bool> nodesPublished{false};
-        if (!nodesPublished.load(std::memory_order_relaxed)
-            && state::build_data::nodes::load_and_publish()) {
-            nodesPublished.store(true, std::memory_order_relaxed);
-        }
-    }
-
     // 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.
@@ -253,7 +264,7 @@ bool encode(const state::CharacterState& state,
     }
 
     // One lore book counts in the character bank rather than the account one.
-    (void)state::record_claims::apply_character_node_progress(object.objectiveValues);
+    (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);
@@ -282,7 +293,7 @@ bool encode(const state::CharacterState& state,
             object.equippedInstanceSoids[item.equipmentSlot] = item.instance.instanceSoid;
         }
     }
-    if (!place_collectible_quest_items(object)) {
+    if (!place_character_stacks(state, object) || !place_collectible_quest_items(object)) {
         return false;
     }
 

+ 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

+ 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

+ 137 - 69
Sunrise/src/server/bap/bap_route.cpp

@@ -1,12 +1,12 @@
 #include <Windows.h>
 
+#include <algorithm>
 #include <array>
-#include <atomic>
-#include <cstdio>
 #include <limits>
 
 #include "../../core/logging/log.h"
 #include "../../state/matchmaking/matchmaking_state.h"
+#include "../../state/progression/seasonal_experience.h"
 #include "encrypted/bap_connection_publication.h"
 #include "internal.h"
 #include "runtime.h"
@@ -17,39 +17,69 @@ namespace {
 SRWLOCK g_lock{SRWLOCK_INIT};
 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;
 
-/** 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;
+[[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();
         }
-        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) {
+        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::debug,
-                         {line.data(), static_cast<std::size_t>(count)});
+                         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;
+    arm_account_resync_elsewhere(origin);
 }
 
 /** @param id Nonzero connection id. @return Matching open session, or null. */
@@ -171,11 +201,6 @@ 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(
@@ -184,65 +209,98 @@ void clear_session(Session& session) noexcept {
 
 } // 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 {
-    g_accountGeneration = g_accountGeneration == (std::numeric_limits<std::uint64_t>::max)()
-                              ? 1
-                              : g_accountGeneration + 1;
-    std::size_t armed = 0;
     for (auto& peer : g_sessions) {
         if (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=resync_arm_all result=ok generation=%llu peers=%zu",
-                      static_cast<unsigned long long>(g_accountGeneration), armed);
-    if (count > 0) {
-        core::log::write(core::log::Channel::server, core::log::Level::debug,
-                         {line.data(), static_cast<std::size_t>(count)});
+}
+
+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(state::PendingItemAcquisition acquisition) noexcept {
-    if (!acquisition.prepared) {
+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;
     }
-    for (auto& peer : g_sessions) {
-        if (peer.id == 0 || !peer.authenticated || !peer.queuez.family4Active
-            || peer.worldItemAcquisitionArmed) {
-            continue;
-        }
-        peer.pendingWorldItemAcquisition = acquisition;
-        peer.worldItemAcquisitionArmed = true;
-        return true;
+    return enqueue_world_reward({quantity, itemDefinitionIndex, WorldRewardKind::profileItem});
+}
+
+bool current_world_reward(WorldRewardRequest& request) noexcept {
+    if (g_worldRewardCount == 0) {
+        request = {};
+        return false;
     }
-    return false;
+    request = g_worldRewards[g_worldRewardHead];
+    return true;
 }
 
-bool arm_world_profile_item_acquisition(
-    state::PendingProfileItemAcquisition acquisition) noexcept {
-    if (!acquisition.prepared) {
+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.worldProfileItemAcquisitionArmed) {
+            || peer.pendingSeasonalExperienceAmount
+                   > (std::numeric_limits<std::int32_t>::max)() - amount) {
             continue;
         }
-        peer.pendingWorldProfileItemAcquisition = acquisition;
-        peer.worldProfileItemAcquisitionArmed = true;
+        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. */
 bool consume(const client::network::BapRequest& request,
              client::network::BapResponse& response) noexcept {
@@ -277,6 +335,14 @@ bool consume(const client::network::BapRequest& request,
 /** Securely erases every connection-owned nonce and transform buffer. */
 void shutdown() noexcept {
     AcquireSRWLockExclusive(&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) {
@@ -288,8 +354,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;
     ReleaseSRWLockExclusive(&g_lock);
 }
 

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

@@ -1,4 +1,3 @@
-#include "../../../../state/activity/current_activity.h"
 #include "activity_host_manager_route.h"
 
 #include <array>
@@ -54,36 +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 {
-    // Keep the bubble: a pickup later needs to know which activity it happened in, and the incident
-    // that reports it carries the bubble at a different place per incident type.
-    state::activity::set_current_bubble(source.hasArrivalBubbleHash ? source.arrivalBubbleHash
-                                                                    : state::activity::kNoBubble);
-    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.
@@ -139,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;

+ 0 - 22
Sunrise/src/server/bap/encrypted/activity_message/activity_message_route.cpp

@@ -49,27 +49,6 @@ constexpr std::uint32_t kJoinRequestMessageType = 3;
  */
 constexpr std::uint32_t kLocalActivityHostMessageType = 8;
 
-/**
- * Reports one inbound activity message, whatever the route goes on to do with it.
- * Without this line a type the client never sends reads the same as one handled in silence.
- * Nothing else says whether the client ever asks for or returns an entity slot.
- * @param request Parsed envelope.
- */
-void report_arrival(const service::Request& request) noexcept {
-    std::array<char, core::log::kLineCapacity> line{};
-    const int written = std::snprintf(line.data(),
-                                      line.size(),
-                                      "ev=activity stage=inbound type=%u handle=0x%llX bytes=%zu",
-                                      request.messageType,
-                                      static_cast<unsigned long long>(request.accountHandle),
-                                      request.payload.size());
-    if (written > 0) {
-        core::log::write(core::log::Channel::server,
-                         core::log::Level::debug,
-                         {line.data(), static_cast<std::size_t>(written)});
-    }
-}
-
 /**
  * Reports one activity message the route did not stage, naming its type.
  * Every inbound activity message is one-way, so nothing here can jam the client's reply ring. An
@@ -315,7 +294,6 @@ bool process(const ActivityClientBinding& binding,
         report_message(0, 0, "parse");
         return false;
     }
-    report_arrival(request);
     // Join acquires or preserves an exact binding. Every other message, including type 52 and the
     // receipt-only types, must name the exact session already owned by this link before it can
     // mutate State or the receipt registry.

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

@@ -67,33 +67,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 a changed identity push or an unchanged transactional no-op. */
@@ -102,7 +75,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.accountHandle, make_identity(parsed), plan.membershipMutation)) {
         core::log::write(core::log::Channel::server,
@@ -169,21 +141,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 the

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 311 - 652
Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_message_receipts.cpp


+ 0 - 4
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"
@@ -32,9 +31,6 @@ namespace {
     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;
 }
 

+ 325 - 299
Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp

@@ -1,6 +1,5 @@
-#include <array>
 #include <atomic>
-#include <cstdio>
+#include <limits>
 
 #include "../../../../core/logging/log.h"
 #include "../../../../middleware/bap/account_translation/account_translation_response.h"
@@ -23,27 +22,26 @@
 namespace sunrise::server::bap::encrypted::body {
 namespace {
 
-/** One line carries the family and the root soid and nothing else. */
-constexpr std::size_t kSubscribeReportLimit = 96;
 /** The svc-23 request identity sits after its entry count and both type bytes. */
 constexpr std::size_t kTranslationIdentityOffset = 4;
 /** A request shorter than this carries no identity to read. */
 constexpr std::size_t kTranslationRequestSize =
     kTranslationIdentityOffset + middleware::encoding::kU64Size;
 
-/**
- * Identity already paired with the account soid, or zero before the first pairing.
- * There is one account, so this is process-wide rather than per connection.
- */
+/** 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 requestBody Complete svc-23 request body.
- * @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::span<const std::byte> requestBody) noexcept {
     if (requestBody.size() < kTranslationRequestSize) {
         return false;
@@ -89,35 +87,41 @@ bool process(const ServiceRoute& route,
         return true;
     case BodyCodec::accountTranslationResponse: {
         const state::AccountState account = state::account_snapshot();
-        // A zero soid makes the encoder write its zero-entry answer. That refuses an unpaired
-        // request without leaving the peer waiting.
+        // A zero SOID refuses an unpaired request without withholding its reply.
         const bool pairs = pairs_identity(requestBody);
         const std::uint64_t soid = pairs ? account.primarySoid : 0;
-        core::log::write(core::log::Channel::server,
-                         core::log::Level::info,
-                         pairs ? "ev=queuez stage=translate result=paired"
-                               : "ev=queuez stage=translate result=unpaired");
+        if (!pairs) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             "ev=queuez stage=translate result=unpaired");
+        }
         return middleware::bap::account_translation::encode_response(
             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, requestBody, plan, hasTransaction);
-        if (processed && hasTransaction) {
-            outcome.transaction = plan;
+            activity_message::process(activity, requestBody, *plan, hasTransaction);
+        if (!processed || !hasTransaction) {
+            clear_transaction(outcome);
         }
         return processed;
     }
@@ -132,21 +136,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: {
@@ -156,12 +145,15 @@ 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;
         const bool encoded = matchmaking::encode_response(
-            matchmakingContext, requestBody, output, written, mutation, hasMutation);
-        if (encoded && hasMutation) {
-            outcome.transaction = mutation;
+            matchmakingContext, requestBody, output, written, *mutation, hasMutation);
+        if (!encoded || !hasMutation) {
+            clear_transaction(outcome);
         }
         return encoded;
     }
@@ -173,22 +165,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.
-                outcome.changeCharacter = {};
-                return middleware::web_service::encode_response(
-                    message,
-                    middleware::web_service::ResponseShape::statusPair,
-                    middleware::web_service::StatusResponse{},
-                    output,
-                    written);
+                clear_transaction(outcome);
+                return refuse_web_action(message, output, written);
             }
             outcome.hasChangeCharacter = true;
             return true;
@@ -198,22 +185,22 @@ bool process(const ServiceRoute& route,
             return false;
         }
         if (webOutcome.hasTitleEquip) {
-            // The title lives in both character summary records, so refresh the roster and banner
-            // after the correlated success reply. Promise the exact Family-4 revision that carries
-            // the title field, matching every other optimistic character-screen action; otherwise
-            // the open Seals widget redraws its label but retains its pre-click action binding.
-            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");
-                return false;
+            // 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();
         }
@@ -234,226 +221,256 @@ bool process(const ServiceRoute& route,
             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 (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");
-                // Keep the already-encoded sentinel response and publish no mutation, matching
-                // the change-character failure contract instead of dropping the correlated task.
-                outcome.transaction = std::monostate{};
-            } else {
-                middleware::web_service::StatusResponse status{};
-                status.value = transaction.update.after.family4Version;
-                if (!middleware::web_service::encode_response(
-                        message,
-                        middleware::web_service::ResponseShape::statusPair,
-                        status,
-                        output,
-                        written)) {
-                    core::log::write(core::log::Channel::server,
-                                     core::log::Level::warn,
-                                     "ev=ws403 stage=response result=fail");
-                    return false;
-                }
-                web_service::report_equip_response(message, status.value, output.first(written));
-                transaction.pending = *equipmentSwap;
+                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{};
-            } 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{};
-            } 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{};
-            } 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{};
-            } 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;
+                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);
         }
         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{};
-            } 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;
+                    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);
         }
         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{};
+                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,
@@ -462,38 +479,38 @@ 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;
             }
         }
-        if (recordRewardGrant != nullptr) {
-            // A record-claim reward names an item directly rather than a Collections row, so
-            // which acquisition kind actually lands it depends on the granted item's own
-            // inventory bucket. Stage whichever alternative resolved, exactly like the
-            // corresponding opcode-1820 branch above, so the reply below promises the same
-            // version the staged Family-4 update actually carries.
-            auto& transaction = outcome.transaction.emplace<RecordRewardGrantTransaction>();
+        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>(&recordRewardGrant->grant)) {
-                auto& update = transaction.update.emplace<queuez::ItemAcquisition>();
+                    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,
-                                                        itemGrant->profileChanged,
+                                                        true,
                                                         update);
                 stagedVersion = update.after.family4Version;
             } else if (const auto* profileGrant = std::get_if<state::PendingProfileItemAcquisition>(
-                           &recordRewardGrant->grant)) {
-                auto& update = transaction.update.emplace<queuez::ProfileItemAcquisition>();
+                           &seasonPassReward->grant)) {
+                auto& update = transaction->update.emplace<queuez::ProfileItemAcquisition>();
                 staged = queuez::stage_profile_item_acquisition(queuezState,
                                                                 profileGrant->accountSoid,
                                                                 profileGrant->acquiredInstanceSoid,
@@ -501,39 +518,42 @@ bool process(const ServiceRoute& route,
                                                                 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);
             }
             if (!staged) {
                 core::log::write(core::log::Channel::server,
                                  core::log::Level::warn,
-                                 "ev=ws1801 stage=reward_preflight result=fail");
-                // Keep the plain claim reply below: the flag-bank claim already committed, so a
-                // reward that cannot be staged falls back to the ordinary account resync instead
-                // of failing the whole request.
-                outcome.transaction = std::monostate{};
-            } else {
-                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=ws1801 stage=reward_response result=fail");
-                    return false;
-                }
-                transaction.pending = *recordRewardGrant;
+                                 "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);
         }
-        // A pick that names the resident character moves nothing, so staging refuses it and the
-        // reply still stands on its own.
-        if (webOutcome.hasRecordClaim && std::holds_alternative<std::monostate>(outcome.transaction)) {
-            // Every other client action promises the exact Family-4 revision that makes it
-            // authoritative, and the claim was answering with the sentinel instead. The account
-            // resync staged later carries the next version, so promise that one here. A staged
-            // reward above already encoded its own correlated reply and is skipped here.
+        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 = queuezState.family4Version + 1;
             if (!middleware::web_service::encode_response(
@@ -545,14 +565,20 @@ bool process(const ServiceRoute& route,
                 core::log::write(core::log::Channel::server,
                                  core::log::Level::warn,
                                  "ev=ws1801 stage=response result=fail");
+                sunrise::server::bap::arm_account_resync_everywhere();
+                return false;
             }
         }
-        if (webOutcome.hasSelectedCharacter
-            && queuez::stage_select_character(
-                queuezState, webOutcome.selectedCharacterSoid, outcome.selectCharacter)) {
-            outcome.hasSelectCharacter = true;
-        } 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;
     }

+ 116 - 176
Sunrise/src/server/bap/encrypted/encrypted_runtime.cpp

@@ -1,10 +1,9 @@
 #include <Windows.h>
 
 #include <algorithm>
-#include <array>
-#include <cstdio>
 
 #include "../../../core/logging/log.h"
+#include "../../../middleware/datagen/definitions.h"
 #include "../../../middleware/secure_channel/runtime.h"
 #include "../../../state/runtime/runtime.h"
 #include "../internal.h"
@@ -27,6 +26,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;
+}
+
 } // namespace
 
 /**
@@ -94,8 +146,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.messageId, "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,
@@ -132,9 +197,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,
                                                 bapState.sessionKey,
                                                 nextSendNonce,
                                                 scratch.framed,
@@ -166,39 +242,26 @@ bool consume(Session& session,
         }
     }
     const bool mutatesAccount =
-        outcome.hasChangeCharacter || outcome.hasSelectCharacter
+        outcome.hasSelectCharacter || outcome.hasRecordClaim
         || transaction_if<EquipmentSwapTransaction>(outcome) != nullptr
+        || transaction_if<SubclassSelectionTransaction>(outcome) != nullptr
         || transaction_if<SocketPlugTransaction>(outcome) != nullptr
         || transaction_if<ItemStateTransaction>(outcome) != nullptr
         || transaction_if<ItemAcquisitionTransaction>(outcome) != nullptr
         || transaction_if<ProfileItemAcquisitionTransaction>(outcome) != nullptr
         || transaction_if<ItemDismantleTransaction>(outcome) != nullptr
-        || transaction_if<RecordRewardGrantTransaction>(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.
+        || transaction_if<RecordRewardGrantTransaction>(outcome) != nullptr
+        || transaction_if<SeasonPassRewardTransaction>(outcome) != nullptr;
+    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
+        || transaction_if<ItemDismantleTransaction>(outcome) != nullptr;
+    const bool hasPrecommittedAccountAction = outcome.hasRecordClaim || outcome.hasSelectCharacter;
+    // 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.
@@ -215,6 +278,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);
             // The caller copy is done, so what the staged roster body owes is settled here.
             push::activity::commit_staged_roster(session);
@@ -224,153 +302,18 @@ bool consume(Session& session,
             if (activityPlan != nullptr && framedSize != 0) {
                 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)});
-                }
-            }
-            if (const auto* transaction = transaction_if<SocketPlugTransaction>(outcome)) {
-                std::array<char, core::log::kLineCapacity> line{};
-                const int count = std::snprintf(
-                    line.data(),
-                    line.size(),
-                    "ev=socket_plug stage=output_publish result=ok framed_bytes=%zu "
-                    "queuez_published=%u family_version=%d family0_version=%d "
-                    "family3_version=%d instance=0x%llX lane=%u "
-                    "plug_definition=%u target_bucket=%u plug_bucket=%u",
-                    framedSize,
-                    static_cast<unsigned>(publishesQueuez),
-                    session.queuez.family4Version,
-                    session.queuez.family0Version,
-                    session.queuez.family3Version,
-                    static_cast<unsigned long long>(transaction->update.targetInstanceSoid),
-                    static_cast<unsigned>(socketLane),
-                    static_cast<unsigned>(socketPlugDefinition),
-                    static_cast<unsigned>(socketTargetBucket),
-                    static_cast<unsigned>(socketPlugBucket));
-                if (count > 0) {
-                    core::log::write(core::log::Channel::server,
-                                     core::log::Level::debug,
-                                     {line.data(), static_cast<std::size_t>(count)});
-                }
-            }
-            if (const auto* transaction = transaction_if<ItemStateTransaction>(outcome)) {
-                std::array<char, core::log::kLineCapacity> line{};
-                const int count = std::snprintf(
-                    line.data(),
-                    line.size(),
-                    "ev=item_state stage=output_publish result=ok framed_bytes=%zu "
-                    "queuez_published=%u family_version=%d instance=0x%llX flags=0x%X",
-                    framedSize,
-                    static_cast<unsigned>(publishesQueuez),
-                    session.queuez.family4Version,
-                    static_cast<unsigned long long>(itemStateInstance),
-                    itemStateFlags);
-                if (count > 0) {
-                    core::log::write(core::log::Channel::server,
-                                     core::log::Level::debug,
-                                     {line.data(), static_cast<std::size_t>(count)});
-                }
-            }
-            if (const auto* transaction = transaction_if<ItemAcquisitionTransaction>(outcome)) {
-                std::array<char, core::log::kLineCapacity> line{};
-                const int count = std::snprintf(
-                    line.data(),
-                    line.size(),
-                    "ev=acquire stage=output_publish result=ok framed_bytes=%zu "
-                    "queuez_published=%u family_version=%d residents=%u instance=0x%llX",
-                    framedSize,
-                    static_cast<unsigned>(publishesQueuez),
-                    session.queuez.family4Version,
-                    static_cast<unsigned>(session.queuez.family4ResidentCount),
-                    static_cast<unsigned long long>(transaction->update.acquiredInstanceSoid));
-                if (count > 0) {
-                    core::log::write(core::log::Channel::server,
-                                     core::log::Level::debug,
-                                     {line.data(), static_cast<std::size_t>(count)});
-                }
-            }
-            if (const auto* transaction =
-                    transaction_if<ProfileItemAcquisitionTransaction>(outcome)) {
-                std::array<char, core::log::kLineCapacity> line{};
-                const int count = std::snprintf(
-                    line.data(),
-                    line.size(),
-                    "ev=profile_acquire stage=output_publish result=ok framed_bytes=%zu "
-                    "queuez_published=%u family_version=%d residents=%u definition_hash=0x%08X "
-                    "quantity=%d instance=0x%llX action_source=%u appended_row=%u "
-                    "appended_resident=%u",
-                    framedSize,
-                    static_cast<unsigned>(publishesQueuez),
-                    session.queuez.family4Version,
-                    static_cast<unsigned>(session.queuez.family4ResidentCount),
-                    profileDefinitionHash,
-                    profileQuantity,
-                    static_cast<unsigned long long>(transaction->update.acquiredInstanceSoid),
-                    static_cast<unsigned>(profileActionSource),
-                    static_cast<unsigned>(profileAppended),
-                    static_cast<unsigned>(transaction->update.appendedResident));
-                if (count > 0) {
-                    core::log::write(core::log::Channel::server,
-                                     core::log::Level::debug,
-                                     {line.data(), static_cast<std::size_t>(count)});
-                }
-            }
-            if (const auto* transaction = transaction_if<ItemDismantleTransaction>(outcome)) {
-                std::array<char, core::log::kLineCapacity> line{};
-                const int count = std::snprintf(
-                    line.data(),
-                    line.size(),
-                    "ev=dismantle stage=output_publish result=ok framed_bytes=%zu "
-                    "queuez_published=%u family_version=%d residents=%u instance=0x%llX",
-                    framedSize,
-                    static_cast<unsigned>(publishesQueuez),
-                    session.queuez.family4Version,
-                    static_cast<unsigned>(session.queuez.family4ResidentCount),
-                    static_cast<unsigned long long>(transaction->update.dismantledInstanceSoid));
-                if (count > 0) {
-                    core::log::write(core::log::Channel::server,
-                                     core::log::Level::debug,
-                                     {line.data(), static_cast<std::size_t>(count)});
-                }
-            }
-            if (const auto* transaction = transaction_if<RecordRewardGrantTransaction>(outcome)) {
-                const bool profileGrant =
-                    std::holds_alternative<queuez::ProfileItemAcquisition>(transaction->update);
-                std::array<char, core::log::kLineCapacity> line{};
-                const int count = std::snprintf(
-                    line.data(),
-                    line.size(),
-                    "ev=record_reward stage=output_publish result=ok framed_bytes=%zu "
-                    "queuez_published=%u family_version=%d residents=%u profile_grant=%u",
-                    framedSize,
-                    static_cast<unsigned>(publishesQueuez),
-                    session.queuez.family4Version,
-                    static_cast<unsigned>(session.queuez.family4ResidentCount),
-                    static_cast<unsigned>(profileGrant));
-                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();
             }
+            session.accountMutationPublished = mutatesAccount && !resyncsCommittedAccount;
         }
     }
     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);
@@ -381,9 +324,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;
 }
 

+ 139 - 63
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,54 +41,56 @@ 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{};
 };
 
 /** Character acquisition and its exact QueueZ after-image. */
 struct ItemAcquisitionTransaction {
-    state::PendingItemAcquisition pending{};
+    std::unique_ptr<state::PendingItemAcquisition> pending{};
     queuez::ItemAcquisition update{};
 };
 
 /** Profile acquisition and its exact account/resident QueueZ after-image. */
 struct ProfileItemAcquisitionTransaction {
-    state::PendingProfileItemAcquisition pending{};
+    std::unique_ptr<state::PendingProfileItemAcquisition> pending{};
     queuez::ProfileItemAcquisition update{};
 };
 
 /** 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 grant and its exact QueueZ after-image.
- * A record reward lands through whichever acquisition kind its item's bucket resolved to, so the
- * update carries the matching alternative of the same pair the pending grant itself wraps.
- */
+/** Record-claim reward batch and its exact Queuez after-image. */
 struct RecordRewardGrantTransaction {
-    state::PendingRecordRewardGrant pending{};
-    std::variant<queuez::ItemAcquisition, queuez::ProfileItemAcquisition> update{};
+    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> update{};
 };
 
 /** Optional side effect produced while decoding one authenticated service body. */
@@ -95,35 +102,61 @@ struct ServiceOutcome {
     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,
-                                     ItemAcquisitionTransaction,
-                                     ProfileItemAcquisitionTransaction,
-                                     ItemDismantleTransaction,
-                                     RecordRewardGrantTransaction>;
+                                     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<ItemAcquisitionTransaction>,
+                                     std::unique_ptr<ProfileItemAcquisitionTransaction>,
+                                     std::unique_ptr<ItemDismantleTransaction>,
+                                     std::unique_ptr<RecordRewardGrantTransaction>,
+                                     std::unique_ptr<SeasonPassRewardTransaction>>;
     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. */
@@ -139,7 +172,6 @@ struct ServiceRoute {
     ResponseMode responseMode{};
     middleware::bap::ResponseService response{};
     BodyCodec bodyCodec{};
-    std::string_view successEvent{};
 };
 
 /** Owns encrypted service-to-response routing. */
@@ -236,14 +268,40 @@ 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::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.
@@ -328,24 +386,26 @@ 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 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 same-character Family-0 appearance upsert paired with one equipment swap.
@@ -452,25 +512,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,

+ 21 - 34
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 {
@@ -62,7 +62,6 @@ void report_fail(const char* stage, const char* reason) noexcept {
                                            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
@@ -97,7 +96,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;
 }
 
@@ -113,7 +111,6 @@ append_roster_appearance_frame(Scratch& scratch,
                                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 +134,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;
 }
 
@@ -168,9 +164,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{};
@@ -182,8 +175,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,
@@ -207,11 +198,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;
 }
 
@@ -258,6 +244,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;
     }
@@ -274,8 +265,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,
@@ -289,11 +278,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;
 }
 
@@ -381,10 +365,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{};
@@ -443,10 +428,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{};
@@ -471,10 +457,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;
 

+ 208 - 83
Sunrise/src/server/bap/encrypted/push/queuez/queuez_select_character.cpp

@@ -1,24 +1,13 @@
-#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"
 #include "queuez_update_frame.h"
 
 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 +18,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 +28,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,24 +56,25 @@ bool append_equipment_swap_notification(Scratch& scratch,
                               written)) {
         return false;
     }
-    queuez_report::push("equip", queuez::kAccountFamilyType, objectCount, written - beforeBytes, 1);
     return true;
 }
 
 /** Appends one opcode-406 selected-character item-state upsert. */
-bool append_item_state_notification(Scratch& scratch,
-                                    const queuez::EquipmentSwap& update,
-                                    const state::PendingItemState& mutation,
-                                    std::span<const std::byte, state::kAesKeySize> key,
-                                    std::span<const std::byte, state::kBapNonceSize> nonce,
-                                    std::span<std::byte> response,
-                                    std::size_t& written) noexcept {
+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
@@ -121,8 +89,6 @@ bool append_item_state_notification(Scratch& scratch,
                                  written)) {
         return false;
     }
-    queuez_report::push(
-        "item_state", queuez::kAccountFamilyType, objectCount, written - beforeBytes, 1);
     return true;
 }
 
@@ -140,7 +106,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
@@ -161,8 +126,6 @@ bool append_socket_plug_notification(Scratch& scratch,
                                  written)) {
         return false;
     }
-    queuez_report::push(
-        "socket_plug", queuez::kAccountFamilyType, objectCount, written - beforeBytes, 1);
     return true;
 }
 
@@ -179,7 +142,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
@@ -194,25 +156,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
@@ -238,8 +205,6 @@ bool append_item_acquisition_notification(Scratch& scratch,
                                  written)) {
         return false;
     }
-    queuez_report::push(
-        "acquire", queuez::kAccountFamilyType, objectCount, written - beforeBytes, 1);
     return true;
 }
 
@@ -248,16 +213,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
@@ -283,11 +249,173 @@ 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::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, 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,
@@ -301,7 +429,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
@@ -325,8 +452,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,37 +68,35 @@ 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;
     if (!queuez::valid(before) || !before.family4Active || before.family4RootSoid == 0
         || before.family4Version == (std::numeric_limits<std::int32_t>::max)()) {
         return false;
     }
     snapshot::Prepared prepared{};
-    if (!snapshot::prepare_family4_refresh(
-            scratch, before.family4RootSoid, before.family4Version + 1, prepared)
+    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,
@@ -114,8 +109,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;
 }
 
@@ -199,7 +192,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;
     }
@@ -236,8 +229,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,
@@ -250,11 +241,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

+ 3 - 37
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"
@@ -128,8 +125,7 @@ bool prepare_banner(Scratch& scratch,
     return commit(staged, prepared);
 }
 
-/** Builds one in-place Family-0 character-record upsert from an uncommitted equipment after-image.
- */
+/** Builds a Family-0 character upsert from an uncommitted equipment after-image. */
 bool prepare_character_appearance_refresh(Scratch& scratch,
                                           const queuez::CharacterAppearanceRefresh& refresh,
                                           const state::CharacterState& afterCharacter,
@@ -166,10 +162,7 @@ bool prepare_character_appearance_refresh(Scratch& scratch,
         return report_failure("equip_appearance_resolve");
     }
 
-    // The banner-facing emblem consumers bind through the Family-0 anchor rather than directly
-    // observing the character record.  A normal equipment refresh can upsert the resident record
-    // alone, but an emblem move must touch the unchanged anchor as well so those consumers are
-    // dirtied without releasing either resident key.
+    // Emblem consumers bind through the anchor, so emblem moves must touch it too.
     constexpr std::uint8_t kEmblemEquipmentSlot = 13;
     const bool refreshAnchor = nativeEquipmentSlot == kEmblemEquipmentSlot;
     const std::size_t anchorSize = refreshAnchor ? character_record::kFamily0AnchorSize : 0U;
@@ -209,9 +202,7 @@ bool prepare_character_appearance_refresh(Scratch& scratch,
         clear_after(scratch, reservation);
         return report_failure("equip_appearance_object");
     }
-    // On an incremental refresh the record is already resident.  Publish its new body first and
-    // touch the anchor second, so an anchor-driven banner observer resolves the new emblem rather
-    // than the prior record during the same family update.
+    // Publish the record before the anchor so observers resolve the new emblem.
     if (refreshAnchor
         && !append_object(scratch,
                           anchor,
@@ -234,31 +225,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;
 }
 

+ 661 - 136
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,10 +23,16 @@ 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;
+};
+
 /** Builds a single full account-object upsert from an uncommitted profile-stack after-image. */
 bool prepare_profile_item_acquisition(Scratch& scratch,
                                       const queuez::ProfileItemAcquisition& acquisition,
                                       const state::PendingProfileItemAcquisition& mutation,
+                                      std::optional<std::uint16_t> pendingSeasonReward,
                                       Prepared& prepared) noexcept {
     const Reservation reservation = reserve_prior(scratch, prepared);
     if (reservation.rawWriteOffset > scratch.plaintext.size()
@@ -51,14 +60,12 @@ 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");
     }
 
-    // The native account observer compares profile quantities but only emits pickup feedback when
-    // the changed row's mutation serial also appears in this transient 16-record bank at 0x6978.
-    // Keep the descriptor local to this one incremental upsert; ordinary snapshots encode an empty
-    // bank, while the row's rising mutation serial remains persistent State.
+    // Pickup feedback requires the changed row's mutation serial in the transient bank at 0x6978.
+    // Populate it only for this incremental upsert; ordinary snapshots leave the bank empty.
     constexpr std::uint16_t kAcquisitionChangeSequence = 0;
     constexpr std::uint16_t kAcquisitionChangeNextWriteSlot = 1;
     constexpr std::uint16_t kAcquisitionChangeNextSequence = 1;
@@ -78,14 +85,9 @@ bool prepare_profile_item_acquisition(Scratch& scratch,
         }
         acquiredRow = row;
     }
-    const auto recordIsZero =
-        [](const family4_datagen::account::layout::ProfileInventoryChangeRecord& record) noexcept {
-            return record.sequence == 0 && record.reserved == 0 && record.mutationSerial == 0
-                   && record.kind == 0 && record.reservedKind == 0 && record.flags == 0;
-        };
     const bool recordsAreZero = std::all_of(accountObject.profileInventoryChanges.records.cbegin(),
                                             accountObject.profileInventoryChanges.records.cend(),
-                                            recordIsZero);
+                                            kChangeRecordIsZero);
     if (acquiredRow >= accountObject.profileItems.size()
         || accountObject.profileItems[acquiredRow].quantity != mutation.acquiredQuantity
         || accountObject.profileInventoryChanges.writeSlot != 0
@@ -150,41 +152,642 @@ 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>(kAcquisitionChangeNextWriteSlot),
-        static_cast<unsigned>(kAcquisitionChangeNextSequence),
-        static_cast<unsigned>(kAcquisitionChangeKind),
-        prepared.family.objects[accountObjectIndex].payload.size(),
-        objectCount,
-        acquisition.appendedResident ? "item-account" : "account");
-    if (count > 0) {
-        core::log::write(core::log::Channel::server,
-                         core::log::Level::debug,
-                         {line.data(), static_cast<std::size_t>(count)});
+    return true;
+}
+
+/** Builds 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;
+    constexpr std::uint8_t kChangeKind = 1;
+    constexpr std::uint16_t kChangeFlags = 0;
+
+    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::span<const queuez::AcquisitionPresentationRow> acquisitionPresentationRows,
+    Prepared& prepared) noexcept {
+    namespace account_layout = family4_datagen::account::layout;
+    namespace character_layout = family4_datagen::character::layout;
+    constexpr std::uint8_t kChangeKind = 1;
+    constexpr std::uint16_t kChangeFlags = 0;
+    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,
+                                            &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, std::nullopt, &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()) {
@@ -196,7 +799,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]
@@ -255,10 +859,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;
@@ -276,18 +878,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
@@ -308,10 +905,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 =
@@ -349,8 +951,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()) {
@@ -358,7 +958,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,
@@ -374,10 +974,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);
@@ -393,43 +991,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;
 }
 
@@ -526,17 +1087,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);
@@ -602,36 +1157,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

+ 120 - 146
Sunrise/src/server/bap/encrypted/push/snapshot/family4_selection_move.cpp

@@ -1,12 +1,10 @@
 #include <algorithm>
 #include <array>
 #include <cstddef>
-#include <cstdio>
 #include <limits>
 #include <optional>
 #include <span>
 
-#include "../../../../../core/logging/log.h"
 #include "../../../../../middleware/datagen/family4/account/account_encoder.h"
 #include "../../../../../middleware/datagen/family4/account/layout.h"
 #include "../../../../../middleware/datagen/family4/account/selection_patch/\
@@ -20,85 +18,83 @@ account_selection_patch_encoder.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,
-        object.nextInventorySerial,
-        static_cast<unsigned long long>(mutation.requestedInstanceSoid),
-        requestedRow,
-        static_cast<unsigned long long>(requestedRowSoid),
-        static_cast<unsigned>(requestedEquipped),
-        nativeSlot,
-        static_cast<unsigned long long>(equippedSoid),
-        requestedSerial,
-        static_cast<unsigned long long>(mutation.previousInstanceSoid),
-        previousRow,
-        static_cast<unsigned long long>(previousRowSoid),
-        static_cast<unsigned>(previousEquipped),
-        previousSerial);
-    if (count > 0) {
-        core::log::write(core::log::Channel::server,
-                         core::log::Level::debug,
-                         {line.data(), static_cast<std::size_t>(count)});
+
+    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,16 +119,12 @@ 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,
@@ -164,8 +156,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)
@@ -216,10 +207,12 @@ bool prepare_selection_move(Scratch& scratch,
 }
 
 /** 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,12 +246,37 @@ 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()));
 
+    // 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_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");
+        }
+    }
     Prepared staged{};
     staged.rawClearSize =
         (std::max)(reservation.rawClearSize,
@@ -288,10 +306,12 @@ bool prepare_equipment_swap(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()) {
@@ -340,6 +360,11 @@ 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");
+    }
 
     Prepared staged{};
     staged.rawClearSize =
@@ -484,37 +509,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;
 }
 
@@ -608,26 +602,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()) {
@@ -115,6 +116,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);
@@ -34,7 +36,8 @@ bool prepare_initial(Scratch& scratch,
     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) {
@@ -72,18 +75,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;
     }

+ 64 - 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"
@@ -24,8 +27,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. */
@@ -38,8 +39,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.
@@ -84,11 +89,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.
@@ -106,19 +113,24 @@ 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 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 Family-4 item-instance upsert for one prepared ordinary-socket selection.
@@ -145,10 +157,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.
@@ -159,8 +174,36 @@ 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::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

@@ -25,7 +25,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 {
@@ -45,6 +52,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{};
@@ -165,6 +173,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{};
@@ -196,6 +215,11 @@ struct StagedPublication {
     std::uint64_t bannerRepushRoot{};
     /** 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

+ 146 - 92
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"
@@ -15,37 +13,49 @@
 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) {
-        core::log::write(core::log::Channel::server,
-                         core::log::Level::info,
-                         {line.data(), static_cast<std::size_t>(count)});
-    }
+[[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");
 }
 
-/** Publishes and commits one world reward through the ordinary acquisition notification path. */
-[[nodiscard]] bool consume_world_item_acquisition(Session& session,
+/** 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 {
-    if (!session.worldItemAcquisitionArmed) {
+    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{};
-    const state::PendingItemAcquisition pending = session.pendingWorldItemAcquisition;
     if (!queuez::stage_item_acquisition(session.queuez,
                                         pending.accountSoid,
                                         pending.characterSoid,
@@ -55,6 +65,7 @@ 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=world_acquisition result=fail reason=stage");
+        bap::fail_world_reward_attempt();
         return false;
     }
     auto nextSendNonce = session.sendNonce;
@@ -62,6 +73,8 @@ void report_repush(const char* stage, std::size_t bytes) noexcept {
     if (!push::append_item_acquisition_notification(scratch,
                                                     acquisition,
                                                     pending,
+                                                    std::nullopt,
+                                                    active_acquisition_presentation_rows(session),
                                                     state::bap().sessionKey,
                                                     nextSendNonce,
                                                     scratch.framed,
@@ -70,13 +83,14 @@ 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=world_acquisition result=fail reason=encode");
+        bap::fail_world_reward_attempt();
         return false;
     }
-    if (!state::commit_item_acquisition(session.pendingWorldItemAcquisition)) {
-        session.worldItemAcquisitionArmed = 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());
@@ -84,24 +98,30 @@ void report_repush(const char* stage, std::size_t bytes) noexcept {
     middleware::secure_channel::advance_nonce(nextSendNonce);
     session.sendNonce = nextSendNonce;
     session.queuez = acquisition.after;
-    session.worldItemAcquisitionArmed = false;
-    report_repush("world_acquisition", framedSize);
+    bap::complete_world_reward();
+    bap::arm_account_resync_elsewhere(session);
+    bap::arm_acquisition_presentation_hold(session);
     return true;
 }
 
-/** Publishes and commits one profile material reward through its acquisition notification. */
-[[nodiscard]] bool consume_world_profile_item_acquisition(Session& session,
+/** 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 {
-    if (!session.worldProfileItemAcquisitionArmed) {
+    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{};
-    const state::PendingProfileItemAcquisition pending =
-        session.pendingWorldProfileItemAcquisition;
     if (!queuez::stage_profile_item_acquisition(session.queuez,
                                                 pending.accountSoid,
                                                 pending.acquiredInstanceSoid,
@@ -111,6 +131,7 @@ 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=world_profile_acquisition result=fail reason=stage");
+        bap::fail_world_reward_attempt();
         return false;
     }
     auto nextSendNonce = session.sendNonce;
@@ -118,6 +139,7 @@ void report_repush(const char* stage, std::size_t bytes) noexcept {
     if (!push::append_profile_item_acquisition_notification(scratch,
                                                             acquisition,
                                                             pending,
+                                                            std::nullopt,
                                                             state::bap().sessionKey,
                                                             nextSendNonce,
                                                             scratch.framed,
@@ -126,13 +148,14 @@ 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=world_profile_acquisition result=fail reason=encode");
+        bap::fail_world_reward_attempt();
         return false;
     }
-    if (!state::commit_profile_item_acquisition(session.pendingWorldProfileItemAcquisition)) {
-        session.worldProfileItemAcquisitionArmed = false;
+    if (!state::commit_profile_item_acquisition(pending)) {
         core::log::write(core::log::Channel::server,
                          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());
@@ -140,8 +163,64 @@ void report_repush(const char* stage, std::size_t bytes) noexcept {
     middleware::secure_channel::advance_nonce(nextSendNonce);
     session.sendNonce = nextSendNonce;
     session.queuez = acquisition.after;
-    session.worldProfileItemAcquisitionArmed = false;
-    report_repush("world_profile_acquisition", framedSize);
+    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),
+            state::bap().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;
 }
 
@@ -151,7 +230,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;
@@ -160,6 +239,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),
                                                   state::bap().sessionKey,
                                                   nextSendNonce,
                                                   scratch.framed,
@@ -212,22 +292,11 @@ 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);
     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,
@@ -237,15 +306,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;
@@ -279,21 +346,10 @@ void report_repush(const char* stage, std::size_t bytes) noexcept {
         session.queuez = bannerAfter;
     }
     session.bannerRepushArmed = false;
-    report_repush("banner_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,
@@ -302,8 +358,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;
     }
@@ -349,24 +404,14 @@ 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;
 }
 
 } // 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,
@@ -376,30 +421,40 @@ bool consume_deferred(Session& session,
     if (!session.authenticated) {
         return false;
     }
-    if (consume_world_item_acquisition(session, scratch, response, written, touchesScratch)) {
+    if (consume_account_resync(session, scratch, response, written, touchesScratch)) {
         return true;
     }
-    if (session.worldItemAcquisitionArmed) {
+    // A failed resync blocks every incremental that could depend on its missing objects.
+    if (session.accountResyncArmed) {
         return false;
     }
-    if (consume_world_profile_item_acquisition(session, scratch, response, written, touchesScratch)) {
-        return true;
-    }
-    if (session.worldProfileItemAcquisitionArmed) {
-        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_account_resync(session, scratch, response, written, touchesScratch)) {
+    if (consume_seasonal_experience_presentation(
+            session, scratch, response, written, touchesScratch)) {
         return true;
     }
-    // A failed resync remains armed and blocks unrelated deferred output until it can be retried.
-    if (session.accountResyncArmed) {
-        return false;
-    }
     if (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_banner_repush(session, scratch, response, written, touchesScratch)
                || push::activity::consume_activity_keepalive(
                    session, scratch, response, written, touchesScratch);
@@ -443,7 +498,6 @@ bool consume_deferred(Session& session,
         session.queuez = after;
     }
     session.family4RepushArmed = false;
-    report_repush("repush", framedSize);
     return true;
 }
 

+ 280 - 105
Sunrise/src/server/bap/encrypted/queuez/queuez_outcome_staging.cpp

@@ -1,29 +1,87 @@
 #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 "queuez_state_validation.h"
 
 namespace sunrise::server::bap::encrypted::queuez {
 namespace {
 
-/**
- * Stages the character upsert and appended resident an item-acquisition transaction promised.
- * Shared by the ordinary opcode-1820 transaction and a record-claim reward that resolved to a
- * character-bucket grant: both stage the exact same way once an `ItemAcquisitionTransaction` is
- * in hand.
- */
-[[nodiscard]] bool stage_item_acquisition_push(Scratch& scratch,
-                                               const SessionState& before,
-                                               const ItemAcquisitionTransaction& itemAcquisition,
-                                               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 ItemAcquisition& acquisition = itemAcquisition.update;
+/** 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) {
@@ -33,10 +91,10 @@ namespace {
                                    == 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 != 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()
@@ -46,8 +104,15 @@ namespace {
                != acquisition.acquiredInstanceSoid
         || acquisition.after.family4Residents[appendedIndex].definitionId
                != acquisition.itemInstanceDefinitionId
-        || !push::append_item_acquisition_notification(
-            scratch, acquisition, itemAcquisition.pending, key, nonce, response, written)) {
+        || !push::append_item_acquisition_notification(scratch,
+                                                       acquisition,
+                                                       pending,
+                                                       pendingSeasonReward,
+                                                       presentationRows,
+                                                       key,
+                                                       nonce,
+                                                       response,
+                                                       written)) {
         return false;
     }
     middleware::secure_channel::advance_nonce(nonce);
@@ -55,21 +120,18 @@ namespace {
     return true;
 }
 
-/**
- * Stages the account upsert (and possible manifest append) a profile-acquisition transaction
- * promised. Shared by the ordinary opcode-1820 transaction and a record-claim reward that
- * resolved to a profile-bucket grant.
- */
+/** 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 ProfileItemAcquisitionTransaction& profileAcquisition,
+                                    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 ProfileItemAcquisition& acquisition = profileAcquisition.update;
     const std::size_t priorResidentCount = before.family4ResidentCount;
     const std::size_t expectedResidentCount =
         priorResidentCount + static_cast<std::size_t>(acquisition.appendedResident);
@@ -105,18 +167,17 @@ stage_profile_item_acquisition_push(Scratch& scratch,
                 : 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 != 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, profileAcquisition.pending, key, nonce, response, written)) {
+            scratch, acquisition, pending, pendingSeasonReward, key, nonce, response, written)) {
         return false;
     }
     middleware::secure_channel::advance_nonce(nonce);
@@ -130,6 +191,8 @@ stage_profile_item_acquisition_push(Scratch& scratch,
 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,
@@ -141,6 +204,8 @@ bool stage_service_outcome(Scratch& scratch,
     bool armsBannerRepush = false;
     std::uint64_t bannerRoot = 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);
@@ -149,6 +214,10 @@ bool stage_service_outcome(Scratch& scratch,
     const auto* profileAcquisition = transaction_if<ProfileItemAcquisitionTransaction>(outcome);
     const auto* itemDismantle = transaction_if<ItemDismantleTransaction>(outcome);
     const auto* recordRewardGrant = transaction_if<RecordRewardGrantTransaction>(outcome);
+    const auto* seasonPassReward = transaction_if<SeasonPassRewardTransaction>(outcome);
+    const auto presentationRows = preserveAcquisitionPresentation
+                                      ? acquisitionPresentationRows
+                                      : std::span<const AcquisitionPresentationRow>{};
     if (outcome.hasSubscription) {
         push::append_queuez_notification(scratch,
                                          before,
@@ -166,26 +235,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 presentationRows =
+            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, presentationRows, key, nonce, response, written)) {
             core::log::write(core::log::Channel::server,
                              core::log::Level::warn,
                              "ev=queuez stage=equip result=fail");
@@ -194,10 +285,17 @@ bool stage_service_outcome(Scratch& scratch,
         }
         middleware::secure_channel::advance_nonce(nonce);
         after = swap.after;
+        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 (equipment->pending.equipmentSlotIndex
+        if (pending.equipmentSlotIndex
             == static_cast<std::size_t>(state::account::inventory::EquipmentSlot::subclass)) {
             armsAbilityRefresh = true;
         }
@@ -206,10 +304,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");
@@ -224,10 +321,9 @@ bool stage_service_outcome(Scratch& scratch,
         // 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");
@@ -238,6 +334,10 @@ bool stage_service_outcome(Scratch& scratch,
     } 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;
@@ -248,12 +348,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");
@@ -265,6 +365,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;
@@ -280,14 +384,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");
@@ -303,10 +407,9 @@ bool stage_service_outcome(Scratch& scratch,
         // always owed a refresh once one is active.
         if (after.family0Active) {
             CharacterAppearanceRefresh refresh{};
-            if (!stage_character_appearance_refresh(
-                    after, subclassSelection->pending.characterSoid, refresh)
+            if (!stage_character_appearance_refresh(after, pending.characterSoid, refresh)
                 || !push::append_subclass_appearance_refresh_notification(
-                    scratch, refresh, subclassSelection->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=subclass_appearance result=fail");
@@ -316,10 +419,9 @@ bool stage_service_outcome(Scratch& scratch,
         }
         if (after.family3Active) {
             RosterAppearanceRefresh refresh{};
-            if (!stage_roster_appearance_refresh(
-                    after, subclassSelection->pending.characterSoid, false, refresh)
+            if (!stage_roster_appearance_refresh(after, pending.characterSoid, false, refresh)
                 || !push::append_subclass_roster_refresh_notification(
-                    scratch, refresh, subclassSelection->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=subclass_roster result=fail");
@@ -330,6 +432,10 @@ bool stage_service_outcome(Scratch& scratch,
     } 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;
@@ -349,15 +455,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");
@@ -367,11 +473,11 @@ bool stage_service_outcome(Scratch& scratch,
         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) {
+        if (pending.targetEquipped && after.family0Active) {
             CharacterAppearanceRefresh refresh{};
-            if (!stage_character_appearance_refresh(after, socket->pending.characterSoid, refresh)
+            if (!stage_character_appearance_refresh(after, pending.characterSoid, refresh)
                 || !push::append_socket_appearance_refresh_notification(
-                    scratch, refresh, socket->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=socket_appearance result=fail");
@@ -381,12 +487,11 @@ bool stage_service_outcome(Scratch& scratch,
         }
         // 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) {
+        if (pending.targetEquipped && after.family3Active) {
             RosterAppearanceRefresh refresh{};
-            if (!stage_roster_appearance_refresh(
-                    after, socket->pending.characterSoid, false, refresh)
+            if (!stage_roster_appearance_refresh(after, pending.characterSoid, false, refresh)
                 || !push::append_socket_roster_refresh_notification(
-                    scratch, refresh, socket->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=socket_roster result=fail");
@@ -397,8 +502,18 @@ bool stage_service_outcome(Scratch& scratch,
     } 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.
-        if (!stage_item_acquisition_push(
-                scratch, before, *itemAcquisition, key, nonce, response, written, after)) {
+        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");
@@ -407,50 +522,105 @@ bool stage_service_outcome(Scratch& scratch,
     } 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.
-        if (!stage_profile_item_acquisition_push(
-                scratch, before, *profileAcquisition, key, nonce, response, written, after)) {
+        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) {
-        // A record-claim reward names an item directly rather than a Collections row; whichever
-        // acquisition kind its bucket resolved to stages exactly the same way its opcode-1820
-        // counterpart does above, so this takes priority over the plain resync fallback below.
+        if (recordRewardGrant->pending == nullptr) {
+            return false;
+        }
+        if (!push::append_record_reward_notification(scratch,
+                                                     before,
+                                                     recordRewardGrant->update,
+                                                     *recordRewardGrant->pending,
+                                                     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 = 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>(&recordRewardGrant->update)) {
-            ItemAcquisitionTransaction wrapped{};
-            if (const auto* itemPending = std::get_if<state::PendingItemAcquisition>(
-                    &recordRewardGrant->pending.grant)) {
-                wrapped.pending = *itemPending;
+        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);
             }
-            wrapped.update = *itemUpdate;
-            staged = stage_item_acquisition_push(
-                scratch, before, wrapped, key, nonce, response, written, after);
         } else if (const auto* profileUpdate =
-                       std::get_if<ProfileItemAcquisition>(&recordRewardGrant->update)) {
-            ProfileItemAcquisitionTransaction wrapped{};
-            if (const auto* profilePending = std::get_if<state::PendingProfileItemAcquisition>(
-                    &recordRewardGrant->pending.grant)) {
-                wrapped.pending = *profilePending;
+                       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);
             }
-            wrapped.update = *profileUpdate;
-            staged = stage_profile_item_acquisition_push(
-                scratch, before, wrapped, key, nonce, response, written, after);
         }
         if (!staged) {
             core::log::write(core::log::Channel::server,
                              core::log::Level::warn,
-                             "ev=queuez stage=record_reward result=fail");
+                             "ev=ws2400 stage=queuez_reward result=fail");
             return false;
         }
     } 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, key, nonce, response, written, after)) {
+                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");
@@ -460,6 +630,10 @@ bool stage_service_outcome(Scratch& scratch,
         // 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
@@ -487,17 +661,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");
@@ -508,22 +682,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,

+ 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"
@@ -157,6 +158,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,
@@ -176,6 +185,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.
  *

+ 280 - 161
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"
@@ -15,7 +17,8 @@ bool stage_change_character(const SessionState& before, ChangeCharacter& change)
     if (!valid(before) || !before.family4Active || !before.family3Active
         || before.family4RootSoid == 0 || before.family4ResidentCount == 0
         || before.family4ResidentCount > before.family4Residents.size()
-        || before.family3Phase != Family3Phase::normal) {
+        || before.family3Phase != Family3Phase::normal
+        || before.family4Version == (std::numeric_limits<std::int32_t>::max)()) {
         return false;
     }
     const ResidentObject& account = before.family4Residents.front();
@@ -39,6 +42,7 @@ bool stage_select_character(const SessionState& before,
     if (!valid(before) || !before.family4Active || selectedCharacterSoid == 0
         || before.family4RootSoid == 0 || selectedCharacterSoid == before.family4RootSoid
         || before.family4ResidentCount == 0
+        || before.family4Version == (std::numeric_limits<std::int32_t>::max)()
         || !middleware::datagen::object_id(
             kAccountFamilyType, middleware::datagen::kAccountSlot, accountDefinitionId)
         || !middleware::datagen::object_id(
@@ -133,23 +137,24 @@ bool stage_equipment_swap(const SessionState& before,
     swap.characterDefinitionId = characterDefinitionId;
     swap.characterSoid = characterSoid;
     const bool staged = valid(swap.after);
-    std::array<char, core::log::kLineCapacity> line{};
-    const int count = std::snprintf(
-        line.data(),
-        line.size(),
-        "ev=equip stage=queuez_version result=%s root=0x%llX before=%d after=%d residents=%u "
-        "character=0x%llX definition=%u",
-        staged ? "ok" : "fail",
-        static_cast<unsigned long long>(before.family4RootSoid),
-        before.family4Version,
-        swap.after.family4Version,
-        static_cast<unsigned>(before.family4ResidentCount),
-        static_cast<unsigned long long>(characterSoid),
-        characterDefinitionId);
-    if (count > 0) {
-        core::log::write(core::log::Channel::server,
-                         staged ? core::log::Level::debug : core::log::Level::warn,
-                         {line.data(), static_cast<std::size_t>(count)});
+    if (!staged) {
+        std::array<char, core::log::kLineCapacity> line{};
+        const int count = std::snprintf(
+            line.data(),
+            line.size(),
+            "ev=equip stage=queuez_version result=fail root=0x%llX before=%d after=%d "
+            "residents=%u character=0x%llX definition=%u",
+            static_cast<unsigned long long>(before.family4RootSoid),
+            before.family4Version,
+            swap.after.family4Version,
+            static_cast<unsigned>(before.family4ResidentCount),
+            static_cast<unsigned long long>(characterSoid),
+            characterDefinitionId);
+        if (count > 0) {
+            core::log::write(core::log::Channel::server,
+                             core::log::Level::warn,
+                             {line.data(), static_cast<std::size_t>(count)});
+        }
     }
     return staged;
 }
@@ -168,21 +173,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;
 }
@@ -203,22 +209,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;
 }
@@ -274,27 +281,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;
 }
@@ -347,24 +355,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;
 }
@@ -425,33 +434,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,
@@ -517,30 +635,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;
 }
@@ -611,32 +730,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

@@ -134,6 +134,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;

+ 58 - 122
Sunrise/src/server/bap/encrypted/routing/bap_service_routing.cpp

@@ -3,164 +3,100 @@
 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::matchmaking:
-        route = {ResponseMode::reply,
-                 middleware::bap::ResponseService::matchmaking,
-                 BodyCodec::matchmakingResponse,
-                 "ev=bap svc=42 rsp=43 result=ok"};
+    case Request::matchmaking:
+        route = {ResponseMode::reply, Response::matchmaking, BodyCodec::matchmakingResponse};
         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;
     }
 }

+ 68 - 66
Sunrise/src/server/bap/encrypted/transactions/service_outcome_commit.cpp

@@ -83,6 +83,9 @@ void discard_activity_publication(Publication& publication) noexcept {
  * @param committed Whether the commit succeeded.
  */
 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);
@@ -91,9 +94,8 @@ void report_lease(const slots::PendingMutation& mutation, bool committed) noexce
     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 known=%u",
-                      committed ? "ok" : "fail",
                       kind < kLeaseKinds.size() ? kLeaseKinds[kind] : "bad",
                       static_cast<unsigned long long>(mutation.sessionId),
                       mutation.requestedCount,
@@ -103,11 +105,18 @@ void report_lease(const slots::PendingMutation& mutation, bool committed) noexce
                       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]] 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;
+}
+
 } // namespace
 
 /**
@@ -182,92 +191,85 @@ bool commit(ServiceOutcome& outcome, Publication& publication) noexcept {
         return state::matchmaking::commit(*mutation);
     }
     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);
-        core::log::write(core::log::Channel::server,
-                         committed ? core::log::Level::debug : core::log::Level::warn,
-                         committed ? "ev=equip stage=transaction_commit result=ok"
-                                   : "ev=equip stage=transaction_commit result=fail");
-        if (committed && isSubclassSlot) {
+        if (!report_commit(state::commit_equipment_swap(pending),
+                           "ev=equip stage=transaction_commit result=fail")) {
+            return false;
+        }
+        if (isSubclassSlot) {
             // The equipped subclass just changed, which makes the published ability buckets
             // stale the same way an ability-entry pick does. Wake the investment worker so the
             // 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 (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 (transaction->pending == nullptr) {
+            return false;
         }
-        return committed;
+        if (!report_commit(state::commit_subclass_selection(*transaction->pending),
+                           "ev=subclass_select stage=transaction_commit result=fail")) {
+            return false;
+        }
+        // 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);
-        core::log::write(core::log::Channel::server,
-                         committed ? core::log::Level::debug : core::log::Level::warn,
-                         committed ? "ev=acquire stage=transaction_commit result=ok"
-                                   : "ev=acquire stage=transaction_commit result=fail");
-        return committed;
+        if (transaction->pending == nullptr) {
+            return false;
+        }
+        return report_commit(state::commit_item_acquisition(*transaction->pending),
+                             "ev=acquire stage=transaction_commit result=fail");
     }
     if (auto* transaction = transaction_if<SocketPlugTransaction>(outcome)) {
-        const bool committed = 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"
-                                   : "ev=socket_plug stage=transaction_commit result=fail");
-        return committed;
+        if (transaction->pending == nullptr) {
+            return false;
+        }
+        return report_commit(state::commit_socket_plug(*transaction->pending),
+                             "ev=socket_plug stage=transaction_commit result=fail");
     }
     if (auto* transaction = transaction_if<ItemStateTransaction>(outcome)) {
-        const bool committed = 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"
-                                   : "ev=item_state stage=transaction_commit result=fail");
-        return committed;
+        if (transaction->pending == nullptr) {
+            return false;
+        }
+        return report_commit(state::commit_item_state(*transaction->pending),
+                             "ev=item_state stage=transaction_commit result=fail");
     }
     if (auto* transaction = transaction_if<ProfileItemAcquisitionTransaction>(outcome)) {
-        const bool committed = 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"
-                                   : "ev=profile_acquire stage=transaction_commit result=fail");
-        return committed;
+        if (transaction->pending == nullptr) {
+            return false;
+        }
+        return report_commit(state::commit_profile_item_acquisition(*transaction->pending),
+                             "ev=profile_acquire stage=transaction_commit result=fail");
     }
     if (auto* transaction = transaction_if<ItemDismantleTransaction>(outcome)) {
-        const bool committed = 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"
-                                   : "ev=dismantle stage=transaction_commit result=fail");
-        return committed;
+        if (transaction->pending == nullptr) {
+            return false;
+        }
+        return report_commit(state::commit_item_dismantle(*transaction->pending),
+                             "ev=dismantle stage=transaction_commit result=fail");
+    }
+    if (auto* transaction = transaction_if<SeasonPassRewardTransaction>(outcome)) {
+        if (transaction->pending == nullptr) {
+            return false;
+        }
+        return report_commit(state::commit_season_pass_reward(*transaction->pending),
+                             "ev=ws2400 stage=transaction_commit result=fail");
     }
     if (auto* transaction = transaction_if<RecordRewardGrantTransaction>(outcome)) {
-        // Whichever acquisition kind the reward resolved to commits through the exact same path
-        // its opcode-1820 counterpart uses.
-        bool committed = false;
-        if (auto* itemPending =
-                std::get_if<state::PendingItemAcquisition>(&transaction->pending.grant)) {
-            committed = state::commit_item_acquisition(*itemPending);
-        } else if (auto* profilePending = std::get_if<state::PendingProfileItemAcquisition>(
-                       &transaction->pending.grant)) {
-            committed = state::commit_profile_item_acquisition(*profilePending);
+        if (transaction->pending == nullptr) {
+            return false;
         }
-        core::log::write(core::log::Channel::server,
-                         committed ? core::log::Level::debug : core::log::Level::warn,
-                         committed ? "ev=record_reward stage=transaction_commit result=ok"
-                                   : "ev=record_reward stage=transaction_commit result=fail");
-        return committed;
+        return report_commit(state::commit_record_reward(*transaction->pending),
+                             "ev=record_reward stage=transaction_commit result=fail");
     }
     return true;
 }

+ 69 - 85
Sunrise/src/server/bap/internal.h

@@ -97,114 +97,98 @@ struct AdvertisementPublication {
     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 {
-    std::uint32_t id{};
-    bool authenticated{};
-    std::array<std::byte, state::kBapNonceSize> sendNonce{};
-    std::array<std::byte, state::kBapNonceSize> receiveNonce{};
-    /** Opaque State handle taken only after the server hello authenticates. */
-    state::matchmaking::ContextHandle matchmakingContext{};
-    /** Exact private or public ActivityClient generation owned by this connection. */
-    ActivityClientBinding activity{};
-    /** Tick count after which the activity link owes its next keepalive write. */
+    // Eight-byte scalars first avoid padding in every connection slot.
     std::uint64_t activityKeepaliveDueTick{};
-    /** Client member key from the join request. It seeds the membership id. */
     std::uint64_t activityMemberKey{};
-    /**
-     * Character the join request named, or zero when it carried none.
-     * The roster's participation key must be the character the client signed in on. The client
-     * binds its player by matching that value.
-     */
     std::uint64_t activityCharacterSoid{};
-    /** Tick count after which the activity link owes its next roster update. */
     std::uint64_t activityRosterDueTick{};
-    /**
-     * Binding generation whose membership body this link has already delivered.
-     * The client sets its membership flag once and never clears it, and never acknowledges a body
-     * on a public-target link, so this is a one-shot per binding. Latched on delivery, not encode.
-     */
     std::uint64_t activityMembershipSentGeneration{};
-    /**
-     * Tick count until which the client is loading, so the roster runs at its faster cadence.
-     * A join and a transition-token change are the only two things that open it.
-     */
     std::uint64_t activityTransitionUntilTick{};
-    /** The client's own patch epoch, scoped to the binding that received message 52. */
+    std::uint64_t activityAdvertisementHostGeneration{};
+    std::uint64_t family4RepushDueTick{};
+    std::uint64_t family4RepushRoot{};
+    std::uint64_t bannerRepushDueTick{};
+    std::uint64_t bannerRepushRoot{};
+    std::uint64_t acquisitionPresentationUntilTick{};
+    std::uint64_t abilityRefreshDueTick{};
+
+    state::matchmaking::ContextHandle matchmakingContext{};
+    AdvertisementPublication activityAdvertisementStaged{};
     BoundPatchEpoch activityPatchEpoch{};
-    /** Group set the last roster update published, folded into one comparable value. */
+    std::array<encrypted::queuez::AcquisitionPresentationRow,
+               encrypted::queuez::kAcquisitionPresentationRowCapacity>
+        acquisitionPresentationRows{};
+    ActivityClientBinding activity{};
+    encrypted::queuez::SessionState queuez{};
+
+    std::uint32_t id{};
     std::uint32_t activityRosterGroups{};
-    /** Roster updates sent on this connection, capped once the warm-up bumps are spent. */
+    std::int32_t pendingSeasonalExperienceAmount{};
+    std::uint32_t pendingSeasonalExperienceMutationSerial{};
+    std::uint8_t pendingSeasonalExperienceFailures{};
+
+    bool authenticated{};
     std::uint8_t activityRosterSends{};
-    /** Per-entry state byte the last roster update carried. */
     std::uint8_t activityRosterState{};
-    /** Host row retained by the last delivered citizen advertisement. */
-    std::uint64_t activityAdvertisementHostGeneration{};
-    /** Host row retained by a staged membership body until publication is known. */
-    AdvertisementPublication activityAdvertisementStaged{};
-    /**
-     * Reason code of the last logged roster outcome.
-     * The push runs every second, so a refusal is logged only when the reason changes. One flag
-     * for every reason hides the second failure behind the first.
-     */
     std::uint8_t activityRosterReason{};
-    /** What one staged roster body owes, and what to put back if it never reaches the caller. */
-    RosterPublication activityRosterStaged{};
-    /** Queuez versions and residents published only through this authenticated peer. */
-    encrypted::queuez::SessionState queuez{};
-    /** Tick count after which the owed Family-4 re-push may go out. */
-    std::uint64_t family4RepushDueTick{};
-    /** Root the owed re-push must use. */
-    std::uint64_t family4RepushRoot{};
-    /** True while one Family-4 re-push is still owed to this peer. */
     bool family4RepushArmed{};
-    /** Tick count after which the owed banner re-push may go out. */
-    std::uint64_t bannerRepushDueTick{};
-    /** Root the owed banner re-push must use. */
-    std::uint64_t bannerRepushRoot{};
-    /** True while one banner re-push is still owed to this peer. */
     bool bannerRepushArmed{};
-    /** Latest shared-account generation this peer has received. */
-    std::uint64_t accountGeneration{};
-    /** Newest shared-account generation owed as a full cross-peer refresh. */
-    std::uint64_t accountResyncGeneration{};
-    /** Set by encrypted processing only after one account mutation commits and is copied out. */
     bool accountMutationPublished{};
-    /** True while another peer's account mutation still needs a full local refresh. */
     bool accountResyncArmed{};
-    /** Direct world reward waiting for its ordinary item-acquisition notification and commit. */
-    state::PendingItemAcquisition pendingWorldItemAcquisition{};
-    bool worldItemAcquisitionArmed{};
-    /** Direct world material reward waiting for its profile-acquisition notification and commit. */
-    state::PendingProfileItemAcquisition pendingWorldProfileItemAcquisition{};
-    bool worldProfileItemAcquisitionArmed{};
-    /**
-     * Tick count after which the owed ability-icon refresh may go out. A subclass selection
-     * invalidates the published ability buckets and the rebuild runs off the Client
-     * content-extraction pump, so the inline refresh can carry empty ones; this one re-derives.
-     */
-    std::uint64_t abilityRefreshDueTick{};
-    /** True while one ability-icon refresh is still owed to this peer. */
+    std::uint8_t acquisitionPresentationRowCount{};
     bool abilityRefreshArmed{};
+    std::array<std::byte, state::kBapNonceSize> sendNonce{};
+    std::array<std::byte, state::kBapNonceSize> receiveNonce{};
+
+    /** What one staged roster body owes if publication succeeds or fails. */
+    RosterPublication activityRosterStaged{};
 };
 
-/**
- * Arms every active peer to re-read the account, including the one that caused the change.
- *
- * `publish_account_mutation` deliberately skips the origin, because a web service transaction
- * carries the new account back in its own response. A change made outside such a transaction has no
- * response to carry, so the peer that caused it would otherwise keep showing stale state until some
- * unrelated action happened to stage an image. Picking up a collectible is such a change.
- */
+/** 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;
 
-/** Queues one prepared world reward on an active Family-4 peer for normal acquisition feedback. */
-[[nodiscard]] bool
-arm_world_item_acquisition(state::PendingItemAcquisition acquisition) 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 prepared profile material reward with ordinary acquisition feedback. */
-[[nodiscard]] bool arm_world_profile_item_acquisition(
-    state::PendingProfileItemAcquisition acquisition) 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;
 
 namespace plaintext {
 

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

@@ -140,7 +140,6 @@ void arm_encryption(Session& session, const state::BapState& bap) noexcept {
     encrypted::ServiceRoute route;
     (void)encrypted::routing::resolve(request.messageId, 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{};
@@ -165,12 +164,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.messageId, "encode");
         return false;
     }
-    core::log::write(core::log::Channel::server, core::log::Level::info, route.successEvent);
     return true;
 }
 
@@ -205,11 +203,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.messageId
@@ -264,8 +257,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;
 }
 

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 306 - 640
Sunrise/src/server/web_service/web_service_actions.cpp


+ 5 - 11
Sunrise/src/server/web_service/web_service_actions.h

@@ -20,19 +20,13 @@ void mutate_item_state(const middleware::web_service::Message& message, Outcome&
 void dismantle_item(const middleware::web_service::Message& message, Outcome& outcome) noexcept;
 void acquire_item(const middleware::web_service::Message& message, Outcome& outcome) noexcept;
 
-/**
- * Decodes one opcode-1801 Triumphs claim request and reports the record it names.
- *
- * The request carries a record row index and nothing else. No state transition is prepared yet:
- * the state separating a claimed record from a merely completed one is not identified, since every
- * record completion flag can be set while the client still offers the claim. This is the seam that
- * transition attaches to once that state is known.
- *
- * @param message Parsed Web Service envelope.
- * @param outcome Left untouched, so the shared reply path still answers the claim with success.
- */
+/** 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;
 

+ 31 - 69
Sunrise/src/server/web_service/web_service_runtime.cpp

@@ -4,18 +4,14 @@
 #include <array>
 #include <chrono>
 #include <cstdio>
-#include <cstring>
-#include <limits>
-#include <string_view>
 
 #include "../../core/logging/log.h"
-#include "../../middleware/encoding/bit_reader.h"
-#include "../../middleware/encoding/byte_order.h"
-#include "../../middleware/web_service/messages/opcode1901.h"
-#include "../../middleware/web_service/messages/opcode205.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/opcode2400.h"
 #include "../../middleware/web_service/messages/opcode501_codec.h"
 #include "../../middleware/web_service/messages/opcode503.h"
 #include "../../middleware/web_service/messages/opcode504.h"
@@ -25,19 +21,12 @@
 #include "../../middleware/web_service/messages/opcode903.h"
 #include "../../middleware/web_service/web_service_envelope.h"
 #include "../../state/account/account_state.h"
-#include "../../state/build_data/runtime.h"
 #include "../../state/runtime/runtime.h"
 #include "opcode_routes.h"
 #include "web_service_actions.h"
 
 namespace sunrise::server::web_service {
 
-/** 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";
 /** 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. */
@@ -48,8 +37,6 @@ constexpr std::uint16_t kItemStateOpcode = 406;
 constexpr std::uint16_t kItemDismantleOpcode = 402;
 /** Web Service opcode used by Collections to create one item instance. */
 constexpr std::uint16_t kItemAcquisitionOpcode = 1820;
-/** The mutation variant's first alternative is the empty one, so index zero prepared nothing. */
-constexpr std::size_t kNoMutation = 0;
 /**
  * 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
@@ -57,39 +44,12 @@ constexpr std::size_t kNoMutation = 0;
  */
 constexpr std::int32_t kRefusedStatus = 1;
 
-/**
- * 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.
- */
-void report_request(const middleware::web_service::Message& message) noexcept {
-    std::array<char, core::log::kLineCapacity> line{};
-    const int prefix =
-        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();
-    }
-    if (length != 0) {
-        core::log::write(core::log::Channel::server, core::log::Level::info, {line.data(), length});
-    }
-}
+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;
 /**
  * Status code answered to a purchase request.
  * Any non-zero value refuses. Zero is the success code, so it must not be used here.
@@ -165,7 +125,7 @@ constexpr std::int32_t kPurchaseRefusedCode = 1;
 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);
     if (count > 0) {
@@ -178,18 +138,25 @@ bool encode_echo(const middleware::web_service::Message& message,
         message, ws::ResponseShape::generic, ws::StatusResponse{}, response, written);
 }
 
-/**
- * Parses and answers one Web Service request with its whole descriptor layout.
- * @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.
- * @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) noexcept {
-    Outcome outcome;
-    return consume(request, response, written, outcome);
+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;
+    }
+    refused = true;
+    middleware::web_service::ResponseShape shape{};
+    resolve_response_shape(message.opcode, shape);
+    middleware::web_service::StatusResponse status{};
+    status.code = kRefusedStatus;
+    return middleware::web_service::encode_response(message, shape, status, response, written)
+           || encode_echo(message, response, written);
 }
 
 /**
@@ -213,8 +180,6 @@ 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);
-
     if (message.opcode == middleware::web_service::messages::opcode205::kOpcode) {
         const auto investment = state::investment_snapshot();
         return middleware::web_service::messages::opcode205::encode_response(
@@ -275,15 +240,10 @@ bool consume(std::span<const std::byte> request,
     // The action runs before its reply is encoded, because the reply reports whether it worked.
     // An action fills the outcome only once it has prepared its whole transition, so an outcome
     // still empty afterwards is that action refusing the request. Nothing is published here.
-    // Decoded and reported outside the dispatch chain on purpose: a claim prepares no mutation
-    // yet, and any opcode inside the chain that prepares nothing is answered with the refusal
-    // status. Reporting here keeps the claim's existing successful reply intact.
+    bool dispatched = true;
     if (message.opcode == middleware::web_service::messages::opcode1801::kOpcode) {
         claim_record(message, outcome);
-    }
-
-    bool dispatched = true;
-    if (message.opcode == middleware::web_service::messages::opcode504::kOpcode) {
+    } else if (message.opcode == middleware::web_service::messages::opcode504::kOpcode) {
         select_character(message, outcome);
     } else if (message.opcode == kItemDismantleOpcode) {
         dismantle_item(message, outcome);
@@ -303,11 +263,13 @@ bool consume(std::span<const std::byte> request,
         mutate_item_state(message, outcome);
     } 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 {
         dispatched = false;
     }
     const bool prepared = outcome.hasSelectedCharacter || outcome.hasTitleEquip
-                          || outcome.mutation.index() != kNoMutation;
+                          || outcome.hasRecordClaim || has_mutation(outcome);
 
     middleware::web_service::ResponseShape shape{};
     resolve_response_shape(message.opcode, shape);

+ 61 - 56
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"
@@ -20,74 +24,69 @@ struct Outcome {
     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. */
+    /** 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::PendingRecordRewardGrant>;
+                                  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::PendingRecordRewardGrant>,
+                                  std::unique_ptr<state::PendingSeasonPassReward>>;
     Mutation mutation{};
 };
 
+/** 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;
-
-/** 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;
-
-/** 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,
-                                              std::int32_t family4Version,
-                                              std::uint32_t definitionHash,
-                                              std::int32_t quantity,
-                                              std::span<const std::byte> response) noexcept;
-
-/** Records a dismantle reply after its exact Family-4 version and removed instance are known. */
-void report_item_dismantle_response(const middleware::web_service::Message& message,
-                                    std::int32_t family4Version,
-                                    std::uint64_t dismantledInstanceSoid,
-                                    std::span<const std::byte> response) noexcept;
-
-/** Records a socket-action reply after its exact item-instance Family-4 revision is known. */
-void report_socket_plug_response(const middleware::web_service::Message& message,
-                                 std::int32_t family4Version,
-                                 std::uint64_t targetInstanceSoid,
-                                 std::uint8_t socketLane,
-                                 std::uint16_t plugDefinitionIndex,
-                                 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;
+}
 
-/** Records an opcode-801 reply after its exact subclass item-instance revision is known. */
-void report_subclass_selection_response(const middleware::web_service::Message& message,
-                                        std::int32_t family4Version,
-                                        const state::PendingSubclassSelection& mutation,
-                                        std::span<const std::byte> response) noexcept;
+[[nodiscard]] inline bool has_mutation(const Outcome& outcome) noexcept {
+    return outcome.mutation.index() != 0;
+}
 
-/**
- * Answers one whole supported Web Service request body.
- * @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.
- * @return False only when the envelope header does not parse.
- */
-[[nodiscard]] bool consume(std::span<const std::byte> request,
-                           std::span<std::byte> response,
-                           std::size_t& written) noexcept;
+inline void clear_mutation(Outcome& outcome) noexcept {
+    outcome.mutation.template emplace<std::monostate>();
+}
 
 /**
  * Answers one request and reports any subscription side effect.
@@ -103,4 +102,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

+ 19 - 39
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,27 +49,21 @@ 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);
 }
 
-/** @return True when one unused record-reward policy row is canonical zero. */
 [[nodiscard]] bool empty_record_reward(const RecordRewardPolicy& reward) noexcept {
     return reward.recordIndex == 0 && reward.itemIndex == 0 && reward.quantity == 0;
 }
 
-/** Checks filled record-reward rows, uniqueness by record, and the zero tail. */
 [[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.recordRewards.size(); ++index) {
+    for (std::size_t index = 0; index < state.recordRewardCount; ++index) {
         const RecordRewardPolicy& reward = state.recordRewards[index];
-        if (index >= state.recordRewardCount) {
-            if (!empty_record_reward(reward)) {
-                return false;
-            }
-            continue;
-        }
         if (reward.quantity <= 0) {
             return false;
         }
@@ -85,20 +73,18 @@ constexpr std::uint8_t kDismantleClassMaskBits =
             }
         }
     }
-    return true;
+    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;
 }
@@ -133,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
@@ -148,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) {
@@ -157,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;
@@ -175,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
@@ -223,13 +210,6 @@ std::uint64_t banner_character_soid(const AccountState& state) noexcept {
     return state.characterCount == 0 ? 0 : state.characters[0].soid;
 }
 
-/**
- * Finds the configured reward for one claimed record, if any.
- * @param state Account snapshot carrying the authored reward table.
- * @param recordIndex Native record row the claim named.
- * @param reward Receives the matching row only on success.
- * @return True when the table carries a row for this record.
- */
 bool find_record_reward(const AccountState& state,
                         std::uint16_t recordIndex,
                         RecordRewardPolicy& reward) noexcept {

+ 7 - 25
Sunrise/src/state/account/account_state.h

@@ -13,7 +13,7 @@ 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;
-/** A server-authored record-reward policy: one row per rewarded Triumph. */
+/** 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;
@@ -53,25 +53,15 @@ same_dismantle_policy_key(const DismantleRewardPolicy& left,
            && left.classMask == right.classMask && left.masterwork == right.masterwork;
 }
 
-/**
- * One item granted alongside a record's completion flag when its claim lands.
- * The map source is deliberately settings-authored for now, kept pluggable behind
- * `account::find_record_reward` so a later task can source it from extracted build data instead.
- */
+/** Settings override for one Triumph claim reward. */
 struct RecordRewardPolicy {
-    /** Native record row an opcode-1801 claim names, matching records::Definition::definitionIndex.
-     */
     std::uint16_t recordIndex{};
-    /** Native item-definition row the claim grants, resolved the same way Collections resolves one.
-     */
     std::uint16_t itemIndex{};
-    /** Units granted. Ignored beyond 1 for a non-stackable (character-bucket) item. */
     std::int32_t quantity{};
 };
 
-/** @return True when both rows reward the same record. */
-[[nodiscard]] constexpr bool
-same_record_reward_key(const RecordRewardPolicy& left, const RecordRewardPolicy& right) noexcept {
+[[nodiscard]] constexpr bool same_record_reward_key(const RecordRewardPolicy& left,
+                                                    const RecordRewardPolicy& right) noexcept {
     return left.recordIndex == right.recordIndex;
 }
 
@@ -105,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
@@ -182,6 +169,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{};
 };
@@ -192,7 +181,6 @@ struct AccountState {
     /** Economy policy comes from configuration, never from item-specific runtime constants. */
     std::array<DismantleRewardPolicy, kDismantleRewardPolicyCapacity> dismantleRewards{};
     std::size_t dismantleRewardCount{};
-    /** Server-authored record-claim reward table, empty by default. */
     std::array<RecordRewardPolicy, kRecordRewardPolicyCapacity> recordRewards{};
     std::size_t recordRewardCount{};
     /** Account-wide currencies and materials, placed by bucket rather than by authored slot. */
@@ -222,13 +210,7 @@ namespace account {
  */
 [[nodiscard]] std::uint64_t banner_character_soid(const AccountState& state) noexcept;
 
-/**
- * Finds the configured reward for one claimed record, if any.
- * @param state Account snapshot carrying the authored reward table.
- * @param recordIndex Native record row the claim named.
- * @param reward Receives the matching row only on success.
- * @return True when the table carries a row for this record.
- */
+/** Finds the settings override for a record claim. */
 [[nodiscard]] bool find_record_reward(const AccountState& state,
                                       std::uint16_t recordIndex,
                                       RecordRewardPolicy& reward) noexcept;

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

@@ -104,4 +104,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

+ 16 - 5
Sunrise/src/state/account/inventory/inventory_state.h

@@ -60,12 +60,10 @@ inline constexpr std::uint64_t kFirstProfileItemInstanceSoid = 0x500000000000000
  * Confirmed against the installed build by three observed lock and unlock transitions.
  */
 inline constexpr std::uint32_t kLockedItemFlag = 0x1;
-/**
- * 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;
 
 /** One authored account-wide item, placed by the inventory bucket its definition names. */
 struct ProfileItem {
@@ -110,6 +108,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{};
@@ -143,4 +152,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

+ 0 - 22
Sunrise/src/state/activity/current_activity.cpp

@@ -1,22 +0,0 @@
-#include "current_activity.h"
-
-#include <atomic>
-
-namespace sunrise::state::activity {
-namespace {
-
-std::atomic<std::uint32_t> g_bubble{kNoBubble};
-
-} // namespace
-
-/** Records the arrival bubble of the activity just selected. */
-void set_current_bubble(std::uint32_t bubble) noexcept {
-    g_bubble.store(bubble, std::memory_order_relaxed);
-}
-
-/** @return The arrival bubble of the current activity. */
-std::uint32_t current_bubble() noexcept {
-    return g_bubble.load(std::memory_order_relaxed);
-}
-
-} // namespace sunrise::state::activity

+ 0 - 29
Sunrise/src/state/activity/current_activity.h

@@ -1,29 +0,0 @@
-#pragma once
-
-#include <cstdint>
-
-namespace sunrise::state::activity {
-
-/**
- * The activity the account is currently in.
- *
- * A collectible pickup arrives as an incident, and which book it should fill depends on where the
- * player is. The incident does carry the bubble, but only in a position that differs per incident
- * type, and there are several: a vase reports type 2 and a dead ghost type 10, each with its own
- * schema. The selection that put the player there already names the bubble once, so keeping it is
- * cheaper than decoding every type's payload and works for types not yet seen.
- */
-
-/** No activity has been selected yet. */
-inline constexpr std::uint32_t kNoBubble = 0;
-
-/**
- * Records the arrival bubble of the activity just selected.
- * @param bubble Arrival bubble hash, or kNoBubble when the selection carried none.
- */
-void set_current_bubble(std::uint32_t bubble) noexcept;
-
-/** @return The arrival bubble of the current activity, or kNoBubble. */
-[[nodiscard]] std::uint32_t current_bubble() noexcept;
-
-} // namespace sunrise::state::activity

+ 5 - 2
Sunrise/src/state/build_data/build_data_runtime.cpp

@@ -15,6 +15,7 @@
 #include "items/details/item_detail_catalog.h"
 #include "items/socket_plugs/socket_plug_catalog.h"
 #include "material_requirements/material_requirement_catalog.h"
+#include "nodes/node_catalog.h"
 #include "progressions/progression_catalog.h"
 #include "records/record_catalog.h"
 #include "runtime.h"
@@ -22,6 +23,7 @@
 #include "runtime/domain_markers.h"
 #include "runtime/persistence/build_data_persistence.h"
 #include "scenarios/scenario_catalog.h"
+#include "sobjects/sobject_catalog.h"
 #include "socket_entry_lists/socket_entry_list_catalog.h"
 #include "spawn_sets/spawn_set_catalog.h"
 #include "vendors/vendor_catalog.h"
@@ -108,8 +110,9 @@ bool initialize(void* module, std::uint64_t configuredEquipmentHash) noexcept {
         || !items::socket_plugs::replace(
             domains.socketPlugRules, domains.socketPlugPools, domains.socketPlugMembers)
         || !abilities::replace(domains.abilityBuckets)
-        || !progressions::replace(domains.progressions)
-        || !records::replace(domains.records)
+        || !progressions::replace(domains.progressions) || !records::replace(domains.records)
+        || !nodes::replace(domains.nodes)
+        || !sobjects::replace(domains.sobjects)
         // The layouts are what activity message 1 reads. Without them a cache hit makes the
         // other domains ready, the package build skips itself, and every destination falls back.
         || !scenarios::replace(domains.scenarios, domains.rosterGroups)

+ 6 - 2
Sunrise/src/state/build_data/cache/read/cache_file_reader.cpp

@@ -14,7 +14,8 @@ namespace {
     return counts.named != 0 && counts.items != 0 && counts.collectibles != 0
            && counts.materialRequirementSets != 0 && counts.socketPlugRules != 0
            && counts.socketPlugPools != 0 && counts.inventoryBuckets != 0
-           && counts.socketEntryLists != 0 && counts.progressions != 0 && counts.scenarios != 0;
+           && counts.socketEntryLists != 0 && counts.progressions != 0 && counts.records != 0
+           && counts.nodes != 0 && counts.sobjects != 0 && counts.scenarios != 0;
 }
 
 /** @return True when every count fits the output storage. */
@@ -32,7 +33,8 @@ namespace {
            && counts.socketEntryTables <= output.socketEntryTables.size()
            && counts.abilityBuckets <= output.abilityBuckets.size()
            && counts.progressions <= output.progressions.size()
-           && counts.records <= output.records.size()
+           && counts.records <= output.records.size() && counts.nodes <= output.nodes.size()
+           && counts.sobjects <= output.sobjects.size()
            && counts.scenarios <= output.scenarios.size()
            && counts.rosterGroups <= output.rosterGroups.size()
            && counts.spawnStems <= output.spawnStems.size()
@@ -62,6 +64,8 @@ namespace {
         header.abilityBucketCount,
         header.progressionCount,
         header.recordCount,
+        header.nodeCount,
+        header.sobjectCount,
         header.scenarioCount,
         header.rosterGroupCount,
         header.spawnStemCount,

+ 12 - 0
Sunrise/src/state/build_data/cache/read/cache_payload_reader.cpp

@@ -86,6 +86,8 @@ void clear(records::MutableDomains output) noexcept {
     std::fill(output.abilityBuckets.begin(), output.abilityBuckets.end(), abilities::Definition{});
     std::fill(output.progressions.begin(), output.progressions.end(), progressions::Definition{});
     std::fill(output.records.begin(), output.records.end(), build_data::records::Definition{});
+    std::fill(output.nodes.begin(), output.nodes.end(), nodes::Definition{});
+    std::fill(output.sobjects.begin(), output.sobjects.end(), sobjects::Definition{});
     std::fill(output.scenarios.begin(), output.scenarios.end(), scenarios::Definition{});
     std::fill(output.rosterGroups.begin(), output.rosterGroups.end(), scenarios::RosterGroup{});
     std::fill(output.spawnStems.begin(), output.spawnStems.end(), spawn_sets::Stem{});
@@ -119,6 +121,8 @@ bool expected_size(const records::DomainCounts& counts, std::uint64_t& size) noe
            && add_records(counts.abilityBuckets, sizeof(records::AbilityBucketRecord), size)
            && add_records(counts.progressions, sizeof(records::ProgressionRecord), size)
            && add_records(counts.records, sizeof(records::RecordDefinitionRecord), size)
+           && add_records(counts.nodes, sizeof(records::NodeDefinitionRecord), size)
+           && add_records(counts.sobjects, sizeof(records::SObjectDefinitionRecord), size)
            && add_records(counts.scenarios, sizeof(records::ScenarioRecord), size)
            && add_records(counts.rosterGroups, sizeof(records::RosterGroupRecord), size)
            && add_records(counts.spawnStems, sizeof(records::SpawnStemRecord), size)
@@ -180,6 +184,12 @@ bool read_payload(HANDLE file,
     valid = valid
             && read_domain<records::RecordDefinitionRecord>(
                 file, output.records.first(counts.records), checksum);
+    valid = valid
+            && read_domain<records::NodeDefinitionRecord>(
+                file, output.nodes.first(counts.nodes), checksum);
+    valid = valid
+            && read_domain<records::SObjectDefinitionRecord>(
+                file, output.sobjects.first(counts.sobjects), checksum);
     valid = valid
             && read_domain<records::ScenarioRecord>(
                 file, output.scenarios.first(counts.scenarios), checksum);
@@ -229,6 +239,8 @@ bool read_payload(HANDLE file,
         output.abilityBuckets.first(counts.abilityBuckets),
         output.progressions.first(counts.progressions),
         output.records.first(counts.records),
+        output.nodes.first(counts.nodes),
+        output.sobjects.first(counts.sobjects),
         output.scenarios.first(counts.scenarios),
         output.rosterGroups.first(counts.rosterGroups),
         output.spawnStems.first(counts.spawnStems),

+ 6 - 3
Sunrise/src/state/build_data/cache/records/cache_domain_validation.cpp

@@ -7,12 +7,13 @@
 #include "../../abilities/ability_bucket_catalog.h"
 #include "../../hash_names/hash_name_catalog.h"
 #include "../../inventory/buckets/inventory_bucket_catalog.h"
-#include "../../items/details/item_detail_catalog.h"
 #include "../../items/socket_plugs/socket_plug_catalog.h"
 #include "../../material_requirements/material_requirement_catalog.h"
+#include "../../nodes/node_catalog.h"
 #include "../../progressions/progression_catalog.h"
 #include "../../records/record_catalog.h"
 #include "../../scenarios/scenario_catalog.h"
+#include "../../sobjects/sobject_catalog.h"
 #include "../../socket_entry_lists/socket_entry_list_catalog.h"
 #include "../../spawn_sets/spawn_set_catalog.h"
 #include "../../vendors/vendor_catalog.h"
@@ -146,7 +147,8 @@ template <typename Value, typename Less>
            && counts.socketEntryTables <= domains.socketEntryTables.size()
            && counts.abilityBuckets <= domains.abilityBuckets.size()
            && counts.progressions <= domains.progressions.size()
-           && counts.records <= domains.records.size()
+           && counts.records <= domains.records.size() && counts.nodes <= domains.nodes.size()
+           && counts.sobjects <= domains.sobjects.size()
            && counts.scenarios <= domains.scenarios.size()
            && counts.rosterGroups <= domains.rosterGroups.size()
            && counts.spawnStems <= domains.spawnStems.size()
@@ -219,7 +221,8 @@ bool valid_domains(Domains domains) noexcept {
         || !abilities::valid(domains.abilityBuckets)
         || !strictly_ordered(domains.abilityBuckets, ability_less)
         || !progressions::valid(domains.progressions)
-        || !build_data::records::valid(domains.records)
+        || !build_data::records::valid(domains.records) || !nodes::valid(domains.nodes)
+        || !sobjects::valid(domains.sobjects)
         || !scenarios::valid(domains.scenarios, domains.rosterGroups)
         // An empty catalog is complete. It is what a build with no installed spawn set means.
         // Both arrays must be empty together, because a stem names its hashes by range.

+ 61 - 4
Sunrise/src/state/build_data/cache/records/cache_investment_records.cpp

@@ -79,8 +79,7 @@ bool decode(const ProgressionRecord& record, progressions::Definition& value) no
 }
 
 /** Encodes one record with its padding zeroed. */
-bool encode(const build_data::records::Definition& value,
-            RecordDefinitionRecord& record) noexcept {
+bool encode(const build_data::records::Definition& value, RecordDefinitionRecord& record) noexcept {
     record = {
         value.definitionIndex,
         value.definitionHash,
@@ -95,8 +94,7 @@ bool encode(const build_data::records::Definition& value,
 }
 
 /** Decodes one record after checking its padding. */
-bool decode(const RecordDefinitionRecord& record,
-            build_data::records::Definition& value) noexcept {
+bool decode(const RecordDefinitionRecord& record, build_data::records::Definition& value) noexcept {
     value = {};
     // Assigned by name, never positionally: this row outlived two field additions to Definition,
     // and a positional list silently filled the new fields with the wrong disk values.
@@ -113,4 +111,63 @@ bool decode(const RecordDefinitionRecord& record,
     return true;
 }
 
+/** Encodes one presentation node with canonical unused child rows. */
+bool encode(const nodes::Definition& value, NodeDefinitionRecord& record) noexcept {
+    if (value.childCount > nodes::kChildCapacity) {
+        return false;
+    }
+    record = {};
+    record.definitionIndex = value.definitionIndex;
+    record.valueIndex = value.valueIndex;
+    record.valueSlot = value.valueSlot;
+    record.characterValueSlot = value.characterValueSlot;
+    record.parentValueIndex = value.parentValueIndex;
+    record.parentCharacterValueIndex = value.parentCharacterValueIndex;
+    record.visibilityFlagIndex = value.visibilityFlagIndex;
+    record.visibilityCharacterFlagIndex = value.visibilityCharacterFlagIndex;
+    record.characterValueIndex = value.characterValueIndex;
+    record.childCount = value.childCount;
+    for (std::size_t index = 0; index < value.childCount; ++index) {
+        record.children[index] = value.children[index];
+    }
+    return true;
+}
+
+/** Decodes one presentation node after checking its child list. */
+bool decode(const NodeDefinitionRecord& record, nodes::Definition& value) noexcept {
+    value = {};
+    if (record.childCount > nodes::kChildCapacity) {
+        return false;
+    }
+    for (std::size_t index = record.childCount; index < record.children.size(); ++index) {
+        if (record.children[index] != 0) {
+            return false;
+        }
+    }
+    value.definitionIndex = record.definitionIndex;
+    value.valueIndex = record.valueIndex;
+    value.valueSlot = record.valueSlot;
+    value.characterValueSlot = record.characterValueSlot;
+    value.parentValueIndex = record.parentValueIndex;
+    value.parentCharacterValueIndex = record.parentCharacterValueIndex;
+    value.visibilityFlagIndex = record.visibilityFlagIndex;
+    value.visibilityCharacterFlagIndex = record.visibilityCharacterFlagIndex;
+    value.characterValueIndex = record.characterValueIndex;
+    value.childCount = record.childCount;
+    value.children = record.children;
+    return true;
+}
+
+/** Encodes one incident-target definition. */
+bool encode(const sobjects::Definition& value, SObjectDefinitionRecord& record) noexcept {
+    record = {value.nameHash, value.lane4, value.typeCode};
+    return true;
+}
+
+/** Decodes one incident-target definition. */
+bool decode(const SObjectDefinitionRecord& record, sobjects::Definition& value) noexcept {
+    value = {record.nameHash, record.lane4, record.typeCode};
+    return true;
+}
+
 } // namespace sunrise::state::build_data::cache::records

+ 8 - 232
Sunrise/src/state/build_data/cache/records/codec.h

@@ -5,333 +5,109 @@
 
 namespace sunrise::state::build_data::cache::records {
 
-/**
- * @param value Runtime row to pack.
- * @param record Receives the packed disk row.
- * @return True when the row is valid.
- */
 [[nodiscard]] bool encode(const content::Definition& value, NamedRecord& record) noexcept;
-
-/**
- * @param record Packed disk row.
- * @param value Receives the runtime row.
- * @return True when the disk row is in standard form.
- */
 [[nodiscard]] bool decode(const NamedRecord& record, content::Definition& value) noexcept;
 
-/**
- * @param value Runtime row to pack.
- * @param record Receives the packed disk row.
- * @return Always true.
- */
 [[nodiscard]] bool encode(const items::Definition& value, ItemRecord& record) noexcept;
-
-/**
- * @param record Packed disk row.
- * @param value Receives the runtime row.
- * @return True when the disk row is in standard form.
- */
 [[nodiscard]] bool decode(const ItemRecord& record, items::Definition& value) noexcept;
 
-/**
- * @param value Runtime row to pack.
- * @param record Receives the packed disk row.
- * @return Always true.
- */
 [[nodiscard]] bool encode(const collectibles::Definition& value,
                           CollectibleRecord& record) noexcept;
-
-/**
- * @param record Packed disk row.
- * @param value Receives the runtime row.
- * @return Always true.
- */
 [[nodiscard]] bool decode(const CollectibleRecord& record,
                           collectibles::Definition& value) noexcept;
 
-/**
- * Exact dense action-cost set codec.
- * @param value Runtime row to pack.
- * @param record Receives the packed disk row.
- * @return True when the requirement count fits its fixed rows.
- */
 [[nodiscard]] bool encode(const material_requirements::Definition& value,
                           MaterialRequirementSetRecord& record) noexcept;
-
-/**
- * Exact dense action-cost set codec.
- * @param record Packed disk row.
- * @param value Receives the runtime row.
- * @return True when the reserved field and every packed boolean are canonical.
- */
 [[nodiscard]] bool decode(const MaterialRequirementSetRecord& record,
                           material_requirements::Definition& value) noexcept;
 
-/**
- * @param value Runtime row to pack.
- * @param record Receives the packed disk row.
- * @return True when the state is a known one.
- */
 [[nodiscard]] bool encode(const items::details::Definition& value,
                           ItemDetailRecord& record) noexcept;
-
-/**
- * @param record Packed disk row.
- * @param value Receives the runtime row.
- * @return True when the state byte is a known one.
- */
 [[nodiscard]] bool decode(const ItemDetailRecord& record,
                           items::details::Definition& value) noexcept;
 
-/**
- * Exact ordinary-socket rule codec.
- * @param value Runtime row to pack.
- * @param record Receives the packed disk row.
- * @return True when the row's canonical padding is zero.
- */
 [[nodiscard]] bool encode(const items::socket_plugs::Rule& value,
                           SocketPlugRuleRecord& record) noexcept;
-
-/**
- * Exact ordinary-socket rule codec.
- * @param record Packed disk row.
- * @param value Receives the runtime row.
- * @return True when the record's reserved byte is zero.
- */
 [[nodiscard]] bool decode(const SocketPlugRuleRecord& record,
                           items::socket_plugs::Rule& value) noexcept;
 
-/**
- * Deduplicated socket-pool range codec.
- * @param value Runtime row to pack.
- * @param record Receives the packed disk row.
- * @return Always true.
- */
 [[nodiscard]] bool encode(const items::socket_plugs::Pool& value,
                           SocketPlugPoolRecord& record) noexcept;
-
-/**
- * Deduplicated socket-pool range codec. Cross-row contiguity is checked at the domain boundary.
- * @param record Packed disk row.
- * @param value Receives the runtime row.
- * @return Always true.
- */
 [[nodiscard]] bool decode(const SocketPlugPoolRecord& record,
                           items::socket_plugs::Pool& value) noexcept;
 
-/**
- * Flat allowed-plug member codec.
- * @param value Runtime row to pack.
- * @param record Receives the packed disk row.
- * @return Always true.
- */
 [[nodiscard]] bool encode(items::socket_plugs::Member value,
                           SocketPlugMemberRecord& record) noexcept;
-
-/**
- * Flat allowed-plug member codec.
- * @param record Packed disk row.
- * @param value Receives the runtime row.
- * @return Always true.
- */
 [[nodiscard]] bool decode(const SocketPlugMemberRecord& record,
                           items::socket_plugs::Member& value) noexcept;
 
-/**
- * @param value Runtime row to pack.
- * @param record Receives the packed disk row.
- * @return Always true.
- */
 [[nodiscard]] bool encode(const inventory::buckets::Descriptor& value,
                           InventoryBucketRecord& record) noexcept;
-
-/**
- * @param record Packed disk row.
- * @param value Receives the runtime row.
- * @return Always true.
- */
 [[nodiscard]] bool decode(const InventoryBucketRecord& record,
                           inventory::buckets::Descriptor& value) noexcept;
 
-/**
- * @param value Runtime row to pack.
- * @param record Receives the packed disk row.
- * @return Always true.
- */
 [[nodiscard]] bool encode(const socket_entry_lists::Definition& value,
                           SocketEntryListRecord& record) noexcept;
-
-/**
- * @param record Packed disk row.
- * @param value Receives the runtime row.
- * @return True when the disk row is in standard form.
- */
 [[nodiscard]] bool decode(const SocketEntryListRecord& record,
                           socket_entry_lists::Definition& value) noexcept;
 
-/**
- * @param value Runtime row to pack.
- * @param record Receives the packed disk row.
- * @return Always true.
- */
 [[nodiscard]] bool encode(const socket_entry_lists::EntryTable& value,
                           SocketEntryTableRecord& record) noexcept;
-
-/**
- * @param record Packed disk row.
- * @param value Receives the runtime row.
- * @return True when the disk row is in standard form.
- */
 [[nodiscard]] bool decode(const SocketEntryTableRecord& record,
                           socket_entry_lists::EntryTable& value) noexcept;
 
-/**
- * @param value Runtime row to pack.
- * @param record Receives the packed disk row.
- * @return Always true.
- */
 [[nodiscard]] bool encode(const abilities::Definition& value, AbilityBucketRecord& record) noexcept;
-
-/**
- * @param record Packed disk row.
- * @param value Receives the runtime row.
- * @return True when every count is inside its limit.
- */
 [[nodiscard]] bool decode(const AbilityBucketRecord& record, abilities::Definition& value) noexcept;
 
-/**
- * @param value Runtime row to pack.
- * @param record Receives the packed disk row.
- * @return Always true.
- */
 [[nodiscard]] bool encode(const progressions::Definition& value,
                           ProgressionRecord& record) noexcept;
-
-/**
- * @param record Packed disk row.
- * @param value Receives the runtime row.
- * @return True when the disk row is in standard form.
- */
 [[nodiscard]] bool decode(const ProgressionRecord& record,
                           progressions::Definition& value) noexcept;
 
-/**
- * @param value Runtime row to pack.
- * @param record Receives the packed disk row.
- * @return Always true.
- */
 [[nodiscard]] bool encode(const build_data::records::Definition& value,
                           RecordDefinitionRecord& record) noexcept;
-
-/**
- * @param record Packed disk row.
- * @param value Receives the runtime row.
- * @return True when the disk row is in standard form.
- */
 [[nodiscard]] bool decode(const RecordDefinitionRecord& record,
                           build_data::records::Definition& value) noexcept;
 
-/**
- * @param value Runtime row to pack.
- * @param record Receives the packed disk row.
- * @return True when the row is valid.
- */
-[[nodiscard]] bool encode(const scenarios::Definition& value, ScenarioRecord& record) noexcept;
+[[nodiscard]] bool encode(const nodes::Definition& value, NodeDefinitionRecord& record) noexcept;
+[[nodiscard]] bool decode(const NodeDefinitionRecord& record, nodes::Definition& value) noexcept;
 
-/**
- * @param record Packed disk row.
- * @param value Receives the runtime row.
- * @return True when the disk row is in standard form.
- */
+[[nodiscard]] bool encode(const sobjects::Definition& value,
+                          SObjectDefinitionRecord& record) noexcept;
+[[nodiscard]] bool decode(const SObjectDefinitionRecord& record,
+                          sobjects::Definition& value) noexcept;
+
+[[nodiscard]] bool encode(const scenarios::Definition& value, ScenarioRecord& record) noexcept;
 [[nodiscard]] bool decode(const ScenarioRecord& record, scenarios::Definition& value) noexcept;
 
-/**
- * @param value Runtime row to pack.
- * @param record Receives the packed disk row.
- * @return True when the row is valid.
- */
 [[nodiscard]] bool encode(const scenarios::RosterGroup& value, RosterGroupRecord& record) noexcept;
-
-/**
- * @param record Packed disk row.
- * @param value Receives the runtime row.
- * @return True when the disk row is in standard form.
- */
 [[nodiscard]] bool decode(const RosterGroupRecord& record, scenarios::RosterGroup& value) noexcept;
 
-/**
- * @param value Runtime row to pack.
- * @param record Receives the packed disk row.
- * @return True when the row is valid.
- */
 [[nodiscard]] bool encode(const hash_names::Name& value, HashNameRecord& record) noexcept;
-
-/**
- * @param record Packed disk row.
- * @param value Receives the runtime row.
- * @return True when the disk row is in standard form.
- */
 [[nodiscard]] bool decode(const HashNameRecord& record, hash_names::Name& value) noexcept;
 
-/**
- * @param value Runtime row to pack.
- * @param record Receives the packed disk row.
- * @return True when the row is valid.
- */
 [[nodiscard]] bool encode(const spawn_sets::Stem& value, SpawnStemRecord& record) noexcept;
-
-/**
- * @param record Packed disk row.
- * @param value Receives the runtime row.
- * @return True when the disk row is in standard form.
- */
 [[nodiscard]] bool decode(const SpawnStemRecord& record, spawn_sets::Stem& value) noexcept;
 
-/**
- * @param value Runtime row to pack.
- * @param record Receives the packed disk row.
- * @return True when the row is valid.
- */
 [[nodiscard]] bool encode(const spawn_sets::NameHash& value, SpawnNameHashRecord& record) noexcept;
-
-/**
- * @param record Packed disk row.
- * @param value Receives the runtime row.
- * @return True when the disk row is in standard form.
- */
 [[nodiscard]] bool decode(const SpawnNameHashRecord& record, spawn_sets::NameHash& value) noexcept;
 
-/** @param record Receives the packed disk row. @return Always true. */
 [[nodiscard]] bool encode(const spawn_sets::Point& value, SpawnPointRecord& record) noexcept;
-
-/** @param value Receives the runtime row. @return True when the disk row is in standard form. */
 [[nodiscard]] bool decode(const SpawnPointRecord& record, spawn_sets::Point& value) noexcept;
 
-/** @param record Receives the packed disk row. @return Always true. */
 [[nodiscard]] bool encode(const vendors::IndexEntry& value, VendorIndexRecord& record) noexcept;
-
-/** @param value Receives the runtime row. @return True when the disk row is in standard form. */
 [[nodiscard]] bool decode(const VendorIndexRecord& record, vendors::IndexEntry& value) noexcept;
 
-/** @param record Receives the packed disk row. @return Always true. */
 [[nodiscard]] bool encode(const vendors::Definition& value,
                           VendorDefinitionRecord& record) noexcept;
-
-/** @param value Receives the runtime row. @return True when the disk row is in standard form. */
 [[nodiscard]] bool decode(const VendorDefinitionRecord& record,
                           vendors::Definition& value) noexcept;
 
-/** @param record Receives the packed disk row. @return Always true. */
 [[nodiscard]] bool encode(const vendors::SaleRow& value, VendorSaleRowRecord& record) noexcept;
-
-/** @param value Receives the runtime row. @return True when the disk row is in standard form. */
 [[nodiscard]] bool decode(const VendorSaleRowRecord& record, vendors::SaleRow& value) noexcept;
 
-/** @param record Receives the packed disk row. @return Always true. */
 [[nodiscard]] bool encode(const vendors::InstalledRow& value,
                           VendorInstalledRowRecord& record) noexcept;
-
-/** @param value Receives the runtime row. @return True when the disk row is in standard form. */
 [[nodiscard]] bool decode(const VendorInstalledRowRecord& record,
                           vendors::InstalledRow& value) noexcept;
 

+ 8 - 0
Sunrise/src/state/build_data/cache/records/domains.h

@@ -12,9 +12,11 @@
 #include "../../items/item_catalog.h"
 #include "../../items/socket_plugs/definition.h"
 #include "../../material_requirements/material_requirement_catalog.h"
+#include "../../nodes/definition.h"
 #include "../../progressions/definition.h"
 #include "../../records/definition.h"
 #include "../../scenarios/definition.h"
+#include "../../sobjects/sobject_catalog.h"
 #include "../../socket_entry_lists/definition.h"
 #include "../../spawn_sets/definition.h"
 #include "../../vendors/definition.h"
@@ -38,6 +40,8 @@ struct DomainCounts {
     std::size_t abilityBuckets{};
     std::size_t progressions{};
     std::size_t records{};
+    std::size_t nodes{};
+    std::size_t sobjects{};
     std::size_t scenarios{};
     std::size_t rosterGroups{};
     std::size_t spawnStems{};
@@ -68,6 +72,8 @@ struct MutableDomains {
     std::span<abilities::Definition> abilityBuckets;
     std::span<progressions::Definition> progressions;
     std::span<build_data::records::Definition> records;
+    std::span<nodes::Definition> nodes;
+    std::span<sobjects::Definition> sobjects;
     std::span<scenarios::Definition> scenarios;
     std::span<scenarios::RosterGroup> rosterGroups;
     std::span<spawn_sets::Stem> spawnStems;
@@ -97,6 +103,8 @@ struct Domains {
     std::span<const abilities::Definition> abilityBuckets;
     std::span<const progressions::Definition> progressions;
     std::span<const build_data::records::Definition> records;
+    std::span<const nodes::Definition> nodes;
+    std::span<const sobjects::Definition> sobjects;
     std::span<const scenarios::Definition> scenarios;
     std::span<const scenarios::RosterGroup> rosterGroups;
     std::span<const spawn_sets::Stem> spawnStems;

+ 33 - 6
Sunrise/src/state/build_data/cache/records/format.h

@@ -15,9 +15,11 @@
 #include "../../items/item_catalog.h"
 #include "../../items/socket_plugs/definition.h"
 #include "../../material_requirements/material_requirement_catalog.h"
+#include "../../nodes/definition.h"
 #include "../../progressions/definition.h"
 #include "../../records/definition.h"
 #include "../../scenarios/definition.h"
+#include "../../sobjects/sobject_catalog.h"
 #include "../../spawn_sets/definition.h"
 #include "../../vendors/definition.h"
 
@@ -30,12 +32,9 @@ inline constexpr std::array<char, 8> kCacheMagic{'S', 'U', 'N', 'R', 'I', 'S', '
  * Bump it when a stored shape changes, and when the extraction filling it changes what it writes.
  * A cached row survives a code change, so a corrected walk keeps publishing the old rows.
  *
- * 46: records::Definition gained definitionHash (record-reward lookup). The old cache's record
- *     rows are four bytes short per row; reading them as the new shape shifts every later field,
- *     which surfaced as claims resolving to the score value instead of the flag index. Bumped so
- *     the stale shape is rejected and rebuilt instead of misread.
+ * 48: nodes and SObjects joined the unified cache, replacing their incomplete sidecar lifecycle.
  */
-inline constexpr std::uint32_t kCacheFormatVersion = 47;
+inline constexpr std::uint32_t kCacheFormatVersion = 48;
 /** Signed -1 on disk means there is no equipment slot. */
 inline constexpr std::int8_t kAbsentEquipmentSlot = -1;
 /** The standard 64-bit FNV-1a offset basis starts the payload checksum. */
@@ -85,6 +84,8 @@ struct Header {
     std::uint32_t abilityBucketCount{};
     std::uint32_t progressionCount{};
     std::uint32_t recordCount{};
+    std::uint32_t nodeCount{};
+    std::uint32_t sobjectCount{};
     std::uint32_t scenarioCount{};
     std::uint32_t rosterGroupCount{};
     std::uint32_t spawnStemCount{};
@@ -262,6 +263,28 @@ struct RecordDefinitionRecord {
     std::uint8_t reserved{};
 };
 
+/** Disk form of one presentation node and its owned record rows. */
+struct NodeDefinitionRecord {
+    std::uint16_t definitionIndex{};
+    std::uint16_t valueIndex{nodes::kUnavailableValueIndex};
+    std::int16_t valueSlot{-1};
+    std::int16_t characterValueSlot{-1};
+    std::uint16_t parentValueIndex{nodes::kUnavailableValueIndex};
+    std::uint16_t parentCharacterValueIndex{nodes::kUnavailableValueIndex};
+    std::uint16_t visibilityFlagIndex{nodes::kUnavailableFlagIndex};
+    std::uint16_t visibilityCharacterFlagIndex{nodes::kUnavailableFlagIndex};
+    std::uint16_t characterValueIndex{nodes::kUnavailableValueIndex};
+    std::uint8_t childCount{};
+    std::array<std::uint16_t, nodes::kChildCapacity> children{};
+};
+
+/** Disk form of one incident-target definition. */
+struct SObjectDefinitionRecord {
+    std::uint32_t nameHash{};
+    std::uint32_t lane4{};
+    std::int32_t typeCode{sobjects::kAbsentTypeCode};
+};
+
 /** Disk form of one dense socket-entry-list definition. */
 struct SocketEntryListRecord {
     std::uint32_t definitionHash{};
@@ -448,7 +471,7 @@ static_assert(sizeof(Prefix) == kCacheMagic.size() + sizeof(std::uint32_t));
 static_assert(sizeof(InvestmentConstants)
               == constants::kCharacterStatRowCount + 2 * sizeof(std::uint8_t));
 static_assert(sizeof(Header)
-              == kCacheMagic.size() + 27 * sizeof(std::uint32_t) + 2 * sizeof(std::uint64_t)
+              == kCacheMagic.size() + 29 * sizeof(std::uint32_t) + 2 * sizeof(std::uint64_t)
                      + sizeof(InvestmentConstants));
 static_assert(sizeof(SpawnPointRecord)
               == spawn_sets::kPositionComponents * sizeof(float) + sizeof(std::uint32_t)
@@ -488,6 +511,10 @@ static_assert(sizeof(ProgressionRecord) == sizeof(std::uint16_t) + 2 * sizeof(st
 static_assert(sizeof(RecordDefinitionRecord)
               == sizeof(std::uint16_t) + sizeof(std::uint32_t) + 4 * sizeof(std::uint16_t)
                      + 2 * sizeof(std::uint8_t));
+static_assert(sizeof(NodeDefinitionRecord)
+              == 9 * sizeof(std::uint16_t) + sizeof(std::uint8_t)
+                     + nodes::kChildCapacity * sizeof(std::uint16_t));
+static_assert(sizeof(SObjectDefinitionRecord) == 2 * sizeof(std::uint32_t) + sizeof(std::int32_t));
 static_assert(sizeof(AbilityBucketRecord)
               == sizeof(std::uint16_t) + 6 * sizeof(std::uint8_t)
                      + 2 * abilities::kBucketCapacity * sizeof(std::uint8_t)

+ 6 - 2
Sunrise/src/state/build_data/cache/write/cache_file_writer.cpp

@@ -29,9 +29,13 @@ namespace {
            && domains.socketEntryTables.size() <= kMaximumCount
            && domains.abilityBuckets.size() <= kMaximumCount
            && domains.progressions.size() <= kMaximumCount
-           && domains.records.size() <= kMaximumCount
-           && domains.scenarios.size() <= kMaximumCount
+           && domains.records.size() <= kMaximumCount && domains.nodes.size() <= kMaximumCount
+           && domains.sobjects.size() <= kMaximumCount && domains.scenarios.size() <= kMaximumCount
            && domains.rosterGroups.size() <= kMaximumCount
+           && domains.spawnStems.size() <= kMaximumCount
+           && domains.spawnNameHashes.size() <= kMaximumCount
+           && domains.spawnPoints.size() <= kMaximumCount
+           && domains.hashNames.size() <= kMaximumCount
            && domains.vendorIndex.size() <= kMaximumCount
            && domains.vendorDefinitions.size() <= kMaximumCount
            && domains.vendorSaleRows.size() <= kMaximumCount

+ 4 - 0
Sunrise/src/state/build_data/cache/write/cache_payload_writer.cpp

@@ -78,6 +78,8 @@ bool payload_checksum(records::Domains domains, std::uint64_t& checksum) noexcep
            && checksum_domain<records::AbilityBucketRecord>(domains.abilityBuckets, checksum)
            && checksum_domain<records::ProgressionRecord>(domains.progressions, checksum)
            && checksum_domain<records::RecordDefinitionRecord>(domains.records, checksum)
+           && checksum_domain<records::NodeDefinitionRecord>(domains.nodes, checksum)
+           && checksum_domain<records::SObjectDefinitionRecord>(domains.sobjects, checksum)
            && checksum_domain<records::ScenarioRecord>(domains.scenarios, checksum)
            && checksum_domain<records::RosterGroupRecord>(domains.rosterGroups, checksum)
            && checksum_domain<records::SpawnStemRecord>(domains.spawnStems, checksum)
@@ -108,6 +110,8 @@ bool write_payload(HANDLE file, records::Domains domains) noexcept {
            && write_domain<records::AbilityBucketRecord>(file, domains.abilityBuckets)
            && write_domain<records::ProgressionRecord>(file, domains.progressions)
            && write_domain<records::RecordDefinitionRecord>(file, domains.records)
+           && write_domain<records::NodeDefinitionRecord>(file, domains.nodes)
+           && write_domain<records::SObjectDefinitionRecord>(file, domains.sobjects)
            && write_domain<records::ScenarioRecord>(file, domains.scenarios)
            && write_domain<records::RosterGroupRecord>(file, domains.rosterGroups)
            && write_domain<records::SpawnStemRecord>(file, domains.spawnStems)

+ 2 - 0
Sunrise/src/state/build_data/cache/write/temporary/temporary_cache_file.cpp

@@ -120,6 +120,8 @@ enum class WriteStatus {
         static_cast<std::uint32_t>(domains.abilityBuckets.size()),
         static_cast<std::uint32_t>(domains.progressions.size()),
         static_cast<std::uint32_t>(domains.records.size()),
+        static_cast<std::uint32_t>(domains.nodes.size()),
+        static_cast<std::uint32_t>(domains.sobjects.size()),
         static_cast<std::uint32_t>(domains.scenarios.size()),
         static_cast<std::uint32_t>(domains.rosterGroups.size()),
         static_cast<std::uint32_t>(domains.spawnStems.size()),

+ 0 - 16
Sunrise/src/state/build_data/collectibles/collectible_catalog.cpp

@@ -110,22 +110,6 @@ bool snapshot(std::span<Definition> output, std::size_t& count) noexcept {
     return g_definitions.snapshot(output, count);
 }
 
-/** Finds the collectible that unlocks one lore row. */
-bool find_by_lore_row(std::uint16_t loreRow, Definition& definition) noexcept {
-    if (loreRow == kUnavailableLoreRow) {
-        return false;
-    }
-    const Lock::Shared guard(g_lock);
-    for (const Definition& row : g_definitions.rows()) {
-        if (row.loreRow != loreRow) {
-            continue;
-        }
-        definition = row;
-        return true;
-    }
-    return false;
-}
-
 /** @return Number of published rows, read under the catalog lock. */
 std::size_t count() noexcept {
     const Lock::Shared guard(g_lock);

+ 0 - 21
Sunrise/src/state/build_data/collectibles/collectible_catalog.h

@@ -14,8 +14,6 @@ inline constexpr std::uint16_t kUnavailableItemDefinitionIndex = 0xFFFFU;
 /** A collectible with no acquisition charge carries this native requirement-set sentinel. */
 inline constexpr std::uint16_t kUnavailableMaterialRequirementSetIndex = 0xFFFFU;
 
-/** A collectible that unlocks no lore carries this instead of a row. */
-inline constexpr std::uint16_t kUnavailableLoreRow = 0xFFFFU;
 /** Installed requirement sets contain at most six material rows. */
 inline constexpr std::size_t kMaterialRequirementCapacity = 6;
 
@@ -34,13 +32,6 @@ struct Definition {
     std::uint16_t collectibleIndex{};
     std::uint16_t itemDefinitionIndex{kUnavailableItemDefinitionIndex};
     std::uint16_t materialRequirementSetIndex{kUnavailableMaterialRequirementSetIndex};
-    /**
-     * Lore row this collectible unlocks, or kUnavailableLoreRow when it unlocks none.
-     *
-     * The record that displays the same row is the chapter this collectible completes, so the pair
-     * joins on the row rather than on any hash.
-     */
-    std::uint16_t loreRow{kUnavailableLoreRow};
     std::uint8_t materialRequirementCount{};
     std::array<MaterialRequirement, kMaterialRequirementCapacity> materialRequirements{};
 };
@@ -70,16 +61,4 @@ void clear() noexcept;
 /** @return Number of installed-build collectible mappings. */
 [[nodiscard]] std::size_t count() noexcept;
 
-/**
- * Finds the collectible that unlocks one lore row.
- *
- * The reverse of the record join: a chapter record and the collectible that grants it name the same
- * lore row, so this reaches the collectible whose item the account has to own for the lore itself to
- * be readable rather than obscured.
- * @param loreRow Lore table row.
- * @param definition Receives the collectible.
- * @return True when a collectible names that row.
- */
-[[nodiscard]] bool find_by_lore_row(std::uint16_t loreRow, Definition& definition) noexcept;
-
 } // namespace sunrise::state::build_data::collectibles

+ 12 - 65
Sunrise/src/state/build_data/nodes/definition.h

@@ -6,25 +6,16 @@
 
 namespace sunrise::state::build_data::nodes {
 
-/** The shipped build declares 924 presentation nodes. The domain leaves room above that. */
+/** Fixed capacity above the shipped build's 924 presentation nodes. */
 inline constexpr std::size_t kDefinitionCapacity = 1024;
 
-/** No shipped node owns more records than this; the widest seen is a lore book at fifteen. */
+/** Child capacity; the widest shipped lore book owns fifteen records. */
 inline constexpr std::size_t kChildCapacity = 64;
 
 /** A node whose expression names no addressable value slot carries this instead of an index. */
 inline constexpr std::uint16_t kUnavailableValueIndex = 0xFFFFU;
 
-/**
- * The lore book categories: 815 to 829 under The Light, 831 to 843 under The Darkness, and 845 to
- * 854 under Dusk and Dawn.
- *
- * The upper bound was 853, counted from the game's own list, and it was one short: node 854 carries
- * the same gate every other book carries, a value read tested against zero, while 855 onward pair
- * two flag reads or use a different opcode entirely. The end of the range is therefore taken from
- * the shape of the rows rather than from counting entries on screen. Only these nodes have their
- * visibility gate satisfied.
- */
+/** Lore-book category bounds derived from the installed node expressions. */
 inline constexpr std::uint16_t kLoreNodeFirst = 815U;
 inline constexpr std::uint16_t kLoreNodeLast = 854U;
 
@@ -36,69 +27,25 @@ inline constexpr std::uint16_t kLoreNodeLast = 854U;
 /** A node whose gate names no addressable flag carries this instead of an index. */
 inline constexpr std::uint16_t kUnavailableFlagIndex = 0xFFFFU;
 
-/**
- * One presentation node reduced to what a progress bar needs.
- *
- * A node's bar is not derived by the client from its children. The node carries an expression that
- * reads a value slot, and the bar shows whatever that slot holds, so a server that wants the bar to
- * move has to count the claimed children itself and write the count. The slot is resolved to its
- * mapping-table row here, at extraction, exactly as a record's completion flag is.
- */
+/** Extracted fields needed to publish one node's visibility and progress. */
 struct Definition {
     /** Native node row. */
     std::uint16_t definitionIndex{};
-    /** Account value bank mapping row, or kUnavailableValueIndex when no slot is addressable. */
+    /** Account value mapping row, or unavailable. */
     std::uint16_t valueIndex{kUnavailableValueIndex};
-    /**
-     * The raw account-scoped value SLOT this node's expression names, before mapping.
-     *
-     * Parent-slot assignment needs the slot itself, not the mapped row: the shipped allocation
-     * groups books by contiguous slot runs, and the run shape is invisible through indices alone.
-     */
+    /** Raw account value slot used to resolve contiguous parent slots. */
     std::int16_t valueSlot{-1};
-    /**
-     * The raw character-scoped value SLOT this node's expression names, or -1.
-     *
-     * One lore book's bar reads a slot only the character table carries. Its parent needs the same
-     * run-aware assignment as account-scoped books, keyed by this slot's place in the allocation
-     * sequence.
-     */
+    /** Raw character value slot, or -1. */
     std::int16_t characterValueSlot{-1};
-    /**
-     * Account value index of the parent record's own bar, one slot above the node's.
-     *
-     * The node's bar counts every child including the parent record; the parent's bar counts only
-     * the chapters. They are separate slots and need separate counts.
-     */
+    /** Account value index of the parent record's chapter bar. */
     std::uint16_t parentValueIndex{kUnavailableValueIndex};
-    /**
-     * Character value index of the parent record's own bar for a character-scoped book, or
-     * kUnavailableValueIndex.
-     */
+    /** Character value index of the parent record's chapter bar. */
     std::uint16_t parentCharacterValueIndex{kUnavailableValueIndex};
-    /**
-     * Account flag index this node's own gate reads, or kUnavailableFlagIndex.
-     *
-     * A category with a redacted title is not waiting on progress: its expression reads a flag, and
-     * until that flag is set the client shows no name and offers nothing inside to claim. A book
-     * gated this way cannot reveal itself by being played, so the gate is satisfied here.
-     */
+    /** Account flag read by this node's visibility gate. */
     std::uint16_t visibilityFlagIndex{kUnavailableFlagIndex};
-    /**
-     * Character flag index this node's gate reads, or kUnavailableFlagIndex.
-     *
-     * Not every gate is account scoped. One lore book names a slot that no account mapping row
-     * carries at all, and the same slot sits in the character table instead, so a gate is resolved
-     * against both banks and satisfied in whichever one claims it.
-     */
+    /** Character flag read by this node's visibility gate. */
     std::uint16_t visibilityCharacterFlagIndex{kUnavailableFlagIndex};
-    /**
-     * Character value index this node's bar reads, or kUnavailableValueIndex.
-     *
-     * Not every category counts in the account bank. One lore book reads a slot that only the
-     * character table carries, so it stayed redacted while every other book opened: its count had
-     * nowhere to go.
-     */
+    /** Character value index read by this node's progress bar. */
     std::uint16_t characterValueIndex{kUnavailableValueIndex};
     /** Records this node owns, held at node row `+136`. */
     std::uint8_t childCount{};

+ 20 - 34
Sunrise/src/state/build_data/nodes/node_catalog.cpp

@@ -1,7 +1,7 @@
 #include "node_catalog.h"
 
 #include "../../record_claims/objective_slot_table.h"
-
+#include "../../record_claims/record_claims.h"
 #include "../../unlocks/definition.h"
 #include "../table.h"
 
@@ -15,8 +15,11 @@ Table<Definition, kDefinitionCapacity> g_definitions;
 
 /** Clears every generated node definition under the catalog lock. */
 void clear() noexcept {
-    const Lock::Exclusive guard(g_lock);
-    g_definitions.clear();
+    {
+        const Lock::Exclusive guard(g_lock);
+        g_definitions.clear();
+    }
+    record_claims::invalidate_build_data_cache();
 }
 
 /** Checks that the definitions are dense and in native index order. */
@@ -38,22 +41,15 @@ bool replace(std::span<const Definition> definitions) noexcept {
     if (!valid(definitions)) {
         return false;
     }
-    const Lock::Exclusive guard(g_lock);
-    return g_definitions.replace(definitions);
-}
-
-/** Runs one callable over every node that drives a value slot. */
-void for_each_driving(void* context,
-                      void (*visit)(void* context, const Definition& definition)) noexcept {
-    if (visit == nullptr) {
-        return;
+    bool replaced = false;
+    {
+        const Lock::Exclusive guard(g_lock);
+        replaced = g_definitions.replace(definitions);
     }
-    const Lock::Shared guard(g_lock);
-    for (const Definition& definition : g_definitions.rows()) {
-        if (definition.childCount != 0 && definition.valueIndex != kUnavailableValueIndex) {
-            visit(context, definition);
-        }
+    if (replaced) {
+        record_claims::invalidate_build_data_cache();
     }
+    return replaced;
 }
 
 /** Copies every row in native node order. */
@@ -92,17 +88,15 @@ std::size_t apply_visibility(std::span<std::uint8_t> accountFlags) noexcept {
     return set;
 }
 
-/** Sets the value-gate of every lore book category that has no flag gate at all. */
-std::size_t apply_category_gates(std::span<std::int32_t> objectiveValues, bool revealAll) noexcept {
-    static_cast<void>(revealAll);
+/** Opens lore categories whose gates read an otherwise-empty value slot. */
+std::size_t apply_category_gates(std::span<std::int32_t> objectiveValues) noexcept {
     const Lock::Shared guard(g_lock);
     std::size_t set = 0;
     for (const Definition& node : g_definitions.rows()) {
         if (node.definitionIndex < kLoreNodeFirst || node.definitionIndex > kLoreNodeLast) {
             continue;
         }
-        // apply_visibility already owns the flag-gated books; a node with a flag gate is not one of
-        // this pass's eighteen and must be left alone.
+        // Flag-gated books are handled by apply_visibility.
         if (node.visibilityFlagIndex != kUnavailableFlagIndex
             || node.visibilityCharacterFlagIndex != kUnavailableFlagIndex) {
             continue;
@@ -111,20 +105,12 @@ std::size_t apply_category_gates(std::span<std::int32_t> objectiveValues, bool r
             || static_cast<std::size_t>(node.valueIndex) >= objectiveValues.size()) {
             continue;
         }
-        // Three lore nodes name a valueIndex inside the record-objective range and must never be
-        // written here: that slot belongs to a record's objective, not this node's own gate, and
-        // stamping it has previously redacted large numbers of records in one pass.
-        if (static_cast<std::int32_t>(node.valueIndex) >= record_claims::objective_slot_table::kRecordObjectiveRangeStart) {
+        // Some node indices alias record objectives and must remain untouched.
+        if (static_cast<std::int32_t>(node.valueIndex)
+            >= record_claims::objective_slot_table::kRecordObjectiveRangeStart) {
             continue;
         }
-        // Every value-gated category carries the same READ_VALUE-based not-zero condition. Publish
-        // an acquisition sentinel for an empty gate, whether or not that slot also drives a bar.
-        // Never lower a value already written -- a non-zero slot is either already open or holds a
-        // count from elsewhere, and this pass only ever needs to prove the gate, never reset it.
-        // Where the gate index is also the bar index, a 1 shows as a false claim on the book's
-        // parent triumph. A negative value satisfies a not-zero test while clamping out of the
-        // bar's display range, so it is tried there instead -- the shipped data uses -1 as a
-        // sentinel elsewhere (node 896 carries one, as does the character bank).
+        // -1 satisfies NOT_ZERO without appearing as one collected chapter on shared bars.
         if (objectiveValues[node.valueIndex] == 0) {
             objectiveValues[node.valueIndex] = -1;
             ++set;

+ 9 - 49
Sunrise/src/state/build_data/nodes/node_catalog.h

@@ -7,79 +7,39 @@
 
 namespace sunrise::state::build_data::nodes {
 
-/** Clears every generated node definition. */
+/** Clears all generated nodes. */
 void clear() noexcept;
 
-/**
- * Checks that the definitions are dense and in native index order.
- * @param definitions Candidate rows.
- * @return True when the rows fit storage, index n sits at position n, and no child count overflows.
- */
+/** Checks capacity, native row order, and child bounds. */
 [[nodiscard]] bool valid(std::span<const Definition> definitions) noexcept;
 
-/**
- * Replaces the generated node definitions in one step.
- * @param definitions Complete dense rows in native node order.
- * @return True when the rows pass the checks and fit fixed State storage.
- */
+/** Replaces all nodes atomically after validation. */
 [[nodiscard]] bool replace(std::span<const Definition> definitions) noexcept;
 
-/**
- * Runs one callable over every node that drives a value slot.
- * Held under the shared lock, so the callable must not re-enter this domain.
- * @param visit Receives each node owning at least one record and an addressable value slot.
- */
-void for_each_driving(void* context,
-                      void (*visit)(void* context, const Definition& definition)) noexcept;
-
-/**
- * Copies every row in native node order.
- * @param output Caller-owned fixed row storage.
- * @param count Receives the copied row count, or zero when output is too small.
- * @return True when output can hold every row.
- */
+/** Copies all nodes in native order. */
 [[nodiscard]] bool snapshot(std::span<Definition> output, std::size_t& count) noexcept;
 
 /** @return Number of generated node definitions, read under the lock. */
 [[nodiscard]] std::size_t count() noexcept;
 
-/**
- * Sets the visibility gate of every lore book category over one account flag bank.
- * @param accountFlags Bank already filled from the authored policy.
- * @return Number of gates set.
- */
+/** Sets account-scoped lore visibility flags. */
 std::size_t apply_visibility(std::span<std::uint8_t> accountFlags) noexcept;
 
 /**
  * Calls back for every node, under the shared lock.
  *
- * Copying the table out costs a hundred and fifty kilobytes a call, and both callers wanted only a
- * few fields of a few rows. The callback must not take the catalog lock again.
+ * The callback runs under the catalog's shared lock and must not re-enter it.
  * @param context Passed through untouched.
  * @param visit Called once per node in native order.
  */
 void for_each(void* context, void (*visit)(void*, const Definition&) noexcept) noexcept;
 
-/**
- * Sets the character scoped visibility gates of the lore book categories.
- * @param characterFlags Character bank already filled from the authored policy.
- * @return Number of gates set.
- */
+/** Sets character-scoped lore visibility flags. */
 std::size_t apply_character_visibility(std::span<std::byte> characterFlags) noexcept;
 
 /**
- * Sets the value-gate of every lore book category that has no flag gate at all.
- *
- * Fifteen books are satisfied by apply_visibility over a flag. Eighteen more have no flag gate:
- * their expression instead reads a value slot and tests it against zero, and nothing else in this
- * build ever writes that slot, so they stay redacted forever without this. Call after every other
- * pass that can touch the value bank -- writing a bar count zeroes the same slot on a mis-sourced
- * table entry, and once that gate is zero the book stays hidden for the rest of the image.
- * @param objectiveValues Bank already filled by the authored policy and every value-writing pass.
- * @param revealAll Retained for configuration compatibility; value-gated books are always opened.
- * @return Number of gates set.
+ * Opens value-gated lore categories. Call after progress writers because some gates share bars.
  */
-std::size_t apply_category_gates(std::span<std::int32_t> objectiveValues,
-                                 bool revealAll) noexcept;
+std::size_t apply_category_gates(std::span<std::int32_t> objectiveValues) noexcept;
 
 } // namespace sunrise::state::build_data::nodes

+ 0 - 150
Sunrise/src/state/build_data/nodes/node_persistence.cpp

@@ -1,150 +0,0 @@
-#include "node_persistence.h"
-
-#include <array>
-#include <cstdio>
-#include <cstring>
-#include <vector>
-
-#include <windows.h>
-
-#include "../../../core/logging/log.h"
-#include "../../../core/filesystem/path.h"
-#include "../runtime.h"
-#include "node_catalog.h"
-
-namespace sunrise::state::build_data::nodes {
-namespace {
-
-/** The node file lives beside the claim file, in the directory the build data cache already owns. */
-constexpr std::wstring_view kNodeFileSuffix = L"\\cache\\node_definitions.bin";
-/** Identifies the file on sight, so an unrelated file of the right length cannot be read as one. */
-constexpr std::array<char, 8> kMagic{'S', 'N', 'R', 'S', 'N', 'O', 'D', '1'};
-
-core::path::Buffer g_path{};
-bool g_pathReady{};
-
-void report(const char* stage, const char* result, std::size_t detail) noexcept {
-    std::array<char, 128> line{};
-    const int written = std::snprintf(
-        line.data(), line.size(), "ev=nodes stage=%s result=%s rows=%zu", stage, result, detail);
-    if (written > 0) {
-        core::log::write(core::log::Channel::state,
-                         std::string_view{result} == "ok" ? core::log::Level::info
-                                                          : core::log::Level::warn,
-                         {line.data(), static_cast<std::size_t>(written)});
-    }
-}
-
-/**
- * Header carried ahead of the rows.
- *
- * The row width is written out and checked on the way back in. The rows are stored as they sit in
- * memory, so a build whose Definition has changed shape must not read an older file as if it had
- * not: the width mismatch rejects it, extraction runs, and the file is replaced.
- */
-struct Header {
-    std::array<char, 8> magic{};
-    std::uint32_t rows{};
-    std::uint32_t rowWidth{};
-};
-
-} // namespace
-
-/** Derives the node file path and publishes any table already held. */
-bool initialize(void* module) noexcept {
-    g_pathReady = false;
-    if (!core::path::artifact_directory(module, g_path)
-        || !core::path::append(g_path, kNodeFileSuffix)) {
-        report("initialize", "path_fail", 0);
-        return false;
-    }
-    g_pathReady = true;
-    return true;
-}
-
-/** Reads the node table and publishes it. Separate from initialize because a publish is only
- *  accepted once the build data runtime is up, which is later than path setup. */
-bool load_and_publish() noexcept {
-    if (!g_pathReady) {
-        return false;
-    }
-    const HANDLE file = CreateFileW(
-        g_path.chars.data(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, 0, nullptr);
-    if (file == INVALID_HANDLE_VALUE) {
-        // A first run, not a fault. Extraction will build the table and write it.
-        report("load", "absent", 0);
-        return false;
-    }
-
-    Header header{};
-    DWORD read = 0;
-    const bool headerRead =
-        ReadFile(file, &header, sizeof header, &read, nullptr) != FALSE && read == sizeof header;
-    if (!headerRead || std::memcmp(header.magic.data(), kMagic.data(), kMagic.size()) != 0
-        || header.rowWidth != sizeof(Definition) || header.rows == 0
-        || header.rows > kDefinitionCapacity) {
-        CloseHandle(file);
-        report("load", headerRead ? "rejected" : "header_fail", header.rows);
-        return false;
-    }
-
-    std::vector<Definition> rows(header.rows);
-    const auto expected = static_cast<DWORD>(rows.size() * sizeof(Definition));
-    const bool rowsRead =
-        ReadFile(file, rows.data(), expected, &read, nullptr) != FALSE && read == expected;
-    CloseHandle(file);
-    if (!rowsRead) {
-        report("load", "read_fail", rows.size());
-        return false;
-    }
-    // Replaces the catalog directly rather than going through publish_node_definitions. That path
-    // opens a publication transaction, and a saved cache freezes every domain, so on the warm start
-    // this exists for it is refused before validation is even reached. The freeze is there to keep
-    // published domains agreeing with the cache; this domain is not in the cache, and the rows being
-    // restored are the ones extraction published and wrote here, so the same checks are run and the
-    // agreement the freeze protects is not touched.
-    if (!valid(std::span<const Definition>{rows})
-        || !replace(std::span<const Definition>{rows})) {
-        report("load", "publish_fail", rows.size());
-        return false;
-    }
-    report("load", "ok", rows.size());
-    return true;
-}
-
-/** Writes the node table so the next start does not need the package pass to rebuild it. */
-bool store(std::span<const Definition> definitions) noexcept {
-    if (!g_pathReady || definitions.empty() || definitions.size() > kDefinitionCapacity) {
-        return false;
-    }
-    Header header{};
-    header.magic = kMagic;
-    header.rows = static_cast<std::uint32_t>(definitions.size());
-    header.rowWidth = static_cast<std::uint32_t>(sizeof(Definition));
-
-    const HANDLE file = CreateFileW(g_path.chars.data(),
-                                    GENERIC_WRITE,
-                                    0,
-                                    nullptr,
-                                    CREATE_ALWAYS,
-                                    FILE_ATTRIBUTE_NORMAL,
-                                    nullptr);
-    if (file == INVALID_HANDLE_VALUE) {
-        report("store", "open_fail", definitions.size());
-        return false;
-    }
-    DWORD written = 0;
-    bool complete =
-        WriteFile(file, &header, sizeof header, &written, nullptr) != FALSE
-        && written == sizeof header;
-    if (complete) {
-        const auto size = static_cast<DWORD>(definitions.size() * sizeof(Definition));
-        complete = WriteFile(file, definitions.data(), size, &written, nullptr) != FALSE
-                   && written == size;
-    }
-    complete = CloseHandle(file) != FALSE && complete;
-    report("store", complete ? "ok" : "write_fail", definitions.size());
-    return complete;
-}
-
-} // namespace sunrise::state::build_data::nodes

+ 0 - 44
Sunrise/src/state/build_data/nodes/node_persistence.h

@@ -1,44 +0,0 @@
-#pragma once
-
-#include <cstddef>
-#include <span>
-
-#include "definition.h"
-
-namespace sunrise::state::build_data::nodes {
-
-/**
- * Keeps the presentation node table across restarts, in a file of its own.
- *
- * The build data cache does not carry this domain, and the extraction pass only runs when the
- * investment refresh gate finds something missing. Nodes are not in that gate, so a warm start ran
- * with an empty node table: no category was counted and no progress bar could move, whatever had
- * been claimed. Adding nodes to either gate is worse than the bug -- one is inert because it only
- * governs cache writes, and the other retries the package pass forever because nodes do not publish
- * on that path -- so the domain is persisted beside the claim file instead. Neither gate changes,
- * and a warm start simply finds the table already published.
- */
-
-/**
- * Derives the node file path and publishes any table already held.
- * A missing file is not a failure: it is a first run, and extraction will write one.
- * @param module Loaded DLL, used to find the artifact directory.
- * @return True when the path resolves. Loading is best effort and reported separately.
- */
-[[nodiscard]] bool initialize(void* module) noexcept;
-
-/**
- * Reads the held table and publishes it, if there is one.
- * Kept apart from initialize because a publish is only accepted once the build data runtime is up.
- * @return True when a table was published.
- */
-[[nodiscard]] bool load_and_publish() noexcept;
-
-/**
- * Writes the node table so the next start does not need the package pass to rebuild it.
- * @param definitions Complete rows in native node order, as published.
- * @return True when the file is written whole.
- */
-[[nodiscard]] bool store(std::span<const Definition> definitions) noexcept;
-
-} // namespace sunrise::state::build_data::nodes

+ 15 - 20
Sunrise/src/state/build_data/records/record_catalog.cpp

@@ -1,5 +1,6 @@
 #include "record_catalog.h"
 
+#include "../../record_claims/record_claims.h"
 #include "../table.h"
 
 namespace sunrise::state::build_data::records {
@@ -12,8 +13,11 @@ Table<Definition, kDefinitionCapacity> g_definitions;
 
 /** Clears every generated record definition under the catalog lock. */
 void clear() noexcept {
-    const Lock::Exclusive guard(g_lock);
-    g_definitions.clear();
+    {
+        const Lock::Exclusive guard(g_lock);
+        g_definitions.clear();
+    }
+    record_claims::invalidate_build_data_cache();
 }
 
 /** Checks that the definitions are dense and in native index order. */
@@ -34,8 +38,15 @@ bool replace(std::span<const Definition> definitions) noexcept {
     if (!valid(definitions)) {
         return false;
     }
-    const Lock::Exclusive guard(g_lock);
-    return g_definitions.replace(definitions);
+    bool replaced = false;
+    {
+        const Lock::Exclusive guard(g_lock);
+        replaced = g_definitions.replace(definitions);
+    }
+    if (replaced) {
+        record_claims::invalidate_build_data_cache();
+    }
+    return replaced;
 }
 
 /** Finds one record by the native row a claim names. */
@@ -55,22 +66,6 @@ bool snapshot(std::span<Definition> output, std::size_t& count) noexcept {
     return g_definitions.snapshot(output, count);
 }
 
-/** Finds the record that displays one lore row. */
-bool find_by_lore_row(std::uint16_t loreRow, Definition& definition) noexcept {
-    if (loreRow == kUnavailableLoreRow) {
-        return false;
-    }
-    const Lock::Shared guard(g_lock);
-    for (const Definition& row : g_definitions.rows()) {
-        if (row.loreRow != loreRow) {
-            continue;
-        }
-        definition = row;
-        return true;
-    }
-    return false;
-}
-
 /** @return Number of generated record definitions, read under the lock. */
 std::size_t count() noexcept {
     const Lock::Shared guard(g_lock);

+ 0 - 11
Sunrise/src/state/build_data/records/record_catalog.h

@@ -43,15 +43,4 @@ void clear() noexcept;
 /** @return Number of generated record definitions, read under the lock. */
 [[nodiscard]] std::size_t count() noexcept;
 
-/**
- * Finds the record that displays one lore row.
- *
- * A collectible and the chapter record it completes name the same lore row, so this is the join
- * between them. A book's parent triumph names no row and is never returned.
- * @param loreRow Lore table row, from a collectible or from elsewhere.
- * @param definition Receives the record.
- * @return True when exactly one record displays that row.
- */
-[[nodiscard]] bool find_by_lore_row(std::uint16_t loreRow, Definition& definition) noexcept;
-
 } // namespace sunrise::state::build_data::records

+ 0 - 150
Sunrise/src/state/build_data/records/record_persistence.cpp

@@ -1,150 +0,0 @@
-#include "record_persistence.h"
-
-#include <array>
-#include <cstdio>
-#include <cstring>
-#include <vector>
-
-#include <windows.h>
-
-#include "../../../core/logging/log.h"
-#include "../../../core/filesystem/path.h"
-#include "../runtime.h"
-#include "record_catalog.h"
-
-namespace sunrise::state::build_data::records {
-namespace {
-
-/** The record file lives beside the claim file, in the directory the build data cache already owns. */
-constexpr std::wstring_view kRecordFileSuffix = L"\\cache\\record_definitions.bin";
-/** Identifies the file on sight, so an unrelated file of the right length cannot be read as one. */
-constexpr std::array<char, 8> kMagic{'S', 'N', 'R', 'S', 'R', 'E', 'C', '1'};
-
-core::path::Buffer g_path{};
-bool g_pathReady{};
-
-void report(const char* stage, const char* result, std::size_t detail) noexcept {
-    std::array<char, 128> line{};
-    const int written = std::snprintf(
-        line.data(), line.size(), "ev=recstore stage=%s result=%s rows=%zu", stage, result, detail);
-    if (written > 0) {
-        core::log::write(core::log::Channel::state,
-                         std::string_view{result} == "ok" ? core::log::Level::info
-                                                          : core::log::Level::warn,
-                         {line.data(), static_cast<std::size_t>(written)});
-    }
-}
-
-/**
- * Header carried ahead of the rows.
- *
- * The row width is written out and checked on the way back in. The rows are stored as they sit in
- * memory, so a build whose Definition has changed shape must not read an older file as if it had
- * not: the width mismatch rejects it, extraction runs, and the file is replaced.
- */
-struct Header {
-    std::array<char, 8> magic{};
-    std::uint32_t rows{};
-    std::uint32_t rowWidth{};
-};
-
-} // namespace
-
-/** Derives the record file path and publishes any table already held. */
-bool initialize(void* module) noexcept {
-    g_pathReady = false;
-    if (!core::path::artifact_directory(module, g_path)
-        || !core::path::append(g_path, kRecordFileSuffix)) {
-        report("initialize", "path_fail", 0);
-        return false;
-    }
-    g_pathReady = true;
-    return true;
-}
-
-/** Reads the record table and publishes it. Separate from initialize because a publish is only
- *  accepted once the build data runtime is up, which is later than path setup. */
-bool load_and_publish() noexcept {
-    if (!g_pathReady) {
-        return false;
-    }
-    const HANDLE file = CreateFileW(
-        g_path.chars.data(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, 0, nullptr);
-    if (file == INVALID_HANDLE_VALUE) {
-        // A first run, not a fault. Extraction will build the table and write it.
-        report("load", "absent", 0);
-        return false;
-    }
-
-    Header header{};
-    DWORD read = 0;
-    const bool headerRead =
-        ReadFile(file, &header, sizeof header, &read, nullptr) != FALSE && read == sizeof header;
-    if (!headerRead || std::memcmp(header.magic.data(), kMagic.data(), kMagic.size()) != 0
-        || header.rowWidth != sizeof(Definition) || header.rows == 0
-        || header.rows > kDefinitionCapacity) {
-        CloseHandle(file);
-        report("load", headerRead ? "rejected" : "header_fail", header.rows);
-        return false;
-    }
-
-    std::vector<Definition> rows(header.rows);
-    const auto expected = static_cast<DWORD>(rows.size() * sizeof(Definition));
-    const bool rowsRead =
-        ReadFile(file, rows.data(), expected, &read, nullptr) != FALSE && read == expected;
-    CloseHandle(file);
-    if (!rowsRead) {
-        report("load", "read_fail", rows.size());
-        return false;
-    }
-    // Replaces the catalog directly rather than going through publish_node_definitions. That path
-    // opens a publication transaction, and a saved cache freezes every domain, so on the warm start
-    // this exists for it is refused before validation is even reached. The freeze is there to keep
-    // published domains agreeing with the cache; this domain is not in the cache, and the rows being
-    // restored are the ones extraction published and wrote here, so the same checks are run and the
-    // agreement the freeze protects is not touched.
-    if (!valid(std::span<const Definition>{rows})
-        || !replace(std::span<const Definition>{rows})) {
-        report("load", "publish_fail", rows.size());
-        return false;
-    }
-    report("load", "ok", rows.size());
-    return true;
-}
-
-/** Writes the record table so the next start does not need the package pass to rebuild it. */
-bool store(std::span<const Definition> definitions) noexcept {
-    if (!g_pathReady || definitions.empty() || definitions.size() > kDefinitionCapacity) {
-        return false;
-    }
-    Header header{};
-    header.magic = kMagic;
-    header.rows = static_cast<std::uint32_t>(definitions.size());
-    header.rowWidth = static_cast<std::uint32_t>(sizeof(Definition));
-
-    const HANDLE file = CreateFileW(g_path.chars.data(),
-                                    GENERIC_WRITE,
-                                    0,
-                                    nullptr,
-                                    CREATE_ALWAYS,
-                                    FILE_ATTRIBUTE_NORMAL,
-                                    nullptr);
-    if (file == INVALID_HANDLE_VALUE) {
-        report("store", "open_fail", definitions.size());
-        return false;
-    }
-    DWORD written = 0;
-    bool complete =
-        WriteFile(file, &header, sizeof header, &written, nullptr) != FALSE
-        && written == sizeof header;
-    if (complete) {
-        const auto size = static_cast<DWORD>(definitions.size() * sizeof(Definition));
-        complete = WriteFile(file, definitions.data(), size, &written, nullptr) != FALSE
-                   && written == size;
-    }
-    complete = CloseHandle(file) != FALSE && complete;
-    report("store", complete ? "ok" : "write_fail", definitions.size());
-    return complete;
-}
-
-} // namespace sunrise::state::build_data::records

+ 0 - 44
Sunrise/src/state/build_data/records/record_persistence.h

@@ -1,44 +0,0 @@
-#pragma once
-
-#include <cstddef>
-#include <span>
-
-#include "definition.h"
-
-namespace sunrise::state::build_data::records {
-
-/**
- * Keeps the record table across restarts, in a file of its own.
- *
- * The build data cache does not carry this domain, and the extraction pass only runs when the
- * investment refresh gate finds something missing. Nodes are not in that gate, so a warm start ran
- * with an empty record table: no category was counted and no progress bar could move, whatever had
- * been claimed. Adding records to either gate is worse than the bug -- one is inert because it only
- * governs cache writes, and the other retries the package pass forever because records do not publish
- * on that path -- so the domain is persisted beside the claim file instead. Neither gate changes,
- * and a warm start simply finds the table already published.
- */
-
-/**
- * Derives the record file path and publishes any table already held.
- * A missing file is not a failure: it is a first run, and extraction will write one.
- * @param module Loaded DLL, used to find the artifact directory.
- * @return True when the path resolves. Loading is best effort and reported separately.
- */
-[[nodiscard]] bool initialize(void* module) noexcept;
-
-/**
- * Reads the held table and publishes it, if there is one.
- * Kept apart from initialize because a publish is only accepted once the build data runtime is up.
- * @return True when a table was published.
- */
-[[nodiscard]] bool load_and_publish() noexcept;
-
-/**
- * Writes the record table so the next start does not need the package pass to rebuild it.
- * @param definitions Complete rows in native node order, as published.
- * @return True when the file is written whole.
- */
-[[nodiscard]] bool store(std::span<const Definition> definitions) noexcept;
-
-} // namespace sunrise::state::build_data::records

+ 8 - 0
Sunrise/src/state/build_data/records/rewards/definition.h

@@ -10,6 +10,14 @@ namespace sunrise::state::build_data::records::rewards {
  * this is ample headroom rather than a measured ceiling.
  */
 inline constexpr std::size_t kRewardCapacity = 4096;
+/** Widest reward bundle in the generated table. */
+inline constexpr std::size_t kRewardPerRecordCapacity = 3;
+
+/** Installed item row resolved from one generated reward. */
+struct ResolvedReward {
+    std::uint16_t itemDefinitionIndex{};
+    std::int32_t quantity{};
+};
 
 /**
  * One manifest-sourced record reward: claiming the named Triumph grants the named item.

+ 25 - 26
Sunrise/src/state/build_data/records/rewards/reward_build_data_runtime.cpp

@@ -1,47 +1,46 @@
+#include <limits>
+
 #include "../../runtime.h"
 #include "reward_catalog.h"
 
 namespace sunrise::state::build_data {
 namespace {
 
-/** Carries one resolution attempt through the raw RowVisitor callback. */
 struct ResolveContext {
-    std::uint16_t itemDefinitionIndex{};
-    std::int32_t quantity{};
-    bool resolved{false};
+    std::array<records::rewards::ResolvedReward, records::rewards::kRewardPerRecordCapacity>*
+        rewards{};
+    std::size_t count{};
+    bool valid{true};
 };
 
-/** Accepts the first visited row whose item hash resolves in this build's item table. */
-bool resolve_first_installed(void* context, const records::rewards::RewardRow& row) noexcept {
+bool resolve_installed(void* context, const records::rewards::RewardRow& row) noexcept {
     auto& resolution = *static_cast<ResolveContext*>(context);
     items::Definition item{};
     if (!find_item_definition_hash(row.itemHash, item)) {
-        // Vaulted or later-era item this build never installed. Keep looking rather than failing
-        // the whole record: the aggregate count of these is computed once when the table loads.
         return true;
     }
-    resolution.itemDefinitionIndex = item.definitionIndex;
-    resolution.quantity = static_cast<std::int32_t>(row.quantity);
-    resolution.resolved = true;
-    return false;
+    if (row.quantity == 0
+        || row.quantity > static_cast<std::uint32_t>((std::numeric_limits<std::int32_t>::max)())
+        || resolution.count >= resolution.rewards->size()) {
+        resolution.valid = false;
+        return false;
+    }
+    (*resolution.rewards)[resolution.count++] = {item.definitionIndex,
+                                                 static_cast<std::int32_t>(row.quantity)};
+    return true;
 }
 
 } // namespace
 
-/** Finds one manifest-sourced reward for a claimed record, from the shipped generated table. */
-bool find_generated_record_reward(std::uint32_t recordHash,
-                                  std::uint16_t& itemDefinitionIndex,
-                                  std::int32_t& quantity) noexcept {
-    itemDefinitionIndex = 0;
-    quantity = 0;
-    ResolveContext resolution;
-    records::rewards::visit_for_record(recordHash, resolve_first_installed, &resolution);
-    if (!resolution.resolved) {
-        return false;
-    }
-    itemDefinitionIndex = resolution.itemDefinitionIndex;
-    quantity = resolution.quantity;
-    return true;
+bool find_generated_record_rewards(std::uint32_t recordHash,
+                                   std::array<records::rewards::ResolvedReward,
+                                              records::rewards::kRewardPerRecordCapacity>& rewards,
+                                   std::size_t& rewardCount) noexcept {
+    rewards = {};
+    ResolveContext resolution{&rewards};
+    records::rewards::visit_for_record(recordHash, resolve_installed, &resolution);
+    rewardCount = resolution.count;
+    return resolution.valid;
 }
 
 } // namespace sunrise::state::build_data

+ 0 - 12
Sunrise/src/state/build_data/records/rewards/reward_catalog.cpp

@@ -10,12 +10,6 @@ Table<RewardRow, kRewardCapacity> g_rows;
 
 } // namespace
 
-/** Clears every generated record-reward row under the catalog lock. */
-void clear() noexcept {
-    const Lock::Exclusive guard(g_lock);
-    g_rows.clear();
-}
-
 /** Checks that the rows fit fixed storage. Zero rows is valid. */
 bool valid(std::span<const RewardRow> rows) noexcept {
     return rows.size() <= kRewardCapacity;
@@ -43,10 +37,4 @@ void visit_for_record(std::uint32_t recordHash, RowVisitor visitor, void* contex
     }
 }
 
-/** @return Number of generated reward rows, read under the lock. */
-std::size_t count() noexcept {
-    const Lock::Shared guard(g_lock);
-    return g_rows.count();
-}
-
 } // namespace sunrise::state::build_data::records::rewards

+ 3 - 24
Sunrise/src/state/build_data/records/rewards/reward_catalog.h

@@ -8,36 +8,15 @@
 
 namespace sunrise::state::build_data::records::rewards {
 
-/** Clears every generated record-reward row. */
-void clear() noexcept;
-
-/**
- * Checks that the rows fit fixed storage. Zero rows is valid: it is what an absent or empty
- * shipped table loads as, and is a normal deployment with no generated rewards, not a malformed
- * one.
- * @param rows Candidate rows.
- * @return True when the rows fit storage.
- */
+/** Checks fixed capacity; an absent reward table is valid empty input. */
 [[nodiscard]] bool valid(std::span<const RewardRow> rows) noexcept;
 
-/**
- * Replaces the generated record-reward table in one step.
- * @param rows Complete replacement rows, in any order.
- * @return True when the rows pass the checks and fit fixed State storage.
- */
+/** Replaces the generated reward table atomically after validation. */
 [[nodiscard]] bool replace(std::span<const RewardRow> rows) noexcept;
 
 /**
- * Visits every reward row naming one record, holding the catalog lock for the whole walk.
- * Resolving an item hash against this build's item table is the caller's concern, not the
- * catalog's: this domain only owns the authored rows extracted from the manifest join.
- * @param recordHash records::Definition::definitionHash to match.
- * @param visitor Called once per matching row, in table order. Returning false ends the walk early.
- * @param context Opaque pointer handed back to the visitor.
+ * Visits matching rows under the shared lock; returning false stops the walk.
  */
 void visit_for_record(std::uint32_t recordHash, RowVisitor visitor, void* context) noexcept;
 
-/** @return Number of generated reward rows, read under the lock. */
-[[nodiscard]] std::size_t count() noexcept;
-
 } // namespace sunrise::state::build_data::records::rewards

+ 26 - 71
Sunrise/src/state/build_data/records/rewards/reward_persistence.cpp

@@ -1,5 +1,7 @@
 #include "reward_persistence.h"
 
+#include <windows.h>
+
 #include <algorithm>
 #include <array>
 #include <cstdio>
@@ -7,85 +9,58 @@
 #include <string_view>
 #include <vector>
 
-#include <windows.h>
-
-#include "../../../../core/logging/log.h"
 #include "../../../../core/filesystem/path.h"
-#include "../../runtime.h"
+#include "../../../../core/logging/log.h"
 #include "reward_catalog.h"
 
 namespace sunrise::state::build_data::records::rewards {
 namespace {
 
-/**
- * Shipped beside settings.json in the artifact directory, not under `\cache\`: the record and node
- * tables there are extraction output the running client rebuilds on a cold cache, while this file
- * is generated ahead of time by an external tool and is never written by this process.
- */
+/** Generated input shipped beside settings.json, outside the extraction cache. */
 constexpr std::wstring_view kRewardFileSuffix = L"\\record_rewards.bin";
-/** Identifies the file on sight, so an unrelated file of the right length cannot be read as one. */
 constexpr std::array<char, 8> kMagic{'S', 'N', 'R', 'S', 'R', 'W', 'D', '1'};
 
 core::path::Buffer g_path{};
 bool g_pathReady{};
 
-/** Reports the one line this domain logs per load attempt, success or not. */
-void report(const char* result,
-           std::size_t rows,
-           std::size_t records,
-           std::size_t unresolvedItems) noexcept {
-    std::array<char, 160> line{};
-    const int written = std::snprintf(line.data(),
-                                      line.size(),
-                                      "ev=recrewards stage=load result=%s rows=%zu records=%zu "
-                                      "unresolved_items=%zu",
-                                      result,
-                                      rows,
-                                      records,
-                                      unresolvedItems);
+void report_failure(const char* result, std::size_t rows) noexcept {
+    if (!core::log::accepts(core::log::Channel::state, core::log::Level::warn)) {
+        return;
+    }
+    std::array<char, 96> line{};
+    const int written = std::snprintf(
+        line.data(), line.size(), "ev=recrewards stage=load result=%s rows=%zu", result, rows);
     if (written > 0) {
-        core::log::write(core::log::Channel::state,
-                         std::string_view{result} == "ok" ? core::log::Level::info
-                                                          : core::log::Level::warn,
-                         {line.data(), static_cast<std::size_t>(written)});
+        core::log::write(
+            core::log::Channel::state,
+            core::log::Level::warn,
+            {line.data(), (std::min)(static_cast<std::size_t>(written), line.size() - 1U)});
     }
 }
 
-/**
- * Header carried ahead of the rows, in the same style as the record and node caches: the row
- * width is written out and checked on the way back in, so a shape change on either side rejects a
- * stale layout instead of misreading it.
- */
+/** Versioned file header guarding row-layout changes. */
 struct Header {
     std::array<char, 8> magic{};
     std::uint32_t rows{};
     std::uint32_t rowWidth{};
 };
 
-/** @return Distinct record hashes named across every row. */
-[[nodiscard]] std::size_t distinct_record_count(std::vector<std::uint32_t> hashes) noexcept {
-    std::sort(hashes.begin(), hashes.end());
-    return static_cast<std::size_t>(std::unique(hashes.begin(), hashes.end()) - hashes.begin());
-}
-
 } // namespace
 
-/** Derives the reward file path. It lives beside settings.json, not the extraction cache. */
 bool initialize(void* module) noexcept {
+    (void)replace(std::span<const RewardRow>{});
+    g_path = {};
     g_pathReady = false;
     if (!core::path::artifact_directory(module, g_path)
         || !core::path::append(g_path, kRewardFileSuffix)) {
-        report("path_fail", 0, 0, 0);
+        report_failure("path_fail", 0);
         return false;
     }
     g_pathReady = true;
     return true;
 }
 
-/**
- * Loads the shipped reward table and publishes it. A missing or empty file is a clean no-op: the
- * feature stays inert and the settings-authored table remains the operator's only reward source.
- */
+/** Loads the shipped table; a missing or empty file disables generated rewards. */
 bool load_and_publish() noexcept {
     if (!g_pathReady) {
         return false;
@@ -93,31 +68,26 @@ bool load_and_publish() noexcept {
     const HANDLE file = CreateFileW(
         g_path.chars.data(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, 0, nullptr);
     if (file == INVALID_HANDLE_VALUE) {
-        // Not shipped for this deployment. Reported as an ordinary zero-row load, not a fault.
-        report("ok", 0, 0, 0);
-        return true;
+        return replace(std::span<const RewardRow>{});
     }
 
     Header header{};
     DWORD read = 0;
     const BOOL headerCall = ReadFile(file, &header, sizeof header, &read, nullptr);
     if (headerCall != FALSE && read == 0) {
-        // A zero-byte file is an explicit "no generated rewards" deployment, not corruption.
         CloseHandle(file);
-        report("ok", 0, 0, 0);
-        return true;
+        return replace(std::span<const RewardRow>{});
     }
     const bool headerRead = headerCall != FALSE && read == sizeof header;
     if (!headerRead || std::memcmp(header.magic.data(), kMagic.data(), kMagic.size()) != 0
         || header.rowWidth != sizeof(RewardRow) || header.rows > kRewardCapacity) {
         CloseHandle(file);
-        report(headerRead ? "rejected" : "header_fail", header.rows, 0, 0);
+        report_failure(headerRead ? "rejected" : "header_fail", header.rows);
         return false;
     }
     if (header.rows == 0) {
         CloseHandle(file);
-        report("ok", 0, 0, 0);
-        return true;
+        return replace(std::span<const RewardRow>{});
     }
 
     std::vector<RewardRow> rows(header.rows);
@@ -126,28 +96,13 @@ bool load_and_publish() noexcept {
         ReadFile(file, rows.data(), expected, &read, nullptr) != FALSE && read == expected;
     CloseHandle(file);
     if (!rowsRead) {
-        report("read_fail", rows.size(), 0, 0);
+        report_failure("read_fail", rows.size());
         return false;
     }
     if (!valid(std::span<const RewardRow>{rows}) || !replace(std::span<const RewardRow>{rows})) {
-        report("publish_fail", rows.size(), 0, 0);
+        report_failure("publish_fail", rows.size());
         return false;
     }
-
-    // The three numbers this domain reports are computed once, here, rather than on every claim:
-    // an item hash this build never installed (vaulted, or authored in a later era) is expected,
-    // and a claim skipping it silently is cheaper than re-deriving the same count each time.
-    std::vector<std::uint32_t> recordHashes;
-    recordHashes.reserve(rows.size());
-    std::size_t unresolved = 0;
-    for (const RewardRow& row : rows) {
-        recordHashes.push_back(row.recordHash);
-        state::build_data::items::Definition item{};
-        if (!state::build_data::find_item_definition_hash(row.itemHash, item)) {
-            ++unresolved;
-        }
-    }
-    report("ok", rows.size(), distinct_record_count(std::move(recordHashes)), unresolved);
     return true;
 }
 

+ 2 - 22
Sunrise/src/state/build_data/records/rewards/reward_persistence.h

@@ -2,30 +2,10 @@
 
 namespace sunrise::state::build_data::records::rewards {
 
-/**
- * Keeps the manifest-sourced record-reward table in its own file, beside settings.json.
- *
- * This is shipped, generated data -- an external tool joins Bungie's manifest by record hash and
- * writes the file -- not extraction-cache output, so it does not live under the cache directory
- * the record and node tables use, and nothing here ever writes it back out. The settings-authored
- * `record_rewards` table remains the operator's override and is tried first; see
- * `state::account::find_record_reward` and `find_generated_record_reward` for the precedence.
- */
-
-/**
- * Derives the reward file path next to the executable's other shipped data.
- * @param module Loaded DLL, used to find the artifact directory.
- * @return True when the path resolves. Loading is best effort and reported separately.
- */
+/** Resolves the shipped reward-table path from the loaded DLL. */
 [[nodiscard]] bool initialize(void* module) noexcept;
 
-/**
- * Loads the shipped reward table and publishes it, if one is installed.
- * A missing or empty file is a clean no-op: the feature simply stays inert. Logs one informational
- * line reporting rows loaded, distinct records covered, and item hashes that failed to resolve
- * against this build's item table.
- * @return True when the file was absent, empty, or loaded and published without error.
- */
+/** Loads and publishes the table; a missing or empty file is a clean no-op. */
 [[nodiscard]] bool load_and_publish() noexcept;
 
 } // namespace sunrise::state::build_data::records::rewards

+ 9 - 36
Sunrise/src/state/build_data/runtime.h

@@ -13,12 +13,13 @@
 #include "hash_names/definition.h"
 #include "inventory/buckets/definition.h"
 #include "items/details/definition.h"
-#include "nodes/definition.h"
-#include "records/definition.h"
 #include "items/item_catalog.h"
 #include "items/socket_plugs/definition.h"
 #include "material_requirements/material_requirement_catalog.h"
+#include "nodes/definition.h"
 #include "progressions/definition.h"
+#include "records/definition.h"
+#include "records/rewards/definition.h"
 #include "scenarios/definition.h"
 #include "socket_entry_buckets/definition.h"
 #include "socket_entry_lists/definition.h"
@@ -96,22 +97,12 @@ publish_item_definitions(std::span<const items::Definition> definitions) noexcep
 [[nodiscard]] bool find_item_definition_index(std::uint16_t definitionIndex,
                                               items::Definition& definition) noexcept;
 
-/**
- * Finds one manifest-sourced reward for a claimed record, from the shipped generated table.
- *
- * This is the fallback source behind `state::account::find_record_reward`: the settings-authored
- * `record_rewards` table is the operator's override and is tried first, and this is consulted only
- * when it names nothing for the claimed record. See `state::build_data::records::rewards` for the
- * shipped table itself. A row naming an item this build never installed (vaulted, or authored in a
- * later era than this build) is skipped rather than failing the lookup.
- * @param recordHash records::Definition::definitionHash of the claimed Triumph.
- * @param itemDefinitionIndex Receives the resolved dense item index only on success.
- * @param quantity Receives the row's authored quantity only on success.
- * @return True when the shipped table holds a resolvable reward for this record.
- */
-[[nodiscard]] bool find_generated_record_reward(std::uint32_t recordHash,
-                                                std::uint16_t& itemDefinitionIndex,
-                                                std::int32_t& quantity) noexcept;
+/** Resolves every installed reward row for one record. Empty output means no usable reward. */
+[[nodiscard]] bool
+find_generated_record_rewards(std::uint32_t recordHash,
+                              std::array<records::rewards::ResolvedReward,
+                                         records::rewards::kRewardPerRecordCapacity>& rewards,
+                              std::size_t& rewardCount) noexcept;
 
 [[nodiscard]] bool collectible_definitions_ready() noexcept;
 
@@ -552,24 +543,6 @@ publish_vendor_catalog(std::span<const vendors::IndexEntry> index,
                        std::span<const vendors::SaleRow> saleRows,
                        std::span<const vendors::InstalledRow> installedRows) noexcept;
 
-/**
- * Finds one vendor's index row, which carries the index the wire uses.
- * @param definitionHash Vendor definition hash.
- * @param entry Receives the matching row.
- * @return True when the catalog is ready and exactly one row carries the hash.
- */
-[[nodiscard]] bool find_vendor_index(std::uint32_t definitionHash,
-                                     vendors::IndexEntry& entry) noexcept;
-
-/**
- * Finds one extracted vendor definition.
- * @param definitionHash Vendor definition hash.
- * @param definition Receives the matching definition.
- * @return True when the catalog is ready and holds that definition.
- */
-[[nodiscard]] bool find_vendor_definition(std::uint32_t definitionHash,
-                                          vendors::Definition& definition) noexcept;
-
 /** @return True only when every domain is ready and any needed cache write succeeds. */
 [[nodiscard]] bool persist() noexcept;
 

+ 6 - 13
Sunrise/src/state/build_data/runtime/build_data_catalog_runtime.cpp

@@ -9,9 +9,12 @@
 #include "../items/details/item_detail_catalog.h"
 #include "../items/socket_plugs/socket_plug_catalog.h"
 #include "../material_requirements/material_requirement_catalog.h"
+#include "../nodes/node_catalog.h"
 #include "../progressions/progression_catalog.h"
+#include "../records/record_catalog.h"
 #include "../runtime.h"
 #include "../scenarios/scenario_catalog.h"
+#include "../sobjects/sobject_catalog.h"
 #include "../socket_entry_buckets/socket_entry_bucket_catalog.h"
 #include "../socket_entry_lists/socket_entry_list_catalog.h"
 #include "../spawn_sets/spawn_set_catalog.h"
@@ -340,19 +343,6 @@ bool publish_vendor_catalog(std::span<const vendors::IndexEntry> index,
                                  vendors::clear);
 }
 
-/** Finds one vendor's index row. */
-bool find_vendor_index(std::uint32_t definitionHash, vendors::IndexEntry& entry) noexcept {
-    entry = {};
-    return vendor_catalog_ready() && vendors::find_hash(definitionHash, entry);
-}
-
-/** Finds one extracted vendor definition. */
-bool find_vendor_definition(std::uint32_t definitionHash,
-                            vendors::Definition& definition) noexcept {
-    definition = {};
-    return vendor_catalog_ready() && vendors::find(definitionHash, definition);
-}
-
 namespace runtime {
 
 /** Clears every generated catalog and the configured-domain publication state. */
@@ -369,6 +359,9 @@ void clear_catalogs() noexcept {
     socket_entry_lists::clear();
     rollback_ability_publication();
     progressions::clear();
+    records::clear();
+    nodes::clear();
+    sobjects::clear();
     scenarios::clear();
     rollback_spawn_catalog_publication();
     rollback_name_catalog_publication();

+ 20 - 9
Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.cpp

@@ -2,7 +2,6 @@
 
 #include <Windows.h>
 
-#include <algorithm>
 #include <span>
 #include <vector>
 
@@ -17,14 +16,15 @@
 #include "../../items/details/item_detail_catalog.h"
 #include "../../items/socket_plugs/socket_plug_catalog.h"
 #include "../../material_requirements/material_requirement_catalog.h"
+#include "../../nodes/node_catalog.h"
 #include "../../progressions/progression_catalog.h"
 #include "../../records/record_catalog.h"
 #include "../../runtime.h"
 #include "../../scenarios/scenario_catalog.h"
+#include "../../sobjects/sobject_catalog.h"
 #include "../../socket_entry_lists/socket_entry_list_catalog.h"
 #include "../../spawn_sets/spawn_set_catalog.h"
 #include "../../vendors/vendor_catalog.h"
-#include "../build_data_catalog_runtime.h"
 #include "../domain_markers.h"
 
 namespace sunrise::state::build_data::runtime::persistence {
@@ -83,6 +83,8 @@ to_record(const constants::InvestmentConstants& value) noexcept {
            && abilities::snapshot(scratch.abilityBuckets, counts.abilityBuckets)
            && progressions::snapshot(scratch.progressions, counts.progressions)
            && records::snapshot(scratch.records, counts.records)
+           && nodes::snapshot(scratch.nodes, counts.nodes)
+           && sobjects::snapshot(scratch.sobjects, counts.sobjects)
            && scenarios::snapshot(scratch.scenarios, counts.scenarios)
            && scenarios::snapshot_groups(scratch.rosterGroups, counts.rosterGroups)
            && spawn_sets::snapshot(scratch.spawnStems, counts.spawnStems)
@@ -112,8 +114,8 @@ bool all_domains_ready() noexcept {
            && material_requirement_sets_ready() && inventory_bucket_descriptors_ready()
            && socket_entry_lists_ready() && ability_buckets_ready()
            && progression_definitions_ready() && record_definitions_ready()
-           && scenario_layouts_ready() && spawn_sets_ready()
-           && hash_names_ready() && constants::find(published);
+           && node_definitions_ready() && sobjects::count() != 0 && scenario_layouts_ready()
+           && spawn_sets_ready() && hash_names_ready() && constants::find(published);
 }
 
 /** Gives mutable views over every fixed snapshot buffer. */
@@ -156,8 +158,12 @@ cache::records::MutableDomains scratch_domains(Context& state) noexcept {
     const auto progressions =
         ensure_scratch<progressions::Definition, progressions::kDefinitionCapacity>(
             state.progressionScratch);
-    const auto recordRows = ensure_scratch<records::Definition, records::kDefinitionCapacity>(
-        state.recordScratch);
+    const auto recordRows =
+        ensure_scratch<records::Definition, records::kDefinitionCapacity>(state.recordScratch);
+    const auto nodeRows =
+        ensure_scratch<nodes::Definition, nodes::kDefinitionCapacity>(state.nodeScratch);
+    const auto sobjectRows =
+        ensure_scratch<sobjects::Definition, sobjects::kDefinitionCapacity>(state.sobjectScratch);
     const auto scenarios = ensure_scratch<scenarios::Definition, scenarios::kDefinitionCapacity>(
         state.scenarioScratch);
     const auto rosterGroups =
@@ -198,6 +204,8 @@ cache::records::MutableDomains scratch_domains(Context& state) noexcept {
         abilityBuckets,
         progressions,
         recordRows,
+        nodeRows,
+        sobjectRows,
         scenarios,
         rosterGroups,
         spawnStems,
@@ -215,9 +223,8 @@ cache::records::MutableDomains scratch_domains(Context& state) noexcept {
  * Releases one transient cache snapshot bank without walking its capacity.
  * @param storage Bank emptied and handed back to the allocator.
  */
-template <typename Value> void release_bank(std::vector<Value>& storage) noexcept {
-    storage.clear();
-    storage.shrink_to_fit();
+template <typename Value> static void release_bank(std::vector<Value>& storage) noexcept {
+    std::vector<Value>{}.swap(storage);
 }
 
 /** Releases transient cache snapshot banks without allocating or walking their capacities. */
@@ -236,6 +243,8 @@ void release_scratch_locked(Context& state) noexcept {
     release_bank(state.abilityBucketScratch);
     release_bank(state.progressionScratch);
     release_bank(state.recordScratch);
+    release_bank(state.nodeScratch);
+    release_bank(state.sobjectScratch);
     release_bank(state.scenarioScratch);
     release_bank(state.rosterGroupScratch);
     release_bank(state.spawnStemScratch);
@@ -294,6 +303,8 @@ cache::records::Domains occupied_domains(Context& state,
         std::span<const progressions::Definition>{state.progressionScratch.data(),
                                                   counts.progressions},
         std::span<const records::Definition>{state.recordScratch.data(), counts.records},
+        std::span<const nodes::Definition>{state.nodeScratch.data(), counts.nodes},
+        std::span<const sobjects::Definition>{state.sobjectScratch.data(), counts.sobjects},
         std::span<const scenarios::Definition>{state.scenarioScratch.data(), counts.scenarios},
         std::span<const scenarios::RosterGroup>{state.rosterGroupScratch.data(),
                                                 counts.rosterGroups},

+ 5 - 0
Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.h

@@ -18,8 +18,11 @@
 #include "../../items/item_catalog.h"
 #include "../../items/socket_plugs/definition.h"
 #include "../../material_requirements/material_requirement_catalog.h"
+#include "../../nodes/definition.h"
 #include "../../progressions/definition.h"
+#include "../../records/definition.h"
 #include "../../scenarios/definition.h"
+#include "../../sobjects/sobject_catalog.h"
 #include "../../socket_entry_lists/definition.h"
 #include "../../spawn_sets/definition.h"
 #include "../../vendors/definition.h"
@@ -43,6 +46,8 @@ struct Context {
     std::vector<abilities::Definition> abilityBucketScratch{};
     std::vector<progressions::Definition> progressionScratch{};
     std::vector<records::Definition> recordScratch{};
+    std::vector<nodes::Definition> nodeScratch{};
+    std::vector<sobjects::Definition> sobjectScratch{};
     std::vector<scenarios::Definition> scenarioScratch{};
     std::vector<scenarios::RosterGroup> rosterGroupScratch{};
     std::vector<spawn_sets::Stem> spawnStemScratch{};

+ 6 - 0
Sunrise/src/state/build_data/sobjects/sobject_catalog.cpp

@@ -36,6 +36,12 @@ bool replace(std::span<const Definition> definitions) noexcept {
     return g_definitions.replace(definitions);
 }
 
+/** Copies every row in incident-target order. */
+bool snapshot(std::span<Definition> output, std::size_t& count) noexcept {
+    const Lock::Shared guard(g_lock);
+    return g_definitions.snapshot(output, count);
+}
+
 /** Finds one row by the target index an incident carries. */
 bool find(std::uint16_t targetIndex, Definition& definition) noexcept {
     const Lock::Shared guard(g_lock);

+ 8 - 35
Sunrise/src/state/build_data/sobjects/sobject_catalog.h

@@ -1,49 +1,25 @@
 #pragma once
 
-#include <array>
 #include <cstddef>
 #include <cstdint>
 #include <span>
 
 namespace sunrise::state::build_data::sobjects {
 
-/**
- * The definition table an incident target names.
- *
- * An incident is the client's one general typed gameplay event, and its target is a 13-bit index
- * into this table. Pickup handling resolves that target to identify an exact record where the
- * installed row provides one, or to select the appropriate fallback path.
- *
- * The table is shipped package data. Its class is `0x80807C9B` and two installed tags carry it with
- * byte-identical hash and type-code columns, so a row index means the same thing against either.
- */
-
-/** The wire target is thirteen bits, and 7763 rows install. */
+/** Capacity of the package-derived table indexed by 13-bit wire target. */
 inline constexpr std::size_t kDefinitionCapacity = 8192;
 
 /** A row carrying this type code has no handler and cannot produce a schema. */
 inline constexpr std::int32_t kAbsentTypeCode = -1;
 
-/** One row of the table, reduced to what resolving a target needs. */
+/** Fields used to resolve one incident target. */
 struct Definition {
-    /** The eight opaque 32-bit lanes preceding the selector and type fields. */
-    std::array<std::uint32_t, 8> lanes{};
     /** FNV-1 of the definition name. */
     std::uint32_t nameHash{};
+    /** Packed pair selected by typeCode: record row in the low half or lore ordinal in the high. */
+    std::uint32_t lane4{};
     /** Selects the payload shape. Runs -1 to 48. */
     std::int32_t typeCode{kAbsentTypeCode};
-    /** Compressed-selector group, or 0xFFFF for none. */
-    std::uint16_t selectorGroup{};
-    /** Position in that group's node table. */
-    std::uint16_t nodeOrdinal{};
-    /**
-     * Row offset +16, "lane 4": a packed pair of u16s whose meaning is chosen by typeCode, not
-     * fixed. On typeCode 10 (dead ghosts and similar) the low half is a DestinyRecordDefinition row
-     * index -- 807 rows carry this type, 360 of them resolving. On type-code 2 world lore objects,
-     * the high half is a lore-object ordinal rather than a DestinyCollectibleDefinition row. The
-     * installed crystal and bone runs are contiguous and follow their manifest chapter order.
-     */
-    std::uint32_t lane4{};
 
     /** @return Lane 4's low half, the record row a typeCode-10 sobject names. */
     [[nodiscard]] constexpr std::uint16_t recordRow() const noexcept {
@@ -54,7 +30,6 @@ struct Definition {
     [[nodiscard]] constexpr std::uint16_t loreObjectOrdinal() const noexcept {
         return static_cast<std::uint16_t>(lane4 >> 16U);
     }
-
 };
 
 /** Clears every generated row. */
@@ -66,12 +41,10 @@ void clear() noexcept;
 /** Replaces the whole table in one step. */
 [[nodiscard]] bool replace(std::span<const Definition> definitions) noexcept;
 
-/**
- * Finds one row by the target index an incident carries.
- * @param targetIndex Index from the wire, already range-checked by the incident validator.
- * @param definition Receives the row.
- * @return True when the index is in range.
- */
+/** Copies every row in incident-target order. */
+[[nodiscard]] bool snapshot(std::span<Definition> output, std::size_t& count) noexcept;
+
+/** Finds a validated wire target by row index. */
 [[nodiscard]] bool find(std::uint16_t targetIndex, Definition& definition) noexcept;
 
 /** @return Number of installed rows. */

+ 0 - 247
Sunrise/src/state/lore/bubble_record_table.h

@@ -1,247 +0,0 @@
-// GENERATED FILE -- do not hand-edit.
-//
-// Produced by gen_bubble_table.py from bubble_record_map.json. To change an entry, fix
-// the map data and regenerate:
-//   python3 collectible-triumph-map/gen_bubble_table.py
-#pragma once
-
-#include <array>
-#include <cstdint>
-#include <span>
-
-namespace sunrise::state::lore::bubble_record_table {
-
-/**
- * bubble -> candidate record rows, joined from the 86657-era manifest.
- *
- * Each bucket is one bubble a lore-collectible pickup can report, holding the record rows
- * (build row indices into the record table, not definition hashes) that bubble's pickups can
- * complete. Where a bucket holds more than one row, a pickup grants the first the account
- * does not already hold -- see grant_from_bubble_table in lore_grant.h.
- *
- * 59 bubbles, generated from bubble_record_map.json's non-empty buckets. The FNV-1a
- * offset basis (0x811C9DC5) is not a real bubble and never appears here --
- * see kConfessionsNode in lore_grant.h for the instanced-activity case that used to be
- * aliased onto it.
- */
-
-namespace detail {
-
-// bubble 0x09711AEB, destination 308080871, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows0 = {819};
-// bubble 0x09777230, destination 308080871, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows1 = {800};
-// bubble 0x0E5486FF, destination 290444260, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows2 = {1834};
-// bubble 0x109F7CF6, destination 2779202173, 2 candidate row(s).
-inline constexpr std::array<std::uint16_t, 2> kRows3 = {778, 40};
-// bubble 0x110EFBDC, destination 290444260, 2 candidate row(s).
-inline constexpr std::array<std::uint16_t, 2> kRows4 = {1849, 1829};
-// bubble 0x18E267F9, destination 290444260, 2 candidate row(s).
-inline constexpr std::array<std::uint16_t, 2> kRows5 = {1826, 1827};
-// bubble 0x1CFBD6EB, destination 1199524104, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows6 = {748};
-// bubble 0x257C904B, destination 359854275, 3 candidate row(s).
-inline constexpr std::array<std::uint16_t, 3> kRows7 = {790, 803, 744};
-// bubble 0x272010E9, destination 1199524104, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows8 = {806};
-// bubble 0x29A640EF, destination 1199524104, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows9 = {749};
-// bubble 0x29B8886A, destination 359854275, 2 candidate row(s).
-inline constexpr std::array<std::uint16_t, 2> kRows10 = {788, 755};
-// bubble 0x2A2301B8, destination 2779202173, 3 candidate row(s).
-inline constexpr std::array<std::uint16_t, 3> kRows11 = {785, 783, 40};
-// bubble 0x2DCABF42, destination 2779202173, 4 candidate row(s).
-inline constexpr std::array<std::uint16_t, 4> kRows12 = {787, 772, 765, 43};
-// bubble 0x31256234, destination 359854275, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows13 = {824};
-// bubble 0x31943967, destination 359854275, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows14 = {791};
-// bubble 0x3453E759, destination 290444260, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows15 = {1828};
-// bubble 0x37A08717, destination 126924919, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows16 = {813};
-// bubble 0x3976EE58, destination 2388758973, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows17 = {811};
-// bubble 0x3C18FBCE, destination 290444260, 2 candidate row(s).
-inline constexpr std::array<std::uint16_t, 2> kRows18 = {1831, 1842};
-// bubble 0x3CB36B81, destination 1993421442, 2 candidate row(s).
-inline constexpr std::array<std::uint16_t, 2> kRows19 = {798, 815};
-// bubble 0x41006024, destination 359854275, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows20 = {821};
-// bubble 0x410C4F97, destination 2779202173, 2 candidate row(s).
-inline constexpr std::array<std::uint16_t, 2> kRows21 = {784, 42};
-// bubble 0x447E4EB5, destination 290444260, 2 candidate row(s).
-inline constexpr std::array<std::uint16_t, 2> kRows22 = {1832, 1844};
-// bubble 0x4AC36E96, destination 308080871, 2 candidate row(s).
-inline constexpr std::array<std::uint16_t, 2> kRows23 = {820, 750};
-// bubble 0x4CF9B596, destination 2388758973, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows24 = {810};
-// bubble 0x5A95C41A, destination 359854275, 5 candidate row(s).
-inline constexpr std::array<std::uint16_t, 5> kRows25 = {786, 802, 742, 741, 740};
-// bubble 0x5E856821, destination 2779202173, 2 candidate row(s).
-inline constexpr std::array<std::uint16_t, 2> kRows26 = {761, 41};
-// bubble 0x64BFD67C, destination 359854275, 3 candidate row(s).
-inline constexpr std::array<std::uint16_t, 3> kRows27 = {792, 805, 746};
-// bubble 0x6D64AA42, destination 290444260, 2 candidate row(s).
-inline constexpr std::array<std::uint16_t, 2> kRows28 = {1833, 1846};
-// bubble 0x6DDAC118, destination 2388758973, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows29 = {809};
-// bubble 0x75A68449, destination 290444260, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows30 = {1830};
-// bubble 0x773F859E, destination 2779202173, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows31 = {48};
-// bubble 0x7B3F5526, destination 359854275, 4 candidate row(s).
-inline constexpr std::array<std::uint16_t, 4> kRows32 = {793, 823, 757, 756};
-// bubble 0x7EE2011A, destination 359854275, 2 candidate row(s).
-inline constexpr std::array<std::uint16_t, 2> kRows33 = {789, 804};
-// bubble 0x83682E45, destination 290444260, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows34 = {1843};
-// bubble 0x85B17A04, destination 2218917881, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows35 = {818};
-// bubble 0x890FA3E0, destination 359854275, 2 candidate row(s).
-inline constexpr std::array<std::uint16_t, 2> kRows36 = {822, 754};
-// bubble 0x9F2597D3, destination 126924919, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows37 = {814};
-// bubble 0xA4ADFE84, destination 2779202173, 2 candidate row(s).
-inline constexpr std::array<std::uint16_t, 2> kRows38 = {780, 781};
-// bubble 0xA7CF4C10, destination 2218917881, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows39 = {797};
-// bubble 0xAA40DF1C, destination 2779202173, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows40 = {48};
-// bubble 0xADE65E0A, destination 1199524104, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows41 = {808};
-// bubble 0xB76C4F9A, destination 1199524104, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows42 = {794};
-// bubble 0xB76C4F9B, destination 1199524104, 2 candidate row(s).
-inline constexpr std::array<std::uint16_t, 2> kRows43 = {807, 747};
-// bubble 0xB9E2BD60, destination 2779202173, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows44 = {48};
-// bubble 0xBEE4E974, destination 2779202173, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows45 = {48};
-// bubble 0xC1FAAA4C, destination 126924919, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows46 = {812};
-// bubble 0xC64457D2, destination 290444260, 3 candidate row(s).
-inline constexpr std::array<std::uint16_t, 3> kRows47 = {1841, 1822, 1823};
-// bubble 0xC65A2A44, destination 2388758973, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows48 = {795};
-// bubble 0xD1CF7944, destination 359854275, 2 candidate row(s).
-inline constexpr std::array<std::uint16_t, 2> kRows49 = {753, 752};
-// bubble 0xD1EF1EF5, destination 2779202173, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows50 = {779};
-// bubble 0xEF2B6D69, destination 2218917881, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows51 = {816};
-// bubble 0xEF2B6D6A, destination 2218917881, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows52 = {817};
-// bubble 0xEFEF8119, destination 290444260, 3 candidate row(s).
-inline constexpr std::array<std::uint16_t, 3> kRows53 = {1847, 1824, 1825};
-// bubble 0xF1E4371C, destination 2779202173, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows54 = {782};
-// bubble 0xF25CEA8D, destination 290444260, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows55 = {1845};
-// bubble 0xF68613C1, destination 126924919, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows56 = {796};
-// bubble 0xFA122719, destination 290444260, 4 candidate row(s).
-inline constexpr std::array<std::uint16_t, 4> kRows57 = {1850, 1822, 1820, 1821};
-// bubble 0xFA9B272B, destination 308080871, 1 candidate row(s).
-inline constexpr std::array<std::uint16_t, 1> kRows58 = {751};
-
-/** One bubble's candidate record rows, in table order. */
-struct Bucket {
-    std::uint32_t bubbleHash;
-    std::span<const std::uint16_t> rows;
-};
-
-inline constexpr std::array<Bucket, 59> kBuckets{{
-    {0x09711AEBU, kRows0},
-    {0x09777230U, kRows1},
-    {0x0E5486FFU, kRows2},
-    {0x109F7CF6U, kRows3},
-    {0x110EFBDCU, kRows4},
-    {0x18E267F9U, kRows5},
-    {0x1CFBD6EBU, kRows6},
-    {0x257C904BU, kRows7},
-    {0x272010E9U, kRows8},
-    {0x29A640EFU, kRows9},
-    {0x29B8886AU, kRows10},
-    {0x2A2301B8U, kRows11},
-    {0x2DCABF42U, kRows12},
-    {0x31256234U, kRows13},
-    {0x31943967U, kRows14},
-    {0x3453E759U, kRows15},
-    {0x37A08717U, kRows16},
-    {0x3976EE58U, kRows17},
-    {0x3C18FBCEU, kRows18},
-    {0x3CB36B81U, kRows19},
-    {0x41006024U, kRows20},
-    {0x410C4F97U, kRows21},
-    {0x447E4EB5U, kRows22},
-    {0x4AC36E96U, kRows23},
-    {0x4CF9B596U, kRows24},
-    {0x5A95C41AU, kRows25},
-    {0x5E856821U, kRows26},
-    {0x64BFD67CU, kRows27},
-    {0x6D64AA42U, kRows28},
-    {0x6DDAC118U, kRows29},
-    {0x75A68449U, kRows30},
-    {0x773F859EU, kRows31},
-    {0x7B3F5526U, kRows32},
-    {0x7EE2011AU, kRows33},
-    {0x83682E45U, kRows34},
-    {0x85B17A04U, kRows35},
-    {0x890FA3E0U, kRows36},
-    {0x9F2597D3U, kRows37},
-    {0xA4ADFE84U, kRows38},
-    {0xA7CF4C10U, kRows39},
-    {0xAA40DF1CU, kRows40},
-    {0xADE65E0AU, kRows41},
-    {0xB76C4F9AU, kRows42},
-    {0xB76C4F9BU, kRows43},
-    {0xB9E2BD60U, kRows44},
-    {0xBEE4E974U, kRows45},
-    {0xC1FAAA4CU, kRows46},
-    {0xC64457D2U, kRows47},
-    {0xC65A2A44U, kRows48},
-    {0xD1CF7944U, kRows49},
-    {0xD1EF1EF5U, kRows50},
-    {0xEF2B6D69U, kRows51},
-    {0xEF2B6D6AU, kRows52},
-    {0xEFEF8119U, kRows53},
-    {0xF1E4371CU, kRows54},
-    {0xF25CEA8DU, kRows55},
-    {0xF68613C1U, kRows56},
-    {0xFA122719U, kRows57},
-    {0xFA9B272BU, kRows58},
-}};
-
-/** @return True if no bucket names the FNV-1a offset basis as a bubble. */
-[[nodiscard]] constexpr bool none_is_unset_sentinel() noexcept {
-    for (const Bucket& bucket : kBuckets) {
-        if (bucket.bubbleHash == 0x811C9DC5U) {
-            return false;
-        }
-    }
-    return true;
-}
-static_assert(none_is_unset_sentinel(),
-              "generated bubble table must never contain the FNV-1a offset basis -- it is not "
-              "a real bubble, see lore_grant.h's instanced-activity case instead");
-
-} // namespace detail
-
-/**
- * Looks up one bubble's candidate record rows.
- * @param bubble Bubble hash an incident reported.
- * @return The bucket's rows in table order, or an empty span when the bubble is not mapped.
- */
-[[nodiscard]] constexpr std::span<const std::uint16_t> records_for_bubble(
-    std::uint32_t bubble) noexcept {
-    for (const detail::Bucket& bucket : detail::kBuckets) {
-        if (bucket.bubbleHash == bubble) {
-            return bucket.rows;
-        }
-    }
-    return {};
-}
-
-} // namespace sunrise::state::lore::bubble_record_table

+ 24 - 104
Sunrise/src/state/lore/lore_grant.cpp

@@ -1,67 +1,15 @@
 #include "lore_grant.h"
 
-#include <atomic>
-#include "../build_data/collectibles/collectible_catalog.h"
 #include "../build_data/records/definition.h"
 #include "../build_data/records/record_catalog.h"
-#include "../build_data/records/record_persistence.h"
-#include "../build_data/runtime.h"
 #include "../record_claims/record_claims.h"
 
 namespace sunrise::state::lore {
 namespace {
 
-std::atomic<std::uint16_t> g_lastGranted{0};
-
-/** Publishes the record table once, so a warm start does not resolve exact rows empty. */
-bool ensure_records_published() noexcept {
-    namespace records = build_data::records;
-    static std::atomic<bool> published{false};
-    if (published.load(std::memory_order_relaxed)) {
-        return true;
-    }
-    const bool haveRecords = records::count() != 0 || records::load_and_publish();
-    if (haveRecords) {
-        published.store(true, std::memory_order_relaxed);
-        return true;
-    }
-    return false;
-}
-
-} // namespace
-
-/** @return A short name for the outcome, for logs. */
-const char* grant_outcome_name(GrantOutcome outcome) noexcept {
-    switch (outcome) {
-    case GrantOutcome::granted:
-        return "granted";
-    case GrantOutcome::progressed:
-        return "progressed";
-    case GrantOutcome::refused:
-        return "refused";
-    case GrantOutcome::recordNotFound:
-        return "record_not_found";
-    case GrantOutcome::noFlag:
-        return "no_flag";
-    case GrantOutcome::alreadyHeld:
-        return "already_held";
-    case GrantOutcome::notAChapter:
-        return "not_a_chapter";
-    case GrantOutcome::collectibleNotFound:
-        return "collectible_not_found";
-    case GrantOutcome::collectibleNoLore:
-        return "collectible_no_lore";
-    case GrantOutcome::loreRecordNotFound:
-        return "lore_record_not_found";
-    }
-    return "unknown";
-}
-
-/** Grants one record's completion directly, by the row an sobject's lane 4 names. */
-GrantOutcome grant_record(std::uint16_t definitionIndex) noexcept {
+/** Resolves a record row to the completion flag backing a lore chapter. */
+GrantOutcome chapter_flag(std::uint16_t definitionIndex, std::uint16_t& flagIndex) noexcept {
     namespace records = build_data::records;
-    (void)ensure_records_published();
-
     records::Definition record{};
     if (!records::find(definitionIndex, record)) {
         return GrantOutcome::recordNotFound;
@@ -69,46 +17,43 @@ GrantOutcome grant_record(std::uint16_t definitionIndex) noexcept {
     if (record.completionFlagIndex == records::kUnavailableFlagIndex) {
         return GrantOutcome::noFlag;
     }
-    // A record naming no lore row is not a chapter and must never be granted by a collectible.
     if (record.loreRow == records::kUnavailableLoreRow) {
         return GrantOutcome::notAChapter;
     }
-    // Finding lore completes a chapter; claiming it is the player's act, so an already-held
-    // chapter is left alone rather than reported as this pickup's doing.
-    if (record_claims::claimed(record.completionFlagIndex)
-        || record_claims::claimable(record.completionFlagIndex)) {
-        return GrantOutcome::alreadyHeld;
-    }
-    if (!record_claims::mark_claimable(record.completionFlagIndex)) {
-        return GrantOutcome::refused;
-    }
-    g_lastGranted.store(definitionIndex, std::memory_order_relaxed);
+    flagIndex = record.completionFlagIndex;
     return GrantOutcome::granted;
 }
 
-/** Advances one counted chapter record without completing it early. */
-GrantOutcome advance_record(std::uint16_t definitionIndex) noexcept {
-    namespace records = build_data::records;
-    (void)ensure_records_published();
+} // namespace
 
-    records::Definition record{};
-    if (!records::find(definitionIndex, record)) {
-        return GrantOutcome::recordNotFound;
+/** Grants one record's completion directly, by the row an sobject's lane 4 names. */
+GrantOutcome grant_record(std::uint16_t definitionIndex) noexcept {
+    std::uint16_t flagIndex = 0;
+    const GrantOutcome resolved = chapter_flag(definitionIndex, flagIndex);
+    if (resolved != GrantOutcome::granted) {
+        return resolved;
     }
-    if (record.completionFlagIndex == records::kUnavailableFlagIndex) {
-        return GrantOutcome::noFlag;
+    if (record_claims::mark_claimable(flagIndex)) {
+        return GrantOutcome::granted;
     }
-    if (record.loreRow == records::kUnavailableLoreRow) {
-        return GrantOutcome::notAChapter;
+    return record_claims::claimed(flagIndex) || record_claims::claimable(flagIndex)
+               ? GrantOutcome::alreadyHeld
+               : GrantOutcome::refused;
+}
+
+/** Advances one counted chapter record without completing it early. */
+GrantOutcome advance_record(std::uint16_t definitionIndex) noexcept {
+    std::uint16_t flagIndex = 0;
+    const GrantOutcome resolved = chapter_flag(definitionIndex, flagIndex);
+    if (resolved != GrantOutcome::granted) {
+        return resolved;
     }
     const record_claims::ObjectiveAdvance outcome =
-        record_claims::advance_single_objective(record.completionFlagIndex);
+        record_claims::advance_single_objective(flagIndex);
     switch (outcome) {
     case record_claims::ObjectiveAdvance::advanced:
-        g_lastGranted.store(definitionIndex, std::memory_order_relaxed);
         return GrantOutcome::progressed;
     case record_claims::ObjectiveAdvance::completed:
-        g_lastGranted.store(definitionIndex, std::memory_order_relaxed);
         return GrantOutcome::granted;
     case record_claims::ObjectiveAdvance::alreadyHeld:
         return GrantOutcome::alreadyHeld;
@@ -118,29 +63,4 @@ GrantOutcome advance_record(std::uint16_t definitionIndex) noexcept {
     return GrantOutcome::refused;
 }
 
-/** Resolves a type-2 collectible through its authored lore-row join. */
-GrantOutcome grant_collectible(std::uint16_t collectibleIndex) noexcept {
-    namespace collectibles = build_data::collectibles;
-    namespace records = build_data::records;
-    (void)ensure_records_published();
-
-    collectibles::Definition collectible{};
-    if (!build_data::find_collectible_definition(collectibleIndex, collectible)) {
-        return GrantOutcome::collectibleNotFound;
-    }
-    if (collectible.loreRow == collectibles::kUnavailableLoreRow) {
-        return GrantOutcome::collectibleNoLore;
-    }
-    records::Definition record{};
-    if (!records::find_by_lore_row(collectible.loreRow, record)) {
-        return GrantOutcome::loreRecordNotFound;
-    }
-    return grant_record(record.definitionIndex);
-}
-
-/** @return The record row the last successful grant claimed. */
-std::uint16_t last_granted_record() noexcept {
-    return g_lastGranted.load(std::memory_order_relaxed);
-}
-
 } // namespace sunrise::state::lore

+ 0 - 17
Sunrise/src/state/lore/lore_grant.h

@@ -7,35 +7,18 @@ namespace sunrise::state::lore {
 /** Why one exact collectible grant did not change account state. */
 enum class GrantOutcome : std::uint8_t {
     granted,
-    /** A counted record advanced but remains below its completion value. */
     progressed,
     refused,
     recordNotFound,
     noFlag,
     alreadyHeld,
-    /** The named record is not a lore chapter. */
     notAChapter,
-    /** The SObject's collectible row was not published. */
-    collectibleNotFound,
-    /** The collectible does not unlock a lore row. */
-    collectibleNoLore,
-    /** No chapter record displays the collectible's lore row. */
-    loreRecordNotFound,
 };
 
-/** @return A short name for the outcome, for logs. */
-[[nodiscard]] const char* grant_outcome_name(GrantOutcome outcome) noexcept;
-
 /** Grants one exact lore record by its native definition row. */
 [[nodiscard]] GrantOutcome grant_record(std::uint16_t definitionIndex) noexcept;
 
 /** Advances one exact counted lore record by one objective unit. */
 [[nodiscard]] GrantOutcome advance_record(std::uint16_t definitionIndex) noexcept;
 
-/** Resolves a type-2 SObject collectible row to its exact lore record and grants it. */
-[[nodiscard]] GrantOutcome grant_collectible(std::uint16_t collectibleIndex) noexcept;
-
-/** @return The record row the last successful grant or progress advance changed. */
-[[nodiscard]] std::uint16_t last_granted_record() noexcept;
-
 } // namespace sunrise::state::lore

+ 101 - 202
Sunrise/src/state/progression/season_pass_reward_catalog.h

@@ -8,219 +8,120 @@ namespace sunrise::state::progression::season_pass {
 
 /** Account progression used by the installed Season of Arrivals pass. */
 inline constexpr std::uint16_t kProgressionDefinitionIndex = 40;
+/** Repeating HUD bar paired with the Arrivals progression. */
+inline constexpr std::uint16_t kHudProgressionDefinitionIndex = 41;
+
+/** Each rank-one premium wrapper opens into one class armour set plus the pass weapon. */
+inline constexpr std::size_t kPremiumPackageItemCount = 6;
+
+struct PremiumClassPackage {
+    std::uint32_t hash{};
+    std::array<std::uint32_t, kPremiumPackageItemCount> items{};
+};
+
+inline constexpr std::array<PremiumClassPackage, 3> kPremiumClassPackages{{
+    {1937677973U, {3750210364U, 2930001572U, 3673831673U, 3097544525U, 3136019014U, 2357297366U}},
+    {1115291847U, {287888126U, 1585947570U, 1756730947U, 1214477175U, 1131831128U, 2357297366U}},
+    {156406410U, {327547301U, 119457531U, 2771141010U, 1173249516U, 674876967U, 2357297366U}},
+}};
+
+[[nodiscard]] constexpr const PremiumClassPackage*
+find_premium_class_package(std::uint32_t hash) noexcept {
+    for (const auto& package : kPremiumClassPackages) {
+        if (package.hash == hash) {
+            return &package;
+        }
+    }
+    return nullptr;
+}
 
 /** First account acquired-flag byte backing an Arrivals reward's claimed unlock. */
 inline constexpr std::size_t kFirstClaimAccountFlagIndex = 11'726;
 /** Reward 61 follows one reserved unlock row, so every later mapping advances one extra byte. */
 inline constexpr std::uint16_t kClaimFlagGapRewardIndex = 61;
 
-/** One reward-array row addressed directly by opcode 2400. */
+/** One opcode-2400 reward row. Field order keeps each row eight bytes. */
 struct Reward {
-    std::uint8_t requiredRank{};
     std::uint32_t itemHash{};
-    std::int32_t quantity{};
+    std::uint16_t quantity{};
+    std::uint8_t requiredRank{};
+
+    constexpr Reward(std::uint8_t rank, std::uint32_t hash, std::uint16_t count) noexcept
+        : itemHash(hash), quantity(count), requiredRank(rank) {}
 };
 
+static_assert(sizeof(Reward) == 8);
+
 /**
  * Season of Arrivals reward rows in native progression order.
  * The opcode names the array index, so class-specific rows at one rank remain distinct.
  */
 inline constexpr std::array<Reward, 196> kRewards{{
-    {2, 2979281381U, 3},
-    {3, 3159615086U, 8000},
-    {4, 2979281381U, 3},
-    {5, 3750210364U, 1},
-    {5, 287888126U, 1},
-    {5, 327547301U, 1},
-    {6, 3159615086U, 8000},
-    {7, 3453985408U, 1},
-    {8, 2979281381U, 3},
-    {9, 2817410917U, 200},
-    {10, 3136019014U, 1},
-    {10, 1131831128U, 1},
-    {10, 674876967U, 1},
-    {11, 2979281381U, 2},
-    {12, 3159615086U, 6000},
-    {13, 3767535285U, 1},
-    {14, 2979281381U, 2},
-    {15, 3673831673U, 1},
-    {15, 1756730947U, 1},
-    {15, 2771141010U, 1},
-    {16, 3159615086U, 6000},
-    {17, 3767535285U, 1},
-    {18, 2979281381U, 2},
-    {19, 2817410917U, 200},
-    {20, 2930001572U, 1},
-    {20, 1585947570U, 1},
-    {20, 119457531U, 1},
-    {21, 2979281381U, 2},
-    {23, 3767535285U, 1},
-    {25, 3097544525U, 1},
-    {25, 1214477175U, 1},
-    {25, 1173249516U, 1},
-    {26, 2979281381U, 2},
-    {27, 3767535285U, 1},
-    {29, 2817410917U, 200},
-    {30, 614426548U, 1},
-    {33, 3767535285U, 1},
-    {35, 2357297366U, 1},
-    {37, 3767535285U, 1},
-    {40, 2817410917U, 200},
-    {43, 3767535285U, 1},
-    {45, 1216130969U, 1},
-    {47, 3767535285U, 1},
-    {50, 2817410917U, 200},
-    {53, 3767535285U, 1},
-    {55, 3467984096U, 1},
-    {57, 3767535285U, 1},
-    {60, 2817410917U, 200},
-    {63, 3767535285U, 1},
-    {65, 3875551374U, 1},
-    {67, 3767535285U, 1},
-    {70, 2817410917U, 200},
-    {73, 3767535285U, 1},
-    {77, 3767535285U, 1},
-    {80, 2817410917U, 200},
-    {83, 3767535285U, 1},
-    {87, 3767535285U, 1},
-    {90, 2817410917U, 200},
-    {93, 3767535285U, 1},
-    {97, 3767535285U, 1},
-    {100, 2817410917U, 500},
-    {1, 1937677973U, 1},
-    {1, 1115291847U, 1},
-    {1, 156406410U, 1},
-    {2, 4237793825U, 250},
-    {2, 669434421U, 250},
-    {3, 1869036888U, 1},
-    {4, 3853748946U, 5},
-    {5, 3659337057U, 1},
-    {6, 1138508277U, 1},
-    {7, 1960641613U, 1},
-    {8, 3104539653U, 1},
-    {9, 51755992U, 1},
-    {10, 3594816059U, 1},
-    {11, 1993687886U, 1},
-    {12, 4237793825U, 250},
-    {12, 669434421U, 250},
-    {13, 2223145359U, 1},
-    {14, 3750210364U, 1},
-    {14, 287888126U, 1},
-    {14, 327547301U, 1},
-    {15, 3853748946U, 5},
-    {16, 1960641612U, 1},
-    {17, 3136019014U, 1},
-    {17, 1131831128U, 1},
-    {17, 674876967U, 1},
-    {18, 4104235240U, 1},
-    {19, 3104539653U, 1},
-    {20, 574946530U, 1},
-    {21, 1022552290U, 25},
-    {22, 1993687887U, 1},
-    {23, 3159615086U, 10000},
-    {24, 3673831673U, 1},
-    {24, 1756730947U, 1},
-    {24, 2771141010U, 1},
-    {25, 3875551374U, 1},
-    {26, 3659337056U, 1},
-    {27, 2930001572U, 1},
-    {27, 1585947570U, 1},
-    {27, 119457531U, 1},
-    {28, 200218998U, 1},
-    {29, 3104539653U, 1},
-    {30, 2817410917U, 200},
-    {31, 1022552290U, 25},
-    {32, 889395850U, 1},
-    {33, 3853748946U, 5},
-    {34, 3097544525U, 1},
-    {34, 1214477175U, 1},
-    {34, 1173249516U, 1},
-    {35, 2223145359U, 1},
-    {36, 1960641615U, 1},
-    {37, 3750210364U, 1},
-    {37, 287888126U, 1},
-    {37, 327547301U, 1},
-    {38, 1412498128U, 1},
-    {39, 3104539653U, 1},
-    {40, 51755993U, 5},
-    {41, 2223145359U, 1},
-    {42, 3159615086U, 12000},
-    {43, 1052346102U, 1},
-    {44, 3136019014U, 1},
-    {44, 1131831128U, 1},
-    {44, 674876967U, 1},
-    {45, 3853748946U, 5},
-    {46, 1960641614U, 1},
-    {47, 3673831673U, 1},
-    {47, 1756730947U, 1},
-    {47, 2771141010U, 1},
-    {48, 2373084427U, 1},
-    {49, 3104539653U, 1},
-    {50, 1901005163U, 1},
-    {51, 3382825553U, 1},
-    {52, 3453985408U, 1},
-    {53, 2385964065U, 1},
-    {54, 2930001572U, 1},
-    {54, 1585947570U, 1},
-    {54, 119457531U, 1},
-    {55, 2223145359U, 1},
-    {56, 3659337059U, 1},
-    {57, 3097544525U, 1},
-    {57, 1214477175U, 1},
-    {57, 1173249516U, 1},
-    {58, 3149088570U, 1},
-    {59, 3104539653U, 1},
-    {60, 4257549984U, 3},
-    {61, 3382825552U, 1},
-    {62, 3159615086U, 14000},
-    {63, 2935936434U, 1},
-    {64, 3045302654U, 1},
-    {64, 377715970U, 1},
-    {64, 263289775U, 1},
-    {65, 3875551374U, 1},
-    {66, 1960641609U, 1},
-    {67, 3889245656U, 1},
-    {67, 2463438524U, 1},
-    {67, 413332433U, 1},
-    {68, 1915493615U, 1},
-    {69, 3104539653U, 1},
-    {70, 4257549984U, 3},
-    {71, 849868305U, 1},
-    {72, 889395851U, 1},
-    {73, 255933968U, 1},
-    {74, 3875551374U, 1},
-    {75, 3282419336U, 5},
-    {76, 1960641608U, 1},
-    {77, 2745739715U, 1},
-    {77, 2082764111U, 1},
-    {77, 3378492868U, 1},
-    {78, 1022552290U, 25},
-    {79, 3104539653U, 1},
-    {80, 4257549985U, 1},
-    {81, 126458193U, 1},
-    {82, 3159615086U, 16000},
-    {83, 1711249415U, 1},
-    {84, 3282419336U, 5},
-    {85, 4257549985U, 1},
-    {86, 3659337058U, 1},
-    {87, 2574956338U, 1},
-    {87, 1764669670U, 1},
-    {87, 1598189577U, 1},
-    {88, 2801311442U, 1},
-    {89, 3104539653U, 1},
-    {90, 372702809U, 1},
-    {91, 3786371266U, 1},
-    {92, 4237793825U, 1000},
-    {92, 686728455U, 1000},
-    {93, 213682406U, 1},
-    {94, 4257549985U, 1},
-    {95, 3103387299U, 1},
-    {96, 2817410917U, 200},
-    {97, 3971891703U, 1},
-    {97, 2884709491U, 1},
-    {97, 1034893694U, 1},
-    {98, 3875551374U, 1},
-    {99, 772166226U, 1},
+    {2, 2979281381U, 3},     {3, 3159615086U, 8000},   {4, 2979281381U, 3},
+    {5, 3750210364U, 1},     {5, 287888126U, 1},       {5, 327547301U, 1},
+    {6, 3159615086U, 8000},  {7, 3453985408U, 1},      {8, 2979281381U, 3},
+    {9, 2817410917U, 200},   {10, 3136019014U, 1},     {10, 1131831128U, 1},
+    {10, 674876967U, 1},     {11, 2979281381U, 2},     {12, 3159615086U, 6000},
+    {13, 3767535285U, 1},    {14, 2979281381U, 2},     {15, 3673831673U, 1},
+    {15, 1756730947U, 1},    {15, 2771141010U, 1},     {16, 3159615086U, 6000},
+    {17, 3767535285U, 1},    {18, 2979281381U, 2},     {19, 2817410917U, 200},
+    {20, 2930001572U, 1},    {20, 1585947570U, 1},     {20, 119457531U, 1},
+    {21, 2979281381U, 2},    {23, 3767535285U, 1},     {25, 3097544525U, 1},
+    {25, 1214477175U, 1},    {25, 1173249516U, 1},     {26, 2979281381U, 2},
+    {27, 3767535285U, 1},    {29, 2817410917U, 200},   {30, 614426548U, 1},
+    {33, 3767535285U, 1},    {35, 2357297366U, 1},     {37, 3767535285U, 1},
+    {40, 2817410917U, 200},  {43, 3767535285U, 1},     {45, 1216130969U, 1},
+    {47, 3767535285U, 1},    {50, 2817410917U, 200},   {53, 3767535285U, 1},
+    {55, 3467984096U, 1},    {57, 3767535285U, 1},     {60, 2817410917U, 200},
+    {63, 3767535285U, 1},    {65, 3875551374U, 1},     {67, 3767535285U, 1},
+    {70, 2817410917U, 200},  {73, 3767535285U, 1},     {77, 3767535285U, 1},
+    {80, 2817410917U, 200},  {83, 3767535285U, 1},     {87, 3767535285U, 1},
+    {90, 2817410917U, 200},  {93, 3767535285U, 1},     {97, 3767535285U, 1},
+    {100, 2817410917U, 500}, {1, 1937677973U, 1},      {1, 1115291847U, 1},
+    {1, 156406410U, 1},      {2, 4237793825U, 250},    {2, 669434421U, 250},
+    {3, 1869036888U, 1},     {4, 3853748946U, 5},      {5, 3659337057U, 1},
+    {6, 1138508277U, 1},     {7, 1960641613U, 1},      {8, 3104539653U, 1},
+    {9, 51755992U, 1},       {10, 3594816059U, 1},     {11, 1993687886U, 1},
+    {12, 4237793825U, 250},  {12, 669434421U, 250},    {13, 2223145359U, 1},
+    {14, 3750210364U, 1},    {14, 287888126U, 1},      {14, 327547301U, 1},
+    {15, 3853748946U, 5},    {16, 1960641612U, 1},     {17, 3136019014U, 1},
+    {17, 1131831128U, 1},    {17, 674876967U, 1},      {18, 4104235240U, 1},
+    {19, 3104539653U, 1},    {20, 574946530U, 1},      {21, 1022552290U, 25},
+    {22, 1993687887U, 1},    {23, 3159615086U, 10000}, {24, 3673831673U, 1},
+    {24, 1756730947U, 1},    {24, 2771141010U, 1},     {25, 3875551374U, 1},
+    {26, 3659337056U, 1},    {27, 2930001572U, 1},     {27, 1585947570U, 1},
+    {27, 119457531U, 1},     {28, 200218998U, 1},      {29, 3104539653U, 1},
+    {30, 2817410917U, 200},  {31, 1022552290U, 25},    {32, 889395850U, 1},
+    {33, 3853748946U, 5},    {34, 3097544525U, 1},     {34, 1214477175U, 1},
+    {34, 1173249516U, 1},    {35, 2223145359U, 1},     {36, 1960641615U, 1},
+    {37, 3750210364U, 1},    {37, 287888126U, 1},      {37, 327547301U, 1},
+    {38, 1412498128U, 1},    {39, 3104539653U, 1},     {40, 51755993U, 5},
+    {41, 2223145359U, 1},    {42, 3159615086U, 12000}, {43, 1052346102U, 1},
+    {44, 3136019014U, 1},    {44, 1131831128U, 1},     {44, 674876967U, 1},
+    {45, 3853748946U, 5},    {46, 1960641614U, 1},     {47, 3673831673U, 1},
+    {47, 1756730947U, 1},    {47, 2771141010U, 1},     {48, 2373084427U, 1},
+    {49, 3104539653U, 1},    {50, 1901005163U, 1},     {51, 3382825553U, 1},
+    {52, 3453985408U, 1},    {53, 2385964065U, 1},     {54, 2930001572U, 1},
+    {54, 1585947570U, 1},    {54, 119457531U, 1},      {55, 2223145359U, 1},
+    {56, 3659337059U, 1},    {57, 3097544525U, 1},     {57, 1214477175U, 1},
+    {57, 1173249516U, 1},    {58, 3149088570U, 1},     {59, 3104539653U, 1},
+    {60, 4257549984U, 3},    {61, 3382825552U, 1},     {62, 3159615086U, 14000},
+    {63, 2935936434U, 1},    {64, 3045302654U, 1},     {64, 377715970U, 1},
+    {64, 263289775U, 1},     {65, 3875551374U, 1},     {66, 1960641609U, 1},
+    {67, 3889245656U, 1},    {67, 2463438524U, 1},     {67, 413332433U, 1},
+    {68, 1915493615U, 1},    {69, 3104539653U, 1},     {70, 4257549984U, 3},
+    {71, 849868305U, 1},     {72, 889395851U, 1},      {73, 255933968U, 1},
+    {74, 3875551374U, 1},    {75, 3282419336U, 5},     {76, 1960641608U, 1},
+    {77, 2745739715U, 1},    {77, 2082764111U, 1},     {77, 3378492868U, 1},
+    {78, 1022552290U, 25},   {79, 3104539653U, 1},     {80, 4257549985U, 1},
+    {81, 126458193U, 1},     {82, 3159615086U, 16000}, {83, 1711249415U, 1},
+    {84, 3282419336U, 5},    {85, 4257549985U, 1},     {86, 3659337058U, 1},
+    {87, 2574956338U, 1},    {87, 1764669670U, 1},     {87, 1598189577U, 1},
+    {88, 2801311442U, 1},    {89, 3104539653U, 1},     {90, 372702809U, 1},
+    {91, 3786371266U, 1},    {92, 4237793825U, 1000},  {92, 686728455U, 1000},
+    {93, 213682406U, 1},     {94, 4257549985U, 1},     {95, 3103387299U, 1},
+    {96, 2817410917U, 200},  {97, 3971891703U, 1},     {97, 2884709491U, 1},
+    {97, 1034893694U, 1},    {98, 3875551374U, 1},     {99, 772166226U, 1},
     {100, 609666430U, 1},
 }};
 
@@ -229,14 +130,12 @@ inline constexpr std::array<Reward, 196> kRewards{{
 }
 
 /** Resolves a native reward-array index to its backing account acquired-flag byte. */
-[[nodiscard]] constexpr std::size_t claim_account_flag_index(
-    std::uint16_t rewardIndex) noexcept {
+[[nodiscard]] constexpr std::size_t claim_account_flag_index(std::uint16_t rewardIndex) noexcept {
     return kFirstClaimAccountFlagIndex + rewardIndex
            + static_cast<std::size_t>(rewardIndex >= kClaimFlagGapRewardIndex);
 }
 
 static_assert(claim_account_flag_index(63) == 11'790);
-static_assert(claim_account_flag_index(static_cast<std::uint16_t>(kRewards.size() - 1U))
-              == 11'922);
+static_assert(claim_account_flag_index(static_cast<std::uint16_t>(kRewards.size() - 1U)) == 11'922);
 
 } // namespace sunrise::state::progression::season_pass

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