Sfoglia il codice sorgente

Bring the nodes domain onto the collectible branch

A pickup unlocks a chapter, and a chapter is a record owned by a presentation
node. Records alone were not enough: five searches found no value shared between
a collectible, its item and any record row or payload, so the route to a chapter
may run through the node that owns it.

The nodes domain, its extraction and the child record lists come across from the
triumph branch. Node persistence stays behind; extraction runs each launch here,
which is what the triumph branch did before the cache gap was found.
Millie 2 settimane fa
parent
commit
8eb39cee99

+ 5 - 0
Sunrise/Sunrise.vcxproj

@@ -826,6 +826,9 @@
     <ClCompile Include="src\client\content\items\packages\package_record_build.cpp" />
     <ClCompile Include="src\state\build_data\records\record_build_data_runtime.cpp" />
     <ClCompile Include="src\state\build_data\records\record_catalog.cpp" />
+    <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" />
   </ItemGroup>
   <ItemGroup Condition="'$(SunriseRunClangTidy)'=='true'">
     <ClCompile Remove="vendor\detours\detours.cpp" />
@@ -1434,6 +1437,8 @@
     <ClInclude Include="src\middleware\content\packages\tables\unlock_expression.h" />
     <ClInclude Include="src\state\build_data\records\definition.h" />
     <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" />
   </ItemGroup>
   <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
 </Project>

+ 21 - 0
Sunrise/src/client/content/items/packages/internal.h

