Sfoglia il codice sorgente

Merge pull request #25 from Haze-xyz/sword-skate

Add a sword skate module
stan 3 settimane fa
parent
commit
8c1fa6d71a

+ 2 - 0
Sunrise/Sunrise.vcxproj

@@ -201,6 +201,7 @@
     <ClCompile Include="src\client\ui\movement\movement_panel.cpp" />
     <ClCompile Include="src\client\movement\movement_settings_store.cpp" />
     <ClCompile Include="src\client\hooks\noclip\horizontal_noclip.cpp" />
+    <ClCompile Include="src\client\hooks\sword_skate\sword_skate.cpp" />
     <ClCompile Include="src\client\hooks\teleport\teleport_lifecycle.cpp" />
     <ClCompile Include="src\client\hooks\teleport\teleport_move.cpp" />
     <ClCompile Include="src\client\hooks\teleport\teleport_action_key.cpp" />
@@ -750,6 +751,7 @@
     <ClInclude Include="src\client\runtime\runtime.h" />
     <ClInclude Include="src\client\movement\movement_settings_store.h" />
     <ClInclude Include="src\client\hooks\noclip\runtime.h" />
+    <ClInclude Include="src\client\hooks\sword_skate\sword_skate.h" />
     <ClInclude Include="src\client\hooks\teleport\internal.h" />
     <ClInclude Include="src\client\hooks\teleport\runtime.h" />
     <ClInclude Include="src\client\ui\movement\movement_panel.h" />

+ 108 - 0
Sunrise/src/client/hooks/sword_skate/sword_skate.cpp

@@ -0,0 +1,108 @@
+/**
+ * Sword skate. A sword's air attack throws the player forward, and a glide started while that
+ * throw is still carrying them keeps the speed for about a second. The client refuses to start a
+ * glide while the throw is active, so the speed decays instead and the chain cannot be continued.
+ *
+ * The refusal is one bit of a movement-state field on the player's physics component. It is set
+ * when the air attack starts and cleared when it ends, and the glide reads it before anything
+ * else. Clearing it on the tick the jump is pressed lets the client start its own glide, with its
+ * own lift, drift and air control; nothing here moves the player.
+ *
+ * The bit was found by comparing two builds through the same injected inputs at the same timings:
+ * one where the glide starts after a sword throw and one where it is refused. The throw itself is
+ * identical on both, reaching the same speed, and a glide the client accepts on its own decays at
+ * the same rate on both, so neither the throw nor the glide was changed. Only the refusal was.
+ */
+
+#include "sword_skate.h"
+
+#include <Windows.h>
+
+#include <cstddef>
+#include <cstdint>
+
+#include "../../../core/ui/runtime/ui_visibility_runtime.h"
+#include "../../movement/movement_settings_store.h"
+#include "../teleport/runtime.h"
+
+namespace sunrise::client::hooks::sword_skate {
+namespace {
+
+/** Movement-state flags on the player's physics component. */
+constexpr std::size_t kMovementStateOffset = 15492;
+/**
+ * Set while a sword's air attack is carrying the player, and read by the glide before it starts.
+ * The field carries other bits that the attack also sets; only this one refuses the glide, and
+ * clearing only it leaves the rest of the attack's state alone.
+ */
+constexpr std::uint32_t kGlideRefusedBit = 0x00000800U;
+/** The high bit of a polled key state marks it held. */
+constexpr SHORT kKeyHeldBit = static_cast<SHORT>(0x8000);
+
+/** Jump held on the previous tick, so the flag is only cleared on the press and not on the hold. */
+bool g_jumpHeld{false};
+
+/**
+ * Reads one value out of game memory without faulting on a torn pointer.
+ * @param address Source address.
+ * @param value Receives the value.
+ * @return True when Windows copied the whole value.
+ */
+[[nodiscard]] bool read_at(const std::byte* address, std::uint32_t& value) noexcept {
+    SIZE_T read = 0;
+    return ReadProcessMemory(GetCurrentProcess(), address, &value, sizeof value, &read) != FALSE
+           && read == sizeof value;
+}
+
+/**
+ * Writes one value into game memory. The call applies page protection itself.
+ * @param address Destination address.
+ * @param value Value to store.
+ * @return True when Windows copied the whole value.
+ */
+[[nodiscard]] bool write_at(std::byte* address, std::uint32_t value) noexcept {
+    SIZE_T written = 0;
+    return WriteProcessMemory(GetCurrentProcess(), address, &value, sizeof value, &written) != FALSE
+           && written == sizeof value;
+}
+
+} // namespace
+
+/** Clears the glide refusal for one physics tick of the local player. */
+void apply(void* component) noexcept {
+    const client::movement::Settings settings = client::movement::get();
+    // An open interface owns the keyboard, so a press meant for it must not reach this either.
+    const bool usable = settings.swordSkateEnabled
+                        && settings.swordSkateJumpKey != client::movement::kNoKey
+                        && !core::ui::runtime::snapshot().visible;
+    if (!usable) {
+        // Cleared rather than left as it was, or the first press after the feature comes back is
+        // read as a hold and skipped.
+        g_jumpHeld = false;
+        return;
+    }
+    // Ownership is tested before the key is read, because this runs for every component the sync
+    // touches and the held flag must only ever advance on the player's own tick. Advancing it on
+    // any other component lets that component consume the press, and the player's tick then sees
+    // no edge at all.
+    if (component == nullptr || !teleport::owns_local_player(component)) {
+        return;
+    }
+    const bool held =
+        (GetAsyncKeyState(static_cast<int>(settings.swordSkateJumpKey)) & kKeyHeldBit) != 0;
+    const bool wasHeld = g_jumpHeld;
+    g_jumpHeld = held;
+    // Only the press matters. Clearing the flag for as long as the key is down would keep it clear
+    // through the whole attack, which is a different change to make and not this one.
+    if (!held || wasHeld) {
+        return;
+    }
+    auto* const bytes = static_cast<std::byte*>(component);
+    std::uint32_t state = 0;
+    if (!read_at(bytes + kMovementStateOffset, state) || (state & kGlideRefusedBit) == 0) {
+        return;
+    }
+    (void)write_at(bytes + kMovementStateOffset, state & ~kGlideRefusedBit);
+}
+
+} // namespace sunrise::client::hooks::sword_skate

