فهرست منبع

Reveal every lore book category and seed one real chapter in each

A lore category is gated: some read a flag, some test their own progress value
against zero. A book gated on progress cannot open by being played, because with
no title shown there is nothing inside to claim, and nothing claimed leaves the
value at zero. Both gates are now resolved at extraction and satisfied from
state, and each book has its first chapter claimed for real, through the claim
path, so the flag, the score and the count stay in agreement.

The parent is skipped by position: a book's first triumph is the one that counts
the rest. Naming it by the category slot it carries is not enough on its own,
since that field resolves on only eleven records in the whole table.

Three assumptions had to be corrected along the way, each of which made a real
thing look absent:

  - the expression capacity was 32, a guess written as a fact. One book carries
    59 instructions, so its gate was rejected before it was read.
  - gates are not all account scoped. One names a flag only the character table
    carries, another a value only the character table carries, so both banks are
    resolved and whichever claims the slot is the one satisfied.
  - the range ended at 853, counted from the game's list, and was one short.
    Node 854 carries the same gate shape as every other book while 855 onward do
    not, so the bound now comes from the rows rather than from counting.

Verified: every lore book across all three headers is named and counted.
Millie 2 هفته پیش
والد
کامیت
1c7d63b969

+ 298 - 0
Sunrise/src/client/content/items/packages/package_node_build.cpp

@@ -64,6 +64,47 @@ void report(const char* stage, unsigned long long detail) noexcept {
     return false;
 }
 
