Ver Fonte

Add title equipping and exact lore collectible grants

Millie há 1 semana atrás
pai
commit
77e608872d
34 ficheiros alterados com 1049 adições e 914 exclusões
  1. 5 0
      Sunrise/Sunrise.vcxproj
  2. 2 0
      Sunrise/src/client/content/items/packages/package_collectible_build.cpp
  3. 4 69
      Sunrise/src/client/content/items/packages/package_node_build.cpp
  4. 3 44
      Sunrise/src/client/content/items/packages/package_record_build.cpp
  5. 2 0
      Sunrise/src/middleware/content/packages/tables/definition_index_table.h
  6. 15 3
      Sunrise/src/middleware/datagen/character_record/character_record_encoder.cpp
  7. 7 10
      Sunrise/src/middleware/datagen/family4/account/account_encoder.cpp
  8. 1 0
      Sunrise/src/middleware/datagen/family4/character/character_encoder.cpp
  9. 10 2
      Sunrise/src/middleware/datagen/family4/character/layout.h
  10. 24 0
      Sunrise/src/middleware/web_service/messages/opcode1821.h
  11. 38 0
      Sunrise/src/middleware/web_service/messages/opcode1821_codec.cpp
  12. 335 215
      Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_message_receipts.cpp
  13. 20 0
      Sunrise/src/server/bap/encrypted/body/bap_service_body.cpp
  14. 62 0
      Sunrise/src/server/web_service/web_service_actions.cpp
  15. 3 0
      Sunrise/src/server/web_service/web_service_actions.h
  16. 5 1
      Sunrise/src/server/web_service/web_service_runtime.cpp
  17. 2 0
      Sunrise/src/server/web_service/web_service_runtime.h
  18. 4 0
      Sunrise/src/state/account/account_state.h
  19. 6 0
      Sunrise/src/state/build_data/cache/records/cache_investment_records.cpp
  20. 5 2
      Sunrise/src/state/build_data/cache/records/format.h
  21. 3 39
      Sunrise/src/state/build_data/nodes/node_catalog.cpp
  22. 1 0
      Sunrise/src/state/build_data/nodes/node_catalog.h
  23. 2 0
      Sunrise/src/state/build_data/records/definition.h
  24. 0 37
      Sunrise/src/state/build_data/runtime.h
  25. 11 8
      Sunrise/src/state/build_data/sobjects/sobject_catalog.h
  26. 60 145
      Sunrise/src/state/lore/lore_grant.cpp
  27. 17 142
      Sunrise/src/state/lore/lore_grant.h
  28. 9 58
      Sunrise/src/state/record_claims/parent_bar_table.h
  29. 328 124
      Sunrise/src/state/record_claims/record_claims.cpp
  30. 24 5
      Sunrise/src/state/record_claims/record_claims.h
  31. 5 0
      Sunrise/src/state/runtime/runtime.h
  32. 1 0
      Sunrise/src/state/runtime/state_account_equipment_runtime.cpp
  33. 34 0
      Sunrise/src/state/runtime/state_account_runtime.cpp
  34. 1 10
      Sunrise/src/state/unlocks/definition.h

+ 5 - 0
Sunrise/Sunrise.vcxproj

@@ -825,6 +825,7 @@
     <ClCompile Include="src\client\content\items\packages\package_node_build.cpp" />
     <ClCompile Include="src\client\content\items\packages\package_record_build.cpp" />
     <ClCompile Include="src\middleware\web_service\messages\opcode1801_codec.cpp" />
+    <ClCompile Include="src\middleware\web_service\messages\opcode1821_codec.cpp" />
     <ClCompile Include="src\state\build_data\nodes\node_build_data_runtime.cpp" />
     <ClCompile Include="src\state\build_data\nodes\node_catalog.cpp" />
     <ClCompile Include="src\state\build_data\nodes\node_persistence.cpp" />
@@ -1440,6 +1441,7 @@
     <ClInclude Include="src\core\settings\settings_upgrade.h" />
     <ClInclude Include="src\middleware\content\packages\tables\unlock_expression.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode1801.h" />
+    <ClInclude Include="src\middleware\web_service\messages\opcode1821.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode1820.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode402.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode403.h" />
@@ -1452,10 +1454,13 @@
     <ClInclude Include="src\state\build_data\records\rewards\definition.h" />
     <ClInclude Include="src\state\build_data\records\rewards\reward_catalog.h" />
     <ClInclude Include="src\state\build_data\records\rewards\reward_persistence.h" />
+    <ClInclude Include="src\state\record_claims\objective_slot_table.h" />
+    <ClInclude Include="src\state\record_claims\parent_bar_table.h" />
     <ClInclude Include="src\state\record_claims\record_claims.h" />
     <ClInclude Include="src\state\activity\current_activity.h" />
     <ClInclude Include="src\state\build_data\records\record_persistence.h" />
     <ClInclude Include="src\state\build_data\sobjects\sobject_catalog.h" />
+    <ClInclude Include="src\state\lore\bubble_record_table.h" />
     <ClInclude Include="src\state\lore\lore_grant.h" />
   </ItemGroup>
   <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />

+ 2 - 0
Sunrise/src/client/content/items/packages/package_collectible_build.cpp

