Procházet zdrojové kódy

Keep the node table across restarts so warm starts count categories

The build data cache does not carry the presentation node domain, and the
package pass only runs when the investment refresh gate finds something
missing. Nodes are not in that gate, so every start after the first ran with an
empty node table: nothing counted, no bar moved, whatever had been claimed.

The domain is now written beside the claim file and read back at startup. The
read 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 a warm start it is refused before validation
is reached. The same valid() check still runs, and the domain is not in the
cache, so the agreement the freeze protects is untouched.

The file carries a magic and its row width, so a build whose Definition changes
shape rejects the older file and rebuilds instead of misreading it.

Verified: warm start loads 924 rows and both bars count.
Millie před 2 týdny
rodič
revize
95daba0a3f

+ 13 - 0
Sunrise/src/client/content/investment/investment_refresh.cpp

@@ -1,3 +1,6 @@
+#include <atomic>
+
+#include "../../../state/build_data/nodes/node_persistence.h"
 #include <Windows.h>
 
 #include "../../../core/ui/busy/busy.h"
@@ -44,6 +47,16 @@ bool requires_package_sweep() noexcept {
 
 /** Publishes every installed equipment mapping domain. */
 bool refresh() noexcept {
+    // The node table is kept in its own file and is not part of the build data cache. Publishing it
+    // here, before the gate below decides whether the package pass runs, is what lets a warm start
+    // count categories: the gate itself is left exactly as it was, so no pass can be made to repeat.
+    // Latches on success, not on the attempt: an early call is refused rather than failed, so
+    // giving up after one try is what left the table empty.
+    static std::atomic<bool> nodesPublished{false};
+    if (!nodesPublished.load(std::memory_order_relaxed)
+        && state::build_data::nodes::load_and_publish()) {
+        nodesPublished.store(true, std::memory_order_relaxed);
+    }
     if (ready()) {
         // The same lock as the extraction path. A cache write holds its own lock across file
         // calls, so a held thread stopped inside one would deadlock the freeze below.

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

@@ -1,3 +1,4 @@
+#include "../../../../state/build_data/nodes/node_persistence.h"
 #include <Windows.h>
 
 #include <array>
@@ -163,8 +164,12 @@ 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, so the next start does not need this pass at all.
+                        (void)state::build_data::nodes::store(
+                            std::span(storage.nodeRows).first(nodeCount));
+                    }
                 }
             }
             if (!state::build_data::record_definitions_ready()) {

+ 14 - 0
Sunrise/src/middleware/datagen/family4/character/character_encoder.cpp

@@ -1,3 +1,6 @@
+#include <atomic>
+
+#include "../../../../state/build_data/nodes/node_persistence.h"
 #include "character_encoder.h"
 
 #include <algorithm>
@@ -157,6 +160,17 @@ bool encode(const state::CharacterState& state,
             index < unlocks.characterObjectFlags.size() ? unlocks.characterObjectFlags[index]
                                                         : std::uint8_t{});
     }
+    // A character image is only built once a character is selected, which is later than any
+    // account or content path. The node table is published from here on a warm start, where the
+    // package pass is skipped and every earlier attempt is refused. Latches on success.
+    {
+        static std::atomic<bool> nodesPublished{false};
+        if (!nodesPublished.load(std::memory_order_relaxed)
+            && state::build_data::nodes::load_and_publish()) {
+            nodesPublished.store(true, std::memory_order_relaxed);
+        }
+    }
+
     for (std::size_t index = 0; index < object.objectiveValues.size(); ++index) {
         object.objectiveValues[index] =
             index < unlocks.characterObjectValues.size() ? unlocks.characterObjectValues[index] : 0;

+ 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

+ 11 - 0
Sunrise/src/state/record_claims/record_claims.cpp

@@ -1,3 +1,6 @@
+#include <atomic>
+
+#include "../build_data/nodes/node_persistence.h"
 #include "record_claims.h"
 
 #include <array>
@@ -258,6 +261,14 @@ struct NodeProgress {
 
 /** Writes each node's claimed-child count into the value slot its bar reads. */
 std::size_t apply_node_progress(std::span<std::int32_t> objectiveValues) noexcept {
+    // The account image is the latest point anything asks for the node table, and on a warm start it
+    // may still be unpublished: the content pass is skipped and an earlier publish can be refused.
+    // Latches on success, so a run that has its table pays nothing.
+    static std::atomic<bool> nodesPublished{false};
+    if (!nodesPublished.load(std::memory_order_relaxed)
+        && build_data::nodes::load_and_publish()) {
+        nodesPublished.store(true, std::memory_order_relaxed);
+    }
     NodeProgress progress{objectiveValues, 0};
     // The claim lock is taken first and the node lock inside the walk. Nothing takes them the other
     // way round, so the order cannot close a cycle.

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

@@ -1,3 +1,4 @@
+#include "../build_data/nodes/node_persistence.h"
 #include <Windows.h>
 
 #include <algorithm>
@@ -209,6 +210,9 @@ 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)record_claims::initialize(module);
+    // Publishes the node table if a previous run wrote one, so a warm start counts
+    // categories without needing the package pass to run again.
+    (void)build_data::nodes::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
     // after package extraction has published the relation.