+/** Reads the flag slot one expression tests, or reports that it tests none. */
+[[nodiscard]] bool expression_flag_slot(std::span<const std::byte> table,
+                                        std::size_t rowAt,
+                                        std::size_t field,
+                                        std::int16_t& slot) noexcept {
+    std::int64_t count = 0;
+    std::int64_t relative = 0;
+    std::memcpy(&count, table.data() + rowAt + field, sizeof count);
+    std::memcpy(&relative, table.data() + rowAt + field + 8, sizeof relative);
+    if (count < 1 || count > tables::kNodeExpressionCapacity) {
+        return false;
+    }
+    const std::size_t pointerAt = rowAt + field + 8;
+    const std::int64_t target = static_cast<std::int64_t>(pointerAt) + relative
+                                + static_cast<std::int64_t>(tables::kHeaderSkip);
+    if (target < 0
+        || static_cast<std::size_t>(target)
+                   + static_cast<std::size_t>(count) * tables::kUnlockInstructionStride
+               > table.size()) {
+        return false;
+    }
+    const auto base = static_cast<std::size_t>(target);
+    for (std::int64_t index = 0; index < count; ++index) {
+        std::uint32_t opcode = 0;
+        std::uint32_t operand = 0;
+        const std::size_t at =
+            base + static_cast<std::size_t>(index) * tables::kUnlockInstructionStride;
+        std::memcpy(&opcode, table.data() + at, sizeof opcode);
+        std::memcpy(&operand, table.data() + at + 4, sizeof operand);
+        if (opcode > tables::kUnlockOpcodeCeiling) {
+            return false;
+        }
+        if (opcode == tables::kUnlockReadFlagOpcode
+            && operand <= static_cast<std::uint32_t>(INT16_MAX)) {
+            slot = static_cast<std::int16_t>(operand);
+            return true;
+        }
+    }
+    return false;
+}
+
 } // namespace
 
 /**
@@ -81,6 +122,91 @@ bool build_nodes(const reader::Source& source,
     namespace domain = state::build_data::nodes;
     count = 0;
 
+    // The account flag mapping table, read first. A category's gate names a flag slot, and a slot
+    // is not an index: the byte that feeds it sits at the row whose destination is that slot.
+    std::uint32_t flagMapTag = 0;
+    tables::Array flagMapRows{};
+    std::unordered_map<std::int16_t, std::uint16_t> flagIndexBySlot{};
+    if (tables::slot_tag(root, tables::kUnlockFlagMapTableSlot, flagMapTag) && flagMapTag != 0
+        && tables::package_of(flagMapTag) != tables::kAbsentPackageId
+        && reader::read_tag(source, scratch, flagMapTag, blob)
+        && tables::find_array_at(
+            std::span<const std::byte>{blob}, tables::kAccountFlagMapDescriptor, flagMapRows)
+        && flagMapRows.count != 0
+        && flagMapRows.dataOffset
+                   + static_cast<std::size_t>(flagMapRows.count) * tables::kUnlockMapRowStride
+               <= blob.size()) {
+        for (std::uint64_t row = 0; row < flagMapRows.count && row <= domain::kUnavailableFlagIndex;
+             ++row) {
+            std::int16_t slot = 0;
+            std::memcpy(&slot,
+                        blob.data() + flagMapRows.dataOffset
+                            + static_cast<std::size_t>(row) * tables::kUnlockMapRowStride
+                            + tables::kUnlockMapDestinationSlotOffset,
+                        sizeof slot);
+            flagIndexBySlot.emplace(slot, static_cast<std::uint16_t>(row));
+        }
+    }
+
+    // TEMPORARY PROBE: one lore gate resolves to no account row. Root slot 111 holds several flag
+    // mapping tables and only the account one has been read, so the gate is likely in another scope
+    // rather than absent. Enumerate every descriptor that yields a table and say where 14907 lands.
+    for (std::size_t descriptor = 0; descriptor <= 72; descriptor += 8) {
+        tables::Array probeRows{};
+        if (!tables::find_array_at(std::span<const std::byte>{blob}, descriptor, probeRows)
+            || probeRows.count == 0
+            || probeRows.dataOffset
+                       + static_cast<std::size_t>(probeRows.count) * tables::kUnlockMapRowStride
+                   > blob.size()) {
+            continue;
+        }
+        int found = -1;
+        for (std::uint64_t row = 0; row < probeRows.count; ++row) {
+            std::int16_t slot = 0;
+            std::memcpy(&slot,
+                        blob.data() + probeRows.dataOffset
+                            + static_cast<std::size_t>(row) * tables::kUnlockMapRowStride
+                            + tables::kUnlockMapDestinationSlotOffset,
+                        sizeof slot);
+            if (slot == static_cast<std::int16_t>(14907)) {
+                found = static_cast<int>(row);
+                break;
+            }
+        }
+        std::array<char, 200> line{};
+        const int told = std::snprintf(line.data(), line.size(),
+                                       "ev=fmapscope descriptor=%zu rows=%llu slot14907_index=%d",
+                                       descriptor,
+                                       static_cast<unsigned long long>(probeRows.count), found);
+        if (told > 0) {
+            core::log::write(core::log::Channel::client, core::log::Level::info,
+                             {line.data(), static_cast<std::size_t>(told)});
+        }
+    }
+
+    tables::Array characterFlagMapRows{};
+    std::unordered_map<std::int16_t, std::uint16_t> characterFlagIndexBySlot{};
+    if (flagMapTag != 0
+        && tables::find_array_at(std::span<const std::byte>{blob},
+                                 tables::kCharacterFlagMapDescriptor,
+                                 characterFlagMapRows)
+        && characterFlagMapRows.count != 0
+        && characterFlagMapRows.dataOffset
+                   + static_cast<std::size_t>(characterFlagMapRows.count)
+                         * tables::kUnlockMapRowStride
+               <= blob.size()) {
+        for (std::uint64_t row = 0;
+             row < characterFlagMapRows.count && row <= domain::kUnavailableFlagIndex; ++row) {
+            std::int16_t slot = 0;
+            std::memcpy(&slot,
+                        blob.data() + characterFlagMapRows.dataOffset
+                            + static_cast<std::size_t>(row) * tables::kUnlockMapRowStride
+                            + tables::kUnlockMapDestinationSlotOffset,
+                        sizeof slot);
+            characterFlagIndexBySlot.emplace(slot, static_cast<std::uint16_t>(row));
+        }
+    }
+
     std::uint32_t mapTag = 0;
     tables::Array mapRows{};
     if (!tables::slot_tag(root, tables::kUnlockValueMapTableSlot, mapTag) || mapTag == 0
@@ -106,6 +232,63 @@ bool build_nodes(const reader::Source& source,
         indexBySlot.emplace(slot, static_cast<std::uint16_t>(row));
     }
 
+    // TEMPORARY PROBE: one lore book's gate reads a value slot that the account map does not
+    // carry, so it is scoped elsewhere. Say which table holds it.
+    for (std::size_t descriptor = 0; descriptor <= 72; descriptor += 8) {
+        tables::Array probeRows{};
+        if (!tables::find_array_at(std::span<const std::byte>{blob}, descriptor, probeRows)
+            || probeRows.count == 0
+            || probeRows.dataOffset
+                       + static_cast<std::size_t>(probeRows.count) * tables::kUnlockMapRowStride
+                   > blob.size()) {
+            continue;
+        }
+        int found = -1;
+        for (std::uint64_t row = 0; row < probeRows.count; ++row) {
+            std::int16_t slot = 0;
+            std::memcpy(&slot,
+                        blob.data() + probeRows.dataOffset
+                            + static_cast<std::size_t>(row) * tables::kUnlockMapRowStride
+                            + tables::kUnlockMapDestinationSlotOffset,
+                        sizeof slot);
+            if (slot == static_cast<std::int16_t>(8275)) {
+                found = static_cast<int>(row);
+                break;
+            }
+        }
+        std::array<char, 200> line{};
+        const int told = std::snprintf(line.data(), line.size(),
+                                       "ev=vmapscope descriptor=%zu rows=%llu slot8275_index=%d",
+                                       descriptor,
+                                       static_cast<unsigned long long>(probeRows.count), found);
+        if (told > 0) {
+            core::log::write(core::log::Channel::client, core::log::Level::info,
+                             {line.data(), static_cast<std::size_t>(told)});
+        }
+    }
+
+    tables::Array characterValueMapRows{};
+    std::unordered_map<std::int16_t, std::uint16_t> characterValueIndexBySlot{};
+    if (tables::find_array_at(std::span<const std::byte>{blob},
+                              tables::kCharacterValueMapDescriptor,
+                              characterValueMapRows)
+        && characterValueMapRows.count != 0
+        && characterValueMapRows.dataOffset
+                   + static_cast<std::size_t>(characterValueMapRows.count)
+                         * tables::kUnlockMapRowStride
+               <= blob.size()) {
+        for (std::uint64_t row = 0;
+             row < characterValueMapRows.count && row <= domain::kUnavailableValueIndex; ++row) {
+            std::int16_t slot = 0;
+            std::memcpy(&slot,
+                        blob.data() + characterValueMapRows.dataOffset
+                            + static_cast<std::size_t>(row) * tables::kUnlockMapRowStride
+                            + tables::kUnlockMapDestinationSlotOffset,
+                        sizeof slot);
+            characterValueIndexBySlot.emplace(slot, static_cast<std::uint16_t>(row));
+        }
+    }
+
     std::uint32_t tableTag = 0;
     tables::Array rows{};
     if (!tables::slot_tag(root, tables::kPresentationNodeTableSlot, tableTag) || tableTag == 0
@@ -142,6 +325,10 @@ bool build_nodes(const reader::Source& source,
             // The parent record's own bar reads the next slot up. Resolve it through the mapping
             // table rather than adding one to the index: rows happen to run in slot order around
             // here, but nothing guarantees that.
+            const auto characterSlot = characterValueIndexBySlot.find(slot);
+            if (characterSlot != characterValueIndexBySlot.end()) {
+                definition.characterValueIndex = characterSlot->second;
+            }
             const auto parent = indexBySlot.find(
                 static_cast<std::int16_t>(slot + tables::kNodeParentSlotStep));
             if (parent != indexBySlot.end()) {
@@ -149,6 +336,117 @@ bool build_nodes(const reader::Source& source,
             }
         }
 
+        // A category gated on a flag rather than on its own progress cannot reveal itself by being
+        // played: with no title shown there is nothing inside to claim, and nothing to claim leaves
+        // the gate shut. Resolve that flag so the gate can be satisfied.
+        std::int16_t gateSlot = 0;
+        if (expression_flag_slot(table, at, tables::kNodeExpressionFieldPrimary, gateSlot)
+            || expression_flag_slot(table, at, tables::kNodeExpressionFieldAlternate, gateSlot)) {
+            const auto gate = flagIndexBySlot.find(gateSlot);
+            if (gate != flagIndexBySlot.end()) {
+                definition.visibilityFlagIndex = gate->second;
+            }
+            const auto characterGate = characterFlagIndexBySlot.find(gateSlot);
+            if (characterGate != characterFlagIndexBySlot.end()) {
+                definition.visibilityCharacterFlagIndex = characterGate->second;
+            }
+        }
+
+        // TEMPORARY PROBE: sweep the whole row, every four byte offset, not a hand written list
+        // of fields. The previous sweep covered sixteen to one hundred and twenty eight in steps of
+        // eight and so never looked at the first two fields or anything past the child array, which
+        // is most of the reason a gate could still be sitting somewhere unread.
+        if (row == 819 || row == 838) {
+            for (std::size_t field = 0; field + 16 <= tables::kNodeRowStride; field += 4) {
+                std::int64_t count = 0;
+                std::int64_t relative = 0;
+                std::memcpy(&count, table.data() + at + field, sizeof count);
+                std::memcpy(&relative, table.data() + at + field + 8, sizeof relative);
+                if (count < 1 || count > 4096 || relative == 0) {
+                    continue;
+                }
+                const std::size_t pointerAt = at + field + 8;
+                const std::int64_t target = static_cast<std::int64_t>(pointerAt) + relative
+                                            + static_cast<std::int64_t>(tables::kHeaderSkip);
+                if (target < 0
+                    || static_cast<std::size_t>(target)
+                               + static_cast<std::size_t>(count) * tables::kUnlockInstructionStride
+                           > table.size()) {
+                    continue;
+                }
+
+                // Print the stream itself. A field carrying a plausible count and a target inside
+                // the table is worth reading whatever its offset, and the opcodes say at once
+                // whether it is an expression or an array being misread as one.
+                const auto base = static_cast<std::size_t>(target);
+                const std::int64_t shown = count < 8 ? count : 8;
+                for (std::int64_t step = 0; step < shown; ++step) {
+                    std::uint32_t opcode = 0;
+                    std::uint32_t operand = 0;
+                    const std::size_t stepAt =
+                        base + static_cast<std::size_t>(step) * tables::kUnlockInstructionStride;
+                    std::memcpy(&opcode, table.data() + stepAt, sizeof opcode);
+                    std::memcpy(&operand, table.data() + stepAt + 4, sizeof operand);
+                    std::array<char, 200> line{};
+                    const int told = std::snprintf(
+                        line.data(), line.size(),
+                        "ev=fullsweep node=%llu field=%zu count=%lld step=%lld opcode=%u operand=%u",
+                        static_cast<unsigned long long>(row), field,
+                        static_cast<long long>(count), static_cast<long long>(step), opcode,
+                        operand);
+                    if (told > 0) {
+                        core::log::write(core::log::Channel::client, core::log::Level::info,
+                                         {line.data(), static_cast<std::size_t>(told)});
+                    }
+                }
+            }
+        }
+
+        // TEMPORARY PROBE: three lore books resolve no gate at all, yet the client redacts them,
+        // so something gates them that is neither a flag read nor a value read in the two fields
+        // checked. Sweep every aligned field of those rows and dump whatever parses as an
+        // expression, opcode and operand, rather than guessing which field or opcode it uses.
+        if (row == 819 || row == 837 || row == 838) {
+            for (std::size_t field = 0; field + 16 <= tables::kNodeRowStride; field += 4) {
+                std::int64_t instructions = 0;
+                std::int64_t relative = 0;
+                std::memcpy(&instructions, table.data() + at + field, sizeof instructions);
+                std::memcpy(&relative, table.data() + at + field + 8, sizeof relative);
+                if (instructions < 1 || instructions > tables::kNodeExpressionCapacity) {
+                    continue;
+                }
+                const std::size_t pointerAt = at + field + 8;
+                const std::int64_t target = static_cast<std::int64_t>(pointerAt) + relative
+                                            + static_cast<std::int64_t>(tables::kHeaderSkip);
+                if (target < 0
+                    || static_cast<std::size_t>(target)
+                               + static_cast<std::size_t>(instructions)
+                                     * tables::kUnlockInstructionStride
+                           > table.size()) {
+                    continue;
+                }
+                const auto base = static_cast<std::size_t>(target);
+                for (std::int64_t step = 0; step < instructions; ++step) {
+                    std::uint32_t opcode = 0;
+                    std::uint32_t operand = 0;
+                    const std::size_t stepAt =
+                        base + static_cast<std::size_t>(step) * tables::kUnlockInstructionStride;
+                    std::memcpy(&opcode, table.data() + stepAt, sizeof opcode);
+                    std::memcpy(&operand, table.data() + stepAt + 4, sizeof operand);
+                    std::array<char, 180> line{};
+                    const int told = std::snprintf(
+                        line.data(), line.size(),
+                        "ev=noroute node=%llu field=%zu step=%lld opcode=%u operand=%u",
+                        static_cast<unsigned long long>(row), field,
+                        static_cast<long long>(step), opcode, operand);
+                    if (told > 0) {
+                        core::log::write(core::log::Channel::client, core::log::Level::info,
+                                         {line.data(), static_cast<std::size_t>(told)});
+                    }
+                }
+            }
+        }
+
         // Records the node owns, four bytes each as a row and a gate.
         std::int64_t childCount = 0;
         std::int64_t childRelative = 0;

+ 15 - 2
Sunrise/src/middleware/content/packages/tables/definition_index_table.h

@@ -111,8 +111,15 @@ inline constexpr std::size_t kNodeExpressionFieldAlternate = 48;
 /** Records a node owns, four bytes each as a row then a gate. */
 inline constexpr std::size_t kNodeChildRecordField = 136;
 inline constexpr std::size_t kNodeChildRecordStride = 4;
