瀏覽代碼

Restore upstream fade release and enable its configuration

Millie 4 天之前
父節點
當前提交
5b2463c82e

+ 1 - 0
Sunrise/Sunrise.vcxproj

@@ -274,6 +274,7 @@
     <ClCompile Include="src\client\hooks\bootflow\profile_setup_skip.cpp" />
     <ClCompile Include="src\client\hooks\bootflow\world_step.cpp" />
     <ClCompile Include="src\client\hooks\bootflow\spawn_hold.cpp" />
+    <ClCompile Include="src\client\hooks\bootflow\fade_release.cpp" />
     <ClCompile Include="src\client\hooks\bootflow\spawn\spawn_gate_probe.cpp" />
     <ClCompile Include="src\client\hooks\bootflow\spawn\spawn_gate_targets.cpp" />
     <ClCompile Include="src\client\hooks\bootflow\spawn\spawn_gate_record_dump.cpp" />

+ 1 - 0
Sunrise/resources/default_settings.json

@@ -35,6 +35,7 @@
       "config_guid": "d2legacy-0000-0000-0000-000000000001"
     },
     "suppress_peer_relay": true,
+    "fade_release": true,
     "region_private": false,
     "pin_replicated_record": true,
     "hold_spawn": true,

+ 4 - 2
Sunrise/src/client/hooks/bootflow/bootflow_hook_lifecycle.cpp

@@ -26,15 +26,17 @@ bool install() noexcept {
     const bool regionPrivate = install_region_private();
     const bool worldStep = install_world_step();
     const bool spawn = install_spawn_hold();
+    const bool fade = install_fade_release();
     const bool anyFix = hold || sliceSet || skip || composition || handoff || ownerSlot
-                        || regionPrivate || worldStep || spawn;
+                        || regionPrivate || worldStep || spawn || fade;
     g_installed.store(anyFix, std::memory_order_release);
     return hold && sliceSet && skip && composition && handoff && ownerSlot && regionPrivate
-           && worldStep && spawn;
+           && worldStep && spawn && fade;
 }
 
 /** Detaches every boot-step fix, in the reverse order of install. */
 void uninstall() noexcept {
+    uninstall_fade_release();
     uninstall_spawn_hold();
     uninstall_world_step();
     uninstall_region_private();

+ 122 - 0
Sunrise/src/client/hooks/bootflow/fade_release.cpp

@@ -0,0 +1,122 @@
+#include <array>
+#include <atomic>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <string_view>
+
+#include "../../../core/logging/log.h"
+#include "../../../core/settings/settings.h"
+#include "internal.h"
+
+namespace sunrise::client::hooks::bootflow {
+namespace {
+
+using core::log::kLineCapacity;
+
+/**
+ * The narrow channel release: one channel, with a blend time, so the world fades in.
+ * Anchored on the load of the channel key, then run on through the argument spills, because the
+ * wildcarded frame size leaves the head too short to be unique.
+ */
+constexpr std::string_view kReleaseSignatureText =
+    "48 83 EC ? 8B 02 0F 57 C0 F3 0F 10 0D ? ? ? ? 48 8D 54 24 ? F3 0F 11 44 24";
+/** Compiled pattern bytes of the signature text above. */
+constexpr auto kReleaseSignature =
+    signature<signature_length(kReleaseSignatureText)>(kReleaseSignatureText);
+
+/**
+ * The fade manager accessor: one load-effective-address of its static object, then a return.
+ * Every displacement is wildcarded, so the match runs past the return to stay unique. The bytes
+ * after it are the next function's prologue and its thread-block read in this build.
+ */
+constexpr std::string_view kAccessorSignatureText =
+    "48 8D 05 ? ? ? ? C3 48 63 C0 E9 ? ? ? ? 48 83 EC ? 65 48 8B 04 25 58 00 00 00";
+/** Compiled pattern bytes of the signature text above. */
+constexpr auto kAccessorSignature =
+    signature<signature_length(kAccessorSignatureText)>(kAccessorSignatureText);
+
+/** Byte offsets inside the accessor, used to decode the object address from its operand. */
+struct AccessorLayout {
+    /** The 4-byte displacement follows the 2-byte load opcode. */
+    static constexpr std::size_t displacement = 3;
+    /** The instruction after the load, which the displacement is relative to. */
+    static constexpr std::size_t nextInstruction = 7;
+};
+
+/** The world-transition fade channel. Stage two of the transition arms it with opaque black. */
+constexpr std::uint32_t kWorldTransitionChannel = 0x57572DAC;
+/** The channel's colour is a static initialiser, so it needs no lookup. */
+constexpr std::array<float, 4> kOpaqueBlack{0.0F, 0.0F, 0.0F, 1.0F};
+/** Blend seconds, so the world fades in rather than popping. */
+constexpr float kFadeInSeconds = 0.5F;
+
+using ReleaseChannel = std::int64_t(__fastcall*)(void*, std::uint32_t*, float*, float) noexcept;
+
+void* g_manager{nullptr};
+std::atomic<ReleaseChannel> g_release{nullptr};
+std::atomic_bool g_released{false};
+
+} // namespace
+
+/** Re-arms the release, so the next world load fades in once and logs its own line. */
+void rearm_fade_release() noexcept {
+    g_released.store(false, std::memory_order_release);
+}
+
+/** Releases the world-transition fade channel. The spawn gate decides when. */
+void release_world_fade() noexcept {
+    const ReleaseChannel release = g_release.load(std::memory_order_acquire);
+    if (release == nullptr || g_manager == nullptr || !core::settings::get().client.fadeRelease) {
+        return;
+    }
+    // Fire once per arming. The spawn gate polls this every frame while the player is arriving, and
+    // restarting the blend each frame re-slams the channel to black, which reads as flicker. The
+    // off-destination step re-arms it through `rearm_fade_release`.
+    if (g_released.exchange(true, std::memory_order_relaxed)) {
+        return;
+    }
+    std::uint32_t channel = kWorldTransitionChannel;
+    std::array<float, 4> colour = kOpaqueBlack;
+    (void)release(g_manager, &channel, colour.data(), kFadeInSeconds);
+    std::array<char, kLineCapacity> line{};
+    const int written = std::snprintf(line.data(),
+                                      line.size(),
+                                      "ev=bootflow stage=fade_release result=issued channel=0x%X",
+                                      kWorldTransitionChannel);
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/** Finds the fade release and its manager object. */
+bool install_fade_release() noexcept {
+    std::byte* const release = scan_main_image_unique(kReleaseSignature, "fade_release_channel");
+    std::byte* const accessor = scan_main_image_unique(kAccessorSignature, "fade_manager_accessor");
+    if (release == nullptr || accessor == nullptr) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         "ev=bootflow stage=fade_release result=fail reason=target");
+        return false;
+    }
+    // The manager is the static object the accessor returns. The address comes from that
+    // instruction's own operand, not from a stored offset.
+    g_manager = resolve_relative(accessor + AccessorLayout::displacement,
+                                 accessor + AccessorLayout::nextInstruction);
+    g_release.store(reinterpret_cast<ReleaseChannel>(release), std::memory_order_release);
+    core::log::write(core::log::Channel::client,
+                     core::log::Level::info,
+                     "ev=bootflow stage=fade_release result=ok");
+    return true;
+}
+
+/** Clears the fade release it found. */
+void uninstall_fade_release() noexcept {
+    g_release.store(nullptr, std::memory_order_release);
+    g_manager = nullptr;
+    g_released.store(false, std::memory_order_release);
+}
+
+} // namespace sunrise::client::hooks::bootflow

