Quellcode durchsuchen

Clean up artifact socket repair and add portable regression coverage

Millie vor 4 Tagen
Ursprung
Commit
5230acaa37

+ 3 - 1
.gitignore

@@ -2,7 +2,7 @@
 /.idea/
 /.vscode/
 /.direnv/
-/.xwin-cache/
+/.xwin-cache
 /.cache/
 *.user
 *.suo
@@ -14,4 +14,6 @@
 
 # CMake build files
 /build/
+/build-merge/
+/build-resolved/
 /out/

+ 22 - 0
Sunrise/Sunrise.vcxproj

@@ -1973,5 +1973,27 @@
     <ClInclude Include="src\server\gameplay\group\group_migration_receipts.h" />
     <ClInclude Include="src\core\runtime\server_clock.h" />
   </ItemGroup>
+  <ItemGroup>
+    <ClCompile Include="src\client\hooks\network\investment\investment_socket_menu_routing.cpp" />
+    <ClInclude Include="src\client\hooks\network\investment\socket_row_relocation.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\opcode2400.h" />
+    <ClInclude Include="src\state\build_data\nodes\definition.h" />
+    <ClInclude Include="src\state\build_data\nodes\node_catalog.h" />
+    <ClInclude Include="src\state\build_data\records\definition.h" />
+    <ClInclude Include="src\state\build_data\records\record_catalog.h" />
+    <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\build_data\sobjects\sobject_catalog.h" />
+    <ClInclude Include="src\state\lore\lore_grant.h" />
+    <ClInclude Include="src\state\progression\season_pass_reward_catalog.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\parent_bar_table.h" />
+    <ClInclude Include="src\state\record_claims\record_claims.h" />
+  </ItemGroup>
   <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
 </Project>

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

@@ -21,7 +21,7 @@ using patterns::signature_length;
 /** @return True while the family-five commit rearm is attached. */
 [[nodiscard]] bool family5_rearm_is_installed() noexcept;
 
-/** Moves four mislabeled Arrivals members between their validated native reusable plug sets. */
+/** Applies the armed set correction and services bounded category-readiness retries. */
 void apply_socket_menu_routing() noexcept;
 
 /** Reserves low-address storage before retail content occupies the compatible address domain. */
@@ -30,7 +30,7 @@ void reserve_socket_menu_routing_storage() noexcept;
 /** Arms socket-menu correction at native content-table patch completion. */
 void arm_socket_menu_routing() noexcept;
 
