Просмотр исходного кода

Bring the claim store onto the collectible branch

Granting a lore chapter means setting a record's completion flag, and the flag
bank the client reads is authored policy that cannot be edited in place. The
claim store holds claims separately and the account encoder lays them over the
bank on its way out, so a claimed record reads Acquired on the next image and a
restart keeps what the client already shows.

Ported unchanged from the triumph branch except for node persistence, which
stays there: this branch extracts the node table every launch, so the warm start
republish it exists for has nothing to do here.

No trigger yet. What should cause a pickup to grant a chapter is still open --
faithful reproduction needs activity and spawn data to map an object to its
reward, and the alternative is a rule that needs only a book association.
Millie 2 недель назад
Родитель
Сommit
2d3f78d9e0

+ 2 - 0
Sunrise/Sunrise.vcxproj

@@ -829,6 +829,7 @@
     <ClCompile Include="src\client\content\items\packages\package_node_build.cpp" />
     <ClCompile Include="src\state\build_data\nodes\node_build_data_runtime.cpp" />
     <ClCompile Include="src\state\build_data\nodes\node_catalog.cpp" />
+    <ClCompile Include="src\state\record_claims\record_claims.cpp" />
   </ItemGroup>
   <ItemGroup Condition="'$(SunriseRunClangTidy)'=='true'">
     <ClCompile Remove="vendor\detours\detours.cpp" />
@@ -1439,6 +1440,7 @@
     <ClInclude Include="src\state\build_data\records\record_catalog.h" />
     <ClInclude Include="src\state\build_data\nodes\definition.h" />
     <ClInclude Include="src\state\build_data\nodes\node_catalog.h" />
+    <ClInclude Include="src\state\record_claims\record_claims.h" />
   </ItemGroup>
   <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
 </Project>

+ 4 - 0
Sunrise/src/middleware/datagen/family4/account/account_encoder.cpp

@@ -1,3 +1,4 @@
+#include "../../../../state/record_claims/record_claims.h"
 #include "account_encoder.h"
 
 #include <algorithm>
@@ -95,6 +96,9 @@ bool encode(const state::AccountState& state, std::span<std::byte> output) noexc
     object.acquiredFlags = unlocks.accountFlags;
     object.profileUnlockFlags = unlocks.profileFlags;
     object.objectiveValues = unlocks.objectiveValues;
+    // Claims are laid over the authored bank on the way out, so a claimed record reads Acquired on
+    // the next image. The authored policy itself is immutable and is never edited.
+    (void)state::record_claims::apply(object.acquiredFlags);
     for (layout::CharacterUnlockBlock& block : object.characterUnlocks) {
         block.flags = unlocks.characterFlags;
     }

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

