Sfoglia il codice sorgente

Persist records and nodes, and rule out item ownership as the reveal

Both domains were ported without their cache integration, so each was rebuilt
only on a cache miss and a warm start ran with an empty table. A grant then
failed for a reason the log could not distinguish. Each now keeps its own file
beside the claim store, loaded at the point of use because a saved cache freezes
the domains and refuses a publish attempted earlier.

The grant outcome is split by cause. One value covered five different failures,
and the log said empty_book whether the node table was missing, the book was
absent, or no record resolved. Those are now separate and the record table being
unpublished on a warm start was found in one launch rather than inferred from
interleaved timestamps.

Also ruled out: the lore is not gated on owning the collectible's item. Granting
it succeeded for two books and both stayed obscured, so the item grant is
withdrawn rather than left handing out items the account did not earn.

Three theories have now failed the same way, all assuming the reveal comes from
a bank already sent. Collectibles appear nowhere in the family-4 account image,
and a client expecting per-collectible state would obscure regardless.
Millie 2 settimane fa
parent
commit
8c4635e3c4

+ 6 - 0
Sunrise/Sunrise.vcxproj

@@ -831,6 +831,9 @@
     <ClCompile Include="src\state\build_data\nodes\node_catalog.cpp" />
     <ClCompile Include="src\state\record_claims\record_claims.cpp" />
     <ClCompile Include="src\state\lore\lore_grant.cpp" />
+    <ClCompile Include="src\state\activity\current_activity.cpp" />
+    <ClCompile Include="src\state\build_data\nodes\node_persistence.cpp" />
+    <ClCompile Include="src\state\build_data\records\record_persistence.cpp" />
   </ItemGroup>
   <ItemGroup Condition="'$(SunriseRunClangTidy)'=='true'">
     <ClCompile Remove="vendor\detours\detours.cpp" />
@@ -1443,6 +1446,9 @@
     <ClInclude Include="src\state\build_data\nodes\node_catalog.h" />
     <ClInclude Include="src\state\record_claims\record_claims.h" />
     <ClInclude Include="src\state\lore\lore_grant.h" />
+    <ClInclude Include="src\state\activity\current_activity.h" />
+    <ClInclude Include="src\state\build_data\nodes\node_persistence.h" />
+    <ClInclude Include="src\state\build_data\records\record_persistence.h" />
   </ItemGroup>
   <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
 </Project>

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

@@ -1,3 +1,5 @@
+#include "../../../../state/build_data/records/record_persistence.h"
+#include "../../../../state/build_data/nodes/node_persistence.h"
 #include <Windows.h>
 
 #include <array>