+ 16 - 0
Sunrise/src/client/hooks/sword_skate/sword_skate.h

@@ -0,0 +1,16 @@
+#pragma once
+
+namespace sunrise::client::hooks::sword_skate {
+
+/**
+ * Clears the glide refusal for one physics tick of the local player.
+ *
+ * Runs from the physics sync, because the flag it clears is written when the sword's air attack
+ * starts and read by the glide in the same tick the jump is pressed. A frame-timed poll would
+ * race with both.
+ *
+ * @param component Physics component being synced, tested for player ownership here.
+ */
+void apply(void* component) noexcept;
+
+} // namespace sunrise::client::hooks::sword_skate

+ 9 - 0
Sunrise/src/client/hooks/teleport/runtime.h

@@ -69,4 +69,13 @@ void invoke_sync(void* component) noexcept;
  */
 void apply_pending(void* component) noexcept;
 
+/**
+ * @param component Candidate physics component.
+ * @return True when it drives the object the local player controls.
+ *
+ * Exposed because the physics sync is the only tick that sees every component, and a feature that
+ * has to act on the player's own tick needs the same test this module already performs.
+ */
+[[nodiscard]] bool owns_local_player(void* component) noexcept;
+
 } // namespace sunrise::client::hooks::teleport

+ 4 - 0
Sunrise/src/client/hooks/teleport/teleport_lifecycle.cpp

@@ -14,6 +14,7 @@
 #include "../../../core/logging/log.h"
 #include "../../hooking/detour.h"
 #include "../polled_input/runtime.h"
+#include "../sword_skate/sword_skate.h"
 #include "internal.h"
 #include "runtime.h"
 
@@ -89,6 +90,9 @@ std::int64_t __fastcall camera_transform(std::uint32_t playerIndex) noexcept {
  */
 std::int64_t __fastcall physics_sync(std::byte* component, std::byte* outFlags) noexcept {
     apply_pending(component);
+    // Shares this detour rather than adding a second one to the same function. The flag it clears
+    // is written and read inside this tick, so it has to run here and not on a frame poll.
+    hooks::sword_skate::apply(component);
     const PhysicsSync next = original<PhysicsSync>(kPhysicsSlot);
     return next != nullptr ? next(component, outFlags) : 0;
 }

+ 6 - 0
Sunrise/src/client/hooks/teleport/teleport_move.cpp

@@ -435,4 +435,10 @@ void force_pending() noexcept {
         core::log::Channel::client, core::log::Level::info, "ev=teleport stage=force result=ok");
 }
 
+/** @param component Candidate physics component. @return True when the local player drives it. */
+bool owns_local_player(void* component) noexcept {
+    return component != nullptr && g_controlledHandle != nullptr
+           && owns_player(static_cast<std::byte*>(component));
+}
+
 } // namespace sunrise::client::hooks::teleport

+ 15 - 3
Sunrise/src/client/movement/movement_settings_store.cpp

@@ -38,7 +38,8 @@ bool g_pathResolved{};
 [[nodiscard]] bool valid(const Settings& settings) noexcept {
     return settings.distance >= kMinimumDistance && settings.distance <= kMaximumDistance
            && settings.virtualKey <= kMaximumVirtualKey
-           && settings.noclipToggleKey <= kMaximumVirtualKey;
+           && settings.noclipToggleKey <= kMaximumVirtualKey
+           && settings.swordSkateJumpKey <= kMaximumVirtualKey;
 }
 
 /** @param reason Key naming the step that failed. */
