Pārlūkot izejas kodu

Merge remote-tracking branch 'origin/master'

stan 2 dienas atpakaļ
vecāks
revīzija
2c2418ff4b
24 mainītis faili ar 287 papildinājumiem un 78 dzēšanām
  1. 1 1
      Sunrise/src/client/content/activity/activity_sdk_generation_worker.h
  2. 40 0
      Sunrise/src/client/content/items/packages/package_item_rows.cpp
  3. 3 3
      Sunrise/src/core/settings/settings.h
  4. 1 1
      Sunrise/src/core/settings/settings_parser.cpp
  5. 4 2
      Sunrise/src/core/settings/settings_upgrade.cpp
  6. 3 2
      Sunrise/src/server/bap/encrypted/push/activity/activity_roster_push.cpp
  7. 7 5
      Sunrise/src/server/bap/encrypted/push/activity/activity_roster_snapshot.cpp
  8. 5 4
      Sunrise/src/server/bap/encrypted/push/activity/internal.h
  9. 12 4
      Sunrise/src/server/gameplay/peer/peer_out_of_band.cpp
  10. 2 0
      Sunrise/src/state/build_data/cache/records/cache_exotic_catalyst_record_codec.cpp
  11. 5 2
      Sunrise/src/state/build_data/cache/records/format.h
  12. 12 0
      Sunrise/src/state/build_data/items/catalysts/definition.h
  13. 4 0
      Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_build_data_runtime.cpp
  14. 43 0
      Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_builder.cpp
  15. 2 0
      Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_builder.h
  16. 52 1
      Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_catalog.cpp
  17. 12 3
      Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_catalog.h
  18. 9 1
      Sunrise/src/state/build_data/runtime.h
  19. 2 2
      Sunrise/src/state/investment/investment.h
  20. 54 47
      Sunrise/src/state/runtime/state_progression_runtime.cpp
  21. 3 0
      Sunrise/src/state/runtime/state_runtime.cpp
  22. 1 0
      Sunrise/src/state/unlocks/unlocks_records.cpp
  23. 7 0
      Sunrise/src/state/unlocks/unlocks_runtime.cpp
  24. 3 0
      Sunrise/src/state/unlocks/unlocks_runtime.h

+ 1 - 1
Sunrise/src/client/content/activity/activity_sdk_generation_worker.h

@@ -62,7 +62,7 @@ struct OfflineBuildResult final {
                                                void* progressContext,
                                                OfflineBuildResult& output) noexcept;
 
