Kaynağa Gözat

Keep claims across restarts, and promise the revision a claim lands on

Two faults, both of which made claiming look non-deterministic.

Claims lived only in memory, so a restart dropped every one and the client offered them again. Hold
them in a file beside the build data cache instead, as the flag bank row and the score its record
carries, written on each claim rather than at shutdown so a crash cannot lose what the client is
already showing as Acquired. Settings stay configuration; nothing here edits them. The stored score
means loading does not depend on the records domain being ready first.

The claim reply promised no Family-4 revision. Every other client action answers with the exact
revision that makes it authoritative, and the claim was answering with the sentinel, so the client
had nothing to wait for. The symptom was bizarre and worth recording: claims are laid out twelve to
a page, and having claimed the first slot on one page, the first slot on the next page would not
claim -- a different record entirely. The stuck state belonged to the screen position, not the
record. Promise the version the account resync carries and the position clears.
Millie 2 hafta önce
ebeveyn
işleme
5c17ed7ead

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

@@ -452,6 +452,23 @@ bool process(const ServiceRoute& route,
         }
         // A pick that names the resident character moves nothing, so staging refuses it and the
         // reply still stands on its own.
+        if (webOutcome.hasRecordClaim) {
+            // Every other client action promises the exact Family-4 revision that makes it
+            // authoritative, and the claim was answering with the sentinel instead. The account
+            // resync staged later carries the next version, so promise that one here.
+            middleware::web_service::StatusResponse status{};
+            status.value = queuezState.family4Version + 1;
+            if (!middleware::web_service::encode_response(
+                    message,
+                    middleware::web_service::ResponseShape::statusPair,
+                    status,
+                    output,
+                    written)) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=ws1801 stage=response result=fail");
+            }
+        }
         if (webOutcome.hasSelectedCharacter
             && queuez::stage_select_character(
                 queuezState, webOutcome.selectedCharacterSoid, outcome.selectCharacter)) {

+ 168 - 4
Sunrise/src/state/record_claims/record_claims.cpp

@@ -2,8 +2,15 @@
 
 #include <array>
 #include <bit>
+#include <cstdio>
+#include <cstring>
 #include <mutex>
+#include <vector>
 
+#include <windows.h>
+
+#include "../../core/filesystem/path.h"
+#include "../../core/logging/log.h"
 #include "../unlocks/definition.h"
 
 namespace sunrise::state::record_claims {
@@ -14,22 +21,175 @@ constexpr std::size_t kIndexCapacity = unlocks::kAccountFlagCapacity;
 constexpr std::size_t kWordBits = 64;
 constexpr std::size_t kWordCount = (kIndexCapacity + kWordBits - 1) / kWordBits;
 
+/** The claim file lives beside the build data cache, which already owns this directory. */
+constexpr std::wstring_view kClaimFileSuffix = L"\\cache\\record_claims.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', 'C', 'L', 'M', '1'};
+/** A claim is a flag bank row and the score its record carries. */
+constexpr std::size_t kEntrySize = 2 * sizeof(std::uint16_t);
+/** Far above the 2242 records the build ships, and small enough to read in one go. */
+constexpr std::uint32_t kMaximumEntries = 8192;
+
 std::mutex g_lock;
 std::array<std::uint64_t, kWordCount> g_claimed{};
+std::array<std::uint16_t, kIndexCapacity> g_scoreByIndex{};
 std::size_t g_count{};
 std::uint32_t g_score{};
+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=claims stage=%s result=%s entries=%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)});
+    }
+}
+
+/** Writes every held claim. The caller holds the lock. */
+void store_locked() noexcept {
+    if (!g_pathReady) {
+        return;
+    }
+    std::vector<char> document{};
+    document.insert(document.end(), kMagic.begin(), kMagic.end());
+    const auto entries = static_cast<std::uint32_t>(g_count);
+    const auto* entryBytes = reinterpret_cast<const char*>(&entries);
+    document.insert(document.end(), entryBytes, entryBytes + sizeof entries);
+    for (std::size_t word = 0; word < g_claimed.size(); ++word) {
+        std::uint64_t bits = g_claimed[word];
+        while (bits != 0) {
+            const auto offset = static_cast<std::size_t>(std::countr_zero(bits));
+            bits &= bits - 1;
+            const std::size_t index = word * kWordBits + offset;
+            const auto packedIndex = static_cast<std::uint16_t>(index);
+            const std::uint16_t packedScore = g_scoreByIndex[index];
+            const auto* indexBytes = reinterpret_cast<const char*>(&packedIndex);
+            const auto* scoreBytes = reinterpret_cast<const char*>(&packedScore);
+            document.insert(document.end(), indexBytes, indexBytes + sizeof packedIndex);
+            document.insert(document.end(), scoreBytes, scoreBytes + sizeof packedScore);
+        }
+    }
+
+    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", g_count);
+        return;
+    }
+    DWORD written = 0;
+    const auto size = static_cast<DWORD>(document.size());
+    bool complete =
+        WriteFile(file, document.data(), size, &written, nullptr) != FALSE && written == size;
+    complete = CloseHandle(file) != FALSE && complete;
+    report("store", complete ? "ok" : "write_fail", g_count);
+}
+
+/** Reads every claim the file holds. The caller holds the lock. */
+void load_locked() noexcept {
+    const HANDLE file = CreateFileW(g_path.chars.data(),
+                                    GENERIC_READ,
+                                    FILE_SHARE_READ,
+                                    nullptr,
+                                    OPEN_EXISTING,
+                                    FILE_ATTRIBUTE_NORMAL,
+                                    nullptr);
+    if (file == INVALID_HANDLE_VALUE) {
+        // No file is an account that has claimed nothing, which is the first-run state.
+        report("load", "absent", 0);
+        return;
+    }
+    std::array<char, sizeof(kMagic) + sizeof(std::uint32_t)> header{};
+    DWORD read = 0;
+    if (ReadFile(file, header.data(), static_cast<DWORD>(header.size()), &read, nullptr) == FALSE
+        || read != header.size()
+        || std::memcmp(header.data(), kMagic.data(), kMagic.size()) != 0) {
+        (void)CloseHandle(file);
+        report("load", "header_fail", 0);
+        return;
+    }
+    std::uint32_t entries = 0;
+    std::memcpy(&entries, header.data() + kMagic.size(), sizeof entries);
+    if (entries > kMaximumEntries) {
+        (void)CloseHandle(file);
+        report("load", "count_fail", entries);
+        return;
+    }
+    std::vector<char> payload(static_cast<std::size_t>(entries) * kEntrySize);
+    read = 0;
+    const bool readOk =
+        payload.empty()
+        || (ReadFile(file, payload.data(), static_cast<DWORD>(payload.size()), &read, nullptr)
+                != FALSE
+            && read == payload.size());
+    (void)CloseHandle(file);
+    if (!readOk) {
+        report("load", "read_fail", entries);
+        return;
+    }
+
+    std::size_t restored = 0;
+    for (std::uint32_t entry = 0; entry < entries; ++entry) {
+        std::uint16_t index = 0;
+        std::uint16_t score = 0;
+        std::memcpy(&index, payload.data() + static_cast<std::size_t>(entry) * kEntrySize,
+                    sizeof index);
+        std::memcpy(&score,
+                    payload.data() + static_cast<std::size_t>(entry) * kEntrySize + sizeof index,
+                    sizeof score);
+        if (static_cast<std::size_t>(index) >= kIndexCapacity) {
+            continue;
+        }
+        const std::size_t word = static_cast<std::size_t>(index) / kWordBits;
+        const std::uint64_t bit = std::uint64_t{1}
+                                  << (static_cast<std::size_t>(index) % kWordBits);
+        if ((g_claimed[word] & bit) != 0) {
+            continue;
+        }
+        g_claimed[word] |= bit;
+        g_scoreByIndex[index] = score;
+        g_score += score;
+        ++g_count;
+        ++restored;
+    }
+    report("load", "ok", restored);
+}
 
 } // namespace
 
