فهرست منبع

Bring the records domain onto the collectible branch

A pickup names its collectible, but what it should unlock is a lore book, and
lore is held as records. The records domain and its extraction come across from
the triumph branch unchanged: the table reads 2242 rows here, the same count it
reads there, and each row's completion flag resolves through the account flag
map.

Only records came over. The nodes domain, claims, score totalling and lore
visibility stay on the triumph branch; they would bring the cache format and the
claim file with them, and nothing here needs them yet.
Millie 2 هفته پیش
والد
کامیت
0d48b70d9c

+ 6 - 0
Sunrise/Sunrise.vcxproj

@@ -823,6 +823,9 @@
     <ClCompile Include="src\middleware\gameplay\group\notice_messages.cpp" />
     <ClCompile Include="src\server\gameplay\group\group_migration_receipts.cpp" />
     <ClCompile Include="src\state\build_data\sobjects\sobject_catalog.cpp" />
+    <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" />
   </ItemGroup>
   <ItemGroup Condition="'$(SunriseRunClangTidy)'=='true'">
     <ClCompile Remove="vendor\detours\detours.cpp" />
@@ -1428,6 +1431,9 @@
     <ClInclude Include="src\middleware\web_service\messages\opcode403.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode406.h" />
     <ClInclude Include="src\state\build_data\sobjects\sobject_catalog.h" />
+    <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" />
   </ItemGroup>
   <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
 </Project>

+ 31 - 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{};
+    /** Record rows held until the completion flag mapping is resolved. */
+    std::array<state::build_data::records::Definition,
+               state::build_data::records::kDefinitionCapacity>
+        recordRows{};
     std::vector<std::byte> container{};
     std::vector<std::byte> child{};
     std::vector<std::byte> root{};
