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

Merge pull request #110 from gagefulwood/fix/quest-set-initialization

Fix quest state initialization during item acquisition
stan 1 день назад
Родитель
Сommit
41a84d17fe
24 измененных файлов с 689 добавлено и 73 удалено
  1. 1 1
      .clang-tidy
  2. 1 0
      Sunrise/Sunrise.vcxproj
  3. 2 0
      Sunrise/src/client/content/items/packages/internal.h
  4. 7 4
      Sunrise/src/client/content/items/packages/package_item_build.cpp
  5. 40 3
      Sunrise/src/client/content/items/packages/package_item_rows.cpp
  6. 8 1
      Sunrise/src/client/content/items/packages/package_node_build.cpp
  7. 2 0
      Sunrise/src/middleware/content/packages/tables/definition_index_table.h
  8. 274 0
      Sunrise/src/middleware/content/packages/tables/quest_initialization_reader.cpp
  9. 37 0
      Sunrise/src/middleware/content/packages/tables/quest_initialization_reader.h
  10. 14 4
      Sunrise/src/middleware/datagen/family4/account/account_encoder.cpp
  11. 12 0
      Sunrise/src/middleware/datagen/family4/account/account_encoder.h
  12. 20 6
      Sunrise/src/middleware/datagen/family4/character/character_encoder.cpp
  13. 16 0
      Sunrise/src/middleware/datagen/family4/character/character_encoder.h
  14. 1 1
      Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp
  15. 7 6
      Sunrise/src/server/bap/encrypted/push/snapshot/family4_inventory_updates.cpp
  16. 1 1
      Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp
  17. 17 6
      Sunrise/src/state/build_data/cache/records/cache_record_codec.cpp
  18. 6 7
      Sunrise/src/state/build_data/cache/records/format.h
  19. 4 2
      Sunrise/src/state/build_data/items/item_catalog.cpp
  20. 4 0
      Sunrise/src/state/build_data/items/item_catalog.h
  21. 55 0
      Sunrise/src/state/build_data/items/quest_initialization.h
  22. 26 7
      Sunrise/src/state/runtime/runtime.h
  23. 121 19
      Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp
  24. 13 5
      Sunrise/src/state/runtime/state_account_transaction_helpers.h

+ 1 - 1
.clang-tidy

@@ -74,5 +74,5 @@ ExtraArgsBefore:
   - -Wdocumentation
   - -Wdocumentation
 FormatStyle: file
 FormatStyle: file
 CheckOptions:
 CheckOptions:
-  portability-restrict-system-includes.Includes: '-*,Windows.h,WinSock2.h,WS2tcpip.h,MSWSock.h,WinDNS.h,TlHelp32.h,Shellapi.h,bcrypt.h,d3d11.h,d3d11_1.h,d3dcompiler.h,detours.h,dxgi.h,wincodec.h,imgui.h,imgui_impl_dx11.h,imgui_impl_win32.h,intrin.h,algorithm,array,atomic,bit,bitset,cctype,cerrno,charconv,chrono,climits,cmath,compare,concepts,cstdarg,cstddef,cstdint,cstdio,cstdlib,cstring,cwchar,deque,functional,initializer_list,iterator,limits,map,memory,mutex,new,numeric,optional,queue,set,shared_mutex,span,sstream,string,string_view,tuple,type_traits,unordered_map,unordered_set,utility,variant,vector'
+  portability-restrict-system-includes.Includes: '-*,Windows.h,WinSock2.h,WS2tcpip.h,MSWSock.h,WinDNS.h,TlHelp32.h,Shellapi.h,bcrypt.h,d3d11.h,d3d11_1.h,d3dcompiler.h,detours.h,dxgi.h,wincodec.h,imgui.h,imgui_impl_dx11.h,imgui_impl_win32.h,intrin.h,sqlite3.h,algorithm,array,atomic,bit,bitset,cctype,cerrno,charconv,chrono,climits,cmath,compare,concepts,cstdarg,cstddef,cstdint,cstdio,cstdlib,cstring,cwchar,deque,functional,initializer_list,iterator,limits,map,memory,mutex,new,numeric,optional,queue,set,shared_mutex,span,sstream,string,string_view,tuple,type_traits,unordered_map,unordered_set,utility,variant,vector'
 ...
 ...

+ 1 - 0
Sunrise/Sunrise.vcxproj

@@ -102,6 +102,7 @@
     <ClCompile Include="src\state\gameplay\external\entity_object_types.cpp" />
     <ClCompile Include="src\state\gameplay\external\entity_object_types.cpp" />
     <ClCompile Include="src\state\investment\investment_account_store.cpp" />
     <ClCompile Include="src\state\investment\investment_account_store.cpp" />
     <ClCompile Include="src\state\investment\investment_database.cpp" />
     <ClCompile Include="src\state\investment\investment_database.cpp" />
+    <ClCompile Include="src\middleware\content\packages\tables\quest_initialization_reader.cpp" />
     <ClCompile Include="src\state\investment\investment_inventory_store.cpp" />
     <ClCompile Include="src\state\investment\investment_inventory_store.cpp" />
     <ClCompile Include="src\state\investment\investment_store_boot.cpp" />
     <ClCompile Include="src\state\investment\investment_store_boot.cpp" />
     <ClCompile Include="src\state\investment\investment_unlock_store.cpp" />
     <ClCompile Include="src\state\investment\investment_unlock_store.cpp" />

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

@@ -115,6 +115,8 @@ struct Storage {
     std::vector<std::byte> child{};
     std::vector<std::byte> child{};
     std::vector<std::byte> root{};
     std::vector<std::byte> root{};
     std::vector<std::byte> definition{};
     std::vector<std::byte> definition{};
+    std::vector<std::byte> questParentDefinition{};
+    std::vector<std::byte> questValueMap{};
     /** Shared reusable/randomized plug-set table read from investment-root slot 51. */
     /** Shared reusable/randomized plug-set table read from investment-root slot 51. */
     std::vector<std::byte> plugSetTable{};
     std::vector<std::byte> plugSetTable{};
     /** Dense item-indexed catalyst completion expressions for this package pass. */
     /** Dense item-indexed catalyst completion expressions for this package pass. */

+ 7 - 4
Sunrise/src/client/content/items/packages/package_item_build.cpp

@@ -50,7 +50,10 @@ bool ready() noexcept {
            && content::activity::entity_position_profiles::ready();
            && content::activity::entity_position_profiles::ready();
 }
 }
 
 