-/** No node expression seen is longer than this. */
-inline constexpr std::int64_t kNodeExpressionCapacity = 32;
+/**
+ * Upper bound on how many instructions a node expression may hold.
+ *
+ * This was thirty-two, which was a guess rather than a measurement, and it silently hid a real gate:
+ * one lore book carries fifty-nine instructions, so its expression was rejected before it was read
+ * and the category looked as though nothing gated it at all. The bound exists only to stop a wild
+ * count being walked as if it were an expression, so it is set well above anything observed.
+ */
+inline constexpr std::int64_t kNodeExpressionCapacity = 128;
 
 /** One unlock expression instruction: an opcode then its operand. */
 inline constexpr std::size_t kUnlockInstructionStride = 8;
@@ -120,6 +127,8 @@ inline constexpr std::size_t kUnlockInstructionStride = 8;
 inline constexpr std::uint32_t kUnlockOpcodeCeiling = 20;
 /** The opcode that reads a value slot. */
 inline constexpr std::uint32_t kUnlockReadValueOpcode = 10;
+/** The opcode that tests a flag. */
+inline constexpr std::uint32_t kUnlockReadFlagOpcode = 1;
 /** Array payloads begin after a sixteen byte header. */
 inline constexpr std::size_t kHeaderSkip = 16;
 