@@ -126,6 +127,13 @@ void parse(std::string_view text, Settings& output) noexcept {
         output.noclipToggleKey =
             static_cast<std::uint32_t>(std::strtoul(buffer.data(), nullptr, 0));
     }
+    if (scalar_for(text, "\"sword_skate_enabled\"", scalar)) {
+        output.swordSkateEnabled = scalar.starts_with("true");
+    }
+    if (scalar_for(text, "\"sword_skate_jump_key\"", scalar) && terminated(scalar, buffer)) {
+        output.swordSkateJumpKey =
+            static_cast<std::uint32_t>(std::strtoul(buffer.data(), nullptr, 0));
+    }
 }
 
 /**
@@ -144,12 +152,16 @@ void parse(std::string_view text, Settings& output) noexcept {
                                    "{\n  \"enabled\": %s,\n  \"distance\": %.3f,\n"
                                    "  \"virtual_key\": %u,\n"
                                    "  \"noclip_enabled\": %s,\n"
-                                   "  \"noclip_toggle_key\": %u\n}\n",
+                                   "  \"noclip_toggle_key\": %u,\n"
+                                   "  \"sword_skate_enabled\": %s,\n"
+                                   "  \"sword_skate_jump_key\": %u\n}\n",
                                    settings.enabled ? "true" : "false",
                                    static_cast<double>(settings.distance),
                                    static_cast<unsigned>(settings.virtualKey),
                                    settings.noclipEnabled ? "true" : "false",
-                                   static_cast<unsigned>(settings.noclipToggleKey));
+                                   static_cast<unsigned>(settings.noclipToggleKey),
+                                   settings.swordSkateEnabled ? "true" : "false",
+                                   static_cast<unsigned>(settings.swordSkateJumpKey));
     if (size <= 0) {
         return false;
     }

+ 5 - 0
Sunrise/src/client/movement/movement_settings_store.h

@@ -12,6 +12,8 @@ inline constexpr float kMinimumDistance = 1.0F;
 inline constexpr float kMaximumDistance = 100.0F;
 /** No key is bound until one is picked, so a fresh install cannot fire a movement feature. */
 inline constexpr std::uint32_t kNoKey = 0;
+/** Space is the stock jump binding, and the one the sword skate fix has to watch to do anything. */
+inline constexpr std::uint32_t kDefaultJumpKey = 0x20;
 
 /** Runtime movement configuration. This module owns it; Core settings do not carry it. */
 struct Settings {
@@ -20,6 +22,9 @@ struct Settings {
     std::uint32_t virtualKey{kNoKey};
     bool noclipEnabled{false};
     std::uint32_t noclipToggleKey{kNoKey};
+    bool swordSkateEnabled{false};
+    /** The player's jump key. The refusal is only cleared on the tick this key goes down. */
+    std::uint32_t swordSkateJumpKey{kDefaultJumpKey};
 };
 
 /**

+ 25 - 0
Sunrise/src/client/ui/movement/movement_panel.cpp

@@ -29,6 +29,7 @@ enum class CaptureTarget {
     none,
     teleport,
     noclip,
+    swordSkate,
 };
 
 CaptureTarget g_capturing{CaptureTarget::none};
@@ -182,6 +183,30 @@ void draw() noexcept {
         key_picker("noclip_key", CaptureTarget::noclip, settings.noclipToggleKey, controlWidth)
         || changed;
 
+    ImGui::Spacing();
+    ImGui::Spacing();
+    ImGui::TextUnformatted("Sword Skate Fix");
+    ImGui::Separator();
+    ImGui::TextWrapped("A sword's air attack throws you forward, and a glide started while that "
+                       "throw is still carrying you keeps the speed. The client refuses to start a "
+                       "glide during the throw. This clears that refusal on the tick you press "
+                       "jump, and the client's own glide runs from there.");
+    ImGui::Spacing();
+
+    changed =
+        core::ui::components::toggle::control("Enabled##sword_skate", settings.swordSkateEnabled)
+        || changed;
+
+    ImGui::Spacing();
+    ImGui::AlignTextToFramePadding();
+    ImGui::TextUnformatted("Jump key");
+    ImGui::SameLine(labelWidth);
+    changed =
+        key_picker(
+            "sword_skate_key", CaptureTarget::swordSkate, settings.swordSkateJumpKey, controlWidth)
+        || changed;
+    ImGui::TextWrapped("Must match the key the game jumps on. Nothing happens on any other key.");
+
     if (changed && !client::movement::publish(settings)) {
         ImGui::Spacing();
         ImGui::TextUnformatted("value out of range, not saved");