@@ -353,6 +357,33 @@ 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 records table and resolves each record's completion flag to a bank index.
+ * @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 record order.
+ * @param count Receives the number of rows read.
+ * @return True when both tables read and every row fits.
+ */
+/**
+ * Reads the records table and resolves each record's completion flag to a bank index.
+ * @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 record order.
+ * @param count Receives the number of rows read.
+ * @return True when both tables read and every row fits.
+ */
+[[nodiscard]] bool build_records(const reader::Source& source,
+                                 reader::Scratch& scratch,
+                                 std::span<const std::byte> root,
+                                 std::vector<std::byte>& blob,
+                                 std::span<state::build_data::records::Definition> output,
+                                 std::size_t& count) noexcept;
+
 [[nodiscard]] bool build_collectibles(const reader::Source& source,
                                       Storage& storage,
                                       std::span<const std::byte> root,

+ 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::record_definitions_ready()) {
+                std::size_t recordCount = 0;
+                if (build_records(source,
+                                  storage.scratch,
+                                  std::span<const std::byte>{storage.root},
+                                  storage.child,
+                                  storage.recordRows,
+                                  recordCount)) {
+                    (void)state::build_data::publish_record_definitions(
+                        std::span(storage.recordRows).first(recordCount));
+                }
+            }
             if (!state::build_data::investment_constants_ready()) {
                 state::build_data::constants::InvestmentConstants extracted{};
                 if (read_investment_constants(source,

+ 169 - 0
Sunrise/src/client/content/items/packages/package_record_build.cpp

@@ -0,0 +1,169 @@
+#include <array>
+#include <cstdio>
+#include <cstring>
+
+#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 record pass stopped, so a silent miss cannot look like a working claim. */
+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=records 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)});
+    }
+}
+
+/** A record with no completion flag carries a non-positive slot, which addresses nothing. */
+[[nodiscard]] constexpr bool addressable_slot(std::int16_t slot) noexcept {
+    return slot > 0;
+}
+
+
+} // namespace
+
+/**
+ * Reads the records table and resolves each record's completion flag to a bank index.
+ *
+ * A record row carries the unlock slot of its completion flag, and a slot is not an array index:
+ * the byte that feeds a slot sits at the row number of the mapping table whose destination is that
+ * slot. Both tables are walked here so a claim can go straight from a record row to the index it
+ * has to set.
+ */
+bool build_records(const reader::Source& source,
+                   reader::Scratch& scratch,
+                   std::span<const std::byte> root,
+                   std::vector<std::byte>& blob,
+                   std::span<state::build_data::records::Definition> output,
+                   std::size_t& count) noexcept {
+    namespace domain = state::build_data::records;
+    count = 0;
+
+    // The account flag mapping table, read first because the record rows are matched against it.
+    std::uint32_t mapTag = 0;
+    tables::Array mapRows{};
+    if (!tables::slot_tag(root, tables::kUnlockFlagMapTableSlot, 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::kAccountFlagMapDescriptor,
+                                  mapRows)
+        || mapRows.count == 0
+        || mapRows.dataOffset
+                   + static_cast<std::size_t>(mapRows.count) * tables::kUnlockMapRowStride
+               > blob.size()) {
+        report("flag_map_fail", mapTag);
+        return false;
+    }
+
+    // Destination slot to mapping row. The first row wins, matching how a bank is addressed.
+    constexpr std::size_t kSlotSpace = 32768;
+    static_assert(domain::kUnavailableFlagIndex == 0xFFFFU);
+    std::vector<std::uint16_t> indexBySlot{};
+    indexBySlot.assign(kSlotSpace, domain::kUnavailableFlagIndex);
+    for (std::uint64_t row = 0; row < mapRows.count; ++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);
+        if (!addressable_slot(slot) || static_cast<std::size_t>(slot) >= kSlotSpace
+            || row > domain::kUnavailableFlagIndex) {
+            continue;
+        }
+        std::uint16_t& existing = indexBySlot[static_cast<std::size_t>(slot)];
+        if (existing == domain::kUnavailableFlagIndex) {
+            existing = static_cast<std::uint16_t>(row);
+        }
+    }
+
+    // The account value mapping table, read while the blob is still free. A record names its
+    // category's value slot, and that slot has to become an index the same way a flag slot does.
+    std::uint32_t valueMapTag = 0;
+    tables::Array valueMapRows{};
+    std::vector<std::uint16_t> valueIndexBySlot{};
+    if (tables::slot_tag(root, tables::kUnlockValueMapTableSlot, valueMapTag) && valueMapTag != 0
+        && tables::package_of(valueMapTag) != tables::kAbsentPackageId
+        && reader::read_tag(source, scratch, valueMapTag, blob)
+        && tables::find_array_at(std::span<const std::byte>{blob},
+                                 tables::kAccountValueMapDescriptor,
+                                 valueMapRows)
+        && valueMapRows.count != 0
+        && valueMapRows.dataOffset
+                   + static_cast<std::size_t>(valueMapRows.count) * tables::kUnlockMapRowStride
+               <= blob.size()) {
+        valueIndexBySlot.assign(kSlotSpace, domain::kUnavailableValueIndex);
+        for (std::uint64_t row = 0; row < valueMapRows.count; ++row) {
+            std::int16_t slot = 0;
+            std::memcpy(&slot,
+                        blob.data() + valueMapRows.dataOffset
+                            + static_cast<std::size_t>(row) * tables::kUnlockMapRowStride
+                            + tables::kUnlockMapDestinationSlotOffset,
+                        sizeof slot);
+            if (!addressable_slot(slot) || static_cast<std::size_t>(slot) >= kSlotSpace
+                || row > domain::kUnavailableValueIndex) {
+                continue;
+            }
+            std::uint16_t& existing = valueIndexBySlot[static_cast<std::size_t>(slot)];
+            if (existing == domain::kUnavailableValueIndex) {
+                existing = static_cast<std::uint16_t>(row);
+            }
+        }
+    }
+
+    std::uint32_t tableTag = 0;
+    tables::Array rows{};
+    if (!tables::slot_tag(root, tables::kRecordTableSlot, 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::kRecordRowStride
+               > blob.size()) {
+        report("record_table_fail", tableTag);
+        return false;
+    }
+
+    for (std::uint64_t row = 0; row < rows.count; ++row) {
+        const std::size_t at =
+            rows.dataOffset + static_cast<std::size_t>(row) * tables::kRecordRowStride;
+        std::int16_t slot = 0;
+        std::memcpy(&slot,
+                    blob.data() + at + tables::kRecordCompletionFlagOffset,
+                    sizeof slot);
+        std::uint32_t score = 0;
+        std::memcpy(&score, blob.data() + at + tables::kRecordScoreOffset, sizeof score);
+        domain::Definition& definition = output[static_cast<std::size_t>(row)];
+        definition = {};
+        definition.definitionIndex = static_cast<std::uint16_t>(row);
+        // The shipped table tops out at 500, so anything wider is not a score and is dropped.
+        definition.scoreValue = score <= 0xFFFFU ? static_cast<std::uint16_t>(score) : 0U;
+        std::int16_t categorySlot = 0;
+        if (!valueIndexBySlot.empty()
+            && tables::expression_value_slot(std::span<const std::byte>{blob},
+                                     at,
+                                     tables::kRecordCategoryExpressionField,
+                                     categorySlot)
+            && addressable_slot(categorySlot)
+            && static_cast<std::size_t>(categorySlot) < kSlotSpace) {
+            definition.categoryValueIndex = valueIndexBySlot[static_cast<std::size_t>(categorySlot)];
+        }
+        if (addressable_slot(slot) && static_cast<std::size_t>(slot) < kSlotSpace) {
+            definition.completionFlagIndex = indexBySlot[static_cast<std::size_t>(slot)];
+        }
+        ++count;
+    }
+    report("ok", static_cast<unsigned long long>(count));
+    return count != 0;
+}
+
+} // namespace sunrise::client::content::items::packages

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