@@ -43,6 +43,8 @@ bool build_collectibles(const reader::Source& source,
             std::vector<sobjects::Definition> rows(static_cast<std::size_t>(rowCount));
             for (std::size_t row = 0; row < rows.size(); ++row) {
                 const std::size_t at = kRowBase + row * kRowStride;
+                std::memcpy(rows[row].lanes.data(), blob.data() + at,
+                            rows[row].lanes.size() * sizeof(std::uint32_t));
                 std::memcpy(&rows[row].nameHash, blob.data() + at, sizeof(std::uint32_t));
                 std::memcpy(&rows[row].lane4, blob.data() + at + 16, sizeof(std::uint32_t));
                 std::memcpy(&rows[row].selectorGroup, blob.data() + at + 32, sizeof(std::uint16_t));

+ 4 - 69
Sunrise/src/client/content/items/packages/package_node_build.cpp

@@ -169,60 +169,6 @@ bool build_nodes(const reader::Source& source,
         const bool named =
             tables::expression_value_slot(table, at, tables::kNodeExpressionFieldPrimary, slot)
             || tables::expression_value_slot(table, at, tables::kNodeExpressionFieldAlternate, slot);
-        // A lore book whose expression parses as neither a value read nor a flag test is
-        // indistinguishable from one whose slot simply is not addressable, so the raw instruction
-        // stream is reported for the lore range rather than inferred. Sixteen books resolve no
-        // slot at all and their bars can never move until that is understood.
-        if (domain::lore_category(static_cast<std::uint16_t>(row))) {
-            // Every 8-byte-aligned field in the row is tried, not just the two the resolver
-            // knows: sixteen books carry a flag test where the others carry a value read, so the
-            // value expression that drives their bar must sit in a field nobody has looked at.
-            for (std::size_t field = 0; field + 16 <= tables::kNodeRowStride; field += 8) {
-                std::int16_t probe = 0;
-                if (!tables::expression_value_slot(table, at, field, probe)) {
-                    continue;
-                }
-                std::int64_t count = 0;
-                std::int64_t relative = 0;
-                if (at + field + 16 > table.size()) {
-                    continue;
-                }
-                std::memcpy(&count, table.data() + at + field, sizeof count);
-                std::memcpy(&relative, table.data() + at + field + 8, sizeof relative);
-                std::array<char, 200> head{};
-                int used = std::snprintf(head.data(), head.size(),
-                                         "ev=nodes stage=expr node=%llu field=%zu count=%lld ops=",
-                                         static_cast<unsigned long long>(row), field,
-                                         static_cast<long long>(count));
-                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 (count >= 1 && count <= tables::kNodeExpressionCapacity && target >= 0
-                    && static_cast<std::size_t>(target)
-                               + static_cast<std::size_t>(count) * tables::kUnlockInstructionStride
-                           <= table.size()) {
-                    const auto base = static_cast<std::size_t>(target);
-                    for (std::int64_t index = 0; index < count && index < 6; ++index) {
-                        std::uint32_t instruction = 0;
-                        std::uint32_t operand = 0;
-                        const std::size_t insAt =
-                            base + static_cast<std::size_t>(index) * tables::kUnlockInstructionStride;
-                        std::memcpy(&instruction, table.data() + insAt, sizeof instruction);
-                        std::memcpy(&operand, table.data() + insAt + 4, sizeof operand);
-                        used += std::snprintf(head.data() + used,
-                                              head.size() - static_cast<std::size_t>(used),
-                                              "%u:%u ", instruction, operand);
-                    }
-                } else {
-                    used += std::snprintf(head.data() + used,
-                                          head.size() - static_cast<std::size_t>(used), "unparsed");
-                }
-                if (used > 0) {
-                    core::log::write(core::log::Channel::client, core::log::Level::info,
-                                     {head.data(), static_cast<std::size_t>(used)});
-                }
-            }
-        }
         if (named) {
             definition.valueSlot = slot;
             const auto found = indexBySlot.find(slot);
@@ -231,21 +177,10 @@ bool build_nodes(const reader::Source& source,
             }
             // The same expression may resolve in the character scope: one lore book's bar reads a
             // slot only the character table carries, and its parent has to be fed there too.
-            std::int16_t characterSlot = 0;
-            if (tables::expression_value_slot(table,
-                                              at,
-                                              tables::kNodeExpressionFieldPrimary,
-                                              characterSlot)
-                || tables::expression_value_slot(table,
-                                                 at,
-                                                 tables::kNodeExpressionFieldAlternate,
-                                                 characterSlot)) {
-                definition.characterValueSlot = characterSlot;
-                const auto characterResolved =
-                    characterValueIndexBySlot.find(characterSlot);
-                if (characterResolved != characterValueIndexBySlot.end()) {
-                    definition.characterValueIndex = characterResolved->second;
-                }
+            definition.characterValueSlot = slot;
+            const auto character_resolved = characterValueIndexBySlot.find(slot);
+            if (character_resolved != characterValueIndexBySlot.end()) {
+                definition.characterValueIndex = character_resolved->second;
             }
         }
 

+ 3 - 44
Sunrise/src/client/content/items/packages/package_record_build.cpp

@@ -152,6 +152,9 @@ bool build_records(const reader::Source& source,
                     sizeof definition.loreRow);
         // 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::uint32_t hasTitle = 0;
+        std::memcpy(&hasTitle, blob.data() + at + tables::kRecordHasTitleOffset, sizeof hasTitle);
+        definition.hasTitle = hasTitle != 0;
         std::int16_t categorySlot = 0;
         if (!valueIndexBySlot.empty()
             && tables::expression_value_slot(std::span<const std::byte>{blob},
@@ -162,50 +165,6 @@ bool build_records(const reader::Source& source,
             && static_cast<std::size_t>(categorySlot) < kSlotSpace) {
             definition.categoryValueIndex = valueIndexBySlot[static_cast<std::size_t>(categorySlot)];
         }
-        // A book's parent triumph shows a progress bar, and the slot that bar reads has never
-        // been identified -- only the category field is parsed. Every 8-byte-aligned field of the
-        // row is reported for the lore books' parent records (loreRow 0xFFFF), with both the raw
-        // slot each names and the bank index it maps to, so the bar's source can be read off
-        // rather than guessed.
-        if (definition.loreRow == 0xFFFFU && !valueIndexBySlot.empty()) {
-            for (std::size_t field = 0; field + 16 <= tables::kRecordRowStride; field += 8) {
-                std::int16_t raw = 0;
-                const bool isValue = tables::expression_value_slot(
-                    std::span<const std::byte>{blob}, at, field, raw);
-                std::int16_t rawFlag = 0;
-                const bool isFlag = tables::expression_flag_slot(
-                    std::span<const std::byte>{blob}, at, field, rawFlag);
-                if (!isValue && !isFlag) {
-                    continue;
-                }
-                const std::int16_t named = isValue ? raw : rawFlag;
-                // Both maps, always. Twelve books name only a flag here and their bars stay dead;
-                // if the same raw number also resolves in the value map, that index is the bar's
-                // source and nobody has looked because the flag reading answered first.
-                int mapped = -1;
-                int asValue = -1;
-                if (addressable_slot(named) && static_cast<std::size_t>(named) < kSlotSpace) {
-                    mapped = isValue ? static_cast<int>(valueIndexBySlot[
-                                           static_cast<std::size_t>(named)])
-                                     : static_cast<int>(indexBySlot[
-                                           static_cast<std::size_t>(named)]);
-                    asValue = !valueIndexBySlot.empty()
-                                  ? static_cast<int>(
-                                        valueIndexBySlot[static_cast<std::size_t>(named)])
-                                  : -1;
-                }
-                std::array<char, 160> line{};
-                const int told = std::snprintf(
-                    line.data(), line.size(),
-                    "ev=records stage=parent_expr row=%llu field=%zu kind=%s raw=%d mapped=%d as_value=%d",
-                    static_cast<unsigned long long>(row), field, isValue ? "value" : "flag",
-                    static_cast<int>(named), mapped, asValue);
-                if (told > 0) {
-                    core::log::write(core::log::Channel::client, core::log::Level::info,
-                                     {line.data(), static_cast<std::size_t>(told)});
-                }
-            }
-        }
         if (addressable_slot(slot) && static_cast<std::size_t>(slot) < kSlotSpace) {
             definition.completionFlagIndex = indexBySlot[static_cast<std::size_t>(slot)];
         }

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

@@ -62,6 +62,8 @@ inline constexpr std::size_t kLoreHashOffset = 8;
 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;
+/** Nonzero when the record's completion grants a character-equippable title. */
+inline constexpr std::size_t kRecordHasTitleOffset = 0xB8;
 /** 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. */

+ 15 - 3
Sunrise/src/middleware/datagen/character_record/character_record_encoder.cpp

@@ -60,10 +60,12 @@ constexpr std::size_t kPreviewFlagOffsets[]{8, 9};
 }
 
 /** @param light Equipment light. @return The trailing summary block both records carry. */
-[[nodiscard]] layout::Summary build_summary(std::int32_t light) noexcept {
+[[nodiscard]] layout::Summary build_summary(std::int32_t light,
+                                            std::uint16_t titleRecordIndex) noexcept {
     layout::Summary summary{};
     summary.light = light;
     summary.hashA = layout::kNoHash;
+    summary.indexD = titleRecordIndex;
     return summary;
 }
 
@@ -108,7 +110,12 @@ bool encode_family3(const state::CharacterState& character,
     }
     const auto record = output.first(kFamily3RecordSize);
     copy_record(
-        identity, block, build_summary(light), kFamily3ReservedSize, kFamily3TailSize, record);
+        identity,
+        block,
+        build_summary(light, character.equippedTitleRecordIndex),
+        kFamily3ReservedSize,
+        kFamily3TailSize,
+        record);
     // The character-select preview flags sit past the summary; their accessor returns true when
     // the context is missing, so a cleared flag asserts the opposite of the client's own fallback.
     const std::size_t tailStart = kFamily3RecordSize - kFamily3TailSize;
@@ -130,7 +137,12 @@ bool encode_family0(const state::CharacterState& character,
         return false;
     }
     const auto record = output.first(kFamily0RecordSize);
-    copy_record(identity, block, build_summary(light), 0, kFamily0TailSize, record);
+    copy_record(identity,
+                block,
+                build_summary(light, character.equippedTitleRecordIndex),
+                0,
+                kFamily0TailSize,
+                record);
     const layout::Family0Tail tail{};
     std::memcpy(record.data() + kFamily0RecordSize - kFamily0TailSize, &tail, sizeof tail);
     return true;

+ 7 - 10
Sunrise/src/middleware/datagen/family4/account/account_encoder.cpp

@@ -124,6 +124,9 @@ bool encode(const state::AccountState& state, std::span<std::byte> output) noexc
         }
     }
 
+    // Settings predate collectible persistence and author many lore objectives at completion.
+    // Clear every lore-owned record first; claims and collected progress are overlaid below.
+    (void)state::record_claims::clear_lore_objectives(object.objectiveValues);
     // Claims are laid over the authored bank on the way out, so a claimed record reads Acquired on
     // the next image. The authored policy itself is immutable and is never edited.
     (void)state::record_claims::apply(object.acquiredFlags);
@@ -143,18 +146,12 @@ bool encode(const state::AccountState& state, std::span<std::byte> output) noexc
     // A record reads claimable when its objective equals completionValue and its flag is clear --
     // the flag alone can never carry that state, so its objective value(s) are written here instead.
     (void)state::record_claims::apply_claimable_objectives(object.objectiveValues);
-    // Eighteen lore books gate on a value slot instead of a flag, and this must run last of the
-    // three value-bank passes above: apply_node_progress writes a claimed-chapter count into every
-    // parent bar slot, and on fourteen books that slot is mis-sourced to equal the book's own gate,
-    // so writing the count there zeros the gate right back out. Running the gate pass after both
-    // value-writing passes is what makes it stick -- reordering this call ahead of either one would
-    // silently reintroduce the exact bug this fixes.
+    // Eighteen lore books gate on a value slot instead of a flag. Run this after every other value
+    // pass so an empty shared gate/bar receives its sentinel without overwriting a real count.
     (void)state::build_data::nodes::apply_category_gates(object.objectiveValues,
                                                         unlocks.revealAllLoreBooks);
-    // The Year 1 chapters carry a visibility gate of their own, one slot per record row just above
-    // the parent bars. Without it a chapter completes -- its book's parent triumph even goes
-    // claimable off the back of it -- while the entry itself stays redacted and unclaimable.
-    (void)state::record_claims::apply_chapter_visibility_gates(object.objectiveValues);
+    // Undiscovered chapters keep their authored visibility state. A former blanket gate fill made
+    // whole books look claimable on a clean account, which bypassed collectible progression.
 
     for (layout::CharacterUnlockBlock& block : object.characterUnlocks) {
         block.flags = unlocks.characterFlags;

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

@@ -151,6 +151,7 @@ bool encode(const state::CharacterState& state,
     object.lastOrbitedDestination = state.lastOrbitedDestination;
     object.previewMirrors.fill(state.previewAvailable ? kNativeTrue : kNativeFalse);
     object.contentBypass = state.contentBypass ? kNativeTrue : kNativeFalse;
+    object.equippedTitleRecordIndex = state.equippedTitleRecordIndex;
     object.seenMessages.fill(kSeenMessageByte);
     for (inventory::layout::Entry& item : object.inventoryItems) {
         item.definitionIndex = kEmptyDefinitionIndex;

+ 10 - 2
Sunrise/src/middleware/datagen/family4/character/layout.h

@@ -43,7 +43,9 @@ inline constexpr std::size_t kInventoryChangeUnknownSize = 4;
 /** The character object carries at most 16 transient inventory-change records. */
 inline constexpr std::size_t kInventoryChangeRecordCapacity = 16;
 /** 52 reserved bytes separate the equipment summary from its validity gate. */
-inline constexpr std::size_t kSummaryGatePaddingSize = 52;
+inline constexpr std::size_t kSummaryTitlePaddingSize = 36;
+/** Reserved bytes between the equipped-title row and the inventory validity gate. */
+inline constexpr std::size_t kTitleGatePaddingSize = 14;
 /** 14 reserved bytes separate the two inventory validity gate fields. */
 inline constexpr std::size_t kGateStatePaddingSize = 14;
 /** 16 reserved bytes separate the second gate from seen-message storage. */
@@ -76,6 +78,8 @@ inline constexpr std::size_t kSummaryDefinitionWordCount = 2;
 inline constexpr std::size_t kSummaryArrayCount = 2;
 /** Every set bit is the native absent definition index in an equipment-summary slot. */
 inline constexpr std::uint16_t kEmptySummaryDefinitionIndex = 0xFFFF;
+/** The Seals screen compares its record row against this field. */
+inline constexpr std::size_t kEquippedTitleRecordIndexOffset = 11'992;
 
 #pragma pack(push, 1)
 
@@ -146,7 +150,10 @@ struct Object {
     InventoryChangeList inventoryChanges{};
     std::array<std::uint64_t, kEquipmentCapacity> equippedInstanceSoids{};
     EquipmentSummary equipmentSummary{};
-    std::array<std::byte, kSummaryGatePaddingSize> summaryGatePadding{};
+    std::array<std::byte, kSummaryTitlePaddingSize> summaryTitlePadding{};
+    /** Native DestinyRecordDefinition row of the equipped title, or 0xFFFF. */
+    std::uint16_t equippedTitleRecordIndex{kEmptySummaryDefinitionIndex};
+    std::array<std::byte, kTitleGatePaddingSize> titleGatePadding{};
     /** 0 is a valid definition index and keeps the inventory-present gate open. */
     std::uint16_t inventoryGateDefinitionIndex{};
     std::array<std::byte, kGateStatePaddingSize> gateStatePadding{};
@@ -197,6 +204,7 @@ static_assert(sizeof(InventoryChangeList)
                      + kInventoryChangeRecordCapacity * sizeof(InventoryChangeRecord));
 static_assert(offsetof(InventoryChangeList, records) == 2 * sizeof(std::uint16_t));
 static_assert(sizeof(Object) == kObjectSize);
+static_assert(offsetof(Object, equippedTitleRecordIndex) == kEquippedTitleRecordIndexOffset);
 static_assert(std::is_trivially_copyable_v<Object>);
 
 } // namespace sunrise::middleware::datagen::family4::character::layout

+ 24 - 0
Sunrise/src/middleware/web_service/messages/opcode1821.h

@@ -0,0 +1,24 @@
+#pragma once
+
+#include <cstdint>
+
+#include "../web_service_envelope.h"
+
+namespace sunrise::middleware::web_service::messages::opcode1821 {
+
+/** Web Service opcode the Seals screen uses to equip one earned title. */
+inline constexpr std::uint16_t kOpcode = 1821;
+
+struct Request {
+    std::uint16_t recordIndex{};
+};
+
+/**
+ * Parses the exact biased signed title row carried by opcode 1821.
+ * Logical -1 becomes kUnequippedRecordIndex and clears the current title.
+ */
+[[nodiscard]] bool parse_request(const Message& message, Request& request) noexcept;
+
+inline constexpr std::uint16_t kUnequippedRecordIndex = 0xFFFFU;
+
+} // namespace sunrise::middleware::web_service::messages::opcode1821

+ 38 - 0
Sunrise/src/middleware/web_service/messages/opcode1821_codec.cpp

@@ -0,0 +1,38 @@
+#include <cstddef>
+
+#include "../../encoding/bit_reader.h"
+#include "opcode1821.h"
+
+namespace sunrise::middleware::web_service::messages::opcode1821 {
+namespace {
+
+constexpr std::size_t kPayloadSize = 3;
+constexpr std::uint8_t kRecordIndexWidth = 16;
+constexpr std::uint8_t kPaddingWidth = 8;
+constexpr std::uint64_t kRecordBias = 1ULL << 15U;
+
+} // namespace
+
+bool parse_request(const Message& message, Request& request) noexcept {
+    request = {};
+    if (message.opcode != kOpcode || message.payload.size() != kPayloadSize) {
+        return false;
+    }
+    encoding::bits::Reader reader(message.payload);
+    std::uint64_t encodedRecordIndex = 0;
+    std::uint64_t padding = 0;
+    if (!reader.read(kRecordIndexWidth, encodedRecordIndex)
+        || !reader.read(kPaddingWidth, padding) || reader.remaining_bits() != 0 || padding != 0) {
+        return false;
+    }
+    const std::int64_t logicalRecord = static_cast<std::int64_t>(encodedRecordIndex)
+                                       - static_cast<std::int64_t>(kRecordBias);
+    if (logicalRecord < -1 || logicalRecord > 0x7FFF) {
+        return false;
+    }
+    request.recordIndex = logicalRecord == -1 ? kUnequippedRecordIndex
+                                              : static_cast<std::uint16_t>(logicalRecord);
+    return true;
+}
+
+} // namespace sunrise::middleware::web_service::messages::opcode1821

+ 335 - 215
Sunrise/src/server/bap/encrypted/activity_message/receipts/activity_message_receipts.cpp

@@ -1,13 +1,16 @@
 /**
- * Framing handlers for every activity message that changes no State. Each one reads as much of its
- * body as the recovered grammar reaches, reports what it saw, and returns how completely the body
- * was read so the caller can record one arrival receipt. None of them acts on what it read.
+ * Framing handlers for activity messages. Each reads as much of its body as the recovered grammar
+ * reaches and returns how completely it was read so the caller can record one arrival receipt.
+ * Pickup incidents also grant their resolved lore record and request a fresh account image.
  */
 
-#include "../../../../../state/activity/current_activity.h"
-#include "../../../../../state/lore/lore_grant.h"
-#include "../../../../../state/build_data/collectibles/collectible_catalog.h"
+#include "../../../../../client/player/player_position.h"
+#include "../../../../../state/activity/destination/activity_destination_snapshot.h"
+#include "../../../../../state/activity/membership/activity_membership_query.h"
+#include "../../../../../state/activity/runtime.h"
+#include "../../../../../state/build_data/runtime.h"
 #include "../../../../../state/build_data/sobjects/sobject_catalog.h"
+#include "../../../../../state/lore/lore_grant.h"
 #include "../../../../bap/internal.h"
 #include "activity_message_receipts.h"
 
@@ -16,6 +19,7 @@
 #include <cstddef>
 #include <cstdint>
 #include <cstdio>
+#include <string_view>
 
 #include "../../../../../core/logging/log.h"
 #include "../../../../../middleware/bap/activity_message/activity_client_keepalive_validator.h"
@@ -82,6 +86,73 @@ void report(core::log::Level level, const char* format, ...) noexcept {
     return Verdict::malformed;
 }
 
+struct EggResolution {
+    state::lore::GrantOutcome outcome{state::lore::GrantOutcome::recordNotFound};
+    std::uint16_t record{};
+    bool resolved{};
+};
+
+/** Reports and resolves the live world context for an incident whose packet has no egg id. */
+[[nodiscard]] EggResolution resolve_egg_context() noexcept {
+    namespace activity = state::activity;
+    const client::player::position::Snapshot player = client::player::position::snapshot();
+    const std::uint64_t sessionId =
+        activity::membership::live_region_session(activity::kAbsentSessionId);
+    activity::destination::DestinationSelection selection{};
+    state::build_data::scenarios::Definition layout{};
+    const bool hasDestination = sessionId != activity::kAbsentSessionId
+                                && activity::destination::snapshot(sessionId, selection);
+    const std::string_view packageName{
+        reinterpret_cast<const char*>(selection.packageName.data()), selection.packageNameLength};
+    const bool hasLayout = hasDestination
+                           && state::build_data::find_scenario_layout(packageName, layout);
+    const std::string_view stem{layout.spawnStem.data(), layout.spawnStemLength};
+    state::build_data::spawn_sets::Point point{};
+    float distance = 0.0F;
+    const bool hasSpawn = player.present && hasLayout
+                          && state::build_data::find_nearest_spawn_point(
+                              stem, player.position, point, distance);
+    state::build_data::hash_names::Name name{};
+    const bool hasName = hasSpawn && state::build_data::find_hash_name(point.nameHash, name);
+    const std::string_view spawnName{name.name.data(), name.nameLength};
+    const std::int32_t region = sessionId == activity::kAbsentSessionId
+                                    ? -1
+                                    : activity::membership::reported_region(sessionId);
+    report(core::log::Level::info,
+           "ev=activity stage=lore_egg_context position=%s x=%.3f y=%.3f z=%.3f "
+           "session=0x%016llX region=%d package=%.*s stem=%.*s spawn=%s hash=0x%08X "
+           "name=%.*s distance=%.3f",
+           player.present ? "present" : "absent",
+           static_cast<double>(player.position[0]),
+           static_cast<double>(player.position[1]),
+           static_cast<double>(player.position[2]),
+           static_cast<unsigned long long>(sessionId),
+           region,
+           static_cast<int>(packageName.size()),
+           packageName.data(),
+           static_cast<int>(stem.size()),
+           stem.data(),
+           hasSpawn ? "found" : "absent",
+           hasSpawn ? point.nameHash : 0U,
+           static_cast<int>(hasName ? spawnName.size() : 0U),
+           hasName ? spawnName.data() : "",
+           static_cast<double>(hasSpawn ? distance : 0.0F));
+
+    // The community checklist groups its Egg #1 under Gardens of Esila / Imponent II while naming
+    // the physical location "Divalian Mists - Next to spawn". The reported region changes across
+    // loads at the same coordinates, so the stable nearest-spawn identity resolves that egg.
+    constexpr std::string_view kDreamingCityFreeroam = "dreaming_city_freeroam";
+    constexpr std::uint32_t kDivalianSpawnHash = 0xE3D5F2D5U;
+    constexpr float kDivalianSpawnRadius = 16.0F;
+    constexpr std::uint16_t kImponentTwoRecord = 40;
+    if (player.present && packageName == kDreamingCityFreeroam
+        && hasSpawn && point.nameHash == kDivalianSpawnHash
+        && distance <= kDivalianSpawnRadius) {
+        return {state::lore::advance_record(kImponentTwoRecord), kImponentTwoRecord, true};
+    }
+    return {};
+}
+
 } // namespace
 
 /** Frames a sensor sense update and reports its epoch. */
@@ -301,230 +372,279 @@ Framed frame_incident(const message::Request& request) noexcept {
            static_cast<unsigned>(parsed.hasOptionalBlock),
            parsed.payloadLength,
            parsed.headerBits);
-    // Resolve the target against the definition table it indexes, so an incident says what it is
-    // rather than carrying a bare number. Nothing acts on it yet; this is what acting on it needs.
+    // Resolve only the authored identity the incident names. A bubble cannot distinguish the
+    // collectible families that coexist in the Dreaming City, so unknown identities grant nothing.
     if (accepted) {
-        state::build_data::sobjects::Definition definition{};
-        const bool sobjectFound = state::build_data::sobjects::find(
-            static_cast<std::uint16_t>(parsed.primaryTarget), definition);
-        if (sobjectFound) {
-            report(core::log::Level::info,
-                   "ev=activity stage=sobject target=%u hash=0x%08X type=%d group=%u ordinal=%u "
-                   "lane4=0x%08X",
-                   parsed.primaryTarget,
-                   definition.nameHash,
-                   definition.typeCode,
-                   static_cast<unsigned>(definition.selectorGroup),
-                   static_cast<unsigned>(definition.nodeOrdinal),
-                   definition.lane4);
-        } else {
-            report(core::log::Level::warn,
-                   "ev=activity stage=sobject target=%u result=unresolved rows=%zu",
-                   parsed.primaryTarget,
-                   state::build_data::sobjects::count());
-        }
-
-        // The decoded world position, only present for the one validated message shape.
-        if (parsed.hasPosition) {
-            report(core::log::Level::info,
-                   "ev=activity stage=incident_pos x=%.4f y=%.4f z=%.4f",
-                   static_cast<double>(parsed.x),
-                   static_cast<double>(parsed.y),
-                   static_cast<double>(parsed.z));
-        }
-
-        // The extra targets, which have never been looked at. They are sobject indices too -- the
-        // parser range-checks them the same way -- and the primary target has turned out to name
-        // only the kind of object, not the instance: one pickup in the Menagerie and another on the
-        // Tangled Shore both reported target 3539. So if anything in the incident distinguishes one
-        // vase from another, this is where it can still be. Logged rather than acted on, because a
-        // guess about which extra means what is exactly the mistake this project keeps paying for.
-        for (std::uint32_t extra = 0; extra < parsed.extraTargetCount; ++extra) {
-            const std::uint32_t target = parsed.extraTargets[extra];
-            state::build_data::sobjects::Definition row{};
-            if (state::build_data::sobjects::find(static_cast<std::uint16_t>(target), row)) {
-                report(core::log::Level::info,
-                       "ev=activity stage=sobject_extra slot=%u target=%u hash=0x%08X type=%d "
-                       "lane4=0x%08X",
-                       extra,
-                       target,
-                       row.nameHash,
-                       row.typeCode,
-                       row.lane4);
-            } else {
-                report(core::log::Level::info,
-                       "ev=activity stage=sobject_extra slot=%u target=%u result=unresolved",
-                       extra,
-                       target);
-            }
-        }
-
-        // The type payload, which is what the schema describes. The parser separates it from
-        // the envelope; reading the whole message body instead puts every field at an offset the
-        // schema does not predict.
+        // Preserve the common-header size gate before acting on the incident.
         const std::span<const std::byte> body{parsed.payload.data(), parsed.payloadLength};
-
-        // The common header every type payload starts with: definition 0x80809512. A u32 sequence,
-        // then a three bit discriminator carrying a bias of one, then a 64 bit identity read only
-        // when that discriminator selects the player branch. Decoding it is the cheapest check that
-        // the payload is being read from the right place: the identity should be a SOID.
         if (body.size() >= 13) {
-            std::uint64_t cursor = 0;
-            const auto take = [&body, &cursor](std::size_t width) noexcept -> std::uint64_t {
-                std::uint64_t value = 0;
-                for (std::size_t step = 0; step < width; ++step) {
-                    const std::size_t at = cursor + step;
-                    const auto byte = static_cast<std::uint8_t>(body[at / 8]);
-                    value = (value << 1U) | ((byte >> (7 - (at % 8))) & 1U);
-                }
-                cursor += width;
-                return value;
+            struct Resolution {
+                state::lore::GrantOutcome outcome{state::lore::GrantOutcome::recordNotFound};
+                bool resolved{};
             };
-            const std::uint64_t sequence = take(32);
-            const std::uint64_t kindRaw = take(3);
-            const std::uint64_t identity = take(64);
-            // Which reward. Lane 4 of the target's sobject row names it exactly when the type code
-            // resolves one -- a type-10 row's low half is the record itself, no guessing needed --
-            // so that is tried first. Only when it does not resolve does the bubble heuristic below
-            // run: the payload does not name the chapter it granted -- its only per-object content
-            // is a position -- and the bubble at least says which activity the pickup happened in,
-            // which is a far smaller association (one book per activity) than one entry per object.
-            //
-            // The bubble sits at bit 126, the first of three consecutive u32 fields of the nested
-            // block. That position is measured: the same hash appears at 126, 158 and 190, and the
-            // character SOID lands at bit 35 exactly where the header schema puts it.
-            //
-            // Only type 2 carries this layout. Another type read against it yields a plausible
-            // number that means nothing, which is how a counter was once mistaken for an identity.
-            // A vase reports type 2 and a dead ghost type 10. Each type has its own schema, so
-            // the bubble sits somewhere different in each payload, and more types will turn up.
-            // The activity selection already named the bubble once, so it is read from there and
-            // the payload is not decoded at all.
-            constexpr std::array<std::int32_t, 2> kPickupTypeCodes{2, 10};
-            bool pickupType = false;
-            if (sobjectFound) {
-                for (const std::int32_t code : kPickupTypeCodes) {
-                    pickupType = pickupType || definition.typeCode == code;
+            constexpr std::uint32_t kCorruptedEggTarget = 693U;
+            constexpr std::uint32_t kCorruptedEggNameHash = 0x179A5E15U;
+            constexpr std::uint32_t kCorruptedEggLane4 = 0x0A06FFFFU;
+            const auto resolve = [](std::uint32_t target) noexcept -> Resolution {
+                state::build_data::sobjects::Definition definition{};
+                if (!state::build_data::sobjects::find(static_cast<std::uint16_t>(target), definition)) {
+                    return {};
                 }
-            }
-            if (pickupType) {
-                bool resolvedExactly = false;
                 if (definition.typeCode == 10) {
-                    // typeCode 10's lane-4 low half is a DestinyRecordDefinition row. 360 of 807
-                    // type-10 rows carry one that resolves; the rest fall through to the fallback
-                    // below exactly as they did before this path existed.
-                    const std::uint16_t recordRow = definition.recordRow();
-                    const state::lore::GrantOutcome outcome = state::lore::grant_record(recordRow);
-                    // A row whose lane 4 does not name a chapter is not an exact resolution at
-                    // all, only a number that fell inside the record table, so it falls back.
-                    resolvedExactly = outcome != state::lore::GrantOutcome::recordNotFound
-                                      && outcome != state::lore::GrantOutcome::notAChapter;
-                    if (resolvedExactly && outcome == state::lore::GrantOutcome::granted) {
-                        // Nothing else will stage an account image for this peer: a pickup is not a
-                        // web service transaction and has no response to carry the change back. Arm
-                        // every peer, the origin included, so the record appears without a relaunch.
-                        bap::arm_account_resync_everywhere();
+                    const auto outcome = state::lore::grant_record(definition.recordRow());
+                    return {outcome, outcome != state::lore::GrantOutcome::recordNotFound
+                                          && outcome != state::lore::GrantOutcome::notAChapter};
+                }
+                if (definition.typeCode == 2) {
+                    constexpr std::uint16_t kDroneFirstOrdinal = 2455U;
+                    constexpr std::uint16_t kDroneLastOrdinal = 2470U;
+                    // Four Forsaken Prince chapters are campaign rewards rather than Fallen
+                    // device collectibles, so the collectible records are not contiguous.
+                    constexpr std::array<std::uint16_t, 16> kDroneRecords{
+                        740U, 741U, 742U, 744U, 746U, 747U, 748U, 749U,
+                        750U, 751U, 752U, 753U, 754U, 755U, 756U, 757U,
+                    };
+                    constexpr std::uint16_t kGhostFirstOrdinal = 2471U;
+                    constexpr std::uint16_t kGhostLastOrdinal = 2493U;
+                    constexpr std::uint16_t kGhostFirstRecord = 802U;
+                    constexpr std::uint16_t kCrystalFirstOrdinal = 2494U;
+                    constexpr std::uint16_t kCrystalLastOrdinal = 2516U;
+                    constexpr std::uint16_t kCrystalFirstRecord = 778U;
+                    constexpr std::uint16_t kBoneFirstOrdinal = 2517U;
+                    constexpr std::uint16_t kBoneLastOrdinal = 2532U;
+                    constexpr std::uint16_t kBoneFirstRecord = 759U;
+                    const std::uint16_t ordinal = definition.loreObjectOrdinal();
+                    std::uint16_t record = 0;
+                    if (ordinal >= kDroneFirstOrdinal && ordinal <= kDroneLastOrdinal) {
+                        record = kDroneRecords[ordinal - kDroneFirstOrdinal];
+                    } else if (ordinal >= kGhostFirstOrdinal && ordinal <= kGhostLastOrdinal) {
+                        record = static_cast<std::uint16_t>(
+                            kGhostFirstRecord + ordinal - kGhostFirstOrdinal);
+                    } else if (ordinal >= kCrystalFirstOrdinal && ordinal <= kCrystalLastOrdinal) {
+                        record = static_cast<std::uint16_t>(
+                            kCrystalFirstRecord + ordinal - kCrystalFirstOrdinal);
+                    } else if (ordinal >= kBoneFirstOrdinal && ordinal <= kBoneLastOrdinal) {
+                        record = static_cast<std::uint16_t>(
+                            kBoneFirstRecord + ordinal - kBoneFirstOrdinal);
+                    } else {
+                        return {};
                     }
-                    if (resolvedExactly) {
-                        report(outcome == state::lore::GrantOutcome::granted
-                                   ? core::log::Level::info
-                                   : core::log::Level::warn,
-                               "ev=activity stage=lore path=exact type=10 target=%u lane4=0x%08X "
-                               "record=%u result=%s",
-                               parsed.primaryTarget,
-                               definition.lane4,
-                               static_cast<unsigned>(recordRow),
-                               state::lore::grant_outcome_name(outcome));
+                    const auto outcome = state::lore::grant_record(record);
+                    return {outcome, true};
+                }
+                return {};
+            };
+
+            Resolution resolution = resolve(parsed.primaryTarget);
+            std::uint32_t resolvedTarget = parsed.primaryTarget;
+            if (!resolution.resolved) {
+                for (std::uint32_t index = 0; index < parsed.extraTargetCount; ++index) {
+                    resolution = resolve(parsed.extraTargets[index]);
+                    if (resolution.resolved) {
+                        resolvedTarget = parsed.extraTargets[index];
+                        break;
                     }
-                } else if (definition.typeCode == 2) {
-                    // typeCode 2's lane-4 high half is a DestinyCollectibleDefinition row and
-                    // resolves 709 of 713 times, but nothing in this file's scope owns the
-                    // account-side collections write: record_claims only knows how to claim a
-                    // record's completion flag, and inventing a second mechanism here is exactly
-                    // what this pass was told not to do.
-                    // TODO(lore): grant the item behind collectibleRow once a collections claim
-                    // path exists. Until then this always falls back to the bubble table below.
-                    const std::uint16_t collectibleRow = definition.collectibleRow();
-                    state::build_data::collectibles::Definition collectible{};
-                    const bool collectibleResolves = state::build_data::collectibles::find(
-                        collectibleRow, collectible);
-                    report(core::log::Level::warn,
-                           "ev=activity stage=lore path=exact type=2 target=%u lane4=0x%08X "
-                           "collectible=%u resolves=%d result=not_implemented",
-                           parsed.primaryTarget,
-                           definition.lane4,
-                           static_cast<unsigned>(collectibleRow),
-                           collectibleResolves ? 1 : 0);
                 }
-                if (!resolvedExactly) {
-                    const std::uint32_t bubble = state::activity::current_bubble();
-                    state::lore::GrantOutcome outcome;
-                    const char* path;
-                    std::size_t bucketSize = 0;
-                    std::uint16_t node = state::lore::kNoBook;
-
-                    if (bubble == state::lore::kBubbleUnsetSentinel) {
-                        // caluseum_experience is instanced and so carries no real bubble at all --
-                        // every one of its pickups reports the FNV-1a offset basis here. That makes
-                        // this Confessions by construction, not a bubble_record_table lookup, so the
-                        // sentinel is recognised directly rather than ever being handed to the table.
-                        node = state::lore::kConfessionsNode;
-                        outcome = state::lore::grant_next_chapter(node);
-                        path = "instanced";
-                    } else {
-                        outcome = state::lore::grant_from_bubble_table(bubble, bucketSize);
-                        path = "bubble_table";
-                        if (outcome == state::lore::GrantOutcome::bubbleTableExhausted) {
-                            // Every candidate this bubble's bucket names is already held. Logged
-                            // distinctly here so an exhausted bucket reads differently in the logs
-                            // from a bubble the table never covered, then fall through below rather
-                            // than granting nothing silently.
-                            report(core::log::Level::warn,
-                                   "ev=activity stage=lore path=bubble_table bubble=0x%08X "
-                                   "bucket=%zu result=%s",
-                                   bubble, bucketSize, state::lore::grant_outcome_name(outcome));
-                        }
-                        if (outcome != state::lore::GrantOutcome::granted
-                            && outcome != state::lore::GrantOutcome::refused) {
-                            // Not in the table, or the table's own candidates are exhausted: fall
-                            // through to the same book path this always used. The generated table is
-                            // built from public map data that never recorded every bubble hosting a
-                            // pickup, so a bubble missing from it is a gap in that data rather than a
-                            // place nothing is collectable -- walk whatever book is known for it and
-                            // grant an arbitrary chapter rather than granting nothing.
-                            node = state::lore::legacy_book_for_bubble(bubble);
-                            outcome = state::lore::grant_next_chapter(node);
-                            path = "fallback";
+            }
+
+            state::build_data::sobjects::Definition primary{};
+            const bool primaryFound = state::build_data::sobjects::find(
+                static_cast<std::uint16_t>(parsed.primaryTarget), primary);
+            const bool isCorruptedEgg = parsed.primaryTarget == kCorruptedEggTarget && primaryFound
+                                        && primary.typeCode == 3
+                                        && primary.nameHash == kCorruptedEggNameHash
+                                        && primary.lane4 == kCorruptedEggLane4;
+            if (resolution.outcome == state::lore::GrantOutcome::granted
+                || resolution.outcome == state::lore::GrantOutcome::progressed) {
+                bap::arm_account_resync_everywhere();
+            }
+            if (resolution.resolved) {
+                state::build_data::sobjects::Definition exact{};
+                const bool exactFound = state::build_data::sobjects::find(
+                    static_cast<std::uint16_t>(resolvedTarget), exact);
+                report(resolution.outcome == state::lore::GrantOutcome::granted
+                           ? core::log::Level::info
+                           : core::log::Level::debug,
+                       "ev=activity stage=lore path=exact target=%u result=%s record=%u "
+                       "type=%d hash=0x%08X lanes=%08X,%08X,%08X,%08X,%08X,%08X,%08X,%08X",
+                       resolvedTarget, state::lore::grant_outcome_name(resolution.outcome),
+                       static_cast<unsigned>(
+                           resolution.outcome == state::lore::GrantOutcome::granted
+                                   || resolution.outcome == state::lore::GrantOutcome::progressed
+                               ? state::lore::last_granted_record()
+                               : 0),
+                       exactFound ? exact.typeCode : 0,
+                       exactFound ? exact.nameHash : 0U,
+                       exactFound ? exact.lanes[0] : 0U,
+                       exactFound ? exact.lanes[1] : 0U,
+                       exactFound ? exact.lanes[2] : 0U,
+                       exactFound ? exact.lanes[3] : 0U,
+                       exactFound ? exact.lanes[4] : 0U,
+                       exactFound ? exact.lanes[5] : 0U,
+                       exactFound ? exact.lanes[6] : 0U,
+                       exactFound ? exact.lanes[7] : 0U);
+            } else if (isCorruptedEgg) {
+                const EggResolution egg = resolve_egg_context();
+                if (egg.resolved
+                    && (egg.outcome == state::lore::GrantOutcome::granted
+                        || egg.outcome == state::lore::GrantOutcome::progressed)) {
+                    bap::arm_account_resync_everywhere();
+                }
+                report(core::log::Level::info,
+                       "ev=activity stage=lore path=egg result=%s target=%u type=%d "
+                       "lane4=0x%08X record=%u",
+                       egg.resolved ? state::lore::grant_outcome_name(egg.outcome) : "unresolved",
+                       parsed.primaryTarget,
+                       primary.typeCode,
+                       primary.lane4,
+                       static_cast<unsigned>(egg.resolved ? egg.record : 0));
+                if (core::log::accepts(core::log::Channel::server, core::log::Level::info)) {
+                    std::array<char, core::log::kLineCapacity> line{};
+                    const int prefix = std::snprintf(line.data(), line.size(),
+                                                     "ev=activity stage=lore_egg payload_bytes=%u "
+                                                     "payload_hex=",
+                                                     parsed.payloadLength);
+                    if (prefix > 0 && static_cast<std::size_t>(prefix) < line.size()) {
+                        std::size_t length = static_cast<std::size_t>(prefix);
+                        (void)core::log::append_hex(line, length, body);
+                        const int requestPrefix = std::snprintf(
+                            line.data() + length, line.size() - length,
+                            " request_bytes=%zu request_hex=", request.payload.size());
+                        if (requestPrefix > 0
+                            && static_cast<std::size_t>(requestPrefix) < line.size() - length) {
+                            length += static_cast<std::size_t>(requestPrefix);
+                            (void)core::log::append_hex(line, length, request.payload);
                         }
+                        core::log::write(core::log::Channel::server, core::log::Level::info,
+                                         {line.data(), length});
+                    }
+                }
+            } else {
+                bool contextResolved = false;
+                if (parsed.primaryTarget == 3539U) {
+                    namespace activity = state::activity;
+                    const auto player = client::player::position::snapshot();
+                    const std::uint64_t sessionId = activity::membership::live_region_session(
+                        activity::kAbsentSessionId);
+                    activity::destination::DestinationSelection selection{};
+                    (void)activity::destination::snapshot(sessionId, selection);
+                    const std::string_view packageName{
+                        reinterpret_cast<const char*>(selection.packageName.data()),
+                        selection.packageNameLength};
+                    report(core::log::Level::info,
+                           "ev=activity stage=lore_generic_context target=%u type=%d "
+                           "hash=0x%08X lane4=0x%08X position=%s "
+                           "x=%.3f y=%.3f z=%.3f region=%d package=%.*s extras=%u",
+                           parsed.primaryTarget,
+                           primaryFound ? primary.typeCode : 0,
+                           primaryFound ? primary.nameHash : 0U,
+                           primaryFound ? primary.lane4 : 0U,
+                           player.present ? "present" : "absent",
+                           static_cast<double>(player.position[0]),
+                           static_cast<double>(player.position[1]),
+                           static_cast<double>(player.position[2]),
+                           sessionId == activity::kAbsentSessionId
+                               ? -1
+                               : activity::membership::reported_region(sessionId),
+                           static_cast<int>(packageName.size()),
+                           packageName.data(),
+                           parsed.extraTargetCount);
+                    for (std::uint32_t index = 0; index < parsed.extraTargetCount; ++index) {
+                        state::build_data::sobjects::Definition extra{};
+                        const bool found = state::build_data::sobjects::find(
+                            static_cast<std::uint16_t>(parsed.extraTargets[index]), extra);
+                        report(core::log::Level::info,
+                               "ev=activity stage=lore_generic_extra slot=%u target=%u found=%u "
+                               "type=%d hash=0x%08X lanes=%08X,%08X,%08X,%08X,%08X,%08X,%08X,%08X",
+                               index,
+                               parsed.extraTargets[index],
+                               found ? 1U : 0U,
+                               found ? extra.typeCode : 0,
+                               found ? extra.nameHash : 0U,
+                               found ? extra.lanes[0] : 0U,
+                               found ? extra.lanes[1] : 0U,
+                               found ? extra.lanes[2] : 0U,
+                               found ? extra.lanes[3] : 0U,
+                               found ? extra.lanes[4] : 0U,
+                               found ? extra.lanes[5] : 0U,
+                               found ? extra.lanes[6] : 0U,
+                               found ? extra.lanes[7] : 0U);
                     }
 
-                    if (outcome == state::lore::GrantOutcome::granted) {
-                        // Nothing else will stage an account image for this peer: a pickup is not a web
-                        // service transaction and has no response to carry the change back. Arm every
-                        // peer, the origin included, so the chapter appears without a relaunch and a
-                        // second pickup in the same run sees the first one already held.
-                        bap::arm_account_resync_everywhere();
+                    // Confessions vases have no per-object target. Their interaction positions are
+                    // stable across captures, so each measured centre resolves its authored entry.
+                    struct ConfessionsVase {
+                        std::array<float, 3> position;
+                        std::uint16_t record;
+                    };
+                    constexpr std::string_view kMenageriePackage = "caluseum_experience";
+                    constexpr std::array<ConfessionsVase, 8> kConfessionsVases{{
+                        {{30.559F, 31.233F, -2.185F}, 1708U},   // Entry I, Lamplighting
+                        {{57.683F, 8.844F, -43.121F}, 1709U},  // Entry II, The Hunted
+                        {{61.939F, 220.947F, 2.439F}, 1710U},  // Entry III, Royal Theatre
+                        {{143.082F, -23.093F, 11.629F}, 1711U}, // Entry IV, War Beast statue
+                        {{109.207F, 211.327F, -144.309F}, 1712U}, // Entry V, Gauntlet
+                        {{403.643F, -5.111F, 6.669F}, 1713U},  // Entry VI, Crown of Sorrow
+                        {{947.203F, 2.456F, 131.591F}, 1714U},  // Entry VII, Crown of Sorrow
+                        {{1138.881F, 89.454F, 94.136F}, 1715U}, // Entry VIII, Crown of Sorrow
+                    }};
+                    constexpr float kConfessionsVaseRadiusSquared = 36.0F;
+                    constexpr std::string_view kTributeHallPackage = "trophy_hall_freeroam";
+                    constexpr std::array<float, 3> kConfessionsEntryNinePosition{
+                        25.642F, 0.012F, 5.922F};
+                    if (player.present && packageName == kTributeHallPackage) {
+                        const float dx = player.position[0] - kConfessionsEntryNinePosition[0];
+                        const float dy = player.position[1] - kConfessionsEntryNinePosition[1];
+                        const float dz = player.position[2] - kConfessionsEntryNinePosition[2];
+                        if (dx * dx + dy * dy + dz * dz <= kConfessionsVaseRadiusSquared) {
+                            constexpr std::uint16_t kConfessionsEntryNineRecord = 1716U;
+                            const auto outcome =
+                                state::lore::grant_record(kConfessionsEntryNineRecord);
+                            contextResolved = outcome != state::lore::GrantOutcome::recordNotFound
+                                              && outcome != state::lore::GrantOutcome::notAChapter;
+                            if (outcome == state::lore::GrantOutcome::granted) {
+                                bap::arm_account_resync_everywhere();
+                            }
+                            report(core::log::Level::info,
+                                   "ev=activity stage=lore path=position target=%u package=%.*s "
+                                   "record=%u result=%s",
+                                   parsed.primaryTarget,
+                                   static_cast<int>(packageName.size()), packageName.data(),
+                                   static_cast<unsigned>(kConfessionsEntryNineRecord),
+                                   state::lore::grant_outcome_name(outcome));
+                        }
+                    } else if (player.present && packageName == kMenageriePackage) {
+                        for (const ConfessionsVase& vase : kConfessionsVases) {
+                            const float dx = player.position[0] - vase.position[0];
+                            const float dy = player.position[1] - vase.position[1];
+                            const float dz = player.position[2] - vase.position[2];
+                            if (dx * dx + dy * dy + dz * dz > kConfessionsVaseRadiusSquared) {
+                                continue;
+                            }
+                            const auto outcome = state::lore::grant_record(vase.record);
+                            contextResolved = outcome != state::lore::GrantOutcome::recordNotFound
+                                              && outcome != state::lore::GrantOutcome::notAChapter;
+                            if (outcome == state::lore::GrantOutcome::granted) {
+                                bap::arm_account_resync_everywhere();
+                            }
+                            report(core::log::Level::info,
+                                   "ev=activity stage=lore path=position target=%u package=%.*s "
+                                   "record=%u result=%s",
+                                   parsed.primaryTarget,
+                                   static_cast<int>(packageName.size()),
+                                   packageName.data(),
+                                   static_cast<unsigned>(vase.record),
+                                   state::lore::grant_outcome_name(outcome));
+                            break;
+                        }
                     }
-                    report(outcome == state::lore::GrantOutcome::granted ? core::log::Level::info
-                                                                         : core::log::Level::warn,
-                           "ev=activity stage=lore path=%s bubble=0x%08X node=%u bucket=%zu "
-                           "result=%s record=%u item=%d",
-                           path, bubble, static_cast<unsigned>(node), bucketSize,
-                           state::lore::grant_outcome_name(outcome),
-                           static_cast<unsigned>(outcome == state::lore::GrantOutcome::granted
-                                                     ? state::lore::last_granted_record()
-                                                     : 0),
-                           state::lore::last_item_granted() ? 1 : 0);
+                }
+                if (!contextResolved) {
+                    report(core::log::Level::debug,
+                           "ev=activity stage=lore path=unresolved target=%u extra=%u",
+                           parsed.primaryTarget, parsed.extraTargetCount);
                 }
             }
-
-            report(core::log::Level::info,
-                   "ev=activity stage=header target=%u sequence=%llu kind_raw=%llu "
-                   "identity=0x%016llX",
-                   parsed.primaryTarget,
-                   static_cast<unsigned long long>(sequence),
-                   static_cast<unsigned long long>(kindRaw),
-                   static_cast<unsigned long long>(identity));
         }
     }
 

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

@@ -197,6 +197,26 @@ bool process(const ServiceRoute& route,
         if (!sunrise::server::web_service::consume(requestBody, output, written, webOutcome)) {
             return false;
         }
+        if (webOutcome.hasTitleEquip) {
+            // The title lives in both character summary records, so refresh the roster and banner
+            // after the correlated success reply. Promise the exact Family-4 revision that carries
+            // the title field, matching every other optimistic character-screen action; otherwise
+            // the open Seals widget redraws its label but retains its pre-click action binding.
+            middleware::web_service::StatusResponse status{};
+            status.value = queuezState.family4Version + 1;
+            if (!middleware::web_service::encode_response(
+                    message,
+                    middleware::web_service::ResponseShape::statusPair,
+                    status,
+                    output,
+                    written)) {
+                core::log::write(core::log::Channel::server,
+                                 core::log::Level::warn,
+                                 "ev=title_equip stage=response result=fail");
+                return false;
+            }
+            sunrise::server::bap::arm_account_resync_everywhere();
+        }
         outcome.hasSubscription = webOutcome.hasSubscription;
         outcome.hasRecordClaim = webOutcome.hasRecordClaim;
         outcome.subscription = webOutcome.subscription;

+ 62 - 0
Sunrise/src/server/web_service/web_service_actions.cpp

@@ -8,6 +8,7 @@
 
 #include "../../core/logging/log.h"
 #include "../../middleware/web_service/messages/opcode1801.h"
+#include "../../middleware/web_service/messages/opcode1821.h"
 #include "../../state/record_claims/record_claims.h"
 #include "../../middleware/web_service/messages/opcode1820.h"
 #include "../../middleware/web_service/messages/opcode1901.h"
@@ -1036,4 +1037,65 @@ void claim_record(const middleware::web_service::Message& message, Outcome& outc
     grant_record_reward(message, request.recordIndex, definition.definitionHash, outcome);
 }
 
+/** Equips an earned title record on the selected character. */
+void equip_title(const middleware::web_service::Message& message, Outcome& outcome) noexcept {
+    namespace records = state::build_data::records;
+    middleware::web_service::messages::opcode1821::Request request{};
+    records::Definition definition{};
+    std::uint64_t characterSoid = 0;
+    bool changed = false;
+    const char* result = "fail";
+    const char* reason = "payload_bits";
+    if (middleware::web_service::messages::opcode1821::parse_request(message, request)) {
+        if (request.recordIndex
+            == middleware::web_service::messages::opcode1821::kUnequippedRecordIndex) {
+            reason = "selected_character";
+            if (state::set_selected_title(
+                    state::kUnequippedTitleRecordIndex, characterSoid, changed)) {
+                outcome.hasTitleEquip = true;
+                result = "ok";
+                reason = "unequipped";
+            }
+        } else {
+            reason = "record_definition";
+            if (state::build_data::find_record_definition(request.recordIndex, definition)) {
+                reason = "not_title";
+                if (definition.hasTitle) {
+                    reason = "not_claimed";
+                    if (definition.completionFlagIndex != records::kUnavailableFlagIndex
+                        && state::record_claims::claimed(definition.completionFlagIndex)) {
+                        reason = "selected_character";
+                        if (state::set_selected_title(
+                                request.recordIndex, characterSoid, changed)) {
+                            outcome.hasTitleEquip = true;
+                            result = "ok";
+                            reason = changed ? "equipped" : "already_equipped";
+                        }
+                    }
+                }
+            }
+        }
+    }
+    std::array<char, core::log::kLineCapacity> line{};
+    const int count = std::snprintf(
+        line.data(),
+        line.size(),
+        "ev=title_equip result=%s reason=%s opcode=%u transaction=%u record=%u "
+        "definition_hash=0x%08X completion_flag=%u character=0x%llX changed=%u",
+        result,
+        reason,
+        static_cast<unsigned>(message.opcode),
+        static_cast<unsigned>(message.transactionId),
+        static_cast<unsigned>(request.recordIndex),
+        definition.definitionHash,
+        static_cast<unsigned>(definition.completionFlagIndex),
+        static_cast<unsigned long long>(characterSoid),
+        changed ? 1U : 0U);
+    if (count > 0) {
+        core::log::write(core::log::Channel::server,
+                         outcome.hasTitleEquip ? core::log::Level::debug : core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(count)});
+    }
+}
+
 } // namespace sunrise::server::web_service

+ 3 - 0
Sunrise/src/server/web_service/web_service_actions.h

@@ -33,4 +33,7 @@ void acquire_item(const middleware::web_service::Message& message, Outcome& outc
  */
 void claim_record(const middleware::web_service::Message& message, Outcome& outcome) noexcept;
 
+/** Decodes and applies one opcode-1821 earned-title selection. */
+void equip_title(const middleware::web_service::Message& message, Outcome& outcome) noexcept;
+
 } // namespace sunrise::server::web_service

+ 5 - 1
Sunrise/src/server/web_service/web_service_runtime.cpp

@@ -14,6 +14,7 @@
 #include "../../middleware/web_service/messages/opcode1901.h"
 #include "../../middleware/web_service/messages/opcode205.h"
 #include "../../middleware/web_service/messages/opcode1801.h"
+#include "../../middleware/web_service/messages/opcode1821.h"
 #include "../../middleware/web_service/messages/opcode206.h"
 #include "../../middleware/web_service/messages/opcode501_codec.h"
 #include "../../middleware/web_service/messages/opcode503.h"
@@ -292,6 +293,8 @@ bool consume(std::span<const std::byte> request,
         mutate_equipment(message, true, outcome);
     } else if (message.opcode == middleware::web_service::messages::opcode801::kOpcode) {
         mutate_subclass_selection(message, outcome);
+    } else if (message.opcode == middleware::web_service::messages::opcode1821::kOpcode) {
+        equip_title(message, outcome);
     } else if (message.opcode == middleware::web_service::messages::opcode903::kOpcode) {
         mutate_socket_plug(message, outcome);
     } else if (message.opcode == middleware::web_service::messages::opcode1901::kOpcode) {
@@ -303,7 +306,8 @@ bool consume(std::span<const std::byte> request,
     } else {
         dispatched = false;
     }
-    const bool prepared = outcome.hasSelectedCharacter || outcome.mutation.index() != kNoMutation;
+    const bool prepared = outcome.hasSelectedCharacter || outcome.hasTitleEquip
+                          || outcome.mutation.index() != kNoMutation;
 
     middleware::web_service::ResponseShape shape{};
     resolve_response_shape(message.opcode, shape);

+ 2 - 0
Sunrise/src/server/web_service/web_service_runtime.h

@@ -16,6 +16,8 @@ struct Outcome {
     middleware::queuez::Subscription subscription{};
     /** A claim changed the account flag bank, so a fresh account image has to follow. */
     bool hasRecordClaim{};
+    /** An earned title changed on the selected character; roster and banner must be republished. */
+    bool hasTitleEquip{};
     /** An opcode-504 pick moved the selection and its Family-4 object still has to follow. */
     bool hasSelectedCharacter{};
     std::uint64_t selectedCharacterSoid{};

+ 4 - 0
Sunrise/src/state/account/account_state.h

@@ -15,6 +15,8 @@ inline constexpr std::size_t kCharacterCapacity = 3;
 inline constexpr std::size_t kDismantleRewardPolicyCapacity = 32;
 /** A server-authored record-reward policy: one row per rewarded Triumph. */
 inline constexpr std::size_t kRecordRewardPolicyCapacity = 256;
+/** Native sentinel used when a character has no title equipped. */
+inline constexpr std::uint16_t kUnequippedTitleRecordIndex = 0xFFFFU;
 
 /** Gear classes a dismantle payout row can be limited to. */
 enum class DismantleGearClass : std::uint8_t {
@@ -168,6 +170,8 @@ struct CharacterState {
     std::uint32_t lastOrbitedDestination{};
     /** Server policy that arms content checks only with the matching family-5 flag. */
     bool contentBypass{};
+    /** Native DestinyRecordDefinition row of the equipped earned title. */
+    std::uint16_t equippedTitleRecordIndex{kUnequippedTitleRecordIndex};
     /**
      * Runtime-only socket entries the player has selected at least once. Selected entries still
      * publish active; this mask keeps a later inactive entry acquired instead of new. Unverified:

+ 6 - 0
Sunrise/src/state/build_data/cache/records/cache_investment_records.cpp

@@ -88,6 +88,8 @@ bool encode(const build_data::records::Definition& value,
         value.loreRow,
         value.scoreValue,
         value.categoryValueIndex,
+        static_cast<std::uint8_t>(value.hasTitle),
+        0,
     };
     return true;
 }
@@ -104,6 +106,10 @@ bool decode(const RecordDefinitionRecord& record,
     value.loreRow = record.loreRow;
     value.scoreValue = record.scoreValue;
     value.categoryValueIndex = record.categoryValueIndex;
+    if (record.hasTitle > 1 || record.reserved != 0) {
+        return false;
+    }
+    value.hasTitle = record.hasTitle != 0;
     return true;
 }
 

+ 5 - 2
Sunrise/src/state/build_data/cache/records/format.h

@@ -35,7 +35,7 @@ inline constexpr std::array<char, 8> kCacheMagic{'S', 'U', 'N', 'R', 'I', 'S', '
  *     which surfaced as claims resolving to the score value instead of the flag index. Bumped so
  *     the stale shape is rejected and rebuilt instead of misread.
  */
-inline constexpr std::uint32_t kCacheFormatVersion = 46;
+inline constexpr std::uint32_t kCacheFormatVersion = 47;
 /** Signed -1 on disk means there is no equipment slot. */
 inline constexpr std::int8_t kAbsentEquipmentSlot = -1;
 /** The standard 64-bit FNV-1a offset basis starts the payload checksum. */
@@ -258,6 +258,8 @@ struct RecordDefinitionRecord {
     std::uint16_t loreRow{};
     std::uint16_t scoreValue{};
     std::uint16_t categoryValueIndex{};
+    std::uint8_t hasTitle{};
+    std::uint8_t reserved{};
 };
 
 /** Disk form of one dense socket-entry-list definition. */
@@ -484,7 +486,8 @@ static_assert(sizeof(RosterGroupRecord)
                      + scenarios::kRosterSlotCapacity * sizeof(std::uint16_t));
 static_assert(sizeof(ProgressionRecord) == sizeof(std::uint16_t) + 2 * sizeof(std::uint8_t));
 static_assert(sizeof(RecordDefinitionRecord)
-              == sizeof(std::uint16_t) + sizeof(std::uint32_t) + 4 * sizeof(std::uint16_t));
+              == sizeof(std::uint16_t) + sizeof(std::uint32_t) + 4 * sizeof(std::uint16_t)
+                     + 2 * sizeof(std::uint8_t));
 static_assert(sizeof(AbilityBucketRecord)
               == sizeof(std::uint16_t) + 6 * sizeof(std::uint8_t)
                      + 2 * abilities::kBucketCapacity * sizeof(std::uint8_t)

+ 3 - 39
Sunrise/src/state/build_data/nodes/node_catalog.cpp

@@ -1,10 +1,6 @@
 #include "node_catalog.h"
 
 #include "../../record_claims/objective_slot_table.h"
-#include "../../record_claims/parent_bar_table.h"
-#include "../../../core/logging/log.h"
-#include <cstdio>
-#include <array>
 
 #include "../../unlocks/definition.h"
 #include "../table.h"
@@ -15,18 +11,6 @@ namespace {
 Lock g_lock;
 Table<Definition, kDefinitionCapacity> g_definitions;
 
-/**
- * First account value-bank slot the record-objective allocation owns.
- *
- * Mirrors objective_slot_table::kObjectives.front().slot (2746), documented in
- * record_claims.cpp/objective_slot_table.h as the base of the 2746-5686 record-objective range.
- * Not read from that table directly: build_data must not depend upward on record_claims, so the
- * boundary is restated here as its own constant rather than reached for across the layer.
- * Three lore nodes (820, 835, 837) name a valueIndex inside this range -- writing to it has
- * previously trampled record objectives wholesale, so this guard exists to keep this pass off it.
- */
-
-
 } // namespace
 
 /** Clears every generated node definition under the catalog lock. */
@@ -110,6 +94,7 @@ std::size_t apply_visibility(std::span<std::uint8_t> accountFlags) noexcept {
 
 /** Sets the value-gate of every lore book category that has no flag gate at all. */
 std::size_t apply_category_gates(std::span<std::int32_t> objectiveValues, bool revealAll) noexcept {
+    static_cast<void>(revealAll);
     const Lock::Shared guard(g_lock);
     std::size_t set = 0;
     for (const Definition& node : g_definitions.rows()) {
@@ -132,35 +117,14 @@ std::size_t apply_category_gates(std::span<std::int32_t> objectiveValues, bool r
         if (static_cast<std::int32_t>(node.valueIndex) >= record_claims::objective_slot_table::kRecordObjectiveRangeStart) {
             continue;
         }
-        // Whether this gate is the book's own bar decides who is allowed to open it. Ten books
-        // read their gate from the very slot their bar counts into, so collecting an entry opens
-        // them by itself, exactly as the live game does -- forcing those is a presentation choice
-        // and stays behind revealAll. The other eight name a gate slot distinct from their bar:
-        // nothing on this server ever writes it, because on a real account it is set when the book
-        // is acquired from a quest or vendor. That is the same acquisition marker apply_visibility
-        // already publishes for the flag-gated books, just held in the value bank instead, so it
-        // is satisfied here unconditionally for the same reason.
-        // Every value-gated category is published, with no distinction between them. All
-        // eighteen carry the identical gate -- READ_VALUE on their own slot, op11, op8 -- so
-        // there is no reading of the shipped data on which some of them should be satisfied and
-        // others left shut. An earlier version skipped the ten whose gate index equals their bar
-        // index, on the theory that writing one would falsify the other. It does not: this pass
-        // only ever raises a zero, so those ten read 1 on their parent triumph while nothing is
-        // claimed and the true count the moment anything is, which is the same bargain the other
-        // eight already make.
+        // Every value-gated category carries the same READ_VALUE-based not-zero condition. Publish
+        // an acquisition sentinel for an empty gate, whether or not that slot also drives a bar.
         // Never lower a value already written -- a non-zero slot is either already open or holds a
         // count from elsewhere, and this pass only ever needs to prove the gate, never reset it.
         // Where the gate index is also the bar index, a 1 shows as a false claim on the book's
         // parent triumph. A negative value satisfies a not-zero test while clamping out of the
         // bar's display range, so it is tried there instead -- the shipped data uses -1 as a
         // sentinel elsewhere (node 896 carries one, as does the character bank).
-        bool sameAsBar = false;
-        for (const auto& bar : record_claims::parent_bar_table::kBars) {
-            if (bar.nodeIndex == node.definitionIndex) {
-                sameAsBar = bar.valueIndex == node.valueIndex;
-                break;
-            }
-        }
         if (objectiveValues[node.valueIndex] == 0) {
             objectiveValues[node.valueIndex] = -1;
             ++set;

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

@@ -76,6 +76,7 @@ std::size_t apply_character_visibility(std::span<std::byte> characterFlags) noex
  * pass that can touch the value bank -- writing a bar count zeroes the same slot on a mis-sourced
  * table entry, and once that gate is zero the book stays hidden for the rest of the image.
  * @param objectiveValues Bank already filled by the authored policy and every value-writing pass.
+ * @param revealAll Retained for configuration compatibility; value-gated books are always opened.
  * @return Number of gates set.
  */
 std::size_t apply_category_gates(std::span<std::int32_t> objectiveValues,

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

@@ -62,6 +62,8 @@ struct Definition {
      * the parent from the chapters beneath it. The parent is excluded from its own progress bar.
      */
     std::uint16_t categoryValueIndex{kUnavailableValueIndex};
+    /** True only when this record grants a character-equippable title. */
+    bool hasTitle{};
 };
 
 } // namespace sunrise::state::build_data::records

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

@@ -19,8 +19,6 @@
 #include "items/socket_plugs/definition.h"
 #include "material_requirements/material_requirement_catalog.h"
 #include "progressions/definition.h"
-#include "nodes/definition.h"
-#include "records/definition.h"
 #include "scenarios/definition.h"
 #include "socket_entry_buckets/definition.h"
 #include "socket_entry_lists/definition.h"
@@ -98,41 +96,6 @@ publish_item_definitions(std::span<const items::Definition> definitions) noexcep
 [[nodiscard]] bool find_item_definition_index(std::uint16_t definitionIndex,
                                               items::Definition& definition) noexcept;
 
-/** @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;
-
-/**
- * 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;
-
 /**
  * Finds one manifest-sourced reward for a claimed record, from the shipped generated table.
  *

+ 11 - 8
Sunrise/src/state/build_data/sobjects/sobject_catalog.h

@@ -1,5 +1,6 @@
 #pragma once
 
+#include <array>
 #include <cstddef>
 #include <cstdint>
 #include <span>
@@ -10,8 +11,8 @@ namespace sunrise::state::build_data::sobjects {
  * The definition table an incident target names.
  *
  * An incident is the client's one general typed gameplay event, and its target is a 13-bit index
- * into this table. Nothing acts on an incident today, so a collectible picked up in the world tells
- * the server nothing. Resolving the target is the first step of acting on one.
+ * into this table. Pickup handling resolves that target to identify an exact record where the
+ * installed row provides one, or to select the appropriate fallback path.
  *
  * The table is shipped package data. Its class is `0x80807C9B` and two installed tags carry it with
  * byte-identical hash and type-code columns, so a row index means the same thing against either.
@@ -25,6 +26,8 @@ inline constexpr std::int32_t kAbsentTypeCode = -1;
 
 /** One row of the table, reduced to what resolving a target needs. */
 struct Definition {
+    /** The eight opaque 32-bit lanes preceding the selector and type fields. */
+    std::array<std::uint32_t, 8> lanes{};
     /** FNV-1 of the definition name. */
     std::uint32_t nameHash{};
     /** Selects the payload shape. Runs -1 to 48. */
@@ -36,10 +39,9 @@ struct Definition {
     /**
      * Row offset +16, "lane 4": a packed pair of u16s whose meaning is chosen by typeCode, not
      * fixed. On typeCode 10 (dead ghosts and similar) the low half is a DestinyRecordDefinition row
-     * index -- 807 rows carry this type, 360 of them resolving. On typeCode 2 (lore vases and
-     * similar) the high half is a DestinyCollectibleDefinition row index -- 713 rows carry this
-     * type, 709 resolving. Kept raw and read through the accessors below so a caller cannot reach
-     * into the wrong half without first deciding, from typeCode, which one applies.
+     * index -- 807 rows carry this type, 360 of them resolving. On type-code 2 world lore objects,
+     * the high half is a lore-object ordinal rather than a DestinyCollectibleDefinition row. The
+     * installed crystal and bone runs are contiguous and follow their manifest chapter order.
      */
     std::uint32_t lane4{};
 
@@ -48,10 +50,11 @@ struct Definition {
         return static_cast<std::uint16_t>(lane4 & 0xFFFFU);
     }
 
-    /** @return Lane 4's high half, the collectible row a typeCode-2 sobject names. */
-    [[nodiscard]] constexpr std::uint16_t collectibleRow() const noexcept {
+    /** @return Lane 4's high half, the world-lore ordinal a type-code 2 sobject names. */
+    [[nodiscard]] constexpr std::uint16_t loreObjectOrdinal() const noexcept {
         return static_cast<std::uint16_t>(lane4 >> 16U);
     }
+
 };
 
 /** Clears every generated row. */

+ 60 - 145
Sunrise/src/state/lore/lore_grant.cpp

@@ -1,45 +1,27 @@
 #include "lore_grant.h"
 
 #include <atomic>
-#include <cstddef>
-#include <span>
-#include <vector>
-
-#include "../build_data/nodes/definition.h"
-#include "../build_data/nodes/node_catalog.h"
-#include "../build_data/nodes/node_persistence.h"
+#include "../build_data/collectibles/collectible_catalog.h"
 #include "../build_data/records/definition.h"
 #include "../build_data/records/record_catalog.h"
 #include "../build_data/records/record_persistence.h"
 #include "../build_data/runtime.h"
 #include "../record_claims/record_claims.h"
-#include "bubble_record_table.h"
 
 namespace sunrise::state::lore {
 namespace {
 
 std::atomic<std::uint16_t> g_lastGranted{0};
-std::atomic<bool> g_lastItemGranted{false};
 
-/**
- * Publishes the node and record tables once, so a warm start does not run either path empty.
- *
- * On a warm start the package pass is skipped, so neither table is published until something asks
- * for it. Both grant paths need the record table and the book path also needs the node table, so
- * both are brought up together here; a caller that only needs records still pays for nodes once,
- * which costs nothing once the tables are up and is simpler than tracking each domain apart.
- * @return True once both tables are known published, this call or an earlier one.
- */
-bool ensure_tables_published() noexcept {
-    namespace nodes = build_data::nodes;
+/** Publishes the record table once, so a warm start does not resolve exact rows empty. */
+bool ensure_records_published() noexcept {
     namespace records = build_data::records;
     static std::atomic<bool> published{false};
     if (published.load(std::memory_order_relaxed)) {
         return true;
     }
-    const bool haveNodes = nodes::count() != 0 || nodes::load_and_publish();
     const bool haveRecords = records::count() != 0 || records::load_and_publish();
-    if (haveNodes && haveRecords) {
+    if (haveRecords) {
         published.store(true, std::memory_order_relaxed);
         return true;
     }
@@ -53,20 +35,8 @@ const char* grant_outcome_name(GrantOutcome outcome) noexcept {
     switch (outcome) {
     case GrantOutcome::granted:
         return "granted";
-    case GrantOutcome::unknownBook:
-        return "unknown_book";
-    case GrantOutcome::noNodeTable:
-        return "no_node_table";
-    case GrantOutcome::bookNotFound:
-        return "book_not_found";
-    case GrantOutcome::noChildren:
-        return "no_children";
-    case GrantOutcome::noRecords:
-        return "no_records";
-    case GrantOutcome::noChapters:
-        return "no_chapters";
-    case GrantOutcome::bookComplete:
-        return "book_complete";
+    case GrantOutcome::progressed:
+        return "progressed";
     case GrantOutcome::refused:
         return "refused";
     case GrantOutcome::recordNotFound:
@@ -77,81 +47,20 @@ const char* grant_outcome_name(GrantOutcome outcome) noexcept {
         return "already_held";
     case GrantOutcome::notAChapter:
         return "not_a_chapter";
-    case GrantOutcome::bubbleTableExhausted:
-        return "bubble_table_exhausted";
+    case GrantOutcome::collectibleNotFound:
+        return "collectible_not_found";
+    case GrantOutcome::collectibleNoLore:
+        return "collectible_no_lore";
+    case GrantOutcome::loreRecordNotFound:
+        return "lore_record_not_found";
     }
     return "unknown";
 }
 
-/** Grants the next chapter of one book that the account does not already hold. */
-GrantOutcome grant_next_chapter(std::uint16_t node) noexcept {
-    if (node == kNoBook) {
-        return GrantOutcome::unknownBook;
-    }
-
-    namespace nodes = build_data::nodes;
-    namespace records = build_data::records;
-    (void)ensure_tables_published();
-    std::vector<nodes::Definition> rows(nodes::kDefinitionCapacity);
-    std::size_t count = 0;
-    if (!nodes::snapshot(std::span<nodes::Definition>{rows}, count) || count == 0) {
-        return GrantOutcome::noNodeTable;
-    }
-
-    const nodes::Definition* book = nullptr;
-    for (std::size_t row = 0; row < count; ++row) {
-        if (rows[row].definitionIndex == node) {
-            book = &rows[row];
-            break;
-        }
-    }
-    if (book == nullptr) {
-        return GrantOutcome::bookNotFound;
-    }
-    if (book->childCount == 0) {
-        return GrantOutcome::noChildren;
-    }
-
-    bool sawRecord = false;
-    bool sawChapter = false;
-    for (std::size_t child = 0; child < book->childCount; ++child) {
-        records::Definition record{};
-        if (!build_data::find_record_definition(book->children[child], record)) {
-            continue;
-        }
-        sawRecord = true;
-        if (record.completionFlagIndex == records::kUnavailableFlagIndex) {
-            continue;
-        }
-        // A child naming no lore row is the book's parent triumph, not a chapter. Granting it would
-        // mark the book complete without giving any of its contents.
-        if (record.loreRow == records::kUnavailableLoreRow) {
-            continue;
-        }
-        sawChapter = true;
-        // Finding lore completes a chapter; claiming it is the player's act, not this one. So the
-        // record is left claimable rather than claimed, and a chapter already in either state is
-        // passed over.
-        if (record_claims::claimed(record.completionFlagIndex)
-            || record_claims::claimable(record.completionFlagIndex)) {
-            continue;
-        }
-        if (!record_claims::mark_claimable(record.completionFlagIndex)) {
-            return GrantOutcome::refused;
-        }
-        g_lastGranted.store(record.definitionIndex, std::memory_order_relaxed);
-        return GrantOutcome::granted;
-    }
-    if (sawChapter) {
-        return GrantOutcome::bookComplete;
-    }
-    return sawRecord ? GrantOutcome::noChapters : GrantOutcome::noRecords;
-}
-
 /** Grants one record's completion directly, by the row an sobject's lane 4 names. */
 GrantOutcome grant_record(std::uint16_t definitionIndex) noexcept {
     namespace records = build_data::records;
-    (void)ensure_tables_published();
+    (void)ensure_records_published();
 
     records::Definition record{};
     if (!records::find(definitionIndex, record)) {
@@ -160,15 +69,12 @@ GrantOutcome grant_record(std::uint16_t definitionIndex) noexcept {
     if (record.completionFlagIndex == records::kUnavailableFlagIndex) {
         return GrantOutcome::noFlag;
     }
-    // Same rule as grant_next_chapter: a record naming no lore row is not a chapter. Here it also
-    // means lane 4 did not really name a record -- see GrantOutcome::notAChapter -- so the caller
-    // is told to fall back rather than granting whatever triumph the number happened to land on.
+    // A record naming no lore row is not a chapter and must never be granted by a collectible.
     if (record.loreRow == records::kUnavailableLoreRow) {
         return GrantOutcome::notAChapter;
     }
-    // Same rule as grant_next_chapter: finding lore completes a chapter, claiming it is the
-    // player's own act, so a record already claimed or already offered claimable is left alone
-    // rather than reported as this pickup's doing.
+    // Finding lore completes a chapter; claiming it is the player's act, so an already-held
+    // chapter is left alone rather than reported as this pickup's doing.
     if (record_claims::claimed(record.completionFlagIndex)
         || record_claims::claimable(record.completionFlagIndex)) {
         return GrantOutcome::alreadyHeld;
@@ -180,47 +86,56 @@ GrantOutcome grant_record(std::uint16_t definitionIndex) noexcept {
     return GrantOutcome::granted;
 }
 
-std::uint16_t legacy_book_for_bubble(std::uint32_t bubble) noexcept {
-    // Thieves' Landing. Its checklist entries are Region Chests, so the Ghost Lore join that built
-    // bubble_record_table.h never produced a bucket for it, but a pickup here does grant a Ghost
-    // Stories chapter -- observed granting record 802 before the table replaced kBubbleBooks.
-    constexpr std::uint32_t kThievesLandingBubble = 0x5FE28198U;
+/** Advances one counted chapter record without completing it early. */
+GrantOutcome advance_record(std::uint16_t definitionIndex) noexcept {
+    namespace records = build_data::records;
+    (void)ensure_records_published();
 
-    if (bubble == kThievesLandingBubble) {
-        return kGhostStoriesNode;
+    records::Definition record{};
+    if (!records::find(definitionIndex, record)) {
+        return GrantOutcome::recordNotFound;
     }
-    return kNoBook;
-}
-
-/**
- * Grants the first record in one bubble's generated candidate bucket that the account does not
- * already hold, walking the bucket in table order.
- */
-GrantOutcome grant_from_bubble_table(std::uint32_t bubble, std::size_t& bucketSize) noexcept {
-    const std::span<const std::uint16_t> rows = bubble_record_table::records_for_bubble(bubble);
-    bucketSize = rows.size();
-    if (rows.empty()) {
-        return GrantOutcome::unknownBook;
+    if (record.completionFlagIndex == records::kUnavailableFlagIndex) {
+        return GrantOutcome::noFlag;
     }
-
-    // Each row is tried through grant_record, which is the one place that already knows how to
-    // check and write a claim -- there is no second mechanism to maintain here. A row this bucket
-    // names but that is already held is exactly the case a later pickup in the same bubble is
-    // meant to skip past, so the walk continues; a row that fails to resolve at all would be a
-    // data problem in the generated table, and is skipped rather than aborting the whole pickup.
-    // Only a claim-store refusal stops the walk outright, the same as grant_next_chapter.
-    for (const std::uint16_t row : rows) {
-        const GrantOutcome outcome = grant_record(row);
-        if (outcome == GrantOutcome::granted || outcome == GrantOutcome::refused) {
-            return outcome;
-        }
+    if (record.loreRow == records::kUnavailableLoreRow) {
+        return GrantOutcome::notAChapter;
+    }
+    const record_claims::ObjectiveAdvance outcome =
+        record_claims::advance_single_objective(record.completionFlagIndex);
+    switch (outcome) {
+    case record_claims::ObjectiveAdvance::advanced:
+        g_lastGranted.store(definitionIndex, std::memory_order_relaxed);
+        return GrantOutcome::progressed;
+    case record_claims::ObjectiveAdvance::completed:
+        g_lastGranted.store(definitionIndex, std::memory_order_relaxed);
+        return GrantOutcome::granted;
+    case record_claims::ObjectiveAdvance::alreadyHeld:
+        return GrantOutcome::alreadyHeld;
+    case record_claims::ObjectiveAdvance::unavailable:
+        return GrantOutcome::refused;
     }
-    return GrantOutcome::bubbleTableExhausted;
+    return GrantOutcome::refused;
 }
 
-/** @return True when the last grant also gave the collectible's item. */
-bool last_item_granted() noexcept {
-    return g_lastItemGranted.load(std::memory_order_relaxed);
+/** Resolves a type-2 collectible through its authored lore-row join. */
+GrantOutcome grant_collectible(std::uint16_t collectibleIndex) noexcept {
+    namespace collectibles = build_data::collectibles;
+    namespace records = build_data::records;
+    (void)ensure_records_published();
+
+    collectibles::Definition collectible{};
+    if (!build_data::find_collectible_definition(collectibleIndex, collectible)) {
+        return GrantOutcome::collectibleNotFound;
+    }
+    if (collectible.loreRow == collectibles::kUnavailableLoreRow) {
+        return GrantOutcome::collectibleNoLore;
+    }
+    records::Definition record{};
+    if (!records::find_by_lore_row(collectible.loreRow, record)) {
+        return GrantOutcome::loreRecordNotFound;
+    }
+    return grant_record(record.definitionIndex);
 }
 
 /** @return The record row the last successful grant claimed. */

+ 17 - 142
Sunrise/src/state/lore/lore_grant.h

@@ -1,166 +1,41 @@
 #pragma once
 
-#include <cstddef>
 #include <cstdint>
 
 namespace sunrise::state::lore {
 
-/**
- * Granting a lore chapter when a collectible is picked up.
- *
- * The incident a pickup emits does not name what it granted. Its only per-object content is a
- * position: the same object picked twice produces a payload differing by one bit, the sequence,
- * while a different object differs only across the position vector. Reproducing Bungie's choice of
- * chapter would mean mapping every object in the world to a reward, from activity and spawn data.
- *
- * So the chapter is chosen here instead. A pickup grants the next chapter its bubble has candidates
- * for that the account does not already hold, which is what a player observes anyway: collect, and
- * the book fills in. The association needed is bubble -> candidate chapters (bubble_record_table,
- * generated from the manifest) rather than one reward per object -- except for Confessions, whose
- * caluseum_experience activity is instanced and so carries no bubble at all; see kConfessionsNode.
- */
-
-/** A node index that names no book. */
-inline constexpr std::uint16_t kNoBook = 0xFFFFU;
-
-/**
- * The FNV-1a 32-bit offset basis -- the hash of the empty string.
- *
- * Every instanced activity reports this value when its bubble field is otherwise unset. It is not
- * an authored bubble, is confirmed absent from the whole manifest, and must never be looked up in
- * bubble_record_table (which asserts as much at compile time). A bubble equal to this is the
- * instanced-activity case: see kConfessionsNode.
- */
-inline constexpr std::uint32_t kBubbleUnsetSentinel = 0x811C9DC5U;
-
-/**
- * Presentation node of Confessions, the caluseum_experience book whose vases feed it.
- *
- * Confessions has no bubble: caluseum_experience is instanced, so every one of its pickups reports
- * 0x811C9DC5, the FNV-1a 32-bit offset basis every instanced activity carries when its bubble field
- * is otherwise unset -- not an authored identifier for this or any other place. It is therefore
- * never looked up in the generated bubble table (see bubble_record_table.h, which asserts the
- * sentinel absent); the caller recognises the sentinel directly and grants against this node
- * instead, the same ordered walk grant_next_chapter always did here. Node 838 confirmed against the
- * published manifest: nine chapters, Entry I on record 1708 carrying lore hash 0x58C9C088.
- */
-inline constexpr std::uint16_t kConfessionsNode = 838U;
-
-/**
- * Presentation node of Ghost Stories. Confirmed against the published manifest: 24 chapters.
- *
- * Ghost Stories is granted by Dead Ghosts, which are scattered across the whole game rather than
- * gathered into one destination, so the generated bubble table does not cover every bubble that
- * hosts one -- the public map data it is built from simply never recorded them. Bubbles known to
- * host a pickup but absent from the table fall back to an ordered walk of this node, which is what
- * the retired kBubbleBooks did for them. It grants the right book and an arbitrary chapter of it,
- * which beats granting nothing; see legacy_book_for_bubble.
- */
-inline constexpr std::uint16_t kGhostStoriesNode = 817U;
-
-/**
- * Book to walk for a bubble the generated table does not cover, or kNoBook when none is known.
- *
- * This is deliberately a short hand-maintained list and not a second generated table: it exists
- * only to stop a pickup in a known-good bubble granting nothing while that bubble is missing from
- * bubble_record_map.json. Entries should be deleted as the generated table grows to cover them.
- */
-[[nodiscard]] std::uint16_t legacy_book_for_bubble(std::uint32_t bubble) noexcept;
-
-/** Why one pickup granted nothing. */
+/** Why one exact collectible grant did not change account state. */
 enum class GrantOutcome : std::uint8_t {
     granted,
-    /** The bubble is not associated with a book. */
-    unknownBook,
-    /** The node table is not published, so no book can be read. */
-    noNodeTable,
-    /** The node table is published but does not contain this book. */
-    bookNotFound,
-    /** The book is present but owns no children. */
-    noChildren,
-    /** The children are present but no record resolves, so the record table is missing. */
-    noRecords,
-    /** Records resolve but none of them is a chapter. */
-    noChapters,
-    /** Every chapter of the book is already held. */
-    bookComplete,
-    /** The claim store refused the write. */
+    /** A counted record advanced but remains below its completion value. */
+    progressed,
     refused,
-    /** The named record row does not exist, or the record table is not published. */
     recordNotFound,
-    /** The record exists but names no completion flag, so it cannot be claimed. */
     noFlag,
-    /** The record is already claimed or already marked claimable. */
     alreadyHeld,
-    /**
-     * The named record is not a lore chapter, so this pickup is not what completes it.
-     *
-     * A type-10 row's lane 4 resolves into the record table for 360 of 807 rows, but only 102 of
-     * those name a record that carries a lore row. The rest land on unrelated triumphs -- Season of
-     * Dawn, Titan Strength, Reward: Jumpship -- because a small u16 falls inside a 2242 row table
-     * often enough to look like an index without being one. Granting those would hand out a
-     * visibly wrong triumph, so they are refused here and left to the bubble table / fallback.
-     */
+    /** The named record is not a lore chapter. */
     notAChapter,
-    /**
-     * The bubble resolved to a bucket in the generated table, but every candidate row in it is
-     * already claimed or claimable. The caller's cue to fall through to the fallback path rather
-     * than report a silent no-op.
-     */
-    bubbleTableExhausted,
+    /** The SObject's collectible row was not published. */
+    collectibleNotFound,
+    /** The collectible does not unlock a lore row. */
+    collectibleNoLore,
+    /** No chapter record displays the collectible's lore row. */
+    loreRecordNotFound,
 };
 
 /** @return A short name for the outcome, for logs. */
 [[nodiscard]] const char* grant_outcome_name(GrantOutcome outcome) noexcept;
 
-/**
- * Grants the first record in one bubble's generated candidate bucket that the account does not
- * already hold, walking the bucket in table order.
- *
- * Replaces the old kBubbleBooks heuristic (one book per activity, guessed at) with the generated
- * bubble_record_table (one set of candidate chapters per bubble, joined from the manifest). Where a
- * bucket holds several rows, successive pickups in that bubble hand out successive chapters -- this
- * does not claim to identify the exact physical object, only to walk the same small set a player
- * would see filling in as they collect.
- *
- * @param bubble Bubble hash the incident carried. Never pass kBubbleUnsetSentinel here -- the
- *        caller must recognise that case itself and use kConfessionsNode with grant_next_chapter
- *        instead; bubble_record_table asserts the sentinel is not one of its entries.
- * @param bucketSize Out: number of candidate rows the bubble's bucket held, 0 when the bubble is not
- *        in the table. For logs.
- * @return granted; unknownBook when the bubble is not in the generated table; bubbleTableExhausted
- *         when it is but every candidate is already held; or whatever grant_record returned for the
- *         row that stopped the walk (recordNotFound, noFlag, notAChapter, refused).
- */
-[[nodiscard]] GrantOutcome grant_from_bubble_table(std::uint32_t bubble, std::size_t& bucketSize) noexcept;
+/** Grants one exact lore record by its native definition row. */
+[[nodiscard]] GrantOutcome grant_record(std::uint16_t definitionIndex) noexcept;
 
-/**
- * Grants the next chapter of one book that the account does not already hold.
- * Chapters are the node's children that name a lore row; the child naming none is the book's parent
- * triumph and is never granted.
- * @param node Presentation node of the book.
- * @return What happened, so a refusal reads differently from a completed book.
- */
-[[nodiscard]] GrantOutcome grant_next_chapter(std::uint16_t node) noexcept;
+/** Advances one exact counted lore record by one objective unit. */
+[[nodiscard]] GrantOutcome advance_record(std::uint16_t definitionIndex) noexcept;
 
-/**
- * Grants one record's completion directly, by the row an sobject's lane 4 names.
- *
- * Performs the same write grant_next_chapter does -- record_claims::mark_claimable on the record's
- * completion flag -- but on a row named exactly rather than one found by walking a book's children.
- * This is the path a type-10 incident takes when its target's lane 4 resolves a record on its own,
- * which makes guessing at the book unnecessary.
- * @param definitionIndex Native record row, from an sobject's lane 4 low half.
- * @return What happened. recordNotFound means the row itself does not resolve -- the caller's cue
- *         to fall back to the bubble table / instanced case instead of treating this as a completed
- *         attempt.
- */
-[[nodiscard]] GrantOutcome grant_record(std::uint16_t definitionIndex) noexcept;
+/** Resolves a type-2 SObject collectible row to its exact lore record and grants it. */
+[[nodiscard]] GrantOutcome grant_collectible(std::uint16_t collectibleIndex) noexcept;
 
-/** @return The record row the last successful grant claimed. Only meaningful after `granted`. */
+/** @return The record row the last successful grant or progress advance changed. */
 [[nodiscard]] std::uint16_t last_granted_record() noexcept;
 
-/** @return True when the last grant also gave the collectible's item, which the lore is gated on. */
-[[nodiscard]] bool last_item_granted() noexcept;
-
 } // namespace sunrise::state::lore

+ 9 - 58
Sunrise/src/state/record_claims/parent_bar_table.h

@@ -8,64 +8,15 @@ namespace sunrise::state::record_claims::parent_bar_table {
 /**
  * The value bank index a lore book's parent-triumph bar reads, keyed by presentation node.
  *
- * Measured in game for most of these: a marker sweep authored distinct values across the bank and
- * each book displayed the one belonging to its own slot, naming it outright. That was necessary
- * because the expression at record field 136 names the wrong index for several books -- Ecdysis,
- * Trials and Tribulations, The Chronicon, For Every Rose -- and names only a flag for twelve more,
- * which is why those bars never moved however correct the count was.
+ * Most indices were measured directly with distinct in-game marker values. The four entries marked
+ * "decoded, unconfirmed" come from the parent objective's READ_VALUE expression; that extraction
+ * reproduces nineteen of the twenty measured indices, but those four still need an in-game check.
  *
- * Keyed by node rather than by parent record: The Tangled Shore has no parent record at all, so
- * there is nothing to key it on, yet its bar is at a measured slot like any other.
- *
- * The allocation runs in content ship order -- the Year 1 books hold a contiguous run at 1931-1941,
- * Year 2 seasons follow in the 2200-2400s, Year 3 later still.
- *
- * Fourteen entries were removed from this table and then restored with a corrected reading, so
- * the history is worth stating. They came from record field 136, which names the category's
- * VISIBILITY GATE -- a value slot tested against zero -- and not, as was assumed, a bar. Because
- * apply_node_progress writes the claimed-chapter count into every entry here, a book with no
- * claimed chapters had its own gate written to zero on every account image and stayed redacted
- * forever. That is why those bars never moved however correct the count was.
- *
- * The correction is that ten of the fourteen are not mis-sourced at all: for those books one slot
- * does both jobs. The parent record's tracked objective reads the very slot that gates the
- * category, so the book reveals itself once a chapter is claimed and the same value drives the
- * bar. Dust settles it -- an in-game marker sweep measured its bar at 2342, which is precisely its
- * gate. Those ten are listed below as "gate is bar".
- *
- * The remaining four do name a distinct bar, recovered from the parent record's tracked objective
- * at byte offset +8 (a one-instruction READ_VALUE expression; the field has no named constant in
- * this codebase). That method reproduces nineteen of the twenty measured rows above exactly, which
- * is why these four are trusted enough to carry, but none of the four is itself measured yet --
- * they are marked "decoded" and should be confirmed in game before being relied on.
- *
- * Node 819, Letters from a Renegade, is the one row the +8 method fails: its shipped objective
- * tracks an unrelated record's value. Its entry below is measured and stands.
- *
- * Ten entries name the same index the node's gate reads. That is real, confirmed repeatedly in
- * game, and it is not a defect: the gate is a NOT-ZERO test, not a threshold, so the slot can
- * carry a value the gate accepts and the bar does not display. apply_category_gates publishes -1
- * for exactly these ten. The book opens, the bar shows nothing while nothing is claimed, and the
- * claimed count counts up normally over the top of it.
- *
- * Getting here cost a long detour. A non-zero bar forces the category to reveal, which is not the
- * same as the bar being the gate, and reading it that way produced a chain of wrong conclusions:
- * that these ten were special, that they had to choose between showing collected or being
- * unreachable, and that a separate gate slot must exist. It does not. Every slot in the account
- * value bank was written non-zero looking for one, the flag bank was swept 0-8922, the profile and
- * character banks with it, and the family5 override path reaches this same index by raw slot and
- * outranks the bank. The answer was the value, not the address.
- *
- * The original superseded reading follows. That is genuine and confirmed in game:
- * the number appears on the book's parent triumph, and it tracked the collected count exactly,
- * book by book, when that count was written there. So the slot is the parent bar AND the gate.
- *
- * That is not a contradiction to resolve by dropping the entries -- they were dropped once on the
- * theory that the index was a category-header counter, and it is not. It does mean those ten books
- * cannot reveal themselves: the bar counts claims, a hidden chapter cannot be claimed, so the gate
- * can never rise on its own. Something outside this table opens them on a live account. What keeps such a
- * book visible at zero chapters is ordering alone: nodes::apply_category_gates runs after this
- * pass and raises a zero gate back to one. That call must stay last -- see account_encoder.
+ * The table is keyed by node because The Lawless Frontier has no parent record. Ten books genuinely
+ * use one slot for both their NOT-ZERO category gate and parent bar. The account encoder therefore
+ * applies category gates after progress: an empty shared slot is raised without overwriting a
+ * non-zero count. Four of those books grant chapters by incrementing that shared counter directly;
+ * the remaining books publish their claimed-chapter count there.
  */
 struct Bar {
     std::uint16_t nodeIndex;
@@ -78,7 +29,7 @@ inline constexpr std::array<Bar, 35> kBars{{
     {816U, 1933U},  // The Man They Call Cayde (measured)
     {817U, 1940U},  // Ghost Stories (measured)
     {818U, 1941U},  // Most Loyal (measured)
-    {819U, 1939U},  // Letters from a Renegade (measured)
+    {819U, 2266U},  // Letters from a Renegade (decoded)
     {821U, 2273U},  // Dawning Delights (measured)
     {831U, 1931U},  // The Forsaken Prince (measured)
     {832U, 1936U},  // Truth to Power (measured)

+ 328 - 124
Sunrise/src/state/record_claims/record_claims.cpp

@@ -38,6 +38,24 @@ constexpr std::size_t kEntrySize = 2 * sizeof(std::uint16_t);
 /** Far above the 2242 records the build ships, and small enough to read in one go. */
 constexpr std::uint32_t kMaximumEntries = 8192;
 
+/**
+ * Books whose shared progress counter grants chapters directly.
+ *
+ * Unlike collectible-backed lore, these chapters never become separately claimable records. The
+ * counter is both the number of entries collected and the parent bar value.
+ */
+constexpr std::array<std::uint16_t, 4> kCounterGrantedLoreNodes{
+    823U,  // Stolen Intelligence
+    839U,  // Unveiling
+    850U,  // A Man with No Name
+    853U,  // Revelation
+};
+
+[[nodiscard]] constexpr bool counter_granted_lore(std::uint16_t nodeIndex) noexcept {
+    return std::find(kCounterGrantedLoreNodes.begin(), kCounterGrantedLoreNodes.end(), nodeIndex)
+           != kCounterGrantedLoreNodes.end();
+}
+
 /**
  * Completions that have not been claimed live in their own file, beside the claim file.
  *
@@ -49,11 +67,17 @@ constexpr std::uint32_t kMaximumEntries = 8192;
 constexpr std::wstring_view kClaimableFileSuffix = L"\\cache\\record_claimable.bin";
 /** Distinct from kMagic so neither file can ever be read as the other. */
 constexpr std::array<char, 8> kClaimableMagic{'S', 'N', 'R', 'S', 'C', 'M', 'P', '1'};
+/** Partial single-objective values live apart from completion and claim state. */
+constexpr std::wstring_view kProgressFileSuffix = L"\\cache\\record_progress.bin";
+constexpr std::array<char, 8> kProgressMagic{'S', 'N', 'R', 'S', 'P', 'R', 'G', '1'};
+constexpr std::size_t kProgressEntrySize = sizeof(std::uint16_t) + sizeof(std::int32_t);
 
 std::mutex g_lock;
 std::array<std::uint64_t, kWordCount> g_claimed{};
 /** Records complete but not yet claimed. A claim supersedes this, never the other way round. */
 std::array<std::uint64_t, kWordCount> g_claimable{};
+/** Partial progress keyed by completion-flag index; zero means no persisted partial value. */
+std::array<std::int32_t, kIndexCapacity> g_progress{};
 std::array<std::uint16_t, kIndexCapacity> g_scoreByIndex{};
 std::size_t g_count{};
 std::uint32_t g_score{};
@@ -61,6 +85,11 @@ core::path::Buffer g_path{};
 bool g_pathReady{};
 core::path::Buffer g_claimablePath{};
 bool g_claimablePathReady{};
+core::path::Buffer g_progressPath{};
+bool g_progressPathReady{};
+
+[[nodiscard]] bool claimed_locked(std::uint16_t flagIndex) noexcept;
+[[nodiscard]] bool claimable_locked(std::uint16_t flagIndex) noexcept;
 
 void report(const char* stage, const char* result, std::size_t detail) noexcept {
     std::array<char, 128> line{};
@@ -294,6 +323,109 @@ void load_claimable_locked() noexcept {
     report("load_claimable", "ok", restored);
 }
 
+/** Writes every nonzero partial objective value. The caller holds the lock. */
+void store_progress_locked() noexcept {
+    if (!g_progressPathReady) {
+        return;
+    }
+    std::vector<char> entries{};
+    std::uint32_t count = 0;
+    for (std::size_t index = 0; index < g_progress.size(); ++index) {
+        if (g_progress[index] <= 0 || claimed_locked(static_cast<std::uint16_t>(index))
+            || claimable_locked(static_cast<std::uint16_t>(index))) {
+            continue;
+        }
+        const auto packedIndex = static_cast<std::uint16_t>(index);
+        const auto* indexBytes = reinterpret_cast<const char*>(&packedIndex);
+        const auto* valueBytes = reinterpret_cast<const char*>(&g_progress[index]);
+        entries.insert(entries.end(), indexBytes, indexBytes + sizeof packedIndex);
+        entries.insert(entries.end(), valueBytes, valueBytes + sizeof g_progress[index]);
+        ++count;
+    }
+    std::vector<char> document{};
+    document.insert(document.end(), kProgressMagic.begin(), kProgressMagic.end());
+    const auto* countBytes = reinterpret_cast<const char*>(&count);
+    document.insert(document.end(), countBytes, countBytes + sizeof count);
+    document.insert(document.end(), entries.begin(), entries.end());
+
+    const HANDLE file = CreateFileW(g_progressPath.chars.data(),
+                                    GENERIC_WRITE,
+                                    0,
+                                    nullptr,
+                                    CREATE_ALWAYS,
+                                    FILE_ATTRIBUTE_NORMAL,
+                                    nullptr);
+    if (file == INVALID_HANDLE_VALUE) {
+        report("store_progress", "open_fail", count);
+        return;
+    }
+    DWORD written = 0;
+    const auto size = static_cast<DWORD>(document.size());
+    bool complete =
+        WriteFile(file, document.data(), size, &written, nullptr) != FALSE && written == size;
+    complete = CloseHandle(file) != FALSE && complete;
+    report("store_progress", complete ? "ok" : "write_fail", count);
+}
+
+/** Reads persisted partial objective values. The caller holds the lock. */
+void load_progress_locked() noexcept {
+    const HANDLE file = CreateFileW(g_progressPath.chars.data(),
+                                    GENERIC_READ,
+                                    FILE_SHARE_READ,
+                                    nullptr,
+                                    OPEN_EXISTING,
+                                    FILE_ATTRIBUTE_NORMAL,
+                                    nullptr);
+    if (file == INVALID_HANDLE_VALUE) {
+        report("load_progress", "absent", 0);
+        return;
+    }
+    std::array<char, sizeof(kProgressMagic) + sizeof(std::uint32_t)> header{};
+    DWORD read = 0;
+    if (ReadFile(file, header.data(), static_cast<DWORD>(header.size()), &read, nullptr) == FALSE
+        || read != header.size()
+        || std::memcmp(header.data(), kProgressMagic.data(), kProgressMagic.size()) != 0) {
+        (void)CloseHandle(file);
+        report("load_progress", "header_fail", 0);
+        return;
+    }
+    std::uint32_t entries = 0;
+    std::memcpy(&entries, header.data() + kProgressMagic.size(), sizeof entries);
+    if (entries > kMaximumEntries) {
+        (void)CloseHandle(file);
+        report("load_progress", "count_fail", entries);
+        return;
+    }
+    std::vector<char> payload(static_cast<std::size_t>(entries) * kProgressEntrySize);
+    read = 0;
+    const bool readOk =
+        payload.empty()
+        || (ReadFile(file, payload.data(), static_cast<DWORD>(payload.size()), &read, nullptr)
+                != FALSE
+            && read == payload.size());
+    (void)CloseHandle(file);
+    if (!readOk) {
+        report("load_progress", "read_fail", entries);
+        return;
+    }
+
+    std::size_t restored = 0;
+    for (std::uint32_t entry = 0; entry < entries; ++entry) {
+        const std::size_t at = static_cast<std::size_t>(entry) * kProgressEntrySize;
+        std::uint16_t index = 0;
+        std::int32_t value = 0;
+        std::memcpy(&index, payload.data() + at, sizeof index);
+        std::memcpy(&value, payload.data() + at + sizeof index, sizeof value);
+        if (static_cast<std::size_t>(index) >= kIndexCapacity || value <= 0
+            || claimed_locked(index) || claimable_locked(index)) {
+            continue;
+        }
+        g_progress[index] = value;
+        ++restored;
+    }
+    report("load_progress", "ok", restored);
+}
+
 } // namespace
 
 /** Derives the claim file path and loads any claims already held. */
@@ -318,6 +450,15 @@ bool initialize(void* module) noexcept {
     } else {
         report("initialize", "claimable_path_fail", 0);
     }
+
+    g_progressPathReady = false;
+    if (core::path::artifact_directory(module, g_progressPath)
+        && core::path::append(g_progressPath, kProgressFileSuffix)) {
+        g_progressPathReady = true;
+        load_progress_locked();
+    } else {
+        report("initialize", "progress_path_fail", 0);
+    }
     return true;
 }
 
@@ -326,6 +467,7 @@ void clear() noexcept {
     const std::lock_guard<std::mutex> guard(g_lock);
     g_claimed.fill(0);
     g_claimable.fill(0);
+    g_progress.fill(0);
     g_scoreByIndex.fill(0);
     g_count = 0;
     g_score = 0;
@@ -341,6 +483,7 @@ bool claim(std::uint16_t flagIndex, std::uint16_t scoreValue) noexcept {
     const std::lock_guard<std::mutex> guard(g_lock);
     if ((g_claimed[word] & bit) == 0) {
         g_claimed[word] |= bit;
+        g_progress[flagIndex] = 0;
         g_scoreByIndex[flagIndex] = scoreValue;
         ++g_count;
         // Only a first claim scores, so a repeated click cannot inflate the total.
@@ -351,6 +494,7 @@ bool claim(std::uint16_t flagIndex, std::uint16_t scoreValue) noexcept {
         // The claimable file lists completions still awaiting a claim, so this index has to leave
         // it now that the claim supersedes it -- otherwise it is carried in both files forever.
         store_claimable_locked();
+        store_progress_locked();
     }
     return true;
 }
@@ -383,38 +527,65 @@ std::size_t apply(std::span<std::uint8_t> accountFlags) noexcept {
     return changed;
 }
 
+/** Clears the authored completion values of every record owned by a lore book. */
+std::size_t clear_lore_objectives(std::span<std::int32_t> objectiveValues) noexcept {
+    struct ClearState {
+        std::span<std::int32_t> values;
+        std::size_t cleared{};
+    } state{objectiveValues};
+    build_data::nodes::for_each(
+        &state, [](void* context, const build_data::nodes::Definition& node) noexcept {
+            if (!build_data::nodes::lore_category(node.definitionIndex)) {
+                return;
+            }
+            auto* clear = static_cast<ClearState*>(context);
+            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) {
+                    continue;
+                }
+                const auto found = std::lower_bound(
+                    objective_slot_table::kRecords.begin(), objective_slot_table::kRecords.end(),
+                    record.completionFlagIndex,
+                    [](const objective_slot_table::RecordEntry& entry, std::uint16_t flag) {
+                        return entry.flagIndex < flag;
+                    });
+                if (found == objective_slot_table::kRecords.end()
+                    || found->flagIndex != record.completionFlagIndex) {
+                    continue;
+                }
+                for (std::uint8_t objective = 0; objective < found->objectiveCount; ++objective) {
+                    const std::size_t at =
+                        static_cast<std::size_t>(found->firstObjective) + objective;
+                    if (at >= objective_slot_table::kObjectives.size()) {
+                        break;
+                    }
+                    const std::size_t slot = objective_slot_table::kObjectives[at].slot;
+                    if (slot >= clear->values.size()) {
+                        continue;
+                    }
+                    clear->values[slot] = 0;
+                    ++clear->cleared;
+                }
+            }
+        });
+    static std::atomic<bool> reported{false};
+    if (!reported.exchange(true, std::memory_order_relaxed)) {
+        report("clear_lore_objectives", "ok", state.cleared);
+    }
+    return state.cleared;
+}
+
 namespace {
 
 /** Carries the bank and a tally through the node walk, which takes a plain function pointer. */
 struct NodeProgress {
     std::span<std::int32_t> values;
     std::size_t written;
-    /** One entry per value index, non-zero where a category's own bar reads. */
-    std::span<const char> categories;
 };
 
-/**
- * The objective slot run one record owns, or an empty span when the table does not name it.
- *
- * The slot space was derived and verified in game against thirteen measured points; see
- * objective_slot_table.h. A record's objectives occupy consecutive slots, so the run is found once
- * rather than a lookup per slot.
- */
-[[nodiscard]] std::span<const objective_slot_table::ObjectiveSlot> objective_slots_for(
-    std::uint16_t flagIndex) noexcept {
-    const std::span<const objective_slot_table::RecordEntry> table{objective_slot_table::kRecords};
-    const auto found = std::lower_bound(
-        table.begin(), table.end(), flagIndex,
-        [](const objective_slot_table::RecordEntry& entry, std::uint16_t key) {
-            return entry.flagIndex < key;
-        });
-    if (found == table.end() || found->flagIndex != flagIndex) {
-        return {};
-    }
-    return std::span<const objective_slot_table::ObjectiveSlot>{objective_slot_table::kObjectives}
-        .subspan(found->firstObjective, found->objectiveCount);
-}
-
 /** True when this account flag bank row is held. The caller owns the claim lock. */
 [[nodiscard]] bool claimed_locked(std::uint16_t flagIndex) noexcept {
     if (static_cast<std::size_t>(flagIndex) >= kIndexCapacity) {
@@ -435,6 +606,36 @@ struct NodeProgress {
     return (g_claimable[word] & bit) != 0;
 }
 
+/**
+ * Builds the completion-flag mask for counter-granted chapters. The caller owns the claim lock.
+ *
+ * Their persisted completion bits are still the collection ledger, so relaunches retain the
+ * counter. They are masked only from ordinary objective emission: native content derives their
+ * collected state from the shared counter and never offers the chapter records for claiming.
+ */
+[[nodiscard]] std::array<std::uint64_t, kWordCount>
+counter_granted_chapter_mask_locked() noexcept {
+    std::array<std::uint64_t, kWordCount> mask{};
+    build_data::nodes::for_each(
+        &mask, [](void* context, const build_data::nodes::Definition& node) noexcept {
+            if (!counter_granted_lore(node.definitionIndex)) {
+                return;
+            }
+            auto* bits = static_cast<std::array<std::uint64_t, kWordCount>*>(context);
+            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.loreRow == build_data::records::kUnavailableLoreRow
+                    || static_cast<std::size_t>(record.completionFlagIndex) >= kIndexCapacity) {
+                    continue;
+                }
+                const std::size_t index = record.completionFlagIndex;
+                (*bits)[index / kWordBits] |= std::uint64_t{1} << (index % kWordBits);
+            }
+        });
+    return mask;
+}
+
 } // namespace
 
 /**
@@ -552,20 +753,7 @@ std::size_t apply_chapter_visibility_gates(std::span<std::int32_t> objectiveValu
 }
 
 std::size_t apply_node_progress(std::span<std::int32_t> objectiveValues) noexcept {
-    // Every category's own index, so the walk can tell a free slot above a category from the next
-    // category along. Without it, writing the slot above drives whichever book owns that slot.
-    std::vector<char> categoryFlags(objectiveValues.size(), 0);
-    build_data::nodes::for_each(
-        &categoryFlags, [](void* context, const build_data::nodes::Definition& node) noexcept {
-            auto* flags = static_cast<std::vector<char>*>(context);
-            const std::uint16_t index = node.valueIndex;
-            if (index != build_data::nodes::kUnavailableValueIndex
-                && static_cast<std::size_t>(index) < flags->size()) {
-                (*flags)[index] = 1;
-            }
-        });
-
-    NodeProgress progress{objectiveValues, 0, std::span<const char>{categoryFlags}};
+    NodeProgress progress{objectiveValues, 0};
     // The claim lock is taken first and the node lock inside the walk. Nothing takes them the other
     // way round, so the order cannot close a cycle.
     const std::lock_guard<std::mutex> guard(g_lock);
@@ -591,8 +779,7 @@ std::size_t apply_node_progress(std::span<std::int32_t> objectiveValues) noexcep
             // Dust reads 0 with all nine collected and every chapter still claimable -- so those
             // keep the sentinel and their bar stays honest.
             bool cumulative = false;
-            build_data::records::Definition parent{};
-            bool haveParent = false;
+            const bool counterGranted = counter_granted_lore(node.definitionIndex);
             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)
@@ -601,21 +788,16 @@ std::size_t apply_node_progress(std::span<std::int32_t> objectiveValues) noexcep
                     continue;
                 }
                 if (record.loreRow == build_data::records::kUnavailableLoreRow) {
-                    parent = record;
-                    haveParent = true;
                     continue;
                 }
-                // Claimed only. Verified against the live game: a lore book's bar moves when the
-                // chapter's triumph is claimed, not when the entry is collected. Counting
-                // collected entries as well was tried on the reasoning that a record completes on
-                // collection, and it is simply not what the bar does.
+                // Most books count chapter triumph claims. Four activity/vendor books are
+                // different: advancing their parent counter is the act that grants each entry.
                 if (claimed_locked(record.completionFlagIndex)) {
                     ++chapters;
                 }
-                // The category tile counts what has been collected, not what has been claimed.
-                // It has to: the tile's counter is also the gate that reveals the book, and a
-                // book that stayed hidden until a claim could never be opened at all, since a
-                // chapter cannot be claimed while it is invisible.
+                // The completion set is also the persistent collection ledger. For ordinary lore
+                // it means complete-but-unclaimed; for counter-granted lore it records an entry
+                // already granted by the shared counter and is never emitted as a claimable row.
                 if (claimed_locked(record.completionFlagIndex)
                     || claimable_locked(record.completionFlagIndex)) {
                     ++collected;
@@ -653,19 +835,18 @@ std::size_t apply_node_progress(std::span<std::int32_t> objectiveValues) noexcep
                     // What prevents that is ordering, not a guard -- nodes::apply_category_gates
                     // runs after this pass and raises a zero gate back to one.
                     if (static_cast<std::size_t>(bar.valueIndex) < state->values.size()) {
-                        state->values[bar.valueIndex] = chapters;
+                        state->values[bar.valueIndex] = counterGranted ? collected : chapters;
                         parentSlot = static_cast<std::int32_t>(bar.valueIndex);
                         ++state->written;
                     }
                     break;
                 }
             }
-            // The node's own value slot is the category tile's counter, and it is a different
-            // number from the parent triumph's bar: the tile counts entries collected, the bar
-            // counts triumphs claimed. Conflating them is what made ten books look as though
-            // their gate were their bar -- writing the bar revealed them, but only because a
-            // category with progress shows itself. On a cumulative book every chapter compares
-            // against this counter too, so it has to carry the real total and not a token 1.
+            // The node's own value slot normally carries the category's collection count while
+            // the parent bar carries claims. Cumulative chapters compare against the former, so
+            // it must carry the collected total rather than a visibility token. The four
+            // counter-granted books deliberately collapse those meanings: their node value is
+            // also the parent bar, and incrementing it is what grants the next chapter.
             // Kept below the record-objective range for the same reason apply_category_gates is:
             // three books name a slot inside it that belongs to a record's objective, and writing
             // a count there redacts records wholesale.
@@ -673,71 +854,25 @@ std::size_t apply_node_progress(std::span<std::int32_t> objectiveValues) noexcep
                 && static_cast<std::int32_t>(node.valueIndex) < objective_slot_table::kRecordObjectiveRangeStart
                 && static_cast<std::size_t>(node.valueIndex) < state->values.size()
                 ) {
-                // Collected, not claimed. This slot is the book's entries counter, and on a
-                // cumulative book every chapter compares against it -- chapter n completes at n --
-                // so a counter tracking claims leaves exactly one chapter claimable and the rest
-                // locked, which is what claiming-only produced. It is only reached when the slot
-                // is not also the book's bar: the guard above skips it when they coincide, and for
-                // those ten the slot is the bar and has to keep counting claims.
-                // This slot is the book's collected counter and it gates the chapters: chapter
-                // n is only offered once it reads n, so -1 hides every chapter and 1 offers only
-                // the first. It carries the collected total, and -1 only when nothing has been
-                // collected -- which still satisfies the category's not-zero gate, so the book
-                // opens with a blank bar rather than a false count.
-                // A cumulative book needs its collected total somewhere its chapters can read.
-                // Where the node's own slot is also the bar, putting it there would show collected
-                // as a claim count -- which is wrong, the bar counts claims. Those books have a
-                // second slot and it takes the counter instead, leaving the bar alone.
-                const bool ownSlotIsBar =
-                    parentSlot == static_cast<std::int32_t>(node.valueIndex);
-                std::uint16_t counterSlot = node.valueIndex;
-                if (ownSlotIsBar
-                    && node.parentValueIndex != build_data::nodes::kUnavailableValueIndex) {
-                    counterSlot = node.parentValueIndex;
-                }
-                // Four books are not collected from the world at all -- they are handed out by
-                // an activity or vendor counter, one entry at a time:
-                //   Stolen Intelligence  Zavala rank-up packages
-                //   A Man with No Name   Gambit Prime bounties
-                //   Unveiling            one page a week for visiting Eris Morn
-                //   Revelation           the weekly Lost Sector bounty, four times
-                // Their node value slot is that counter, not a claim count, which is why the
-                // category gates on it and why no other slot in any bank ever revealed them --
-                // the whole account value bank, the flag bank below the record range, the profile
-                // and character banks and the family5 override path were all swept looking for a
-                // separate gate that does not exist. Entries obtained and activity completions
-                // are the same number for these four, so the collected total belongs here and the
-                // parent triumph showing it is faithful rather than a compromise. Revelation sits
-                // in this group despite completing every chapter at 1: it is activity-gated, not
-                // cumulative, which is why it never behaved like the books it otherwise matches.
-                constexpr std::array<std::uint16_t, 3> kActivityAcquired{
-                    823U,  // Stolen Intelligence
-                    839U,  // Unveiling
-                    850U,  // A Man with No Name
-                };
-                // The gate that reveals these four books' entries and the bar that reports their
-                // claims are one slot, so a single image cannot carry both numbers. This publishes
-                // the entry count on the first few images -- long enough for the client to unlock
-                // the entries -- and the claim count from then on. Whether the client keeps the
-                // unlock or re-reads it every image decides if this holds.
-                // Four books -- Stolen Intelligence, A Man with No Name, Unveiling and
-                // Revelation -- render a chapter only if it is claimed, or if its index is at or
-                // below the count in this slot. That slot is also what their bar displays, so a
-                // count large enough to reveal unclaimed entries reports itself as claims. It is
-                // left carrying the claim count: the bar stays honest and claimed entries render.
-                //
-                // Every alternative was tested. Both value banks at 1 and at 100, all four flag
-                // banks, both progression banks, the parent record's "Stories gathered"
-                // objective, the second block slot, and the family5 override on both its lists --
-                // the override simply outranks the bank on the same raw slot, in both directions,
-                // so it cannot carry a second number. Nothing in the shipped data separates these
-                // four from six books of identical shape that work: manifest, node rows and all
-                // 129 chapter record rows were decoded and compared byte for byte.
-                                if (cumulative && collected > 0
-                    && static_cast<std::int32_t>(counterSlot)
-                           < objective_slot_table::kRecordObjectiveRangeStart
-                    && static_cast<std::size_t>(counterSlot) < state->values.size()) {
-                    state->values[counterSlot] = collected;
+                // Four activity/vendor books grant entries by advancing this very counter. It is
+                // therefore both their visibility source and their parent progress bar; publishing
+                // the collected total is the native behavior, not the claim count used elsewhere.
+                if (counterGranted) {
+                    state->values[node.valueIndex] = collected;
+                } else if (cumulative && collected > 0) {
+                    // A normal cumulative book needs a collection counter distinct from a shared
+                    // parent bar. Where its own slot is the bar, the extracted parent slot carries
+                    // the collection value instead so the bar can continue counting claims.
+                    std::uint16_t counterSlot = node.valueIndex;
+                    if (parentSlot == static_cast<std::int32_t>(node.valueIndex)
+                        && node.parentValueIndex != build_data::nodes::kUnavailableValueIndex) {
+                        counterSlot = node.parentValueIndex;
+                    }
+                    if (static_cast<std::int32_t>(counterSlot)
+                            < objective_slot_table::kRecordObjectiveRangeStart
+                        && static_cast<std::size_t>(counterSlot) < state->values.size()) {
+                        state->values[counterSlot] = collected;
+                    }
                 }
 
                 ++state->written;
@@ -785,12 +920,38 @@ std::size_t apply_claimable_objectives(std::span<std::int32_t> objectiveValues)
     std::size_t written = 0;
     const std::span<const objective_slot_table::RecordEntry> table{objective_slot_table::kRecords};
     const std::lock_guard<std::mutex> guard(g_lock);
+    const auto counterGrantedChapters = counter_granted_chapter_mask_locked();
+    for (std::size_t index = 0; index < g_progress.size(); ++index) {
+        if (g_progress[index] <= 0 || claimed_locked(static_cast<std::uint16_t>(index))
+            || claimable_locked(static_cast<std::uint16_t>(index))) {
+            continue;
+        }
+        const auto flagIndex = static_cast<std::uint16_t>(index);
+        const auto found = std::lower_bound(
+            table.begin(), table.end(), flagIndex,
+            [](const objective_slot_table::RecordEntry& entry, std::uint16_t key) {
+                return entry.flagIndex < key;
+            });
+        if (found == table.end() || found->flagIndex != flagIndex
+            || found->objectiveCount != 1
+            || static_cast<std::size_t>(found->firstObjective)
+                   >= objective_slot_table::kObjectives.size()) {
+            continue;
+        }
+        const auto& objective = objective_slot_table::kObjectives[found->firstObjective];
+        if (static_cast<std::size_t>(objective.slot) >= objectiveValues.size()) {
+            continue;
+        }
+        objectiveValues[objective.slot] = std::min(g_progress[index], objective.completionValue);
+        ++written;
+    }
     for (std::size_t word = 0; word < g_claimable.size(); ++word) {
         // Only claimable-and-unclaimed records. Writing claimed ones too was tried and redacted
         // nearly every lore book: record objective slots share the 2746-5686 range with the value
         // indices some book gates read (4619, 4719, 4991 among them), so writing a value per claim
         // trampled those gates. A claim already shows through its completion flag.
-        std::uint64_t bits = g_claimable[word] & ~g_claimed[word];
+        std::uint64_t bits =
+            g_claimable[word] & ~g_claimed[word] & ~counterGrantedChapters[word];
         while (bits != 0) {
             const auto offset = static_cast<std::size_t>(std::countr_zero(bits));
             bits &= bits - 1;
@@ -826,7 +987,7 @@ std::size_t apply_claimable_objectives(std::span<std::int32_t> objectiveValues)
 
 /** Writes each category's claimed-child count into the character value slot its bar reads. */
 std::size_t apply_character_node_progress(std::span<std::int32_t> characterValues) noexcept {
-    NodeProgress progress{characterValues, 0, {}};
+    NodeProgress progress{characterValues, 0};
     // Same lock order as the account pass: claims first, catalog inside the walk.
     const std::lock_guard<std::mutex> guard(g_lock);
     build_data::nodes::for_each(
@@ -866,14 +1027,57 @@ bool mark_claimable(std::uint16_t flagIndex) noexcept {
         return false;
     }
     const std::lock_guard<std::mutex> guard(g_lock);
+    g_progress[flagIndex] = 0;
     g_claimable[static_cast<std::size_t>(flagIndex) / kWordBits] |=
         1ULL << (static_cast<std::size_t>(flagIndex) % kWordBits);
     // Written through immediately, as mark_claimed does: a pickup is the player's progress and has
     // to survive the process, not just the session that recorded it.
     store_claimable_locked();
+    store_progress_locked();
     return true;
 }
 
+/** Advances one persisted objective and promotes it to claimable at its authored threshold. */
+ObjectiveAdvance advance_single_objective(std::uint16_t flagIndex) noexcept {
+    if (static_cast<std::size_t>(flagIndex) >= kIndexCapacity) {
+        return ObjectiveAdvance::unavailable;
+    }
+    const auto found = std::lower_bound(
+        objective_slot_table::kRecords.begin(), objective_slot_table::kRecords.end(), flagIndex,
+        [](const objective_slot_table::RecordEntry& entry, std::uint16_t key) {
+            return entry.flagIndex < key;
+        });
+    if (found == objective_slot_table::kRecords.end() || found->flagIndex != flagIndex
+        || found->objectiveCount != 1
+        || static_cast<std::size_t>(found->firstObjective)
+               >= objective_slot_table::kObjectives.size()) {
+        return ObjectiveAdvance::unavailable;
+    }
+    const std::int32_t completion =
+        objective_slot_table::kObjectives[found->firstObjective].completionValue;
+    if (completion <= 0) {
+        return ObjectiveAdvance::unavailable;
+    }
+
+    const std::lock_guard<std::mutex> guard(g_lock);
+    if (claimed_locked(flagIndex) || claimable_locked(flagIndex)) {
+        return ObjectiveAdvance::alreadyHeld;
+    }
+    const std::int32_t next =
+        g_progress[flagIndex] >= completion - 1 ? completion : g_progress[flagIndex] + 1;
+    if (next >= completion) {
+        g_progress[flagIndex] = 0;
+        g_claimable[static_cast<std::size_t>(flagIndex) / kWordBits] |=
+            std::uint64_t{1} << (static_cast<std::size_t>(flagIndex) % kWordBits);
+        store_progress_locked();
+        store_claimable_locked();
+        return ObjectiveAdvance::completed;
+    }
+    g_progress[flagIndex] = next;
+    store_progress_locked();
+    return ObjectiveAdvance::advanced;
+}
+
 /** @return True when this index is marked claimable. */
 bool claimable(std::uint16_t flagIndex) noexcept {
     if (static_cast<std::size_t>(flagIndex) >= kIndexCapacity) {

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

@@ -6,6 +6,18 @@
 
 namespace sunrise::state::record_claims {
 
+/** Result of advancing a record that has one authored objective. */
+enum class ObjectiveAdvance : std::uint8_t {
+    /** No single objective mapping exists for the supplied completion flag. */
+    unavailable,
+    /** The record was already complete or claimed. */
+    alreadyHeld,
+    /** Progress advanced but remains below the authored completion value. */
+    advanced,
+    /** Progress reached the authored completion value and the record became claimable. */
+    completed,
+};
+
 /**
  * Records claimed through Web Service opcode 1801, as account flag bank indices.
  *
@@ -47,6 +59,12 @@ void clear() noexcept;
  */
 [[nodiscard]] bool mark_claimable(std::uint16_t flagIndex) noexcept;
 
+/**
+ * Advances a record's sole objective by one and persists the partial value.
+ * At the authored completion value the partial row is replaced by claimable state.
+ */
+[[nodiscard]] ObjectiveAdvance advance_single_objective(std::uint16_t flagIndex) noexcept;
+
 /** @return True when this index is marked claimable, whether or not it is also claimed. */
 [[nodiscard]] bool claimable(std::uint16_t flagIndex) noexcept;
 
@@ -57,6 +75,9 @@ void clear() noexcept;
  */
 std::size_t apply(std::span<std::uint8_t> accountFlags) noexcept;
 
+/** Clears authored objective values for every child and parent record owned by a lore node. */
+std::size_t clear_lore_objectives(std::span<std::int32_t> objectiveValues) noexcept;
+
 /**
  * Writes each presentation node's claimed-child count into the value slot its bar reads.
  *
@@ -68,11 +89,9 @@ std::size_t apply(std::span<std::uint8_t> accountFlags) noexcept;
 std::size_t apply_node_progress(std::span<std::int32_t> objectiveValues) noexcept;
 
 /**
- * Writes each claimable-and-unclaimed record's authored objective value(s) into the objective
- * value bank, so its triumph reads at completionValue while its completion flag stays clear --
- * the two conditions the client requires before it will offer a claim. The flag itself is never
- * touched here: writing it can only mean claimed or nothing, never claimable, so claimable is
- * carried by the objective bank alone.
+ * Writes persisted partial single-objective progress, then each claimable-and-unclaimed record's
+ * authored completion value(s), into the objective bank. The completion flag remains clear until
+ * the player claims the record.
  * @param objectiveValues Account value bank, already filled from the authored policy.
  * @return Number of values this wrote.
  */

+ 5 - 0
Sunrise/src/state/runtime/runtime.h

@@ -299,6 +299,11 @@ void shutdown() noexcept;
  */
 [[nodiscard]] bool set_selected_character(std::uint64_t characterSoid, bool& changed) noexcept;
 
+/** Equips one validated title record on the currently selected character. */
+[[nodiscard]] bool set_selected_title(std::uint16_t recordIndex,
+                                      std::uint64_t& characterSoid,
+                                      bool& changed) noexcept;
+
 /**
  * Prepares an equip operation for one unequipped instance on the selected character.
  * An occupied slot is swapped; an empty semantic slot receives the requested item directly.

+ 1 - 0
Sunrise/src/state/runtime/state_account_equipment_runtime.cpp

@@ -350,6 +350,7 @@ void report_item_state(std::string_view stage,
         || left.appearanceValue != right.appearanceValue
         || left.lastOrbitedDestination != right.lastOrbitedDestination
         || left.contentBypass != right.contentBypass
+        || left.equippedTitleRecordIndex != right.equippedTitleRecordIndex
         || left.nextInventorySerial != right.nextInventorySerial
         || left.inventory.count != right.inventory.count) {
         return false;

+ 34 - 0
Sunrise/src/state/runtime/state_account_runtime.cpp

@@ -302,6 +302,40 @@ bool set_selected_character(std::uint64_t characterSoid, bool& changed) noexcept
     return true;
 }
 
+/** Stores the selected character's equipped native title row. */
+bool set_selected_title(std::uint16_t recordIndex,
+                        std::uint64_t& characterSoid,
+                        bool& changed) noexcept {
+    characterSoid = 0;
+    changed = false;
+    AcquireSRWLockExclusive(&runtime::storage::g_stateLock);
+    AccountState candidate = runtime::storage::g_state.account;
+    std::size_t selectedIndex = candidate.characterCount;
+    for (std::size_t index = 0; index < candidate.characterCount; ++index) {
+        if (candidate.characters[index].selected) {
+            selectedIndex = index;
+            break;
+        }
+    }
+    if (selectedIndex == candidate.characterCount) {
+        ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
+        return false;
+    }
+    CharacterState& character = candidate.characters[selectedIndex];
+    characterSoid = character.soid;
+    changed = character.equippedTitleRecordIndex != recordIndex;
+    character.equippedTitleRecordIndex = recordIndex;
+    if (!account::valid(candidate)) {
+        characterSoid = 0;
+        changed = false;
+        ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
+        return false;
+    }
+    runtime::storage::g_state.account = candidate;
+    ReleaseSRWLockExclusive(&runtime::storage::g_stateLock);
+    return true;
+}
+
 /** Prepares one checked equip transition without changing account State. */
 bool prepare_equipment_swap(std::uint64_t requestedInstanceSoid,
                             PendingEquipmentSwap& mutation) noexcept {

+ 1 - 10
Sunrise/src/state/unlocks/definition.h

@@ -59,16 +59,7 @@ struct Table {
     ProgressionBank accountProgressions{};
     /** Lanes published into the selected-character object's progression bank. */
     ProgressionBank characterProgressions{};
-    /**
-     * Reveal every lore book regardless of whether anything in it has been collected.
-     *
-     * Eighteen books gate on a value slot tested above zero, and that slot is the same one the
-     * book's own bar counts into, so a book with nothing collected is hidden -- which is what the
-     * live game does too. That is faithful but unhelpful when the point is to show the collection
-     * off, so this publishes the gate anyway. It costs a book its true zero state: a revealed book
-     * with nothing collected reads 1/N rather than 0/N, because the gate and the bar are one slot
-     * and the gate has to be above zero to open. Clear it to get exact live behaviour back.
-     */
+    /** Legacy configuration field retained for parser compatibility; value gates always publish. */
     bool revealAllLoreBooks{true};
 };