@@ -127,11 +136,15 @@ inline constexpr std::size_t kHeaderSkip = 16;
 inline constexpr std::size_t kUnlockValueMapTableSlot = 113;
 /** Array descriptor of the account object's value mapping table. */
 inline constexpr std::size_t kAccountValueMapDescriptor = 8;
+/** Array descriptor of the character object's value mapping table, sized to that bank. */
+inline constexpr std::size_t kCharacterValueMapDescriptor = 24;
 
 /** Investment root slot of the five unlock flag mapping tables. */
 inline constexpr std::size_t kUnlockFlagMapTableSlot = 111;
 /** Array descriptor of the account object's flag mapping table, the bank flag runs author. */
 inline constexpr std::size_t kAccountFlagMapDescriptor = 8;
+/** Array descriptor of the character object's flag mapping table, sized to that bank. */
+inline constexpr std::size_t kCharacterFlagMapDescriptor = 40;
 /** One mapping row: the unlock hash, then the destination slot its object byte feeds. */
 inline constexpr std::size_t kUnlockMapRowStride = 8;
 /** Destination slot offset inside one mapping row. */

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

@@ -1,3 +1,6 @@
+#include <atomic>
+
+#include "../../../../state/build_data/nodes/node_catalog.h"
 #include "account_encoder.h"
 
 #include <algorithm>