@@ -32,6 +32,40 @@ inline constexpr std::uint16_t kAbsentPackageId = 0xFFFFU;
 inline constexpr std::uint32_t kItemIndexTableClass = 0x80807BE8U;
 /** The investment root holds the installed collectible definition table at this slot. */
 /** Investment root slot of the unlock flag mapping tables. */
+/** The investment root holds the progression definition table at this slot. */
+/** Investment root slot of the records and lore table. */
+inline constexpr std::size_t kRecordTableSlot = 72;
+/** One record row, wider than any field this pass reads. */
+inline constexpr std::size_t kRecordRowStride = 216;
+/** Unlock slot of the record's completion flag, or a non-positive value when it has none. */
+inline constexpr std::size_t kRecordCompletionFlagOffset = 100;
+/** Points the record is worth. Zero for lore and for the interval records that score per step. */
+inline constexpr std::size_t kRecordScoreOffset = 92;
+/** A record names its category's value slot here. The record's own bar reads the next slot up. */
+inline constexpr std::size_t kRecordCategoryExpressionField = 120;
+/** Investment root slot of the four unlock value mapping tables. */
+inline constexpr std::size_t kUnlockValueMapTableSlot = 113;
+/** Array descriptor of the account object's value mapping table. */
+inline constexpr std::size_t kAccountValueMapDescriptor = 8;
+/** One unlock expression instruction: an opcode then its operand. */
+inline constexpr std::size_t kUnlockInstructionStride = 8;
+/** Opcodes run to fifteen; anything wider means the field is not an expression. */
+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;
+/**
+ * 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;
 inline constexpr std::size_t kUnlockFlagMapTableSlot = 111;
 /** Array descriptor of the account object's flag mapping table. */
 inline constexpr std::size_t kAccountFlagMapDescriptor = 8;

+ 87 - 0
Sunrise/src/middleware/content/packages/tables/unlock_expression.h

