Browse Source

Add a sword skate module

A sword's air attack throws the player forward, and a glide started while that throw is still
carrying them keeps the speed. The client refuses to start a glide while the throw is active, so
the speed decays and the chain cannot be continued.

The refusal is one bit of a movement-state field on the player's physics component, set when the
air attack starts and read by the glide before anything else. This clears that bit on the tick the
jump key goes down, and the client starts its own glide from there, with its own lift, drift and
air control. Nothing here moves the player or writes a velocity.

Off by default and driven by its own configuration file beside teleport.json, with a page in the
interface for the toggle and the jump key. A wrong or unset key does nothing at all.

The bit is cleared only on the press, never while the key is held: holding it would keep the flag
clear for the whole attack, which is a different change and not this one. The write is skipped
when the bit is already clear, so an ordinary glide takes no extra work.

Shares the physics sync detour the teleport module already installs, because the flag is written
and read inside that tick and a frame-timed poll would race with both. That module's ownership
test is exposed for it rather than duplicated.
Haze 4 weeks ago
parent
commit
1b54611b37

+ 6 - 0
Sunrise/Sunrise.vcxproj

@@ -186,6 +186,9 @@
     <ClCompile Include="src\client\hooks\egress\winsock\transmission\egress_message_transmission.cpp" />
     <ClCompile Include="src\client\ui\runtime\client_ui_module_runtime.cpp" />
     <ClCompile Include="src\client\ui\teleport\teleport_panel.cpp" />
+    <ClCompile Include="src\client\sword_skate\sword_skate_settings_store.cpp" />
+    <ClCompile Include="src\client\hooks\sword_skate\sword_skate.cpp" />
+    <ClCompile Include="src\client\ui\sword_skate\sword_skate_panel.cpp" />
     <ClCompile Include="src\client\teleport\teleport_settings_store.cpp" />
     <ClCompile Include="src\client\hooks\teleport\teleport_lifecycle.cpp" />
     <ClCompile Include="src\client\hooks\teleport\teleport_move.cpp" />
@@ -671,6 +674,9 @@
     <ClInclude Include="src\core\ui\layout\navigation\ui_layout_navigation.h" />
     <ClInclude Include="src\core\ui\layout\credits\sunrise_credits_badge.h" />
     <ClInclude Include="src\client\runtime\runtime.h" />
+    <ClInclude Include="src\client\sword_skate\sword_skate_settings_store.h" />
+    <ClInclude Include="src\client\hooks\sword_skate\sword_skate.h" />
+    <ClInclude Include="src\client\ui\sword_skate\sword_skate_panel.h" />
     <ClInclude Include="src\client\teleport\teleport_settings_store.h" />
     <ClInclude Include="src\client\hooks\teleport\internal.h" />
     <ClInclude Include="src\client\hooks\teleport\runtime.h" />

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