+ 19 - 0
Sunrise/src/client/hooks/bootflow/internal.h

@@ -99,4 +99,23 @@ void observe_world_step() noexcept;
 /** Detaches the spawn hold. */
 void uninstall_spawn_hold() noexcept;
 
+/**
+ * Finds the world-transition fade release and its manager object.
+ * Nothing is detoured: both are called, so a miss leaves the feature off, not the client changed.
+ * @return True when both targets were found.
+ */
+[[nodiscard]] bool install_fade_release() noexcept;
+
+/** Clears the fade release it found. */
+void uninstall_fade_release() noexcept;
+
+/**
+ * Releases the world-transition fade channel.
+ * The spawn gate owns the timing. Does nothing unless `client.fade_release` is set.
+ */
+void release_world_fade() noexcept;
+
+/** Re-arms the one line the release logs, so the next load reports its own. */
+void rearm_fade_release() noexcept;
+
 } // namespace sunrise::client::hooks::bootflow

+ 5 - 2
Sunrise/src/client/hooks/bootflow/spawn_hold.cpp

@@ -85,9 +85,12 @@ __declspec(noinline) bool __fastcall spawn_gate(std::int32_t datum) noexcept {
     const std::uint64_t age = state::activity::world_transition_age();
     const core::settings::client::Settings& client = core::settings::get().client;
     const bool gaveUp = age >= client.spawnHoldMs;
-    // Keep upstream's native spawn boundary: presentation readiness must never release the player
-    // before the boot flow reaches activity:in_world. Fade release is now independent of this gate.
     const bool loading = transitioning && !gaveUp && client.holdSpawn;
+    // Release only on arrival. The step-37 exit re-arms the fade unless one is already up, and
+    // nothing polls this gate after the spawn, so an early release leaves a fade nobody clears.
+    if (phase == state::activity::WorldPhase::arrived) {
+        release_world_fade();
+    }
     if (!allowed && transitioning) {
         report_spawn_refusal(datum, phase, age);
     }

+ 12 - 6
Sunrise/src/client/hooks/bootflow/world_step.cpp

@@ -26,7 +26,7 @@ constexpr auto kStepSignature = signature<signature_length(kStepSignatureText)>(
 
 /** First step that loads the map with no player in it yet. */
 constexpr std::int32_t kActivityLoadFirst = 33;
-/** Native `activity:in_world`; upstream releases the spawn hold on this step. */
+/** `activity:in_world`. The fade is armed by then, so a spawn now releases it. */
 constexpr std::int32_t kInWorld = 38;
 /** No step has been published. */
 constexpr std::int32_t kNoStep = -1;
@@ -56,17 +56,20 @@ std::atomic_uint64_t g_publishedSliceSetTick{0};
 
 /** Publishes the client's own boot-flow step. */
 void poll_world_step() noexcept {
-    const std::int32_t step = read_step();
-    g_publishedStep.store(step, std::memory_order_relaxed);
+    g_publishedStep.store(read_step(), std::memory_order_relaxed);
     g_publishedTick.store(GetTickCount64(), std::memory_order_release);
 }
 
 /** Publishes the client's current local slice-set index. */
 void poll_current_slice_set() noexcept {
     const std::int32_t index = spawn::sample_current_slice_set();
-    // A normal z-leg changes slice sets while the player remains in the same destination. It must
-    // not re-arm the destination-entry fade: public-area handoffs would otherwise consume a new
-    // fade release even though no fresh world entry occurred. Step 33 owns entry arming.
+    const std::int32_t previous = g_publishedSliceSet.load(std::memory_order_relaxed);
+    // A slice-set change is a world replacement whose transition arms a fresh fade, and a
+    // teleport never passes the off-destination step that re-arms the release. Re-arm here or
+    // the new world stays black behind the spent one-shot.
+    if (index >= 0 && previous >= 0 && index != previous) {
+        rearm_fade_release();
+    }
     g_publishedSliceSet.store(index, std::memory_order_relaxed);
     g_publishedSliceSetTick.store(GetTickCount64(), std::memory_order_release);
 }
@@ -106,6 +109,9 @@ void observe_world_step() noexcept {
         phase = state::activity::WorldPhase::arrived;
     } else if (step >= kActivityLoadFirst && step < kInWorld) {
         phase = state::activity::WorldPhase::transitioning;
+    } else {
+        // Off a destination, so the next load is a fresh arming and logs its own release line.
+        rearm_fade_release();
     }
     state::activity::note_world_phase(phase);
 }

+ 6 - 0
Sunrise/src/core/settings/client/client_settings_parser.cpp

@@ -10,6 +10,7 @@ bool Parser::client_settings(client::Settings& output) noexcept {
     client::Settings candidate = output;
     bool hasUserInterface = false;
     bool hasExternalServer = false;
+    bool hasFadeRelease = false;
     bool hasRegionPrivate = false;
     bool hasSkipOrbitCinematicWait = false;
     bool hasSuppressPeerRelay = false;
@@ -34,6 +35,11 @@ bool Parser::client_settings(client::Settings& output) noexcept {
                 return false;
             }
             hasExternalServer = true;
+        } else if (key == "fade_release") {
+            if (hasFadeRelease || !boolean(candidate.fadeRelease)) {
+                return false;
+            }
+            hasFadeRelease = true;
         } else if (key == "region_private") {
             if (hasRegionPrivate || !boolean(candidate.regionPrivate)) {
                 return false;

+ 6 - 0
Sunrise/src/core/settings/client/definition.h

@@ -18,6 +18,12 @@ struct Settings {
     ui::runtime::Settings userInterface;
     /** Points the Client at a server outside this process. Off answers everything in process. */
     external::Settings externalServer;
+    /**
+     * Releases the world-transition fade channel at the in-world step.
+     * The client only releases it on the player spawn, so this covers a spawn that never runs
+     * and leaves the world black. On by default.
+     */
+    bool fadeRelease{true};
     /**
      * Reports a public region as private to the region transition.
      * On, a public region loads solo. Off, it waits for a public activity host, which is the