@@ -0,0 +1,87 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <cstring>
+#include <span>
+
+#include "definition_index_table.h"
+
+namespace sunrise::middleware::content::packages::tables {
+
+/**
+ * Reading unlock expressions out of definition rows.
+ *
+ * A row field holds a count and, eight bytes on, a self-relative offset to a run of instructions.
+ * Each instruction is an opcode then an operand. Which field a row uses varies, and so does what it
+ * carries: the same field holds a value read on one row and a flag test on another, so both readers
+ * are tried against the same fields rather than each field being treated as fixed-purpose.
+ */
+
+/**
+ * Reads the first operand of a given opcode out of one expression field.
+ * @param table Blob the row sits in.
+ * @param rowAt Byte offset of the row.
+ * @param field Byte offset of the expression field within the row.
+ * @param opcode Opcode to look for: kUnlockReadValueOpcode or kUnlockReadFlagOpcode.
+ * @param slot Receives the operand when one is found.
+ * @return True when the field parses as an expression and names that opcode.
+ */
+[[nodiscard]] inline bool expression_operand(std::span<const std::byte> table,
+                                             std::size_t rowAt,
+                                             std::size_t field,
+                                             std::uint32_t opcode,
+                                             std::int16_t& slot) noexcept {
+    std::int64_t count = 0;
+    std::int64_t relative = 0;
+    if (rowAt + field + 16 > table.size()) {
+        return false;
+    }
+    std::memcpy(&count, table.data() + rowAt + field, sizeof count);
+    std::memcpy(&relative, table.data() + rowAt + field + 8, sizeof relative);
+    if (count < 1 || count > 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>(kHeaderSkip);
+    if (target < 0
+        || static_cast<std::size_t>(target) + static_cast<std::size_t>(count) * 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 instruction = 0;
+        std::uint32_t operand = 0;
+        const std::size_t at = base + static_cast<std::size_t>(index) * kUnlockInstructionStride;
+        std::memcpy(&instruction, table.data() + at, sizeof instruction);
+        std::memcpy(&operand, table.data() + at + 4, sizeof operand);
+        if (instruction > kUnlockOpcodeCeiling) {
+            return false;
+        }
+        if (instruction == opcode && operand <= static_cast<std::uint32_t>(INT16_MAX)) {
+            slot = static_cast<std::int16_t>(operand);
+            return true;
+        }
+    }
+    return false;
+}
+
+/** Reads the value slot one expression field names, or reports that it names none. */
+[[nodiscard]] inline bool expression_value_slot(std::span<const std::byte> table,
+                                                std::size_t rowAt,
+                                                std::size_t field,
+                                                std::int16_t& slot) noexcept {
+    return expression_operand(table, rowAt, field, kUnlockReadValueOpcode, slot);
+}
+
+/** Reads the flag slot one expression field tests, or reports that it tests none. */
+[[nodiscard]] inline bool expression_flag_slot(std::span<const std::byte> table,
+                                               std::size_t rowAt,
+                                               std::size_t field,
+                                               std::int16_t& slot) noexcept {
+    return expression_operand(table, rowAt, field, kUnlockReadFlagOpcode, slot);
+}
+
+} // namespace sunrise::middleware::content::packages::tables

+ 49 - 0
Sunrise/src/state/build_data/records/definition.h

@@ -0,0 +1,49 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+
+namespace sunrise::state::build_data::records {
+
+/** The shipped build declares 2242 records and lore entries. The domain leaves room above that. */
+inline constexpr std::size_t kDefinitionCapacity = 4096;
+
+/**
+ * Account value bank row that holds Triumph Score.
+ *
+ * Found by authoring every account value slot to `100000 + its own row` and reading the score back
+ * as 102115. It is a plain replicated value, not a progression and not derived by the client, so a
+ * server that wants a score has to total one itself.
+ */
+inline constexpr std::uint16_t kTriumphScoreValueIndex = 2115U;
+
+/** A record whose completion flag no mapping table addresses carries this instead of an index. */
+inline constexpr std::uint16_t kUnavailableFlagIndex = 0xFFFFU;
+
+/** A record naming no category value slot carries this instead of an index. */
+inline constexpr std::uint16_t kUnavailableValueIndex = 0xFFFFU;
+
+/**
+ * One record reduced to what a claim needs.
+ *
+ * A record row carries the unlock slot of its completion flag. A slot is not an array index: the
+ * byte that feeds it lives at the row number of the mapping table whose destination is that slot.
+ * The index is resolved once here, at extraction, so a claim never has to walk the mapping tables.
+ */
+struct Definition {
+    /** Native record row, which is what an opcode-1801 claim names. */
+    std::uint16_t definitionIndex{};
+    /** Account flag bank mapping row, or kUnavailableFlagIndex when the slot is unaddressable. */
+    std::uint16_t completionFlagIndex{kUnavailableFlagIndex};
+    /** Points this record is worth, which the shipped table keeps at 500 or below. */
+    std::uint16_t scoreValue{};
+    /**
+     * Account value index of the category this record names, or kUnavailableValueIndex.
+     *
+     * Only a category's parent record names its category's own slot, so this is what distinguishes
+     * the parent from the chapters beneath it. The parent is excluded from its own progress bar.
+     */
+    std::uint16_t categoryValueIndex{kUnavailableValueIndex};
+};
+
+} // namespace sunrise::state::build_data::records

+ 25 - 0
Sunrise/src/state/build_data/records/record_build_data_runtime.cpp

@@ -0,0 +1,25 @@
+#include "../runtime.h"
+#include "../runtime/persistence/publication_transaction.h"
+#include "record_catalog.h"
+
+namespace sunrise::state::build_data {
+
+/** @return True when the whole native record table is published. */
+bool record_definitions_ready() noexcept {
+    return records::count() != 0;
+}
+
+/** Publishes one complete dense record-to-completion-flag table. */
+bool publish_record_definitions(std::span<const records::Definition> definitions) noexcept {
+    runtime::persistence::Transaction transaction;
+    return transaction.active() && records::valid(definitions)
+           && transaction.finish(records::replace(definitions), records::clear);
+}
+
+/** Resolves the native record row an opcode-1801 claim names. */
+bool find_record_definition(std::uint16_t definitionIndex,
+                            records::Definition& definition) noexcept {
+    return records::find(definitionIndex, definition);
+}
+
+} // namespace sunrise::state::build_data

+ 64 - 0
Sunrise/src/state/build_data/records/record_catalog.cpp

@@ -0,0 +1,64 @@
+#include "record_catalog.h"
+
+#include "../table.h"
+
+namespace sunrise::state::build_data::records {
+namespace {
+
+Lock g_lock;
+Table<Definition, kDefinitionCapacity> g_definitions;
+
+} // namespace
+
+/** Clears every generated record 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) {
+            return false;
+        }
+    }
+    return true;
+}
+
+/** Replaces the generated record 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);
+}
+
+/** Finds one record by the native row a claim names. */
+bool find(std::uint16_t definitionIndex, Definition& definition) noexcept {
+    const Lock::Shared guard(g_lock);
+    const std::span<const Definition> rows = g_definitions.rows();
+    if (definitionIndex >= rows.size()) {
+        return false;
+    }
+    definition = rows[definitionIndex];
+    return true;
+}
+
+/** Copies every row in native record 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 record definitions, read under the lock. */
+std::size_t count() noexcept {
+    const Lock::Shared guard(g_lock);
+    return g_definitions.count();
+}
+
+} // namespace sunrise::state::build_data::records

+ 46 - 0
Sunrise/src/state/build_data/records/record_catalog.h

@@ -0,0 +1,46 @@
+#pragma once
+
+#include <cstddef>
+#include <span>
+
+#include "definition.h"
+
+namespace sunrise::state::build_data::records {
+
+/** Clears every generated record 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 and index n sits at position n.
+ */
+[[nodiscard]] bool valid(std::span<const Definition> definitions) noexcept;
+
+/**
+ * Replaces the generated record definitions in one step.
+ * @param definitions Complete dense rows in native index order.
+ * @return True when the rows pass the checks and fit fixed State storage.
+ */
+[[nodiscard]] bool replace(std::span<const Definition> definitions) noexcept;
+
+/**
+ * Finds one record by the native row a claim names.
+ * @param definitionIndex Native record row.
+ * @param definition Receives the row only on success.
+ * @return True when the domain is complete and the row exists.
+ */
+[[nodiscard]] bool find(std::uint16_t definitionIndex, Definition& definition) noexcept;
+
+/**
+ * Copies every row in native record 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 record definitions, read under the lock. */
+[[nodiscard]] std::size_t count() noexcept;
+
+} // namespace sunrise::state::build_data::records

+ 22 - 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 "records/definition.h"
 #include "items/item_catalog.h"
 #include "items/socket_plugs/definition.h"
 #include "material_requirements/material_requirement_catalog.h"
@@ -95,6 +96,27 @@ publish_item_definitions(std::span<const items::Definition> definitions) noexcep
                                               items::Definition& definition) noexcept;
 
 /** @return True when the whole dense collectible definition table is in State. */
+
+/** @return True when the whole record definition table is in State. */
+[[nodiscard]] bool record_definitions_ready() noexcept;
+
+/**
+ * Publishes the whole record definition table in one step.
+ * @param definitions Complete dense rows in native record order.
+ * @return True when the rows pass the domain checks and fit fixed State storage.
+ */
+[[nodiscard]] bool
+publish_record_definitions(std::span<const records::Definition> definitions) noexcept;
+
+/**
+ * Resolves the native record row an opcode-1801 claim names.
+ * @param definitionIndex Native record row carried by the claim.
+ * @param definition Receives the row, including its completion flag index, only on success.
+ * @return True when the table is complete and the row exists.
+ */
+[[nodiscard]] bool find_record_definition(std::uint16_t definitionIndex,
+                                          records::Definition& definition) noexcept;
+
 [[nodiscard]] bool collectible_definitions_ready() noexcept;
 
 /**