Przeglądaj źródła

Initialize supported quest-set state on first acquisition

Gage Fulwood 4 dni temu
rodzic
commit
c56daa594d
25 zmienionych plików z 1048 dodań i 41 usunięć
  1. 1 0
      Sunrise/Sunrise.vcxproj
  2. 2 0
      Sunrise/src/client/content/items/packages/internal.h
  3. 2 1
      Sunrise/src/client/content/items/packages/package_item_build.cpp
  4. 30 2
      Sunrise/src/client/content/items/packages/package_item_rows.cpp
  5. 1 0
      Sunrise/src/client/content/items/packages/package_node_build.cpp
  6. 159 0
      Sunrise/src/middleware/content/packages/tables/quest_initialization_reader.cpp
  7. 19 0
      Sunrise/src/middleware/content/packages/tables/quest_initialization_reader.h
  8. 7 4
      Sunrise/src/middleware/datagen/family4/account/account_encoder.cpp
  9. 6 0
      Sunrise/src/middleware/datagen/family4/account/account_encoder.h
  10. 10 4
      Sunrise/src/middleware/datagen/family4/character/character_encoder.cpp
  11. 8 0
      Sunrise/src/middleware/datagen/family4/character/character_encoder.h
  12. 1 1
      Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp
  13. 7 6
      Sunrise/src/server/bap/encrypted/push/snapshot/family4_inventory_updates.cpp
  14. 1 1
      Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp
  15. 10 4
      Sunrise/src/state/build_data/cache/records/cache_record_codec.cpp
  16. 5 2
      Sunrise/src/state/build_data/cache/records/format.h
  17. 4 2
      Sunrise/src/state/build_data/items/item_catalog.cpp
  18. 3 0
      Sunrise/src/state/build_data/items/item_catalog.h
  19. 36 0
      Sunrise/src/state/build_data/items/quest_initialization.h
  20. 12 1
      Sunrise/src/state/runtime/runtime.h
  21. 69 13
      Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp
  22. 93 0
      tests/README.md
  23. 11 0
      tests/build_release.cmd
  24. 532 0
      tests/quest_initialization_test.cpp
  25. 19 0
      tests/run_quest_initialization.cmd

+ 1 - 0
Sunrise/Sunrise.vcxproj

@@ -102,6 +102,7 @@
     <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_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_store_boot.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> root{};
     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. */
     std::vector<std::byte> plugSetTable{};
     /** Dense item-indexed catalyst completion expressions for this package pass. */

+ 2 - 1
Sunrise/src/client/content/items/packages/package_item_build.cpp

@@ -115,7 +115,8 @@ bool build() noexcept {
             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()
+            if (!state::build_data::item_definitions_ready()
+                || !state::build_data::record_definitions_ready()
                 || !state::build_data::node_definitions_ready()
                 || !state::build_data::season_pass_ready() || !exotic_catalysts_settled()) {
                 reason = "unlock_maps";

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

@@ -1,6 +1,7 @@
 #include <array>
 #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/details/item_detail_catalog.h"
 #include "../../../../state/build_data/runtime.h"
@@ -97,13 +98,39 @@ bool build_item_rows(const reader::Source& source,
         tables::items::Row item{};
         item.definitionHash = row.definitionHash;
         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},
                                                item)) {
             continue;
         }
         const std::uint32_t plugCategoryHash =
             corrected_plug_category(item.definitionHash, item.plugCategoryHash);
