From 6cdd8d2f66eafac3b50d3863d875c3268e39271b Mon Sep 17 00:00:00 2001 From: codepuncher Date: Mon, 20 Jul 2026 17:44:48 +0100 Subject: [PATCH] fix(input): add controlmap binding resolver --- src/BindingResolver.h | 62 ++++++++++++++++++++++++++++++++ src/InputHandler.cpp | 82 ++++++++++++++++++++++++++++++++++++++++--- src/InputHandler.h | 44 ++++++++++++----------- test/PluginTests.cpp | 81 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 244 insertions(+), 25 deletions(-) create mode 100644 src/BindingResolver.h diff --git a/src/BindingResolver.h b/src/BindingResolver.h new file mode 100644 index 0000000..3083143 --- /dev/null +++ b/src/BindingResolver.h @@ -0,0 +1,62 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace HoldFast +{ + /** + * Mirrors RE::ControlMap::UserEventMapping without the SKSE dependency. modifier == 0 + * means unmodified; otherwise modifier is the combo's held-first button. + */ + struct RawMapping + { + std::string eventID; + std::uint16_t inputKey{}; + std::uint16_t modifier{}; + }; + + struct ResolvedBinding + { + std::string soloEvent; + std::vector comboModifiers; + }; + + /** + * Unlike ControlMap::GetUserEventName, a key that is both solo-bound and a combo + * terminal still resolves its solo event. + */ + [[nodiscard]] inline ResolvedBinding ResolveBinding(std::uint16_t keyCode, std::span mappings) + { + ResolvedBinding resolved; + for (const auto& mapping : mappings) { + if (mapping.inputKey != keyCode) { + continue; + } + if (mapping.modifier == 0) { + if (resolved.soloEvent.empty()) { + resolved.soloEvent = mapping.eventID; + } + continue; + } + if (std::ranges::find(resolved.comboModifiers, mapping.modifier) == resolved.comboModifiers.end()) { + resolved.comboModifiers.push_back(mapping.modifier); + } + } + return resolved; + } + + /** + * HoldFast can't pass a combo through when it also tracks the modifier button itself, + * since it must own that button's Down to detect holds. Callers only warn about this. + */ + [[nodiscard]] inline bool IsUsedAsModifier(std::uint16_t keyCode, std::span mappings) + { + return std::ranges::any_of(mappings, [keyCode](const RawMapping& mapping) { + return mapping.modifier == keyCode; + }); + } +} diff --git a/src/InputHandler.cpp b/src/InputHandler.cpp index 3210b15..af5a4d7 100644 --- a/src/InputHandler.cpp +++ b/src/InputHandler.cpp @@ -1,5 +1,6 @@ #include "PCH.h" +#include "BindingResolver.h" #include "InputHandler.h" #include "MCMNavigator.h" #include "MenuUI.h" @@ -101,17 +102,45 @@ void InputHandler::UpdateShortPressBinding() logger::error("ControlMap unavailable — short press will have no effect"); for (auto& bs : _buttons) { bs.shortPressUserEvent = ""; + bs.comboModifiers.clear(); } return; } + std::vector mappings; + const auto* gameplayContext = controlMap->controlMap[RE::UserEvents::INPUT_CONTEXT_ID::kGameplay]; + if (gameplayContext) { + const auto& deviceMappings = gameplayContext->deviceMappings[RE::INPUT_DEVICE::kGamepad]; + mappings.reserve(deviceMappings.size()); + for (const auto& mapping : deviceMappings) { + // BSFixedString::c_str() never returns null (falls back to an empty string). + mappings.push_back({ + .eventID = std::string{ mapping.eventID.c_str() }, + .inputKey = mapping.inputKey, + .modifier = mapping.modifier, + }); + } + } else { + logger::error("Gameplay input context unavailable — short press will have no effect"); + } + for (auto& bs : _buttons) { - bs.shortPressUserEvent = controlMap->GetUserEventName(bs.keyCode, RE::INPUT_DEVICE::kGamepad); + const auto keyCode = static_cast(bs.keyCode); + const auto resolved = HoldFast::ResolveBinding(keyCode, mappings); + bs.shortPressUserEvent = resolved.soloEvent.c_str(); + bs.comboModifiers = resolved.comboModifiers; + if (bs.shortPressUserEvent.empty()) { - logger::warn("{} has no binding in ControlMap — short press disabled", bs.name); - continue; + logger::warn("{} has no unmodified binding in ControlMap — short press disabled", bs.name); + } else { + logger::info("{} short press user event: '{}'", bs.name, bs.shortPressUserEvent); + } + if (!bs.comboModifiers.empty()) { + logger::info("{} is a combo terminal for {} modifier(s) — combo presses pass through", bs.name, bs.comboModifiers.size()); + } + if (HoldFast::IsUsedAsModifier(keyCode, mappings)) { + logger::warn("{} is bound as a combo modifier — combos starting with it will not work while HoldFast tracks it", bs.name); } - logger::info("{} short press user event: '{}'", bs.name, bs.shortPressUserEvent); } } @@ -248,8 +277,37 @@ bool InputHandler::ScanInputEvents(RE::InputEvent* const* a_events) return shouldBlock; } +/** + * Queries live gamepad state rather than tracking modifier events, so nothing can go + * stale across menus or focus changes. + */ +bool InputHandler::IsAnyComboModifierHeld(const ButtonState& state) +{ + if (state.comboModifiers.empty()) { + return false; + } + auto* deviceManager = RE::BSInputDeviceManager::GetSingleton(); + if (!deviceManager) { + return false; + } + auto* gamepad = deviceManager->GetGamepad(); + if (!gamepad) { + return false; + } + return std::ranges::any_of(state.comboModifiers, [gamepad](std::uint16_t modifier) { + return gamepad->IsPressed(modifier); + }); +} + bool InputHandler::ProcessButton(const RE::ButtonEvent* btn, ButtonState& state) { + if (btn->IsDown() && IsAnyComboModifierHeld(state)) { + // pressTime stays unset so the Held/Up guards below also fall through. + state.pressTime.reset(); + state.triggered = false; + return false; + } + if (btn->IsDown()) { state.pressTime = std::chrono::steady_clock::now(); state.triggered = false; @@ -754,5 +812,19 @@ void InputHandler::DispatchShortPress(const ButtonState& state, float held) return; } - DispatchViaMenuOpenHandler(state.shortPressUserEvent, state.keyCode, state.name + " short press"); + const std::string logCtx = state.name + " short press"; + + // menuOpenHandler rejects Favorites and QuickSave/QuickLoad/NewSave; route to their own handlers. + const auto* userEvents = RE::UserEvents::GetSingleton(); + if (userEvents && state.shortPressUserEvent == userEvents->favorites) { + DispatchViaFavoritesHandler(state.shortPressUserEvent, state.keyCode, logCtx); + return; + } + constexpr std::array quickSaveLoadEvents{ &RE::UserEvents::quicksave, &RE::UserEvents::quickload, &RE::UserEvents::newSave }; + if (userEvents && std::ranges::any_of(quickSaveLoadEvents, [&](auto member) { return state.shortPressUserEvent == userEvents->*member; })) { + DispatchViaQuickSaveLoadHandler(state.shortPressUserEvent, state.keyCode, logCtx); + return; + } + + DispatchViaMenuOpenHandler(state.shortPressUserEvent, state.keyCode, logCtx); } diff --git a/src/InputHandler.h b/src/InputHandler.h index 925b939..db0dc29 100644 --- a/src/InputHandler.h +++ b/src/InputHandler.h @@ -39,8 +39,10 @@ class InputHandler : // Call before registering the input sink. void SetButtons(std::vector a_configs); - // Queries ControlMap for the short-press user event for every tracked button and caches it. - // Call at kInputLoaded, kPostLoadGame, kNewGame, and on JournalMenu close. + /** + * Resolves and caches each tracked button's short-press event and combo modifiers. + * Call at kInputLoaded, kPostLoadGame, kNewGame, and on JournalMenu close. + */ void UpdateShortPressBinding(); ~InputHandler() override = default; @@ -64,28 +66,30 @@ class InputHandler : struct ButtonState : ButtonConfig { RE::BSFixedString shortPressUserEvent; + std::vector comboModifiers; std::optional pressTime; bool triggered{ false }; }; - bool ScanInputEvents(RE::InputEvent* const* a_events); - bool ProcessButton(const RE::ButtonEvent* btn, ButtonState& state); - static bool DispatchViaMenuOpenHandler(const RE::BSFixedString& userEvent, std::uint32_t keyCode, const std::string& logContext); - static bool DispatchViaQuickSaveLoadHandler(const RE::BSFixedString& userEvent, std::uint32_t keyCode, const std::string& logContext); - static bool DispatchViaFavoritesHandler(const RE::BSFixedString& userEvent, std::uint32_t keyCode, const std::string& logContext); - static bool DispatchViaHandler(RE::MenuEventHandler* handler, std::string_view handlerName, const RE::BSFixedString& userEvent, std::uint32_t keyCode, const std::string& logContext); - static void DispatchShortPress(const ButtonState& state, float held); - static std::uint32_t JournalTabToIndex(JournalTab tab); - static void CloseJournal(); - void DispatchLongPress(const ButtonState& state); - void OpenJournalOnTab(JournalTab tab, const std::string& buttonName); - void RestoreJournalTab(); - void InvokeScaleformTab(JournalTab tab); - void InvokeRestoreTabIfNeeded(JournalTab tab); - void SnapshotJournalTab(RE::UI* ui); - void DetectQJOIfNeeded(RE::GFxMovieView* movie); - void HandleMCMQuickexit(); - void ResetMCMQuickexitState(); + bool ScanInputEvents(RE::InputEvent* const* a_events); + bool ProcessButton(const RE::ButtonEvent* btn, ButtonState& state); + [[nodiscard]] static bool IsAnyComboModifierHeld(const ButtonState& state); + static bool DispatchViaMenuOpenHandler(const RE::BSFixedString& userEvent, std::uint32_t keyCode, const std::string& logContext); + static bool DispatchViaQuickSaveLoadHandler(const RE::BSFixedString& userEvent, std::uint32_t keyCode, const std::string& logContext); + static bool DispatchViaFavoritesHandler(const RE::BSFixedString& userEvent, std::uint32_t keyCode, const std::string& logContext); + static bool DispatchViaHandler(RE::MenuEventHandler* handler, std::string_view handlerName, const RE::BSFixedString& userEvent, std::uint32_t keyCode, const std::string& logContext); + static void DispatchShortPress(const ButtonState& state, float held); + static std::uint32_t JournalTabToIndex(JournalTab tab); + static void CloseJournal(); + void DispatchLongPress(const ButtonState& state); + void OpenJournalOnTab(JournalTab tab, const std::string& buttonName); + void RestoreJournalTab(); + void InvokeScaleformTab(JournalTab tab); + void InvokeRestoreTabIfNeeded(JournalTab tab); + void SnapshotJournalTab(RE::UI* ui); + void DetectQJOIfNeeded(RE::GFxMovieView* movie); + void HandleMCMQuickexit(); + void ResetMCMQuickexitState(); float holdDuration{ kDefaultHoldDuration }; std::vector _buttons; diff --git a/test/PluginTests.cpp b/test/PluginTests.cpp index f5cf2f2..92cb7c3 100644 --- a/test/PluginTests.cpp +++ b/test/PluginTests.cpp @@ -1,6 +1,8 @@ #include #include +#include +#include "BindingResolver.h" #include "Config.h" #include "Utils.h" @@ -122,3 +124,82 @@ TEST_CASE("ActionName maps enum values and falls back to None", "[config]") CHECK(std::string_view{ ActionName(Action::kCharacterSheet) } == "CharacterSheet"); CHECK(std::string_view{ ActionName(static_cast(9999)) } == "None"); } + +namespace +{ + /** + * Gameplay-context gamepad rows from a custom controlmap that dual-binds keys. + * 0x0010 = Start, 0x0020 = Back, 0x0100 = LB, 0x1000 = A, 0x0200 = RB. + */ + const std::vector kCustomControlmap{ + { "Ready Weapon", 0x0004, 0x0100 }, + { "Tween Menu", 0x0010, 0x0100 }, + { "Sneak", 0x0040, 0x0100 }, + { "Favorites", 0x0020, 0x0000 }, + { "Wait", 0x0008, 0x0100 }, + { "Journal", 0x0010, 0x0000 }, + { "Quick Inventory", 0x0200, 0x1000 }, + }; +} + +TEST_CASE("ResolveBinding resolves solo event for a dual-bound key", "[binding]") +{ + using HoldFast::ResolveBinding; + + // Start is both "Journal" (solo) and the terminal of LB+Start ("Tween Menu"). + const auto resolved = ResolveBinding(0x0010, kCustomControlmap); + CHECK(resolved.soloEvent == "Journal"); + REQUIRE(resolved.comboModifiers.size() == 1); + CHECK(resolved.comboModifiers[0] == 0x0100); +} + +TEST_CASE("ResolveBinding resolves solo-only and combo-only keys", "[binding]") +{ + using HoldFast::ResolveBinding; + + const auto back = ResolveBinding(0x0020, kCustomControlmap); + CHECK(back.soloEvent == "Favorites"); + CHECK(back.comboModifiers.empty()); + + const auto rb = ResolveBinding(0x0200, kCustomControlmap); + CHECK(rb.soloEvent.empty()); + REQUIRE(rb.comboModifiers.size() == 1); + CHECK(rb.comboModifiers[0] == 0x1000); +} + +TEST_CASE("ResolveBinding returns empty for an unbound key", "[binding]") +{ + using HoldFast::ResolveBinding; + + const auto resolved = ResolveBinding(0x4000, kCustomControlmap); + CHECK(resolved.soloEvent.empty()); + CHECK(resolved.comboModifiers.empty()); +} + +TEST_CASE("ResolveBinding keeps first solo event and deduplicates modifiers", "[binding]") +{ + using HoldFast::ResolveBinding; + + const std::vector mappings{ + { "First", 0x0010, 0x0000 }, + { "ComboA", 0x0010, 0x0100 }, + { "Second", 0x0010, 0x0000 }, + { "ComboA repeat", 0x0010, 0x0100 }, + { "ComboB", 0x0010, 0x1000 }, + }; + const auto resolved = ResolveBinding(0x0010, mappings); + CHECK(resolved.soloEvent == "First"); + REQUIRE(resolved.comboModifiers.size() == 2); + CHECK(resolved.comboModifiers[0] == 0x0100); + CHECK(resolved.comboModifiers[1] == 0x1000); +} + +TEST_CASE("IsUsedAsModifier detects keys used as combo modifiers", "[binding]") +{ + using HoldFast::IsUsedAsModifier; + + CHECK(IsUsedAsModifier(0x0100, kCustomControlmap)); + CHECK(IsUsedAsModifier(0x1000, kCustomControlmap)); + CHECK_FALSE(IsUsedAsModifier(0x0010, kCustomControlmap)); + CHECK_FALSE(IsUsedAsModifier(0x0020, kCustomControlmap)); +}