Browse Source

Reveal affected lore books and records without granting progress

Millie 5 days ago
parent
commit
c0b85d7c84

+ 2 - 0
Sunrise/Sunrise.vcxproj

@@ -1993,6 +1993,8 @@
     <ClInclude Include="src\state\progression\seasonal_experience.h" />
     <ClInclude Include="src\state\progression\seasonal_experience.h" />
     <ClInclude Include="src\state\record_claims\objective_slot_table.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\parent_bar_table.h" />
+    <ClInclude Include="src\client\hooks\network\investment\lore_visibility_patch.h" />
+    <ClCompile Include="src\client\hooks\network\investment\investment_lore_visibility.cpp" />
     <ClInclude Include="src\state\record_claims\record_claims.h" />
     <ClInclude Include="src\state\record_claims\record_claims.h" />
   </ItemGroup>
   </ItemGroup>
   <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
   <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />

+ 4 - 0
Sunrise/src/client/hooks/network/investment/internal.h

@@ -33,6 +33,10 @@ void arm_socket_menu_routing() noexcept;
 /** Restores owned category fields and plug-set descriptors without recycling published storage. */
 /** Restores owned category fields and plug-set descriptors without recycling published storage. */
 void restore_socket_menu_routing() noexcept;
 void restore_socket_menu_routing() noexcept;
 
 
+/** Reveals the specified lore entries without altering any account progress. */
+void apply_lore_visibility() noexcept;
+void restore_lore_visibility() noexcept;
+
 /**
 /**
  * Arms one derived-state rebuild after replicated investment state changes.
  * Arms one derived-state rebuild after replicated investment state changes.
  */
  */

+ 1 - 0
Sunrise/src/client/hooks/network/investment/investment_derived_rebuild.cpp