-/** Restores native plug-set descriptors changed by the Arrivals membership correction. */
+/** Restores owned category fields and plug-set descriptors without recycling published storage. */
 void restore_socket_menu_routing() noexcept;
 
 /**

+ 118 - 126
Sunrise/src/client/hooks/network/investment/investment_socket_menu_routing.cpp

@@ -58,6 +58,12 @@ constexpr std::size_t kMaximumMemberCount = 4096;
 constexpr std::uintptr_t kMaximumLowAddress = UINT32_MAX;
 constexpr std::size_t kLowArenaSize = 64U * 1024U;
 constexpr std::size_t kArenaAlignment = 16U;
+constexpr std::size_t kPlugBlockOffset = 0x184;
+constexpr std::size_t kPlugCategoryOffset = 4;
+constexpr std::size_t kPlugBlockSize = 64;
+constexpr unsigned kCategoryAttemptLimit = 120;
+constexpr ULONGLONG kCategoryRetryIntervalMs = 250;
+constexpr ULONGLONG kCategoryRetryWindowMs = 30000;
 
 struct ArrayDescriptor {
     std::uint64_t count{};
@@ -110,8 +116,6 @@ enum class Failure : std::uint8_t {
     targets,
     source,
     ambiguous,
-    shape,
-    membership,
     allocation,
     write,
     verification,
@@ -125,7 +129,7 @@ std::size_t g_appliedCount{};
 Failure g_lastFailure{Failure::none};
 std::byte* g_lowArena{};
 std::size_t g_lowArenaUsed{};
-std::array<std::array<relocation::Row, 56>, 2> g_expected{};
+std::array<std::array<relocation::Row, relocation::kMaximumMembers>, 2> g_expected{};
 std::array<std::size_t, 2> g_expectedCounts{};
 content_investment::Source g_categorySource{};
 std::array<std::uint16_t, 4> g_categoryRoutes{};
@@ -145,10 +149,6 @@ const char* g_categoryFailure = "none";
         return "source";
     case Failure::ambiguous:
         return "ambiguous";
-    case Failure::shape:
-        return "shape";
-    case Failure::membership:
-        return "membership";
     case Failure::allocation:
         return "allocation";
     case Failure::write:
@@ -167,12 +167,12 @@ void report_failure(Failure failure, std::uint64_t detail = 0) noexcept {
     }
     g_lastFailure = failure;
     std::array<char, core::log::kLineCapacity> line{};
-    const int written = std::snprintf(
-        line.data(),
-        line.size(),
-        "ev=investment stage=arrivals_leg_sets result=deferred reason=%s detail=%llu",
-        failure_name(failure),
-        static_cast<unsigned long long>(detail));
+    const int written =
+        std::snprintf(line.data(),
+                      line.size(),
+                      "ev=investment stage=arrivals_leg_sets result=deferred reason=%s detail=%llu",
+                      failure_name(failure),
+                      static_cast<unsigned long long>(detail));
     if (written > 0) {
         core::log::write(
             core::log::Channel::client,
@@ -185,8 +185,7 @@ void report_failure(Failure failure, std::uint64_t detail = 0) noexcept {
     return !output.empty() && memory::read_current_process(nullptr, address, output);
 }
 
-template <typename Value>
-[[nodiscard]] bool read(std::uintptr_t address, Value& value) noexcept {
+template <typename Value> [[nodiscard]] bool read(std::uintptr_t address, Value& value) noexcept {
     return read_bytes(address, std::span(reinterpret_cast<std::byte*>(&value), sizeof value));
 }
 
@@ -202,19 +201,17 @@ template <typename Value>
     std::memcpy(destination, bytes.data(), bytes.size());
     DWORD ignored = 0;
     const bool restored = VirtualProtect(destination, bytes.size(), previous, &ignored) != FALSE;
-    FlushInstructionCache(GetCurrentProcess(), destination, bytes.size());
     return restored;
 }
 
 template <typename Value>
 [[nodiscard]] bool write(std::uintptr_t address, const Value& value) noexcept {
-    return write_bytes(
-        address, std::span(reinterpret_cast<const std::byte*>(&value), sizeof value));
+    return write_bytes(address,
+                       std::span(reinterpret_cast<const std::byte*>(&value), sizeof value));
 }
 
-[[nodiscard]] bool add_relative(std::uintptr_t base,
-                                std::int64_t relative,
-                                std::uintptr_t& output) noexcept {
+[[nodiscard]] bool
+add_relative(std::uintptr_t base, std::int64_t relative, std::uintptr_t& output) noexcept {
     if (relative >= 0) {
         const auto distance = static_cast<std::uint64_t>(relative);
         if (distance > (std::numeric_limits<std::uintptr_t>::max)() - base) {
@@ -250,9 +247,8 @@ template <typename Value>
     std::uint64_t repeatedCount = 0;
     std::uint32_t elementClass = 0;
     if (!read(header - kArrayMarkerSize, marker) || !read(header, repeatedCount)
-        || !read(header + sizeof(std::uint64_t), elementClass)
-        || repeatedCount != encoded.count || (marker >> 16U) != 0x8080U
-        || (elementClass >> 16U) != 0x8080U
+        || !read(header + sizeof(std::uint64_t), elementClass) || repeatedCount != encoded.count
+        || (marker >> 16U) != 0x8080U || (elementClass >> 16U) != 0x8080U
         || encoded.count
                > ((std::numeric_limits<std::uintptr_t>::max)() - header - kArrayHeaderSize)
                      / stride) {
@@ -267,9 +263,8 @@ template <typename Value>
     return true;
 }
 
-[[nodiscard]] bool member_index(const ArrayView& array,
-                                std::size_t position,
-                                std::uint32_t& index) noexcept {
+[[nodiscard]] bool
+member_index(const ArrayView& array, std::size_t position, std::uint32_t& index) noexcept {
     return position < array.count
            && read(array.data + static_cast<std::uintptr_t>(position) * kPlugMemberStride, index);
 }
@@ -312,8 +307,7 @@ template <typename Value>
     const auto& targets = targets::game::content::get();
     content_investment::Source source{};
     source.investmentGlobalsTag = candidate.tag;
-    source.handles.tablesSlot =
-        reinterpret_cast<std::uintptr_t>(targets.contentHandleTablesSlot);
+    source.handles.tablesSlot = reinterpret_cast<std::uintptr_t>(targets.contentHandleTablesSlot);
     source.handles.read = &memory::read_current_process;
 
     std::uintptr_t globals = 0;
@@ -355,22 +349,18 @@ template <typename Value>
     }
 
     ArrayView sets{};
-    if (!resolve_array(plugSetTable + kTableArrayDescriptorOffset,
-                       kMaximumSetCount,
-                       kPlugSetRowStride,
-                       sets)
+    if (!resolve_array(
+            plugSetTable + kTableArrayDescriptorOffset, kMaximumSetCount, kPlugSetRowStride, sets)
         || sets.count <= kLegSetIndex) {
         return false;
     }
-    const std::uintptr_t generalDescriptor = sets.data
-                                             + kGeneralSetIndex * kPlugSetRowStride
-                                             + kPlugSetMemberDescriptorOffset;
-    const std::uintptr_t legDescriptor = sets.data + kLegSetIndex * kPlugSetRowStride
-                                         + kPlugSetMemberDescriptorOffset;
+    const std::uintptr_t generalDescriptor =
+        sets.data + kGeneralSetIndex * kPlugSetRowStride + kPlugSetMemberDescriptorOffset;
+    const std::uintptr_t legDescriptor =
+        sets.data + kLegSetIndex * kPlugSetRowStride + kPlugSetMemberDescriptorOffset;
     ArrayView general{};
     ArrayView legs{};
-    if (!resolve_array(
-            generalDescriptor, kMaximumMemberCount, kPlugMemberStride, general)
+    if (!resolve_array(generalDescriptor, kMaximumMemberCount, kPlugMemberStride, general)
         || !resolve_array(legDescriptor, kMaximumMemberCount, kPlugMemberStride, legs)
         || !native_membership(general, legs, routes, reference)) {
         return false;
@@ -395,8 +385,7 @@ template <typename Value>
     }
     std::array<state::content::Definition, kBootstrapCandidateCapacity> candidates{};
     std::size_t candidateCount = 0;
-    if (!state::content::lookup_hash(
-            kInvestmentGlobalsNameHash, candidates, candidateCount)
+    if (!state::content::lookup_hash(kInvestmentGlobalsNameHash, candidates, candidateCount)
         && candidateCount == 0) {
         return false;
     }
@@ -433,8 +422,8 @@ template <typename Value>
     SYSTEM_INFO system{};
     GetSystemInfo(&system);
     const std::uintptr_t granularity = system.dwAllocationGranularity;
-    std::uintptr_t cursor = align_up(
-        reinterpret_cast<std::uintptr_t>(system.lpMinimumApplicationAddress), granularity);
+    std::uintptr_t cursor =
+        align_up(reinterpret_cast<std::uintptr_t>(system.lpMinimumApplicationAddress), granularity);
     while (cursor != 0 && cursor <= kMaximumLowAddress
            && kLowArenaSize <= kMaximumLowAddress - cursor + 1U) {
         MEMORY_BASIC_INFORMATION information{};
@@ -451,8 +440,8 @@ template <typename Value>
             const std::uintptr_t candidate = align_up((std::max)(cursor, base), granularity);
             const std::uintptr_t offset = candidate >= base ? candidate - base : 0;
             if (candidate != 0 && candidate <= kMaximumLowAddress
-                && kLowArenaSize <= kMaximumLowAddress - candidate + 1U
-                && candidate >= base && offset <= information.RegionSize
+                && kLowArenaSize <= kMaximumLowAddress - candidate + 1U && candidate >= base
+                && offset <= information.RegionSize
                 && kLowArenaSize <= information.RegionSize - offset) {
                 void* const allocated = VirtualAlloc(reinterpret_cast<void*>(candidate),
                                                      kLowArenaSize,
@@ -473,8 +462,7 @@ template <typename Value>
 
 /** Carves one immutable array payload from the arena reserved at DLL process attach. */
 [[nodiscard]] Allocation allocate_array(std::size_t size) noexcept {
-    const std::size_t aligned = static_cast<std::size_t>(
-        align_up(g_lowArenaUsed, kArenaAlignment));
+    const std::size_t aligned = static_cast<std::size_t>(align_up(g_lowArenaUsed, kArenaAlignment));
     if (g_lowArena == nullptr || aligned > kLowArenaSize || size > kLowArenaSize - aligned) {
         return {};
     }
@@ -487,35 +475,35 @@ void release(Allocation& allocation) noexcept {
     allocation = {};
 }
 
-[[nodiscard]] bool snapshot_row(const ArrayView& source,
-                                std::size_t position,
-                                relocation::Row& output) noexcept {
+[[nodiscard]] bool
+snapshot_row(const ArrayView& source, std::size_t position, relocation::Row& output) noexcept {
     output = {};
     const auto address = source.data + position * kPlugMemberStride;
     if (position >= source.count || address % 8 != 0
-        || source.elementClass != relocation::kMemberClass
-        || !read_bytes(address, output.bytes)) return false;
+        || source.elementClass != relocation::kMemberClass || !read_bytes(address, output.bytes))
+        return false;
     const auto count = relocation::get<std::uint64_t>(output.bytes.data() + 8);
     if (count != 0) {
         std::uintptr_t header = 0;
         if (count != 1
-            || !add_relative(address + 16,
-                             relocation::get<std::int64_t>(output.bytes.data() + 16), header)
-            || header < 4 || header % 8 != 0
-            || !read_bytes(header - 4, output.condition)) return false;
+            || !add_relative(
+                address + 16, relocation::get<std::int64_t>(output.bytes.data() + 16), header)
+            || header < 4 || header % 8 != 0 || !read_bytes(header - 4, output.condition))
+            return false;
     }
     return relocation::valid(output);
 }
 
 [[nodiscard]] bool verify_owned(std::size_t set, const Allocation& allocation) noexcept {
-    return allocation && relocation::verify(
-        std::span(g_expected[set].data(), g_expectedCounts[set]),
-        std::span<const std::byte>(allocation.base, allocation.size));
+    return allocation
+           && relocation::verify(std::span(g_expected[set].data(), g_expectedCounts[set]),
+                                 std::span<const std::byte>(allocation.base, allocation.size));
 }
 
 /** Validates the exact plug blocks observed in this build before changing four category fields. */
 bool stage_categories(const content_investment::Source& source,
-                      std::span<const std::uint16_t> routes, std::uint16_t reference,
+                      std::span<const std::uint16_t> routes,
+                      std::uint16_t reference,
                       std::array<CategoryPatch, 4>& output) noexcept {
     std::uintptr_t globals = 0, root = 0, table = 0;
     g_categoryFailure = "table";
@@ -524,32 +512,37 @@ bool stage_categories(const content_investment::Source& source,
         || !read(globals + content_investment::layout::kGlobalsRootTagOffset, tag)
         || !content_handles::resolve(source.handles, tag, root)
         || !read(root + content_investment::layout::kItemTableTagOffset, tag)
-        || !content_handles::resolve(source.handles, tag, table)) return false;
-    const auto block = [&](std::uint16_t index, std::uint32_t hash,
-                           std::uintptr_t& address, std::array<std::byte, 64>& bytes) noexcept {
+        || !content_handles::resolve(source.handles, tag, table))
+        return false;
+    const auto block = [&](std::uint16_t index,
+                           std::uint32_t hash,
+                           std::uintptr_t& address,
+                           std::array<std::byte, kPlugBlockSize>& bytes) noexcept {
         item_layout::ItemIndexRow row{};
         g_categoryFailure = "definition";
         std::uintptr_t definition = 0;
         if (!read(table + item_layout::kTableFirstRowOffset + index * sizeof row, row)
             || row.definitionHash != hash
-            || !content_handles::resolve(source.handles, row.targetHandle, definition)) return false;
-        address = definition + 0x188;
+            || !content_handles::resolve(source.handles, row.targetHandle, definition))
+            return false;
+        address = definition + kPlugBlockOffset + kPlugCategoryOffset;
         g_categoryFailure = "block";
-        return read_bytes(definition + 0x184, bytes)
+        return read_bytes(definition + kPlugBlockOffset, bytes)
                && relocation::get<std::uint32_t>(bytes.data()) == 0x808077E3U;
     };