-/** Forgets every claim made since boot. */
+/** Derives the claim file path and loads any claims already held. */
+bool initialize(void* module) noexcept {
+    const std::lock_guard<std::mutex> guard(g_lock);
+    g_pathReady = false;
+    if (!core::path::artifact_directory(module, g_path)
+        || !core::path::append(g_path, kClaimFileSuffix)) {
+        report("initialize", "path_fail", 0);
+        return false;
+    }
+    g_pathReady = true;
+    load_locked();
+    return true;
+}
+
+/** Forgets every held claim, in memory only. */
 void clear() noexcept {
     const std::lock_guard<std::mutex> guard(g_lock);
     g_claimed.fill(0);
+    g_scoreByIndex.fill(0);
     g_count = 0;
     g_score = 0;
 }
 
-/** Marks one account flag bank index claimed. */
+/** Marks one account flag bank index claimed, adds its score, and writes the claim file. */
 bool claim(std::uint16_t flagIndex, std::uint16_t scoreValue) noexcept {
     if (static_cast<std::size_t>(flagIndex) >= kIndexCapacity) {
         return false;
@@ -39,9 +199,13 @@ bool claim(std::uint16_t flagIndex, std::uint16_t scoreValue) noexcept {
     const std::lock_guard<std::mutex> guard(g_lock);
     if ((g_claimed[word] & bit) == 0) {
         g_claimed[word] |= bit;
+        g_scoreByIndex[flagIndex] = scoreValue;
         ++g_count;
         // Only a first claim scores, so a repeated click cannot inflate the total.
         g_score += scoreValue;
+        // Written per claim rather than at shutdown: a crash must not lose what the client is
+        // already showing as Acquired.
+        store_locked();
     }
     return true;
 }
@@ -69,13 +233,13 @@ std::size_t apply(std::span<std::uint8_t> accountFlags) noexcept {
     return changed;
 }
 
-/** @return Total score of every record claimed since boot. */
+/** @return Total score of every held claim. */
 std::uint32_t total_score() noexcept {
     const std::lock_guard<std::mutex> guard(g_lock);
     return g_score;
 }
 
-/** @return Number of distinct indices claimed since boot. */
+/** @return Number of distinct indices held. */
 std::size_t count() noexcept {
     const std::lock_guard<std::mutex> guard(g_lock);
     return g_count;

+ 20 - 10
Sunrise/src/state/record_claims/record_claims.h

@@ -10,26 +10,33 @@ namespace sunrise::state::record_claims {
  * Records claimed through Web Service opcode 1801, as account flag bank indices.
  *
  * The authored unlock policy is immutable for the life of the process, so a claim cannot write to
- * it. This holds the claims made since boot instead, and the account encoder lays them over the
- * authored bank on its way out. A claim is therefore visible to the client on the next Family-4
- * image, and is lost on restart unless the same flag is authored.
+ * it. This holds the claims instead, and the account encoder lays them over the authored bank on
+ * its way out. A claim is therefore visible to the client on the next Family-4 image.
+ *
+ * Claims are written to a file beside the build data cache, so they survive a restart. Settings
+ * stay configuration: nothing here edits them.
+ */
+
+/**
+ * Derives the claim file path and loads any claims already held.
+ * A missing file is not a failure: it is an account that has claimed nothing yet.
+ * @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;
 
-/** Forgets every claim made since boot. */
+/** Forgets every held claim, in memory only. The file is left alone. */
 void clear() noexcept;
 
 /**
- * Marks one account flag bank index claimed and adds its record's score to the total.
- * A repeated claim of the same index is held once and scores once.
+ * Marks one account flag bank index claimed, adds its score, and writes the claim file.
+ * A repeated claim of the same index is held once, scores once, and rewrites nothing.
  * @param flagIndex Mapping-table row whose object byte feeds the record's completion flag.
  * @param scoreValue Points the record is worth, counted only on the first claim.
  * @return True when the index is in range and the claim is now held.
  */
 [[nodiscard]] bool claim(std::uint16_t flagIndex, std::uint16_t scoreValue) noexcept;
 
-/** @return Total score of every record claimed since boot. */
-[[nodiscard]] std::uint32_t total_score() noexcept;
-
 /**
  * Lays every held claim over one account flag bank.
  * @param accountFlags Bank already filled from the authored policy.
@@ -37,7 +44,10 @@ void clear() noexcept;
  */
 std::size_t apply(std::span<std::uint8_t> accountFlags) noexcept;
 
-/** @return Number of distinct indices claimed since boot. */
+/** @return Total score of every held claim. */
+[[nodiscard]] std::uint32_t total_score() noexcept;
+
+/** @return Number of distinct indices held. */
 [[nodiscard]] std::size_t count() noexcept;
 
 } // namespace sunrise::state::record_claims

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

@@ -13,6 +13,7 @@
 #include "../../core/settings/settings.h"
 #include "../activity/defaults/activity_defaults_validation.h"
 #include "../build_data/runtime.h"
+#include "../record_claims/record_claims.h"
 #include "equipment/configured_equipment_identity.h"
 #include "runtime.h"
 #include "state.h"
@@ -205,6 +206,9 @@ bool initialize(void* module,
     if (!build_data::initialize(module, runtime::equipment::configured_hash(runtimeAccount))) {
         return false;
     }
+    // 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);
     // 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.