@@ -97,7 +100,18 @@ bool encode(const state::AccountState& state, std::span<std::byte> output) noexc
     object.acquiredFlags = unlocks.accountFlags;
     // Claims arrive after boot, so they cannot be in the authored policy. Lay them over the
     // bank here, which is the one place every Family-4 account image passes through.
+    // Seeded once the node and record tables are up, which is why it hangs off the image rather
+    // than startup. Latches, so a run whose books are already open pays nothing.
+    static std::atomic<bool> loreSeeded{false};
+    if (!loreSeeded.load(std::memory_order_relaxed)
+        && state::build_data::nodes::count() != 0) {
+        (void)state::record_claims::seed_lore_visibility();
+        loreSeeded.store(true, std::memory_order_relaxed);
+    }
     (void)state::record_claims::apply(object.acquiredFlags);
+    // Lore book categories are gated on a flag they cannot set by being played: with no title shown
+    // there is nothing inside to claim, and nothing to claim leaves the gate shut.
+    (void)state::build_data::nodes::apply_visibility(object.acquiredFlags);
     object.profileUnlockFlags = unlocks.profileFlags;
     object.objectiveValues = unlocks.objectiveValues;
     // Triumph Score is a plain replicated value the client never derives, so total the claims made

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

@@ -1,3 +1,5 @@
+#include "../../../../state/record_claims/record_claims.h"
+#include "../../../../state/build_data/nodes/node_catalog.h"
 #include <atomic>
 
 #include "../../../../state/build_data/nodes/node_persistence.h"