@@ -163,8 +165,14 @@ bool build() noexcept {
                                 storage.child,
                                 storage.nodeRows,
                                 nodeCount)) {
-                    (void)state::build_data::publish_node_definitions(
-                        std::span(storage.nodeRows).first(nodeCount));
+                    if (state::build_data::publish_node_definitions(
+                            std::span(storage.nodeRows).first(nodeCount))) {
+                        // Kept in its own file: the build data cache does not carry this domain, so
+                        // without it a warm start runs with no node table and nothing can be
+                        // granted.
+                        (void)state::build_data::nodes::store(
+                            std::span(storage.nodeRows).first(nodeCount));
+                    }
                 }
             }
             if (!state::build_data::record_definitions_ready()) {
@@ -175,8 +183,14 @@ bool build() noexcept {
                                   storage.child,
                                   storage.recordRows,
                                   recordCount)) {
-                    (void)state::build_data::publish_record_definitions(
-                        std::span(storage.recordRows).first(recordCount));
+                    if (state::build_data::publish_record_definitions(
+                            std::span(storage.recordRows).first(recordCount))) {
+                        // Kept beside the node table and for the same reason: the build data cache
+                        // does not carry this domain, so a warm start would have no records and
+                        // nothing could resolve a chapter.
+                        (void)state::build_data::records::store(
+                            std::span(storage.recordRows).first(recordCount));
+                    }
                 }
             }
             if (!state::build_data::investment_constants_ready()) {

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

@@ -1,3 +1,4 @@
+#include "../../../../state/activity/current_activity.h"
 #include "activity_host_manager_route.h"
 
 #include <array>
@@ -59,6 +60,10 @@ choose_copy(const request_selection::ActivityManagerSelectionResult& parsed) noe
  * @param source Parsed selection carrying a package name.
  */
 void report_selection(const request_selection::ActivityManagerSelection& source) noexcept {
+    // Keep the bubble: a pickup later needs to know which activity it happened in, and the incident
+    // that reports it carries the bubble at a different place per incident type.
+    state::activity::set_current_bubble(source.hasArrivalBubbleHash ? source.arrivalBubbleHash
+                                                                    : state::activity::kNoBubble);
     std::array<char, core::log::kLineCapacity> line{};
     const int written =
         std::snprintf(line.data(),

+ 17 - 14
Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_message_receipts.cpp

@@ -4,6 +4,7 @@
  * was read so the caller can record one arrival receipt. None of them acts on what it read.
  */
 
+#include "../../../../../state/activity/current_activity.h"
 #include "../../../../../state/lore/lore_grant.h"
 #include <vector>
 #include <algorithm>
@@ -355,20 +356,21 @@ Framed frame_incident(const message::Request& request) noexcept {
             //
             // Only type 2 carries this layout. Another type read against it yields a plausible
             // number that means nothing, which is how a counter was once mistaken for an identity.
-            constexpr std::int32_t kPickupTypeCode = 2;
-            constexpr std::size_t kBubbleBit = 126;
+            // A vase reports type 2 and a dead ghost type 10. Each type has its own schema, so
+            // the bubble sits somewhere different in each payload, and more types will turn up.
+            // The activity selection already named the bubble once, so it is read from there and
+            // the payload is not decoded at all.
+            constexpr std::array<std::int32_t, 2> kPickupTypeCodes{2, 10};
             state::build_data::sobjects::Definition row{};
-            const bool pickupType =
-                state::build_data::sobjects::find(static_cast<std::uint16_t>(parsed.primaryTarget),
-                                                  row)
-                && row.typeCode == kPickupTypeCode;
-            if (pickupType && body.size() * 8 >= kBubbleBit + 32) {
-                std::uint32_t bubble = 0;
-                for (std::size_t step = 0; step < 32; ++step) {
-                    const std::size_t at = kBubbleBit + step;
-                    const auto byte = static_cast<std::uint8_t>(body[at / 8]);
-                    bubble = (bubble << 1U) | ((byte >> (7 - (at % 8))) & 1U);
+            bool pickupType = false;
+            if (state::build_data::sobjects::find(static_cast<std::uint16_t>(parsed.primaryTarget),
+                                                  row)) {
+                for (const std::int32_t code : kPickupTypeCodes) {
+                    pickupType = pickupType || row.typeCode == code;
                 }
+            }
+            if (pickupType) {
+                const std::uint32_t bubble = state::activity::current_bubble();
                 const std::uint16_t node = state::lore::book_for_bubble(bubble);
                 const state::lore::GrantOutcome outcome = state::lore::grant_next_chapter(node);
                 if (outcome == state::lore::GrantOutcome::granted) {
@@ -380,12 +382,13 @@ Framed frame_incident(const message::Request& request) noexcept {
                 }
                 report(outcome == state::lore::GrantOutcome::granted ? core::log::Level::info
                                                                      : core::log::Level::warn,
-                       "ev=activity stage=lore bubble=0x%08X node=%u result=%s record=%u",
+                       "ev=activity stage=lore bubble=0x%08X node=%u result=%s record=%u item=%d",
                        bubble, static_cast<unsigned>(node),
                        state::lore::grant_outcome_name(outcome),
                        static_cast<unsigned>(outcome == state::lore::GrantOutcome::granted
                                                  ? state::lore::last_granted_record()
-                                                 : 0));
+                                                 : 0),
+                       state::lore::last_item_granted() ? 1 : 0);
             }
 
             report(core::log::Level::info,

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

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

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

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

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

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

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

@@ -70,4 +70,16 @@ void clear() noexcept;
 /** @return Number of installed-build collectible mappings. */
 [[nodiscard]] std::size_t count() noexcept;
 
+/**
+ * Finds the collectible that unlocks one lore row.
+ *
+ * The reverse of the record join: a chapter record and the collectible that grants it name the same
+ * lore row, so this reaches the collectible whose item the account has to own for the lore itself to
+ * be readable rather than obscured.
+ * @param loreRow Lore table row.
+ * @param definition Receives the collectible.
+ * @return True when a collectible names that row.
+ */
+[[nodiscard]] bool find_by_lore_row(std::uint16_t loreRow, Definition& definition) noexcept;
+
 } // namespace sunrise::state::build_data::collectibles

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

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

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

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

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

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

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

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

+ 58 - 11
Sunrise/src/state/lore/lore_grant.cpp

@@ -8,7 +8,14 @@
 
 #include "../build_data/nodes/definition.h"
 #include "../build_data/nodes/node_catalog.h"
+#include "../build_data/nodes/node_persistence.h"
 #include "../build_data/records/definition.h"
+#include "../build_data/records/record_catalog.h"
+#include "../build_data/records/record_persistence.h"
+#include "../build_data/collectibles/collectible_catalog.h"
+#include "../build_data/inventory/buckets/definition.h"
+#include "../build_data/items/details/definition.h"
+#include "../runtime/runtime.h"
 #include "../build_data/runtime.h"
 #include "../record_claims/record_claims.h"
 
@@ -30,12 +37,18 @@ struct BubbleBook {
     std::uint16_t node;
 };
 
-constexpr std::array<BubbleBook, 1> kBubbleBooks{{
-    // caluseum_experience, whose vases fill Confessions.
+constexpr std::array<BubbleBook, 2> kBubbleBooks{{
+    // caluseum_experience, whose vases fill Confessions. Node 838 confirmed against the published
+    // manifest: nine chapters, Entry I on record 1708 carrying lore hash 0x58C9C088.
     {0x811C9DC5U, 838U},
+    // The destination whose dead ghosts fill Ghost Stories. Node 817 identified by child count --
+    // the manifest lists 23 records and only two books here have 23 chapters -- and by section,
+    // since Ghost Stories sits under The Light and the other candidate, node 847, does not.
+    {0x5FE28198U, 817U},
 }};
 
 std::atomic<std::uint16_t> g_lastGranted{0};
+std::atomic<bool> g_lastItemGranted{false};
 
 } // namespace
 
@@ -46,8 +59,16 @@ const char* grant_outcome_name(GrantOutcome outcome) noexcept {
         return "granted";
     case GrantOutcome::unknownBook:
         return "unknown_book";
-    case GrantOutcome::emptyBook:
-        return "empty_book";
+    case GrantOutcome::noNodeTable:
+        return "no_node_table";
+    case GrantOutcome::bookNotFound:
+        return "book_not_found";
+    case GrantOutcome::noChildren:
+        return "no_children";
+    case GrantOutcome::noRecords:
+        return "no_records";
+    case GrantOutcome::noChapters:
+        return "no_chapters";
     case GrantOutcome::bookComplete:
         return "book_complete";
     case GrantOutcome::refused:
@@ -74,10 +95,21 @@ GrantOutcome grant_next_chapter(std::uint16_t node) noexcept {
 
     namespace nodes = build_data::nodes;
     namespace records = build_data::records;
+    // On a warm start the package pass is skipped, so the node table is never published and every
+    // book looks empty. The table is kept in its own file for exactly this; publishing it here
+    // costs nothing once it has succeeded.
+    static std::atomic<bool> published{false};
+    if (!published.load(std::memory_order_relaxed)) {
+        const bool haveNodes = nodes::count() != 0 || nodes::load_and_publish();
+        const bool haveRecords = records::count() != 0 || records::load_and_publish();
+        if (haveNodes && haveRecords) {
+            published.store(true, std::memory_order_relaxed);
+        }
+    }
     std::vector<nodes::Definition> rows(nodes::kDefinitionCapacity);
     std::size_t count = 0;
-    if (!nodes::snapshot(std::span<nodes::Definition>{rows}, count)) {
-        return GrantOutcome::emptyBook;
+    if (!nodes::snapshot(std::span<nodes::Definition>{rows}, count) || count == 0) {
+        return GrantOutcome::noNodeTable;
     }
 
     const nodes::Definition* book = nullptr;
@@ -87,15 +119,22 @@ GrantOutcome grant_next_chapter(std::uint16_t node) noexcept {
             break;
         }
     }
-    if (book == nullptr || book->childCount == 0) {
-        return GrantOutcome::emptyBook;
+    if (book == nullptr) {
+        return GrantOutcome::bookNotFound;
+    }
+    if (book->childCount == 0) {
+        return GrantOutcome::noChildren;
     }
 
+    bool sawRecord = false;
     bool sawChapter = false;
     for (std::size_t child = 0; child < book->childCount; ++child) {
         records::Definition record{};
-        if (!build_data::find_record_definition(book->children[child], record)
-            || record.completionFlagIndex == records::kUnavailableFlagIndex) {
+        if (!build_data::find_record_definition(book->children[child], record)) {
+            continue;
+        }
+        sawRecord = true;
+        if (record.completionFlagIndex == records::kUnavailableFlagIndex) {
             continue;
         }
         // A child naming no lore row is the book's parent triumph, not a chapter. Granting it would
@@ -117,7 +156,15 @@ GrantOutcome grant_next_chapter(std::uint16_t node) noexcept {
         g_lastGranted.store(record.definitionIndex, std::memory_order_relaxed);
         return GrantOutcome::granted;
     }
-    return sawChapter ? GrantOutcome::bookComplete : GrantOutcome::emptyBook;
+    if (sawChapter) {
+        return GrantOutcome::bookComplete;
+    }
+    return sawRecord ? GrantOutcome::noChapters : GrantOutcome::noRecords;
+}
+
+/** @return True when the last grant also gave the collectible's item. */
+bool last_item_granted() noexcept {
+    return g_lastItemGranted.load(std::memory_order_relaxed);
 }
 
 /** @return The record row the last successful grant claimed. */

+ 13 - 2
Sunrise/src/state/lore/lore_grant.h

@@ -25,8 +25,16 @@ enum class GrantOutcome : std::uint8_t {
     granted,
     /** The bubble is not associated with a book. */
     unknownBook,
-    /** The book has no chapters this build can resolve. */
-    emptyBook,
+    /** The node table is not published, so no book can be read. */
+    noNodeTable,
+    /** The node table is published but does not contain this book. */
+    bookNotFound,
+    /** The book is present but owns no children. */
+    noChildren,
+    /** The children are present but no record resolves, so the record table is missing. */
+    noRecords,
+    /** Records resolve but none of them is a chapter. */
+    noChapters,
     /** Every chapter of the book is already held. */
     bookComplete,
     /** The claim store refused the write. */
@@ -54,4 +62,7 @@ enum class GrantOutcome : std::uint8_t {
 /** @return The record row the last successful grant claimed. Only meaningful after `granted`. */
 [[nodiscard]] std::uint16_t last_granted_record() noexcept;
 
+/** @return True when the last grant also gave the collectible's item, which the lore is gated on. */
+[[nodiscard]] bool last_item_granted() noexcept;
+
 } // namespace sunrise::state::lore

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

@@ -1,3 +1,5 @@
+#include "../build_data/records/record_persistence.h"
+#include "../build_data/nodes/node_persistence.h"
 #include "../record_claims/record_claims.h"
 #include <Windows.h>
 
@@ -208,6 +210,8 @@ bool initialize(void* module,
     }
     // Claims are held beside the build data cache, so a restart keeps what the client already shows
     // as Acquired. A missing file is a first run, not a failure.
+    (void)build_data::nodes::initialize(module);
+    (void)build_data::records::initialize(module);
     (void)record_claims::initialize(module);
     // A cache hit already has the complete plug relation, so publish canonical profile identities
     // in the first State image.  On a first cache build, snapshot preparation repeats this step