Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions src/BindingResolver.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#pragma once

#include <algorithm>
#include <cstdint>
#include <span>
#include <string>
#include <vector>

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<std::uint16_t> 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<const RawMapping> 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<const RawMapping> mappings)
{
return std::ranges::any_of(mappings, [keyCode](const RawMapping& mapping) {
return mapping.modifier == keyCode;
});
}
}
82 changes: 77 additions & 5 deletions src/InputHandler.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#include "PCH.h"

#include "BindingResolver.h"
#include "InputHandler.h"
#include "MCMNavigator.h"
#include "MenuUI.h"
Expand Down Expand Up @@ -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<HoldFast::RawMapping> 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<std::uint16_t>(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);
}
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
44 changes: 24 additions & 20 deletions src/InputHandler.h
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,10 @@ class InputHandler :
// Call before registering the input sink.
void SetButtons(std::vector<ButtonConfig> 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;
Expand All @@ -64,28 +66,30 @@ class InputHandler :
struct ButtonState : ButtonConfig
{
RE::BSFixedString shortPressUserEvent;
std::vector<std::uint16_t> comboModifiers;
std::optional<std::chrono::steady_clock::time_point> 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<ButtonState> _buttons;
Expand Down
81 changes: 81 additions & 0 deletions test/PluginTests.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#include <catch2/catch_test_macros.hpp>
#include <limits>
#include <vector>

#include "BindingResolver.h"
#include "Config.h"
#include "Utils.h"

Expand Down Expand Up @@ -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<Action>(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<HoldFast::RawMapping> 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<HoldFast::RawMapping> 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));
}
Loading