Explorar el Código

Gate lore book categories and chapters on the values they actually read

Three separate gates kept lore books and their chapters redacted, and all
three are value reads that nothing on this server ever satisfied.

parent_bar_table named fourteen books' visibility gate where it meant to
name a parent bar. apply_node_progress writes the chapter count into every
entry, so on a book with nothing claimed it wrote zero into the very slot
that gates the category and the book stayed redacted for good. Ten of the
fourteen were not mis-sourced at all -- one slot serves as both gate and
bar there, confirmed by Dust, whose bar was measured at 2342, its own gate.
Those are restored with that reading recorded; the other four name a
distinct bar recovered from the parent record's tracked objective.

apply_category_gates publishes the gate a value-gated category reads, the
value-bank twin of apply_visibility. It runs after both value passes, since
running it earlier is precisely the bug above. Eight books whose gate is
not their bar are satisfied unconditionally -- that slot is the acquisition
marker a quest sets on a real account. The other ten are governed by
reveal_all_lore_books, because their gate being their bar means collecting
opens them exactly as the live game does.

apply_chapter_visibility_gates publishes the per-chapter gate of the Year 1
chapters, one slot per record row at 1935 + row. The test is a threshold,
not an equality, so each chapter gets its own completion value: the
corrupted-egg records run to nine and a flat 1 leaves every one redacted.
Bounded at both ends -- below, rows 1-6 land on other books' parent bars,
which already satisfy them; above, row 738 onward would land inside the
record-objective range, where writing redacts records wholesale.

Bars now count completed records rather than claimed ones. A lore record
completes when the entry is collected and claiming only pays the score, so
counting claims left a book reading zero for entries already found -- and
where the gate is the bar, that hid the book outright.

The base at 1935 and the bar indices are measured, not derived. Comments
record which readings fix them so they are not re-derived from scratch.
Millie hace 2 semanas
padre
commit
4863f0ba35

+ 2 - 0
Sunrise/src/core/settings/state_settings.cpp

@@ -174,6 +174,8 @@ bool Parser::unlocks(state::unlocks::Table& output) noexcept {
             parsed = progression_values(output.accountProgressions);
         } else if (key == "character_progressions") {
             parsed = progression_values(output.characterProgressions);
+        } else if (key == "reveal_all_lore_books") {
+            parsed = boolean(output.revealAllLoreBooks);
         } else {
             parsed = skip_value(0);
         }

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