@@ -0,0 +1,385 @@
+#include <atomic>
+
+#include "record_claims.h"
+
+#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 "../build_data/nodes/definition.h"
+#include "../build_data/nodes/node_catalog.h"
+#include "../build_data/runtime.h"
+#include "../unlocks/definition.h"
+
+namespace sunrise::state::record_claims {
+namespace {
+
+/** One bit per addressable account flag index, which is cheaper than a set and never allocates. */
+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
+
+/** 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, 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;
+    }
+    const std::size_t word = static_cast<std::size_t>(flagIndex) / kWordBits;
+    const std::uint64_t bit = std::uint64_t{1} << (static_cast<std::size_t>(flagIndex) % kWordBits);
+    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;
+}
+
+/** Lays every held claim over one account flag bank. */
+std::size_t apply(std::span<std::uint8_t> accountFlags) noexcept {
+    std::size_t changed = 0;
+    const std::lock_guard<std::mutex> guard(g_lock);
+    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;
+            // A bank shorter than the index space is not an error: the tail simply is not sent.
+            if (index >= accountFlags.size()) {
+                continue;
+            }
+            if (accountFlags[index] != unlocks::kFlagSet) {
+                accountFlags[index] = unlocks::kFlagSet;
+                ++changed;
+            }
+        }
+    }
+    return changed;
+}
+
+namespace {
+
+/** Carries the bank and a tally through the node walk, which takes a plain function pointer. */
+struct NodeProgress {
+    std::span<std::int32_t> values;
+    std::size_t written;
+    /** One entry per value index, non-zero where a category's own bar reads. */
+    std::span<const char> categories;
+};
+
+/** True when this account flag bank row is held. The caller owns the claim lock. */
+[[nodiscard]] bool claimed_locked(std::uint16_t flagIndex) noexcept {
+    if (static_cast<std::size_t>(flagIndex) >= kIndexCapacity) {
+        return false;
+    }
+    const std::size_t word = static_cast<std::size_t>(flagIndex) / kWordBits;
+    const std::uint64_t bit = std::uint64_t{1} << (static_cast<std::size_t>(flagIndex) % kWordBits);
+    return (g_claimed[word] & bit) != 0;
+}
+
+} // namespace
+
+/**
+ * @return How many of a category's children are claimed. The caller owns the claim lock.
+ *
+ * Both passes count the same thing against different banks, so they share the count rather than
+ * each carrying a copy of the loop.
+ */
+[[nodiscard]] std::int32_t claimed_children(const build_data::nodes::Definition& node) noexcept {
+    std::int32_t claimed = 0;
+    for (std::size_t child = 0; child < node.childCount; ++child) {
+        build_data::records::Definition record{};
+        if (build_data::find_record_definition(node.children[child], record)
+            && record.completionFlagIndex != build_data::records::kUnavailableFlagIndex
+            && claimed_locked(record.completionFlagIndex)) {
+            ++claimed;
+        }
+    }
+    return claimed;
+}
+
+/**
+ * @return One when a category's parent record is itself claimed, otherwise zero.
+ *
+ * The parent is the child naming the category's own value slot. It sits in the category's count and
+ * not in its own bar's, so subtracting it is what separates the two totals. The caller owns the
+ * claim lock.
+ */
+[[nodiscard]] std::int32_t claimed_parent(const build_data::nodes::Definition& node) noexcept {
+    for (std::size_t child = 0; child < node.childCount; ++child) {
+        build_data::records::Definition record{};
+        if (build_data::find_record_definition(node.children[child], record)
+            && record.completionFlagIndex != build_data::records::kUnavailableFlagIndex
+            && record.categoryValueIndex == node.valueIndex
+            && claimed_locked(record.completionFlagIndex)) {
+            return 1;
+        }
+    }
+    return 0;
+}
+
+/** 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 {
+    // Every category's own index, so the walk can tell a free slot above a category from the next
+    // category along. Without it, writing the slot above drives whichever book owns that slot.
+    std::vector<char> categoryFlags(objectiveValues.size(), 0);
+    build_data::nodes::for_each(
+        &categoryFlags, [](void* context, const build_data::nodes::Definition& node) noexcept {
+            auto* flags = static_cast<std::vector<char>*>(context);
+            const std::uint16_t index = node.valueIndex;
+            if (index != build_data::nodes::kUnavailableValueIndex
+                && static_cast<std::size_t>(index) < flags->size()) {
+                (*flags)[index] = 1;
+            }
+        });
+
+    NodeProgress progress{objectiveValues, 0, std::span<const char>{categoryFlags}};
+    // 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.
+    const std::lock_guard<std::mutex> guard(g_lock);
+    build_data::nodes::for_each_driving(
+        &progress, [](void* context, const build_data::nodes::Definition& node) noexcept {
+            auto* state = static_cast<NodeProgress*>(context);
+            // Only the lore books are counted. Every other category that drives a bar keeps what
+            // the authored policy gave it: node 896 carries an authored -1 at its value index, and
+            // writing a count over it replaced a deliberate sentinel with a zero.
+            if (!build_data::nodes::lore_category(node.definitionIndex)) {
+                return;
+            }
+            // Two counts, because a category and its parent record keep separate bars. The category
+            // counts every child it owns, the parent among them; the parent's own bar counts only
+            // the chapters, which is why its denominator is one lower. The parent is the child
+            // naming the category's own value slot.
+            const std::int32_t claimed = claimed_children(node);
+            const std::int32_t claimedChapters = claimed - claimed_parent(node);
+            // The parent's bar sits one slot above its category on the books where that slot is
+            // free, confirmed by marker on two of them. Categories are allocated contiguously
+            // though, so for many books the slot above is the next book's category, and writing it
+            // drove the wrong book's bar. Those are skipped: a bar left at zero is wrong, a bar
+            // carrying another book's count is worse. Where those parents read is not yet known.
+            if (static_cast<std::size_t>(node.parentValueIndex) < state->values.size()
+                && state->categories[node.parentValueIndex] == 0) {
+                state->values[node.parentValueIndex] = claimedChapters;
+                ++state->written;
+            }
+            if (static_cast<std::size_t>(node.valueIndex) < state->values.size()) {
+                state->values[node.valueIndex] = claimed;
+                ++state->written;
+            }
+        });
+    return progress.written;
+}
+
+/** Writes each category's claimed-child count into the character value slot its bar reads. */
+std::size_t apply_character_node_progress(std::span<std::int32_t> characterValues) noexcept {
+    NodeProgress progress{characterValues, 0, {}};
+    // Same lock order as the account pass: claims first, catalog inside the walk.
+    const std::lock_guard<std::mutex> guard(g_lock);
+    build_data::nodes::for_each(
+        &progress, [](void* context, const build_data::nodes::Definition& node) noexcept {
+            auto* state = static_cast<NodeProgress*>(context);
+            if (!build_data::nodes::lore_category(node.definitionIndex)
+                || node.characterValueIndex == build_data::nodes::kUnavailableValueIndex
+                || static_cast<std::size_t>(node.characterValueIndex) >= state->values.size()) {
+                return;
+            }
+            state->values[node.characterValueIndex] = claimed_children(node);
+            ++state->written;
+        });
+    return progress.written;
+}
+
+/** @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 held. */
+std::size_t count() noexcept {
+    const std::lock_guard<std::mutex> guard(g_lock);
+    return g_count;
+}
+
+} // namespace sunrise::state::record_claims