@@ -55,6 +55,10 @@ inline constexpr std::size_t kContainerCandidates = 16;
 /** Lock-owned storage kept off the caller stack, shared by every stage of the pass. */
 struct Storage {
     reader::Scratch scratch{};
+    /** Node rows held until the value slot and owned records are resolved. */
+    std::array<state::build_data::nodes::Definition,
+               state::build_data::nodes::kDefinitionCapacity>
+        nodeRows{};
     /** Record rows held until the completion flag mapping is resolved. */
     std::array<state::build_data::records::Definition,
                state::build_data::records::kDefinitionCapacity>
@@ -357,6 +361,23 @@ void report(std::size_t published, const char* reason) noexcept;
  * @param itemDefinitionCount Number of rows in the installed item index table.
  * @return True when every tag, class, bound, and item link validates and publishes.
  */
+/**
+ * Reads the presentation node table and resolves each node's value slot and owned records.
+ * @param source Package source.
+ * @param scratch Reader scratch.
+ * @param root Investment root bytes.
+ * @param blob Scratch storage for the tables.
+ * @param output Row storage in native node order.
+ * @param count Receives the number of rows read.
+ * @return True when both tables read and every row fits.
+ */
+[[nodiscard]] bool build_nodes(const reader::Source& source,
+                               reader::Scratch& scratch,
+                               std::span<const std::byte> root,
+                               std::vector<std::byte>& blob,
+                               std::span<state::build_data::nodes::Definition> output,
+                               std::size_t& count) noexcept;
+
 /**
  * Reads the records table and resolves each record's completion flag to a bank index.
  * @param source Package source.

+ 12 - 0
Sunrise/src/client/content/items/packages/package_item_build.cpp

@@ -155,6 +155,18 @@ bool build() noexcept {
                         std::span(storage.progressionRows).first(progressionCount));
                 }
             }
+            if (!state::build_data::node_definitions_ready()) {
+                std::size_t nodeCount = 0;
+                if (build_nodes(source,
+                                storage.scratch,
+                                std::span<const std::byte>{storage.root},
+                                storage.child,
+                                storage.nodeRows,
+                                nodeCount)) {
+                    (void)state::build_data::publish_node_definitions(
+                        std::span(storage.nodeRows).first(nodeCount));
+                }
+            }
             if (!state::build_data::record_definitions_ready()) {
                 std::size_t recordCount = 0;
                 if (build_records(source,

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

@@ -0,0 +1,245 @@
+#include <array>
+#include <cstdio>
+#include <cstring>
+#include <unordered_map>
+
+#include "../../../../core/logging/log.h"
+#include "../../../../state/build_data/runtime.h"
+#include "../../../../middleware/content/packages/tables/unlock_expression.h"
+#include "internal.h"
+
+namespace sunrise::client::content::items::packages {
+namespace {
+
+/** Reports where the node pass stopped, so a silent miss cannot look like a stuck progress bar. */
+void report(const char* stage, unsigned long long detail) noexcept {
+    std::array<char, 128> line{};
+    const int count = std::snprintf(
+        line.data(), line.size(), "ev=pkg stage=nodes result=%s detail=%llu", stage, detail);
+    if (count > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+}
+
+
+
+} // namespace
+
+/**
+ * Reads the presentation node table and resolves each node's value slot and owned records.
+ *
+ * A node's progress bar shows a value slot named by its own expression, and the records it owns sit
+ * at row `+136` as a row and a gate. Both are read here so a claim never has to walk the node table.
+ */
+bool build_nodes(const reader::Source& source,
+                 reader::Scratch& scratch,
+                 std::span<const std::byte> root,
+                 std::vector<std::byte>& blob,
+                 std::span<state::build_data::nodes::Definition> output,
+                 std::size_t& count) noexcept {
+    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));
+        }
+    }
+
+
+    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
+        || tables::package_of(mapTag) == tables::kAbsentPackageId
+        || !reader::read_tag(source, scratch, mapTag, blob)
+        || !tables::find_array_at(std::span<const std::byte>{blob},
+                                  tables::kAccountValueMapDescriptor,
+                                  mapRows)
+        || mapRows.count == 0
+        || mapRows.dataOffset
+                   + static_cast<std::size_t>(mapRows.count) * tables::kUnlockMapRowStride
+               > blob.size()) {
+        report("value_map_fail", mapTag);
+        return false;
+    }
+    std::unordered_map<std::int16_t, std::uint16_t> indexBySlot{};
+    for (std::uint64_t row = 0; row < mapRows.count && row <= domain::kUnavailableValueIndex;
+         ++row) {
+        const std::size_t at =
+            mapRows.dataOffset + static_cast<std::size_t>(row) * tables::kUnlockMapRowStride;
+        std::int16_t slot = 0;
+        std::memcpy(&slot, blob.data() + at + tables::kUnlockMapDestinationSlotOffset, sizeof slot);
+        indexBySlot.emplace(slot, static_cast<std::uint16_t>(row));
+    }
+
+
+    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
+        || tables::package_of(tableTag) == tables::kAbsentPackageId
+        || !reader::read_tag(source, scratch, tableTag, blob)
+        || !tables::find_array_at(
+            std::span<const std::byte>{blob}, tables::kTableArrayDescriptor, rows)
+        || rows.count == 0 || rows.count > output.size()
+        || rows.dataOffset + static_cast<std::size_t>(rows.count) * tables::kNodeRowStride
+               > blob.size()) {
+        report("node_table_fail", tableTag);
+        return false;
+    }
+
+    const std::span<const std::byte> table{blob};
+    std::size_t driving = 0;
+    for (std::uint64_t row = 0; row < rows.count; ++row) {
+        const std::size_t at =
+            rows.dataOffset + static_cast<std::size_t>(row) * tables::kNodeRowStride;
+        domain::Definition& definition = output[static_cast<std::size_t>(row)];
+        definition = {};
+        definition.definitionIndex = static_cast<std::uint16_t>(row);
+
+        // The expression sits at one of two fields, and only one of them holds it on any node.
+        std::int16_t slot = 0;
+        const bool named =
+            tables::expression_value_slot(table, at, tables::kNodeExpressionFieldPrimary, slot)
+            || tables::expression_value_slot(table, at, tables::kNodeExpressionFieldAlternate, slot);
+        if (named) {
+            const auto found = indexBySlot.find(slot);
+            if (found != indexBySlot.end()) {
+                definition.valueIndex = found->second;
+            }
+            // 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()) {
+                definition.parentValueIndex = parent->second;
+            }
+        }
+
+        // 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 (tables::expression_flag_slot(table, at, tables::kNodeExpressionFieldPrimary, gateSlot)
+            || tables::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;
+            }
+        }
+
+
+
+        // Records the node owns, four bytes each as a row and a gate.
+        std::int64_t childCount = 0;
+        std::int64_t childRelative = 0;
+        std::memcpy(&childCount, table.data() + at + tables::kNodeChildRecordField,
+                    sizeof childCount);
+        std::memcpy(&childRelative, table.data() + at + tables::kNodeChildRecordField + 8,
+                    sizeof childRelative);
+        if (childCount >= 1 && childCount <= static_cast<std::int64_t>(domain::kChildCapacity)) {
+            const std::size_t pointerAt = at + tables::kNodeChildRecordField + 8;
+            const std::int64_t target = static_cast<std::int64_t>(pointerAt) + childRelative
+                                        + static_cast<std::int64_t>(tables::kHeaderSkip);
+            if (target >= 0
+                && static_cast<std::size_t>(target)
+                           + static_cast<std::size_t>(childCount) * tables::kNodeChildRecordStride
+                       <= table.size()) {
+                const auto base = static_cast<std::size_t>(target);
+                for (std::int64_t index = 0; index < childCount; ++index) {
+                    std::uint16_t childRow = 0;
+                    std::memcpy(&childRow,
+                                table.data() + base
+                                    + static_cast<std::size_t>(index)
+                                          * tables::kNodeChildRecordStride,
+                                sizeof childRow);
+                    definition.children[static_cast<std::size_t>(definition.childCount++)] =
+                        childRow;
+                }
+            }
+        }
+        if (definition.childCount != 0 && definition.valueIndex != domain::kUnavailableValueIndex) {
+            ++driving;
+        }
+        ++count;
+    }
+    report("ok", static_cast<unsigned long long>(driving));
+    return count != 0;
+}
+
+} // namespace sunrise::client::content::items::packages