@@ -0,0 +1,106 @@
+/**
+ * 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 "../../sword_skate/sword_skate_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::sword_skate::Settings settings = client::sword_skate::get();
+    // An open interface owns the keyboard, so a press meant for it must not reach this either.
+    const bool usable = settings.enabled && settings.jumpKey != client::sword_skate::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.jumpKey)) & 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

@@ -431,4 +431,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

+ 3 - 0
Sunrise/src/client/runtime/client_runtime_lifecycle.cpp

@@ -12,6 +12,7 @@
 #include "../hooks/queuez/queuez_hook_lifecycle.h"
 #include "../hooks/retail_log/retail_log_lifecycle.h"
 #include "../hooks/teleport/runtime.h"
+#include "../sword_skate/sword_skate_settings_store.h"
 #include "../targets/game.h"
 #include "../targets/steam_targets.h"
 #include "../teleport/teleport_settings_store.h"
@@ -25,6 +26,7 @@ namespace sunrise::client {
 bool initialize(void* module) noexcept {
     // Loaded before the pages register, so the teleport page draws saved values on its first frame.
     teleport::initialize(module);
+    sword_skate::initialize(module);
     return ui::runtime::initialize();
 }
 
@@ -90,6 +92,7 @@ bool shutdown() noexcept {
     runtime::g_platformStage = runtime::StageState::pending;
     ui::runtime::shutdown();
     teleport::shutdown();
+    sword_skate::shutdown();
     core::log::write(core::log::Channel::client, core::log::Level::info, "ev=shutdown result=ok");
     ReleaseSRWLockExclusive(&runtime::g_lock);
     return true;

+ 234 - 0
Sunrise/src/client/sword_skate/sword_skate_settings_store.cpp

@@ -0,0 +1,234 @@
+/**
+ * The sword-skate configuration store. It is separate from Core settings because the interface
+ * changes these values while the game runs and saves each change at once. Core settings are
+ * parsed once into an immutable global.
+ */
+
+#include "sword_skate_settings_store.h"
+
+#include <Windows.h>
+
+#include <array>
+#include <cstddef>
+#include <cstdio>
+#include <cstdlib>
+#include <string_view>
+
+#include "../../core/filesystem/path.h"
+#include "../../core/logging/log.h"
+
+namespace sunrise::client::sword_skate {
+namespace {
+
+/** The module-owned configuration file, beside the generated settings and logs. */
+constexpr std::wstring_view kFileSuffix = L"\\sword_skate.json";
+/** The document is 2 scalars, so one small buffer covers both reading and writing. */
+constexpr std::size_t kFileCapacity = 512;
+/** Longest scalar accepted from the file. Anything longer is malformed rather than large. */
+constexpr std::size_t kScalarCapacity = 32;
+/** Highest Windows virtual-key code, so a stored binding cannot name a key that cannot exist. */
+constexpr std::uint32_t kMaximumVirtualKey = 254;
+
+SRWLOCK g_lock{SRWLOCK_INIT};
+Settings g_settings{};
+core::path::Buffer g_path{};
+bool g_pathResolved{};
+
+/** @param settings Candidate configuration. @return True when every field is in range. */
+[[nodiscard]] bool valid(const Settings& settings) noexcept {
+    return settings.jumpKey <= kMaximumVirtualKey;
+}
+
+/** @param reason Key naming the step that failed. */
+void report_fail(const char* reason) noexcept {
+    std::array<char, 96> line{};
+    const int written = std::snprintf(
+        line.data(), line.size(), "ev=sword_skate stage=store result=fail reason=%s", reason);
+    if (written > 0) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::warn,
+                         {line.data(), static_cast<std::size_t>(written)});
+    }
+}
+
+/**
+ * Finds one key's raw scalar text.
+ * @param text Whole document.
+ * @param key Quoted key to locate.
+ * @param output Receives the text between the colon and the next separator.
+ * @return True when the key exists and carries a non-empty value.
+ */
+[[nodiscard]] bool
+scalar_for(std::string_view text, std::string_view key, std::string_view& output) noexcept {
+    const std::size_t at = text.find(key);
+    if (at == std::string_view::npos) {
+        return false;
+    }
+    const std::size_t colon = text.find(':', at + key.size());
+    if (colon == std::string_view::npos) {
+        return false;
+    }
+    std::size_t begin = colon + 1;
+    while (begin < text.size() && (text[begin] == ' ' || text[begin] == '\t')) {
+        ++begin;
+    }
+    std::size_t end = begin;
+    while (end < text.size() && text[end] != ',' && text[end] != '}' && text[end] != '\n'
+           && text[end] != '\r') {
+        ++end;
+    }
+    output = text.substr(begin, end - begin);
+    return !output.empty();
+}
+
+/**
+ * Copies one scalar into null-terminated storage the C conversions require.
+ * @param value Scalar text taken from the document.
+ * @param output Receives the terminated copy.
+ * @return True when the scalar fits.
+ */
+[[nodiscard]] bool terminated(std::string_view value,
+                              std::array<char, kScalarCapacity>& output) noexcept {
+    if (value.size() >= output.size()) {
+        return false;
+    }
+    for (std::size_t index = 0; index < value.size(); ++index) {
+        output[index] = value[index];
+    }
+    output[value.size()] = '\0';
+    return true;
+}
+
+/**
+ * Layers one document over the current defaults. A missing or malformed key keeps its default,
+ * so a hand-edited file cannot stop the module loading.
+ * @param text Whole document.
+ * @param output Receives the parsed configuration.
+ */
+void parse(std::string_view text, Settings& output) noexcept {
+    std::string_view scalar;
+    if (scalar_for(text, "\"enabled\"", scalar)) {
+        output.enabled = scalar.starts_with("true");
+    }
+    std::array<char, kScalarCapacity> buffer{};
+    if (scalar_for(text, "\"jump_key\"", scalar) && terminated(scalar, buffer)) {
+        output.jumpKey = static_cast<std::uint32_t>(std::strtoul(buffer.data(), nullptr, 0));
+    }
+}
+
+/**
+ * Writes the whole document. It is small enough that a complete rewrite is the simplest
+ * correct save, which the shared settings file is not.
+ * @param settings Configuration to store.
+ * @return True when every byte reached the file.
+ */
+[[nodiscard]] bool store(const Settings& settings) noexcept {
+    if (!g_pathResolved) {
+        return false;
+    }
+    std::array<char, kFileCapacity> document{};
+    const int size = std::snprintf(document.data(),
+                                   document.size(),
+                                   "{\n  \"enabled\": %s,\n  \"jump_key\": %u\n}\n",
+                                   settings.enabled ? "true" : "false",
+                                   static_cast<unsigned>(settings.jumpKey));
+    if (size <= 0) {
+        return false;
+    }
+    const HANDLE file = CreateFileW(g_path.chars.data(),
+                                    GENERIC_WRITE,
+                                    0,
+                                    nullptr,
+                                    CREATE_ALWAYS,
+                                    FILE_ATTRIBUTE_NORMAL,
+                                    nullptr);
+    if (file == INVALID_HANDLE_VALUE) {
+        return false;
+    }
+    DWORD written = 0;
+    bool complete =
+        WriteFile(file, document.data(), static_cast<DWORD>(size), &written, nullptr) != FALSE
+        && written == static_cast<DWORD>(size);
+    complete = CloseHandle(file) != FALSE && complete;
+    return complete;
+}
+
+/** Reads the configuration file into the active settings when one exists. */
+void load() noexcept {
+    const HANDLE file = CreateFileW(g_path.chars.data(),
+                                    GENERIC_READ,
+                                    FILE_SHARE_READ,
+                                    nullptr,
+                                    OPEN_EXISTING,
+                                    FILE_ATTRIBUTE_NORMAL,
+                                    nullptr);
+    if (file == INVALID_HANDLE_VALUE) {
+        return;
+    }
+    std::array<char, kFileCapacity> buffer{};
+    DWORD read = 0;
+    const bool readOk =
+        ReadFile(file, buffer.data(), static_cast<DWORD>(buffer.size() - 1), &read, nullptr)
+        != FALSE;
+    (void)CloseHandle(file);
+    if (!readOk || read == 0) {
+        return;
+    }
+    Settings parsed{};
+    parse(std::string_view(buffer.data(), read), parsed);
+    if (!valid(parsed)) {
+        report_fail("range");
+        return;
+    }
+    g_settings = parsed;
+}
+
+} // namespace
+
+/** Resolves the configuration file and loads it when one exists. */
+void initialize(void* module) noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    g_settings = Settings{};
+    g_pathResolved =
+        core::path::artifact_directory(module, g_path) && core::path::append(g_path, kFileSuffix);
+    if (g_pathResolved) {
+        load();
+    } else {
+        report_fail("path");
+    }
+    ReleaseSRWLockExclusive(&g_lock);
+}
+
+/** Drops the runtime configuration and the resolved file path. */
+void shutdown() noexcept {
+    AcquireSRWLockExclusive(&g_lock);
+    g_settings = Settings{};
+    g_path = core::path::Buffer{};
+    g_pathResolved = false;
+    ReleaseSRWLockExclusive(&g_lock);
+}
+
+/** @return One lock-consistent copy of the current configuration. */
+Settings get() noexcept {
+    AcquireSRWLockShared(&g_lock);
+    const Settings snapshot = g_settings;
+    ReleaseSRWLockShared(&g_lock);
+    return snapshot;
+}
+
+/** Publishes one configuration and writes it straight to disk. */
+bool publish(const Settings& settings) noexcept {
+    if (!valid(settings)) {
+        return false;
+    }
+    AcquireSRWLockExclusive(&g_lock);
+    g_settings = settings;
+    const bool stored = store(settings);
+    ReleaseSRWLockExclusive(&g_lock);
+    if (!stored) {
+        report_fail("write");
+    }
+    return true;
+}
+
+} // namespace sunrise::client::sword_skate