@@ -208,6 +208,7 @@ bool install() noexcept {
 
 
 /** @return True when every investment rebuild detour is absent. */
 /** @return True when every investment rebuild detour is absent. */
 bool uninstall() noexcept {
 bool uninstall() noexcept {
+    restore_lore_visibility();
     restore_socket_menu_routing();
     restore_socket_menu_routing();
     if (!uninstall_family5_rearm()) {
     if (!uninstall_family5_rearm()) {
         core::log::write(core::log::Channel::client,
         core::log::write(core::log::Channel::client,

+ 170 - 0
Sunrise/src/client/hooks/network/investment/investment_lore_visibility.cpp

@@ -0,0 +1,170 @@
+#include <Windows.h>
+
+#include <array>
+#include <limits>
+
+#include "../../../../core/logging/log.h"
+#include "../../../content/handles/handle_resolver.h"
+#include "../../../memory/current_process_memory.h"
+#include "../../../targets/game/content.h"
+#include "internal.h"
+#include "lore_visibility_patch.h"
+
+namespace sunrise::client::hooks::network::investment {
+namespace {
+struct Descriptor {
+    std::uint64_t count;
+    std::int64_t relative;
+};
+struct Patch {
+    std::uintptr_t address;
+    lore::Instruction before, after;
+};
+std::array<Patch, lore::kTargets.size()> g_patches{};
+std::size_t g_count{};
+SRWLOCK g_lock = SRWLOCK_INIT;
+
+template <class T> bool read(std::uintptr_t address, T& value) noexcept {
+    return memory::read_current_process(
+        nullptr, address, std::as_writable_bytes(std::span(&value, 1)));
+}
+bool data_at(std::uintptr_t address, const Descriptor& desc, std::uintptr_t& data) noexcept {
+    if (address > static_cast<std::uintptr_t>(INT64_MAX) - 24) return false;
+    const auto base = static_cast<std::int64_t>(address) + 8;
+    if (desc.relative < -base || desc.relative > INT64_MAX - base - 16) return false;
+    const auto header = static_cast<std::uintptr_t>(base + desc.relative);
+    std::uint64_t count{};
+    std::uint32_t marker{}, type{};
+    if (header < 4 || !read(header, count) || count != desc.count || !read(header - 4, marker)
+        || !read(header + 8, type) || marker >> 16 != 0x8080 || type >> 16 != 0x8080)
+        return false;
+    data = header + 16;
+    return true;
+}
+bool write(const Patch& patch, bool restore) noexcept {
+    const auto expected = restore ? patch.after : patch.before;
+    const auto desired = restore ? patch.before : patch.after;
+    lore::Instruction current{};
+    if (!read(patch.address, current) || current != expected) return false;
+    auto* destination = reinterpret_cast<void*>(patch.address);
+    DWORD previous{};
+    if (!VirtualProtect(destination, sizeof desired, PAGE_READWRITE, &previous)) return false;
+    SIZE_T written{};
+    const bool copied =
+        WriteProcessMemory(GetCurrentProcess(), destination, &desired, sizeof desired, &written)
+        && written == sizeof desired;
+    DWORD ignored{};
+    const bool protectedAgain =
+        VirtualProtect(destination, sizeof desired, previous, &ignored) != FALSE;
+    return copied && protectedAgain && read(patch.address, current) && current == desired;
+}
+bool rollback() noexcept {
+    bool ok = true;
+    for (std::size_t i = g_count; i > 0; --i) {
+        lore::Instruction current{};
+        if (!read(g_patches[i - 1].address, current)) {
+            ok = false;
+            continue;
+        }
+        if (current == g_patches[i - 1].before) continue;
+        if (!write(g_patches[i - 1], true)) ok = false;
+    }
+    if (ok) g_count = 0;
+    return ok;
+}
+bool prepare(std::array<Patch, lore::kTargets.size()>& staged) noexcept {
+    content::handles::Source source{};
+    source.tablesSlot =
+        reinterpret_cast<std::uintptr_t>(targets::game::content::get().contentHandleTablesSlot);
+    source.read = &memory::read_current_process;
+    std::array<std::uintptr_t, 2> rows{};
+    constexpr std::array<std::uint32_t, 2> tags{0x81319339U, 0x8131933FU};
+    constexpr std::array<std::size_t, 2> counts{2242, 924}, strides{216, 168};
+    for (std::size_t i = 0; i < 2; ++i) {
+        std::uintptr_t table{};
+        Descriptor desc{};
+        if (!content::handles::resolve(source, tags[i], table) || !read(table + 8, desc)
+            || desc.count != counts[i] || !data_at(table + 8, desc, rows[i]))
+            return false;
+    }
+    for (std::size_t i = 0; i < staged.size(); ++i) {
+        const auto& target = lore::kTargets[i];
+        const std::size_t kind = target.node ? 1 : 0;
+        const auto row = rows[kind] + target.row * strides[kind];
+        std::uint32_t hash{};
+        Descriptor desc{};
+        std::uintptr_t data{};
+        std::array<lore::Instruction, 59> code{};
+        if (!read(row + 40, hash) || hash != target.hash || !read(row + target.field, desc)
+            || desc.count == 0 || desc.count > code.size()
+            || !data_at(row + target.field, desc, data)
+            || !memory::read_current_process(
+                nullptr,
+                data,
+                std::as_writable_bytes(std::span(code).first(static_cast<std::size_t>(desc.count))))
+            || !lore::replacement(target.shape,
+                                  std::span(code).first(static_cast<std::size_t>(desc.count)),
+                                  staged[i].after))
+            return false;
+        staged[i].address = data;
+        staged[i].before = code[0];
+    }
+    // Each edited instruction must be owned by exactly one presentation condition. Never mutate
+    // a constant shared with another record, even if that record is not in this repair's list.
+    std::array<unsigned, lore::kTargets.size()> references{};
+    for (std::size_t kind = 0; kind < 2; ++kind) {
+        for (std::size_t row = 0; row < counts[kind]; ++row) {
+            for (const std::size_t field : (kind == 0 ? std::array<std::size_t, 2>{120, 136}
+                                                      : std::array<std::size_t, 2>{48, 64})) {
+                const auto at = rows[kind] + row * strides[kind] + field;
+                Descriptor desc{};
+                std::uintptr_t data{};
+                if (!read(at, desc)) return false;
+                if (desc.count == 0) continue;
+                if (desc.count > 128 || !data_at(at, desc, data)) return false;
+                for (std::size_t i = 0; i < staged.size(); ++i)
+                    if (staged[i].address >= data && staged[i].address - data < desc.count * 8)
+                        ++references[i];
+            }
+        }
+    }
+    for (auto count : references)
+        if (count != 1) return false;
+    return true;
+}
+} // namespace
+
+void apply_lore_visibility() noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    if (g_count != 0) {
+        ReleaseSRWLockExclusive(&g_lock);
+        return;
+    }
+    std::array<Patch, lore::kTargets.size()> staged{};
+    bool ok = prepare(staged);
+    if (ok) {
+        for (const auto& patch : staged) {
+            g_patches[g_count++] = patch;
+            if (!write(patch, false)) {
+                ok = false;
+                break;
+            }
+        }
+    }
+    const bool restored = ok || rollback();
+    core::log::write(core::log::Channel::client,
+                     ok ? core::log::Level::info : core::log::Level::warn,
+                     ok ? "ev=lore_visibility result=applied conditions=36 progress_unchanged=1"
+                     : restored ? "ev=lore_visibility result=refused originals_retained=1"
+                                : "ev=lore_visibility result=rollback_failed");
+    ReleaseSRWLockExclusive(&g_lock);
+}
+void restore_lore_visibility() noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    if (!rollback())
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         "ev=lore_visibility result=restore_failed");
+    ReleaseSRWLockExclusive(&g_lock);
+}
+} // namespace sunrise::client::hooks::network::investment

+ 87 - 0
Sunrise/src/client/hooks/network/investment/lore_visibility_patch.h

@@ -0,0 +1,87 @@
+#pragma once
+#include <array>
+#include <cstdint>
+#include <span>
+
+namespace sunrise::client::hooks::network::investment::lore {
+struct Instruction {
+    std::uint32_t opcode{}, operand{};
+    friend bool operator==(const Instruction&, const Instruction&) = default;
+};
+enum class Shape { constant, eva, confessions, chronicon };
+struct Target {
+    std::uint16_t row, field;
+    std::uint32_t hash;
+    Shape shape;
+    bool node;
+};
+inline constexpr auto kTargets = [] {
+    std::array<Target, 36> targets{};
+    std::size_t n = 0;
+    targets[n++] = {820, 64, 0x13F7E95CU, Shape::eva, true};
+    targets[n++] = {837, 64, 0x3FCE8988U, Shape::chronicon, true};
+    constexpr std::array<std::uint32_t, 15> wishes{0xFA360CA1U,
+                                                   0xFA360CA2U,
+                                                   0xFA360CA3U,
+                                                   0xFA360CA4U,
+                                                   0xFA360CA5U,
+                                                   0xFA360CA6U,
+                                                   0xFA360CA7U,
+                                                   0xFA360CA8U,
+                                                   0xFA360CA9U,
+                                                   0xFB360E13U,
+                                                   0xFB360E12U,
+                                                   0xFB360E11U,
+                                                   0xFB360E10U,
+                                                   0xFB360E17U,
+                                                   0xFB360E16U};
+    for (std::size_t i = 0; i < wishes.size(); ++i)
+        targets[n++] = {
+            static_cast<std::uint16_t>(825 + i), 120, wishes[i], Shape::constant, false};
+    targets[n++] = {1707, 136, 0xB337A52FU, Shape::confessions, false};
+    constexpr std::array<std::uint32_t, 9> chapters{0xB780F393U,
+                                                    0xB780F390U,
+                                                    0xB780F391U,
+                                                    0xB780F396U,
+                                                    0xB780F397U,
+                                                    0xB780F394U,
+                                                    0xB780F395U,
+                                                    0xB780F39AU,
+                                                    0xB780F39BU};
+    for (std::size_t i = 0; i < chapters.size(); ++i) {
+        targets[n++] = {
+            static_cast<std::uint16_t>(1708 + i), 120, chapters[i], Shape::confessions, false};
+        targets[n++] = {
+            static_cast<std::uint16_t>(1708 + i), 136, chapters[i], Shape::constant, false};
+    }
+    return targets;
+}();
+
+/** Validate the entire shipped expression before replacing just its first instruction. */
+[[nodiscard]] inline bool
+replacement(Shape shape, std::span<const Instruction> code, Instruction& output) noexcept {
+    if (shape == Shape::constant) {
+        if (code.size() != 1 || code[0] != Instruction{11, 1}) return false;
+        output = {11, 0}; // false
+        return true;
+    }
+    if (shape == Shape::eva || shape == Shape::confessions) {
+        const Instruction read =
+            shape == Shape::eva ? Instruction{10, 10343} : Instruction{1, 8702};
+        if (code.size() != 2 || code[0] != read || code[1] != Instruction{2, 0}) return false;
+        output = {11, 1}; // NOT true = false
+        return true;
+    }
+    if (code.size() != 59) return false;
+    for (std::size_t i = 0; i < 15; ++i) {
+        if (code[i * 3] != Instruction{10, static_cast<std::uint32_t>(10615 + i)}
+            || code[i * 3 + 1] != Instruction{11, 0}
+            || code[i * 3 + 2] != Instruction{8, UINT32_MAX})
+            return false;
+    }
+    for (std::size_t i = 45; i < 59; ++i)
+        if (code[i] != Instruction{4, UINT32_MAX}) return false;
+    output = {11, 1}; // First equality is 1 == 0, making the entire conjunction false.
+    return true;
+}
+} // namespace sunrise::client::hooks::network::investment::lore

+ 1 - 0
Sunrise/src/client/hooks/retail_log/retail_log_enqueue_observer.cpp

@@ -75,6 +75,7 @@ void capture_line(std::int32_t siteId, const char* text) noexcept {
         // This must happen before the native logger returns to investment initialization. A later
         // This must happen before the native logger returns to investment initialization. A later
         // callback tick races the socket-menu caches that consume these descriptors.
         // callback tick races the socket-menu caches that consume these descriptors.
         network::investment::apply_socket_menu_routing();
         network::investment::apply_socket_menu_routing();
+        network::investment::apply_lore_visibility();
     }
     }
     if (!core::log::accepts(core::log::Channel::client, core::log::Level::info)) {
     if (!core::log::accepts(core::log::Channel::client, core::log::Level::info)) {
         return;
         return;

+ 36 - 0
docs/lore-visibility.md

@@ -0,0 +1,36 @@
+# Fresh-state lore visibility repair
+
+The earlier earned-objective repair did not satisfy the fresh-state requirement. It and
+the Confessions Family-5 override have been removed. Account projection, settings, and
+claim persistence now remain unchanged from the confirmed title-fix commit.
+
+Read-only inspection on 2026-09-04 confirmed these expressions in the running Arrivals
+client, not merely the manifest. Nodes and records both carry their identity hash at +40.
+
+| Target | Row/field | Original condition | Change |
+| --- | --- | --- | --- |
+| Eva's Journey | node 820, +64 | NOT VALUE(10343) | Replace read with constant 1; NOT true is false. |
+| The Chronicon | node 837, +64 | 15 chapter-value comparisons with zero, ANDed together | Replace the first read with constant 1; the first comparison is false, so the conjunction is false regardless of progress. |
+| Wall of Wishes | records 825–839, +120 | constant true | constant false |
+| Confessions parent | record 1707, +136 | NOT FLAG(8702) | Replace read with constant 1. |
+| Confessions chapters | records 1708–1716, +120 and +136 | NOT FLAG(8702); constant true | Replace read with constant 1; replace true with false. |
+
+There are 36 edited instructions. All other instructions and all array descriptors remain
+unchanged. This avoids relocation or allocation of expression storage. Completion flags,
+objectives, reward data, and claims are not edited. Revealing a card does not earn it.
+
+Native tables: records `0x81319339` (2242 rows × 216 bytes), nodes `0x8131933F`
+(924 rows × 168 bytes). Preflight checks exact target hashes, complete expression shapes,
+array headers and bounds. It also walks both tables' presentation-condition descriptors
+to require each edited instruction to have exactly one owner; shared constants are refused.
+No writes occur until the whole preflight succeeds. Writes are checked and rolled back on
+failure. Original instructions are retained for hook teardown.
+
+The patch runs synchronously at the existing native content-table-patching-complete event,
+before returning to investment initialization. It does not poll, scan process memory,
+or change the working socket implementation. Expected success log:
+`ev=lore_visibility result=applied conditions=36 progress_unchanged=1`.
+
+Portable tests validate every shipped expression shape, reject malformed/already-edited
+expressions, and evaluate the patched conditions with zero, partial, and completed reads.
+These tests do not substitute for confirming the UI after relaunch.

+ 8 - 0
tests/CMakeLists.txt

@@ -10,3 +10,11 @@ else()
 endif()
 endif()
 add_test(NAME socket_row_relocation
 add_test(NAME socket_row_relocation
     COMMAND socket_row_relocation_test "${CMAKE_CURRENT_SOURCE_DIR}/fixtures/socket_rows.hex")
     COMMAND socket_row_relocation_test "${CMAKE_CURRENT_SOURCE_DIR}/fixtures/socket_rows.hex")
+add_executable(lore_visibility_test lore_visibility_test.cpp)
+target_compile_features(lore_visibility_test PRIVATE cxx_std_20)
+if(MSVC)
+    target_compile_options(lore_visibility_test PRIVATE /W4 /WX /UNDEBUG)
+else()
+    target_compile_options(lore_visibility_test PRIVATE -Wall -Wextra -Werror -UNDEBUG)
+endif()
+add_test(NAME lore_visibility COMMAND lore_visibility_test)

+ 5 - 1
tests/README.md

@@ -1,4 +1,4 @@
-# Portable socket regression
+# Portable regressions
 
 
 Run without Wine or the game:
 Run without Wine or the game:
 
 
@@ -17,3 +17,7 @@ so the test detects the shallow-copy bug. There are no account records or absolu
 The test uses the production relocation header. It checks membership/order, payload preservation,
 The test uses the production relocation header. It checks membership/order, payload preservation,
 relocation to another allocation, and rejection of bad pointers, substituted conditions, duplicates,
 relocation to another allocation, and rejection of bad pointers, substituted conditions, duplicates,
 unexpected shapes, insufficient storage, and misalignment. Assertions remain enabled in Release.
 unexpected shapes, insufficient storage, and misalignment. Assertions remain enabled in Release.
+
+`lore_visibility_test` checks all 36 targeted native expression shapes, rejects changed or
+already-patched inputs, and evaluates the repaired conditions with fresh, partial, and completed
+unlock reads. Only visibility fields are targeted. See `docs/lore-visibility.md` for the evidence.

+ 76 - 0
tests/lore_visibility_test.cpp

@@ -0,0 +1,76 @@
+#include <array>
+#include <cassert>
+#include <vector>
+
+#include "../Sunrise/src/client/hooks/network/investment/lore_visibility_patch.h"
+namespace lore = sunrise::client::hooks::network::investment::lore;
+
+// Model the decoded instructions with arbitrary unlock reads, including a completely fresh save.
+bool evaluate(std::span<const lore::Instruction> code, int value) {
+    std::vector<int> stack;
+    for (auto instruction : code) {
+        const auto op = instruction.opcode;
+        if (op == 1 || op == 10)
+            stack.push_back(value);
+        else if (op == 11)
+            stack.push_back(static_cast<int>(instruction.operand));
+        else if (op == 2) {
+            assert(!stack.empty());
+            stack.back() = !stack.back();
+        } else {
+            assert(stack.size() >= 2);
+            int b = stack.back();
+            stack.pop_back();
+            int a = stack.back();
+            stack.pop_back();
+            assert(op == 4 || op == 8);
+            stack.push_back(op == 4 ? (a && b) : (a == b));
+        }
+    }
+    assert(stack.size() == 1);
+    return stack.back() != 0;
+}
+int main() {
+    assert(lore::kTargets.size() == 36);
+    for (const auto& target : lore::kTargets) {
+        std::vector<lore::Instruction> code;
+        switch (target.shape) {
+        case lore::Shape::constant:
+            code = {{11, 1}};
+            break;
+        case lore::Shape::eva:
+            code = {{10, 10343}, {2, 0}};
+            break;
+        case lore::Shape::confessions:
+            code = {{1, 8702}, {2, 0}};
+            break;
+        case lore::Shape::chronicon:
+            for (unsigned i = 0; i < 15; ++i) {
+                code.push_back({10, 10615 + i});
+                code.push_back({11, 0});
+                code.push_back({8, UINT32_MAX});
+            }
+            for (unsigned i = 0; i < 14; ++i)
+                code.push_back({4, UINT32_MAX});
+            break;
+        }
+        const auto original = code;
+        lore::Instruction output{};
+        assert(lore::replacement(target.shape, code, output));
+        code[0] = output;
+        for (int value : {0, 1, 2, 100})
+            assert(!evaluate(code, value));
+        for (std::size_t i = 1; i < code.size(); ++i)
+            assert(code[i] == original[i]);
+        // Refuse changed schema, wrong operands, and already-modified conditions.
+        assert(!lore::replacement(target.shape, code, output));
+        code = original;
+        code.back().operand ^= 1;
+        assert(!lore::replacement(target.shape, code, output));
+        assert(!lore::replacement(target.shape, {}, output));
+    }
+    // No chapter completion, reward, or redemption fields occur in the target plan.
+    for (const auto& target : lore::kTargets) {
+        assert(target.node ? target.field == 64 : (target.field == 120 || target.field == 136));
+    }
+}