+ 16 - 0
Sunrise/src/middleware/content/packages/tables/definition_index_table.h

@@ -66,6 +66,22 @@ inline constexpr std::size_t kHeaderSkip = 16;
  * count being walked as if it were an expression, so it is set well above anything observed.
  */
 inline constexpr std::int64_t kNodeExpressionCapacity = 128;
+/** Investment root slot of the presentation node table. */
+inline constexpr std::size_t kPresentationNodeTableSlot = 63;
+/** One node row. Measured from the spacing of four known node hashes, not divided out of the blob. */
+inline constexpr std::size_t kNodeRowStride = 168;
+/** A node's expression sits at one of these two fields, never both. */
+inline constexpr std::size_t kNodeExpressionFieldPrimary = 64;
+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;
+/** A category's parent record reads the slot immediately above the category's own. */
+inline constexpr std::int32_t kNodeParentSlotStep = 1;
+/** Array descriptor of the character object's flag mapping table, sized to that bank. */
+inline constexpr std::size_t kCharacterFlagMapDescriptor = 40;
+/** Array descriptor of the character object's value mapping table, sized to that bank. */
+inline constexpr std::size_t kCharacterValueMapDescriptor = 24;
 inline constexpr std::size_t kUnlockFlagMapTableSlot = 111;
 /** Array descriptor of the account object's flag mapping table. */
 inline constexpr std::size_t kAccountFlagMapDescriptor = 8;

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

@@ -0,0 +1,89 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+
+namespace sunrise::state::build_data::nodes {
+
+/** The shipped build declares 924 presentation nodes. The domain leaves room above that. */
+inline constexpr std::size_t kDefinitionCapacity = 1024;
+
+/** No shipped node owns more records than this; the widest seen is a lore book at fifteen. */
+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;
+
+/** @return True when this node is a lore book category, the only kind this build counts. */
+[[nodiscard]] constexpr bool lore_category(std::uint16_t definitionIndex) noexcept {
+    return definitionIndex >= kLoreNodeFirst && definitionIndex <= kLoreNodeLast;
+}
+
+/** 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.
+ *
+ * A node's bar is not derived by the client from its children. The node carries an expression that
+ * reads a value slot, and the bar shows whatever that slot holds, so a server that wants the bar to
+ * move has to count the claimed children itself and write the count. The slot is resolved to its
+ * mapping-table row here, at extraction, exactly as a record's completion flag is.
+ */
+struct Definition {
+    /** Native node row. */
+    std::uint16_t definitionIndex{};
+    /** Account value bank mapping row, or kUnavailableValueIndex when no slot is addressable. */
+    std::uint16_t valueIndex{kUnavailableValueIndex};
+    /**
+     * Account value index of the parent record's own bar, one slot above the node's.
+     *
+     * The node's bar counts every child including the parent record; the parent's bar counts only
+     * 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. */
+    std::array<std::uint16_t, kChildCapacity> children{};
+};
+
+} // namespace sunrise::state::build_data::nodes

+ 19 - 0
Sunrise/src/state/build_data/nodes/node_build_data_runtime.cpp

@@ -0,0 +1,19 @@
+#include "../runtime.h"
+#include "../runtime/persistence/publication_transaction.h"
+#include "node_catalog.h"
+
+namespace sunrise::state::build_data {
+
+/** @return True when the whole native node table is published. */
+bool node_definitions_ready() noexcept {
+    return nodes::count() != 0;
+}
+
+/** Publishes one complete dense node-to-value-slot table. */
+bool publish_node_definitions(std::span<const nodes::Definition> definitions) noexcept {
+    runtime::persistence::Transaction transaction;
+    return transaction.active() && nodes::valid(definitions)
+           && transaction.finish(nodes::replace(definitions), nodes::clear);
+}
+
+} // namespace sunrise::state::build_data

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