@@ -143,6 +143,18 @@ 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.
+    (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);
 
     for (layout::CharacterUnlockBlock& block : object.characterUnlocks) {
         block.flags = unlocks.characterFlags;

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

@@ -1,4 +1,7 @@
 #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>
@@ -12,6 +15,18 @@ 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. */
@@ -93,6 +108,58 @@ std::size_t apply_visibility(std::span<std::uint8_t> accountFlags) noexcept {
     return set;
 }
 
+/** 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 {
+    const Lock::Shared guard(g_lock);
+    std::size_t set = 0;
+    for (const Definition& node : g_definitions.rows()) {
+        if (node.definitionIndex < kLoreNodeFirst || node.definitionIndex > kLoreNodeLast) {
+            continue;
+        }
+        // apply_visibility already owns the flag-gated books; a node with a flag gate is not one of
+        // this pass's eighteen and must be left alone.
+        if (node.visibilityFlagIndex != kUnavailableFlagIndex
+            || node.visibilityCharacterFlagIndex != kUnavailableFlagIndex) {
+            continue;
+        }
+        if (node.valueIndex == kUnavailableValueIndex
+            || static_cast<std::size_t>(node.valueIndex) >= objectiveValues.size()) {
+            continue;
+        }
+        // Three lore nodes name a valueIndex inside the record-objective range and must never be
+        // written here: that slot belongs to a record's objective, not this node's own gate, and
+        // stamping it has previously redacted large numbers of records in one pass.
+        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.
+        bool acquisitionGate = false;
+        for (const auto& bar : record_claims::parent_bar_table::kBars) {
+            if (bar.nodeIndex == node.definitionIndex) {
+                acquisitionGate = bar.valueIndex != node.valueIndex;
+                break;
+            }
+        }
+        if (!acquisitionGate && !revealAll) {
+            continue;
+        }
+        // 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.
+        if (objectiveValues[node.valueIndex] == 0) {
+            objectiveValues[node.valueIndex] = 1;
+            ++set;
+        }
+    }
+    return set;
+}
+
 /** Sets the character scoped visibility gates of the lore book categories. */
 std::size_t apply_character_visibility(std::span<std::byte> characterFlags) noexcept {
     const Lock::Shared guard(g_lock);

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

@@ -67,4 +67,18 @@ void for_each(void* context, void (*visit)(void*, const Definition&) noexcept) n
  */
 std::size_t apply_character_visibility(std::span<std::byte> characterFlags) noexcept;
 
+/**
+ * Sets the value-gate of every lore book category that has no flag gate at all.
+ *
+ * Fifteen books are satisfied by apply_visibility over a flag. Eighteen more have no flag gate:
+ * their expression instead reads a value slot and tests it against zero, and nothing else in this
+ * build ever writes that slot, so they stay redacted forever without this. Call after every other
+ * 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.
+ * @return Number of gates set.
+ */
+std::size_t apply_category_gates(std::span<std::int32_t> objectiveValues,
+                                 bool revealAll) noexcept;
+
 } // namespace sunrise::state::build_data::nodes

+ 10 - 0
Sunrise/src/state/record_claims/objective_slot_table.h

@@ -10,6 +10,16 @@
 
 namespace sunrise::state::record_claims::objective_slot_table {
 
+/**
+ * First value-bank slot a record's objective can occupy.
+ *
+ * Below this the bank holds category counters and visibility gates; at and above it every slot
+ * belongs to some record's objective. Three lore nodes name a gate slot inside this range, so any
+ * pass that publishes gates or counters has to stop here or it overwrites a record's objective and
+ * redacts records wholesale -- which is exactly what happened once.
+ */
+inline constexpr std::int32_t kRecordObjectiveRangeStart = 2746;
+
 /** One objective's account value-bank slot and the value it must hold to read complete. */
 struct ObjectiveSlot {
     std::uint16_t slot;

+ 40 - 14
Sunrise/src/state/record_claims/parent_bar_table.h

@@ -19,6 +19,32 @@ namespace sunrise::state::record_claims::parent_bar_table {
  *
  * 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.
+ *
+ * An entry naming the node's own gate is therefore legitimate, not a defect. 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.
  */
 struct Bar {
     std::uint16_t nodeIndex;
@@ -33,21 +59,11 @@ inline constexpr std::array<Bar, 34> kBars{{
     {818U, 1941U},  // Most Loyal (measured)
     {819U, 1939U},  // Letters from a Renegade (measured)
     {821U, 2273U},  // Dawning Delights (measured)
-    {822U, 2342U},  // Dust (measured)
-    {823U, 2344U},  // Stolen Intelligence (field136)
-    {824U, 2346U},  // The Warlock Aunor (field136)
-    {825U, 2520U},  // Luna's Lost (field136)
-    {826U, 2521U},  // Letters from Eris (field136)
-    {828U, 2574U},  // Constellations (field136)
-    {829U, 2664U},  // Duress and Egress (field136)
     {831U, 1931U},  // The Forsaken Prince (measured)
     {832U, 1936U},  // Truth to Power (measured)
     {833U, 1938U},  // A Drifter's Gambit (measured)
     {836U, 2347U},  // For Every Rose, a Thorn (measured)
     {837U, 2399U},  // The Chronicon (measured)
-    {839U, 2514U},  // Unveiling (field136)
-    {840U, 2517U},  // Last Days on Kraken Mare (field136)
-    {841U, 2519U},  // Inquisition of the Damned (field136)
     {842U, 2585U},  // Trials and Tribulations (measured)
     {843U, 2663U},  // The Singular Exegete (measured)
     {845U, 1934U},  // The Dreaming City (measured)
@@ -55,11 +71,21 @@ inline constexpr std::array<Bar, 34> kBars{{
     {847U, 1937U},  // The Awoken of the Reef (measured)
     {848U, 2267U},  // The Black Armory Papers (measured)
     {849U, 2348U},  // Ecdysis (measured)
-    {850U, 2341U},  // A Man with No Name (field136)
     {851U, 2397U},  // Nothing Ends (measured)
-    {852U, 2516U},  // Aspect (field136)
-    {853U, 2518U},  // Revelation (field136)
-    {854U, 2584U},  // The Liar (field136)
+    {822U, 2342U},  // Dust — gate is bar (measured 2342, equals its gate)
+    {823U, 2344U},  // Stolen Intelligence — gate is bar
+    {825U, 2520U},  // Luna's Lost — gate is bar
+    {826U, 2521U},  // Letters from Eris — gate is bar
+    {839U, 2514U},  // Unveiling — gate is bar
+    {840U, 2517U},  // Last Days on Kraken Mare — gate is bar
+    {841U, 2519U},  // Inquisition of the Damned — gate is bar
+    {850U, 2341U},  // A Man with No Name — gate is bar
+    {852U, 2516U},  // Aspect — gate is bar
+    {853U, 2518U},  // Revelation — gate is bar
+    {824U, 2349U},  // The Warlock Aunor — decoded, unconfirmed (gate 2346)
+    {828U, 2575U},  // Constellations — decoded, unconfirmed (gate 2574)
+    {829U, 2665U},  // Duress and Egress — decoded, unconfirmed (gate 2664)
+    {854U, 2583U},  // The Liar — decoded, unconfirmed (gate 2584)
 }};
 
 } // namespace sunrise::state::record_claims::parent_bar_table

+ 85 - 3
Sunrise/src/state/record_claims/record_claims.cpp

@@ -481,6 +481,65 @@ struct NodeProgress {
 }
 
 /** Writes each node's claimed-child count into the value slot its bar reads. */
+/**
+ * Base the per-chapter visibility block is addressed from: a chapter's gate is kChapterGateBase
+ * plus its own record row. Measured in game rather than derived -- the block was located by writing
+ * markers across the bank and reading which chapters appeared, and this base is the unique fit for
+ * four independent readings, including A Drifter's Gambit losing exactly its last chapter at one
+ * segment edge and Most Loyal staying dark until the block was extended past the twenty-two rows
+ * that separate it.
+ */
+constexpr std::int32_t kChapterGateBase = 1935;
+/**
+ * Rows 1-6 land on 1936-1941, which are parent bars of other books and already hold their counts.
+ * Those satisfy the threshold on their own and must not be overwritten, so the block is only
+ * written from here up.
+ */
+constexpr std::int32_t kChapterGateFirstWritable = 1942;
+/** The last row whose chapter needs a gate. Beyond it every chapter is displayed by default. */
+constexpr std::uint16_t kChapterGateLastRow = 106;
+
+/** Publishes the per-chapter visibility gate of the Year 1 lore chapters. */
+std::size_t apply_chapter_visibility_gates(std::span<std::int32_t> objectiveValues) noexcept {
+    const std::span<const objective_slot_table::RecordEntry> table{objective_slot_table::kRecords};
+    std::size_t written = 0;
+    for (std::uint16_t row = 0; row <= kChapterGateLastRow; ++row) {
+        build_data::records::Definition record{};
+        if (!build_data::find_record_definition(row, record)
+            || record.loreRow == build_data::records::kUnavailableLoreRow
+            || record.completionFlagIndex == build_data::records::kUnavailableFlagIndex) {
+            continue;
+        }
+        const std::int32_t slot = kChapterGateBase + static_cast<std::int32_t>(row);
+        if (slot < kChapterGateFirstWritable
+            || static_cast<std::size_t>(slot) >= objectiveValues.size()) {
+            continue;
+        }
+        // The gate is a threshold, so the chapter's own completion value is exactly enough: a
+        // counted chapter -- the corrupted-egg records run to nine -- needs its real count, and a
+        // flat 1 would leave every one of those redacted.
+        std::int32_t need = 1;
+        const auto found = std::lower_bound(
+            table.begin(), table.end(), record.completionFlagIndex,
+            [](const objective_slot_table::RecordEntry& entry, std::uint16_t flag) noexcept {
+                return entry.flagIndex < flag;
+            });
+        if (found != table.end() && found->flagIndex == record.completionFlagIndex) {
+            for (std::uint8_t i = 0; i < found->objectiveCount; ++i) {
+                const std::size_t at = static_cast<std::size_t>(found->firstObjective) + i;
+                if (at < objective_slot_table::kObjectives.size()) {
+                    need = std::max(need, objective_slot_table::kObjectives[at].completionValue);
+                }
+            }
+        }
+        if (objectiveValues[static_cast<std::size_t>(slot)] < need) {
+            objectiveValues[static_cast<std::size_t>(slot)] = need;
+        }
+        ++written;
+    }
+    return written;
+}
+
 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.
@@ -528,9 +587,13 @@ std::size_t apply_node_progress(std::span<std::int32_t> objectiveValues) noexcep
                     haveParent = true;
                     continue;
                 }
-                // Claimed only: measured in game, a book's bar moves when a chapter's triumph is
-                // claimed, not when finding the lore makes it claimable.
-                if (claimed_locked(record.completionFlagIndex)) {
+                // Completed, not claimed. A lore record completes when the entry is collected;
+                // claiming it afterwards only pays the score. Counting claims alone left a book
+                // reading zero for entries the player had already found, and since the category's
+                // visibility gate reads this very slot and tests it above zero, a book whose
+                // entries were all collected but unclaimed disappeared outright.
+                if (claimed_locked(record.completionFlagIndex)
+                    || claimable_locked(record.completionFlagIndex)) {
                     ++chapters;
                 }
             }
@@ -544,6 +607,11 @@ std::size_t apply_node_progress(std::span<std::int32_t> objectiveValues) noexcep
                     if (bar.nodeIndex != node.definitionIndex) {
                         continue;
                     }
+                    // A table entry naming the node's own gate is legitimate: ten books drive
+                    // their bar from the very slot that gates the category, so the count belongs
+                    // here even though writing zero into it would redact the book on its own.
+                    // 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;
                         parentSlot = static_cast<std::int32_t>(bar.valueIndex);
@@ -552,6 +620,20 @@ std::size_t apply_node_progress(std::span<std::int32_t> objectiveValues) noexcep
                     break;
                 }
             }
+            // A book whose gate slot is not its bar slot needs the count in both. That slot is the
+            // book's entries-read counter: the category opens when it rises above zero, and on a
+            // cumulative book -- where chapter n completes at value n rather than 1 -- every
+            // chapter compares against it, so a token 1 left chapters 2 upward locked however
+            // correct their own objectives were. 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.
+            if (node.valueIndex != build_data::nodes::kUnavailableValueIndex
+                && static_cast<std::int32_t>(node.valueIndex) < objective_slot_table::kRecordObjectiveRangeStart
+                && static_cast<std::size_t>(node.valueIndex) < state->values.size()
+                && static_cast<std::int32_t>(node.valueIndex) != parentSlot) {
+                state->values[node.valueIndex] = chapters;
+                ++state->written;
+            }
             // Eight books name no value at field 136 and so have no table entry, but their parent
             // slot is still derivable from the shipped allocation -- the slot just past the run of
             // their children. That derivation drove their bars before this table existed and is

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

@@ -78,6 +78,23 @@ std::size_t apply_node_progress(std::span<std::int32_t> objectiveValues) noexcep
  */
 std::size_t apply_claimable_objectives(std::span<std::int32_t> objectiveValues) noexcept;
 
+/**
+ * Publishes the per-chapter visibility gate of the Year 1 lore chapters.
+ *
+ * A lore chapter is displayed only when a value slot of its own holds at least the chapter's
+ * completion value; below that the client shows a redacted entry. The slot is the record's own row
+ * offset into a contiguous block sitting immediately above the Year 1 parent bars -- measured in
+ * game, see kChapterGateBase -- and the test is a threshold, not an equality, which is why probing
+ * the block with a flat 1 lit every chapter that completes at 1 and no other.
+ *
+ * Only the chapters below kChapterGateLastRow need this. Every later chapter is displayed by
+ * default, and the same arithmetic would put their slots inside the record-objective range, where
+ * writing has previously redacted records wholesale.
+ * @param objectiveValues Account value bank.
+ * @return Number of gates published.
+ */
+std::size_t apply_chapter_visibility_gates(std::span<std::int32_t> objectiveValues) noexcept;
+
 /** @return True when this index is already held. */
 [[nodiscard]] bool claimed(std::uint16_t flagIndex) noexcept;
 

+ 11 - 0
Sunrise/src/state/unlocks/definition.h

@@ -59,6 +59,17 @@ 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.
+     */
+    bool revealAllLoreBooks{true};
 };
 
 } // namespace sunrise::state::unlocks