-/** Immutable boot policy for the live generator. Each output nothing reads is opt-in. */
+/** Immutable boot policy for the live generator. Its pack backs host roster mission seeds. */
 struct Policy final {
     bool enabled{};
     /** Writes the sdk/lua declaration tree, which no runtime loads. */

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

@@ -1,9 +1,13 @@
 #include <array>
+#include <cstring>
 #include <span>
+#include <vector>
 
+#include "../../../../middleware/content/packages/tables/definition_index_table.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"
+#include "../../../../state/unlocks/definition.h"
 #include "internal.h"
 #include "package_socket_plug_build.h"
 
@@ -20,6 +24,35 @@ namespace build_items = state::build_data::items;
  */
 bool g_catalystsUnsupported = false;
 
+/** Reads the package's account flag-map table into its own blob, leaving the item blobs alone. */
+[[nodiscard]] bool read_catalyst_account_mappings(
+    const reader::Source& source,
+    Storage& storage,
+    std::vector<build_items::catalysts::AccountFlagMapping>& output) noexcept {
+    std::uint32_t tag = 0;
+    tables::Array rows{};
+    std::vector<std::byte> blob;
+    if (!tables::slot_tag(storage.root, tables::kUnlockFlagMapTableSlot, tag) || tag == 0
+        || tables::package_of(tag) == tables::kAbsentPackageId
+        || !reader::read_tag(source, storage.scratch, tag, blob)
+        || !tables::find_array_at(blob, tables::kAccountFlagMapDescriptor, rows) || rows.count == 0
+        || rows.count > state::unlocks::kAccountFlagCapacity || rows.dataOffset > blob.size()
+        || rows.count > (blob.size() - rows.dataOffset) / tables::kUnlockMapRowStride) {
+        return false;
+    }
+    output.clear();
+    for (std::size_t row = 0; row < rows.count; ++row) {
+        std::int16_t slot = -1;
+        const auto at = rows.dataOffset + row * tables::kUnlockMapRowStride
+                        + tables::kUnlockMapDestinationSlotOffset;
+        std::memcpy(&slot, blob.data() + at, sizeof slot);
+        if (slot >= 0) {
+            output.push_back({static_cast<std::uint16_t>(slot), static_cast<std::uint16_t>(row)});
+        }
+    }
+    return true;
+}
+
 /** @return True when one extracted detail can join the currently published numeric domains. */
 [[nodiscard]] bool publishable_detail(const build_details::Definition& detail) noexcept {
     const std::size_t itemCount = state::build_data::item_definition_count();
@@ -194,6 +227,12 @@ bool build_item_rows(const reader::Source& source,
             catalystRows{};
         std::size_t catalystCount = 0;
         state::build_data::items::catalysts::Report catalystReport{};
+        std::vector<state::build_data::items::catalysts::AccountFlagMapping> catalystMappings;
+        if (published && needCatalysts
+            && !read_catalyst_account_mappings(source, storage, catalystMappings)) {
+            reason = "catalyst_account_mapping";
+            return false;
+        }
         const state::build_data::items::catalysts::Source catalystSource{
             {},
             std::span(storage.rows).first(rowCount),
@@ -204,6 +243,7 @@ bool build_item_rows(const reader::Source& source,
             storage.catalystCompletionConditions,
             storage.catalystAcquisitionGates,
             storage.catalystObjectiveValues,
+            catalystMappings,
         };
         bool catalystBuilt = false;
         if (published && needCatalysts) {

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

@@ -13,10 +13,10 @@
 
 namespace sunrise::core::settings {
 
-/** Boot policy for the optional full-estate activity SDK generator. */
+/** Boot policy for generating the activity SDK required by host roster construction. */
 struct ActivitySdkGenerationSettings final {
-    /** Allows generation work to be requested. Off unless the settings file opts in. */
-    bool enabled{false};
+    /** On by default like the bundled file, so a file without this block still generates. */
+    bool enabled{true};
     /** Writes the sdk/lua declaration tree. On by default, and only runs when generation does. */
     bool luaDeclarations{true};
 };

+ 1 - 1
Sunrise/src/core/settings/settings_parser.cpp

@@ -111,7 +111,7 @@ bool Parser::core(Settings& output) noexcept {
     }
 }
 
-/** Parses the Core-owned, fail-closed activity SDK generation gate. */
+/** Parses the Core-owned activity SDK generation gate. Omitted members keep their defaults. */
 bool Parser::activity_sdk_generation_settings(ActivitySdkGenerationSettings& output) noexcept {
     if (!consume('{')) {
         return false;

+ 4 - 2
Sunrise/src/core/settings/settings_upgrade.cpp

@@ -39,14 +39,16 @@ constexpr std::array<ReplacedMember, 11> kReplacedMembers{{
     // Version 8 turned the flat payout list into rows filtered by rarity, gear class and
     // masterwork state.
     {"\"dismantle_rewards\"", 8},
-    // Version 13 turned these three on. A file that never carried them takes the new default; one
+    // Version 13 turned these two on. A file that never carried them takes the new default; one
     // that carried the old value is corrected here.
-    {"\"lua_declarations\"", 13},
     {"\"suppress_peer_relay\"", 13},
     {"\"activity_public_membership\"", 13},
     // Version 15 seeded the lore book unlock slots, so both banks take the new default.
     {"\"character_flags\"", 15},
     {"\"objective_values\"", 15},
+    // Version 16 turned generation on. The whole block is replaced, because "enabled" is not
+    // unique in the document. The block also carries the lua_declarations default of version 13.
+    {"\"activity_sdk_generation\"", 16},
 }};
 
 /** One renamed member, and the layout version that renamed it. */

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

@@ -291,8 +291,9 @@ bool append_roster_notification(
     const bool lifetimePending =
         hasScriptablePending && singleScriptableLink
         && scriptablePending.kind == server::activity::host::ScriptableOverrideKind::lifetime;
-    // The loading lifetime holds only until the destination region is instantiated. The stricter
-    // in-world state is reported after spawning and would make this field wait on its own result.
+    // The loading lifetime is the presentation, not the spawn hold: state 4 shows the loading
+    // screen and refuses the native spawn gate on its own, so it is released once the region is
+    // instantiated. `awaiting_client_sync` carries the hold on to the client's arrival report.
     // An explicit lifetime request still wins.
     const bool clientLoading = !client_region_ready(session, refresh);
     const bool bodyPending = hasScriptablePending && singleScriptableLink && !lifetimePending;

+ 7 - 5
Sunrise/src/server/bap/encrypted/push/activity/activity_roster_snapshot.cpp

@@ -98,9 +98,10 @@ bool client_region_ready(const Session& session, const RefreshReport* refresh) n
     return !movePending && held >= 0;
 }
 
-/** Tests whether the client has completed spawning into its instantiated region. */
+/** Tests whether the client has reported arrival in its instantiated region. */
 bool client_in_world(const Session& session, const RefreshReport* refresh) noexcept {
-    // World-state 8 is post-spawn, so no roster field that releases the spawn may read it.
+    // WS-702 world-state 8 follows the bootflow's arrival, independently of the player spawn.
+    // Holding a region alone can precede that report and the world-transition fade's final arm.
     const state::activity::membership::ClientPlacement placement =
         client_placement(session, refresh);
     return placement.entered && client_region_ready(session, refresh);
@@ -607,9 +608,10 @@ build_roster_snapshot(Session& session,
     // carries matches nothing.
     snapshot.playerKey = published_player_key(session);
     snapshot.lifetime = lifetimeState;
-    // Hold the spawn gate only until the region is instantiated. World-state 8 is written after
-    // the spawn, so waiting on it here deadlocks the spawn.
-    snapshot.awaitClientSync = !client_region_ready(session, refresh);
+    // Hold the native spawn gate until the client's arrival report, WS-702 world-state 8. A
+    // region can be loaded before the bootflow arms its fade; a spawn before the arm releases an
+    // inactive fade and leaves the screen black. Arrival does not depend on the spawn (RE/30).
+    snapshot.awaitClientSync = !client_in_world(session, refresh);
     // Player_BindComponents walks every type-13 reference and the player datum can name any one of
     // them. So every participation record carries the same player key. Selecting the first slot
     // leaves the authored cinematic participant unbound whenever it names another record.

+ 5 - 4
Sunrise/src/server/bap/encrypted/push/activity/internal.h

@@ -173,16 +173,17 @@ struct RefreshReport final {
 client_placement(const Session& session, const RefreshReport* refresh) noexcept;
 
 /**
- * Tests whether the client is in a live world: it holds the region it reported and no host
- * move is waiting for its arrival.
+ * Tests whether the client has reported arrival in its instantiated region: its WS-702 world
+ * state reached 8 while it holds the region it reported and no host move is waiting. This is
+ * the report that releases the native spawn gate.
  * @param session Connection whose activity session the client reports on.
  * @param refresh Refresh being answered, or null.
  */
 [[nodiscard]] bool client_in_world(const Session& session, const RefreshReport* refresh) noexcept;
 
 /**
- * Tests whether the client's destination region is instantiated far enough for the native spawn.
- * Never reads the post-spawn world-state 8 write-back; that would be a circular wait.
+ * Tests whether the client's destination region is instantiated, before its arrival report.
+ * This advances the loading lifetime. The spawn gate itself waits for `client_in_world`.
  * @param session Connection whose activity session the client reports on.
  * @param refresh Refresh being answered, or null.
  */

+ 12 - 4
Sunrise/src/server/gameplay/peer/peer_out_of_band.cpp

@@ -29,11 +29,17 @@ constexpr unsigned kByteBits = 8;
 /** Sequence the first packet to a peer carries, because the head advances before it is written. */
 constexpr std::uint16_t kFirstPacketSequence = 1;
 
-/** Fills the address blob that names this host on the direct path. */
-void local_address(std::array<std::byte, wire::kAddressBlobSize>& output) noexcept {
+/**
+ * Fills the address blob that names this host on the direct path.
+ * @param receivingPort Host pool port the request arrived on. Zero names the primary port, as it
+ * does on the transport's send path.
+ * @param output Receives the direct-path address blob.
+ */
+void local_address(std::uint16_t receivingPort,
+                   std::array<std::byte, wire::kAddressBlobSize>& output) noexcept {
     const gp::Endpoint advertised = endpoint::advertised();
     middleware::gameplay::descriptor::write_direct_net_addr(
-        advertised.address, advertised.port, output);
+        advertised.address, receivingPort != 0 ? receivingPort : advertised.port, output);
 }
 
 /** @return A random 32-bit sequence, or zero when Windows refused. */
@@ -98,7 +104,9 @@ void answer_connect(const gp::Endpoint& from,
     // The peer checks both echoed fields and closes the connection on a wrong sequence.
     response.remoteChannelId = request.channelId;
     response.remoteSequence = request.sequence;
-    local_address(response.address);
+    // The client locates its connecting channel by this address, so it must name the host pool
+    // port the request reached rather than always the primary port.
+    local_address(from.localPort, response.address);
     DisplacedExternals displaced{};
     std::size_t displacedCount = 0;
     std::array<std::uint64_t, gp::kSessionsPerLink> resetSessions{};

+ 2 - 0
Sunrise/src/state/build_data/cache/records/cache_exotic_catalyst_record_codec.cpp

@@ -18,6 +18,7 @@ bool encode(const items::catalysts::Definition& value, ExoticCatalystRecord& rec
     record.progressPlugDefinitionIndex = value.progressPlugDefinitionIndex;
     record.effectDefinitionIndex = value.effectDefinitionIndex;
     record.acquisitionDefinitionIndex = value.acquisitionDefinitionIndex;
+    record.completionAccountFlagIndices = value.completionAccountFlagIndices;
     record.completionFlagDefinitionIndices = value.completion.flags;
     for (std::size_t index = 0; index < value.completion.values.size(); ++index) {
         record.completionValueIndices[index] = value.completion.values[index].index;
@@ -49,6 +50,7 @@ bool decode(const ExoticCatalystRecord& record, items::catalysts::Definition& va
     value.progressPlugDefinitionIndex = record.progressPlugDefinitionIndex;
     value.effectDefinitionIndex = record.effectDefinitionIndex;
     value.acquisitionDefinitionIndex = record.acquisitionDefinitionIndex;
+    value.completionAccountFlagIndices = record.completionAccountFlagIndices;
     value.completion.flags = record.completionFlagDefinitionIndices;
     for (std::size_t index = 0; index < value.completion.values.size(); ++index) {
         value.completion.values[index] = {record.completionValueIndices[index],

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

@@ -34,8 +34,9 @@ inline constexpr std::array<char, 8> kCacheMagic{'S', 'U', 'N', 'R', 'I', 'S', '
  * 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.
+ * 63 added the catalyst completion flags' account bank indices to the catalyst record.
  */
-inline constexpr std::uint32_t kCacheFormatVersion = 62;
+inline constexpr std::uint32_t kCacheFormatVersion = 63;
 /** 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. */
@@ -250,6 +251,8 @@ struct ExoticCatalystRecord {
     std::uint16_t progressPlugDefinitionIndex{};
     std::uint16_t effectDefinitionIndex{};
     std::uint16_t acquisitionDefinitionIndex{};
+    std::array<std::uint16_t, items::catalysts::kCompletionFlagCapacity>
+        completionAccountFlagIndices{};
     std::array<std::uint16_t, items::catalysts::kCompletionFlagCapacity>
         completionFlagDefinitionIndices{};
     std::array<std::uint16_t, items::catalysts::kCompletionValueCapacity> completionValueIndices{};
@@ -665,7 +668,7 @@ static_assert(sizeof(SocketPlugRuleRecord)
 static_assert(sizeof(SocketPlugPoolRecord) == 2 * sizeof(std::uint32_t));
 static_assert(sizeof(SocketPlugMemberRecord) == sizeof(std::uint16_t));
 static_assert(sizeof(ExoticCatalystRecord)
-              == 6 * sizeof(std::uint32_t) + 14 * sizeof(std::uint16_t) + 4 * sizeof(std::uint8_t));
+              == 6 * sizeof(std::uint32_t) + 18 * sizeof(std::uint16_t) + 4 * sizeof(std::uint8_t));
 static_assert(sizeof(InventoryBucketRecord)
               == 4 * sizeof(std::uint8_t) + 2 * sizeof(std::uint16_t));
 static_assert(sizeof(SocketEntryListRecord)

+ 12 - 0
Sunrise/src/state/build_data/items/catalysts/definition.h

@@ -115,6 +115,12 @@ enum class Error : std::uint8_t {
     return "unknown";
 }
 
+/** Native account acquired-flag row feeding an evaluated unlock slot. */
+struct AccountFlagMapping {
+    std::uint16_t slot{};
+    std::uint16_t accountIndex{};
+};
+
 /** One build-derived exotic weapon catalyst relation. */
 struct Definition {
     std::uint32_t itemDefinitionHash{};
@@ -127,6 +133,12 @@ struct Definition {
     std::uint16_t effectDefinitionIndex{};
     /** Family-5 acquired-state slot that makes the catalyst socket visible. */
     std::uint16_t acquisitionDefinitionIndex{kUnavailableAcquisitionIndex};
+    /** Account rows for the completion flags, or unavailable for override-only slots. */
+    std::array<std::uint16_t, kCompletionFlagCapacity> completionAccountFlagIndices{
+        kUnavailableCompletionFlagIndex,
+        kUnavailableCompletionFlagIndex,
+        kUnavailableCompletionFlagIndex,
+        kUnavailableCompletionFlagIndex};
     /** Family-5 terms required by the catalyst's active effect item. */
     CompletionRequirements completion{};
     /** Account objective referenced by a legacy progress plug, when present. */

+ 4 - 0
Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_build_data_runtime.cpp

@@ -117,6 +117,10 @@ bool complete_exotic_catalyst_investment(Family5State& family) noexcept {
     return items::catalysts::append_investment_overrides(family);
 }
 
+bool complete_exotic_catalyst_flags(std::span<std::uint8_t> flags) noexcept {
+    return items::catalysts::append_account_completions(flags);
+}
+
 bool complete_exotic_catalyst_objectives(std::span<std::int32_t> values) noexcept {
     return items::catalysts::append_objective_completions(values);
 }

+ 43 - 0
Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_builder.cpp

@@ -511,6 +511,25 @@ bool derive(const Source& source,
         definition.effectDefinitionIndex = completed->effectDefinitionIndex;
         definition.acquisitionDefinitionIndex = completed->acquisitionDefinitionIndex;
         definition.completion = completed->completion;
+        for (std::size_t flag = 0; flag < definition.completion.flagCount; ++flag) {
+            const auto mapping =
+                std::find_if(source.accountFlagMappings.begin(),
+                             source.accountFlagMappings.end(),
+                             [&](const AccountFlagMapping& row) {
+                                 return row.slot == definition.completion.flags[flag];
+                             });
+            if (mapping != source.accountFlagMappings.end()) {
+                if (mapping->accountIndex >= state::unlocks::kAccountFlagCapacity) {
+                    return fail(output,
+                                count,
+                                report,
+                                Error::invalidCompletion,
+                                item->definitionHash,
+                                completed->socketLane);
+                }
+                definition.completionAccountFlagIndices[flag] = mapping->accountIndex;
+            }
+        }
         definition.objective = completed->objective;
         definition.socketLane = completed->socketLane;
         definition.availability =
@@ -569,6 +588,8 @@ bool matches_cached(const Source& source,
     std::array<CompletionCondition, 2 * kDefinitionCapacity> completionConditions{};
     std::array<AcquisitionGate, kDefinitionCapacity> acquisitionGates{};
     std::array<std::int32_t, state::unlocks::kObjectiveValueCapacity> objectiveValues{};
+    std::array<AccountFlagMapping, kDefinitionCapacity * kCompletionFlagCapacity> accountMappings{};
+    std::size_t mappingCount = 0;
     std::size_t completionCount = 0;
     std::size_t acquisitionCount = 0;
     std::size_t objectiveCount = 0;
@@ -598,6 +619,27 @@ bool matches_cached(const Source& source,
         if (definition.availability == Availability::unsupported) {
             continue;
         }
+        for (std::size_t flag = 0; flag < definition.completionAccountFlagIndices.size(); ++flag) {
+            const auto mapped = definition.completionAccountFlagIndices[flag];
+            if (mapped == kUnavailableCompletionFlagIndex) {
+                continue;
+            }
+            if (flag >= definition.completion.flagCount
+                || mapped >= state::unlocks::kAccountFlagCapacity
+                || mappingCount >= accountMappings.size()) {
+                return false;
+            }
+            const auto slot = definition.completion.flags[flag];
+            for (std::size_t prior = 0; prior < mappingCount; ++prior) {
+                if ((accountMappings[prior].slot == slot
+                     && accountMappings[prior].accountIndex != mapped)
+                    || (accountMappings[prior].accountIndex == mapped
+                        && accountMappings[prior].slot != slot)) {
+                    return false;
+                }
+            }
+            accountMappings[mappingCount++] = {slot, mapped};
+        }
         const details::Definition* detail =
             find_detail(source.details, definition.itemDefinitionIndex);
         const bool hasObjective =
@@ -688,6 +730,7 @@ bool matches_cached(const Source& source,
                   return first.socketType < second.socketType;
               });
     Source rebuilt = source;
+    rebuilt.accountFlagMappings = std::span(accountMappings).first(mappingCount);
     rebuilt.completionConditions = std::span(completionConditions).first(completionCount);
     rebuilt.acquisitionGates = std::span(acquisitionGates).first(acquisitionCount);
     rebuilt.objectiveCompletionValues = std::span(objectiveValues).first(objectiveCount);

+ 2 - 0
Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_builder.h

@@ -34,6 +34,8 @@ struct Source {
     std::span<const AcquisitionGate> acquisitionGates;
     /** Dense objective-indexed completion values read from the installed objective table. */
     std::span<const std::int32_t> objectiveCompletionValues;
+    /** Package account-flag mapping; slot identities are not account array indices. */
+    std::span<const AccountFlagMapping> accountFlagMappings;
 };
 
 /** @return The generated facts pinned to Destiny 2 build 86657.20.08.23. */

+ 52 - 1
Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_catalog.cpp

@@ -167,6 +167,15 @@ bool valid(std::span<const Definition> definitions) noexcept {
             definition.objective.definitionIndex != kUnavailableObjectiveIndex;
         const bool directEffect =
             definition.completedPlugDefinitionIndex == definition.effectDefinitionIndex;
+        for (std::size_t flag = 0; flag < definition.completionAccountFlagIndices.size(); ++flag) {
+            const auto mapped = definition.completionAccountFlagIndices[flag];
+            if (mapped != kUnavailableCompletionFlagIndex
+                && (mapped >= state::unlocks::kAccountFlagCapacity
+                    || flag >= definition.completion.flagCount
+                    || definition.availability == Availability::unsupported)) {
+                return false;
+            }
+        }
         if (definition.itemDefinitionHash == 0
             || definition.socketLane >= details::kInitialPlugCapacity
             || !valid_availability(definition.availability)
@@ -301,7 +310,15 @@ bool append_investment_overrides(state::Family5State& family) noexcept {
             return false;
         }
         for (std::size_t flag = 0; flag < definition.completion.flagCount; ++flag) {
-            if (!upsert_flag(candidate, definition.completion.flags[flag])) {
+            const auto slot = definition.completion.flags[flag];
+            // A mapped completion rides in the account bank, so it takes a Family-5 row only when
+            // the state already carries one for that slot, which is then raised to the set value.
+            const bool present = std::any_of(candidate.flags.begin(),
+                                             candidate.flags.begin() + candidate.flagCount,
+                                             [slot](const auto& row) { return row.slot == slot; });
+            if ((definition.completionAccountFlagIndices[flag] == kUnavailableCompletionFlagIndex
+                 || present)
+                && !upsert_flag(candidate, slot)) {
                 return false;
             }
         }
@@ -316,6 +333,40 @@ bool append_investment_overrides(state::Family5State& family) noexcept {
     return true;
 }
 
+/**
+ * Sets the account acquired flags that released catalyst completions map to.
+ * @param flags Candidate account flag bank; unchanged unless every mapped flag is inside it.
+ * @return False when a mapped flag falls outside the bank.
+ */
+bool append_account_completions(std::span<std::uint8_t> flags) noexcept {
+    if (!completion_enabled()) {
+        return true;
+    }
+    const std::shared_lock guard(g_lock);
+    // Range-check every mapping first, so one outside the bank leaves the input untouched.
+    for (const Definition& definition : g_definitions.rows()) {
+        if (definition.availability != Availability::released) {
+            continue;
+        }
+        for (const auto mapped : definition.completionAccountFlagIndices) {
+            if (mapped != kUnavailableCompletionFlagIndex && mapped >= flags.size()) {
+                return false;
+            }
+        }
+    }
+    for (const Definition& definition : g_definitions.rows()) {
+        if (definition.availability != Availability::released) {
+            continue;
+        }
+        for (const auto mapped : definition.completionAccountFlagIndices) {
+            if (mapped != kUnavailableCompletionFlagIndex) {
+                flags[mapped] = state::unlocks::kFlagSet;
+            }
+        }
+    }
+    return true;
+}
+
 /**
  * Raises the account objective values that released legacy catalysts need.
  * @param values Candidate objective bank; unchanged unless every objective is in range.

+ 12 - 3
Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_catalog.h

@@ -70,9 +70,10 @@ void set_completion_enabled(bool enabled) noexcept;
                                           std::span<std::optional<std::uint16_t>> plugs) noexcept;
 
 /**
- * Adds acquired-state gates, completion flags, and completion values for released catalysts.
- * Existing authored rows with the same slot are raised to the required value. The input stays
- * unchanged when either fixed override bank cannot hold the complete deduplicated result.
+ * Adds acquired-state gates, completion flags without an account mapping, and completion values
+ * for released catalysts. Existing authored rows with the same slot are raised to the required
+ * value, mapped or not. The input stays unchanged when either fixed override bank cannot hold
+ * the complete deduplicated result.
  * @param family Candidate Family-5 state.
  * @return True when completion is disabled or every released override fits atomically.
  */
@@ -86,6 +87,14 @@ void set_completion_enabled(bool enabled) noexcept;
  */
 [[nodiscard]] bool append_objective_completions(std::span<std::int32_t> values) noexcept;
 
+/**
+ * Sets the account acquired flags that released catalyst completions map to.
+ * The input stays unchanged if any mapped flag is outside the supplied bank.
+ * @param flags Candidate account acquired-flag bank.
+ * @return True when completion is disabled or every mapped flag applies atomically.
+ */
+[[nodiscard]] bool append_account_completions(std::span<std::uint8_t> flags) noexcept;
+
 /**
  * Copies the complete catalog under its shared lock.
  * @param output Caller-owned fixed catalog storage.

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

@@ -253,7 +253,8 @@ complete_exotic_catalyst(std::uint16_t itemDefinitionIndex,
                          std::span<std::optional<std::uint16_t>> plugs) noexcept;
 
 /**
- * Adds all released catalyst acquisition and completion overrides to one Family-5 snapshot.
+ * Adds the released catalyst acquisition gates, the completion flags without an account mapping,
+ * and the completion values to one Family-5 snapshot.
  * @param family Candidate Family-5 state.
  * @return True when all overrides fit and the complete state commits.
  */
@@ -266,6 +267,13 @@ complete_exotic_catalyst(std::uint16_t itemDefinitionIndex,
  */
 [[nodiscard]] bool complete_exotic_catalyst_objectives(std::span<std::int32_t> values) noexcept;
 
+/**
+ * Raises the account acquired flags that the package maps released catalyst completions to.
+ * @param flags Candidate account acquired-flag bank.
+ * @return True when completion is disabled or every mapped flag is inside the bank.
+ */
+[[nodiscard]] bool complete_exotic_catalyst_flags(std::span<std::uint8_t> flags) noexcept;
+
 /**
  * Resolves the item row that supplies one socketed catalyst's native perks and stat changes.
  * @param itemDefinitionIndex Native weapon definition index.

+ 2 - 2
Sunrise/src/state/investment/investment.h

@@ -6,8 +6,8 @@
 
 namespace sunrise::state {
 
-/** Each family-5 override count ships in a 7-bit wire field, so 127 rows is the wire limit. */
-inline constexpr std::size_t kUnlockOverrideCapacity = 127;
+/** The native family-5 lists hold 100 rows each (RE/27). The 7-bit wire count is not the limit. */
+inline constexpr std::size_t kUnlockOverrideCapacity = 100;
 
 /** One logical unlock-flag value stored by slot. */
 struct UnlockFlagOverride {

+ 54 - 47
Sunrise/src/state/runtime/state_progression_runtime.cpp

@@ -90,25 +90,6 @@ using SaleRows = std::array<build_data::ArtifactSaleRow, build_data::kArtifactSa
     return ladder_ranks(kArtifactUnlockProgressionIndex, experience);
 }
 
-/**
- * Sets one global unlock flag override, replacing any existing entry for the slot.
- * @return False when the slot is new and the override list is full.
- */
-[[nodiscard]] bool
-upsert_flag(Family5State& family, std::uint16_t slot, std::uint8_t value) noexcept {
-    for (std::size_t index = 0; index < family.flagCount; ++index) {
-        if (family.flags[index].slot == slot) {
-            family.flags[index].value = value;
-            return true;
-        }
-    }
-    if (family.flagCount >= family.flags.size()) {
-        return false;
-    }
-    family.flags[family.flagCount++] = UnlockFlagOverride{slot, value};
-    return true;
-}
-
 /**
  * Sets one global unlock value override, replacing any existing entry for the slot.
  * @return False when the slot is new and the override list is full.
@@ -142,9 +123,10 @@ upsert_value(Family5State& family, std::uint16_t slot, std::int32_t value) noexc
     return build_data::artifact_sale_rows(rows, count) && count != 0;
 }
 
-/** @return One bit per owned artifact sale row, read from the global unlock overrides. */
-[[nodiscard]] std::uint32_t
-artifact_mask_locked(const Family5State& family, const SaleRows& rows, std::size_t count) noexcept {
+/** @return One bit per sale row an authored family-5 override marks owned. Read at seed only. */
+[[nodiscard]] std::uint32_t authored_artifact_mask_locked(const Family5State& family,
+                                                          const SaleRows& rows,
+                                                          std::size_t count) noexcept {
     std::uint32_t mask = 0;
     for (std::size_t row = 0; row < count && row < 32; ++row) {
         if (rows[row].unlockFlagSlot != build_data::collectibles::kUnavailableFlagSlot
@@ -155,6 +137,44 @@ artifact_mask_locked(const Family5State& family, const SaleRows& rows, std::size
     return mask;
 }
 
+/** @return One bit per owned artifact sale row, read from the character acquired-flag bank. */
+[[nodiscard]] std::uint32_t artifact_mask(const SaleRows& rows, std::size_t count) noexcept {
+    std::uint32_t mask = 0;
+    for (std::size_t row = 0; row < count && row < 32; ++row) {
+        const std::uint16_t mapped = rows[row].characterFlagIndex;
+        if (mapped != build_data::collectibles::kUnavailableFlagIndex
+            && unlocks::character_object_flag_set(mapped)) {
+            mask |= 1U << row;
+        }
+    }
+    return mask;
+}
+
+/**
+ * Removes the family-5 flag rows that name artifact sale slots.
+ * The character bank carries ownership, and a family-5 copy would mask it and cost 25 rows.
+ */
+void strip_artifact_flags_locked(Family5State& family,
+                                 const SaleRows& rows,
+                                 std::size_t count) noexcept {
+    std::size_t write = 0;
+    for (std::size_t index = 0; index < family.flagCount; ++index) {
+        const UnlockFlagOverride flag = family.flags[index];
+        bool artifact = false;
+        for (std::size_t row = 0; row < count && row < 32 && !artifact; ++row) {
+            artifact = rows[row].unlockFlagSlot != build_data::collectibles::kUnavailableFlagSlot
+                       && rows[row].unlockFlagSlot == flag.slot;
+        }
+        if (!artifact) {
+            family.flags[write++] = flag;
+        }
+    }
+    for (std::size_t index = write; index < family.flagCount; ++index) {
+        family.flags[index] = {};
+    }
+    family.flagCount = write;
+}
+
 [[nodiscard]] std::uint16_t points_used(std::uint32_t mask) noexcept {
     std::uint16_t used = 0;
     for (std::uint16_t bit = 0; bit < 32; ++bit) {
@@ -163,7 +183,7 @@ artifact_mask_locked(const Family5State& family, const SaleRows& rows, std::size
     return used;
 }
 
-/** Character-bank half of an artifact publish; the account half lives in the family-5 overrides. */
+/** Character-bank write of an artifact publish; the family-5 overrides carry only the counters. */
 struct CharacterArtifactWrite {
     const SaleRows* rows{};
     std::size_t count{};
@@ -194,11 +214,11 @@ void publish_artifact_character_banks(CharacterArtifactWrite& write) noexcept {
 }
 
 /**
- * Writes one artifact ownership mask into every bank that publishes it.
+ * Writes one artifact ownership mask into the character bank and its counters into family 5.
  * @param family Global override object, mutated in place.
  * @param mask One bit per owned sale row.
  * @param experience Seasonal XP the derived counters are computed from.
- * @return False only when the bounded override lists are full.
+ * @return False only when the bounded value override list is full.
  */
 [[nodiscard]] bool publish_artifact_locked(Family5State& family,
                                            std::uint32_t mask,
@@ -208,17 +228,7 @@ void publish_artifact_character_banks(CharacterArtifactWrite& write) noexcept {
     if (!sale_rows(rows, count)) {
         return false;
     }
-    for (std::size_t row = 0; row < count && row < 32; ++row) {
-        const std::uint16_t slot = rows[row].unlockFlagSlot;
-        if (slot == build_data::collectibles::kUnavailableFlagSlot) {
-            continue;
-        }
-        if (!upsert_flag(family,
-                         slot,
-                         (mask & (1U << row)) != 0 ? unlocks::kFlagSet : unlocks::kFlagClear)) {
-            return false;
-        }
-    }
+    strip_artifact_flags_locked(family, rows, count);
     const std::uint16_t used = points_used(mask);
     if (!upsert_value(family, kArtifactPowerBonusSlot, artifact_power_bonus_for(experience))
         || !upsert_value(family, kArtifactPointsUsedSlot, used)
@@ -261,8 +271,10 @@ bool seed_seasonal_progression() noexcept {
     }
     AcquireSRWLockExclusive(&runtime::storage::g_stateLock);
     Family5State& family = runtime::storage::g_state.investment.family5;
-    const bool published =
-        publish_artifact_locked(family, artifact_mask_locked(family, rows, count), experience);
+    // An authored family-5 row still seeds ownership. The publish moves it to the character bank.
+    const std::uint32_t mask =
+        authored_artifact_mask_locked(family, rows, count) | artifact_mask(rows, count);
+    const bool published = publish_artifact_locked(family, mask, experience);
     ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
     return published;
 }
@@ -304,8 +316,7 @@ bool grant_seasonal_experience(std::int32_t amount) noexcept {
     Family5State& family = runtime::storage::g_state.investment.family5;
     SaleRows rows{};
     std::size_t count = 0;
-    const std::uint32_t mask =
-        sale_rows(rows, count) ? artifact_mask_locked(family, rows, count) : 0U;
+    const std::uint32_t mask = sale_rows(rows, count) ? artifact_mask(rows, count) : 0U;
     (void)publish_artifact_locked(family, mask, total);
     ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
     return true;
@@ -346,11 +357,7 @@ std::uint32_t artifact_mod_mask() noexcept {
     if (!sale_rows(rows, count)) {
         return 0;
     }
-    AcquireSRWLockShared(&runtime::storage::g_stateLock);
-    const std::uint32_t mask =
-        artifact_mask_locked(runtime::storage::g_state.investment.family5, rows, count);
-    ReleaseSRWLockShared(&runtime::storage::g_stateLock);
-    return mask;
+    return artifact_mask(rows, count);
 }
 
 /** Replaces the exact published mask, refusing when another action changed it first. */
@@ -363,7 +370,7 @@ bool replace_artifact_mod_mask(std::uint32_t expected, std::uint32_t replacement
     const std::int32_t experience = seasonal_experience();
     AcquireSRWLockExclusive(&runtime::storage::g_stateLock);
     Family5State& family = runtime::storage::g_state.investment.family5;
-    bool replaced = artifact_mask_locked(family, rows, count) == expected;
+    bool replaced = artifact_mask(rows, count) == expected;
     if (replaced) {
         replaced = publish_artifact_locked(family, replacement, experience);
     }
@@ -398,7 +405,7 @@ bool prepare_artifact_mod_unlock(std::uint16_t saleIndex,
     const std::uint16_t earned = artifact_points_earned_for(experience);
     AcquireSRWLockExclusive(&runtime::storage::g_stateLock);
     Family5State& family = runtime::storage::g_state.investment.family5;
-    const std::uint32_t before = artifact_mask_locked(family, rows, count);
+    const std::uint32_t before = artifact_mask(rows, count);
     const std::uint16_t used = points_used(before);
     bool prepared = (before & bit) == 0 && used < earned && used >= column_tier(saleIndex);
     if (prepared) {

+ 3 - 0
Sunrise/src/state/runtime/state_runtime.cpp

@@ -448,6 +448,9 @@ bool investment_snapshot(InvestmentState& output) noexcept {
     InvestmentState snapshot = runtime::storage::g_state.investment;
     ReleaseSRWLockShared(&runtime::storage::g_stateLock);
     if (!build_data::complete_exotic_catalyst_investment(snapshot.family5)) {
+        core::log::write(core::log::Channel::state,
+                         core::log::Level::warn,
+                         "ev=investment stage=snapshot result=fail reason=catalyst");
         return false;
     }
     output = snapshot;

+ 1 - 0
Sunrise/src/state/unlocks/unlocks_records.cpp

@@ -397,6 +397,7 @@ void publish_derived(Table& table) noexcept {
         // A lore chapter's authored value is not claim state, so the catalogs replace it once.
         clear_lore_objectives(table);
         (void)build_data::complete_exotic_catalyst_objectives(table.objectiveValues);
+        (void)build_data::complete_exotic_catalyst_flags(table.accountFlags);
         g_loreSeedOwed = false;
     }
     (void)node_catalog::apply_visibility(table.accountFlags);

+ 7 - 0
Sunrise/src/state/unlocks/unlocks_runtime.cpp

@@ -46,6 +46,13 @@ bool account_flag_set(std::uint16_t index) noexcept {
     return index < g_table.accountFlags.size() && g_table.accountFlags[index] == kFlagSet;
 }
 
+/** @return True when the selected character's object flag at this row is set. */
+bool character_object_flag_set(std::uint16_t index) noexcept {
+    const std::shared_lock guard(g_lock);
+    return index < g_table.characterObjectFlags.size()
+           && g_table.characterObjectFlags[index] == kFlagSet;
+}
+
 /** Writes one account acquired flag. */
 bool set_account_flag(std::uint16_t index, std::uint8_t value) noexcept {
     const std::lock_guard guard(g_lock);

+ 3 - 0
Sunrise/src/state/unlocks/unlocks_runtime.h

@@ -29,6 +29,9 @@ void mutate(void* context, void (*apply)(void*, Table&) noexcept) noexcept;
 /** @param index Account flag bank row. @return True when the flag is set. */
 [[nodiscard]] bool account_flag_set(std::uint16_t index) noexcept;
 
+/** @param index Character object flag bank row. @return True when the flag is set. */
+[[nodiscard]] bool character_object_flag_set(std::uint16_t index) noexcept;
+
 /**
  * Writes one account acquired flag.
  * @param index Account flag bank row.