From 149cfef60891f38e1c04e52df0c2f240a6ec53f4 Mon Sep 17 00:00:00 2001 From: Cristian Boehm <75502884+GalaxisBeast@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:37:38 -0400 Subject: [PATCH] keyboard and mouse support, rebinding overhaul, analog triggers to digital inputs (#162) * add keyboard support, analog triggers to digital input, and rebinding overhaul * Update README.md * Update README.md readme typo * implemented code rabbits suggestions - Preserved NSO GameCube analog triggers. - Made modal closure and Escape cancel every rebind kind. - Deduced the native button array size; already existed. - Centralized axis/sign decoding. - Kept threshold updates live, saving only when editing ends. * add dimming when in settings and add clear mapping button * Update settings_overlay.cpp --------- Co-authored-by: patchzyy <64382339+patchzyy@users.noreply.github.com> --- README.md | 14 + aurora-main/include/dolphin/pad.h | 16 + aurora-main/lib/dolphin/pad/pad.cpp | 49 ++- runtime/include/controller_button_names.h | 23 +- runtime/src/input_bindings.cpp | 7 +- runtime/src/settings_overlay.cpp | 349 +++++++++++++++++++--- 6 files changed, 399 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index e9629d1..c5088c0 100644 --- a/README.md +++ b/README.md @@ -55,10 +55,24 @@ Press **F10** while the game window has focus: Everything you change is saved to `Config.toml` on the spot and restored next launch. **Real controller support.** + Controllers are fed to the game as a GameCube controller. Mappings are positional (`south`, `east`, `west`, `north`) rather than Xbox-labelled, so the same config makes sense on Xbox, PlayStation, Nintendo and generic SDL pads alike, and extra inputs like paddles, touchpads and share buttons show up when the hardware reports them. +Both button-binding slots also accept SDL triggers and stick directions. Selecting an analog +input shows a threshold slider beneath it (1–100%, default 50%); reaching that amount of travel +holds the chosen digital button. Each binding's threshold is saved independently in `Config.toml` +(for example, `a = "right_trigger@35,south"`). + +**Keyboard and Mouse support.** + +Keyboard and mouse are also available through **F10 > Controller settings > Keyboard and mouse** +for each port. Enabling this replaces that port's gamepad input. The default preset uses WASD +for the main stick, left mouse for A (accelerate), Space for B (brake), right mouse for R +(drift), middle mouse for Z (item), arrow keys for the D-pad (tricks), and Enter for Start. +Keys and mouse buttons can be remapped, including both sticks and triggers; mouse movement +is not used. These settings are saved in `keyboard_bindings.dat` and restored next launch. **Dolphin-compatible input expressions.** Each GameCube control can carry an expression in Dolphin's input syntax, with the same operators diff --git a/aurora-main/include/dolphin/pad.h b/aurora-main/include/dolphin/pad.h index 6f821c3..69dfcd3 100644 --- a/aurora-main/include/dolphin/pad.h +++ b/aurora-main/include/dolphin/pad.h @@ -171,6 +171,22 @@ typedef struct PADButtonMapping { PADButton padButton; } PADButtonMapping; +// Explicitly disabled, unlike INVALID which permits default L/R trigger input. +#define PAD_NATIVE_BUTTON_DISABLED 0xfffffffeu + +// Axis-to-button bindings share the persisted nativeButton field without +// changing the binary layout of existing controller mapping files. +constexpr u32 PADEncodeAxisButton(u32 axis, bool negative, u32 threshold = 50) { + return 0x10000u | axis | (negative ? 0x80u : 0u) | (threshold << 8); +} +constexpr bool PADIsAxisButton(u32 binding) { return (binding & 0xffff0000u) == 0x10000u; } +constexpr u32 PADAxisButtonThreshold(u32 binding) { return (binding >> 8) & 0xffu; } +constexpr u32 PADAxisButtonAxis(u32 binding) { return binding & 0x7fu; } +constexpr bool PADAxisButtonNegative(u32 binding) { return (binding & 0x80u) != 0; } +constexpr u32 PADAxisButtonIdentity(u32 binding) { + return PADIsAxisButton(binding) ? (binding & ~0xff00u) : binding; +} + typedef struct PADAxisMapping { PADSignedNativeAxis nativeAxis; s32 nativeButton; diff --git a/aurora-main/lib/dolphin/pad/pad.cpp b/aurora-main/lib/dolphin/pad/pad.cpp index bb82c24..75ef372 100644 --- a/aurora-main/lib/dolphin/pad/pad.cpp +++ b/aurora-main/lib/dolphin/pad/pad.cpp @@ -319,6 +319,18 @@ std::array g_suppressLeftTrigger{}; std::array g_suppressRightTrigger{}; bool is_mouse_scancode(const s32 scancode) { return scancode < PAD_KEY_INVALID; } +bool is_native_binding_pressed(SDL_Gamepad* gamepad, u32 binding) { + if (PADIsAxisButton(binding)) { + const u32 axis = PADAxisButtonAxis(binding); + const u32 threshold = PADAxisButtonThreshold(binding); + if (axis >= SDL_GAMEPAD_AXIS_COUNT || threshold < 1 || threshold > 100) return false; + int value = SDL_GetGamepadAxis(gamepad, static_cast(axis)); + if (PADAxisButtonNegative(binding)) value = -value; + return value > 0 && value * 100 >= static_cast(threshold) * 32767; + } + return binding < SDL_GAMEPAD_BUTTON_COUNT && + SDL_GetGamepadButton(gamepad, static_cast(binding)); +} bool is_mouse_button_pressed(const s32 scancode) { const int32_t buttonNum = -(scancode + 1); if (buttonNum < 1 || buttonNum > 5) { @@ -724,10 +736,10 @@ u32 PADRead(PADStatus* status) { } status[i].err = PAD_ERR_NONE; - if (g_keyboardBindings[i].m_mappingsSet) { + if (g_keyboardBindings[i].m_mappingsSet && SDL_GetKeyboardFocus() != nullptr) { std::ranges::for_each( - g_keyboardBindings[i].m_buttonMapping, [&kbState, &i, &status](const PADKeyButtonBinding& mapping) { - if (mapping.scancode > PAD_KEY_INVALID && kbState[mapping.scancode]) { + g_keyboardBindings[i].m_buttonMapping, [&kbState, &numKeys, &i, &status](const PADKeyButtonBinding& mapping) { + if (mapping.scancode > PAD_KEY_INVALID && mapping.scancode < numKeys && kbState[mapping.scancode]) { status[i].button |= mapping.padButton; } else if (is_mouse_scancode(mapping.scancode) && is_mouse_button_pressed(mapping.scancode)) { status[i].button |= mapping.padButton; @@ -788,7 +800,7 @@ u32 PADRead(PADStatus* status) { status[i].triggerRight = static_cast(std::min(static_cast(status[i].triggerRight) + tr, 255)); } - if (controller) { + if (controller && !g_keyboardBindings[i].m_mappingsSet) { EnsureMappingLoaded(controller); // Wii U Pro Controller raw D-pad fallback. SDL's HIDAPI Wii driver posts @@ -835,7 +847,7 @@ u32 PADRead(PADStatus* status) { bool rightTriggerSet = false; std::ranges::for_each(controller->m_buttonMapping, [&controller, &i, &status, &leftTriggerSet, &rightTriggerSet](const auto& mapping) { - if (SDL_GetGamepadButton(controller->m_controller, static_cast(mapping.nativeButton))) { + if (is_native_binding_pressed(controller->m_controller, mapping.nativeButton)) { status[i].button |= mapping.padButton; } @@ -852,7 +864,7 @@ u32 PADRead(PADStatus* status) { if (mapping.nativeButton == PAD_NATIVE_BUTTON_INVALID) { return; } - if (SDL_GetGamepadButton(controller->m_controller, static_cast(mapping.nativeButton))) { + if (is_native_binding_pressed(controller->m_controller, mapping.nativeButton)) { status[i].button |= mapping.padButton; } @@ -946,6 +958,17 @@ u32 PADRead(PADStatus* status) { Sint16 tl = std::max(static_cast(0), _get_axis_value(controller, PAD_AXIS_TRIGGER_L)); Sint16 tr = std::max(static_cast(0), _get_axis_value(controller, PAD_AXIS_TRIGGER_R)); + // Games can read either the digital L/R bits or their analog pressure. + // An explicit button binding must drive both, otherwise the original + // L2/R2 axis still activates L/R even when it was rebound to L1/R1. + // Real GC pads retain independent analog travel and end-stop clicks. + if (!(controller->m_isGameCube || + (SDL_GetGamepadType(controller->m_controller) == SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO && + controller->m_pid == 0x2073))) { + if (leftTriggerSet) tl = (status[i].button & PAD_TRIGGER_L) != 0 ? 32767 : 0; + if (rightTriggerSet) tr = (status[i].button & PAD_TRIGGER_R) != 0 ? 32767 : 0; + } + if (controller->m_deadZones.emulateTriggers) { if (!leftTriggerSet && tl > controller->m_deadZones.leftTriggerActivationZone) { status[i].button |= PAD_TRIGGER_L; @@ -990,12 +1013,13 @@ void PADControlMotor(const u32 chan, const u32 cmd) { } if (controller->m_isGameCube) { - if (cmd == PAD_MOTOR_STOP) { - aurora::input::controller_rumble(instance, 0, 1, 0); + if (cmd == PAD_MOTOR_STOP || cmd == PAD_MOTOR_STOP_HARD) { + // Use an unambiguous motor-off request. The (0, 1) coast encoding + // requires SDL's GameCube brake mode; other backends or an overridden + // hint interpret it as rumble and can leave the controller vibrating. + aurora::input::controller_rumble(instance, 0, 0, 0); } else if (cmd == PAD_MOTOR_RUMBLE) { aurora::input::controller_rumble(instance, 1, 1, 0); - } else if (cmd == PAD_MOTOR_STOP_HARD) { - aurora::input::controller_rumble(instance, 0, 0, 0); } } else { if (cmd == PAD_MOTOR_STOP) { @@ -1278,6 +1302,11 @@ BOOL PADSetKeyButtonBindings(const u32 port, PADKeyButtonBinding bindings[PAD_BU } PADKeyButtonBinding* PADGetKeyButtonBindings(const u32 port, u32* buttonCount) { + PADInit(); + if (!g_keyboardBindingsLoaded) { + g_keyboardBindingsLoaded = true; + load_keyboard_bindings(); + } if (port >= PAD_MAX_CONTROLLERS || !g_keyboardBindings[port].m_mappingsSet) { return nullptr; } diff --git a/runtime/include/controller_button_names.h b/runtime/include/controller_button_names.h index d0d2b81..ab61c03 100644 --- a/runtime/include/controller_button_names.h +++ b/runtime/include/controller_button_names.h @@ -48,8 +48,19 @@ struct NativeButtonItem { uint32_t nativeButton; }; -inline constexpr std::array kNativeButtons = {{ - {"unmapped", "Unmapped / analog trigger", PAD_NATIVE_BUTTON_INVALID}, +inline constexpr auto kNativeButtons = std::to_array({ + {"disabled", "Unmapped", PAD_NATIVE_BUTTON_DISABLED}, + {"left_trigger", "Left trigger (LT / L2)", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_LEFT_TRIGGER, false)}, + {"right_trigger", "Right trigger (RT / R2)", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_RIGHT_TRIGGER, false)}, + {"left_stick_left", "Left stick left", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_LEFTX, true)}, + {"left_stick_right", "Left stick right", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_LEFTX, false)}, + {"left_stick_up", "Left stick up", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_LEFTY, true)}, + {"left_stick_down", "Left stick down", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_LEFTY, false)}, + {"right_stick_left", "Right stick left", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_RIGHTX, true)}, + {"right_stick_right", "Right stick right", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_RIGHTX, false)}, + {"right_stick_up", "Right stick up", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_RIGHTY, true)}, + {"right_stick_down", "Right stick down", PADEncodeAxisButton(SDL_GAMEPAD_AXIS_RIGHTY, false)}, + {"unmapped", "Default", PAD_NATIVE_BUTTON_INVALID}, {"south", "South (A / Cross)", SDL_GAMEPAD_BUTTON_SOUTH}, {"east", "East (B / Circle)", SDL_GAMEPAD_BUTTON_EAST}, {"west", "West (X / Square)", SDL_GAMEPAD_BUTTON_WEST}, @@ -76,7 +87,7 @@ inline constexpr std::array kNat {"misc4", "Misc 4 / GC R click", SDL_GAMEPAD_BUTTON_MISC4}, {"misc5", "Misc 5", SDL_GAMEPAD_BUTTON_MISC5}, {"misc6", "Misc 6", SDL_GAMEPAD_BUTTON_MISC6}, -}}; +}); inline std::string TrimToken(std::string_view token) { const size_t begin = token.find_first_not_of(" \t"); @@ -88,7 +99,7 @@ inline std::string TrimToken(std::string_view token) { } inline const NativeButtonItem* FindNativeButton(std::string_view configName) { - const std::string name = TrimToken(configName); + const std::string name = TrimToken(configName.substr(0, configName.find('@'))); const auto it = std::find_if(kNativeButtons.begin(), kNativeButtons.end(), [&](const NativeButtonItem& item) { return name == item.configName; }); return it == kNativeButtons.end() ? nullptr : &*it; @@ -97,8 +108,8 @@ inline const NativeButtonItem* FindNativeButton(std::string_view configName) { // Falls back to the "unmapped" entry so callers always have a label to draw. inline const NativeButtonItem& NativeButtonForValue(uint32_t nativeButton) { const auto it = std::find_if(kNativeButtons.begin(), kNativeButtons.end(), - [&](const NativeButtonItem& item) { return nativeButton == item.nativeButton; }); - return it == kNativeButtons.end() ? kNativeButtons.front() : *it; + [&](const NativeButtonItem& item) { return PADAxisButtonIdentity(nativeButton) == PADAxisButtonIdentity(item.nativeButton); }); + return it == kNativeButtons.end() ? *FindNativeButton("unmapped") : *it; } inline const GameCubeButtonItem* FindGameCubeButton(std::string_view configKey) { diff --git a/runtime/src/input_bindings.cpp b/runtime/src/input_bindings.cpp index 6ffa984..03d2456 100644 --- a/runtime/src/input_bindings.cpp +++ b/runtime/src/input_bindings.cpp @@ -94,7 +94,12 @@ double ReadInput(SDL_Gamepad* gamepad, const std::string& name) { // Fall back to this project's own positional names, so a binding written // here does not have to use Dolphin vocabulary. if (const auto* native = ControllerNames::FindNativeButton(name)) { - if (native->nativeButton != PAD_NATIVE_BUTTON_INVALID) { + if (PADIsAxisButton(native->nativeButton)) { + const auto axis = static_cast(PADAxisButtonAxis(native->nativeButton)); + const double sign = PADAxisButtonNegative(native->nativeButton) ? -1.0 : 1.0; + return std::clamp(SDL_GetGamepadAxis(gamepad, axis) / 32767.0 * sign, 0.0, 1.0); + } + if (native->nativeButton < SDL_GAMEPAD_BUTTON_COUNT) { return SDL_GetGamepadButton(gamepad, static_cast(native->nativeButton)) ? 1.0 : 0.0; } diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index 8856ba5..aad825d 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -186,6 +187,18 @@ void LimitResolutionForFrameRate() { using ControllerNames::FindNativeButton; +uint32_t ConfiguredNativeButton(const NativeButtonItem& item, const std::string& token) { + if (!PADIsAxisButton(item.nativeButton)) return item.nativeButton; + const size_t separator = token.find('@'); + if (separator == std::string::npos) return item.nativeButton; + uint32_t threshold = 0; + const char* end = token.data() + token.size(); + const auto parsed = std::from_chars(token.data() + separator + 1, end, threshold); + if (parsed.ec != std::errc{} || parsed.ptr != end || threshold < 1 || threshold > 100) + return item.nativeButton; + return PADAxisButtonIdentity(item.nativeButton) | (threshold << 8); +} + struct ControllerBindingPair { std::string primary; std::string secondary; @@ -204,6 +217,13 @@ ControllerBindingPair SplitControllerBinding(const std::string& value) { using ControllerNames::NativeButtonForValue; +std::string NativeBindingConfig(uint32_t binding) { + std::string value = NativeButtonForValue(binding).configName; + if (PADIsAxisButton(binding)) value += '@' + std::to_string(PADAxisButtonThreshold(binding)); + return value; +} + + void SetTopBarVisible(bool visible) { if (g_topBarVisible == visible) { return; @@ -242,7 +262,7 @@ void ApplyConfiguredMappings() { } const ControllerBindingPair binding = SplitControllerBinding(*configured); if (const NativeButtonItem* native = FindNativeButton(binding.primary)) { - PADSetButtonMapping(port, PADButtonMapping{native->nativeButton, kControllerButtons[i].padButton}); + PADSetButtonMapping(port, PADButtonMapping{ConfiguredNativeButton(*native, binding.primary), kControllerButtons[i].padButton}); } else { RT_LOG(RT_TAG_CONFIG) << "Unknown controller." << kControllerButtons[i].configKey << " button '" << binding.primary << "'" << std::endl; @@ -250,7 +270,7 @@ void ApplyConfiguredMappings() { uint32_t altNative = PAD_NATIVE_BUTTON_INVALID; if (!binding.secondary.empty()) { if (const NativeButtonItem* native = FindNativeButton(binding.secondary)) { - altNative = native->nativeButton; + altNative = ConfiguredNativeButton(*native, binding.secondary); } else { RT_LOG(RT_TAG_CONFIG) << "Unknown controller." << kControllerButtons[i].configKey << " secondary button '" << binding.secondary << "'" << std::endl; @@ -395,6 +415,219 @@ void DrawWiiRemoteSettings(uint32_t selectedGamePort) { ImGui::EndMenu(); } +const char* KeyBindingName(int scancode) { + switch (scancode) { + case PAD_KEY_MOUSE_LEFT: return "Mouse left"; + case PAD_KEY_MOUSE_RIGHT: return "Mouse right"; + case PAD_KEY_MOUSE_MIDDLE: return "Mouse middle"; + case PAD_KEY_MOUSE_X1: return "Mouse side 1"; + case PAD_KEY_MOUSE_X2: return "Mouse side 2"; + case PAD_KEY_INVALID: return "Unmapped"; + default: + return scancode >= 0 && scancode < SDL_SCANCODE_COUNT + ? SDL_GetScancodeName(static_cast(scancode)) : "Unknown"; + } +} + +enum class RebindKind { KeyboardButton, KeyboardAxis, Controller }; +struct RebindState { + bool active = false; + bool openPopup = false; + RebindKind kind{}; + uint32_t port = 0; + uint16_t target = 0; + bool secondary = false; + SDL_JoystickID instance = 0; + Clock::time_point deadline{}; + std::string label; + std::array keys{}; + uint32_t mouse = 0; + std::array buttons{}; + std::array axesReady{}; +} g_rebind; + +void BeginRebind(RebindKind kind, uint16_t target, const char* label, bool secondary = false) { + g_rebind = {}; + g_rebind.active = true; + g_rebind.openPopup = true; + g_rebind.kind = kind; + g_rebind.port = static_cast(g_controllerPort); + g_rebind.target = target; + g_rebind.secondary = secondary; + g_rebind.label = label; + g_rebind.deadline = Clock::now() + std::chrono::seconds(10); + int count = 0; + const bool* keys = SDL_GetKeyboardState(&count); + std::copy_n(keys, std::min(count, static_cast(g_rebind.keys.size())), g_rebind.keys.begin()); + g_rebind.mouse = SDL_GetMouseState(nullptr, nullptr); + const int index = PADGetIndexForPort(g_rebind.port); + if (kind == RebindKind::Controller && index >= 0) { + if (auto* pad = PADGetSDLGamepadForIndex(index)) { + g_rebind.instance = SDL_GetGamepadID(pad); + for (int i = 0; i < SDL_GAMEPAD_BUTTON_COUNT; ++i) + g_rebind.buttons[i] = SDL_GetGamepadButton(pad, static_cast(i)); + for (int i = 0; i < SDL_GAMEPAD_AXIS_COUNT; ++i) + g_rebind.axesReady[i] = std::abs(static_cast(SDL_GetGamepadAxis(pad, static_cast(i)))) < 8000; + } + } +} + +void CompleteRebind(uint32_t value) { + const auto& capture = g_rebind; + if (capture.kind == RebindKind::Controller) { + const int index = PADGetIndexForPort(capture.port); + auto* pad = index >= 0 ? PADGetSDLGamepadForIndex(index) : nullptr; + if (pad == nullptr || SDL_GetGamepadID(pad) != capture.instance) { + g_rebind.active = false; + return; + } + if (capture.secondary) PADSetAltButtonMapping(capture.port, {value, capture.target}); + else PADSetButtonMapping(capture.port, {value, capture.target}); + uint32_t count = 0, altCount = 0; + auto* primary = PADGetButtonMappings(capture.port, &count); + auto* alternate = PADGetAltButtonMappings(capture.port, &altCount); + uint32_t primaryValue = PAD_NATIVE_BUTTON_INVALID, alternateValue = PAD_NATIVE_BUTTON_INVALID; + for (uint32_t i = 0; i < count; ++i) + if (primary[i].padButton == capture.target) primaryValue = primary[i].nativeButton; + for (uint32_t i = 0; i < altCount; ++i) + if (alternate[i].padButton == capture.target) alternateValue = alternate[i].nativeButton; + std::string config = NativeBindingConfig(primaryValue); + if (alternateValue != PAD_NATIVE_BUTTON_INVALID) config += ',' + NativeBindingConfig(alternateValue); + for (size_t i = 0; i < kControllerButtons.size(); ++i) + if (kControllerButtons[i].padButton == capture.target) RuntimeConfigFile::SetControllerButton(i, config); + } else if (capture.kind == RebindKind::KeyboardButton) { + PADSetKeyButtonBinding(capture.port, {static_cast(value), capture.target}); + } else { + PADSetKeyAxisBinding(capture.port, {static_cast(value), capture.target, 1}); + } + PADSerializeMappings(); + g_rebind.active = false; +} + +void DrawRebindPrompt() { + if (g_rebind.openPopup) { + ImGui::OpenPopup("Rebind input"); + g_rebind.openPopup = false; + } + if (!ImGui::BeginPopupModal("Rebind input", &g_rebind.active, ImGuiWindowFlags_AlwaysAutoResize)) { + g_rebind.active = false; + return; + } + if (g_rebind.active) { + ImGui::Text("Rebind: %s", g_rebind.label.c_str()); + ImGui::TextUnformatted(g_rebind.kind == RebindKind::Controller + ? "Press a controller button, pull a trigger, or move a stick." + : "Press a keyboard key or click a mouse button."); + ImGui::TextUnformatted("Release any held input first. Backspace or Delete clears the mapping."); + ImGui::TextUnformatted("Escape can be bound. F10 is reserved for settings."); + const float remaining = std::chrono::duration(g_rebind.deadline - Clock::now()).count(); + ImGui::Text("Unmapped in %d seconds", std::max(0, static_cast(std::ceil(remaining)))); + const bool clear = ImGui::Button("Clear mapping"); + ImGui::SameLine(); + if (ImGui::Button("Cancel")) g_rebind.active = false; + // UI clicks must not become mouse bindings (buttons activate on release). + const bool overControl = ImGui::IsAnyItemHovered(); + if (g_rebind.active && (clear || remaining <= 0.0f)) { + CompleteRebind(g_rebind.kind == RebindKind::Controller ? PAD_NATIVE_BUTTON_DISABLED + : static_cast(PAD_KEY_INVALID)); + } else if (g_rebind.active && SDL_GetKeyboardFocus() != nullptr && g_rebind.kind != RebindKind::Controller) { + int count = 0; + const bool* keys = SDL_GetKeyboardState(&count); + for (int i = 1; i < std::min(count, static_cast(SDL_SCANCODE_COUNT)) && g_rebind.active; ++i) { + if (keys[i] && !g_rebind.keys[i] && i != SDL_SCANCODE_F10) CompleteRebind(i); + g_rebind.keys[i] = keys[i]; + } + const uint32_t mouse = SDL_GetMouseState(nullptr, nullptr); + for (int i = 1; i <= 5 && g_rebind.active; ++i) + if (!overControl && (mouse & ~g_rebind.mouse & (1u << (i - 1))) != 0) CompleteRebind(static_cast(-i - 1)); + g_rebind.mouse = mouse; + } else if (g_rebind.active && SDL_GetKeyboardFocus() != nullptr && g_rebind.kind == RebindKind::Controller) { + auto* pad = SDL_GetGamepadFromID(g_rebind.instance); + if (pad != nullptr) { + for (int i = 0; i < SDL_GAMEPAD_BUTTON_COUNT && g_rebind.active; ++i) { + const bool pressed = SDL_GetGamepadButton(pad, static_cast(i)); + if (pressed && !g_rebind.buttons[i]) CompleteRebind(i); + g_rebind.buttons[i] = pressed; + } + for (int i = 0; i < SDL_GAMEPAD_AXIS_COUNT && g_rebind.active; ++i) { + const int value = SDL_GetGamepadAxis(pad, static_cast(i)); + if (std::abs(value) < 8000) g_rebind.axesReady[i] = true; + if (g_rebind.axesReady[i] && std::abs(value) >= 16384) + CompleteRebind(PADEncodeAxisButton(i, value < 0)); + } + } + } + } + if (!g_rebind.active) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); +} + +void DrawKeyBinding(const char* label, int scancode, RebindKind kind, uint16_t target) { + const std::string caption = std::string(KeyBindingName(scancode)) + "##binding"; + if (ImGui::Button(caption.c_str(), ImVec2(220.0f, 0.0f))) BeginRebind(kind, target, label); + ImGui::SameLine(); + ImGui::TextUnformatted(label); + +} + +bool DrawKeyboardSettings(uint32_t port) { + uint32_t count = 0; + auto* buttons = PADGetKeyButtonBindings(port, &count); + bool enabled = buttons != nullptr; + bool usePreset = false; + if (ImGui::Checkbox("Keyboard and mouse", &enabled)) { + PADSetKeyboardActive(port, enabled); + PADSerializeMappings(); + buttons = PADGetKeyButtonBindings(port, &count); + usePreset = enabled && std::all_of(buttons, buttons + count, [](const auto& binding) { + return binding.scancode == PAD_KEY_INVALID; + }); + } + if (!enabled) return false; + ImGui::TextDisabled("Replaces the gamepad on this port. F10 opens settings."); + if (ImGui::Button("Use WASD + mouse preset") || usePreset) { + const std::array keys = { + PAD_KEY_MOUSE_LEFT, SDL_SCANCODE_SPACE, SDL_SCANCODE_E, SDL_SCANCODE_Q, + SDL_SCANCODE_RETURN, PAD_KEY_MOUSE_MIDDLE, SDL_SCANCODE_LSHIFT, PAD_KEY_MOUSE_RIGHT, + SDL_SCANCODE_UP, SDL_SCANCODE_DOWN, SDL_SCANCODE_LEFT, SDL_SCANCODE_RIGHT, + }; + for (size_t i = 0; i < keys.size(); ++i) + PADSetKeyButtonBinding(port, {keys[i], kControllerButtons[i].padButton}); + const std::array axes = { + SDL_SCANCODE_D, SDL_SCANCODE_A, SDL_SCANCODE_W, SDL_SCANCODE_S, + SDL_SCANCODE_L, SDL_SCANCODE_J, SDL_SCANCODE_I, SDL_SCANCODE_K, + SDL_SCANCODE_LSHIFT, PAD_KEY_MOUSE_RIGHT, + }; + uint32_t axisCount = 0; + auto* mappings = PADGetKeyAxisBindings(port, &axisCount); + for (uint32_t i = 0; i < axisCount; ++i) + PADSetKeyAxisBinding(port, {axes[i], mappings[i].padAxis, 1}); + PADSerializeMappings(); + } + ImGui::SeparatorText("Button mapping"); + for (uint32_t i = 0; i < count; ++i) { + int key = buttons[i].scancode; + ImGui::PushID(static_cast(i)); + ImGui::SetNextItemWidth(220.0f); + DrawKeyBinding(PADGetButtonName(buttons[i].padButton), key, RebindKind::KeyboardButton, buttons[i].padButton); + ImGui::PopID(); + } + ImGui::SeparatorText("Stick and trigger mapping"); + uint32_t axisCount = 0; + auto* axes = PADGetKeyAxisBindings(port, &axisCount); + for (uint32_t i = 0; i < axisCount; ++i) { + int key = axes[i].scancode; + ImGui::PushID(static_cast(count + i)); + const char* direction = PADGetAxisDirectionLabel(axes[i].padAxis); + const std::string label = std::string(PADGetAxisName(axes[i].padAxis)) + " " + + (direction != nullptr ? direction : ""); + ImGui::SetNextItemWidth(220.0f); + DrawKeyBinding(label.c_str(), key, RebindKind::KeyboardAxis, axes[i].padAxis); + ImGui::PopID(); + } + return true; +} + // Controller settings menu: port selection, controller assignment and button mapping. int ExpressionResizeCallback(ImGuiInputTextCallbackData* data) { if (data->EventFlag == ImGuiInputTextFlags_CallbackResize) { @@ -501,6 +734,10 @@ void DrawControllerSettings() { ImGui::Separator(); const uint32_t selectedGamePort = static_cast(g_controllerPort); + if (DrawKeyboardSettings(selectedGamePort)) { + return; + } + ImGui::Separator(); const char* currentName = PADGetName(selectedGamePort); ImGui::Text("Assigned: %s", currentName != nullptr ? currentName : "None"); if (ImGui::MenuItem("Unassign controller")) { @@ -542,10 +779,10 @@ void DrawControllerSettings() { PADGetAltButtonMappings(static_cast(g_controllerPort), &altMappingCount); const auto writeBinding = [](size_t index, uint32_t primaryNative, uint32_t altNative) { - std::string value = NativeButtonForValue(primaryNative).configName; + std::string value = NativeBindingConfig(primaryNative); if (altNative != PAD_NATIVE_BUTTON_INVALID) { value += ','; - value += NativeButtonForValue(altNative).configName; + value += NativeBindingConfig(altNative); } RuntimeConfigFile::SetControllerButton(index, value); }; @@ -603,6 +840,11 @@ void DrawControllerSettings() { } ImGui::SeparatorText("Button mapping"); + ImGui::TextDisabled("LT / L2 = left trigger. RT / R2 = right trigger."); + ImGui::TextDisabled("LB / L1 = left shoulder. RB / R1 = right shoulder."); + ImGui::TextDisabled("Click a binding, then press an input. No input for 10 seconds clears it."); + const float bindingWidth = ImGui::CalcTextSize("Right shoulder (RB / R1)").x + + ImGui::GetFrameHeight() + ImGui::GetStyle().FramePadding.x * 2.0f; for (size_t i = 0; i < kControllerButtons.size(); ++i) { auto mappingIt = std::find_if(mappings, mappings + mappingCount, [&](const PADButtonMapping& mapping) { return mapping.padButton == kControllerButtons[i].padButton; @@ -622,30 +864,40 @@ void DrawControllerSettings() { const NativeButtonItem& current = NativeButtonForValue(mappingIt->nativeButton); ImGui::PushID(static_cast(i)); - ImGui::SetNextItemWidth(190.0f); - if (ImGui::BeginCombo("##primary", current.label)) { - for (const auto& candidate : kNativeButtons) { - const bool selected = candidate.nativeButton == mappingIt->nativeButton; - if (ImGui::Selectable(candidate.label, selected)) { - const uint32_t port = static_cast(g_controllerPort); - PADSetButtonMapping(port, PADButtonMapping{candidate.nativeButton, kControllerButtons[i].padButton}); - writeBinding(i, candidate.nativeButton, - altIt != nullptr ? altIt->nativeButton : PAD_NATIVE_BUTTON_INVALID); - PADSerializeMappings(); - mappings = PADGetButtonMappings(port, &mappingCount); - } - if (selected) { - ImGui::SetItemDefaultFocus(); - } + const auto drawThreshold = [&](PADButtonMapping* mapping, bool secondary) { + if (!PADIsAxisButton(mapping->nativeButton)) return; + int threshold = static_cast(PADAxisButtonThreshold(mapping->nativeButton)); + ImGui::SetNextItemWidth(bindingWidth); + if (ImGui::SliderInt(secondary ? "##altThreshold" : "##primaryThreshold", &threshold, + 1, 100, "Threshold: %d%%", ImGuiSliderFlags_AlwaysClamp)) { + const PADButtonMapping updated = { + PADAxisButtonIdentity(mapping->nativeButton) | (static_cast(threshold) << 8), + mapping->padButton, + }; + if (secondary) PADSetAltButtonMapping(selectedGamePort, updated); + else PADSetButtonMapping(selectedGamePort, updated); } - ImGui::EndCombo(); + if (ImGui::IsItemDeactivatedAfterEdit()) { + writeBinding(i, mappingIt->nativeButton, + altIt != nullptr ? altIt->nativeButton : PAD_NATIVE_BUTTON_INVALID); + PADSerializeMappings(); + } + }; + ImGui::BeginGroup(); + ImGui::SetNextItemWidth(bindingWidth); + const std::string primaryCaption = std::string(current.label) + "##primary"; + if (ImGui::Button(primaryCaption.c_str(), ImVec2(bindingWidth, 0.0f))) { + BeginRebind(RebindKind::Controller, kControllerButtons[i].padButton, kControllerButtons[i].label); } + drawThreshold(mappingIt, false); + ImGui::EndGroup(); if (altIt != nullptr) { const bool altBound = altIt->nativeButton != PAD_NATIVE_BUTTON_INVALID; if (!altBound && !altRowExpanded[i]) { ImGui::SameLine(); if (ImGui::SmallButton("+")) { altRowExpanded[i] = true; + BeginRebind(RebindKind::Controller, kControllerButtons[i].padButton, kControllerButtons[i].label, true); } if (ImGui::IsItemHovered()) { ImGui::SetTooltip("Add a second binding; pressing either one works"); @@ -654,27 +906,15 @@ void DrawControllerSettings() { ImGui::SameLine(); ImGui::TextUnformatted("or"); ImGui::SameLine(); + ImGui::BeginGroup(); const char* altLabel = altBound ? NativeButtonForValue(altIt->nativeButton).label : "None"; - ImGui::SetNextItemWidth(190.0f); - if (ImGui::BeginCombo("##alt", altLabel)) { - for (const auto& candidate : kNativeButtons) { - const bool isNone = candidate.nativeButton == PAD_NATIVE_BUTTON_INVALID; - const bool selected = candidate.nativeButton == altIt->nativeButton; - if (ImGui::Selectable(isNone ? "None" : candidate.label, selected)) { - const uint32_t port = static_cast(g_controllerPort); - PADSetAltButtonMapping( - port, PADButtonMapping{candidate.nativeButton, kControllerButtons[i].padButton}); - writeBinding(i, mappingIt->nativeButton, candidate.nativeButton); - if (isNone) { - altRowExpanded[i] = false; - } - } - if (selected) { - ImGui::SetItemDefaultFocus(); - } - } - ImGui::EndCombo(); + ImGui::SetNextItemWidth(bindingWidth); + const std::string altCaption = std::string(altLabel) + "##alt"; + if (ImGui::Button(altCaption.c_str(), ImVec2(bindingWidth, 0.0f))) { + BeginRebind(RebindKind::Controller, kControllerButtons[i].padButton, kControllerButtons[i].label, true); } + drawThreshold(altIt, true); + ImGui::EndGroup(); } } ImGui::SameLine(); @@ -936,10 +1176,27 @@ void DrawStartupScreen() { } void DrawTopBar() { - if (!g_topBarVisible || !ImGui::BeginMainMenuBar()) { + if (!g_topBarVisible) { return; } + const ImGuiViewport* viewport = ImGui::GetMainViewport(); + ImGui::GetBackgroundDrawList()->AddRectFilled(viewport->Pos, + ImVec2(viewport->Pos.x + viewport->Size.x, viewport->Pos.y + viewport->Size.y), + IM_COL32(0, 0, 0, 70)); + ImGui::SetNextWindowPos(ImVec2(viewport->Pos.x + viewport->Size.x * 0.5f, + viewport->Pos.y + viewport->Size.y - 24.0f), + ImGuiCond_Always, ImVec2(0.5f, 1.0f)); + ImGui::SetNextWindowBgAlpha(0.85f); + if (ImGui::Begin("Settings input hint", nullptr, + ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_AlwaysAutoResize | + ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoFocusOnAppearing)) { + ImGui::TextUnformatted("Settings open - game controls disabled. Press F10 to return to the game."); + } + ImGui::End(); + if (!ImGui::BeginMainMenuBar()) return; + ImGui::TextUnformatted("WiiCompiled"); ImGui::Separator(); const auto resolutionIt = std::find_if(kResolutions.begin(), kResolutions.end(), [](const ResolutionItem& item) { @@ -968,6 +1225,9 @@ void DrawTopBar() { if (ImGui::BeginMenu("Controller settings")) { DrawControllerSettings(); + // Nest capture under this menu so opening/closing the modal preserves + // the settings popup and its current port and scroll position. + DrawRebindPrompt(); ImGui::EndMenu(); } @@ -1074,7 +1334,12 @@ void HandleEvents(const AuroraEvent* events) noexcept { continue; } controller_mapping_wizard::HandleSdlEvent(ev->sdl); - if (IsToggleKey(ev->sdl, SDL_SCANCODE_F10)) { + if (g_rebind.active && (IsToggleKey(ev->sdl, SDL_SCANCODE_BACKSPACE) || + IsToggleKey(ev->sdl, SDL_SCANCODE_DELETE))) { + CompleteRebind(g_rebind.kind == RebindKind::Controller ? PAD_NATIVE_BUTTON_DISABLED + : static_cast(PAD_KEY_INVALID)); + } + if (!g_rebind.active && IsToggleKey(ev->sdl, SDL_SCANCODE_F10)) { SetTopBarVisible(!g_topBarVisible); } if (IsMouseActivity(ev->sdl)) { @@ -1102,7 +1367,7 @@ void Draw() noexcept { DrawTopBar(); controller_mapping_wizard::Draw(); // The wizard captures raw presses; keep them out of the game. - const bool inputBlocked = controller_mapping_wizard::IsActive(); + const bool inputBlocked = controller_mapping_wizard::IsActive() || g_rebind.active; PADBlockInput(inputBlocked); InputBindings::SetInputBlocked(inputBlocked); DrawStartupScreen();