+ 74 - 0
Sunrise/src/state/record_claims/record_claims.h

@@ -0,0 +1,74 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <span>
+
+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 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 held claim, in memory only. The file is left alone. */
+void clear() noexcept;
+
+/**
+ * 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;
+
+/**
+ * Lays every held claim over one account flag bank.
+ * @param accountFlags Bank already filled from the authored policy.
+ * @return Number of bytes this changed, so a caller can tell a no-op from real work.
+ */
+std::size_t apply(std::span<std::uint8_t> accountFlags) noexcept;
+
+/**
+ * Writes each presentation node's claimed-child count into the value slot its bar reads.
+ *
+ * A node's progress bar is not derived by the client from its children: the node names a value slot
+ * and shows whatever it holds. Counting here is what makes claiming a chapter move its book.
+ * @param objectiveValues Account value bank, already filled from the authored policy.
+ * @return Number of nodes whose slot was written.
+ */
+std::size_t apply_node_progress(std::span<std::int32_t> objectiveValues) noexcept;
+
+/** @return True when this index is already held. */
+[[nodiscard]] bool claimed(std::uint16_t flagIndex) noexcept;
+
+/**
+ * Writes each category's claimed-child count into the character value slot its bar reads.
+ * One book counts in the character bank rather than the account one.
+ * @param characterValues Character value bank, already filled from the authored policy.
+ * @return Number of categories written.
+ */
+std::size_t apply_character_node_progress(std::span<std::int32_t> characterValues) noexcept;
+
+/** @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

@@ -1,3 +1,4 @@
+#include "../record_claims/record_claims.h"
 #include <Windows.h>
 
 #include <algorithm>
@@ -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.