+ 38 - 0
Sunrise/src/client/sword_skate/sword_skate_settings_store.h

@@ -0,0 +1,38 @@
+#pragma once
+
+#include <cstdint>
+
+namespace sunrise::client::sword_skate {
+
+/** No key is bound until one is picked, so a fresh install cannot change movement by accident. */
+inline constexpr std::uint32_t kNoKey = 0;
+/** Space is the stock jump binding, and the one this feature has to watch to do anything. */
+inline constexpr std::uint32_t kDefaultJumpKey = 0x20;
+
+/** Runtime sword-skate configuration. This module owns it; Core settings do not carry it. */
+struct Settings {
+    bool enabled{false};
+    /** The player's jump key. The refusal is only cleared on the tick this key goes down. */
+    std::uint32_t jumpKey{kDefaultJumpKey};
+};
+
+/**
+ * Resolves the configuration file and loads it when one exists.
+ * @param module Loaded DLL used to resolve the owned artifact directory.
+ */
+void initialize(void* module) noexcept;
+
+/** Drops the runtime configuration and the resolved file path. */
+void shutdown() noexcept;
+
+/** @return One lock-consistent copy of the current configuration. */
+[[nodiscard]] Settings get() noexcept;
+
+/**
+ * Publishes one configuration and writes it straight to disk.
+ * @param settings Candidate configuration, refused when a field is out of range.
+ * @return True when the value was published. A failed write is logged, not returned.
+ */
+bool publish(const Settings& settings) noexcept;
+
+} // namespace sunrise::client::sword_skate

