Kaynağa Gözat

noclip collision fix and fly

stan 3 hafta önce
ebeveyn
işleme
233c811e8c

+ 6 - 2
README.md

@@ -5,14 +5,18 @@ Destiny 2 Offline Exploration Mod
 > This mod installs onto an old build of the game and allows you to play it offline, loading into
 > destinations and exploring them.
 >
-> No other features are currently supported. (Missions, Enemies, NPCs, Quests, Inventory
-> Management, ...)
+> Most gameplay features are not currently supported. (Missions, Enemies, NPCs, Quests, Persistent Saves, ...)
 
 - [Install Instructions](https://github.com/stanuwu/Sunrise/wiki/Installing)
 - [FAQ](https://github.com/stanuwu/Sunrise/wiki/FAQ)
 - [Common Issues](https://github.com/stanuwu/Sunrise/wiki/Common-Issues)
 - [Discord](https://discord.gg/22JS6et5k9)
 
+## Features
+- Load into any Destination (matchmade activities are currently broken)
+- Exploration Features (Fly, Noclip, Activity Override, ...)
+- Basic Inventory Management
+
 ## WIP
 
 This mod is work in progress. Things might break or work in unexpected ways. There is also currently

+ 4 - 0
Sunrise/Sunrise.vcxproj

@@ -208,6 +208,8 @@
     <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\fly\fly.cpp" />
+    <ClCompile Include="src\client\input\window_focus.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" />
@@ -761,6 +763,8 @@
     <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\fly\fly.h" />
+    <ClInclude Include="src\client\input\window_focus.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" />

+ 331 - 0
Sunrise/src/client/hooks/fly/fly.cpp

@@ -0,0 +1,331 @@
+/**
+ * Velocity fly. The movement keys set the player's velocity every tick.
+ * Velocity is set, not added. Adding compounds each tick and leaves gravity in the vertical lane.
+ * Setting it means releasing every key stops the player, which is what holds a hover.
+ * The write goes in before the simulation step, and again before the sync that publishes it.
+ */
+
+#include "fly.h"
+
+#include <Windows.h>
+
+#include <array>
+#include <atomic>
+#include <cmath>
+#include <cstddef>
+#include <cstdint>
+#include <optional>
+
+#include "../../../core/logging/log.h"
+#include "../../../core/ui/runtime/ui_visibility_runtime.h"
+#include "../../../state/account/account_state.h"
+#include "../../../state/runtime/runtime.h"
+#include "../../input/window_focus.h"
+#include "../../movement/movement_settings_store.h"
+#include "../noclip/runtime.h"
+#include "../teleport/runtime.h"
+
+namespace sunrise::client::hooks::fly {
+namespace {
+
+namespace bindings = state::account::settings::bindings;
+
+/** The high bit of a polled key state marks it held. */
+constexpr SHORT kKeyHeldBit = static_cast<SHORT>(0x8000);
+/** Below this squared length a direction counts as none. */
+constexpr float kMinimumLengthSquared = 0.000001F;
+
+/** The horizontal lanes. The basis is X forward, Z up. */
+constexpr std::size_t kLaneX = 0;
+constexpr std::size_t kLaneY = 1;
+
+/** One movement direction in the player's own frame. */
+enum class Direction : std::size_t {
+    forward,
+    backward,
+    left,
+    right,
+    up,
+    down,
+    count,
+};
+
+/** Sizes the per-direction flag array. */
+constexpr std::size_t kDirectionCount = static_cast<std::size_t>(Direction::count);
+
+/** One polled action and the direction it moves. */
+struct ActionDirection {
+    bindings::Action action;
+    Direction direction;
+};
+
+/** Crouch has two actions and either one descends. Both are read as a hold, to pair with jump. */
+constexpr std::array<ActionDirection, 7> kActions{{
+    {bindings::Action::moveForward, Direction::forward},
+    {bindings::Action::moveBackward, Direction::backward},
+    {bindings::Action::moveLeft, Direction::left},
+    {bindings::Action::moveRight, Direction::right},
+    {bindings::Action::jump, Direction::up},
+    {bindings::Action::toggleCrouch, Direction::down},
+    {bindings::Action::holdCrouch, Direction::down},
+}};
+
+/** Both binding halves of each polled action, in the order of the table above. */
+std::array<bindings::Binding, kActions.size()> g_bindings{};
+/** Set once the bindings are read, so the costly account snapshot runs once. */
+bool g_bindingsRead{false};
+/** The toggle key on the previous frame, so the switch only flips on the press. */
+std::atomic_bool g_toggleDown{false};
+/** The height the body had before the step, which is the height to hold. */
+float g_heightBeforeStep{0.0F};
+bool g_heightValid{false};
+/** Set while a press owns the vertical lane. The hold stands aside. */
+bool g_steered{false};
+
+/**
+ * Takes the account's movement bindings once they are loaded.
+ * @return True when they have been read.
+ */
+[[nodiscard]] bool read_bindings() noexcept {
+    if (g_bindingsRead) {
+        return true;
+    }
+    // The snapshot copies the whole account, so it stops once the bindings arrive.
+    const state::AccountState account = state::account_snapshot();
+    if (!account.settings.keyBindings.configured) {
+        return false;
+    }
+    for (std::size_t index = 0; index < kActions.size(); ++index) {
+        const std::size_t action = static_cast<std::size_t>(kActions[index].action);
+        g_bindings[index] = account.settings.keyBindings.values[action];
+    }
+    g_bindingsRead = true;
+    return true;
+}
+
+/**
+ * A half is an input code, not a virtual key. A half bound to a mouse button gives no key.
+ * @param half One binding half, empty when unbound.
+ * @return True while its key is down.
+ */
+[[nodiscard]] bool half_down(const std::optional<std::uint16_t>& half) noexcept {
+    if (!half.has_value()) {
+        return false;
+    }
+    const std::uint32_t key = teleport::action_key(*half);
+    return key != 0 && (GetAsyncKeyState(static_cast<int>(key)) & kKeyHeldBit) != 0;
+}
+
+/** @return One flag per direction, true while any key bound to it is down. */
+[[nodiscard]] std::array<bool, kDirectionCount> pressed_directions() noexcept {
+    std::array<bool, kDirectionCount> pressed{};
+    for (std::size_t index = 0; index < kActions.size(); ++index) {
+        const bindings::Binding& binding = g_bindings[index];
+        if (half_down(binding.primary) || half_down(binding.secondary)) {
+            pressed[static_cast<std::size_t>(kActions[index].direction)] = true;
+        }
+    }
+    return pressed;
+}
+
+/**
+ * Forward turned about the up axis. Which turn is right is unverified: if strafing is mirrored,
+ * negate both lanes.
+ * @return The strafe axis, or zeroes when the camera looks straight up or down.
+ */
+[[nodiscard]] teleport::Vector right_of(const teleport::Vector& forward) noexcept {
+    teleport::Vector right{forward[kLaneY], -forward[kLaneX], 0.0F};
+    const float lengthSquared = right[kLaneX] * right[kLaneX] + right[kLaneY] * right[kLaneY];
+    if (lengthSquared <= kMinimumLengthSquared) {
+        return teleport::Vector{};
+    }
+    const float length = std::sqrt(lengthSquared);
+    right[kLaneX] /= length;
+    right[kLaneY] /= length;
+    return right;
+}
+
+/**
+ * Composes the pressed directions into one unit vector.
+ * @param pressed One flag per direction.
+ * @param forward Camera forward vector.
+ * @return The direction to fly, or all zeroes when nothing is pressed.
+ */
+[[nodiscard]] teleport::Vector travel(const std::array<bool, kDirectionCount>& pressed,
+                                      const teleport::Vector& forward) noexcept {
+    const teleport::Vector right = right_of(forward);
+    teleport::Vector move{};
+    const auto add = [&move](const teleport::Vector& axis, float scale) noexcept {
+        for (std::size_t lane = 0; lane < teleport::kVectorLanes; ++lane) {
+            move[lane] += axis[lane] * scale;
+        }
+    };
+    if (pressed[static_cast<std::size_t>(Direction::forward)]) {
+        add(forward, 1.0F);
+    }
+    if (pressed[static_cast<std::size_t>(Direction::backward)]) {
+        add(forward, -1.0F);
+    }
+    if (pressed[static_cast<std::size_t>(Direction::right)]) {
+        add(right, 1.0F);
+    }
+    if (pressed[static_cast<std::size_t>(Direction::left)]) {
+        add(right, -1.0F);
+    }
+    if (pressed[static_cast<std::size_t>(Direction::up)]) {
+        move[teleport::kVerticalLane] += 1.0F;
+    }
+    if (pressed[static_cast<std::size_t>(Direction::down)]) {
+        move[teleport::kVerticalLane] -= 1.0F;
+    }
+    float lengthSquared = 0.0F;
+    for (const float lane : move) {
+        lengthSquared += lane * lane;
+    }
+    if (lengthSquared <= kMinimumLengthSquared) {
+        return teleport::Vector{};
+    }
+    // Normalised, so a diagonal is not faster than a straight line.
+    const float length = std::sqrt(lengthSquared);
+    for (float& lane : move) {
+        lane /= length;
+    }
+    return move;
+}
+
+/**
+ * Shortens a velocity to a speed limit, keeping its direction.
+ * @param velocity Velocity to limit in place.
+ * @param limit Highest speed to leave.
+ */
+void cap_speed(teleport::Vector& velocity, float limit) noexcept {
+    float speedSquared = 0.0F;
+    for (const float lane : velocity) {
+        speedSquared += lane * lane;
+    }
+    if (speedSquared <= limit * limit) {
+        return;
+    }
+    const float scale = limit / std::sqrt(speedSquared);
+    for (float& lane : velocity) {
+        lane *= scale;
+    }
+}
+
+/**
+ * Works out the velocity the keys ask for. Also records whether a press owns the vertical lane.
+ * @param speed Configured fly speed.
+ * @return Velocity in world units per second.
+ */
+[[nodiscard]] teleport::Vector desired_velocity(float speed) noexcept {
+    // The interface or another application owns the keyboard. Their presses must not steer.
+    std::array<bool, kDirectionCount> pressed{};
+    if (!core::ui::runtime::snapshot().visible && input::game_focused()) {
+        pressed = pressed_directions();
+    }
+    teleport::Vector forward{};
+    const teleport::Vector move =
+        teleport::camera_forward(forward) ? travel(pressed, forward) : teleport::Vector{};
+    g_steered = move[teleport::kVerticalLane] != 0.0F;
+    teleport::Vector velocity{};
+    for (std::size_t lane = 0; lane < teleport::kVectorLanes; ++lane) {
+        velocity[lane] = move[lane] * speed;
+    }
+    return velocity;
+}
+
+} // namespace
+
+/** Reads the toggle key once a frame and flips the switch on the press. */
+void poll_toggle() noexcept {
+    const client::movement::Settings settings = client::movement::get();
+    if (settings.flyToggleKey == client::movement::kNoKey) {
+        g_toggleDown.store(false, std::memory_order_relaxed);
+        return;
+    }
+    const bool down =
+        input::game_focused()
+        && (GetAsyncKeyState(static_cast<int>(settings.flyToggleKey)) & kKeyHeldBit) != 0;
+    // The interface owns the keyboard, so the key tracks the press but never flips the switch.
+    if (core::ui::runtime::snapshot().visible) {
+        g_toggleDown.store(down, std::memory_order_relaxed);
+        return;
+    }
+    if (down && !g_toggleDown.exchange(true, std::memory_order_acq_rel)) {
+        client::movement::Settings updated = settings;
+        updated.flyEnabled = !settings.flyEnabled;
+        if (!client::movement::publish(updated)) {
+            return;
+        }
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::info,
+                         updated.flyEnabled ? "ev=fly stage=toggle enabled=1"
+                                            : "ev=fly stage=toggle enabled=0");
+        return;
+    }
+    if (!down) {
+        g_toggleDown.store(false, std::memory_order_release);
+    }
+}
+
+/** Writes the player's velocity on the physics sync, which publishes it. */
+void apply(void* component) noexcept {
+    const client::movement::Settings settings = client::movement::get();
+    if (!settings.flyEnabled) {
+        return;
+    }
+    if (component == nullptr || !teleport::owns_local_player(component)) {
+        return;
+    }
+    if (!read_bindings()) {
+        return;
+    }
+    // Capped, because this is the field the game reads to decide the player hit something too
+    // hard. The step has the real speed; this is only what the sync publishes.
+    teleport::Vector velocity = desired_velocity(settings.flySpeed);
+    cap_speed(velocity, kPublishedSpeedCap);
+    (void)teleport::write_velocity(component, velocity);
+}
+
+/** Reports whether fly is on. */
+bool enabled() noexcept {
+    return client::movement::get().flyEnabled;
+}
+
+/** Sets the velocity the coming simulation step integrates. */
+void before_step(void* body) noexcept {
+    g_heightValid = false;
+    if (body == nullptr || !read_bindings()) {
+        return;
+    }
+    noclip::write_body_velocity(body, desired_velocity(client::movement::get().flySpeed));
+    noclip::Vector position{};
+    noclip::read_body_position(body, position);
+    g_heightBeforeStep = position[teleport::kVerticalLane];
+    g_heightValid = true;
+}
+
+/** Puts back the height the step's gravity took. */
+void after_step(void* body, bool heldElsewhere) noexcept {
+    if (body == nullptr || heldElsewhere || g_steered || !g_heightValid) {
+        return;
+    }
+    // The height from before this step, so a respawn or any other placement is kept.
+    noclip::Vector position{};
+    noclip::read_body_position(body, position);
+    position[teleport::kVerticalLane] = g_heightBeforeStep;
+    noclip::write_body_position(body, position);
+    // The step also left its gravity in the velocity, where it would build up.
+    noclip::Vector velocity{};
+    noclip::read_body_velocity(body, velocity);
+    velocity[teleport::kVerticalLane] = 0.0F;
+    noclip::write_body_velocity(body, velocity);
+}
+
+/** Clears the key state and the held height. The switch is a stored setting and survives. */
+void reset() noexcept {
+    g_toggleDown.store(false, std::memory_order_release);
+    g_heightValid = false;
+}
+
+} // namespace sunrise::client::hooks::fly

+ 39 - 0
Sunrise/src/client/hooks/fly/fly.h

@@ -0,0 +1,39 @@
+#pragma once
+
+namespace sunrise::client::hooks::fly {
+
+/**
+ * Fastest speed the game is shown while this hook drives the position itself. Contact with
+ * geometry damages the player above roughly this, and the real speed is not needed for movement.
+ */
+inline constexpr float kPublishedSpeedCap = 8.0F;
+
+/** Reads the toggle key once a frame and flips the switch on the press. */
+void poll_toggle() noexcept;
+
+/**
+ * Writes the player's velocity on the physics sync, which publishes it.
+ * @param component Physics component being synced. Tested for player ownership here.
+ */
+void apply(void* component) noexcept;
+
+/** @return True while fly is on. */
+[[nodiscard]] bool enabled() noexcept;
+
+/**
+ * Sets the velocity the coming simulation step integrates. Noclip reads it too.
+ * @param body Character rigid body. Live only inside the step hook.
+ */
+void before_step(void* body) noexcept;
+
+/**
+ * Puts back the height the step's gravity took.
+ * @param body Character rigid body. Live only inside the step hook.
+ * @param heldElsewhere True when noclip carries the vertical lane.
+ */
+void after_step(void* body, bool heldElsewhere) noexcept;
+
+/** Clears the key state. The switch is a stored setting and survives. */
+void reset() noexcept;
+
+} // namespace sunrise::client::hooks::fly

+ 132 - 70
Sunrise/src/client/hooks/noclip/horizontal_noclip.cpp

@@ -1,7 +1,8 @@
 /**
- * Horizontal noclip at the Havok simulation boundary. The hook reads native horizontal velocity
- * before simulation, lets Havok run normally, then replaces the character body's resolved X/Y
- * position before Destiny publishes it.
+ * Noclip at the Havok simulation boundary. The hook reads the body's position and velocity before
+ * simulation, lets Havok run, then writes the position on from where the body stood.
+ * Collision resolution is discarded for the lanes it carries. The game keeps the position, so a
+ * respawn or a teleport needs nothing reset here.
  */
 
 #include <Windows.h>
@@ -9,7 +10,7 @@
 #include <algorithm>
 #include <array>
 #include <atomic>
-#include <bit>
+#include <cmath>
 #include <cstddef>
 #include <cstdint>
 #include <cstdio>
@@ -18,8 +19,10 @@
 #include "../../../core/logging/log.h"
 #include "../../../core/ui/runtime/ui_visibility_runtime.h"
 #include "../../hooking/detour.h"
+#include "../../input/window_focus.h"
 #include "../../movement/movement_settings_store.h"
 #include "../../patterns/image_scan.h"
+#include "../fly/fly.h"
 #include "runtime.h"
 
 namespace sunrise::client::hooks::noclip {
@@ -55,9 +58,10 @@ constexpr std::size_t kBodyMotion = 0x150;
 constexpr std::size_t kBodyPosition = 0x1C0;
 constexpr std::size_t kBodyVelocity = 0x230;
 
-/** X and Y are Destiny's horizontal world-space lanes. */
+/** X and Y are Destiny's horizontal world-space lanes, and Z the vertical one. */
 constexpr std::size_t kHorizontalX = 0;
 constexpr std::size_t kHorizontalY = 1;
+constexpr std::size_t kVertical = 2;
 constexpr std::size_t kVectorLanes = 4;
 
 /**
@@ -87,9 +91,6 @@ static_assert(sizeof(HavokArray) == kHavokArrayBytes);
 
 std::atomic_bool g_installed{false};
 std::atomic_bool g_toggleDown{false};
-std::atomic_bool g_targetValid{false};
-/** The two target floats are one atomic publication, so readers never observe mixed coordinates. */
-std::atomic<std::uint64_t> g_horizontalTarget{};
 /** Module-owned vtable target; unlike Havok objects, its address is stable until DLL teardown. */
 std::uintptr_t g_characterMotionVtable{};
 hooking::detour::Handle g_stepHandle{};
@@ -99,6 +100,38 @@ template <typename T> [[nodiscard]] T& field(std::byte* object, std::size_t offs
     return *reinterpret_cast<T*>(object + offset);
 }
 
+/** Copies the lanes the shorter vector carries, and leaves any beyond it alone. */
+template <typename Source, typename Destination>
+void copy_lanes(const Source& source, Destination& destination) noexcept {
+    // The shorter vector's lane count, so a 3-lane caller never touches the stored fourth.
+    constexpr std::size_t lanes =
+        (std::min)(std::tuple_size_v<Source>, std::tuple_size_v<Destination>);
+    for (std::size_t lane = 0; lane < lanes; ++lane) {
+        destination[lane] = source[lane];
+    }
+}
+
+/**
+ * Shortens a velocity to a speed limit, keeping its direction.
+ * @param velocity Velocity to limit.
+ * @param limit Highest speed to return.
+ */
+[[nodiscard]] std::array<float, kVectorLanes>
+capped_speed(const std::array<float, kVectorLanes>& velocity, float limit) noexcept {
+    const float speedSquared = velocity[kHorizontalX] * velocity[kHorizontalX]
+                               + velocity[kHorizontalY] * velocity[kHorizontalY]
+                               + velocity[kVertical] * velocity[kVertical];
+    if (speedSquared <= limit * limit) {
+        return velocity;
+    }
+    std::array<float, kVectorLanes> capped = velocity;
+    const float scale = limit / std::sqrt(speedSquared);
+    capped[kHorizontalX] *= scale;
+    capped[kHorizontalY] *= scale;
+    capped[kVertical] *= scale;
+    return capped;
+}
+
 /** @return True when the array header is internally consistent and within the supplied bound. */
 [[nodiscard]] bool valid_array(const HavokArray& array, std::int32_t maximum) noexcept {
     const std::uint32_t capacity = array.capacityAndFlags & kArrayCapacityMask;
@@ -150,16 +183,6 @@ template <typename T> [[nodiscard]] T& field(std::byte* object, std::size_t offs
     return nullptr;
 }
 
-/** Packs the horizontal target into one atomic value. */
-[[nodiscard]] std::uint64_t pack_target(float x, float y) noexcept {
-    return std::bit_cast<std::uint64_t>(std::array<float, 2>{x, y});
-}
-
-/** Unpacks one atomically published horizontal target. */
-[[nodiscard]] std::array<float, 2> unpack_target(std::uint64_t value) noexcept {
-    return std::bit_cast<std::array<float, 2>>(value);
-}
-
 /**
  * Polls the bound key on the physics thread and flips the stored switch when it goes down.
  * The key and the interface toggle write the same stored value, so there is one on/off state.
@@ -171,7 +194,9 @@ template <typename T> [[nodiscard]] T& field(std::byte* object, std::size_t offs
         g_toggleDown.store(false, std::memory_order_relaxed);
         return settings.noclipEnabled;
     }
-    const bool down = (GetAsyncKeyState(static_cast<int>(settings.noclipToggleKey)) & 0x8000) != 0;
+    const bool down =
+        client::input::game_focused()
+        && (GetAsyncKeyState(static_cast<int>(settings.noclipToggleKey)) & 0x8000) != 0;
     // An open interface owns the keyboard, so the bound key only tracks the press, it never flips.
     if (core::ui::runtime::snapshot().visible) {
         g_toggleDown.store(down, std::memory_order_relaxed);
@@ -183,7 +208,6 @@ template <typename T> [[nodiscard]] T& field(std::byte* object, std::size_t offs
         if (!client::movement::publish(updated)) {
             return settings.noclipEnabled;
         }
-        invalidate_target();
         core::log::write(core::log::Channel::client,
                          core::log::Level::info,
                          updated.noclipEnabled
@@ -202,71 +226,88 @@ template <typename T> [[nodiscard]] T& field(std::byte* object, std::size_t offs
     return client::movement::get().noclipEnabled;
 }
 
-/** Runs Havok normally, then replaces collision-resolved horizontal position for the character. */
+/** Runs Havok normally, then moves the character on from where it stood before the step. */
 std::int32_t __fastcall havok_step(std::byte* simulation, float deltaTime) noexcept {
     std::array<float, kVectorLanes> nativeVelocity{};
+    std::array<float, kVectorLanes> nativePosition{};
     const bool enabledBeforeStep = poll_toggle();
-    std::byte* const before = enabledBeforeStep ? character_body(simulation) : nullptr;
-    const bool hasVelocity = before != nullptr;
-    if (hasVelocity) {
+    const bool flying = fly::enabled();
+    std::byte* const before = (enabledBeforeStep || flying) ? character_body(simulation) : nullptr;
+    // Fly writes first, so the velocity read below is the one it asked for.
+    if (flying) {
+        fly::before_step(before);
+    }
+    const bool hasBody = before != nullptr;
+    if (hasBody) {
         nativeVelocity = field<std::array<float, kVectorLanes>>(before, kBodyVelocity);
+        nativePosition = field<std::array<float, kVectorLanes>>(before, kBodyPosition);
+    }
+    // Flying through geometry, the body does not have to carry the speed through the step: this
+    // hook writes the position itself. It is put to rest instead, because damage taken inside
+    // geometry scales with contact speed and a resting body makes no fast contacts. The speed is
+    // held above and put back after the step.
+    const bool rested = enabledBeforeStep && flying && hasBody;
+    if (rested) {
+        field<std::array<float, kVectorLanes>>(before, kBodyVelocity) = {};
     }
 
     const HavokStep next = reinterpret_cast<HavokStep>(g_stepHandle.original);
     const std::int32_t result = next != nullptr ? next(simulation, deltaTime) : 0;
 
+    // The body is resolved once here for both features.
+    std::byte* const body = (enabledBeforeStep || flying) ? character_body(simulation) : nullptr;
+    // A character created or replaced during this step has no matching before-state.
+    const bool sameBody = hasBody && body == before;
     // Re-read after the step, so a toggle from the interface thread lands before a position write.
-    if (!enabledBeforeStep || !enabled()) {
-        return result;
-    }
-    std::byte* const body = character_body(simulation);
-    if (body == nullptr) {
-        // Other Havok worlds do not contain the player and must not disturb the shared target.
-        // Only a world that contained the character before this step can prove it was removed.
-        if (before != nullptr) {
-            invalidate_target();
-        }
-        return result;
+    const bool noclipping = enabledBeforeStep && enabled();
+    // With both on this hook drives all three lanes. Fly holds the height, so carrying the
+    // vertical one is safe.
+    const bool verticalToo = noclipping && flying;
+    if (flying) {
+        fly::after_step(body, verticalToo);
     }
-    std::array<float, kVectorLanes> position =
-        field<std::array<float, kVectorLanes>>(body, kBodyPosition);
-    // A character created or replaced during this step has no compatible velocity or target.
-    if (before == nullptr || body != before) {
-        invalidate_target();
-    }
-    if (!g_targetValid.load(std::memory_order_acquire)) {
-        g_horizontalTarget.store(pack_target(position[kHorizontalX], position[kHorizontalY]),
-                                 std::memory_order_relaxed);
-        g_targetValid.store(true, std::memory_order_release);
-        return result;
+    // The game reads this field after the step and damages the player for carrying speed into
+    // geometry. It is shown a capped speed instead. Nothing is lost: the step it belonged to has
+    // already run, and fly writes the real speed again before the next one.
+    if (flying && sameBody) {
+        const std::array<float, kVectorLanes> moved =
+            rested ? nativeVelocity : field<std::array<float, kVectorLanes>>(body, kBodyVelocity);
+        field<std::array<float, kVectorLanes>>(body, kBodyVelocity) =
+            capped_speed(moved, fly::kPublishedSpeedCap);
     }
-    if (!hasVelocity) {
+    if (!noclipping || !sameBody) {
         return result;
     }
-
     const float step = std::clamp(deltaTime, 0.0F, kMaximumStepSeconds);
-    const float velocitySquared = nativeVelocity[kHorizontalX] * nativeVelocity[kHorizontalX]
-                                  + nativeVelocity[kHorizontalY] * nativeVelocity[kHorizontalY];
+    float velocitySquared = nativeVelocity[kHorizontalX] * nativeVelocity[kHorizontalX]
+                            + nativeVelocity[kHorizontalY] * nativeVelocity[kHorizontalY];
+    if (verticalToo) {
+        // Straight up has no horizontal velocity, and without this the move is dropped.
+        velocitySquared += nativeVelocity[kVertical] * nativeVelocity[kVertical];
+    }
     if (step <= 0.0F || velocitySquared <= kMinimumVelocitySquared) {
         return result;
     }
-    const std::array<float, 2> target =
-        unpack_target(g_horizontalTarget.load(std::memory_order_acquire));
-    const float targetX = target[kHorizontalX] + nativeVelocity[kHorizontalX] * step;
-    const float targetY = target[kHorizontalY] + nativeVelocity[kHorizontalY] * step;
-    position[kHorizontalX] = targetX;
-    position[kHorizontalY] = targetY;
+    // Moved on from where the body stood before the step, not from a position of our own. The game
+    // owns the position, so a respawn or any other placement is picked up with nothing to reset.
+    std::array<float, kVectorLanes> position =
+        field<std::array<float, kVectorLanes>>(body, kBodyPosition);
+    position[kHorizontalX] = nativePosition[kHorizontalX] + nativeVelocity[kHorizontalX] * step;
+    position[kHorizontalY] = nativePosition[kHorizontalY] + nativeVelocity[kHorizontalY] * step;
+    if (verticalToo) {
+        position[kVertical] = nativePosition[kVertical] + nativeVelocity[kVertical] * step;
+    }
     field<std::array<float, kVectorLanes>>(body, kBodyPosition) = position;
 
-    // Collision may consume horizontal velocity before publication. Restore the pre-simulation
-    // velocity so the next step keeps advancing the target, while retaining resolved vertical
-    // velocity for ordinary ground movement and gravity.
-    std::array<float, kVectorLanes> wakeVelocity =
-        field<std::array<float, kVectorLanes>>(body, kBodyVelocity);
-    wakeVelocity[kHorizontalX] = nativeVelocity[kHorizontalX];
-    wakeVelocity[kHorizontalY] = nativeVelocity[kHorizontalY];
-    field<std::array<float, kVectorLanes>>(body, kBodyVelocity) = wakeVelocity;
-    g_horizontalTarget.store(pack_target(targetX, targetY), std::memory_order_release);
+    // Collision may consume velocity before publication. Restore it so the next step still moves.
+    // The vertical lane stays resolved. A rested body already had every lane put back above.
+    if (!rested) {
+        std::array<float, kVectorLanes> wakeVelocity =
+            field<std::array<float, kVectorLanes>>(body, kBodyVelocity);
+        wakeVelocity[kHorizontalX] = nativeVelocity[kHorizontalX];
+        wakeVelocity[kHorizontalY] = nativeVelocity[kHorizontalY];
+        field<std::array<float, kVectorLanes>>(body, kBodyVelocity) = wakeVelocity;
+    }
     return result;
 }
 
@@ -314,7 +355,7 @@ bool install() noexcept {
     return true;
 }
 
-/** Detaches the simulation-step detour, then clears the toggle and the horizontal target. */
+/** Detaches the simulation-step detour, then clears the key state. */
 void uninstall() noexcept {
     if (!g_installed.exchange(false, std::memory_order_acq_rel)) {
         return;
@@ -322,13 +363,34 @@ void uninstall() noexcept {
     (void)hooking::detour::uninstall(g_stepHandle);
     g_stepHandle = {};
     g_characterMotionVtable = 0;
-    // The switch is a stored setting, so detaching clears only the key state and the target.
+    // The switch is a stored setting, so detaching clears only the key state.
     g_toggleDown.store(false, std::memory_order_release);
-    invalidate_target();
 }
 
-void invalidate_target() noexcept {
-    g_targetValid.store(false, std::memory_order_release);
+/** Reads a live rigid body's world position. */
+void read_body_position(void* body, Vector& position) noexcept {
+    copy_lanes(field<std::array<float, kVectorLanes>>(static_cast<std::byte*>(body), kBodyPosition),
+               position);
+}
+
+/** Writes a live rigid body's world position. */
+void write_body_position(void* body, const Vector& position) noexcept {
+    auto& stored =
+        field<std::array<float, kVectorLanes>>(static_cast<std::byte*>(body), kBodyPosition);
+    copy_lanes(position, stored);
+}
+
+/** Reads a live rigid body's linear velocity. */
+void read_body_velocity(void* body, Vector& velocity) noexcept {
+    copy_lanes(field<std::array<float, kVectorLanes>>(static_cast<std::byte*>(body), kBodyVelocity),
+               velocity);
+}
+
+/** Writes a live rigid body's linear velocity. */
+void write_body_velocity(void* body, const Vector& velocity) noexcept {
+    auto& stored =
+        field<std::array<float, kVectorLanes>>(static_cast<std::byte*>(body), kBodyVelocity);
+    copy_lanes(velocity, stored);
 }
 
 } // namespace sunrise::client::hooks::noclip

+ 20 - 2
Sunrise/src/client/hooks/noclip/runtime.h

@@ -1,7 +1,12 @@
 #pragma once
 
+#include <array>
+
 namespace sunrise::client::hooks::noclip {
 
+/** Three lanes of a Havok vector. A write leaves the stored fourth lane alone. */
+using Vector = std::array<float, 3>;
+
 /**
  * Resolves the Havok targets and attaches the independent simulation-step detour.
  * @return True when both signatures resolve and the detour is attached.
@@ -11,7 +16,20 @@ namespace sunrise::client::hooks::noclip {
 /** Detaches the simulation-step detour and clears runtime state. */
 void uninstall() noexcept;
 
-/** Invalidates the horizontal target after another feature changes rigid-body position. */
-void invalidate_target() noexcept;
+/**
+ * Reads a live rigid body's world position.
+ * @param body Body from the simulation hook. Valid only inside that call.
+ * @param position Receives the three lanes.
+ */
+void read_body_position(void* body, Vector& position) noexcept;
+
+/** Writes a live rigid body's world position. @see read_body_position */
+void write_body_position(void* body, const Vector& position) noexcept;
+
+/** Reads a live rigid body's linear velocity. @see read_body_position */
+void read_body_velocity(void* body, Vector& velocity) noexcept;
+
+/** Writes a live rigid body's linear velocity. @see read_body_position */
+void write_body_velocity(void* body, const Vector& velocity) noexcept;
 
 } // namespace sunrise::client::hooks::noclip

+ 68 - 30
Sunrise/src/client/hooks/sword_skate/sword_skate.cpp

@@ -1,27 +1,24 @@
 /**
- * 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.
+ * Sword skate. A sword's air attack throws the player forward, and a glide started during the
+ * throw keeps that speed. The client refuses the glide while the throw is active.
+ * The refusal is one bit of a movement-state field, set by the attack and read by the glide.
+ * Clearing it on the tick jump is pressed lets the client start its own glide.
+ * Nothing here moves the player.
  */
 
 #include "sword_skate.h"
 
 #include <Windows.h>
 
+#include <array>
 #include <cstddef>
 #include <cstdint>
+#include <optional>
 
 #include "../../../core/ui/runtime/ui_visibility_runtime.h"
+#include "../../../state/account/account_state.h"
+#include "../../../state/runtime/runtime.h"
+#include "../../input/window_focus.h"
 #include "../../movement/movement_settings_store.h"
 #include "../teleport/runtime.h"
 
@@ -31,16 +28,23 @@ 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.
+ * Set while a sword's air attack carries the player, and read by the glide before it starts.
+ * The attack sets other bits in the same field. Only this one refuses the glide, so only it is
+ * cleared.
  */
 constexpr std::uint32_t kGlideRefusedBit = 0x00000800U;
 /** The high bit of a polled key state marks it held. */
 constexpr SHORT kKeyHeldBit = static_cast<SHORT>(0x8000);
+/** The action this watches. Its binding is the player's jump key. */
+constexpr std::uint16_t kJumpAction =
+    static_cast<std::uint16_t>(state::account::settings::bindings::Action::jump);
 
 /** Jump held on the previous tick, so the flag is only cleared on the press and not on the hold. */
 bool g_jumpHeld{false};
+/** Both halves of the jump binding. An unbound half stays empty. */
+std::array<std::optional<std::uint16_t>, 2> g_jumpBinding{};
+/** Set once the binding is read, so the costly account snapshot runs once. */
+bool g_bindingRead{false};
 
 /**
  * Reads one value out of game memory without faulting on a torn pointer.
@@ -66,34 +70,68 @@ bool g_jumpHeld{false};
            && written == sizeof value;
 }
 
+/**
+ * Takes the account's jump binding once it is loaded.
+ * @return True when the binding has been read, whether or not either half is bound.
+ */
+[[nodiscard]] bool read_jump_binding() noexcept {
+    if (g_bindingRead) {
+        return true;
+    }
+    // The snapshot copies the whole account, so it stops once the bindings arrive.
+    const state::AccountState account = state::account_snapshot();
+    if (!account.settings.keyBindings.configured) {
+        return false;
+    }
+    const auto& binding = account.settings.keyBindings.values[kJumpAction];
+    g_jumpBinding = {binding.primary, binding.secondary};
+    g_bindingRead = true;
+    return true;
+}
+
+/**
+ * A half is an input code, not a virtual key. A half bound to a mouse button gives no key.
+ * @return True while either half of the jump binding is down.
+ */
+[[nodiscard]] bool jump_down() noexcept {
+    for (const std::optional<std::uint16_t>& half : g_jumpBinding) {
+        if (!half.has_value()) {
+            continue;
+        }
+        const std::uint32_t key = teleport::action_key(*half);
+        if (key != 0 && (GetAsyncKeyState(static_cast<int>(key)) & kKeyHeldBit) != 0) {
+            return true;
+        }
+    }
+    return false;
+}
+
 } // 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;
+    // The interface or another application owns the keyboard. Their presses must not reach this.
+    const bool usable = settings.swordSkateEnabled && !core::ui::runtime::snapshot().visible
+                        && input::game_focused();
     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.
+        // Cleared, or the first press after the feature comes back reads as a hold and is 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.
+    // Ownership is tested before the key is read. The held flag must only advance on the player's
+    // own tick, or another component consumes the press and the player's tick sees no edge.
     if (component == nullptr || !teleport::owns_local_player(component)) {
         return;
     }
-    const bool held =
-        (GetAsyncKeyState(static_cast<int>(settings.swordSkateJumpKey)) & kKeyHeldBit) != 0;
+    if (!read_jump_binding()) {
+        return;
+    }
+    const bool held = jump_down();
     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.
+    // Only the press matters. Clearing it for as long as the key is down would hold it clear
+    // through the whole attack.
     if (!held || wasHeld) {
         return;
     }

+ 0 - 5
Sunrise/src/client/hooks/teleport/internal.h

@@ -52,9 +52,4 @@ inline constexpr std::size_t kBodyPositionX = 448;
 /** Rigid-body velocity. The sync copies this into the physics component every tick. */
 inline constexpr std::size_t kBodyVelocityX = 560;
 
-/** Three floats make one position or velocity vector. */
-inline constexpr std::size_t kVectorLanes = 3;
-/** The vertical lane, given the camera basis above. */
-inline constexpr std::size_t kVerticalLane = 2;
-
 } // namespace sunrise::client::hooks::teleport

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

@@ -1,10 +1,19 @@
 #pragma once
 
+#include <array>
 #include <cstddef>
 #include <cstdint>
 
 namespace sunrise::client::hooks::teleport {
 
+/** Three floats make one position or velocity vector. */
+inline constexpr std::size_t kVectorLanes = 3;
+/** The vertical lane. The camera basis is X forward, Z up, so the third lane is up. */
+inline constexpr std::size_t kVerticalLane = 2;
+
+/** One world-space vector as the game stores it. */
+using Vector = std::array<float, kVectorLanes>;
+
 /** Writes the local player's controlled-object handle, or the invalid sentinel. */
 using ControlledHandle = std::uint32_t* (*)(std::uint32_t*);
 /** Returns the camera pose block array. The pointer in its global is obfuscated, so we call it. */
@@ -78,4 +87,43 @@ void apply_pending(void* component) noexcept;
  */
 [[nodiscard]] bool owns_local_player(void* component) noexcept;
 
+/**
+ * Reads the world position of the body a physics component drives.
+ * @param component Physics component.
+ * @param position Receives the three lanes.
+ * @return True when the body was found and read.
+ */
+[[nodiscard]] bool read_position(void* component, Vector& position) noexcept;
+
+/**
+ * Writes the world position of the body a physics component drives.
+ * @param component Physics component.
+ * @param position Three lanes to store.
+ * @return True when the body was found and written.
+ */
+[[nodiscard]] bool write_position(void* component, const Vector& position) noexcept;
+
+/**
+ * Reads the linear velocity of the body a physics component drives.
+ * @param component Physics component.
+ * @param velocity Receives the three lanes.
+ * @return True when the body was found and read.
+ */
+[[nodiscard]] bool read_velocity(void* component, Vector& velocity) noexcept;
+
+/**
+ * Writes the linear velocity of the body a physics component drives.
+ * @param component Physics component.
+ * @param velocity Three lanes to store.
+ * @return True when the body was found and written.
+ */
+[[nodiscard]] bool write_velocity(void* component, const Vector& velocity) noexcept;
+
+/**
+ * The camera hook is the only site that sees the pose block, so it publishes the vector here.
+ * @param forward Receives the camera forward vector published this frame.
+ * @return True once the camera hook has published one.
+ */
+[[nodiscard]] bool camera_forward(Vector& forward) noexcept;
+
 } // namespace sunrise::client::hooks::teleport

+ 11 - 0
Sunrise/src/client/hooks/teleport/teleport_action_key.cpp

@@ -40,6 +40,13 @@ constexpr std::size_t kKeyTableCount = 105;
 constexpr std::uint16_t kKeyCodeMask = 0x00FF;
 /** The table byte meaning the index resolves through its scan code instead. */
 constexpr std::uint8_t kAbsentVirtualKey = 0xFF;
+/** First either-side modifier code. These sit above the key table and are not indexed by it. */
+constexpr std::uint16_t kFirstModifierCode = 105;
+/**
+ * Virtual keys for the four either-side codes: shift, control, key_windows, alt.
+ * Windows has no either-side windows key, so that one resolves to the left key alone.
+ */
+constexpr std::array<std::uint8_t, 4> kModifierKeys{VK_SHIFT, VK_CONTROL, VK_LWIN, VK_MENU};
 
 const std::uint8_t* g_virtualKeys{};
 const std::uint8_t* g_scanCodes{};
@@ -92,6 +99,10 @@ void clear_action_keys() noexcept {
 std::uint32_t action_key(std::uint16_t binding) noexcept {
     // The key code is the low byte. The bits above it are modifiers, which the tables do not index.
     const std::uint16_t index = binding & kKeyCodeMask;
+    // A binding may name a modifier on its own. Crouch does by default.
+    if (index >= kFirstModifierCode && index < kFirstModifierCode + kModifierKeys.size()) {
+        return kModifierKeys[index - kFirstModifierCode];
+    }
     if (g_virtualKeys == nullptr || g_scanCodes == nullptr || index >= kKeyTableCount) {
         return 0;
     }

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

@@ -13,6 +13,7 @@
 
 #include "../../../core/logging/log.h"
 #include "../../hooking/detour.h"
+#include "../fly/fly.h"
 #include "../polled_input/runtime.h"
 #include "../sword_skate/sword_skate.h"
 #include "internal.h"
@@ -78,6 +79,8 @@ std::int64_t __fastcall camera_transform(std::uint32_t playerIndex) noexcept {
     capture_forward(playerIndex);
     poll_request();
     force_pending();
+    // Read here, not on the physics tick: that tick stops for a player who is standing still.
+    hooks::fly::poll_toggle();
     return result;
 }
 
@@ -93,6 +96,7 @@ std::int64_t __fastcall physics_sync(std::byte* component, std::byte* outFlags)
     // 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);
+    hooks::fly::apply(component);
     const PhysicsSync next = original<PhysicsSync>(kPhysicsSlot);
     return next != nullptr ? next(component, outFlags) : 0;
 }
@@ -190,6 +194,7 @@ void uninstall() noexcept {
     }
     clear_targets();
     clear_action_keys();
+    hooks::fly::reset();
     polled_input::release_key();
     (void)hooking::detour::uninstall(g_handles);
     g_handles = {};

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

@@ -16,8 +16,8 @@
 #include "../../../core/ui/runtime/ui_visibility_runtime.h"
 #include "../../../state/account/account_state.h"
 #include "../../../state/runtime/runtime.h"
+#include "../../input/window_focus.h"
 #include "../../movement/movement_settings_store.h"
-#include "../noclip/runtime.h"
 #include "../polled_input/runtime.h"
 #include "internal.h"
 #include "runtime.h"
@@ -378,7 +378,8 @@ void poll_request() noexcept {
         g_keyDown.store(false, std::memory_order_relaxed);
         return;
     }
-    const bool down = (GetAsyncKeyState(static_cast<int>(settings.virtualKey)) & 0x8000) != 0;
+    const bool down = client::input::game_focused()
+                      && (GetAsyncKeyState(static_cast<int>(settings.virtualKey)) & 0x8000) != 0;
     if (down && !g_keyDown.exchange(down, std::memory_order_relaxed)) {
         g_requestAge.store(0, std::memory_order_relaxed);
         g_requested.store(true, std::memory_order_release);
@@ -408,9 +409,7 @@ void apply_pending(void* component) noexcept {
         return;
     }
     g_requested.store(false, std::memory_order_release);
-    if (perform_move(physics)) {
-        noclip::invalidate_target();
-    }
+    (void)perform_move(physics);
 }
 
 /** Runs the move for a request no physics tick collected. */
@@ -429,7 +428,6 @@ void force_pending() noexcept {
     if (!perform_move(physics)) {
         return;
     }
-    noclip::invalidate_target();
     invoke_sync(physics);
     core::log::write(
         core::log::Channel::client, core::log::Level::info, "ev=teleport stage=force result=ok");
@@ -441,4 +439,49 @@ bool owns_local_player(void* component) noexcept {
            && owns_player(static_cast<std::byte*>(component));
 }
 
+/** Reads the world position of the body a physics component drives. */
+bool read_position(void* component, Vector& position) noexcept {
+    if (component == nullptr) {
+        return false;
+    }
+    std::byte* const body = body_of(static_cast<std::byte*>(component));
+    return body != nullptr && read_at(body + kBodyPositionX, position);
+}
+
+/** Writes the world position of the body a physics component drives. */
+bool write_position(void* component, const Vector& position) noexcept {
+    if (component == nullptr) {
+        return false;
+    }
+    std::byte* const body = body_of(static_cast<std::byte*>(component));
+    return body != nullptr && write_vector(body + kBodyPositionX, position);
+}
+
+/** Reads the linear velocity of the body a physics component drives. */
+bool read_velocity(void* component, Vector& velocity) noexcept {
+    if (component == nullptr) {
+        return false;
+    }
+    std::byte* const body = body_of(static_cast<std::byte*>(component));
+    return body != nullptr && read_at(body + kBodyVelocityX, velocity);
+}
+
+/** Writes the linear velocity of the body a physics component drives. */
+bool write_velocity(void* component, const Vector& velocity) noexcept {
+    if (component == nullptr) {
+        return false;
+    }
+    std::byte* const body = body_of(static_cast<std::byte*>(component));
+    return body != nullptr && write_vector(body + kBodyVelocityX, velocity);
+}
+
+/** Reports the camera forward vector published this frame. */
+bool camera_forward(Vector& forward) noexcept {
+    if (!g_forwardValid.load(std::memory_order_acquire)) {
+        return false;
+    }
+    forward = g_forward;
+    return true;
+}
+
 } // namespace sunrise::client::hooks::teleport

+ 23 - 0
Sunrise/src/client/input/window_focus.cpp

@@ -0,0 +1,23 @@
+/**
+ * Whether the game holds focus. The test is the process, not one window.
+ * The game has several windows, and which one is in front changes with the display mode.
+ */
+
+#include "window_focus.h"
+
+#include <Windows.h>
+
+namespace sunrise::client::input {
+
+/** Reports whether a window of this process is in front. */
+bool game_focused() noexcept {
+    const HWND foreground = GetForegroundWindow();
+    if (foreground == nullptr) {
+        return false;
+    }
+    DWORD processId = 0;
+    (void)GetWindowThreadProcessId(foreground, &processId);
+    return processId == GetCurrentProcessId();
+}
+
+} // namespace sunrise::client::input

+ 12 - 0
Sunrise/src/client/input/window_focus.h

@@ -0,0 +1,12 @@
+#pragma once
+
+namespace sunrise::client::input {
+
+/**
+ * The asynchronous key state reports a key held whichever application owns it. Every movement key
+ * poll is gated on this, because the game stops acting on input when it loses focus.
+ * @return True while a window of this process is in front.
+ */
+[[nodiscard]] bool game_focused() noexcept;
+
+} // namespace sunrise::client::input

+ 20 - 6
Sunrise/src/client/movement/movement_settings_store.cpp

@@ -8,6 +8,7 @@
 
 #include <Windows.h>
 
+#include <algorithm>
 #include <array>
 #include <cstddef>
 #include <cstdio>
@@ -39,7 +40,8 @@ bool g_pathResolved{};
     return settings.distance >= kMinimumDistance && settings.distance <= kMaximumDistance
            && settings.virtualKey <= kMaximumVirtualKey
            && settings.noclipToggleKey <= kMaximumVirtualKey
-           && settings.swordSkateJumpKey <= kMaximumVirtualKey;
+           && settings.flyToggleKey <= kMaximumVirtualKey && settings.flySpeed >= kMinimumFlySpeed
+           && settings.flySpeed <= kMaximumFlySpeed;
 }
 
 /** @param reason Key naming the step that failed. */
@@ -130,9 +132,17 @@ void parse(std::string_view text, Settings& output) noexcept {
     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));
+    if (scalar_for(text, "\"fly_enabled\"", scalar)) {
+        output.flyEnabled = scalar.starts_with("true");
+    }
+    if (scalar_for(text, "\"fly_toggle_key\"", scalar) && terminated(scalar, buffer)) {
+        output.flyToggleKey = static_cast<std::uint32_t>(std::strtoul(buffer.data(), nullptr, 0));
+    }
+    if (scalar_for(text, "\"fly_speed\"", scalar) && terminated(scalar, buffer)) {
+        // Clamped, not refused. A speed saved before the maximum came down would otherwise fail
+        // the range check and take every other movement setting with it.
+        output.flySpeed =
+            std::clamp(std::strtof(buffer.data(), nullptr), kMinimumFlySpeed, kMaximumFlySpeed);
     }
 }
 
@@ -154,14 +164,18 @@ void parse(std::string_view text, Settings& output) noexcept {
                                    "  \"noclip_enabled\": %s,\n"
                                    "  \"noclip_toggle_key\": %u,\n"
                                    "  \"sword_skate_enabled\": %s,\n"
-                                   "  \"sword_skate_jump_key\": %u\n}\n",
+                                   "  \"fly_enabled\": %s,\n"
+                                   "  \"fly_toggle_key\": %u,\n"
+                                   "  \"fly_speed\": %.3f\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),
                                    settings.swordSkateEnabled ? "true" : "false",
-                                   static_cast<unsigned>(settings.swordSkateJumpKey));
+                                   settings.flyEnabled ? "true" : "false",
+                                   static_cast<unsigned>(settings.flyToggleKey),
+                                   static_cast<double>(settings.flySpeed));
     if (size <= 0) {
         return false;
     }

+ 12 - 4
Sunrise/src/client/movement/movement_settings_store.h

@@ -12,8 +12,13 @@ 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;
+
+/** Default fly speed, in world units per second. */
+inline constexpr float kDefaultFlySpeed = 15.0F;
+/** Slowest offered fly speed. Below this a press does not visibly move the player. */
+inline constexpr float kMinimumFlySpeed = 1.0F;
+/** Fastest offered fly speed. Past this the player outruns what the map streams in. */
+inline constexpr float kMaximumFlySpeed = 100.0F;
 
 /** Runtime movement configuration. This module owns it; Core settings do not carry it. */
 struct Settings {
@@ -22,9 +27,12 @@ struct Settings {
     std::uint32_t virtualKey{kNoKey};
     bool noclipEnabled{false};
     std::uint32_t noclipToggleKey{kNoKey};
+    /** The jump key comes from the account binding, so none is stored here. */
     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};
+    bool flyEnabled{false};
+    std::uint32_t flyToggleKey{kNoKey};
+    /** World units per second while a direction is pressed. */
+    float flySpeed{kDefaultFlySpeed};
 };
 
 /**

+ 31 - 14
Sunrise/src/client/ui/movement/movement_panel.cpp

@@ -29,7 +29,7 @@ enum class CaptureTarget {
     none,
     teleport,
     noclip,
-    swordSkate,
+    fly,
 };
 
 CaptureTarget g_capturing{CaptureTarget::none};
@@ -168,8 +168,7 @@ void draw() noexcept {
     ImGui::Spacing();
     ImGui::TextUnformatted("Noclip");
     ImGui::Separator();
-    ImGui::TextWrapped("Uses native horizontal rigid-body velocity while preserving the game's "
-                       "vertical movement. The bound key turns it on and off in game.");
+    ImGui::TextWrapped("Disable collision on the horizontal axis.");
     ImGui::Spacing();
 
     changed =
@@ -185,27 +184,45 @@ void draw() noexcept {
 
     ImGui::Spacing();
     ImGui::Spacing();
-    ImGui::TextUnformatted("Sword Skate Fix");
+    ImGui::TextUnformatted("Fly");
     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::TextWrapped("Fly with your movement keys.");
     ImGui::Spacing();
 
+    changed = core::ui::components::toggle::control("Enabled##fly", settings.flyEnabled) || changed;
+
+    ImGui::Spacing();
+    ImGui::AlignTextToFramePadding();
+    ImGui::TextUnformatted("Toggle key");
+    ImGui::SameLine(labelWidth);
     changed =
-        core::ui::components::toggle::control("Enabled##sword_skate", settings.swordSkateEnabled)
-        || changed;
+        key_picker("fly_key", CaptureTarget::fly, settings.flyToggleKey, controlWidth) || changed;
 
     ImGui::Spacing();
     ImGui::AlignTextToFramePadding();
-    ImGui::TextUnformatted("Jump key");
+    ImGui::TextUnformatted("Speed");
     ImGui::SameLine(labelWidth);
+    ImGui::SetNextItemWidth(controlWidth);
+    float flySpeed = settings.flySpeed;
+    if (ImGui::SliderFloat("##fly_speed",
+                           &flySpeed,
+                           client::movement::kMinimumFlySpeed,
+                           client::movement::kMaximumFlySpeed,
+                           "%.0f units/s")) {
+        settings.flySpeed = flySpeed;
+        changed = true;
+    }
+
+    ImGui::Spacing();
+    ImGui::Spacing();
+    ImGui::TextUnformatted("Sword Skate Fix");
+    ImGui::Separator();
+    ImGui::TextWrapped("Disable sword swings blocking ability usage.");
+    ImGui::Spacing();
+
     changed =
-        key_picker(
-            "sword_skate_key", CaptureTarget::swordSkate, settings.swordSkateJumpKey, controlWidth)
+        core::ui::components::toggle::control("Enabled##sword_skate", settings.swordSkateEnabled)
         || 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();