-    std::array<std::byte, 64> referenceBlock{};
+    std::array<std::byte, kPlugBlockSize> referenceBlock{};
     std::uintptr_t referenceAddress = 0;
     if (!block(reference, kLegArmorReferenceHash, referenceAddress, referenceBlock)) return false;
     g_categoryFailure = "reference_category";
     if (relocation::get<std::uint32_t>(referenceBlock.data() + 4) != kLegCategory) return false;
     for (std::size_t i = 0; i < routes.size(); ++i) {
-        std::array<std::byte, 64> bytes{};
+        std::array<std::byte, kPlugBlockSize> bytes{};
         if (!block(routes[i], kArrivalsLegModHashes[i], output[i].address, bytes)) return false;
         output[i].original = relocation::get<std::uint32_t>(bytes.data() + 4);
         g_categoryFailure = "category_or_metadata";
         if ((output[i].original != kGeneralCategory && output[i].original != kLegCategory)
-            || std::memcmp(bytes.data() + 8, referenceBlock.data() + 8, 56) != 0) return false;
+            || std::memcmp(bytes.data() + 8, referenceBlock.data() + 8, 56) != 0)
+            return false;
     }
     return true;
 }
@@ -563,21 +556,39 @@ bool categories_current() noexcept {
     return true;
 }
 