@@ -171,6 +173,12 @@ bool encode(const state::CharacterState& state,
         }
     }
 
+    // One lore book counts in the character bank rather than the account one.
+    (void)state::record_claims::apply_character_node_progress(object.objectiveValues);
+
+    // One lore book's gate is character scoped rather than account scoped.
+    (void)state::build_data::nodes::apply_character_visibility(object.acquiredFlags);
+
     for (std::size_t index = 0; index < object.objectiveValues.size(); ++index) {
         object.objectiveValues[index] =
             index < unlocks.characterObjectValues.size() ? unlocks.characterObjectValues[index] : 0;

+ 40 - 0
Sunrise/src/state/build_data/nodes/definition.h

@@ -15,6 +15,22 @@ inline constexpr std::size_t kChildCapacity = 64;
 /** A node whose expression names no addressable value slot carries this instead of an index. */
 inline constexpr std::uint16_t kUnavailableValueIndex = 0xFFFFU;
 
+/**
+ * The lore book categories: 815 to 829 under The Light, 831 to 843 under The Darkness, and 845 to
+ * 854 under Dusk and Dawn.
+ *
+ * The upper bound was 853, counted from the game's own list, and it was one short: node 854 carries
+ * the same gate every other book carries, a value read tested against zero, while 855 onward pair
+ * two flag reads or use a different opcode entirely. The end of the range is therefore taken from
+ * the shape of the rows rather than from counting entries on screen. Only these nodes have their
+ * visibility gate satisfied.
+ */
+inline constexpr std::uint16_t kLoreNodeFirst = 815U;
+inline constexpr std::uint16_t kLoreNodeLast = 854U;
+
+/** A node whose gate names no addressable flag carries this instead of an index. */
+inline constexpr std::uint16_t kUnavailableFlagIndex = 0xFFFFU;
+
 /**
  * One presentation node reduced to what a progress bar needs.
  *
@@ -35,6 +51,30 @@ struct Definition {
      * the chapters. They are separate slots and need separate counts.
      */
     std::uint16_t parentValueIndex{kUnavailableValueIndex};
+    /**
+     * Account flag index this node's own gate reads, or kUnavailableFlagIndex.
+     *
+     * A category with a redacted title is not waiting on progress: its expression reads a flag, and
+     * until that flag is set the client shows no name and offers nothing inside to claim. A book
+     * gated this way cannot reveal itself by being played, so the gate is satisfied here.
+     */
+    std::uint16_t visibilityFlagIndex{kUnavailableFlagIndex};
+    /**
+     * Character flag index this node's gate reads, or kUnavailableFlagIndex.
+     *
+     * Not every gate is account scoped. One lore book names a slot that no account mapping row
+     * carries at all, and the same slot sits in the character table instead, so a gate is resolved
+     * against both banks and satisfied in whichever one claims it.
+     */
+    std::uint16_t visibilityCharacterFlagIndex{kUnavailableFlagIndex};
+    /**
+     * Character value index this node's bar reads, or kUnavailableValueIndex.
+     *
+     * Not every category counts in the account bank. One lore book reads a slot that only the
+     * character table carries, so it stayed redacted while every other book opened: its count had
+     * nowhere to go.
+     */
+    std::uint16_t characterValueIndex{kUnavailableValueIndex};
     /** Records this node owns, held at node row `+136`. */
     std::uint8_t childCount{};
     /** Native record rows of the owned records. */

+ 47 - 0
Sunrise/src/state/build_data/nodes/node_catalog.cpp

@@ -1,5 +1,9 @@
 #include "node_catalog.h"
+#include "../../../core/logging/log.h"
+#include <cstdio>
+#include <array>
 
+#include "../../unlocks/definition.h"
 #include "../table.h"
 
 namespace sunrise::state::build_data::nodes {
@@ -65,4 +69,47 @@ std::size_t count() noexcept {
     return g_definitions.count();
 }
 
+/** Sets the visibility gate of every lore book category. */
+std::size_t apply_visibility(std::span<std::uint8_t> accountFlags) noexcept {
+    const Lock::Shared guard(g_lock);
+    std::size_t set = 0;
+    for (const Definition& node : g_definitions.rows()) {
+        if (node.definitionIndex < kLoreNodeFirst || node.definitionIndex > kLoreNodeLast
+            || node.visibilityFlagIndex == kUnavailableFlagIndex
+            || static_cast<std::size_t>(node.visibilityFlagIndex) >= accountFlags.size()) {
+            continue;
+        }
+        accountFlags[node.visibilityFlagIndex] = unlocks::kFlagSet;
+        ++set;
+    }
+    return set;
+}
+
+/** Sets the character scoped visibility gates of the lore book categories. */
+std::size_t apply_character_visibility(std::span<std::byte> characterFlags) noexcept {
+    const Lock::Shared guard(g_lock);
+    std::size_t set = 0;
+    for (const Definition& node : g_definitions.rows()) {
+        if (node.definitionIndex < kLoreNodeFirst || node.definitionIndex > kLoreNodeLast
+            || node.visibilityCharacterFlagIndex == kUnavailableFlagIndex
+            || static_cast<std::size_t>(node.visibilityCharacterFlagIndex)
+                   >= characterFlags.size()) {
+            continue;
+        }
+        characterFlags[node.visibilityCharacterFlagIndex] =
+            static_cast<std::byte>(unlocks::kFlagSet);
+        ++set;
+    }
+    // TEMPORARY: say whether the character scoped gates are actually being written. This is the one
+    // fix in the batch never confirmed to land, and a silent no-op looks identical to a wrong index.
+    std::array<char, 160> line{};
+    const int written = std::snprintf(line.data(), line.size(),
+                                      "ev=charvis set=%zu bank=%zu", set, characterFlags.size());
+    if (written > 0) {
+        core::log::write(core::log::Channel::state, core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+    return set;
+}
+
 } // namespace sunrise::state::build_data::nodes

+ 14 - 0
Sunrise/src/state/build_data/nodes/node_catalog.h

@@ -43,4 +43,18 @@ void for_each_driving(void* context,
 /** @return Number of generated node definitions, read under the lock. */
 [[nodiscard]] std::size_t count() noexcept;
 
+/**
+ * Sets the visibility gate of every lore book category over one account flag bank.
+ * @param accountFlags Bank already filled from the authored policy.
+ * @return Number of gates set.
+ */
+std::size_t apply_visibility(std::span<std::uint8_t> accountFlags) noexcept;
+
+/**
+ * Sets the character scoped visibility gates of the lore book categories.
+ * @param characterFlags Character bank already filled from the authored policy.
+ * @return Number of gates set.
+ */
+std::size_t apply_character_visibility(std::span<std::byte> characterFlags) noexcept;
+
 } // namespace sunrise::state::build_data::nodes

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

@@ -324,6 +324,104 @@ std::size_t apply_node_progress(std::span<std::int32_t> objectiveValues) noexcep
     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 {
+    namespace nodes = build_data::nodes;
+    std::vector<nodes::Definition> rows(nodes::kDefinitionCapacity);
+    std::size_t count = 0;
+    if (!nodes::snapshot(std::span<nodes::Definition>{rows}, count)) {
+        return 0;
+    }
+
+    const std::lock_guard<std::mutex> guard(g_lock);
+    std::size_t written = 0;
+    for (std::size_t row = 0; row < count; ++row) {
+        const nodes::Definition& node = rows[row];
+        if (node.characterValueIndex == nodes::kUnavailableValueIndex
+            || static_cast<std::size_t>(node.characterValueIndex) >= characterValues.size()) {
+            continue;
+        }
+        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;
+            }
+        }
+        characterValues[node.characterValueIndex] = claimed;
+        ++written;
+    }
+    return written;
+}
+
+/** @return True when this index is already held. */
+bool claimed(std::uint16_t flagIndex) noexcept {
+    const std::lock_guard<std::mutex> guard(g_lock);
+    return claimed_locked(flagIndex);
+}
+
+/** Claims the first chapter of every lore book still showing a redacted title. */
+std::size_t seed_lore_visibility() noexcept {
+    namespace nodes = build_data::nodes;
+    std::vector<nodes::Definition> rows(nodes::kDefinitionCapacity);
+    std::size_t count = 0;
+    if (!nodes::snapshot(std::span<nodes::Definition>{rows}, count)) {
+        return 0;
+    }
+
+    std::size_t seeded = 0;
+    for (std::size_t row = 0; row < count; ++row) {
+        const nodes::Definition& node = rows[row];
+        if (node.definitionIndex < nodes::kLoreNodeFirst
+            || node.definitionIndex > nodes::kLoreNodeLast
+            || node.childCount == 0) {
+            continue;
+        }
+
+        // A book that already holds a claim is past its gate and is left as it is.
+        bool held = false;
+        for (std::size_t child = 0; child < node.childCount && !held; ++child) {
+            build_data::records::Definition record{};
+            if (build_data::find_record_definition(node.children[child], record)
+                && record.completionFlagIndex != build_data::records::kUnavailableFlagIndex) {
+                held = claimed(record.completionFlagIndex);
+            }
+        }
+        if (held) {
+            continue;
+        }
+
+        // Skip the first child: a book's first triumph is its parent, the one that counts the
+        // rest, and claiming it would not move the category's bar. Naming the parent by the slot it
+        // carries is not enough on its own, because that field resolves on only eleven records in
+        // the whole table, so position is what actually identifies it and the slot check backs it up.
+        for (std::size_t child = 1; 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
+                || (node.valueIndex != nodes::kUnavailableValueIndex
+                    && record.categoryValueIndex == node.valueIndex)) {
+                continue;
+            }
+            if (claim(record.completionFlagIndex, record.scoreValue)) {
+                ++seeded;
+            }
+            break;
+        }
+    }
+
+    std::array<char, 128> line{};
+    const int written = std::snprintf(
+        line.data(), line.size(), "ev=claims stage=lore_seed result=ok entries=%zu", seeded);
+    if (written > 0) {
+        core::log::write(core::log::Channel::state, core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+    return seeded;
+}
+
 /** @return Total score of every held claim. */
 std::uint32_t total_score() noexcept {
     const std::lock_guard<std::mutex> guard(g_lock);

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

@@ -54,6 +54,30 @@ std::size_t apply(std::span<std::uint8_t> accountFlags) noexcept;
  */
 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;
+
+/**
+ * Claims the first chapter of every lore book still showing a redacted title.
+ *
+ * These categories are gated on their own progress: the client shows no name until the value slot
+ * their bar reads is above zero, and with no name there is nothing inside to claim, so the gate
+ * cannot open by being played. Claiming one real chapter opens it. A real claim is used rather than
+ * a written value so the flag, the score and the count stay in agreement, exactly as they would had
+ * the chapter been claimed by hand. A book that already holds a claim is left alone.
+ * @return Number of books seeded.
+ */
+std::size_t seed_lore_visibility() noexcept;
+
+/**
+ * Writes each category's claimed-child count into the character value slot its bar reads.
+ * A category counting in the character bank has nowhere to put its count otherwise, and stays
+ * redacted while every other book opens.
+ * @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;