Browse Source

Reveal affected lore books and records without granting progress

Millie 4 ngày trước cách đây
mục cha
commit
c0b85d7c84

+ 2 - 0
Sunrise/Sunrise.vcxproj

@@ -1993,6 +1993,8 @@
     <ClInclude Include="src\state\progression\seasonal_experience.h" />
     <ClInclude Include="src\state\record_claims\objective_slot_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" />
   </ItemGroup>
   <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. */
 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.
  */

+ 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. */
 bool uninstall() noexcept {
+    restore_lore_visibility();
     restore_socket_menu_routing();
     if (!uninstall_family5_rearm()) {
         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
         // callback tick races the socket-menu caches that consume these descriptors.
         network::investment::apply_socket_menu_routing();
+        network::investment::apply_lore_visibility();
     }
     if (!core::log::accepts(core::log::Channel::client, core::log::Level::info)) {
         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()
 add_test(NAME socket_row_relocation
     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:
 
@@ -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,
 relocation to another allocation, and rejection of bad pointers, substituted conditions, duplicates,
 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));
+    }
+}