+/** Restore only fields still owned by this patch; retain ownership if any restore fails. */
+bool restore_categories() noexcept {
+    bool restored = true;
+    for (std::size_t i = 0; i < g_categoryCount; ++i) {
+        const auto& category = g_categories[i];
+        std::uint32_t current = 0;
+        if (!read(category.address, current)
+            || (current != category.original
+                && (current != kLegCategory || !write(category.address, category.original)))) {
+            restored = false;
+        }
+    }
+    if (restored) g_categoryCount = 0;
+    return restored;
+}
+
 /** Bounded retry for item definitions that become available after set-table initialization. */
 void apply_pending_categories() noexcept {
     if (g_categoryAttempts == 0) return;
     const auto now = GetTickCount64();
     if (now < g_categoryNext) return;
-    g_categoryNext = now + 250;
+    g_categoryNext = now + kCategoryRetryIntervalMs;
     --g_categoryAttempts;
     std::array<CategoryPatch, 4> categories{};
     if (!stage_categories(g_categorySource, g_categoryRoutes, g_categoryReference, categories)) {
         if (now >= g_categoryDeadline) g_categoryAttempts = 0;
-        if (g_categoryAttempts == 119 || g_categoryAttempts == 0) {
+        if (g_categoryAttempts == kCategoryAttemptLimit - 1 || g_categoryAttempts == 0) {
             std::array<char, 200> line{};
-            std::snprintf(line.data(), line.size(),
+            std::snprintf(line.data(),
+                          line.size(),
                           "ev=investment stage=arrivals_leg_categories result=%s reason=%s",
-                          g_categoryAttempts == 0 ? "failed" : "pending", g_categoryFailure);
+                          g_categoryAttempts == 0 ? "failed" : "pending",
+                          g_categoryFailure);
             core::log::write(core::log::Channel::client, core::log::Level::warn, line.data());
         }
         return;
@@ -587,33 +598,29 @@ void apply_pending_categories() noexcept {
     g_categoryCount = categories.size();
     bool written = true;
     for (const auto& category : categories) {
-        if (!write(category.address, kLegCategory)) { written = false; break; }
+        if (!write(category.address, kLegCategory)) {
+            written = false;
+            break;
+        }
     }
     if (written && categories_current()) {
-        core::log::write(core::log::Channel::client, core::log::Level::info,
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
                          "ev=investment stage=arrivals_leg_categories result=applied verified=4");
         return;
     }
-    bool restored = true;
-    for (const auto& category : categories) {
-        std::uint32_t current = 0;
-        if (!read(category.address, current)
-            || (current != category.original
-                && (current != kLegCategory || !write(category.address, category.original)))) {
-            restored = false;
-        }
-    }
-    if (restored) g_categoryCount = 0;
-    core::log::write(core::log::Channel::client, core::log::Level::warn,
-                     "ev=investment stage=arrivals_leg_categories result=failed reason=write_or_verify");
+    (void)restore_categories();
+    core::log::write(
+        core::log::Channel::client,
+        core::log::Level::warn,
+        "ev=investment stage=arrivals_leg_categories result=failed reason=write_or_verify");
 }
 
 [[nodiscard]] bool encode_descriptor(std::uintptr_t descriptor,
                                      const Allocation& allocation,
                                      std::uint64_t count,
                                      ArrayDescriptor& output) noexcept {
-    const std::uintptr_t header =
-        reinterpret_cast<std::uintptr_t>(allocation.base) + 8;
+    const std::uintptr_t header = reinterpret_cast<std::uintptr_t>(allocation.base) + 8;
     const std::uintptr_t relativeBase = descriptor + sizeof(std::uint64_t);
     if (header >= relativeBase) {
         const std::uintptr_t distance = header - relativeBase;
@@ -637,7 +644,7 @@ void apply_pending_categories() noexcept {
                                  Allocation& allocation,
                                  ArrayDescriptor& descriptor) noexcept {
     const std::uint64_t newCount = source.count - routes.size();
-    if (newCount != 15) return false;
+    if (newCount != kGeneralMemberCount - kArrivalsLegModHashes.size()) return false;
     std::size_t written = 0;
     for (std::size_t position = 0; position < source.count; ++position) {
         std::uint32_t index = 0;
@@ -674,7 +681,7 @@ void apply_pending_categories() noexcept {
                               Allocation& allocation,
                               ArrayDescriptor& descriptor) noexcept {
     const std::uint64_t newCount = source.count + routes.size();
-    if (newCount != 56) return false;
+    if (newCount != kLegMemberCount + kArrivalsLegModHashes.size()) return false;
     std::size_t referencePosition = source.count;
     for (std::size_t position = 0; position < source.count; ++position) {
         if (!snapshot_row(source, position, g_expected[1][position])) {
@@ -700,15 +707,16 @@ void apply_pending_categories() noexcept {
             std::uint32_t index = 0;
             if (!member_index(general, position, index)) return false;
             if (index != routes[route]) continue;
-            if (found || !snapshot_row(general, position,
-                                       g_expected[1][source.count + route])) return false;
+            if (found || !snapshot_row(general, position, g_expected[1][source.count + route]))
+                return false;
             found = true;
         }
         if (!found) return false;
     }
     g_expectedCounts[1] = static_cast<std::size_t>(newCount);
     // Keep the native Empty Mod Socket first, followed by the four artifact mods.
-    std::rotate(g_expected[1].begin() + 1, g_expected[1].begin() + source.count,
+    std::rotate(g_expected[1].begin() + 1,
+                g_expected[1].begin() + source.count,
                 g_expected[1].begin() + newCount);
     allocation = allocate_array(relocation::capacity(g_expectedCounts[1]));
     return allocation
@@ -758,14 +766,8 @@ void apply_pending_categories() noexcept {
     }
     ArrayView general{};
     ArrayView legs{};
-    return resolve_array(g_applied[0].descriptor,
-                         kMaximumMemberCount,
-                         kPlugMemberStride,
-                         general)
-           && resolve_array(g_applied[1].descriptor,
-                            kMaximumMemberCount,
-                            kPlugMemberStride,
-                            legs)
+    return resolve_array(g_applied[0].descriptor, kMaximumMemberCount, kPlugMemberStride, general)
+           && resolve_array(g_applied[1].descriptor, kMaximumMemberCount, kPlugMemberStride, legs)
            && patched_membership(general, legs, routes, reference)
            && verify_owned(0, g_allocations[0]) && verify_owned(1, g_allocations[1]);
 }
@@ -773,16 +775,7 @@ void apply_pending_categories() noexcept {
 /** Restores descriptors only when they still name this module's allocations. */
 bool restore_applied() noexcept {
     g_categoryAttempts = 0;
-    bool restoredAll = true;
-    for (std::size_t i = 0; i < g_categoryCount; ++i) {
-        const auto& category = g_categories[i];
-        std::uint32_t value = 0;
-        if (!read(category.address, value)
-            || (value != category.original
-                && (value != kLegCategory || !write(category.address, category.original)))) {
-            restoredAll = false;
-        }
-    }
+    bool restoredAll = restore_categories();
     for (std::size_t index = g_appliedCount; index > 0; --index) {
         const AppliedSet& applied = g_applied[index - 1];
         ArrayDescriptor current{};
@@ -790,8 +783,7 @@ bool restore_applied() noexcept {
             restoredAll = false;
         } else if (current == applied.original) {
             continue;
-        } else if (current != applied.replacement
-                   || !write(applied.descriptor, applied.original)) {
+        } else if (current != applied.replacement || !write(applied.descriptor, applied.original)) {
             restoredAll = false;
         }
     }
@@ -799,8 +791,6 @@ bool restore_applied() noexcept {
         for (Allocation& allocation : g_allocations) {
             release(allocation);
         }
-    }
-    if (restoredAll) {
         g_applied = {};
         g_appliedCount = 0;
         g_categoryCount = 0;
@@ -840,8 +830,8 @@ void apply_socket_menu_routing() noexcept {
         referenceIndex = definition.definitionIndex;
     }
     for (std::size_t route = 0; mapped && route < kArrivalsLegModHashes.size(); ++route) {
-        mapped = state::build_data::find_item_definition_hash(
-            kArrivalsLegModHashes[route], definition);
+        mapped =
+            state::build_data::find_item_definition_hash(kArrivalsLegModHashes[route], definition);
         if (mapped) {
             routeIndices[route] = definition.definitionIndex;
         }
@@ -877,9 +867,12 @@ void apply_socket_menu_routing() noexcept {
     std::array<Allocation, 2> allocations{};
     std::array<ArrayDescriptor, 2> replacements{};
     if (!build_general(located.general, routeIndices, allocations[0], replacements[0])
-        || !build_legs(
-            located.legs, located.general, routeIndices, referenceIndex,
-            allocations[1], replacements[1])) {
+        || !build_legs(located.legs,
+                       located.general,
+                       routeIndices,
+                       referenceIndex,
+                       allocations[1],
+                       replacements[1])) {
         release(allocations[0]);
         release(allocations[1]);
         report_failure(Failure::allocation);
@@ -902,8 +895,7 @@ void apply_socket_menu_routing() noexcept {
                   {located.legs.descriptor, originalLegs, replacements[1]}}};
     g_appliedCount = g_applied.size();
     const bool generalWritten = write(located.general.descriptor, replacements[0]);
-    const bool legsWritten = generalWritten
-                             && write(located.legs.descriptor, replacements[1]);
+    const bool legsWritten = generalWritten && write(located.legs.descriptor, replacements[1]);
     if (!legsWritten) {
         restore_applied();
         report_failure(Failure::write);
@@ -922,9 +914,9 @@ void apply_socket_menu_routing() noexcept {
     g_categorySource = located.source;
     g_categoryRoutes = routeIndices;
     g_categoryReference = referenceIndex;
-    g_categoryAttempts = 120;
+    g_categoryAttempts = kCategoryAttemptLimit;
     g_categoryNext = 0;
-    g_categoryDeadline = GetTickCount64() + 30000;
+    g_categoryDeadline = GetTickCount64() + kCategoryRetryWindowMs;
     apply_pending_categories();
     g_armed.store(false, std::memory_order_release);
     std::array<char, core::log::kLineCapacity> line{};

+ 11 - 9
Sunrise/src/client/hooks/network/investment/socket_row_relocation.h

@@ -12,22 +12,23 @@ inline constexpr std::uint32_t kMarker = 0x80809FBDU;
 inline constexpr std::uint32_t kMemberClass = 0x80802E03U;
 inline constexpr std::uint32_t kConditionClass = 0x80807D31U;
 inline constexpr std::size_t kDataOffset = 24;
+inline constexpr std::size_t kMaximumMembers = 56;
+inline constexpr std::size_t kMemberSize = 32;
+inline constexpr std::size_t kConditionSize = 32;
 
 /** A captured member and its own opaque, single-record condition allocation. */
 struct Row {
-    std::array<std::byte, 32> bytes{};
-    std::array<std::byte, 32> condition{};
+    std::array<std::byte, kMemberSize> bytes{};
+    std::array<std::byte, kConditionSize> condition{};
 };
 
-template <typename T>
-T get(const std::byte* p) noexcept {
+template <typename T> T get(const std::byte* p) noexcept {
     T value{};
     std::memcpy(&value, p, sizeof value);
     return value;
 }
 
-template <typename T>
-void put(std::byte* p, T value) noexcept {
+template <typename T> void put(std::byte* p, T value) noexcept {
     std::memcpy(p, &value, sizeof value);
 }
 
@@ -43,12 +44,12 @@ inline bool valid(const Row& row) noexcept {
 }
 
 inline std::size_t capacity(std::size_t count) noexcept {
-    return kDataOffset + count * 64 + 8;
+    return kDataOffset + count * (kMemberSize + kConditionSize) + 8;
 }
 
 /** Builds aligned, self-contained arrays; the original pointer bits are never reused. */
 inline bool build(std::span<const Row> rows, std::span<std::byte> output) noexcept {
-    if (rows.empty() || rows.size() > 56 || output.size() < capacity(rows.size())
+    if (rows.empty() || rows.size() > kMaximumMembers || output.size() < capacity(rows.size())
         || reinterpret_cast<std::uintptr_t>(output.data()) % 8 != 0) {
         return false;
     }
@@ -83,7 +84,8 @@ inline bool build(std::span<const Row> rows, std::span<std::byte> output) noexce
 
 /** Independently follows every relocated reference and compares all original payload bytes. */
 inline bool verify(std::span<const Row> rows, std::span<const std::byte> blob) noexcept {
-    if (rows.empty() || rows.size() > 56 || blob.size() < kDataOffset + rows.size() * 32
+    if (rows.empty() || rows.size() > kMaximumMembers
+        || blob.size() < kDataOffset + rows.size() * 32
         || reinterpret_cast<std::uintptr_t>(blob.data()) % 8 != 0
         || get<std::uint32_t>(blob.data() + 4) != kMarker
         || get<std::uint64_t>(blob.data() + 8) != rows.size()

+ 1 - 2
Sunrise/src/dllmain.cpp

@@ -140,8 +140,7 @@ BOOL WINAPI DllMain(HINSTANCE instance, DWORD reason, LPVOID) {
     if (reason == DLL_PROCESS_ATTACH) {
         g_module = instance;
         DisableThreadLibraryCalls(instance);
-        // The retail investment consumer stores array payload addresses in its 32-bit content
-        // domain. Reserve its tiny replacement arena before boot content consumes that space.
+        // Preserve the tested low-address placement; reserve before boot content fills that space.
         sunrise::client::hooks::network::investment::reserve_socket_menu_routing_storage();
     } else if (reason == DLL_PROCESS_DETACH) {
         g_module = nullptr;

+ 12 - 0
tests/CMakeLists.txt

@@ -0,0 +1,12 @@
+cmake_minimum_required(VERSION 3.20)
+project(SunrisePortableTests LANGUAGES CXX)
+enable_testing()
+add_executable(socket_row_relocation_test socket_row_relocation_test.cpp)
+target_compile_features(socket_row_relocation_test PRIVATE cxx_std_20)
+if(MSVC)
+    target_compile_options(socket_row_relocation_test PRIVATE /W4 /WX /UNDEBUG)
+else()
+    target_compile_options(socket_row_relocation_test PRIVATE -Wall -Wextra -Werror -UNDEBUG)
+endif()
+add_test(NAME socket_row_relocation
+    COMMAND socket_row_relocation_test "${CMAKE_CURRENT_SOURCE_DIR}/fixtures/socket_rows.hex")

+ 19 - 0
tests/README.md

@@ -0,0 +1,19 @@
+# Portable socket regression
+
+Run without Wine or the game:
+
+```sh
+cmake -S tests -B /tmp/sunrise-portable-tests
+cmake --build /tmp/sunrise-portable-tests
+ctest --test-dir /tmp/sunrise-portable-tests --output-on-failure
+```
+
+The fixture contains 71 original general/leg member rows and 57 opaque condition records captured
+from the Arrivals build on 2026-09-04, before relocation. Each 64-byte record contains the 32-byte
+member followed by its 32-byte condition allocation (zero for an empty condition). The first 19
+members are general; the remaining 52 are legs. Original relative offsets are deliberately retained
+so the test detects the shallow-copy bug. There are no account records or absolute process addresses.
+
+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.

+ 142 - 0
tests/fixtures/socket_rows.hex

@@ -0,0 +1,142 @@
+cd1f000000000000000000000000000000000000000000000000803f00000000
+0000000000000000000000000000000000000000000000000000000000000000
+ec01000000000000010000000000000040020000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000006c05000000000000
+ed01000000000000010000000000000040020000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000006d05000000000000
+ee01000000000000010000000000000040020000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000006e05000000000000
+ef01000000000000010000000000000040020000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000006f05000000000000
+f001000000000000010000000000000040020000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000007005000000000000
+ce1f000000000000010000000000000040020000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000001727000000000000
+cf1f000000000000010000000000000040020000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000001827000000000000
+d01f000000000000010000000000000040020000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000001927000000000000
+d11f000000000000010000000000000040020000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000001a27000000000000
+d21f000000000000010000000000000040020000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000001b27000000000000
+d31f000000000000010000000000000040020000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000001c27000000000000
+d41f000000000000000000000000000000000000000000000000803f00000000
+0000000000000000000000000000000000000000000000000000000000000000
+d51f000000000000000000000000000000000000000000000000803f00000000
+0000000000000000000000000000000000000000000000000000000000000000
+d61f000000000000000000000000000000000000000000000000803f00000000
+0000000000000000000000000000000000000000000000000000000000000000
+d71f000000000000000000000000000000000000000000000000803f00000000
+0000000000000000000000000000000000000000000000000000000000000000
+d81f000000000000000000000000000000000000000000000000803f00000000
+0000000000000000000000000000000000000000000000000000000000000000
+d91f000000000000000000000000000000000000000000000000803f00000000
+0000000000000000000000000000000000000000000000000000000000000000
+b02b000000000000010000000000000080010000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000008b2a000000000000
+3b20000000000000000000000000000000000000000000000000803f00000000
+0000000000000000000000000000000000000000000000000000000000000000
+f401000000000000010000000000000060060000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000007405000000000000
+b42b000000000000000000000000000000000000000000000000803f00000000
+0000000000000000000000000000000000000000000000000000000000000000
+b52b000000000000000000000000000000000000000000000000803f00000000
+0000000000000000000000000000000000000000000000000000000000000000
+b62b000000000000000000000000000000000000000000000000803f00000000
+0000000000000000000000000000000000000000000000000000000000000000
+3c20000000000000010000000000000000060000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000005227000000000000
+3d20000000000000010000000000000000060000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000005327000000000000
+3e20000000000000010000000000000000060000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000005427000000000000
+3f20000000000000010000000000000000060000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000005527000000000000
+4020000000000000010000000000000000060000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000005627000000000000
+4120000000000000010000000000000000060000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000005127000000000000
+4220000000000000010000000000000000060000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000005727000000000000
+4320000000000000010000000000000000060000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000005827000000000000
+4420000000000000010000000000000000060000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000005927000000000000
+4520000000000000010000000000000000060000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000005a27000000000000
+4620000000000000010000000000000000060000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000005b27000000000000
+4720000000000000010000000000000000060000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000005c27000000000000
+4820000000000000010000000000000000060000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000005d27000000000000
+4920000000000000010000000000000000060000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000005e27000000000000
+4a20000000000000010000000000000000060000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000005f27000000000000
+4b20000000000000010000000000000000060000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000006027000000000000
+4c20000000000000000000000000000000000000000000000000803f00000000
+0000000000000000000000000000000000000000000000000000000000000000
+4d20000000000000000000000000000000000000000000000000803f00000000
+0000000000000000000000000000000000000000000000000000000000000000
+4e20000000000000000000000000000000000000000000000000803f00000000
+0000000000000000000000000000000000000000000000000000000000000000
+4f200000000000000100000000000000a0050000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000006727000000000000
+ae2d0000000000000100000000000000a0050000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000009d2c000000000000
+50200000000000000100000000000000a0050000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000006827000000000000
+af2d0000000000000100000000000000a0050000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000009e2c000000000000
+51200000000000000100000000000000a0050000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000006927000000000000
+b02d0000000000000100000000000000a0050000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000009f2c000000000000
+52200000000000000100000000000000a0050000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000006a27000000000000
+b12d0000000000000100000000000000a0050000000000000000803f00000000
+bd9f80800100000000000000317d80800000000001000000a02c000000000000
+53200000000000000100000000000000a0050000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000006b27000000000000
+54200000000000000100000000000000a0050000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000006c27000000000000
+55200000000000000100000000000000a0050000000000000000803f00000000
+bd9f80800100000000000000317d80800000000001000000c426000000000000
+b22d0000000000000100000000000000a0050000000000000000803f00000000
+bd9f80800100000000000000317d80800000000001000000a12c000000000000
+56200000000000000100000000000000a0050000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000006d27000000000000
+b32d0000000000000100000000000000a0050000000000000000803f00000000
+bd9f80800100000000000000317d80800000000001000000a22c000000000000
+57200000000000000100000000000000a0050000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000006e27000000000000
+b42d0000000000000100000000000000a0050000000000000000803f00000000
+bd9f80800100000000000000317d80800000000001000000a32c000000000000
+58200000000000000100000000000000a0050000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000006f27000000000000
+59200000000000000100000000000000a0050000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000007027000000000000
+5a200000000000000100000000000000a0050000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000007127000000000000
+b52d0000000000000100000000000000a0050000000000000000803f00000000
+bd9f80800100000000000000317d80800000000001000000a42c000000000000
+5b200000000000000100000000000000a0050000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000007227000000000000
+5c200000000000000100000000000000a0050000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000007327000000000000
+5d200000000000000100000000000000a0050000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000007427000000000000
+b62d0000000000000100000000000000a0050000000000000000803f00000000
+bd9f80800100000000000000317d80800000000001000000a52c000000000000
+5e200000000000000100000000000000a0050000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000007527000000000000
+b72d0000000000000100000000000000a0050000000000000000803f00000000
+bd9f80800100000000000000317d80800000000001000000a62c000000000000
+5f200000000000000100000000000000a0050000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000007627000000000000
+60200000000000000100000000000000a0050000000000000000803f00000000
+bd9f80800100000000000000317d808000000000010000007727000000000000

+ 80 - 0
tests/socket_row_relocation_test.cpp

@@ -0,0 +1,80 @@
+#include <cassert>
+#include <cstdlib>
+#include <fstream>
+#include <iostream>
+#include <string>
+#include <vector>
+
+#include "../Sunrise/src/client/hooks/network/investment/socket_row_relocation.h"
+
+namespace r = sunrise::client::hooks::network::investment::relocation;
+int main(int argc, char** argv) {
+    assert(argc == 2);
+    std::array<r::Row, 71> original{};
+    std::ifstream input(argv[1], std::ios::binary);
+    std::string hex, line;
+    while (input >> line)
+        hex += line;
+    assert(hex.size() == sizeof(original) * 2);
+    auto* bytes = reinterpret_cast<unsigned char*>(original.data());
+    for (std::size_t i = 0; i < sizeof(original); ++i) {
+        bytes[i] = static_cast<unsigned char>(std::stoul(hex.substr(i * 2, 2), nullptr, 16));
+    }
+    auto target = [](const r::Row& row) {
+        auto id = r::get<std::uint32_t>(row.bytes.data());
+        return id == 492 || id == 494 || id == 495 || id == 496;
+    };
+    std::vector<r::Row> general, legs, moved;
+    for (std::size_t i = 0; i < original.size(); ++i) {
+        assert(r::valid(original[i]));
+        if (i < 19)
+            (target(original[i]) ? moved : general).push_back(original[i]);
+        else
+            legs.push_back(original[i]);
+    }
+    legs.insert(legs.begin() + 1, moved.begin(), moved.end());
+    assert(general.size() == 15 && legs.size() == 56 && moved.size() == 4);
+    assert(legs[0].bytes == original[19].bytes);
+    for (std::size_t i = 1; i < 5; ++i)
+        assert(target(legs[i]));
+    for (std::size_t i = 5; i < legs.size(); ++i) {
+        assert(legs[i].bytes == original[19 + i - 4].bytes);
+    }
+    unsigned conditions = 0;
+    for (auto& rows : {general, legs}) {
+        std::vector<std::byte> blob(r::capacity(rows.size()));
+        assert(r::build(rows, blob) && r::verify(rows, blob));
+        auto relocated = blob;
+        assert(r::verify(rows, relocated)); // actual allocation address changes
+        for (std::size_t i = 0; i < rows.size(); ++i) {
+            if (r::get<std::uint64_t>(rows[i].bytes.data() + 8) == 0) continue;
+            ++conditions;
+            auto shallow = blob;
+            std::memcpy(shallow.data() + 24 + i * 32 + 16, rows[i].bytes.data() + 16, 8);
+            assert(!r::verify(rows, shallow));
+            auto corrupt = blob;
+            auto at = 24 + i * 32;
+            auto header = at + 16 + r::get<std::int64_t>(corrupt.data() + at + 16);
+            corrupt[header + 20] ^= std::byte{1};
+            assert(!r::verify(rows, corrupt));
+        }
+        assert(!r::build(rows, std::span(blob.data(), 32)));
+        auto invalid = rows;
+        r::put(invalid[0].bytes.data() + 8, std::uint64_t{2});
+        assert(!r::build(invalid, blob));
+        invalid = rows;
+        invalid[1] = invalid[0];
+        assert(!r::build(invalid, blob));
+        assert(!r::verify(rows, std::span<const std::byte>(blob.data() + 1, blob.size() - 1)));
+    }
+    assert(conditions == 57);
+    // Regression: substituting Enhanced's condition must fail comparison to target originals.
+    auto wrong = legs;
+    for (std::size_t i = 1; i < 5; ++i)
+        wrong[i].condition = original[20].condition;
+    std::vector<std::byte> blob(r::capacity(56));
+    assert(r::build(wrong, blob));
+    assert(!r::verify(legs, blob));
+    std::cout << "PASS: 71 rows, 57 conditions; relocation, shallow-copy rejection, own-condition "
+                 "identity, malformed shape, duplicate, capacity and alignment checks\n";
+}