ソースを参照

fix(investment): address catalyst review findings

y9522 2 週間 前
コミット
0dda0ac948

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

@@ -91,6 +91,11 @@ bool initialize(void* module, std::uint64_t configuredEquipmentHash) noexcept {
     } else {
         detailsReplaced = items::details::replace(domains.itemDetails);
     }
+    // An unsupported executable has a checked cache with no catalyst rows. Other domains still
+    // publish from that cache, while the catalyst catalog stays unavailable.
+    const bool catalystCatalogAvailable = !domains.exoticCatalysts.empty();
+    const bool catalystsReplaced =
+        !catalystCatalogAvailable || items::catalysts::replace(domains.exoticCatalysts);
     const constants::InvestmentConstants cachedConstants{
         domains.constants.extracted != 0,
         domains.constants.lightStatRow,
@@ -108,8 +113,7 @@ bool initialize(void* module, std::uint64_t configuredEquipmentHash) noexcept {
         || !socket_entry_lists::replace_entry_tables(domains.socketEntryTables) || !detailsReplaced
         || !items::socket_plugs::replace(
             domains.socketPlugRules, domains.socketPlugPools, domains.socketPlugMembers)
-        || !items::catalysts::replace(domains.exoticCatalysts)
-        || !abilities::replace(domains.abilityBuckets)
+        || !catalystsReplaced || !abilities::replace(domains.abilityBuckets)
         || !progressions::replace(domains.progressions)
         // The layouts are what activity message 1 reads. Without them a cache hit makes the
         // other domains ready, the package build skips itself, and every destination falls back.
@@ -136,6 +140,9 @@ bool initialize(void* module, std::uint64_t configuredEquipmentHash) noexcept {
     runtime::ability_buckets::publish();
     runtime::spawn_catalog::publish();
     runtime::name_catalog::publish();
+    persistenceState.catalystError = catalystCatalogAvailable
+                                         ? items::catalysts::Error::none
+                                         : items::catalysts::Error::unsupportedBuild;
     persistenceState.persisted = true;
     runtime::persistence::release_scratch_locked(persistenceState);
     ReleaseSRWLockExclusive(&persistenceState.lock);

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

@@ -13,8 +13,7 @@ namespace {
 [[nodiscard]] bool required_domains_present(const records::DomainCounts& counts) noexcept {
     return counts.named != 0 && counts.items != 0 && counts.collectibles != 0
            && counts.materialRequirementSets != 0 && counts.socketPlugRules != 0
-           && counts.socketPlugPools != 0 && counts.exoticCatalysts != 0
-           && counts.inventoryBuckets != 0
+           && counts.socketPlugPools != 0 && counts.inventoryBuckets != 0
            && counts.socketEntryLists != 0 && counts.progressions != 0 && counts.scenarios != 0
            && counts.rosterGroups != 0;
 }
@@ -179,7 +178,8 @@ LoadStatus load(const wchar_t* path,
     bool valid = required_domains_present(pendingCounts) && counts_fit(pendingCounts, output)
                  && read::expected_size(pendingCounts, expectedSize)
                  && static_cast<std::uint64_t>(actualSize.QuadPart) == expectedSize
-                 && read::read_payload(file, header.constants, pendingCounts, output, checksum)
+                 && read::read_payload(
+                     file, expectedBuild, header.constants, pendingCounts, output, checksum)
                  && checksum == header.payloadChecksum;
     const LoadStatus status = close_with(file, valid ? LoadStatus::loaded : LoadStatus::invalid);
     if (status != LoadStatus::loaded) {

+ 30 - 27
Sunrise/src/state/build_data/cache/read/cache_payload_reader.cpp

@@ -136,6 +136,7 @@ bool expected_size(const records::DomainCounts& counts, std::uint64_t& size) noe
 
 /** Reads every payload array and checks the decoded domains as one transaction. */
 bool read_payload(HANDLE file,
+                  const BuildIdentity& build,
                   const records::InvestmentConstants& constants,
                   const records::DomainCounts& counts,
                   records::MutableDomains output,
@@ -215,33 +216,35 @@ bool read_payload(HANDLE file,
     if (!valid) {
         return false;
     }
-    return records::valid_domains({
-        constants,
-        output.named.first(counts.named),
-        output.items.first(counts.items),
-        output.collectibles.first(counts.collectibles),
-        output.materialRequirementSets.first(counts.materialRequirementSets),
-        output.itemDetails.first(counts.itemDetails),
-        output.socketPlugRules.first(counts.socketPlugRules),
-        output.socketPlugPools.first(counts.socketPlugPools),
-        output.socketPlugMembers.first(counts.socketPlugMembers),
-        output.exoticCatalysts.first(counts.exoticCatalysts),
-        output.inventoryBuckets.first(counts.inventoryBuckets),
-        output.socketEntryLists.first(counts.socketEntryLists),
-        output.socketEntryTables.first(counts.socketEntryTables),
-        output.abilityBuckets.first(counts.abilityBuckets),
-        output.progressions.first(counts.progressions),
-        output.scenarios.first(counts.scenarios),
-        output.rosterGroups.first(counts.rosterGroups),
-        output.spawnStems.first(counts.spawnStems),
-        output.spawnNameHashes.first(counts.spawnNameHashes),
-        output.spawnPoints.first(counts.spawnPoints),
-        output.hashNames.first(counts.hashNames),
-        output.vendorIndex.first(counts.vendorIndex),
-        output.vendorDefinitions.first(counts.vendorDefinitions),
-        output.vendorSaleRows.first(counts.vendorSaleRows),
-        output.vendorInstalledRows.first(counts.vendorInstalledRows),
-    });
+    return records::valid_domains(
+        build,
+        {
+            constants,
+            output.named.first(counts.named),
+            output.items.first(counts.items),
+            output.collectibles.first(counts.collectibles),
+            output.materialRequirementSets.first(counts.materialRequirementSets),
+            output.itemDetails.first(counts.itemDetails),
+            output.socketPlugRules.first(counts.socketPlugRules),
+            output.socketPlugPools.first(counts.socketPlugPools),
+            output.socketPlugMembers.first(counts.socketPlugMembers),
+            output.exoticCatalysts.first(counts.exoticCatalysts),
+            output.inventoryBuckets.first(counts.inventoryBuckets),
+            output.socketEntryLists.first(counts.socketEntryLists),
+            output.socketEntryTables.first(counts.socketEntryTables),
+            output.abilityBuckets.first(counts.abilityBuckets),
+            output.progressions.first(counts.progressions),
+            output.scenarios.first(counts.scenarios),
+            output.rosterGroups.first(counts.rosterGroups),
+            output.spawnStems.first(counts.spawnStems),
+            output.spawnNameHashes.first(counts.spawnNameHashes),
+            output.spawnPoints.first(counts.spawnPoints),
+            output.hashNames.first(counts.hashNames),
+            output.vendorIndex.first(counts.vendorIndex),
+            output.vendorDefinitions.first(counts.vendorDefinitions),
+            output.vendorSaleRows.first(counts.vendorSaleRows),
+            output.vendorInstalledRows.first(counts.vendorInstalledRows),
+        });
 }
 
 } // namespace sunrise::state::build_data::cache::read

+ 3 - 0
Sunrise/src/state/build_data/cache/read/cache_payload_reader.h

@@ -4,6 +4,7 @@
 
 #include <cstdint>
 
+#include "../../definition.h"
 #include "../records/domains.h"
 #include "../records/format.h"
 
@@ -36,6 +37,7 @@ void clear(records::MutableDomains output) noexcept;
 /**
  * Reads, checksums, and checks every payload array.
  * @param file Open cache handle, positioned after its header.
+ * @param build Cache build identity.
  * @param constants Header constants, held until the whole load commits.
  * @param counts Checked row counts.
  * @param output Fixed caller storage for every domain.
@@ -43,6 +45,7 @@ void clear(records::MutableDomains output) noexcept;
  * @return True when all records decode and pass their checks together.
  */
 [[nodiscard]] bool read_payload(HANDLE file,
+                                const BuildIdentity& build,
                                 const records::InvestmentConstants& constants,
                                 const records::DomainCounts& counts,
                                 records::MutableDomains output,

+ 7 - 4
Sunrise/src/state/build_data/cache/records/cache_detail_links.cpp

@@ -111,15 +111,18 @@ bool valid_socket_plug_links(std::span<const items::socket_plugs::Rule> rules,
 }
 
 /** Checks each catalyst against its item, detail, pool, plug, and pinned release state. */
-bool valid_exotic_catalyst_links(Domains domains) noexcept {
-    if (domains.items.empty() || domains.itemDetails.empty()
+bool valid_exotic_catalyst_links(const BuildIdentity& build, Domains domains) noexcept {
+    const items::catalysts::Facts facts = items::catalysts::generated_facts();
+    if (!items::catalysts::supports_build(build, facts)) {
+        return domains.exoticCatalysts.empty();
+    }
+    if (domains.items.empty() || domains.itemDetails.empty() || domains.exoticCatalysts.empty()
         || !items::socket_plugs::valid(
             domains.socketPlugRules, domains.socketPlugPools, domains.socketPlugMembers)) {
         return false;
     }
-    const items::catalysts::Facts facts = items::catalysts::generated_facts();
     const items::catalysts::Source source{
-        {facts.imageTimestamp, facts.imageSize, 0},
+        build,
         domains.items,
         domains.itemDetails,
         domains.socketPlugRules,

+ 5 - 16
Sunrise/src/state/build_data/cache/records/cache_domain_validation.cpp

@@ -7,7 +7,6 @@
 #include "../../abilities/ability_bucket_catalog.h"
 #include "../../hash_names/hash_name_catalog.h"
 #include "../../inventory/buckets/inventory_bucket_catalog.h"
-#include "../../items/catalysts/exotic_catalyst_catalog.h"
 #include "../../items/details/item_detail_catalog.h"
 #include "../../items/socket_plugs/socket_plug_catalog.h"
 #include "../../material_requirements/material_requirement_catalog.h"
@@ -75,16 +74,6 @@ namespace {
            || (left.itemDefinitionIndex == right.itemDefinitionIndex && left.lane < right.lane);
 }
 
-/**
- * @param left First catalyst row.
- * @param right Second catalyst row.
- * @return True when the first native item index is less than the second.
- */
-[[nodiscard]] bool catalyst_less(const items::catalysts::Definition& left,
-                                 const items::catalysts::Definition& right) noexcept {
-    return left.itemDefinitionIndex < right.itemDefinitionIndex;
-}
-
 /** @return Native definition-index order for item rows. */
 [[nodiscard]] bool item_less(const items::Definition& left,
                              const items::Definition& right) noexcept {
@@ -197,7 +186,8 @@ bool canonicalize(MutableDomains domains, const DomainCounts& counts) noexcept {
     if (!std::is_sorted(socketPlugRules.begin(), socketPlugRules.end(), socket_plug_rule_less)) {
         return false;
     }
-    std::sort(exoticCatalysts.begin(), exoticCatalysts.end(), catalyst_less);
+    std::sort(
+        exoticCatalysts.begin(), exoticCatalysts.end(), items::catalysts::definition_index_less);
     std::sort(inventoryBuckets.begin(), inventoryBuckets.end(), bucket_less);
     const auto abilityBuckets = domains.abilityBuckets.first(counts.abilityBuckets);
     const auto socketEntryTables = domains.socketEntryTables.first(counts.socketEntryTables);
@@ -210,12 +200,12 @@ bool canonicalize(MutableDomains domains, const DomainCounts& counts) noexcept {
 }
 
 /** Checks the structure rules, the sort order, and every cross-domain item reference. */
-bool valid_domains(Domains domains) noexcept {
+bool valid_domains(const BuildIdentity& build, Domains domains) noexcept {
     if (domains.constants.extracted != 1U
         || domains.constants.weaponPowerStatRow >= constants::kStatRowCount || domains.named.empty()
         || domains.items.empty() || domains.collectibles.empty()
         || domains.materialRequirementSets.empty() || domains.socketPlugRules.empty()
-        || domains.socketPlugPools.empty() || domains.exoticCatalysts.empty()
+        || domains.socketPlugPools.empty()
         || domains.inventoryBuckets.empty() || domains.socketEntryLists.empty()
         || !std::all_of(domains.named.begin(), domains.named.end(), valid_name)
         || !strictly_ordered(domains.named, named_less) || !items::valid(domains.items)
@@ -230,7 +220,6 @@ bool valid_domains(Domains domains) noexcept {
         || !strictly_ordered(domains.itemDetails, detail_less)
         || !items::socket_plugs::valid(
             domains.socketPlugRules, domains.socketPlugPools, domains.socketPlugMembers)
-        || !items::catalysts::valid(domains.exoticCatalysts)
         || !abilities::valid(domains.abilityBuckets)
         || !strictly_ordered(domains.abilityBuckets, ability_less)
         || !progressions::valid(domains.progressions)
@@ -282,7 +271,7 @@ bool valid_domains(Domains domains) noexcept {
                                       domains.socketPlugMembers,
                                       domains.items,
                                       domains.itemDetails)
-           && valid_exotic_catalyst_links(domains);
+           && valid_exotic_catalyst_links(build, domains);
 }
 
 } // namespace sunrise::state::build_data::cache::records

+ 11 - 4
Sunrise/src/state/build_data/cache/records/validation.h

@@ -1,5 +1,6 @@
 #pragma once
 
+#include "../../definition.h"
 #include "domains.h"
 
 namespace sunrise::state::build_data::cache::records {
@@ -44,10 +45,12 @@ valid_socket_plug_links(std::span<const items::socket_plugs::Rule> rules,
 
 /**
  * Checks catalyst item, socket, plug, lifecycle, and pinned release links.
+ * @param build Cache build identity.
  * @param domains Complete cache domains.
- * @return True when a fresh derivation exactly matches the candidate catalog.
+ * @return True when a supported build matches exactly, or an unsupported build has no catalog.
  */
-[[nodiscard]] bool valid_exotic_catalyst_links(Domains domains) noexcept;
+[[nodiscard]] bool valid_exotic_catalyst_links(const BuildIdentity& build,
+                                               Domains domains) noexcept;
 
 /**
  * Checks every collectible item index against the complete dense item table.
@@ -59,7 +62,11 @@ valid_socket_plug_links(std::span<const items::socket_plugs::Rule> rules,
 valid_collectible_links(std::span<const collectibles::Definition> collectibleDefinitions,
                         std::span<const items::Definition> itemDefinitions) noexcept;
 
-/** @param domains Complete sorted domains. @return True when every domain passes its checks. */
-[[nodiscard]] bool valid_domains(Domains domains) noexcept;
+/**
+ * @param build Cache build identity.
+ * @param domains Complete sorted domains.
+ * @return True when every domain required by the build passes its checks.
+ */
+[[nodiscard]] bool valid_domains(const BuildIdentity& build, Domains domains) noexcept;
 
 } // namespace sunrise::state::build_data::cache::records

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

@@ -62,7 +62,7 @@ bool write(const wchar_t* directory,
            WriteDisposition disposition) noexcept {
     if (directory == nullptr || path == nullptr || build.imageSize == 0
         || !valid_disposition(disposition) || !counts_fit_header(domains)
-        || !records::valid_domains(domains)) {
+        || !records::valid_domains(build, domains)) {
         return false;
     }
     if (CreateDirectoryW(directory, nullptr) == FALSE && GetLastError() != ERROR_ALREADY_EXISTS) {

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

@@ -72,6 +72,16 @@ struct Definition {
     Availability availability{Availability::unsupported};
 };
 
+/**
+ * @param left First catalyst definition.
+ * @param right Second catalyst definition.
+ * @return True when the first native item index is less than the second.
+ */
+[[nodiscard]] constexpr bool definition_index_less(const Definition& left,
+                                                   const Definition& right) noexcept {
+    return left.itemDefinitionIndex < right.itemDefinitionIndex;
+}
+
 /** The only state a released catalyst exposes to callers. */
 struct CompletedCatalyst {
     std::uint8_t socketLane{};

+ 8 - 9
Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_builder.cpp

@@ -289,6 +289,11 @@ classify_lane(const Source& source, const details::Definition& detail, std::uint
 
 } // namespace
 
+bool supports_build(const BuildIdentity& build, const Facts& facts) noexcept {
+    return facts.imageTimestamp != 0 && facts.imageSize != 0
+           && build.imageTimestamp == facts.imageTimestamp && build.imageSize == facts.imageSize;
+}
+
 bool derive(const Source& source,
             const Facts& facts,
             std::span<Definition> output,
@@ -297,13 +302,11 @@ bool derive(const Source& source,
     count = 0;
     report = {};
     std::fill(output.begin(), output.end(), Definition{});
-    if (facts.imageTimestamp == 0 || facts.imageSize == 0
-        || facts.releasedWeaponHashes.size() > kDefinitionCapacity
+    if (facts.releasedWeaponHashes.size() > kDefinitionCapacity
         || !valid_hashes(facts.releasedWeaponHashes)) {
         return fail(output, count, report, Error::unsupportedBuild);
     }
-    if (source.build.imageTimestamp != facts.imageTimestamp
-        || source.build.imageSize != facts.imageSize) {
+    if (!supports_build(source.build, facts)) {
         return fail(output, count, report, Error::unsupportedBuild);
     }
 
@@ -399,11 +402,7 @@ bool derive(const Source& source,
                 output, count, report, Error::missingReleased, facts.releasedWeaponHashes[index]);
         }
     }
-    std::sort(output.begin(),
-              output.begin() + count,
-              [](const Definition& left, const Definition& right) {
-                  return left.itemDefinitionIndex < right.itemDefinitionIndex;
-              });
+    std::sort(output.begin(), output.begin() + count, definition_index_less);
     return true;
 }
 

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

@@ -33,6 +33,13 @@ struct Source {
 /** @return The generated facts pinned to Destiny 2 build 86657.20.08.23. */
 [[nodiscard]] Facts generated_facts() noexcept;
 
+/**
+ * @param build Installed executable identity.
+ * @param facts Build-scoped catalyst facts.
+ * @return True when the facts apply to the installed executable.
+ */
+[[nodiscard]] bool supports_build(const BuildIdentity& build, const Facts& facts) noexcept;
+
 /**
  * Derives all released and placeholder catalyst records without display text.
  * On failure, count is zero and no partial record is visible.

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

@@ -14,15 +14,6 @@ Lock g_lock;
 Table<Definition, kDefinitionCapacity> g_definitions;
 std::atomic<bool> g_completionEnabled{true};
 
-/**
- * @param left First catalyst definition.
- * @param right Second catalyst definition.
- * @return True when the first native item index is less than the second.
- */
-[[nodiscard]] bool less(const Definition& left, const Definition& right) noexcept {
-    return left.itemDefinitionIndex < right.itemDefinitionIndex;
-}
-
 /**
  * Finds one item in a sorted catalog while its lock is held.
  * @param definitions Catalyst definitions in native item index order.
@@ -46,7 +37,6 @@ std::atomic<bool> g_completionEnabled{true};
 void clear() noexcept {
     const Lock::Exclusive guard(g_lock);
     g_definitions.clear();
-    g_completionEnabled.store(true, std::memory_order_release);
 }
 
 void set_completion_enabled(bool enabled) noexcept {
@@ -73,7 +63,7 @@ bool valid(std::span<const Definition> definitions) noexcept {
                 && (hasCompletedPlug || hasEffect))
             || (definition.availability != Availability::unsupported
                 && (!hasCompletedPlug || !hasEffect))
-            || (index != 0 && !less(definitions[index - 1], definition))) {
+            || (index != 0 && !definition_index_less(definitions[index - 1], definition))) {
             return false;
         }
     }

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

@@ -9,7 +9,7 @@
 
 namespace sunrise::state::build_data::items::catalysts {
 
-/** Clears all build-derived catalyst records and their report. */
+/** Clears all build-derived catalyst records without changing the configured completion policy. */
 void clear() noexcept;
 
 /** @param enabled True to complete released catalysts during item resolution. */

+ 1 - 0
Sunrise/src/state/build_data/items/catalysts/exotic_catalyst_generated.cpp

@@ -12,6 +12,7 @@ constexpr std::uint32_t kImageSize = 0x08A5EA00U;
  * Released Season 11 weapon hashes, in definition-hash order.
  * The installed build stages later catalyst sockets with the same item, socket, and visibility
  * fields as released catalysts. Runtime extraction can recover every relation except release date.
+ * Default and completed plugs come from each exact socket pool, so they are not pinned here.
  */
 constexpr std::array<std::uint32_t, 45> kReleasedWeaponHashes{
     0x012248BAU, 0x14B465B2U, 0x17D8FEABU, 0x2E43BDEEU, 0x3092080DU, 0x4F5CCF1DU, 0x50384F32U,

+ 8 - 16
Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.cpp

@@ -116,11 +116,6 @@ Context& context() noexcept {
            && hash_names_ready() && constants::find(published);
 }
 
-/** @return True when every extracted domain is complete in State. */
-bool all_domains_ready() noexcept {
-    return required_domains_ready() && exotic_catalysts_ready();
-}
-
 /** Gives mutable views over every fixed snapshot buffer. */
 cache::records::MutableDomains scratch_domains(Context& state) noexcept {
     const auto named = ensure_scratch<content::Definition, content::kDefinitionCatalogCapacity>(
@@ -320,9 +315,10 @@ cache::records::Domains occupied_domains(Context& state,
     };
 }
 
-/** Saves one complete canonical snapshot when every domain is ready. */
-bool persist_if_complete_locked(Context& state) noexcept {
-    if (!all_domains_ready() || state.persisted || !state.enabled) {
+/** Saves one canonical snapshot when all domains required by its build are ready. */
+bool persist_if_ready_locked(Context& state, bool catalystRequired) noexcept {
+    if (!required_domains_ready() || (catalystRequired && !exotic_catalysts_ready())
+        || state.persisted || !state.enabled) {
         return true;
     }
     cache::records::DomainCounts counts{};
@@ -364,16 +360,12 @@ bool persist() noexcept {
         requiredReady, exotic_catalysts_ready(), state.catalystError)) {
     case runtime::persistence::CacheAction::waitForDomains:
         break;
-    case runtime::persistence::CacheAction::skipUnsupportedCatalog:
-        // Catalyst facts are build-pinned. A rejected build must not keep the refresh worker
-        // active or put an incomplete catalog on disk. Other extracted domains stay usable.
-        runtime::persistence::release_scratch_locked(state);
-        state.enabled = false;
-        state.replaceStaleCache = false;
-        result = true;
+    case runtime::persistence::CacheAction::writeRequiredDomains:
+        // Unsupported catalyst facts do not prevent the other build-bound domains from caching.
+        result = runtime::persistence::persist_if_ready_locked(state, false);
         break;
     case runtime::persistence::CacheAction::writeCompleteCache:
-        result = runtime::persistence::persist_if_complete_locked(state);
+        result = runtime::persistence::persist_if_ready_locked(state, true);
         break;
     }
     ReleaseSRWLockExclusive(&state.lock);

+ 7 - 9
Sunrise/src/state/build_data/runtime/persistence/build_data_persistence.h

@@ -30,7 +30,7 @@ namespace sunrise::state::build_data::runtime::persistence {
 /** Cache action after one extraction pass. */
 enum class CacheAction {
     waitForDomains,
-    skipUnsupportedCatalog,
+    writeRequiredDomains,
     writeCompleteCache,
 };
 
@@ -39,7 +39,7 @@ enum class CacheAction {
  * @param requiredReady True when all required non-catalyst domains are ready.
  * @param catalystReady True when the complete catalyst catalog is published.
  * @param catalystError Exact result of the last catalyst derivation.
- * @return Wait, skip an unsupported catalog, or write a complete cache.
+ * @return Wait, write required domains, or write every domain.
  */
 [[nodiscard]] constexpr CacheAction cache_action(bool requiredReady,
                                                  bool catalystReady,
@@ -51,7 +51,7 @@ enum class CacheAction {
         return CacheAction::writeCompleteCache;
     }
     return catalystError == items::catalysts::Error::unsupportedBuild
-               ? CacheAction::skipUnsupportedCatalog
+               ? CacheAction::writeRequiredDomains
                : CacheAction::waitForDomains;
 }
 
@@ -96,9 +96,6 @@ struct Context {
 /** @return The process-wide persistence context, shared by lifecycle and writer code. */
 [[nodiscard]] Context& context() noexcept;
 
-/** @return True when every generated domain is complete in State. */
-[[nodiscard]] bool all_domains_ready() noexcept;
-
 /**
  * Clears fixed cache paths, identity, flags, and snapshot storage.
  * @param state Its lock must already be held exclusively.
@@ -125,10 +122,11 @@ void release_scratch_locked(Context& state) noexcept;
 occupied_domains(Context& state, const cache::records::DomainCounts& counts) noexcept;
 
 /**
- * Saves one complete canonical snapshot when every domain is ready.
+ * Saves one canonical snapshot when all domains required by its build are ready.
  * @param state Its lock must already be held exclusively.
- * @return True when no write is due, or the complete State is on disk.
+ * @param catalystRequired True when the snapshot must contain the catalyst catalog.
+ * @return True when no write is due, or the required State is on disk.
  */
-[[nodiscard]] bool persist_if_complete_locked(Context& state) noexcept;
+[[nodiscard]] bool persist_if_ready_locked(Context& state, bool catalystRequired) noexcept;
 
 } // namespace sunrise::state::build_data::runtime::persistence

+ 1 - 1
Sunrise/src/state/build_data/runtime/persistence/publication_transaction.cpp

@@ -36,7 +36,7 @@ bool Transaction::finish(bool published, Rollback& rollback) noexcept {
         return false;
     }
     bool result = published;
-    if (result && !persist_if_complete_locked(*state_)) {
+    if (result && !persist_if_ready_locked(*state_, true)) {
         // A failed write must not leave the candidate visible as a published domain.
         rollback();
         result = false;

+ 10 - 2
tests/catalyst_regression_tests.cpp

@@ -231,8 +231,13 @@ void test_safe_derivation_failures() noexcept {
     Fixture fixture;
     std::size_t count = 0;
     catalysts::Report report{};
+    const catalysts::Facts facts = fixture.facts();
+    expect(catalysts::supports_build(fixture.source().build, facts),
+           "target catalyst facts support their exact executable build");
     catalysts::Source mismatched = fixture.source();
     ++mismatched.build.imageSize;
+    expect(!catalysts::supports_build(mismatched.build, facts),
+           "target catalyst facts reject a different executable build");
     expect(!catalysts::derive(mismatched, fixture.facts(), fixture.output, count, report)
                && count == 0 && report.error == catalysts::Error::unsupportedBuild,
            "build fingerprint mismatch fails only the catalog derivation");
@@ -323,6 +328,9 @@ void test_catalog_application() noexcept {
                && disabledFlags == 2 && disabledPlugs == disabledBefore,
            "global policy disables completion without changing the item");
     catalysts::clear();
+    expect(!catalysts::completion_enabled(),
+           "clearing catalyst records preserves the configured completion policy");
+    catalysts::set_completion_enabled(true);
 }
 
 void test_cache_record() noexcept {
@@ -351,8 +359,8 @@ void test_persistence_action() noexcept {
                == waitForDomains,
            "persistence waits for required domains");
     expect(persistence::cache_action(true, false, catalysts::Error::unsupportedBuild)
-               == skipUnsupportedCatalog,
-           "unsupported builds finish without an incomplete cache");
+               == writeRequiredDomains,
+           "unsupported builds cache every required domain without a catalyst catalog");
     constexpr std::array rejectedErrors{
         catalysts::Error::none,
         catalysts::Error::noCatalyst,

+ 8 - 0
tests/opcode406_item_state_regression_tests.cpp

@@ -64,4 +64,12 @@ void test_opcode406_item_state() noexcept {
     opcode406::Request request{};
     expect(!opcode406::parse_request(message, request),
            "opcode 406 rejects unknown item-state bits");
+
+    const auto maximumBytes = payload(0x7FFFFFFFU);
+    const sunrise::middleware::web_service::Message maximumMessage{
+        opcode406::kOpcode, 1, maximumBytes};
+    opcode406::Request maximumRequest{};
+    expect(!opcode406::parse_request(maximumMessage, maximumRequest)
+               && maximumRequest.flags == 0x7FFFFFFFU,
+           "opcode 406 decodes the maximum biased value without overflow");
 }