+ 13 - 1
Sunrise/src/client/ui/runtime/client_ui_module_runtime.cpp

@@ -4,6 +4,7 @@
 
 #include "../../../core/ui/modules/registry/ui_module_registry.h"
 #include "../../../core/ui/modules/ui_module_descriptor.h"
+#include "../sword_skate/sword_skate_panel.h"
 #include "../teleport/teleport_panel.h"
 
 namespace sunrise::client::ui::runtime {
@@ -13,20 +14,31 @@ namespace {
 constexpr std::string_view kTeleportStableId = "client.teleport";
 /** Short menu label for the teleport page. */
 constexpr std::string_view kTeleportDisplayName = "Teleport";
+/** Namespaced stable ID for the sword-skate page. */
+constexpr std::string_view kSwordSkateStableId = "client.sword_skate";
+/** Short menu label for the sword-skate page. */
+constexpr std::string_view kSwordSkateDisplayName = "Sword Skate";
 
 core::ui::modules::registry::PageRegistration g_teleportPage;
+core::ui::modules::registry::PageRegistration g_swordSkatePage;
 
 } // namespace
 
 /** @return True when the Client module owns its Core UI registry slot. */
 bool initialize() noexcept {
-    return g_teleportPage.acquire(
+    const bool teleportPage = g_teleportPage.acquire(
         core::ui::modules::Owner::client, kTeleportStableId, kTeleportDisplayName, &teleport::draw);
+    const bool swordSkatePage = g_swordSkatePage.acquire(core::ui::modules::Owner::client,
+                                                         kSwordSkateStableId,
+                                                         kSwordSkateDisplayName,
+                                                         &sword_skate::draw);
+    return teleportPage && swordSkatePage;
 }
 
 /** Removes the Client module from the Core UI registry. */
 void shutdown() noexcept {
     g_teleportPage.release();
+    g_swordSkatePage.release();
 }
 
 } // namespace sunrise::client::ui::runtime

+ 134 - 0
Sunrise/src/client/ui/sword_skate/sword_skate_panel.cpp

@@ -0,0 +1,134 @@
+/**
+ * The sword-skate module's interface. Every control writes the configuration straight to disk, so
+ * a change made here survives the next launch without a settings edit.
+ */
+
+#include "sword_skate_panel.h"
+
+#include <Windows.h>
+
+#include <array>
+#include <cstdio>
+#include <imgui.h>
+
+#include "../../../core/ui/components/toggle/ui_toggle_component.h"
+#include "../../sword_skate/sword_skate_settings_store.h"
+
+namespace sunrise::client::ui::sword_skate {
+namespace {
+
+/** Lowest and highest virtual keys the picker scans. Zero is not a key. */
+constexpr int kFirstVirtualKey = 1;
+constexpr int kLastVirtualKey = 254;
+/** Mouse buttons are skipped so a click on the picker cannot bind itself. */
+constexpr int kLastMouseKey = 6;
+/** Longest key name Windows returns, plus the null. */
+constexpr std::size_t kKeyNameCapacity = 64;
+
+bool g_capturing{};
+
+/**
+ * Names one virtual key for display.
+ * @param virtualKey Key to name, or zero for no binding.
+ * @param output Receives the name.
+ */
+void key_name(std::uint32_t virtualKey, std::array<char, kKeyNameCapacity>& output) noexcept {
+    if (virtualKey == client::sword_skate::kNoKey) {
+        (void)std::snprintf(output.data(), output.size(), "None");
+        return;
+    }
+    const UINT scanCode = MapVirtualKeyW(virtualKey, MAPVK_VK_TO_VSC);
+    std::array<wchar_t, kKeyNameCapacity> wide{};
+    const int written = scanCode != 0 ? GetKeyNameTextW(static_cast<LONG>(scanCode << 16),
+                                                        wide.data(),
+                                                        static_cast<int>(wide.size()))
+                                      : 0;
+    if (written <= 0
+        || WideCharToMultiByte(CP_UTF8,
+                               0,
+                               wide.data(),
+                               written,
+                               output.data(),
+                               static_cast<int>(output.size() - 1),
+                               nullptr,
+                               nullptr)
+               <= 0) {
+        (void)std::snprintf(
+            output.data(), output.size(), "Key 0x%02X", static_cast<unsigned>(virtualKey));
+    }
+}
+
+/**
+ * Takes the first key held while the picker is armed.
+ * @param picked Receives the key, or zero when Escape clears the binding.
+ * @return True when this frame ended the capture.
+ */
+[[nodiscard]] bool capture_key(std::uint32_t& picked) noexcept {
+    if ((GetAsyncKeyState(VK_ESCAPE) & 0x8000) != 0) {
+        picked = client::sword_skate::kNoKey;
+        return true;
+    }
+    for (int key = kFirstVirtualKey; key <= kLastVirtualKey; ++key) {
+        if (key <= kLastMouseKey) {
+            continue;
+        }
+        if ((GetAsyncKeyState(key) & 0x8000) != 0) {
+            picked = static_cast<std::uint32_t>(key);
+            return true;
+        }
+    }
+    return false;
+}
+
+} // namespace
+
+/** Draws the sword-skate module inside the active Core UI frame. */
+void draw() noexcept {
+    client::sword_skate::Settings settings = client::sword_skate::get();
+    bool changed = false;
+
+    ImGui::TextUnformatted("Sword Skate");
+    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", settings.enabled) || changed;
+
+    ImGui::Spacing();
+    // One label column and one control column, so the key button spans both edges.
+    const float labelWidth =
+        ImGui::CalcTextSize("Jump key").x + ImGui::GetStyle().ItemSpacing.x * 2;
+    const float controlWidth = ImGui::GetContentRegionAvail().x - labelWidth;
+
+    ImGui::AlignTextToFramePadding();
+    ImGui::TextUnformatted("Jump key");
+    ImGui::SameLine(labelWidth);
+    if (g_capturing) {
+        if (ImGui::Button("...", ImVec2(controlWidth, 0.0F))) {
+            g_capturing = false;
+        }
+        std::uint32_t picked = client::sword_skate::kNoKey;
+        if (capture_key(picked)) {
+            settings.jumpKey = picked;
+            g_capturing = false;
+            changed = true;
+        }
+    } else {
+        std::array<char, kKeyNameCapacity> name{};
+        key_name(settings.jumpKey, name);
+        if (ImGui::Button(name.data(), ImVec2(controlWidth, 0.0F))) {
+            g_capturing = true;
+        }
+    }
+    ImGui::TextWrapped("Must match the key the game jumps on. Nothing happens on any other key.");
+
+    if (changed && !client::sword_skate::publish(settings)) {
+        ImGui::Spacing();
+        ImGui::TextUnformatted("value out of range, not saved");
+    }
+}
+
+} // namespace sunrise::client::ui::sword_skate

+ 8 - 0
Sunrise/src/client/ui/sword_skate/sword_skate_panel.h

@@ -0,0 +1,8 @@
+#pragma once
+
+namespace sunrise::client::ui::sword_skate {
+
+/** Draws the sword-skate module inside the active Core UI frame. */
+void draw() noexcept;
+
+} // namespace sunrise::client::ui::sword_skate