@@ -0,0 +1,114 @@
+#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 {
+namespace {
+
+Lock g_lock;
+Table<Definition, kDefinitionCapacity> g_definitions;
+
+} // namespace
+
+/** Clears every generated node definition under the catalog lock. */
+void clear() noexcept {
+    const Lock::Exclusive guard(g_lock);
+    g_definitions.clear();
+}
+
+/** Checks that the definitions are dense and in native index order. */
+bool valid(std::span<const Definition> definitions) noexcept {
+    if (definitions.empty() || definitions.size() > kDefinitionCapacity) {
+        return false;
+    }
+    for (std::size_t row = 0; row < definitions.size(); ++row) {
+        if (definitions[row].definitionIndex != row
+            || definitions[row].childCount > kChildCapacity) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** Replaces the generated node definitions in one step. */
+bool replace(std::span<const Definition> definitions) noexcept {
+    if (!valid(definitions)) {
+        return false;
+    }
+    const Lock::Exclusive guard(g_lock);
+    return g_definitions.replace(definitions);
+}
+
+/** Runs one callable over every node that drives a value slot. */
+void for_each_driving(void* context,
+                      void (*visit)(void* context, const Definition& definition)) noexcept {
+    if (visit == nullptr) {
+        return;
+    }
+    const Lock::Shared guard(g_lock);
+    for (const Definition& definition : g_definitions.rows()) {
+        if (definition.childCount != 0 && definition.valueIndex != kUnavailableValueIndex) {
+            visit(context, definition);
+        }
+    }
+}
+
+/** Copies every row in native node order. */
+bool snapshot(std::span<Definition> output, std::size_t& count) noexcept {
+    const Lock::Shared guard(g_lock);
+    return g_definitions.snapshot(output, count);
+}
+
+/** @return Number of generated node definitions, read under the lock. */
+std::size_t count() noexcept {
+    const Lock::Shared guard(g_lock);
+    return g_definitions.count();
+}
+
+/** Calls back for every node, under the shared lock. */
+void for_each(void* context, void (*visit)(void*, const Definition&) noexcept) noexcept {
+    const Lock::Shared guard(g_lock);
+    for (const Definition& node : g_definitions.rows()) {
+        visit(context, node);
+    }
+}
+
+/** 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;
+    }
+    return set;
+}
+
+} // namespace sunrise::state::build_data::nodes

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

@@ -0,0 +1,70 @@
+#pragma once
+
+#include <cstddef>
+#include <span>
+
+#include "definition.h"
+
+namespace sunrise::state::build_data::nodes {
+
+/** Clears every generated node definition. */
+void clear() noexcept;
+
+/**
+ * Checks that the definitions are dense and in native index order.
+ * @param definitions Candidate rows.
+ * @return True when the rows fit storage, index n sits at position n, and no child count overflows.
+ */
+[[nodiscard]] bool valid(std::span<const Definition> definitions) noexcept;
+
+/**
+ * Replaces the generated node definitions in one step.
+ * @param definitions Complete dense rows in native node order.
+ * @return True when the rows pass the checks and fit fixed State storage.
+ */
+[[nodiscard]] bool replace(std::span<const Definition> definitions) noexcept;
+
+/**
+ * Runs one callable over every node that drives a value slot.
+ * Held under the shared lock, so the callable must not re-enter this domain.
+ * @param visit Receives each node owning at least one record and an addressable value slot.
+ */
+void for_each_driving(void* context,
+                      void (*visit)(void* context, const Definition& definition)) noexcept;
+
+/**
+ * Copies every row in native node order.
+ * @param output Caller-owned fixed row storage.
+ * @param count Receives the copied row count, or zero when output is too small.
+ * @return True when output can hold every row.
+ */
+[[nodiscard]] bool snapshot(std::span<Definition> output, std::size_t& count) noexcept;
+
+/** @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;
+
+/**
+ * Calls back for every node, under the shared lock.
+ *
+ * Copying the table out costs a hundred and fifty kilobytes a call, and both callers wanted only a
+ * few fields of a few rows. The callback must not take the catalog lock again.
+ * @param context Passed through untouched.
+ * @param visit Called once per node in native order.
+ */
+void for_each(void* context, void (*visit)(void*, const Definition&) noexcept) 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

+ 14 - 0
Sunrise/src/state/build_data/runtime.h

@@ -13,6 +13,7 @@
 #include "hash_names/definition.h"
 #include "inventory/buckets/definition.h"
 #include "items/details/definition.h"
+#include "nodes/definition.h"
 #include "records/definition.h"
 #include "items/item_catalog.h"
 #include "items/socket_plugs/definition.h"
@@ -97,6 +98,19 @@ publish_item_definitions(std::span<const items::Definition> definitions) noexcep
 
 /** @return True when the whole dense collectible definition table is in State. */
 
+
+/** @return True when the whole presentation node table is in State. */
+[[nodiscard]] bool node_definitions_ready() noexcept;
+
+/**
+ * Publishes the whole presentation node table in one step.
+ * @param definitions Complete dense rows in native node order.
+ * @return True when the rows pass the domain checks and fit fixed State storage.
+ */
+[[nodiscard]] bool
+publish_node_definitions(std::span<const nodes::Definition> definitions) noexcept;
+
+
 /** @return True when the whole record definition table is in State. */
 [[nodiscard]] bool record_definitions_ready() noexcept;