-/** Publishes the dense item table from the installed packages, once. */
+/**
+ * Publishes missing package domains while retaining completed domains for later calls.
+ * @return True when every owned domain is ready; false leaves unfinished work for another call.
+ */
 bool build() noexcept {
 bool build() noexcept {
     static Storage storage{};
     static Storage storage{};
     reader::BlockKeys keys{};
     reader::BlockKeys keys{};
@@ -113,9 +116,9 @@ bool build() noexcept {
             }
             }
             // The same root names the bucket and socket-list tables.
             // The same root names the bucket and socket-list tables.
             storage.root = storage.child;
             storage.root = storage.child;
-            // Records, nodes, season pass rewards and catalysts all resolve slots through the
-            // two unlock mapping tables, so they are read once here.
-            if (!state::build_data::record_definitions_ready()
+            // Quest, record, node, season and catalyst reads share these unlock maps.
+            if (!state::build_data::item_definitions_ready()
+                || !state::build_data::record_definitions_ready()
                 || !state::build_data::node_definitions_ready()
                 || !state::build_data::node_definitions_ready()
                 || !state::build_data::season_pass_ready() || !exotic_catalysts_settled()) {
                 || !state::build_data::season_pass_ready() || !exotic_catalysts_settled()) {
                 reason = "unlock_maps";
                 reason = "unlock_maps";

+ 40 - 3
Sunrise/src/client/content/items/packages/package_item_rows.cpp

@@ -1,6 +1,7 @@
 #include <array>
 #include <array>
 #include <span>
 #include <span>
 
 
+#include "../../../../middleware/content/packages/tables/quest_initialization_reader.h"
 #include "../../../../state/build_data/items/catalysts/exotic_catalyst_builder.h"
 #include "../../../../state/build_data/items/catalysts/exotic_catalyst_builder.h"
 #include "../../../../state/build_data/items/details/item_detail_catalog.h"
 #include "../../../../state/build_data/items/details/item_detail_catalog.h"
 #include "../../../../state/build_data/runtime.h"
 #include "../../../../state/build_data/runtime.h"
@@ -46,7 +47,15 @@ bool exotic_catalysts_settled() noexcept {
     return state::build_data::exotic_catalysts_ready() || g_catalystsUnsupported;
     return state::build_data::exotic_catalysts_ready() || g_catalystsUnsupported;
 }
 }
 
 
-/** Walks the located item index table, then publishes every domain that depends on it. */
+/**
+ * Publishes missing item domains from the located index table and its definitions.
+ * @param source Borrowed package source.
+ * @param storage Pass storage holding the table in child, root data, maps, and output rows.
+ * @param table Located item index array within storage.child.
+ * @param rowCount Starts at zero; receives the rows retained even if publication fails.
+ * @param reason Receives the last stage reached or its failure reason.
+ * @return True when this pass's required item domains are ready; failure may retain prior results.
+ */
 bool build_item_rows(const reader::Source& source,
 bool build_item_rows(const reader::Source& source,
                      Storage& storage,
                      Storage& storage,
                      const tables::Array& table,
                      const tables::Array& table,
@@ -97,13 +106,40 @@ bool build_item_rows(const reader::Source& source,
         tables::items::Row item{};
         tables::items::Row item{};
         item.definitionHash = row.definitionHash;
         item.definitionHash = row.definitionHash;
         item.definitionIndex = static_cast<std::uint16_t>(index);
         item.definitionIndex = static_cast<std::uint16_t>(index);
-        if (!reader::read_tag(source, storage.scratch, row.targetTag, storage.definition)
+        std::uint32_t itemClass = 0;
+        if (!reader::read_tag(source, storage.scratch, row.targetTag, storage.definition, itemClass)
             || !tables::items::read_definition(std::span<const std::byte>{storage.definition},
             || !tables::items::read_definition(std::span<const std::byte>{storage.definition},
                                                item)) {
                                                item)) {
             continue;
             continue;
         }
         }
         const std::uint32_t plugCategoryHash =
         const std::uint32_t plugCategoryHash =
             corrected_plug_category(item.definitionHash, item.plugCategoryHash);
             corrected_plug_category(item.definitionHash, item.plugCategoryHash);
+        build_items::QuestInitialization quest{};
+        const auto parentIndex = tables::items::quest_parent(storage.definition);
+        if (needDefinitions && itemClass == tables::kItemDefinitionClass
+            && parentIndex < table.count) {
+            std::span<const std::byte> parent = storage.definition;
+            tables::IndexRow parentRow{};
+            std::uint32_t parentClass = itemClass;
+            const bool parentReady = parentIndex == item.definitionIndex
+                                     || (tables::index_row(container, table, parentIndex, parentRow)
+                                         && reader::read_tag(source,
+                                                             storage.scratch,
+                                                             parentRow.targetTag,
+                                                             storage.questParentDefinition,
+                                                             parentClass));
+            if (parentIndex != item.definitionIndex) {
+                parent = storage.questParentDefinition;
+            }
+            if (parentReady && parentClass == tables::kItemDefinitionClass) {
+                quest =
+                    tables::items::read_quest_initialization(storage.definition,
+                                                             item.definitionIndex,
+                                                             parent,
+                                                             static_cast<std::size_t>(table.count),
+                                                             storage.questValueMap);
+            }
+        }
         storage.rows[rowCount++] =
         storage.rows[rowCount++] =
             state::build_data::items::Definition{item.definitionHash,
             state::build_data::items::Definition{item.definitionHash,
                                                  item.definitionIndex,
                                                  item.definitionIndex,
@@ -113,7 +149,8 @@ bool build_item_rows(const reader::Source& source,
                                                  item.tier,
                                                  item.tier,
                                                  plugCategoryHash,
                                                  plugCategoryHash,
                                                  item.rollSetIndex,
                                                  item.rollSetIndex,
-                                                 item.linkedPlugIndex};
+                                                 item.linkedPlugIndex,
+                                                 quest};
         if (needSocketRows) {
         if (needSocketRows) {
             storage.specialPlugCategories[item.definitionIndex] =
             storage.specialPlugCategories[item.definitionIndex] =
                 special_plug_category(plugCategoryHash);
                 special_plug_category(plugCategoryHash);

+ 8 - 1
Sunrise/src/client/content/items/packages/package_node_build.cpp

@@ -110,7 +110,13 @@ read_slot_map(std::span<const std::byte> blob, std::size_t descriptor, SlotMap&
 
 
 } // namespace
 } // namespace
 
 
-/** Reads both unlock mapping tables. A gate names a slot; the bank index is the row naming it. */
+/**
+ * A gate names a slot; its saved bank index is the mapping row that names that slot.
+ * @param source Borrowed package source.
+ * @param storage Receives four slot maps and retained value-map bytes; may be partial on failure.
+ * @param root Investment root bytes naming the flag and value mapping tables.
+ * @return True when both account maps read; a character map may remain unmapped.
+ */
 bool read_unlock_slot_maps(const reader::Source& source,
 bool read_unlock_slot_maps(const reader::Source& source,
                            Storage& storage,
                            Storage& storage,
                            std::span<const std::byte> root) noexcept {
                            std::span<const std::byte> root) noexcept {
@@ -134,6 +140,7 @@ bool read_unlock_slot_maps(const reader::Source& source,
         return false;
         return false;
     }
     }
     const std::span<const std::byte> valueMap{storage.child};
     const std::span<const std::byte> valueMap{storage.child};
+    storage.questValueMap = storage.child;
     const bool valueMapRead =
     const bool valueMapRead =
         read_slot_map(valueMap, tables::kAccountValueMapDescriptor, maps.accountValue);
         read_slot_map(valueMap, tables::kAccountValueMapDescriptor, maps.accountValue);
     (void)read_slot_map(valueMap, tables::kCharacterValueMapDescriptor, maps.characterValue);
     (void)read_slot_map(valueMap, tables::kCharacterValueMapDescriptor, maps.characterValue);

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

@@ -30,6 +30,8 @@ inline constexpr std::uint16_t kAbsentPackageId = 0xFFFFU;
 
 
 /** Element class of the item index table inside the investment container. */
 /** Element class of the item index table inside the investment container. */
 inline constexpr std::uint32_t kItemIndexTableClass = 0x80807BE8U;
 inline constexpr std::uint32_t kItemIndexTableClass = 0x80807BE8U;
+/** Serialized item definition class, distinct from the item index table class. */
+inline constexpr std::uint32_t kItemDefinitionClass = 0x80807BEAU;
 /** Investment root slot of the records and lore table. */
 /** Investment root slot of the records and lore table. */
 inline constexpr std::size_t kRecordTableSlot = 72;
 inline constexpr std::size_t kRecordTableSlot = 72;
 /** One record row, wider than any field this pass reads. */
 /** One record row, wider than any field this pass reads. */

+ 274 - 0
Sunrise/src/middleware/content/packages/tables/quest_initialization_reader.cpp

@@ -0,0 +1,274 @@
+#include "quest_initialization_reader.h"
+
+#include <limits>
+
+#include "definition_index_table.h"
+#include "internal.h"
+
+namespace sunrise::middleware::content::packages::tables::items {
+namespace {
+
+using Quest = state::build_data::items::QuestInitialization;
+
+/** Policy: shorter item blobs stay outside the supported quest layout. */
+constexpr std::size_t kMinimumQuestDefinitionSize = 0xF0;
+/** A serialized block's 32-bit class ID sits immediately before its payload. */
+constexpr std::size_t kBlockClassPrefixSize = sizeof(std::uint32_t);
+/** Policy: keep the full fixed block prefix before reading nested arrays. */
+constexpr std::size_t kMinimumQuestBlockSize = 0x20;
+
+/** Item +0x30 holds a signed 64-bit offset relative to that field. */
+constexpr std::size_t kItemObjectiveBlockOffset = 0x30;
+/** This block holds objective indices and the item index that owns the quest set. */
+constexpr std::uint32_t kItemObjectiveBlockClass = 0x808077EBU;
+/** Objective block +0x1C holds a 16-bit item-table index, not a definition hash. */
+constexpr std::size_t kObjectiveParentItemOffset = 0x1C;
+/** Objective array entries are 16-bit table indices. */
+constexpr std::size_t kObjectiveReferenceStride = sizeof(std::uint16_t);
+
+/** Item +0x60 holds the quest-set block offset relative to that field. */
+constexpr std::size_t kItemQuestSetBlockOffset = 0x60;
+/** This block holds ordered quest members and the value slot that selects the active step. */
+constexpr std::uint32_t kQuestSetBlockClass = 0x808077C8U;
+/** Set +0x10 holds a 16-bit unlock value slot; a map supplies its saved bank row. */
+constexpr std::size_t kQuestSetValueSlotOffset = 0x10;
+/** Set +0x1C holds a one-byte mode separate from the member values. */
+constexpr std::size_t kQuestSetModeOffset = 0x1C;
+/** Policy: only mode 1 permits first-step writes; the other modes are not decoded. */
+constexpr std::uint8_t kSupportedQuestSetMode = 1;
+/** Each member pairs a signed step value with the item that represents it. */
+constexpr std::uint32_t kQuestSetMemberClass = 0x808077CAU;
+/** A member is a 32-bit value, a 16-bit item index, then a 16-bit reserved field. */
+constexpr std::size_t kQuestSetMemberStride = 8;
+/** Member +0 holds the signed step identifier; values are not ordered progress counts. */
+constexpr std::size_t kQuestSetMemberValueOffset = 0;
+/** Member +4 holds the item-table index for that step. */
+constexpr std::size_t kQuestSetMemberItemOffset = 4;
+/** Only member rows whose final 16 bits are zero are supported. */
+constexpr std::size_t kQuestSetMemberReservedOffset = 6;
+
+/** Item +0x90 holds the unlock block offset relative to that field; zero means absent. */
+constexpr std::size_t kItemUnlockBlockOffset = 0x90;
+/** This block's first array names the flags supplied by item presence. */
+constexpr std::uint32_t kItemUnlockBlockClass = 0x808077ABU;
+/** Presence-flag entries hold unlock slot indices, not saved bank rows. */
+constexpr std::uint32_t kItemPresenceFlagClass = 0x80807D4BU;
+/** Each presence-flag entry occupies one 16-bit slot. */
+constexpr std::size_t kItemPresenceFlagStride = sizeof(std::uint16_t);
+/** Authored value/flag slots must fit the nonnegative range of a signed 16-bit mapping. */
+constexpr std::uint16_t kUnlockSlotLimit = 0x8000U;
+/** Policy: pursuits without presence flags need a bucket-37 root with no objective block. */
+constexpr std::uint8_t kSeparateQuestRootBucketId = 37;
+
+/** Map +40 has no supported save bank; a matching slot makes initialization unsafe. */
+constexpr std::size_t kThirdValueMapDescriptor = 40;
+/** Map +56 is also checked for duplicate slots but has no supported save bank. */
+constexpr std::size_t kFourthValueMapDescriptor = 56;
+/** A matching map row is supported only when its final 16 bits are zero. */
+constexpr std::size_t kValueMapReservedOffset = 6;
+/** The all-one 16-bit row is reserved and cannot name saved state. */
+constexpr std::uint16_t kUnavailableValueMapRow = 0xFFFFU;
+
+/**
+ * A block pointer is relative to its own field; its class ID precedes the payload.
+ * @param bytes Blob containing the pointer and block.
+ * @param field Offset of the signed 64-bit block pointer.
+ * @param expectedClass Required serialized block class.
+ * @param offset Receives the payload offset; use only on success.
+ * @return False for absent, out-of-bounds, short, or wrong-class blocks.
+ */
+[[nodiscard]] bool block(std::span<const std::byte> bytes,
+                         std::size_t field,
+                         std::uint32_t expectedClass,
+                         std::size_t& offset) noexcept {
+    std::int64_t relative = 0;
+    if (!read(bytes, field, relative) || relative == 0
+        || relative
+               > (std::numeric_limits<std::int64_t>::max)() - static_cast<std::int64_t>(field)) {
+        return false;
+    }
+    const auto target = relative + static_cast<std::int64_t>(field);
+    if (target < static_cast<std::int64_t>(kBlockClassPrefixSize)
+        || static_cast<std::uint64_t>(target) > bytes.size()
+        || bytes.size() - static_cast<std::size_t>(target) < kMinimumQuestBlockSize) {
+        return false;
+    }
+    offset = static_cast<std::size_t>(target);
+    std::uint32_t actualClass = 0;
+    return read(bytes, offset - kBlockClassPrefixSize, actualClass) && actualClass == expectedClass;
+}
+
+/**
+ * The whole fixed-stride array must fit the blob before any row is read.
+ * @param bytes Blob containing the descriptor and rows.
+ * @param field Offset of the array descriptor.
+ * @param expectedClass Required serialized element class.
+ * @param stride Nonzero byte width of one row.
+ * @param rows Receives the array bounds; use only on success.
+ * @return False when the descriptor, class, or row bounds are invalid.
+ */
+[[nodiscard]] bool array(std::span<const std::byte> bytes,
+                         std::size_t field,
+                         std::uint32_t expectedClass,
+                         std::size_t stride,
+                         Array& rows) noexcept {
+    return find_array_at(bytes, field, rows) && rows.elementClass == expectedClass
+           && rows.dataOffset <= bytes.size()
+           && rows.count <= (bytes.size() - rows.dataOffset) / stride;
+}
+
+} // namespace
+
+/**
+ * Only an objective-bearing pursuit can name a quest-set owner.
+ * @param definition Item definition bytes, including its nested blocks.
+ * @return The set owner's item-table index, or kUnavailableQuestParent on rejection.
+ */
+std::uint16_t quest_parent(std::span<const std::byte> definition) noexcept {
+    std::uint8_t bucket = 0;
+    std::size_t objective = 0;
+    std::uint16_t parent = kUnavailableQuestParent;
+    Array objectives{};
+    if (definition.size() < kMinimumQuestDefinitionSize
+        || !read(definition, kBucketIdOffset, bucket)
+        || bucket != state::build_data::items::kPursuitBucketId
+        || !block(definition, kItemObjectiveBlockOffset, kItemObjectiveBlockClass, objective)
+        || !array(definition,
+                  objective,
+                  kObjectiveReferenceArrayClass,
+                  kObjectiveReferenceStride,
+                  objectives)
+        || !read(definition, objective + kObjectiveParentItemOffset, parent)) {
+        return kUnavailableQuestParent;
+    }
+    return parent;
+}
+
+/**
+ * Only a unique first member with one supported save-bank mapping may start a quest.
+ * @param definition Pursuit item being acquired.
+ * @param itemIndex Pursuit's item-table index.
+ * @param parent Set-owner bytes selected by quest_parent; may be definition itself.
+ * @param itemCount Exclusive bound for item-table indices.
+ * @param valueMap Blob containing all four unlock value maps.
+ * @return The first-step value and bank row, or an empty plan for unsupported content.
+ */
+Quest read_quest_initialization(std::span<const std::byte> definition,
+                                std::uint16_t itemIndex,
+                                std::span<const std::byte> parent,
+                                std::size_t itemCount,
+                                std::span<const std::byte> valueMap) noexcept {
+    std::size_t set = 0;
+    std::size_t unlock = 0;
+    std::uint8_t mode = 0;
+    std::uint16_t slot = 0;
+    Array members{}, flags{};
+    const auto parentIndex = quest_parent(definition);
+    if (itemIndex >= itemCount || parentIndex >= itemCount
+        || parent.size() < kMinimumQuestDefinitionSize
+        || !block(parent, kItemQuestSetBlockOffset, kQuestSetBlockClass, set)
+        || !read(parent, set + kQuestSetModeOffset, mode) || mode != kSupportedQuestSetMode
+        || !read(parent, set + kQuestSetValueSlotOffset, slot) || slot >= kUnlockSlotLimit
+        || !array(parent, set, kQuestSetMemberClass, kQuestSetMemberStride, members)
+        || members.count > itemCount) {
+        return {};
+    }
+    std::int64_t unlockRelative = 0;
+    if (!read(definition, kItemUnlockBlockOffset, unlockRelative)
+        || (unlockRelative != 0
+            && (!block(definition, kItemUnlockBlockOffset, kItemUnlockBlockClass, unlock)
+                || !find_optional_array_at(definition, unlock, flags)
+                || (flags.count != 0
+                    && !array(definition,
+                              unlock,
+                              kItemPresenceFlagClass,
+                              kItemPresenceFlagStride,
+                              flags))))) {
+        return {};
+    }
+    for (std::size_t i = 0; i < flags.count; ++i) {
+        std::uint16_t flag = 0;
+        if (!read(definition, flags.dataOffset + i * kItemPresenceFlagStride, flag)
+            || flag >= kUnlockSlotLimit) {
+            return {};
+        }
+    }
+    // Without presence flags, require a separate objective-free root and a character value.
+    const bool separateRoot = flags.count == 0;
+    if (separateRoot) {
+        std::uint8_t parentBucket = 0;
+        std::int64_t parentObjective = 0;
+        if (parentIndex == itemIndex || !read(parent, kBucketIdOffset, parentBucket)
+            || parentBucket != kSeparateQuestRootBucketId
+            || !read(parent, kItemObjectiveBlockOffset, parentObjective) || parentObjective != 0) {
+            return {};
+        }
+    }
+    Quest quest{};
+    std::size_t matches = 0;
+    for (std::size_t i = 0; i < members.count; ++i) {
+        const auto at = members.dataOffset + i * kQuestSetMemberStride;
+        std::int32_t value = 0;
+        std::uint16_t member = 0, reserved = 0;
+        if (!read(parent, at + kQuestSetMemberValueOffset, value)
+            || !read(parent, at + kQuestSetMemberItemOffset, member)
+            || !read(parent, at + kQuestSetMemberReservedOffset, reserved) || reserved != 0
+            || member >= itemCount) {
+            return {};
+        }
+        if (member == itemIndex) {
+            if (i != 0) {
+                return {};
+            }
+            ++matches;
+            quest.value = value;
+        } else if (i != 0 && matches != 0 && value == quest.value) {
+            return {}; // The first value must identify only one step.
+        }
+    }
+    if (matches != 1) {
+        return {};
+    }
+
+    // A slot must match once across all maps, including maps with no supported save bank.
+    matches = 0;
+    for (const std::size_t descriptor : {kAccountValueMapDescriptor,
+                                         kCharacterValueMapDescriptor,
+                                         kThirdValueMapDescriptor,
+                                         kFourthValueMapDescriptor}) {
+        Array rows{};
+        if (!find_optional_array_at(valueMap, descriptor, rows) || rows.dataOffset > valueMap.size()
+            || rows.count > (valueMap.size() - rows.dataOffset) / kUnlockMapRowStride) {
+            return {};
+        }
+        for (std::size_t i = 0; i < rows.count; ++i) {
+            std::int16_t mappedSlot = -1;
+            std::uint16_t reserved = 0;
+            if (!read(valueMap,
+                      rows.dataOffset + i * kUnlockMapRowStride + kUnlockMapDestinationSlotOffset,
+                      mappedSlot)
+                || !read(valueMap,
+                         rows.dataOffset + i * kUnlockMapRowStride + kValueMapReservedOffset,
+                         reserved)) {
+                return {};
+            }
+            if (mappedSlot < 0 || static_cast<std::uint16_t>(mappedSlot) != slot) {
+                continue;
+            }
+            if (reserved != 0 || ++matches != 1 || i >= kUnavailableValueMapRow
+                || (descriptor != kAccountValueMapDescriptor
+                    && descriptor != kCharacterValueMapDescriptor)) {
+                return {};
+            }
+            quest.row = static_cast<std::uint16_t>(i);
+            quest.scope = descriptor == kAccountValueMapDescriptor ? Quest::Scope::account
+                                                                   : Quest::Scope::character;
+        }
+    }
+    return matches == 1 && (!separateRoot || quest.scope == Quest::Scope::character)
+                   && state::build_data::items::valid(quest)
+               ? quest
+               : Quest{};
+}
+
+} // namespace sunrise::middleware::content::packages::tables::items

+ 37 - 0
Sunrise/src/middleware/content/packages/tables/quest_initialization_reader.h

@@ -0,0 +1,37 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+#include "../../../../state/build_data/items/quest_initialization.h"
+
+namespace sunrise::middleware::content::packages::tables::items {
+
+/** The all-one item index cannot name a quest-set owner. */
+inline constexpr std::uint16_t kUnavailableQuestParent = 0xFFFFU;
+
+/**
+ * Only an objective-bearing pursuit can name a quest-set owner.
+ * @param definition Item definition bytes, including its nested blocks.
+ * @return The owner's item-table index, or kUnavailableQuestParent on rejection.
+ */
+[[nodiscard]] std::uint16_t quest_parent(std::span<const std::byte> definition) noexcept;
+
+/**
+ * Only a unique first member with one supported save-bank mapping may start a quest.
+ * @param definition Pursuit item being acquired.
+ * @param itemIndex Pursuit's item-table index.
+ * @param parent Set-owner bytes selected by quest_parent; may be definition itself.
+ * @param itemCount Exclusive bound for item-table indices.
+ * @param valueMap Blob containing all four unlock value maps.
+ * @return The first-step value and bank row, or an empty plan for unsupported content.
+ */
+[[nodiscard]] state::build_data::items::QuestInitialization
+read_quest_initialization(std::span<const std::byte> definition,
+                          std::uint16_t itemIndex,
+                          std::span<const std::byte> parent,
+                          std::size_t itemCount,
+                          std::span<const std::byte> valueMap) noexcept;
+
+} // namespace sunrise::middleware::content::packages::tables::items

+ 14 - 4
Sunrise/src/middleware/datagen/family4/account/account_encoder.cpp

@@ -75,6 +75,20 @@ constexpr std::size_t kBucketIdentityCapacity = 256;
 
 
 /** Encodes a sentinel-correct account object from live State. */
 /** Encodes a sentinel-correct account object from live State. */
 bool encode(const state::AccountState& state, std::span<std::byte> output) noexcept {
 bool encode(const state::AccountState& state, std::span<std::byte> output) noexcept {
+    state::unlocks::Table unlocks;
+    return state::unlocks::snapshot(unlocks) && encode(state, output, unlocks);
+}
+
+/**
+ * Account-wide unlocks use the supplied snapshot; per-character flags still use saved state.
+ * @param state Account identity, roster, preferences, and inventory to encode.
+ * @param output Receives the account object; unchanged on failure.
+ * @param unlocks Account unlocks from the same live or prepared view as state.
+ * @return False when state, saved flags, mappings, or output bounds are invalid.
+ */
+bool encode(const state::AccountState& state,
+            std::span<std::byte> output,
+            const state::unlocks::Table& unlocks) noexcept {
     if (state.primarySoid == 0 || !state::account::valid(state)
     if (state.primarySoid == 0 || !state::account::valid(state)
         || output.size() < layout::kMinimumSize) {
         || output.size() < layout::kMinimumSize) {
         return false;
         return false;
@@ -89,10 +103,6 @@ bool encode(const state::AccountState& state, std::span<std::byte> output) noexc
         return false;
         return false;
     }
     }
 
 
-    state::unlocks::Table unlocks;
-    if (!state::unlocks::snapshot(unlocks)) {
-        return false;
-    }
     object.acquiredFlags = unlocks.accountFlags;
     object.acquiredFlags = unlocks.accountFlags;
     object.profileUnlockFlags = unlocks.profileFlags;
     object.profileUnlockFlags = unlocks.profileFlags;
     object.objectiveValues = unlocks.objectiveValues;
     object.objectiveValues = unlocks.objectiveValues;

+ 12 - 0
Sunrise/src/middleware/datagen/family4/account/account_encoder.h

@@ -3,6 +3,7 @@
 #include <span>
 #include <span>
 
 
 #include "../../../../state/account/account_state.h"
 #include "../../../../state/account/account_state.h"
+#include "../../../../state/unlocks/definition.h"
 
 
 namespace sunrise::middleware::datagen::family4::account {
 namespace sunrise::middleware::datagen::family4::account {
 
 
@@ -14,4 +15,15 @@ namespace sunrise::middleware::datagen::family4::account {
  */
  */
 [[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) noexcept;
 
 
+/**
+ * Account-wide unlocks use the supplied snapshot; per-character flags still use saved state.
+ * @param state Account identity, roster, preferences, and inventory to encode.
+ * @param output Receives the account object; unchanged on failure.
+ * @param unlocks Account unlocks from the same live or prepared view as state.
+ * @return False when state, saved flags, mappings, or output bounds are invalid.
+ */
+[[nodiscard]] bool encode(const state::AccountState& state,
+                          std::span<std::byte> output,
+                          const state::unlocks::Table& unlocks) noexcept;
+
 } // namespace sunrise::middleware::datagen::family4::account
 } // namespace sunrise::middleware::datagen::family4::account

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

@@ -241,6 +241,25 @@ bool encode(const state::CharacterState& state,
             const loadout::ResolvedLoadout& resolvedLoadout,
             const loadout::ResolvedLoadout& resolvedLoadout,
             const state::equipment::light::Evaluation& lightEvaluation,
             const state::equipment::light::Evaluation& lightEvaluation,
             std::span<std::byte> output) noexcept {
             std::span<std::byte> output) noexcept {
+    state::unlocks::Table unlocks;
+    return state::unlocks::snapshot(unlocks)
+           && encode(state, resolvedLoadout, lightEvaluation, output, unlocks);
+}
+
+/**
+ * Character unlocks must match the live or prepared inventory view being encoded.
+ * @param state Character identity and inventory to encode.
+ * @param resolvedLoadout Item mappings for this character's inventory.
+ * @param lightEvaluation Equipment light values for the same loadout.
+ * @param output Receives the character object; unchanged on failure.
+ * @param unlocks Unlock snapshot for this character, including any prepared quest value.
+ * @return False when state, mappings, light values, or output bounds are invalid.
+ */
+bool encode(const state::CharacterState& state,
+            const loadout::ResolvedLoadout& resolvedLoadout,
+            const state::equipment::light::Evaluation& lightEvaluation,
+            std::span<std::byte> output,
+            const state::unlocks::Table& unlocks) noexcept {
     if (!valid(state) || !valid(resolvedLoadout)
     if (!valid(state) || !valid(resolvedLoadout)
         || !summary_matches_loadout(resolvedLoadout, lightEvaluation)
         || !summary_matches_loadout(resolvedLoadout, lightEvaluation)
         || output.size() < layout::kObjectSize) {
         || output.size() < layout::kObjectSize) {
@@ -278,12 +297,7 @@ bool encode(const state::CharacterState& state,
     for (layout::ItemStackRow& stack : object.itemStacks) {
     for (layout::ItemStackRow& stack : object.itemStacks) {
         stack.selector = kEmptyItemStackSelector;
         stack.selector = kEmptyItemStackSelector;
     }
     }
-    // Acquired flags and objective progress are live world state, written by the request that
-    // changed them.
-    state::unlocks::Table unlocks;
-    if (!state::unlocks::snapshot(unlocks)) {
-        return false;
-    }
+    // Use the supplied quest state even when the acquisition has not committed yet.
     for (std::size_t index = 0; index < object.acquiredFlags.size(); ++index) {
     for (std::size_t index = 0; index < object.acquiredFlags.size(); ++index) {
         object.acquiredFlags[index] = static_cast<std::byte>(
         object.acquiredFlags[index] = static_cast<std::byte>(
             index < unlocks.characterObjectFlags.size() ? unlocks.characterObjectFlags[index]
             index < unlocks.characterObjectFlags.size() ? unlocks.characterObjectFlags[index]

+ 16 - 0
Sunrise/src/middleware/datagen/family4/character/character_encoder.h

@@ -4,6 +4,7 @@
 
 
 #include "../../../../state/account/account_state.h"
 #include "../../../../state/account/account_state.h"
 #include "../../../../state/equipment/light/definition.h"
 #include "../../../../state/equipment/light/definition.h"
+#include "../../../../state/unlocks/definition.h"
 #include "../loadout/definition.h"
 #include "../loadout/definition.h"
 
 
 namespace sunrise::middleware::datagen::family4::character {
 namespace sunrise::middleware::datagen::family4::character {
@@ -21,4 +22,19 @@ namespace sunrise::middleware::datagen::family4::character {
                           const state::equipment::light::Evaluation& lightEvaluation,
                           const state::equipment::light::Evaluation& lightEvaluation,
                           std::span<std::byte> output) noexcept;
                           std::span<std::byte> output) noexcept;
 
 
+/**
+ * Character unlocks must match the live or prepared inventory view being encoded.
+ * @param state Character identity and inventory to encode.
+ * @param resolvedLoadout Item mappings for this character's inventory.
+ * @param lightEvaluation Equipment light values for the same loadout.
+ * @param output Receives the character object; unchanged on failure.
+ * @param unlocks Unlock snapshot for this character, including any prepared quest value.
+ * @return False when state, mappings, light values, or output bounds are invalid.
+ */
+[[nodiscard]] bool encode(const state::CharacterState& state,
+                          const loadout::ResolvedLoadout& resolvedLoadout,
+                          const state::equipment::light::Evaluation& lightEvaluation,
+                          std::span<std::byte> output,
+                          const state::unlocks::Table& unlocks) noexcept;
+
 } // namespace sunrise::middleware::datagen::family4::character
 } // namespace sunrise::middleware::datagen::family4::character

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

@@ -454,7 +454,7 @@ bool process(const ServiceRoute& route,
                                                    itemAcquisition->accountSoid,
                                                    itemAcquisition->accountSoid,
                                                    itemAcquisition->characterSoid,
                                                    itemAcquisition->characterSoid,
                                                    itemAcquisition->acquiredInstanceSoid,
                                                    itemAcquisition->acquiredInstanceSoid,
-                                                   itemAcquisition->profileChanged,
+                                                   itemAcquisition->updates_account(),
                                                    transaction->update)) {
                                                    transaction->update)) {
                 core::log::write(core::log::Channel::server,
                 core::log::write(core::log::Channel::server,
                                  core::log::Level::warn,
                                  core::log::Level::warn,

+ 7 - 6
Sunrise/src/server/bap/encrypted/push/snapshot/family4_inventory_updates.cpp

@@ -395,9 +395,8 @@ bool prepare_item_acquisition(
         || mutation.accountSoid == 0 || mutation.accountSoid != acquisition.accountSoid
         || mutation.accountSoid == 0 || mutation.accountSoid != acquisition.accountSoid
         || mutation.characterSoid != acquisition.characterSoid
         || mutation.characterSoid != acquisition.characterSoid
         || mutation.acquiredInstanceSoid != acquisition.acquiredInstanceSoid
         || mutation.acquiredInstanceSoid != acquisition.acquiredInstanceSoid
-        // The account object may ride for reasons only the caller knows, but a profile change
-        // always has to publish it.
-        || (mutation.profileChanged && !acquisition.updatesAccount)
+        // Profile inventory and account-scoped quest values both require the account object.
+        || (mutation.updates_account() && !acquisition.updatesAccount)
         || acquisition.accountSoid != acquisition.after.family4RootSoid
         || acquisition.accountSoid != acquisition.after.family4RootSoid
         || acquisition.accountDefinitionId == 0 || acquisition.after.family4ResidentCount == 0
         || acquisition.accountDefinitionId == 0 || acquisition.after.family4ResidentCount == 0
         || acquisition.after.family4Residents[acquisition.after.family4ResidentCount - 1U]
         || acquisition.after.family4Residents[acquisition.after.family4ResidentCount - 1U]
@@ -408,7 +407,8 @@ bool prepare_item_acquisition(
                != acquisition.itemInstanceDefinitionId) {
                != acquisition.itemInstanceDefinitionId) {
         return report_failure("acquire_mutation");
         return report_failure("acquire_mutation");
     }
     }
-    if (!state::preview_item_acquisition(mutation, account)
+    state::unlocks::Table afterUnlocks;
+    if (!state::preview_item_acquisition(mutation, account, afterUnlocks)
         || mutation.characterIndex >= account.characterCount
         || mutation.characterIndex >= account.characterCount
         || account.primarySoid != acquisition.accountSoid
         || account.primarySoid != acquisition.accountSoid
         || account.characters[mutation.characterIndex].soid != mutation.characterSoid) {
         || account.characters[mutation.characterIndex].soid != mutation.characterSoid) {
@@ -452,7 +452,8 @@ bool prepare_item_acquisition(
     if (!family4_datagen::character::encode(account.characters[mutation.characterIndex],
     if (!family4_datagen::character::encode(account.characters[mutation.characterIndex],
                                             selected.loadout,
                                             selected.loadout,
                                             selected.lightEvaluation,
                                             selected.lightEvaluation,
-                                            characterBytes)) {
+                                            characterBytes,
+                                            afterUnlocks)) {
         return report_failure("acquire_character_object");
         return report_failure("acquire_character_object");
     }
     }
 
 
@@ -553,7 +554,7 @@ bool prepare_item_acquisition(
             return report_failure("acquire_account_storage");
             return report_failure("acquire_account_storage");
         }
         }
         const auto accountBytes = rawStorage.first(family4_datagen::account::layout::kObjectSize);
         const auto accountBytes = rawStorage.first(family4_datagen::account::layout::kObjectSize);
-        if (!family4_datagen::account::encode(account, accountBytes)
+        if (!family4_datagen::account::encode(account, accountBytes, afterUnlocks)
             || !append_object(scratch,
             || !append_object(scratch,
                               accountBytes,
                               accountBytes,
                               acquisition.accountDefinitionId,
                               acquisition.accountDefinitionId,

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

@@ -78,7 +78,7 @@ selected_character(const state::AccountState& account) noexcept {
                                         pending.accountSoid,
                                         pending.accountSoid,
                                         pending.characterSoid,
                                         pending.characterSoid,
                                         pending.acquiredInstanceSoid,
                                         pending.acquiredInstanceSoid,
-                                        pending.profileChanged,
+                                        pending.updates_account(),
                                         acquisition)) {
                                         acquisition)) {
         core::log::write(core::log::Channel::server,
         core::log::write(core::log::Channel::server,
                          core::log::Level::warn,
                          core::log::Level::warn,

+ 17 - 6
Sunrise/src/state/build_data/cache/records/cache_record_codec.cpp

@@ -51,8 +51,8 @@ bool decode(const NamedRecord& record, content::Definition& value) noexcept {
 /**
 /**
  * Encodes one installed-build item mapping with its padding zeroed.
  * Encodes one installed-build item mapping with its padding zeroed.
  * @param value Runtime row.
  * @param value Runtime row.
- * @param record Receives the packed disk row.
- * @return Always true, because an unknown bucket has its own unset value.
+ * @param record Receives the packed disk row; use only on success.
+ * @return True for empty quest state or a supported first-step value, scope, and bank row.
  */
  */
 bool encode(const items::Definition& value, ItemRecord& record) noexcept {
 bool encode(const items::Definition& value, ItemRecord& record) noexcept {
     record = {
     record = {
@@ -65,11 +65,19 @@ bool encode(const items::Definition& value, ItemRecord& record) noexcept {
         value.plugCategoryHash,
         value.plugCategoryHash,
         value.rollSetIndex,
         value.rollSetIndex,
         value.linkedPlugIndex,
         value.linkedPlugIndex,
+        value.questInitialization.value,
+        value.questInitialization.row,
+        static_cast<std::uint8_t>(value.questInitialization.scope),
     };
     };
-    return true;
+    return items::valid(value.questInitialization);
 }
 }
 
 
-/** Decodes one installed-build item mapping. */
+/**
+ * Cached quest state must fit the same bank limits as freshly read item metadata.
+ * @param record Packed disk row.
+ * @param value Receives the runtime item mapping; use only on success.
+ * @return True for empty quest state or a supported first-step value, scope, and bank row.
+ */
 bool decode(const ItemRecord& record, items::Definition& value) noexcept {
 bool decode(const ItemRecord& record, items::Definition& value) noexcept {
     value = {record.definitionHash,
     value = {record.definitionHash,
              record.definitionIndex,
              record.definitionIndex,
@@ -79,8 +87,11 @@ bool decode(const ItemRecord& record, items::Definition& value) noexcept {
              record.tier,
              record.tier,
              record.plugCategoryHash,
              record.plugCategoryHash,
              record.rollSetIndex,
              record.rollSetIndex,
-             record.linkedPlugIndex};
-    return true;
+             record.linkedPlugIndex,
+             {record.questInitialValue,
+              record.questValueRow,
+              static_cast<items::QuestInitialization::Scope>(record.questValueScope)}};
+    return items::valid(value.questInitialization);
 }
 }
 
 
 /** Encodes one collectible ordinal and its optional item link. */
 /** Encodes one collectible ordinal and its optional item link. */

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

@@ -30,12 +30,8 @@ namespace sunrise::state::build_data::cache::records {
 
 
 /** These 8 ASCII bytes mark a Sunrise build-data file. */
 /** These 8 ASCII bytes mark a Sunrise build-data file. */
 inline constexpr std::array<char, 8> kCacheMagic{'S', 'U', 'N', 'R', 'I', 'S', 'E', 'B'};
 inline constexpr std::array<char, 8> kCacheMagic{'S', 'U', 'N', 'R', 'I', 'S', 'E', 'B'};
-/**
- * Current build-data cache format. Any other version on disk is rebuilt rather than read.
- * Bump it when a stored shape changes or when the extraction filling it changes what it writes,
- * because a cached row survives a code change and a corrected walk keeps publishing old rows.
- */
-inline constexpr std::uint32_t kCacheFormatVersion = 63;
+/** Bump when stored layouts or extracted values change; other versions are rebuilt. */
+inline constexpr std::uint32_t kCacheFormatVersion = 65;
 /** Signed -1 on disk means there is no equipment slot. */
 /** Signed -1 on disk means there is no equipment slot. */
 inline constexpr std::int8_t kAbsentEquipmentSlot = -1;
 inline constexpr std::int8_t kAbsentEquipmentSlot = -1;
 /** The standard 64-bit FNV-1a offset basis starts the payload checksum. */
 /** The standard 64-bit FNV-1a offset basis starts the payload checksum. */
@@ -154,6 +150,9 @@ struct ItemRecord {
     std::uint32_t plugCategoryHash{};
     std::uint32_t plugCategoryHash{};
     std::uint16_t rollSetIndex{};
     std::uint16_t rollSetIndex{};
     std::uint16_t linkedPlugIndex{items::kUnavailableLinkedPlugIndex};
     std::uint16_t linkedPlugIndex{items::kUnavailableLinkedPlugIndex};
+    std::int32_t questInitialValue{};
+    std::uint16_t questValueRow{};
+    std::uint8_t questValueScope{};
 };
 };
 
 
 /** Disk form of one material charged by a native Collections acquisition. */
 /** Disk form of one material charged by a native Collections acquisition. */
@@ -643,7 +642,7 @@ static_assert(sizeof(NamedRecord)
               == content::kDefinitionNameCapacity + 2 * sizeof(std::uint16_t)
               == content::kDefinitionNameCapacity + 2 * sizeof(std::uint16_t)
                      + 2 * sizeof(std::uint32_t));
                      + 2 * sizeof(std::uint32_t));
 static_assert(sizeof(ItemRecord)
 static_assert(sizeof(ItemRecord)
-              == 2 * sizeof(std::uint32_t) + 5 * sizeof(std::uint16_t) + 2 * sizeof(std::uint8_t));
+              == 3 * sizeof(std::uint32_t) + 6 * sizeof(std::uint16_t) + 3 * sizeof(std::uint8_t));
 static_assert(sizeof(MaterialRequirementRecord)
 static_assert(sizeof(MaterialRequirementRecord)
               == sizeof(std::uint32_t) + 2 * sizeof(std::uint16_t) + 2 * sizeof(std::uint8_t));
               == sizeof(std::uint32_t) + 2 * sizeof(std::uint16_t) + 2 * sizeof(std::uint8_t));
 static_assert(sizeof(CollectibleRecord)
 static_assert(sizeof(CollectibleRecord)

+ 4 - 2
Sunrise/src/state/build_data/items/item_catalog.cpp

@@ -103,8 +103,10 @@ bool valid(std::span<const Definition> definitions) noexcept {
     }
     }
     std::array<bool, kDefinitionCapacity> occupied{};
     std::array<bool, kDefinitionCapacity> occupied{};
     for (const Definition& definition : definitions) {
     for (const Definition& definition : definitions) {
-        if (definition.definitionIndex >= definitions.size()
-            || occupied[definition.definitionIndex]) {
+        if (definition.definitionIndex >= definitions.size() || occupied[definition.definitionIndex]
+            || !valid(definition.questInitialization)
+            || (definition.questInitialization.scope != QuestInitialization::Scope::none
+                && definition.bucketId != kPursuitBucketId)) {
             return false;
             return false;
         }
         }
         occupied[definition.definitionIndex] = true;
         occupied[definition.definitionIndex] = true;

+ 4 - 0
Sunrise/src/state/build_data/items/item_catalog.h

@@ -4,6 +4,8 @@
 #include <cstdint>
 #include <cstdint>
 #include <span>
 #include <span>
 
 
+#include "quest_initialization.h"
+
 namespace sunrise::state::build_data::items {
 namespace sunrise::state::build_data::items {
 
 
 /** Signed native definition indices give 32,768 item rows. */
 /** Signed native definition indices give 32,768 item rows. */
@@ -35,6 +37,8 @@ struct Definition {
     /** Item index of the plug this one stands for, or kUnavailableLinkedPlugIndex when it stands
     /** Item index of the plug this one stands for, or kUnavailableLinkedPlugIndex when it stands
      * alone. */
      * alone. */
     std::uint16_t linkedPlugIndex{kUnavailableLinkedPlugIndex};
     std::uint16_t linkedPlugIndex{kUnavailableLinkedPlugIndex};
+    /** Empty unless this item is the first member of a supported quest set. */
+    QuestInitialization questInitialization{};
 };
 };
 
 
 /** Roll-set ordinals outside the rolled ladder. */
 /** Roll-set ordinals outside the rolled ladder. */

+ 55 - 0
Sunrise/src/state/build_data/items/quest_initialization.h

@@ -0,0 +1,55 @@
+#pragma once
+
+#include <cstdint>
+
+#include "../../unlocks/definition.h"
+
+namespace sunrise::state::build_data::items {
+
+/** Native pursuit bucket shared by quest items and bounties. */
+inline constexpr std::uint8_t kPursuitBucketId = 40;
+/** Missing saved quest rows read as zero; only this value permits a first-step write. */
+inline constexpr std::int32_t kUnsetQuestValue = 0;
+/** Policy: -1 cannot start a quest; existing -1 state must still be preserved. */
+inline constexpr std::int32_t kInvalidQuestInitialValue = -1;
+
+/** Authored initial value and bank row for the first member of a supported quest set. */
+struct QuestInitialization {
+    enum class Scope : std::uint8_t { none, account, character };
+    std::int32_t value{};
+    std::uint16_t row{};
+    Scope scope{};
+
+    bool operator==(const QuestInitialization&) const = default;
+};
+
+/**
+ * An empty plan is valid; a nonempty plan must fit its saved bank.
+ * @param quest First-step value, row, and scope from item metadata.
+ * @return True for an empty plan or a supported value within its bank's capacity.
+ */
+[[nodiscard]] constexpr bool valid(const QuestInitialization& quest) noexcept {
+    using Scope = QuestInitialization::Scope;
+    if (quest.scope == Scope::none) {
+        return quest.row == 0 && quest.value == kUnsetQuestValue;
+    }
+    return quest.value != kUnsetQuestValue && quest.value != kInvalidQuestInitialValue
+           && ((quest.scope == Scope::account && quest.row < unlocks::kObjectiveValueCapacity)
+               || (quest.scope == Scope::character
+                   && quest.row < unlocks::kCharacterObjectValueCapacity));
+}
+
+/**
+ * Preserve every nonzero value; step identifiers have no numeric progress order.
+ * @param quest Validated first-step plan; may be empty.
+ * @param before Current saved quest value.
+ * @return The first-step value only when the plan is nonempty and saved state is unset.
+ */
+[[nodiscard]] constexpr std::int32_t initialized_value(const QuestInitialization& quest,
+                                                       std::int32_t before) noexcept {
+    return quest.scope != QuestInitialization::Scope::none && before == kUnsetQuestValue
+               ? quest.value
+               : before;
+}
+
+} // namespace sunrise::state::build_data::items

+ 26 - 7
Sunrise/src/state/runtime/runtime.h

@@ -6,6 +6,7 @@
 #include <span>
 #include <span>
 #include <variant>
 #include <variant>
 
 
+#include "../build_data/items/quest_initialization.h"
 #include "../build_data/records/definition.h"
 #include "../build_data/records/definition.h"
 #include "state.h"
 #include "state.h"
 
 
@@ -135,7 +136,20 @@ struct PendingItemAcquisition {
     bool profileChanged{};
     bool profileChanged{};
     /** Skips Collections revalidation for direct rewards. */
     /** Skips Collections revalidation for direct rewards. */
     bool directGrant{};
     bool directGrant{};
+    build_data::items::QuestInitialization questInitialization{};
+    std::int32_t previousQuestValue{};
     bool prepared{};
     bool prepared{};
+
+    /**
+     * Account-scoped quest writes need an account update even when no materials were charged.
+     * @return True for a profile inventory change or an unset account-scoped quest value.
+     */
+    [[nodiscard]] bool updates_account() const noexcept {
+        return profileChanged
+               || (questInitialization.scope
+                       == build_data::items::QuestInitialization::Scope::account
+                   && previousQuestValue == build_data::items::kUnsetQuestValue);
+    }
 };
 };
 
 
 /** One profile row an exchange changed, named the way the account's change ring names it. */
 /** One profile row an exchange changed, named the way the account's change ring names it. */
@@ -564,16 +578,21 @@ set_selected_title(std::uint16_t recordIndex, std::uint64_t& characterSoid, bool
 [[nodiscard]] bool
 [[nodiscard]] bool
 reserve_selected_character_inventory_serial(std::int32_t& mutationSerial) noexcept;
 reserve_selected_character_inventory_serial(std::int32_t& mutationSerial) noexcept;
 
 
-/** Builds the exact full-account after-image while a prepared item pull remains current. */
+/**
+ * Preview inventory and quest values together without changing the save.
+ * @param mutation Prepared acquisition checked against current saved state.
+ * @param after Receives the candidate account; use only on success.
+ * @param afterUnlocks Receives matching account and selected-character unlocks on success.
+ * @return False when the acquisition is stale or its saved unlocks cannot be read.
+ */
 [[nodiscard]] bool preview_item_acquisition(const PendingItemAcquisition& mutation,
 [[nodiscard]] bool preview_item_acquisition(const PendingItemAcquisition& mutation,
-                                            AccountState& after) noexcept;
+                                            AccountState& after,
+                                            unlocks::Table& afterUnlocks) noexcept;
 
 
 /**
 /**
- * Commits a prepared inventory insertion only while its selected character, existing loadout,
- * and next inventory serial still match the prepare-time view.
- *
- * @param mutation Prepared mutation, always cleared before this function returns.
- * @return True when the insertion commits atomically and leaves the whole account valid.
+ * Inventory and first-step state share one transaction; failure rolls both back.
+ * @param mutation Prepared grant consumed on either success or failure.
+ * @return True when both writes commit against the unchanged prepared state.
  */
  */
 [[nodiscard]] bool commit_item_acquisition(PendingItemAcquisition& mutation) noexcept;
 [[nodiscard]] bool commit_item_acquisition(PendingItemAcquisition& mutation) noexcept;
 
 

+ 121 - 19
Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp

@@ -23,6 +23,39 @@ namespace family4_loadout = middleware::datagen::family4::loadout;
 
 
 namespace runtime::detail {
 namespace runtime::detail {
 
 
+using Quest = build_data::items::QuestInitialization;
+
+/**
+ * The plan must already be valid and nonempty before selecting a save bank.
+ * @param quest First-step plan with account or character scope.
+ * @return The persistent value bank for that scope.
+ */
+[[nodiscard]] investment::store::Bank quest_bank(const Quest& quest) noexcept {
+    return quest.scope == Quest::Scope::account ? investment::store::Bank::objectiveValues
+                                                : investment::store::Bank::characterObjectValues;
+}
+
+/**
+ * Hold investment::store::g_mutex and validate the selected character before this check.
+ * @param mutation Prepared acquisition with the prior saved quest value.
+ * @return True only while item metadata and the saved quest value still match.
+ */
+[[nodiscard]] bool quest_current(const PendingItemAcquisition& mutation) noexcept {
+    build_data::items::Definition definition{};
+    if (!build_data::find_item_definition_hash(mutation.acquiredDefinitionHash, definition)
+        || definition.questInitialization != mutation.questInitialization
+        || !build_data::items::valid(mutation.questInitialization)) {
+        return false;
+    }
+    if (mutation.questInitialization.scope == Quest::Scope::none) {
+        return mutation.previousQuestValue == build_data::items::kUnsetQuestValue;
+    }
+    std::int32_t current = 0;
+    return investment::store::read_unlock(
+               quest_bank(mutation.questInitialization), mutation.questInitialization.row, current)
+           && current == mutation.previousQuestValue;
+}
+
 /** @return The selected character's index, or the character count when none is selected. */
 /** @return The selected character's index, or the character count when none is selected. */
 [[nodiscard]] std::size_t selected_character_index(const AccountState& account) noexcept {
 [[nodiscard]] std::size_t selected_character_index(const AccountState& account) noexcept {
     const std::size_t count = (std::min)(account.characterCount, account.characters.size());
     const std::size_t count = (std::min)(account.characterCount, account.characters.size());
@@ -34,7 +67,16 @@ namespace runtime::detail {
     return account.characters.size();
     return account.characters.size();
 }
 }
 
 
-/** Stages the common selected-character insertion path. */
+/**
+ * Hold investment::store::g_mutex while capturing inventory and quest state together.
+ * @param account State before any acquisition charge.
+ * @param chargedAccount State after the prepared material charge.
+ * @param definitionHash Item definition to grant.
+ * @param profileChanged Whether the charge changed profile inventory.
+ * @param source Grant identity and material requirements for commit checks.
+ * @param mutation Receives a pending grant; use only on success.
+ * @return False when the item, inventory, mapping, or saved quest state is invalid.
+ */
 [[nodiscard]] bool finalize_item_acquisition(const AccountState& account,
 [[nodiscard]] bool finalize_item_acquisition(const AccountState& account,
                                              const AccountState& chargedAccount,
                                              const AccountState& chargedAccount,
                                              std::uint32_t definitionHash,
                                              std::uint32_t definitionHash,
@@ -101,16 +143,35 @@ namespace runtime::detail {
     mutation.materialRequirementCount = source.materialRequirementCount;
     mutation.materialRequirementCount = source.materialRequirementCount;
     mutation.profileChanged = profileChanged;
     mutation.profileChanged = profileChanged;
     mutation.directGrant = source.direct;
     mutation.directGrant = source.direct;
+    build_data::items::Definition definition{};
+    if (!build_data::find_item_definition_hash(definitionHash, definition)
+        || !build_data::items::valid(definition.questInitialization)) {
+        return false;
+    }
+    mutation.questInitialization = definition.questInitialization;
+    if (mutation.questInitialization.scope != Quest::Scope::none
+        && !investment::store::read_unlock(quest_bank(mutation.questInitialization),
+                                           mutation.questInitialization.row,
+                                           mutation.previousQuestValue)) {
+        return false;
+    }
     mutation.prepared = true;
     mutation.prepared = true;
     return true;
     return true;
 }
 }
 
 
 } // namespace runtime::detail
 } // namespace runtime::detail
 
 
-/** Prepares one native-row-checked selected-character inventory insertion. */
+/**
+ * Inventory and quest state must come from the same locked save view.
+ * @param collectibleIndex Collections row, or kNoCollectibleIndex for an item-only grant.
+ * @param definitionHash Item definition to grant.
+ * @param mutation Receives a pending grant; prepared is set only on success.
+ * @return False when identity, costs, capacity, or saved state prevent the grant.
+ */
 bool prepare_item_acquisition(std::uint16_t collectibleIndex,
 bool prepare_item_acquisition(std::uint16_t collectibleIndex,
                               std::uint32_t definitionHash,
                               std::uint32_t definitionHash,
                               PendingItemAcquisition& mutation) noexcept {
                               PendingItemAcquisition& mutation) noexcept {
+    const std::lock_guard lock(investment::store::g_mutex);
     mutation = {};
     mutation = {};
     const AccountState account = account_snapshot();
     const AccountState account = account_snapshot();
     build_data::collectibles::Definition collectible{};
     build_data::collectibles::Definition collectible{};
@@ -156,9 +217,15 @@ bool prepare_item_acquisition(std::uint16_t collectibleIndex,
         mutation);
         mutation);
 }
 }
 
 
-/** Prepares one direct selected-character inventory grant, with no Collections row or charge. */
+/**
+ * Direct grants share quest-state checks but do not charge Collections materials.
+ * @param itemDefinitionIndex Item-table row to grant to the selected character.
+ * @param mutation Receives a pending grant; prepared is set only on success.
+ * @return False when the item, inventory, mapping, or saved quest state is invalid.
+ */
 bool prepare_item_acquisition_for_item(std::uint16_t itemDefinitionIndex,
 bool prepare_item_acquisition_for_item(std::uint16_t itemDefinitionIndex,
                                        PendingItemAcquisition& mutation) noexcept {
                                        PendingItemAcquisition& mutation) noexcept {
+    const std::lock_guard lock(investment::store::g_mutex);
     mutation = {};
     mutation = {};
     const AccountState account = account_snapshot();
     const AccountState account = account_snapshot();
     build_data::items::Definition grantedDefinition{};
     build_data::items::Definition grantedDefinition{};
@@ -349,14 +416,22 @@ valid_item_acquisition_source(const PendingItemAcquisition& mutation) noexcept {
            && definition.definitionHash == mutation.acquiredDefinitionHash;
            && definition.definitionHash == mutation.acquiredDefinitionHash;
 }
 }
 
 
-/** Applies one validated insertion over an exact current account without taking State locks. */
+/**
+ * Hold investment::store::g_mutex; the selected character and saved state must still match.
+ * @param current Current account from the locked save view.
+ * @param mutation Prepared inventory insertion and prior quest state.
+ * @param after Receives the candidate account; use only on success.
+ * @return False for stale state or an invalid resulting inventory.
+ */
 [[nodiscard]] bool materialize_item_acquisition(const AccountState& current,
 [[nodiscard]] bool materialize_item_acquisition(const AccountState& current,
                                                 const PendingItemAcquisition& mutation,
                                                 const PendingItemAcquisition& mutation,
                                                 AccountState& after) noexcept {
                                                 AccountState& after) noexcept {
     std::uint64_t nextSoid = 0;
     std::uint64_t nextSoid = 0;
     if (!valid_item_acquisition_source(mutation)
     if (!valid_item_acquisition_source(mutation)
         || mutation.characterIndex >= current.characterCount
         || mutation.characterIndex >= current.characterCount
-        || current.primarySoid != mutation.accountSoid
+        || !current.characters[mutation.characterIndex].selected
+        || current.characters[mutation.characterIndex].soid != mutation.characterSoid
+        || !quest_current(mutation) || current.primarySoid != mutation.accountSoid
         || !same_character(current.characters[mutation.characterIndex], mutation.beforeCharacter)
         || !same_character(current.characters[mutation.characterIndex], mutation.beforeCharacter)
         || !same_profile_inventory(
         || !same_profile_inventory(
             current, mutation.beforeProfileItems, mutation.expectedProfileItemCount)
             current, mutation.beforeProfileItems, mutation.expectedProfileItemCount)
@@ -454,11 +529,32 @@ valid_item_acquisition_source(const PendingItemAcquisition& mutation) noexcept {
 
 
 } // namespace runtime::detail
 } // namespace runtime::detail
 
 
-/** Produces the full account after-image while a prepared character pull remains current. */
+/**
+ * Preview inventory and quest values together without changing the save.
+ * @param mutation Prepared acquisition checked against current saved state.
+ * @param after Receives the candidate account; use only on success.
+ * @param afterUnlocks Receives matching account and selected-character unlocks on success.
+ * @return False when the acquisition is stale or its saved unlocks cannot be read.
+ */
 bool preview_item_acquisition(const PendingItemAcquisition& mutation,
 bool preview_item_acquisition(const PendingItemAcquisition& mutation,
-                              AccountState& after) noexcept {
+                              AccountState& after,
+                              unlocks::Table& afterUnlocks) noexcept {
+    const std::lock_guard lock(investment::store::g_mutex);
     after = {};
     after = {};
-    return materialize_item_acquisition(account_snapshot(), mutation, after);
+    afterUnlocks = {};
+    if (!materialize_item_acquisition(account_snapshot(), mutation, after)
+        || !investment::store::read_unlocks(afterUnlocks,
+                                            static_cast<int>(mutation.characterIndex))) {
+        return false;
+    }
+    const auto& quest = mutation.questInitialization;
+    const auto value = build_data::items::initialized_value(quest, mutation.previousQuestValue);
+    if (quest.scope == Quest::Scope::account) {
+        afterUnlocks.objectiveValues[quest.row] = value;
+    } else if (quest.scope == Quest::Scope::character) {
+        afterUnlocks.characterObjectValues[quest.row] = value;
+    }
+    return true;
 }
 }
 
 
 /** Produces the full account after-image while a prepared package remains current. */
 /** Produces the full account after-image while a prepared package remains current. */
@@ -468,22 +564,28 @@ bool preview_direct_item_bundle(const PendingDirectItemBundle& mutation,
     return materialize_direct_item_bundle(account_snapshot(), mutation, after);
     return materialize_direct_item_bundle(account_snapshot(), mutation, after);
 }
 }
 
 
-/** Commits one prepared insertion only while its prepare-time loadout remains current. */
+/**
+ * Inventory and first-step state share one transaction; failure rolls both back.
+ * @param mutation Prepared grant consumed on either success or failure.
+ * @return True when both writes commit against the unchanged prepared state.
+ */
 bool commit_item_acquisition(PendingItemAcquisition& mutation) noexcept {
 bool commit_item_acquisition(PendingItemAcquisition& mutation) noexcept {
     const PendingItemAcquisition& prepared = mutation;
     const PendingItemAcquisition& prepared = mutation;
     const PendingConsumption consume{mutation};
     const PendingConsumption consume{mutation};
-    investment::store::g_mutex.lock();
+    investment::store::Transaction transaction;
     AccountState candidate{};
     AccountState candidate{};
-    const bool ready =
-        materialize_item_acquisition(investment::store::account(), prepared, candidate);
-    if (ready) {
-        if (!investment::store::write_account(candidate)) {
-            investment::store::g_mutex.unlock();
-            return false;
-        }
+    if (!transaction.ready()
+        || !materialize_item_acquisition(investment::store::account(), prepared, candidate)
+        || !investment::store::write_account(candidate)) {
+        return false;
     }
     }
-    investment::store::g_mutex.unlock();
-    return ready;
+    const auto& quest = prepared.questInitialization;
+    if (quest.scope != Quest::Scope::none
+        && prepared.previousQuestValue == build_data::items::kUnsetQuestValue
+        && !investment::store::write_unlock(quest_bank(quest), quest.row, quest.value)) {
+        return false;
+    }
+    return transaction.commit();
 }
 }
 
 
 namespace runtime::detail {
 namespace runtime::detail {

+ 13 - 5
Sunrise/src/state/runtime/state_account_transaction_helpers.h

@@ -116,9 +116,14 @@ struct GrantSource {
 /** @return The selected character's index, or the character count when none is selected. */
 /** @return The selected character's index, or the character count when none is selected. */
 [[nodiscard]] std::size_t selected_character_index(const AccountState& account) noexcept;
 [[nodiscard]] std::size_t selected_character_index(const AccountState& account) noexcept;
 /**
 /**
- * Stages the common selected-character insertion path.
- * @param chargedAccount Account after any material cost, or account itself when nothing is charged.
- * @return False when the character has no free row or the after-image does not resolve.
+ * Hold investment::store::g_mutex while capturing inventory and quest state together.
+ * @param account State before any acquisition charge.
+ * @param chargedAccount State after the prepared material charge.
+ * @param definitionHash Item definition to grant.
+ * @param profileChanged Whether the charge changed profile inventory.
+ * @param source Grant identity and material requirements for commit checks.
+ * @param mutation Receives a pending grant; use only on success.
+ * @return False when the item, inventory, mapping, or saved quest state is invalid.
  */
  */
 [[nodiscard]] bool finalize_item_acquisition(const AccountState& account,
 [[nodiscard]] bool finalize_item_acquisition(const AccountState& account,
                                              const AccountState& chargedAccount,
                                              const AccountState& chargedAccount,
@@ -142,8 +147,11 @@ finalize_profile_item_acquisition(const AccountState& account,
                                   const GrantSource& source,
                                   const GrantSource& source,
                                   PendingProfileItemAcquisition& mutation) noexcept;
                                   PendingProfileItemAcquisition& mutation) noexcept;
 /**
 /**
- * Applies one validated insertion over an exact current account without taking State locks.
- * @return False when the account moved since the mutation was prepared.
+ * Hold investment::store::g_mutex; the selected character and saved state must still match.
+ * @param current Current account from the locked save view.
+ * @param mutation Prepared inventory insertion and prior quest state.
+ * @param after Receives the candidate account; use only on success.
+ * @return False for stale state or an invalid resulting inventory.
  */
  */
 [[nodiscard]] bool materialize_item_acquisition(const AccountState& current,
 [[nodiscard]] bool materialize_item_acquisition(const AccountState& current,
                                                 const PendingItemAcquisition& mutation,
                                                 const PendingItemAcquisition& mutation,