+        build_items::QuestInitialization quest{};
+        const auto parentIndex = tables::items::quest_parent(storage.definition);
+        if (needDefinitions && itemClass == 0x80807BEAU && 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 == 0x80807BEAU) {
+                quest =
+                    tables::items::read_quest_initialization(storage.definition,
+                                                             item.definitionIndex,
+                                                             parent,
+                                                             static_cast<std::size_t>(table.count),
+                                                             storage.questValueMap);
+            }
+        }
         storage.rows[rowCount++] =
             state::build_data::items::Definition{item.definitionHash,
                                                  item.definitionIndex,
@@ -113,7 +140,8 @@ bool build_item_rows(const reader::Source& source,
                                                  item.tier,
                                                  plugCategoryHash,
                                                  item.rollSetIndex,
-                                                 item.linkedPlugIndex};
+                                                 item.linkedPlugIndex,
+                                                 quest};
         if (needSocketRows) {
             storage.specialPlugCategories[item.definitionIndex] =
                 special_plug_category(plugCategoryHash);

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

@@ -134,6 +134,7 @@ bool read_unlock_slot_maps(const reader::Source& source,
         return false;
     }
     const std::span<const std::byte> valueMap{storage.child};
+    storage.questValueMap = storage.child;
     const bool valueMapRead =
         read_slot_map(valueMap, tables::kAccountValueMapDescriptor, maps.accountValue);
     (void)read_slot_map(valueMap, tables::kCharacterValueMapDescriptor, maps.characterValue);

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

@@ -0,0 +1,159 @@
+#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;
+
+/** These are serialized block pointers, not count/relative array descriptors. */
+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 < 4 || static_cast<std::uint64_t>(target) > bytes.size()
+        || bytes.size() - static_cast<std::size_t>(target) < 32) {
+        return false;
+    }
+    offset = static_cast<std::size_t>(target);
+    std::uint32_t actualClass = 0;
+    return read(bytes, offset - 4, actualClass) && actualClass == expectedClass;
+}
+
+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
+
+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 = 0xFFFFU;
+    Array objectives{};
+    if (definition.size() < 240 || !read(definition, 184, bucket) || bucket != 40
+        || !block(definition, 0x30, 0x808077EBU, objective)
+        || !array(definition, objective, 0x808087B1U, 2, objectives)
+        || !read(definition, objective + 0x1C, parent)) {
+        return 0xFFFFU;
+    }
+    return parent;
+}
+
+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() < 240
+        || !block(parent, 0x60, 0x808077C8U, set) || !read(parent, set + 0x1C, mode) || mode != 1
+        || !read(parent, set + 0x10, slot) || slot >= 32768
+        || !array(parent, set, 0x808077CAU, 8, members) || members.count > itemCount) {
+        return {};
+    }
+    std::int64_t unlockRelative = 0;
+    if (!read(definition, 0x90, unlockRelative)
+        || (unlockRelative != 0
+            && (!block(definition, 0x90, 0x808077ABU, unlock)
+                || !find_optional_array_at(definition, unlock, flags)
+                || (flags.count != 0 && !array(definition, unlock, 0x80807D4BU, 2, flags))))) {
+        return {};
+    }
+    for (std::size_t i = 0; i < flags.count; ++i) {
+        std::uint16_t flag = 0;
+        if (!read(definition, flags.dataOffset + i * 2, flag) || flag >= 32768) {
+            return {};
+        }
+    }
+    // A separate bucket-37 set root can track a pursuit without an item-presence flag.
+    // Only the objective-free root / character-value form is supported here; this is
+    // first-step initialization, not a general interaction or eligibility evaluator.
+    const bool separateRoot = flags.count == 0;
+    if (separateRoot) {
+        std::uint8_t parentBucket = 0;
+        std::int64_t parentObjective = 0;
+        if (parentIndex == itemIndex || !read(parent, 184, parentBucket) || parentBucket != 37
+            || !read(parent, 0x30, 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 * 8;
+        std::int32_t value = 0;
+        std::uint16_t member = 0, reserved = 0;
+        if (!read(parent, at, value) || !read(parent, at + 4, member)
+            || !read(parent, at + 6, 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 {}; // Two steps cannot give the initial identifier an unambiguous meaning.
+        }
+    }
+    if (matches != 1) {
+        return {};
+    }
+
+    // Resolve across all four maps: a context/roster-lane match or duplicate is unsupported.
+    matches = 0;
+    for (const std::size_t descriptor : {8U, 24U, 40U, 56U}) {
+        Array rows{};
+        if (!find_optional_array_at(valueMap, descriptor, rows) || rows.dataOffset > valueMap.size()
+            || rows.count > (valueMap.size() - rows.dataOffset) / 8) {
+            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 * 8 + 4, mappedSlot)
+                || !read(valueMap, rows.dataOffset + i * 8 + 6, reserved)) {
+                return {};
+            }
+            if (mappedSlot < 0 || static_cast<std::uint16_t>(mappedSlot) != slot) {
+                continue;
+            }
+            if (reserved != 0 || ++matches != 1 || i >= 0xFFFFU
+                || (descriptor != 8 && descriptor != 24)) {
+                return {};
+            }
+            quest.row = static_cast<std::uint16_t>(i);
+            quest.scope = descriptor == 8 ? 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

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

@@ -0,0 +1,19 @@
+#pragma once
+
+#include "../../../../state/build_data/items/quest_initialization.h"
+#include "items.h"
+
+namespace sunrise::middleware::content::packages::tables::items {
+
+/** Returns the objective block's set-bearing item index, or 0xFFFF when unsupported. */
+[[nodiscard]] std::uint16_t quest_parent(std::span<const std::byte> definition) noexcept;
+
+/** Unsupported/malformed contracts return an empty plan; they never imply a guessed write. */
+[[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

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

@@ -75,6 +75,13 @@ constexpr std::size_t kBucketIdentityCapacity = 256;
 
 /** Encodes a sentinel-correct account object from live State. */
 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);
+}
+
+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)
         || output.size() < layout::kMinimumSize) {
         return false;
@@ -89,10 +96,6 @@ bool encode(const state::AccountState& state, std::span<std::byte> output) noexc
         return false;
     }
 
-    state::unlocks::Table unlocks;
-    if (!state::unlocks::snapshot(unlocks)) {
-        return false;
-    }
     object.acquiredFlags = unlocks.accountFlags;
     object.profileUnlockFlags = unlocks.profileFlags;
     object.objectiveValues = unlocks.objectiveValues;

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

@@ -3,6 +3,7 @@
 #include <span>
 
 #include "../../../../state/account/account_state.h"
+#include "../../../../state/unlocks/definition.h"
 
 namespace sunrise::middleware::datagen::family4::account {
 
@@ -14,4 +15,9 @@ namespace sunrise::middleware::datagen::family4::account {
  */
 [[nodiscard]] bool encode(const state::AccountState& state, std::span<std::byte> output) noexcept;
 
+/** Encodes a prepared unlock after-image without first writing it to the live save. */
+[[nodiscard]] bool encode(const state::AccountState& state,
+                          std::span<std::byte> output,
+                          const state::unlocks::Table& unlocks) noexcept;
+
 } // namespace sunrise::middleware::datagen::family4::account

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

@@ -241,6 +241,16 @@ bool encode(const state::CharacterState& state,
             const loadout::ResolvedLoadout& resolvedLoadout,
             const state::equipment::light::Evaluation& lightEvaluation,
             std::span<std::byte> output) noexcept {
+    state::unlocks::Table unlocks;
+    return state::unlocks::snapshot(unlocks)
+           && encode(state, resolvedLoadout, lightEvaluation, output, unlocks);
+}
+
+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)
         || !summary_matches_loadout(resolvedLoadout, lightEvaluation)
         || output.size() < layout::kObjectSize) {
@@ -280,10 +290,6 @@ bool encode(const state::CharacterState& state,
     }
     // 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;
-    }
     for (std::size_t index = 0; index < object.acquiredFlags.size(); ++index) {
         object.acquiredFlags[index] = static_cast<std::byte>(
             index < unlocks.characterObjectFlags.size() ? unlocks.characterObjectFlags[index]

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

@@ -4,6 +4,7 @@
 
 #include "../../../../state/account/account_state.h"
 #include "../../../../state/equipment/light/definition.h"
+#include "../../../../state/unlocks/definition.h"
 #include "../loadout/definition.h"
 
 namespace sunrise::middleware::datagen::family4::character {
@@ -21,4 +22,11 @@ namespace sunrise::middleware::datagen::family4::character {
                           const state::equipment::light::Evaluation& lightEvaluation,
                           std::span<std::byte> output) noexcept;
 
+/** Encodes a prepared unlock after-image without first writing it to the live save. */
+[[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

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

@@ -454,7 +454,7 @@ bool process(const ServiceRoute& route,
                                                    itemAcquisition->accountSoid,
                                                    itemAcquisition->characterSoid,
                                                    itemAcquisition->acquiredInstanceSoid,
-                                                   itemAcquisition->profileChanged,
+                                                   itemAcquisition->updates_account(),
                                                    transaction->update)) {
                 core::log::write(core::log::Channel::server,
                                  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.characterSoid != acquisition.characterSoid
         || 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.accountDefinitionId == 0 || acquisition.after.family4ResidentCount == 0
         || acquisition.after.family4Residents[acquisition.after.family4ResidentCount - 1U]
@@ -408,7 +407,8 @@ bool prepare_item_acquisition(
                != acquisition.itemInstanceDefinitionId) {
         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
         || account.primarySoid != acquisition.accountSoid
         || account.characters[mutation.characterIndex].soid != mutation.characterSoid) {
@@ -452,7 +452,8 @@ bool prepare_item_acquisition(
     if (!family4_datagen::character::encode(account.characters[mutation.characterIndex],
                                             selected.loadout,
                                             selected.lightEvaluation,
-                                            characterBytes)) {
+                                            characterBytes,
+                                            afterUnlocks)) {
         return report_failure("acquire_character_object");
     }
 
@@ -553,7 +554,7 @@ bool prepare_item_acquisition(
             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, afterUnlocks)
             || !append_object(scratch,
                               accountBytes,
                               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.characterSoid,
                                         pending.acquiredInstanceSoid,
-                                        pending.profileChanged,
+                                        pending.updates_account(),
                                         acquisition)) {
         core::log::write(core::log::Channel::server,
                          core::log::Level::warn,

+ 10 - 4
Sunrise/src/state/build_data/cache/records/cache_record_codec.cpp

@@ -52,7 +52,7 @@ bool decode(const NamedRecord& record, content::Definition& value) noexcept {
  * Encodes one installed-build item mapping with its padding zeroed.
  * @param value Runtime row.
  * @param record Receives the packed disk row.
- * @return Always true, because an unknown bucket has its own unset value.
+ * @return True when the optional quest initialization has a supported scope and bank row.
  */
 bool encode(const items::Definition& value, ItemRecord& record) noexcept {
     record = {
@@ -65,8 +65,11 @@ bool encode(const items::Definition& value, ItemRecord& record) noexcept {
         value.plugCategoryHash,
         value.rollSetIndex,
         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. */
@@ -79,8 +82,11 @@ bool decode(const ItemRecord& record, items::Definition& value) noexcept {
              record.tier,
              record.plugCategoryHash,
              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. */

+ 5 - 2
Sunrise/src/state/build_data/cache/records/format.h

@@ -35,7 +35,7 @@ inline constexpr std::array<char, 8> kCacheMagic{'S', 'U', 'N', 'R', 'I', 'S', '
  * 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;
+inline constexpr std::uint32_t kCacheFormatVersion = 65;
 /** 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. */
@@ -154,6 +154,9 @@ struct ItemRecord {
     std::uint32_t plugCategoryHash{};
     std::uint16_t rollSetIndex{};
     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. */
@@ -643,7 +646,7 @@ static_assert(sizeof(NamedRecord)
               == content::kDefinitionNameCapacity + 2 * sizeof(std::uint16_t)
                      + 2 * sizeof(std::uint32_t));
 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)
               == sizeof(std::uint32_t) + 2 * sizeof(std::uint16_t) + 2 * sizeof(std::uint8_t));
 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{};
     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 != 40)) {
             return false;
         }
         occupied[definition.definitionIndex] = true;

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

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

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

@@ -0,0 +1,36 @@
+#pragma once
+
+#include <cstdint>
+
+#include "../../unlocks/definition.h"
+
+namespace sunrise::state::build_data::items {
+
+/** Only the first member of a supported, item-presence-gated 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;
+};
+
+[[nodiscard]] constexpr bool valid(const QuestInitialization& quest) noexcept {
+    using Scope = QuestInitialization::Scope;
+    if (quest.scope == Scope::none) {
+        return quest.row == 0 && quest.value == 0;
+    }
+    return quest.value != 0 && quest.value != -1
+           && ((quest.scope == Scope::account && quest.row < unlocks::kObjectiveValueCapacity)
+               || (quest.scope == Scope::character
+                   && quest.row < unlocks::kCharacterObjectValueCapacity));
+}
+
+/** Set values are identifiers, not a numerically ordered progress counter. */
+[[nodiscard]] constexpr std::int32_t initialized_value(const QuestInitialization& quest,
+                                                       std::int32_t before) noexcept {
+    return quest.scope != QuestInitialization::Scope::none && before == 0 ? quest.value : before;
+}
+
+} // namespace sunrise::state::build_data::items

+ 12 - 1
Sunrise/src/state/runtime/runtime.h

@@ -6,6 +6,7 @@
 #include <span>
 #include <variant>
 
+#include "../build_data/items/quest_initialization.h"
 #include "../build_data/records/definition.h"
 #include "state.h"
 
@@ -135,7 +136,16 @@ struct PendingItemAcquisition {
     bool profileChanged{};
     /** Skips Collections revalidation for direct rewards. */
     bool directGrant{};
+    build_data::items::QuestInitialization questInitialization{};
+    std::int32_t previousQuestValue{};
     bool prepared{};
+
+    [[nodiscard]] bool updates_account() const noexcept {
+        return profileChanged
+               || (questInitialization.scope
+                       == build_data::items::QuestInitialization::Scope::account
+                   && previousQuestValue == 0);
+    }
 };
 
 /** One profile row an exchange changed, named the way the account's change ring names it. */
@@ -566,7 +576,8 @@ reserve_selected_character_inventory_serial(std::int32_t& mutationSerial) noexce
 
 /** Builds the exact full-account after-image while a prepared item pull remains current. */
 [[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,

+ 69 - 13
Sunrise/src/state/runtime/state_account_acquisition_runtime.cpp

@@ -23,6 +23,30 @@ namespace family4_loadout = middleware::datagen::family4::loadout;
 
 namespace runtime::detail {
 
+using Quest = build_data::items::QuestInitialization;
+
+[[nodiscard]] investment::store::Bank quest_bank(const Quest& quest) noexcept {
+    return quest.scope == Quest::Scope::account ? investment::store::Bank::objectiveValues
+                                                : investment::store::Bank::characterObjectValues;
+}
+
+/** The caller holds the investment lock and has checked the selected character. */
+[[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 == 0;
+    }
+    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. */
 [[nodiscard]] std::size_t selected_character_index(const AccountState& account) noexcept {
     const std::size_t count = (std::min)(account.characterCount, account.characters.size());
@@ -101,6 +125,18 @@ namespace runtime::detail {
     mutation.materialRequirementCount = source.materialRequirementCount;
     mutation.profileChanged = profileChanged;
     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;
     return true;
 }
@@ -111,6 +147,7 @@ namespace runtime::detail {
 bool prepare_item_acquisition(std::uint16_t collectibleIndex,
                               std::uint32_t definitionHash,
                               PendingItemAcquisition& mutation) noexcept {
+    const std::lock_guard lock(investment::store::g_mutex);
     mutation = {};
     const AccountState account = account_snapshot();
     build_data::collectibles::Definition collectible{};
@@ -159,6 +196,7 @@ bool prepare_item_acquisition(std::uint16_t collectibleIndex,
 /** Prepares one direct selected-character inventory grant, with no Collections row or charge. */
 bool prepare_item_acquisition_for_item(std::uint16_t itemDefinitionIndex,
                                        PendingItemAcquisition& mutation) noexcept {
+    const std::lock_guard lock(investment::store::g_mutex);
     mutation = {};
     const AccountState account = account_snapshot();
     build_data::items::Definition grantedDefinition{};
@@ -356,7 +394,9 @@ valid_item_acquisition_source(const PendingItemAcquisition& mutation) noexcept {
     std::uint64_t nextSoid = 0;
     if (!valid_item_acquisition_source(mutation)
         || 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_profile_inventory(
             current, mutation.beforeProfileItems, mutation.expectedProfileItemCount)
@@ -456,9 +496,24 @@ valid_item_acquisition_source(const PendingItemAcquisition& mutation) noexcept {
 
 /** Produces the full account after-image while a prepared character pull remains current. */
 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 = {};
-    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. */
@@ -472,18 +527,19 @@ bool preview_direct_item_bundle(const PendingDirectItemBundle& mutation,
 bool commit_item_acquisition(PendingItemAcquisition& mutation) noexcept {
     const PendingItemAcquisition& prepared = mutation;
     const PendingConsumption consume{mutation};
-    investment::store::g_mutex.lock();
+    investment::store::Transaction transaction;
     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 == 0
+        && !investment::store::write_unlock(quest_bank(quest), quest.row, quest.value)) {
+        return false;
+    }
+    return transaction.commit();
 }
 
 namespace runtime::detail {

+ 93 - 0
tests/README.md

@@ -0,0 +1,93 @@
+# Quest-set initialization checks
+
+Branch: `fix/quest-set-initialization`, based on `878b639dd2257924feb01252679634e7bdca4259`.
+
+This change initializes a supported quest set when its first item is acquired and
+the saved set value is zero. The value comes from the first `(value, itemIndex)`
+entry, not the set block's ordering field and not a fixed `100`. Existing nonzero
+values, including negative identifiers and `-1`, are preserved.
+
+The item catalog carries the resolved account/character bank row and initial
+value. Acquisition captures the old value, validates it again before commit,
+and saves inventory plus quest state in one SQLite transaction. Family-4
+preparation encodes the prospective state before commit, including an account
+object when the account bank changes. Cache version 65 rebuilds old metadata,
+including version-64 records that omitted the separate-root form below.
+
+## Run
+
+From this worktree in an **x64 Visual Studio Developer Command Prompt**, with
+the toolset and Windows SDK required by `Sunrise.sln` installed:
+
+```bat
+tests\build_release.cmd
+tests\run_quest_initialization.cmd
+```
+
+The test uses the actual Release object files, an in-memory SQLite database,
+the repository's default schema, and a small synthetic item catalog. It does
+not load the DLL into the game or open the installed save. Assertions remain
+enabled in the test executable.
+The scripts use that prompt's configured tools; no particular VS edition or
+installation path is required. Rebuild after production changes before running
+the checks, so the linked Release objects match the source.
+
+Optional local content comparison (the extracted files must stay outside Git):
+
+```bat
+tests\run_quest_initialization.cmd "PATH\TO\LOCAL\content-02"
+```
+
+Checks cover signed identifiers; malformed blocks/arrays; duplicate membership,
+initial identifiers and mappings; separate-parent links; unsupported scopes;
+the bounded no-direct-flag form and its rejected neighbors; cache round trip;
+both saved scopes; preservation of
+existing state and objective progress; another character's isolation; stale
+values/selection/contracts; full and invalid grants; rollback on either inventory
+or unlock SQL failure; and the encoded account/character after-images.
+Production queuez staging checks cover fresh account/character quests, existing
+nonzero progress, an ordinary item, and the existing profile-change flag. A
+source-level guard checks the actual world-reward caller's staging arguments and
+rejects the old `profileChanged` argument. It is intentionally a wiring guard,
+not an end-to-end test of compressed/encrypted world-reward delivery.
+
+## Coverage and limits
+
+The initial direct-flag-only build-86657 comparison recognized 182 structural
+candidates: 50 account-scoped and 132 character-scoped first steps. The
+separate-root extension recognizes 34 additional character-scoped first steps,
+for 216 total (50 account + 166 character). It includes the confirmed contract
+for item 13138: character row 162, initial value -1583618456; its five later
+members remain excluded. The optional content check reports the current total.
+This is **not** an
+in-game pass count or proof of every vendor's eligibility policy. Technical
+Knockout's gameplay regression has passed on the installed patch: the user
+confirmed the vendor behavior, and the post-test save contains the acquired
+item and initialized account value. Do not repeat that case without a relevant
+regression or behavior change. The user also confirmed Sight, Shoot, Repeat's
+quest-step acquisition behavior and persistence across a client restart after
+the separate-root extension was installed. That gameplay report is accepted;
+its post-test database was not independently inspected. These two completed
+cases do not establish completion/turn-in or text-acknowledgement support.
+
+The supported shape is an objective-bearing pursuit (bucket 40), linked to a
+mode-1 set, with one unambiguous first-member identifier and one supported
+value-bank mapping. It must have direct item-presence flags, or link to a
+**separate, objective-free bucket-37 root with a character-scoped value**.
+The latter form may have an absent/empty direct-flag list; malformed lists
+still fail. This is a structural support boundary, not a claim to evaluate all
+vendor eligibility rules. Unrecognized shapes keep the
+existing grant behavior without a guessed quest-state write. No per-quest
+exceptions, client hooks, schema migration or Lua changes are added.
+
+Not implemented: later-step advancement, objective earning, completion rewards,
+daily/weekly bounty rules, full vendor eligibility evaluation, or repair of
+already inconsistent saves. Reopening a vendor does not initialize an already
+owned item; a successful acquisition is required.
+
+Vendor and world-item acquisition staging now both use `updates_account()`, so
+a newly initialized account-scoped set requests an account update even without
+a profile-material charge. Not every reward service uses the common commit
+path: **season-pass quest initialization is excluded**. That service independently
+writes account state without committing the quest value; no currently affected
+season reward has been established. Do not claim universal reward-path support.

+ 11 - 0
tests/build_release.cmd

@@ -0,0 +1,11 @@
+@echo off
+setlocal
+if /i not "%VSCMD_ARG_TGT_ARCH%"=="x64" (
+    echo Run this script from an x64 Visual Studio Developer Command Prompt.
+    exit /b 1
+)
+where msbuild >nul 2>&1
+if errorlevel 1 exit /b 1
+cd /d "%~dp0.."
+msbuild Sunrise.sln /m:4 /p:Configuration=Release /p:Platform=x64 /v:minimal /nologo /fl /flp:logfile=build-release.log
+exit /b %errorlevel%

+ 532 - 0
tests/quest_initialization_test.cpp

@@ -0,0 +1,532 @@
+#include <algorithm>
+#include <cassert>
+#include <cctype>
+#include <cstring>
+#include <filesystem>
+#include <fstream>
+#include <iostream>
+#include <iterator>
+#include <limits>
+#include <sstream>
+#include <vector>
+
+#include "middleware/content/packages/tables/definition_index_table.h"
+#include "middleware/content/packages/tables/quest_initialization_reader.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/loadout/loadout_resolver.h"
+#include "server/bap/encrypted/queuez/queuez_state_validation.h"
+#include "state/build_data/cache/records/codec.h"
+#include "state/build_data/inventory/buckets/inventory_bucket_catalog.h"
+#include "state/build_data/items/details/item_detail_catalog.h"
+#include "state/build_data/items/item_catalog.h"
+#include "state/build_data/progressions/progression_catalog.h"
+#include "state/build_data/runtime/domain_markers.h"
+#include "state/build_data/socket_entry_lists/socket_entry_list_catalog.h"
+#include "state/investment/store_internal.h"
+#include "state/runtime/runtime.h"
+
+namespace fs = std::filesystem;
+namespace s = sunrise::state;
+namespace items = s::build_data::items;
+namespace tables = sunrise::middleware::content::packages::tables;
+namespace store = s::investment::store;
+using Quest = items::QuestInitialization;
+using Bytes = std::vector<std::byte>;
+
+std::string read_text(const fs::path& path) {
+    std::ifstream file(path, std::ios::binary);
+    assert(file.good());
+    return {std::istreambuf_iterator<char>(file), {}};
+}
+Bytes read_bytes(const fs::path& path) {
+    const auto text = read_text(path);
+    Bytes bytes(text.size());
+    std::memcpy(bytes.data(), text.data(), text.size());
+    return bytes;
+}
+
+// Source-level wiring guard, not an end-to-end world-reward delivery test.
+// Keep the real caller covered without requiring the game's compression DLL.
+void world_reward_caller_check(const fs::path& repository) {
+    auto source =
+        read_text(repository / "Sunrise/src/server/bap/encrypted/queuez/queuez_deferred_push.cpp");
+    std::erase_if(source, [](unsigned char c) { return std::isspace(c) != 0; });
+    const auto function = source.find("boolconsume_world_item_acquisition(");
+    assert(function != std::string::npos);
+    const auto call = source.find("queuez::stage_item_acquisition(", function);
+    const auto end = source.find("acquisition))", call);
+    assert(call != std::string::npos && end != std::string::npos);
+    auto arguments = source.substr(call, end + std::strlen("acquisition))") - call);
+    constexpr auto expected = "queuez::stage_item_acquisition(session.queuez,pending.accountSoid,"
+                              "pending.characterSoid,pending.acquiredInstanceSoid,"
+                              "pending.updates_account(),acquisition))";
+    assert(arguments == expected);
+    // Negative control: restoring the old argument must fail this wiring check.
+    const auto flag = arguments.find("pending.updates_account()");
+    arguments.replace(flag, std::strlen("pending.updates_account()"), "pending.profileChanged");
+    assert(arguments != expected);
+    std::cout << "PASS world-reward caller source guard (old argument rejected)\n";
+}
+
+void check_acquisition_staging(const s::PendingItemAcquisition& mutation, bool updatesAccount) {
+    namespace queuez = sunrise::server::bap::encrypted::queuez;
+    namespace datagen = sunrise::middleware::datagen;
+    queuez::SessionState before{};
+    before.family4Active = true;
+    before.family4RootSoid = mutation.accountSoid;
+    before.family4ResidentCount = 2;
+    before.family4Residents[0] = {mutation.accountSoid, datagen::kAccountObjectId};
+    before.family4Residents[1] = {mutation.characterSoid, datagen::kCharacterObjectId};
+    queuez::ItemAcquisition staged{};
+    assert(queuez::stage_item_acquisition(before,
+                                          mutation.accountSoid,
+                                          mutation.characterSoid,
+                                          mutation.acquiredInstanceSoid,
+                                          mutation.updates_account(),
+                                          staged));
+    assert(staged.updatesAccount == updatesAccount);
+    assert(staged.accountDefinitionId == datagen::kAccountObjectId);
+    assert(staged.characterDefinitionId == datagen::kCharacterObjectId);
+    assert(staged.itemInstanceDefinitionId == datagen::kItemInstanceObjectId);
+    assert(staged.after.family4Version == before.family4Version + 1);
+    assert(staged.after.family4ResidentCount == before.family4ResidentCount + 1);
+    assert(staged.after.family4Residents[2].objectSoid == mutation.acquiredInstanceSoid);
+    assert(before.family4Version == 0 && before.family4ResidentCount == 2);
+}
+template <class T> void put(Bytes& bytes, std::size_t at, T value) {
+    assert(at <= bytes.size() && sizeof value <= bytes.size() - at);
+    std::memcpy(bytes.data() + at, &value, sizeof value);
+}
+void block(Bytes& bytes, std::size_t field, std::size_t at, std::uint32_t cls) {
+    put(bytes, field, static_cast<std::int64_t>(at) - static_cast<std::int64_t>(field));
+    put(bytes, at - 4, cls);
+}
+void array(
+    Bytes& bytes, std::size_t field, std::size_t at, std::uint64_t count, std::uint32_t cls) {
+    put(bytes, field, count);
+    put(bytes, field + 8, static_cast<std::int64_t>(at) - static_cast<std::int64_t>(field + 8));
+    put(bytes, at - 4, std::uint32_t{0x80800000});
+    put(bytes, at, count);
+    put(bytes, at + 8, cls);
+}
+
+Quest parser_checks() {
+    Bytes item(800), map(256);
+    put(item, 184, std::uint8_t{40});
+    block(item, 0x30, 260, 0x808077EB);
+    put(item, 288, std::uint16_t{0});
+    array(item, 260, 500, 1, 0x808087B1);
+    block(item, 0x60, 320, 0x808077C8);
+    put(item, 336, std::uint16_t{7});
+    put(item, 348, std::uint8_t{1});
+    array(item, 320, 550, 2, 0x808077CA);
+    put(item, 566, std::int32_t{-123});
+    put(item, 570, std::uint16_t{0});
+    put(item, 574, std::int32_t{42});
+    put(item, 578, std::uint16_t{1});
+    block(item, 0x90, 380, 0x808077AB);
+    array(item, 380, 600, 1, 0x80807D4B);
+    put(item, 616, std::uint16_t{11});
+    array(map, 8, 100, 1, 0x80800001);
+    put(map, 120, std::int16_t{7});
+    const Quest expected{-123, 0, Quest::Scope::account};
+    const auto parse = [&](const Bytes& a, const Bytes& m) {
+        return tables::items::read_quest_initialization(a, 0, a, 2, m);
+    };
+    assert(parse(item, map) == expected);
+    assert(items::initialized_value(expected, 0) == -123);
+    for (auto before : {100, 200, -1, -1583618456}) {
+        assert(items::initialized_value(expected, before) == before);
+    }
+    assert(!items::valid(Quest{100, 6200, Quest::Scope::account}));
+    assert(!items::valid(Quest{100, 768, Quest::Scope::character}));
+    auto bad = item;
+    put(bad, 0x60, (std::numeric_limits<std::int64_t>::max)());
+    assert(parse(bad, map) == Quest{});
+    bad = item;
+    put(bad, 578, std::uint16_t{0});
+    assert(parse(bad, map) == Quest{}); // Duplicate membership.
+    bad = item;
+    put(bad, 574, std::int32_t{-123});
+    assert(parse(bad, map) == Quest{}); // Ambiguous initial identifier.
+    auto separate = item;
+    put(separate, 288, std::uint16_t{1});
+    put(separate, 0x60, std::int64_t{0});
+    assert(tables::items::read_quest_initialization(separate, 0, item, 2, map) == expected);
+    bad = item;
+    put(bad, 348, std::uint8_t{0});
+    assert(parse(bad, map) == Quest{});
+    bad = item;
+    put(bad, 0x90, std::int64_t{0});
+    assert(parse(bad, map) == Quest{});
+    bad = item;
+    bad.resize(240);
+    assert(parse(bad, map) == Quest{});
+    for (auto value : {0, -1}) {
+        bad = item;
+        put(bad, 566, value);
+        assert(parse(bad, map) == Quest{});
+    }
+    assert(tables::items::read_quest_initialization(item, 1, item, 2, map) == Quest{});
+    auto duplicateMap = map;
+    array(duplicateMap, 24, 160, 1, 0x80800001);
+    put(duplicateMap, 180, std::int16_t{7});
+    assert(parse(item, duplicateMap) == Quest{});
+    auto characterMap = map;
+    put(characterMap, 8, std::uint64_t{0});
+    put(characterMap, 16, std::int64_t{0});
+    array(characterMap, 24, 160, 1, 0x80800001);
+    put(characterMap, 180, std::int16_t{7});
+    assert((parse(item, characterMap) == Quest{-123, 0, Quest::Scope::character}));
+    auto contextMap = map;
+    put(contextMap, 8, std::uint64_t{0});
+    put(contextMap, 16, std::int64_t{0});
+    array(contextMap, 40, 160, 1, 0x80800001);
+    put(contextMap, 180, std::int16_t{7});
+    assert(parse(item, contextMap) == Quest{});
+    auto root = item;
+    put(root, 184, std::uint8_t{37});
+    put(root, 0x30, std::int64_t{0});
+    put(root, 566, std::int32_t{-1583618456});
+    auto noFlags = separate;
+    put(noFlags, 380, std::uint64_t{0});
+    put(noFlags, 388, std::int64_t{0});
+    Bytes rootMap(3800);
+    array(rootMap, 24, 100, 443, 0x80800001);
+    put(rootMap, 116 + 442 * 8 + 4, std::int16_t{7});
+    const auto rootParse = [&](const Bytes& a, const Bytes& p) {
+        return tables::items::read_quest_initialization(a, 0, p, 2, rootMap);
+    };
+    const Quest rootQuest{-1583618456, 442, Quest::Scope::character};
+    assert(rootParse(noFlags, root) == rootQuest);
+    auto absent = noFlags;
+    put(absent, 0x90, std::int64_t{0});
+    assert(rootParse(absent, root) == rootQuest);
+    auto malformed = noFlags;
+    put(malformed, 0x90, (std::numeric_limits<std::int64_t>::max)());
+    assert(rootParse(malformed, root) == Quest{});
+    malformed = noFlags;
+    put(malformed, 380, std::uint64_t{1}); // Nonempty flags need a valid array.
+    assert(rootParse(malformed, root) == Quest{});
+    auto wrongRoot = root;
+    put(wrongRoot, 184, std::uint8_t{40});
+    assert(rootParse(noFlags, wrongRoot) == Quest{});
+    wrongRoot = root;
+    put(wrongRoot, 0x30, std::int64_t{212});
+    assert(rootParse(noFlags, wrongRoot) == Quest{});
+    wrongRoot = root;
+    put(wrongRoot, 570, std::uint16_t{1});
+    put(wrongRoot, 578, std::uint16_t{0}); // Later members stay unsupported.
+    assert(rootParse(noFlags, wrongRoot) == Quest{});
+    assert(tables::items::read_quest_initialization(noFlags, 0, root, 2, map) == Quest{});
+    std::cout << "PASS synthetic parser, signed values, bounds, ambiguity and preservation\n";
+    std::cout << "PASS separate-root first step: empty/absent flags, malformed inputs, "
+                 "wrong root/scope and later-step rejection\n";
+    return rootQuest;
+}
+
+void content_checks(const fs::path& root) {
+    struct Row {
+        std::uint32_t hash{}, tag{};
+        unsigned bucket{};
+        bool objective{}, set{};
+    };
+    std::vector<Row> rows;
+    std::istringstream input(read_text(root / "items.tsv"));
+    std::string line;
+    std::getline(input, line);
+    while (std::getline(input, line)) {
+        std::istringstream fields(line);
+        std::size_t index = 0;
+        Row row;
+        fields >> index >> row.hash >> row.tag >> row.bucket >> row.objective >> row.set;
+        assert(fields && index == rows.size());
+        rows.push_back(row);
+    }
+    auto blob = [&](std::size_t index) {
+        std::ostringstream filename;
+        filename << std::uppercase << std::hex << rows.at(index).tag << ".bin";
+        return read_bytes(root / filename.str());
+    };
+    const auto map = read_bytes(root / "81319320.bin");
+    std::vector<Quest> quests(rows.size());
+    std::size_t account = 0, character = 0;
+    for (std::size_t i = 0; i < rows.size(); ++i) {
+        if (rows[i].bucket != 40 || !rows[i].objective) {
+            continue;
+        }
+        const auto item = blob(i);
+        const auto parent = tables::items::quest_parent(item);
+        if (parent >= rows.size()) {
+            continue;
+        }
+        quests[i] = tables::items::read_quest_initialization(
+            item, static_cast<std::uint16_t>(i), blob(parent), rows.size(), map);
+        account += quests[i].scope == Quest::Scope::account;
+        character += quests[i].scope == Quest::Scope::character;
+    }
+    assert((quests.at(15282) == Quest{100, 5762, Quest::Scope::account}));
+    assert((quests.at(14844) == Quest{100, 442, Quest::Scope::character}));
+    assert((quests.at(13138) == Quest{-1583618456, 162, Quest::Scope::character}));
+    for (auto index : {15U, 13139U, 13140U, 13141U, 13142U, 13143U, 14845U, 15283U}) {
+        assert(quests.at(index) == Quest{});
+    }
+    assert(account == 50 && character >= 133);
+    std::cout << "PASS installed content: " << account << " account + " << character
+              << " character first steps (structural coverage, not gameplay certification)\n";
+}
+
+void cache_checks() {
+    namespace cache = s::build_data::cache::records;
+    items::Definition item{};
+    item.definitionHash = 123;
+    item.bucketId = 40;
+    item.questInitialization = {-123, 7, Quest::Scope::character};
+    cache::ItemRecord record{};
+    items::Definition decoded{};
+    assert(cache::encode(item, record) && cache::decode(record, decoded));
+    assert(decoded.questInitialization == item.questInitialization);
+    record.questValueScope = 255;
+    assert(!cache::decode(record, decoded));
+    std::cout << "PASS cache round trip and invalid scope rejection\n";
+}
+
+void runtime_checks(const fs::path& repo, const Quest& characterQuest) {
+    const auto resources = repo / "Sunrise/resources/database";
+    assert(store::open(":memory:",
+                       read_text(resources / "investment_schema.sql"),
+                       read_text(resources / "investment_defaults.sql"),
+                       read_text(resources / "account_settings_schema.sql"),
+                       read_text(resources / "account_settings_defaults.sql")));
+    auto baseline = store::account();
+    baseline.profileItems = {};
+    baseline.profileItemCount = 0;
+    for (std::size_t i = 0; i < baseline.characterCount; ++i) {
+        auto& character = baseline.characters[i];
+        character.equipment = {};
+        character.inventory = {};
+        character.stacks = {};
+        character.selected = i == 0;
+    }
+    assert(baseline.characterCount >= 2);
+    assert(store::write_account(baseline));
+    std::array<items::Definition, 7> definitions{};
+    std::array<items::details::Definition, 7> details{};
+    for (std::uint16_t i = 0; i < definitions.size(); ++i) {
+        definitions[i].definitionIndex = i;
+        definitions[i].definitionHash = 1000U + i;
+        definitions[i].bucketId = 40;
+        details[i].definitionIndex = i;
+        details[i].definitionHash = 1000U + i;
+        details[i].bucketId = 40;
+        details[i].maxStackSize = 1;
+        details[i].instancedDefinitionState = items::details::InstancedDefinitionState::instanced;
+    }
+    definitions[0].questInitialization = {100, 5762, Quest::Scope::account};
+    definitions[1].questInitialization = characterQuest;
+    // The existing character encoder independently requires these four legacy prerequisites.
+    constexpr std::array<std::uint32_t, 4> legacyQuests{
+        0x57C4540AU, 0x85CC476EU, 0xB099029AU, 0xC3535D63U};
+    for (std::size_t i = 0; i < legacyQuests.size(); ++i) {
+        definitions[i + 3].definitionHash = details[i + 3].definitionHash = legacyQuests[i];
+        details[i + 3].instancedDefinitionState =
+            items::details::InstancedDefinitionState::stackable;
+    }
+    assert(items::replace(definitions));
+    assert(items::details::replace(details));
+    s::build_data::runtime::details::publish();
+    namespace buckets = s::build_data::inventory::buckets;
+    const std::array<buckets::Descriptor, 2> bucketRows{
+        {{0, buckets::ArraySelector::character, 0, 1, 0},
+         {40, buckets::ArraySelector::character, 1, 349}}};
+    assert(buckets::replace(bucketRows));
+    const s::build_data::socket_entry_lists::Definition sockets{1, 0, 0, 0};
+    assert(s::build_data::socket_entry_lists::replace({&sockets, 1}));
+    const s::build_data::progressions::Definition progression{};
+    assert(s::build_data::progressions::replace({&progression, 1}, {}));
+
+    const auto value = [](store::Bank bank, std::uint16_t row) {
+        std::int32_t result = 0;
+        assert(store::read_unlock(bank, row, result));
+        return result;
+    };
+    const auto reset = [&] {
+        assert(store::write_account(baseline));
+        assert(store::execute("DELETE FROM unlocks"));
+    };
+    s::PendingItemAcquisition mutation{};
+    s::AccountState after{};
+    s::unlocks::Table unlocks{};
+    const auto check_encoded = [&](bool accountScope, std::uint16_t row, std::int32_t expected) {
+        namespace family4 = sunrise::middleware::datagen::family4;
+        std::int32_t sent = 0;
+        if (accountScope) {
+            Bytes encoded(family4::account::layout::kObjectSize);
+            assert(family4::account::encode(after, encoded, unlocks));
+            std::memcpy(&sent,
+                        encoded.data() + offsetof(family4::account::layout::Object, objectiveValues)
+                            + row * sizeof sent,
+                        sizeof sent);
+        } else {
+            Bytes encoded(family4::character::layout::kObjectSize);
+            family4::loadout::ResolvedLoadout resolved{};
+            assert(family4::loadout::resolve(after, 0, resolved));
+            s::equipment::light::Evaluation light{};
+            light.divisor = 1;
+            assert(
+                family4::character::encode(after.characters[0], resolved, light, encoded, unlocks));
+            std::memcpy(&sent,
+                        encoded.data()
+                            + offsetof(family4::character::layout::Object, objectiveValues)
+                            + row * sizeof sent,
+                        sizeof sent);
+        }
+        assert(sent == expected);
+    };
+    reset();
+    assert(s::prepare_item_acquisition_for_item(0, mutation));
+    assert(mutation.updates_account());
+    assert(!mutation.profileChanged); // The old world-reward flag misses this account write.
+    check_acquisition_staging(mutation, true);
+    assert(s::preview_item_acquisition(mutation, after, unlocks));
+    assert(unlocks.objectiveValues[5762] == 100);
+    check_encoded(true, 5762, 100);
+    assert(value(store::Bank::objectiveValues, 5762) == 0);
+    assert(store::account().characters[0].inventory.count == 0);
+    assert(s::commit_item_acquisition(mutation) && !mutation.prepared);
+    assert(value(store::Bank::objectiveValues, 5762) == 100);
+    assert(store::account().characters[0].inventory.count == 1);
+
+    for (auto previous : {100, 200, -1, -1583618456}) {
+        reset();
+        assert(store::write_unlock(store::Bank::objectiveValues, 5762, previous));
+        assert(store::write_unlock(store::Bank::objectiveValues, 5763, 4));
+        assert(s::prepare_item_acquisition_for_item(0, mutation));
+        assert(!mutation.updates_account());
+        check_acquisition_staging(mutation, false);
+        assert(s::commit_item_acquisition(mutation));
+        assert(value(store::Bank::objectiveValues, 5762) == previous);
+        assert(value(store::Bank::objectiveValues, 5763) == 4);
+    }
+    reset();
+    auto otherCharacter = baseline;
+    otherCharacter.characters[0].selected = false;
+    otherCharacter.characters[1].selected = true;
+    assert(store::write_account(otherCharacter));
+    assert(store::write_unlock(store::Bank::objectiveValues, 5762, 200));
+    assert(s::prepare_item_acquisition(
+        s::build_data::collectibles::kNoCollectibleIndex, definitions[0].definitionHash, mutation));
+    assert(s::commit_item_acquisition(mutation));
+    assert(value(store::Bank::objectiveValues, 5762) == 200);
+    assert(store::account().characters[1].inventory.count == 1);
+    reset();
+    assert(s::prepare_item_acquisition_for_item(1, mutation));
+    assert(!mutation.updates_account());
+    check_acquisition_staging(mutation, false);
+    assert(s::preview_item_acquisition(mutation, after, unlocks));
+    assert(unlocks.characterObjectValues[442] == characterQuest.value);
+    check_encoded(false, 442, characterQuest.value);
+    assert(value(store::Bank::characterObjectValues, 442) == 0);
+    assert(s::commit_item_acquisition(mutation));
+    assert(value(store::Bank::characterObjectValues, 442) == characterQuest.value);
+    assert(store::read_unlocks(unlocks, 1));
+    assert(unlocks.characterObjectValues[442] == 0);
+    for (auto previous : {characterQuest.value, 790208398, -1}) {
+        reset();
+        assert(store::write_unlock(store::Bank::characterObjectValues, 442, previous));
+        assert(s::prepare_item_acquisition_for_item(1, mutation));
+        assert(s::preview_item_acquisition(mutation, after, unlocks));
+        check_encoded(false, 442, previous);
+        assert(s::commit_item_acquisition(mutation));
+        assert(value(store::Bank::characterObjectValues, 442) == previous);
+    }
+
+    reset();
+    assert(s::prepare_item_acquisition_for_item(0, mutation));
+    assert(store::write_unlock(store::Bank::objectiveValues, 5762, 200));
+    assert(!s::commit_item_acquisition(mutation));
+    assert(store::account().characters[0].inventory.count == 0);
+    assert(value(store::Bank::objectiveValues, 5762) == 200);
+
+    reset();
+    assert(s::prepare_item_acquisition_for_item(1, mutation));
+    auto changed = baseline;
+    changed.characters[0].selected = false;
+    changed.characters[1].selected = true;
+    assert(store::write_account(changed));
+    assert(!s::commit_item_acquisition(mutation));
+    assert(value(store::Bank::characterObjectValues, 442) == 0);
+    reset();
+    assert(s::prepare_item_acquisition_for_item(0, mutation));
+    mutation.questInitialization.row = 5763; // A changed contract cannot redirect the write.
+    assert(!s::commit_item_acquisition(mutation));
+    assert(store::account().characters[0].inventory.count == 0);
+
+    reset();
+    assert(s::prepare_item_acquisition_for_item(0, mutation));
+    assert(store::execute("CREATE TEMP TRIGGER fail_item BEFORE INSERT ON items "
+                          "BEGIN SELECT RAISE(ABORT,'injected inventory failure'); END;"));
+    assert(!s::commit_item_acquisition(mutation));
+    assert(store::execute("DROP TRIGGER fail_item"));
+    assert(store::account().characters[0].inventory.count == 0);
+    assert(value(store::Bank::objectiveValues, 5762) == 0);
+
+    reset();
+    auto full = baseline;
+    auto& inventory = full.characters[0].inventory;
+    for (std::size_t i = 0; i < inventory.values.size(); ++i) {
+        auto& item = inventory.values[i];
+        item.instanceSoid = 0x4000000000000001ULL + i;
+        item.definitionHash = definitions[2].definitionHash;
+        item.quantity = 1;
+    }
+    inventory.count = inventory.values.size();
+    assert(store::write_account(full));
+    assert(!s::prepare_item_acquisition_for_item(0, mutation));
+    assert(!mutation.prepared && value(store::Bank::objectiveValues, 5762) == 0);
+    reset();
+    assert(!s::prepare_item_acquisition_for_item(65535, mutation));
+    assert(!mutation.prepared && value(store::Bank::objectiveValues, 5762) == 0);
+
+    reset();
+    assert(s::prepare_item_acquisition_for_item(0, mutation));
+    assert(store::execute("CREATE TEMP TRIGGER fail_quest BEFORE INSERT ON unlocks "
+                          "BEGIN SELECT RAISE(ABORT,'injected quest write failure'); END;"));
+    assert(!s::commit_item_acquisition(mutation));
+    assert(store::execute("DROP TRIGGER fail_quest"));
+    assert(store::account().characters[0].inventory.count == 0);
+    assert(value(store::Bank::objectiveValues, 5762) == 0);
+
+    reset();
+    assert(s::prepare_item_acquisition_for_item(2, mutation));
+    assert(mutation.questInitialization == Quest{});
+    check_acquisition_staging(mutation, false);
+    auto profileMutation = mutation;
+    profileMutation.profileChanged = true;
+    check_acquisition_staging(profileMutation, true); // Existing profile-charge behavior.
+    assert(s::commit_item_acquisition(mutation));
+    assert(value(store::Bank::objectiveValues, 5762) == 0);
+    assert(value(store::Bank::characterObjectValues, 442) == 0);
+    store::shutdown();
+    std::cout << "PASS production queuez staging: fresh account/character quests, existing "
+                 "progress, ordinary item, profile charge\n";
+    std::cout << "PASS production acquisition + SQLite: atomic rollback, stale value/character, "
+                 "both scopes, preservation, encoded after-image, full/refused grants, unsupported "
+                 "control\n";
+}
+
+int main(int argc, char** argv) {
+    std::cout << std::unitbuf;
+    assert(argc == 2 || argc == 3);
+    const auto characterQuest = parser_checks();
+    cache_checks();
+    world_reward_caller_check(argv[1]);
+    runtime_checks(argv[1], characterQuest);
+    if (argc == 3) {
+        content_checks(argv[2]);
+    }
+}

+ 19 - 0
tests/run_quest_initialization.cmd

@@ -0,0 +1,19 @@
+@echo off
+setlocal
+if /i not "%VSCMD_ARG_TGT_ARCH%"=="x64" (
+    echo Run this script from an x64 Visual Studio Developer Command Prompt.
+    exit /b 1
+)
+where cl >nul 2>&1
+if errorlevel 1 exit /b 1
+where lib >nul 2>&1
+if errorlevel 1 exit /b 1
+cd /d "%~dp0.."
+if not exist "build\quest-tests" mkdir "build\quest-tests"
+rem Reuse the full Release build's production objects; no game initialization is called.
+lib /nologo /out:build\quest-tests\sunrise-test.lib build\obj\x64\Release\*.obj
+if errorlevel 1 exit /b 1
+cl /nologo /std:c++20 /EHsc /W4 /WX /MT /DWIN32_LEAN_AND_MEAN /DNOMINMAX /I"Sunrise\src" /I"Sunrise\vendor\sqlite" /Fo"build\quest-tests\\" /Fe"build\quest-tests\quest_initialization_test.exe" tests\quest_initialization_test.cpp build\quest-tests\sunrise-test.lib /link /LTCG /OPT:REF /STACK:16777216 kernel32.lib bcrypt.lib user32.lib gdi32.lib dwmapi.lib d3dcompiler.lib shell32.lib ws2_32.lib synchronization.lib ole32.lib windowscodecs.lib
+if errorlevel 1 exit /b 1
+"build\quest-tests\quest_initialization_test.exe" "%CD%" %*
+exit /b %errorlevel%