From 35964e4fda117b0a69e15f672e86bca90e2f8e62 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Sun, 7 Jun 2026 15:33:19 +0100 Subject: [PATCH 01/13] feat(menu): add SKSE Menu Framework menu --- .gitmodules | 3 + CMakeLists.txt | 10 ++- README.md | 6 ++ docs/nexus-page.md | 5 ++ lib/skse-mcp | 1 + src/Config.cpp | 148 ++++++++++++++++++++++++++++++++++++++ src/Config.h | 48 +++++++++++++ src/InputHandler.cpp | 11 +++ src/MenuUI.cpp | 168 +++++++++++++++++++++++++++++++++++++++++++ src/MenuUI.h | 7 ++ src/PCH.h | 1 + src/Plugin.cpp | 150 ++++---------------------------------- 12 files changed, 420 insertions(+), 138 deletions(-) create mode 160000 lib/skse-mcp create mode 100644 src/Config.cpp create mode 100644 src/Config.h create mode 100644 src/MenuUI.cpp create mode 100644 src/MenuUI.h diff --git a/.gitmodules b/.gitmodules index 321cad4..ce5ad9e 100644 --- a/.gitmodules +++ b/.gitmodules @@ -6,3 +6,6 @@ path = lib/vcpkg url = https://github.com/microsoft/vcpkg.git shallow = true +[submodule "lib/skse-mcp"] + path = lib/skse-mcp + url = https://github.com/QTR-Modding/SKSE-MCP.git diff --git a/CMakeLists.txt b/CMakeLists.txt index bdc25c7..b0415a0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -83,8 +83,12 @@ else() AUTHOR "codepuncher" SOURCES + src/Config.h + src/Config.cpp src/InputHandler.h src/InputHandler.cpp + src/MenuUI.h + src/MenuUI.cpp src/Plugin.cpp "${CMAKE_CURRENT_BINARY_DIR}/src/version.rc" USE_ADDRESS_LIBRARY) @@ -102,8 +106,10 @@ else() target_precompile_headers(${PROJECT_NAME} PRIVATE src/PCH.h) - target_include_directories(${PROJECT_NAME} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src" - "${CMAKE_CURRENT_BINARY_DIR}/src") + target_include_directories( + ${PROJECT_NAME} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src" "${CMAKE_CURRENT_BINARY_DIR}/src" + "${CMAKE_CURRENT_SOURCE_DIR}/lib/skse-mcp/include") + target_compile_definitions(${PROJECT_NAME} PRIVATE UNICODE _UNICODE) set_target_properties( ${PROJECT_NAME} diff --git a/README.md b/README.md index 33bcc53..3fb1d01 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Inspired by Red Dead Redemption 2's hold-Start-to-open-map mechanic. - [Skyrim Special Edition](https://store.steampowered.com/app/489830) or Anniversary Edition - [SKSE64](https://skse.silverlock.org/) - [Address Library for SKSE Plugins](https://www.nexusmods.com/skyrimspecialedition/mods/32444) +- Optional: [SKSE Menu Framework](https://www.nexusmods.com/skyrimspecialedition/mods/120352) for in-game settings UI ## Installation @@ -48,6 +49,11 @@ sButtonStartAction=Map sButtonBackAction=System ``` +**In-game settings (optional):** + +If SKSE Menu Framework v3 is installed, HoldFast adds a `HoldFast/Settings` page in its mod control panel. +Use **Save to config** to persist and apply changes, **Reload from config** to discard staged edits and reload INI values, and **Reset to defaults** to stage defaults (`0.5`, `Start=Map`, `Back=System`) until saved. + **Valid actions:** | Value | What it does | diff --git a/docs/nexus-page.md b/docs/nexus-page.md index 4c41563..8b29835 100644 --- a/docs/nexus-page.md +++ b/docs/nexus-page.md @@ -47,6 +47,7 @@ Hold a gamepad button for a configurable duration to jump directly to a menu. Ea [*][url=https://store.steampowered.com/app/489830]Skyrim Special Edition[/url] or Anniversary Edition [*][url=https://skse.silverlock.org/]SKSE64[/url] [*][url=https://www.nexusmods.com/skyrimspecialedition/mods/32444]Address Library for SKSE Plugins[/url] +[*]Optional: [url=https://www.nexusmods.com/skyrimspecialedition/mods/120352]SKSE Menu Framework[/url] for in-game settings UI [/list] [line] @@ -85,6 +86,10 @@ sButtonStartAction=Map sButtonBackAction=System [/code] +[b]In-game settings (optional):[/b] +If SKSE Menu Framework v3 is installed, HoldFast adds a [font=Courier New]HoldFast/Settings[/font] page in its mod control panel. +Use [b]Save to config[/b] to persist and apply changes, [b]Reload from config[/b] to discard staged edits and reload INI values, and [b]Reset to defaults[/b] to stage defaults ([font=Courier New]0.5[/font], [font=Courier New]Start=Map[/font], [font=Courier New]Back=System[/font]) until saved. + [b]Valid actions:[/b] [list] diff --git a/lib/skse-mcp b/lib/skse-mcp new file mode 160000 index 0000000..5477196 --- /dev/null +++ b/lib/skse-mcp @@ -0,0 +1 @@ +Subproject commit 5477196dde0d340410e0906577d946f8131d4b7e diff --git a/src/Config.cpp b/src/Config.cpp new file mode 100644 index 0000000..c3b848b --- /dev/null +++ b/src/Config.cpp @@ -0,0 +1,148 @@ +#include "PCH.h" + +#include +#include + +#include "Config.h" +#include "Utils.h" + +namespace +{ + constexpr auto kIniPath = R"(Data\SKSE\Plugins\HoldFast.ini)"; + + using LongPressAction = InputHandler::LongPressAction; + + [[nodiscard]] float ReadHoldDuration(const CSimpleIniA& ini) + { + const auto raw = static_cast(ini.GetDoubleValue("General", "fHoldDuration", InputHandler::kDefaultHoldDuration)); + const auto duration = HoldFast::ClampHoldDuration(raw, InputHandler::kDefaultHoldDuration, InputHandler::kMaxHoldDuration); + if (duration == raw) { + return duration; + } + if (!std::isfinite(raw)) { + logger::warn("fHoldDuration is non-finite — using default {:.1f}", InputHandler::kDefaultHoldDuration); + return duration; + } + if (raw <= 0.0F) { + logger::warn("fHoldDuration ({:.2f}) must be positive — using default {:.1f}", raw, InputHandler::kDefaultHoldDuration); + return duration; + } + logger::warn("fHoldDuration ({:.2f}) exceeds maximum {:.1f} — capping", raw, InputHandler::kMaxHoldDuration); + return duration; + } +} + +HoldFast::Config::Settings HoldFast::Config::LoadSettings() +{ + Settings settings{}; + + CSimpleIniA ini; + const auto rc = ini.LoadFile(kIniPath); + if (rc < SI_OK) { + logger::warn("HoldFast.ini not found or could not be parsed (rc={}) — using defaults", static_cast(rc)); + } + + settings.holdDuration = ReadHoldDuration(ini); + + const char* rawStart = ini.GetValue("General", "sButtonStartAction", nullptr); + const char* rawBack = ini.GetValue("General", "sButtonBackAction", nullptr); + const bool hasStart = rawStart && !HoldFast::TrimWhitespace(rawStart).empty(); + const bool hasBack = rawBack && !HoldFast::TrimWhitespace(rawBack).empty(); + + if (!hasStart && !hasBack) { + logger::info("No button action keys found — applying defaults (Start=Map, Back=System)"); + return settings; + } + + settings.startAction = hasStart ? ParseAction(rawStart, "sButtonStartAction", true) : LongPressAction::kNone; + settings.backAction = hasBack ? ParseAction(rawBack, "sButtonBackAction", true) : LongPressAction::kNone; + return settings; +} + +bool HoldFast::Config::SaveSettings(const Settings& settings) +{ + CSimpleIniA ini; + ini.LoadFile(kIniPath); + + const auto startActionName = std::string{ ActionName(settings.startAction) }; + const auto backActionName = std::string{ ActionName(settings.backAction) }; + + ini.SetDoubleValue("General", "fHoldDuration", static_cast(settings.holdDuration)); + ini.SetValue("General", "sButtonStartAction", startActionName.c_str()); + ini.SetValue("General", "sButtonBackAction", backActionName.c_str()); + + const auto rc = ini.SaveFile(kIniPath); + if (rc < SI_OK) { + logger::error("Failed to write HoldFast.ini (rc={})", static_cast(rc)); + return false; + } + return true; +} + +std::vector HoldFast::Config::BuildButtons(const Settings& settings) +{ + using Key = RE::BSWin32GamepadDevice::Key; + + std::vector buttons; + if (settings.startAction != LongPressAction::kNone) { + buttons.push_back({ .keyCode = static_cast(Key::kStart), .name = "Start", .action = settings.startAction }); + } + if (settings.backAction != LongPressAction::kNone) { + buttons.push_back({ .keyCode = static_cast(Key::kBack), .name = "Back", .action = settings.backAction }); + } + return buttons; +} + +void HoldFast::Config::ApplySettings(InputHandler& handler, const Settings& settings) +{ + handler.SetHoldDuration(settings.holdDuration); + handler.SetButtons(BuildButtons(settings)); + handler.UpdateShortPressBinding(); +} + +InputHandler::LongPressAction HoldFast::Config::ParseAction(std::string_view raw, const char* sourceKey, bool logWarnings) +{ + static const std::unordered_map kActionMap{ + { "map", LongPressAction::kMap }, + { "system", LongPressAction::kSystem }, + { "quests", LongPressAction::kQuests }, + { "stats", LongPressAction::kStats }, + { "inventory", LongPressAction::kInventory }, + { "magic", LongPressAction::kMagic }, + { "favorites", LongPressAction::kFavorites }, + { "favourites", LongPressAction::kFavorites }, + { "tweenmenu", LongPressAction::kTweenMenu }, + { "wait", LongPressAction::kWait }, + { "newsave", LongPressAction::kNewSave }, + { "quicksave", LongPressAction::kQuickSave }, + { "bestiary", LongPressAction::kBestiary }, + { "charactersheet", LongPressAction::kCharacterSheet }, + { "none", LongPressAction::kNone }, + }; + + const auto trimmed = HoldFast::TrimWhitespace(raw); + std::string lower{ trimmed }; + std::ranges::transform(lower, lower.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + + const auto it = kActionMap.find(lower); + if (it != kActionMap.end()) { + return it->second; + } + if (logWarnings) { + logger::warn("{}='{}' is not a recognised action (valid: Map, System, Quests, Stats, Inventory, Magic, Favorites/Favourites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, None) — disabling button", + sourceKey, raw); + } + return LongPressAction::kNone; +} + +std::string_view HoldFast::Config::ActionName(LongPressAction action) +{ + for (const auto& option : kActionOptions) { + if (option.action == action) { + return option.name; + } + } + return "None"; +} diff --git a/src/Config.h b/src/Config.h new file mode 100644 index 0000000..158a507 --- /dev/null +++ b/src/Config.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include + +#include "InputHandler.h" + +namespace HoldFast::Config +{ + struct Settings + { + float holdDuration{ InputHandler::kDefaultHoldDuration }; + InputHandler::LongPressAction startAction{ InputHandler::LongPressAction::kMap }; + InputHandler::LongPressAction backAction{ InputHandler::LongPressAction::kSystem }; + }; + + struct ActionOption + { + std::string_view name; + InputHandler::LongPressAction action; + }; + + inline constexpr std::array kActionOptions{ { + { "Map", InputHandler::LongPressAction::kMap }, + { "System", InputHandler::LongPressAction::kSystem }, + { "Quests", InputHandler::LongPressAction::kQuests }, + { "Stats", InputHandler::LongPressAction::kStats }, + { "Inventory", InputHandler::LongPressAction::kInventory }, + { "Magic", InputHandler::LongPressAction::kMagic }, + { "Favorites", InputHandler::LongPressAction::kFavorites }, + { "TweenMenu", InputHandler::LongPressAction::kTweenMenu }, + { "Wait", InputHandler::LongPressAction::kWait }, + { "NewSave", InputHandler::LongPressAction::kNewSave }, + { "QuickSave", InputHandler::LongPressAction::kQuickSave }, + { "Bestiary", InputHandler::LongPressAction::kBestiary }, + { "CharacterSheet", InputHandler::LongPressAction::kCharacterSheet }, + { "None", InputHandler::LongPressAction::kNone }, + } }; + + [[nodiscard]] Settings LoadSettings(); + [[nodiscard]] bool SaveSettings(const Settings& settings); + + [[nodiscard]] std::vector BuildButtons(const Settings& settings); + void ApplySettings(InputHandler& handler, const Settings& settings); + + [[nodiscard]] InputHandler::LongPressAction ParseAction(std::string_view raw, const char* sourceKey, bool logWarnings); + [[nodiscard]] std::string_view ActionName(InputHandler::LongPressAction action); +} diff --git a/src/InputHandler.cpp b/src/InputHandler.cpp index 562915b..e6c8b87 100644 --- a/src/InputHandler.cpp +++ b/src/InputHandler.cpp @@ -1,6 +1,7 @@ #include "PCH.h" #include "InputHandler.h" +#include "MenuUI.h" namespace { @@ -115,6 +116,16 @@ RE::BSEventNotifyControl InputHandler::ProcessEvent( auto* ui = RE::UI::GetSingleton(); + // If SKSE Menu Framework owns input focus, pass input through and clear held-state + // captures so Start/Back interception cannot fight the settings UI. + if (HoldFastMenuUI::IsBlockingInput()) { + for (auto& bs : _buttons) { + bs.pressTime.reset(); + bs.triggered = false; + } + return RE::BSEventNotifyControl::kContinue; + } + // Fail-safe: if a tab restore is pending but the Journal is not open (or UI singleton // is unavailable), the Journal failed to open or the close event was not delivered — // restore sJournalTabIdx now rather than leaving the forced value in place indefinitely. diff --git a/src/MenuUI.cpp b/src/MenuUI.cpp new file mode 100644 index 0000000..a51ac5e --- /dev/null +++ b/src/MenuUI.cpp @@ -0,0 +1,168 @@ +#include "PCH.h" + +#include + +#include "Config.h" +#include "MenuUI.h" +#include "SKSEMCP/utils.hpp" +#include "Utils.h" + +namespace +{ + bool EnsureFrameworkLoaded() + { + if (menuFramework) { + return true; + } + menuFramework = GetModuleHandleW(L"SKSEMenuFramework.dll"); + if (menuFramework) { + return true; + } + menuFramework = LoadLibraryW(L"Data/SKSE/Plugins/SKSEMenuFramework.dll"); + return menuFramework != nullptr; + } + + struct MenuState + { + HoldFast::Config::Settings stagedSettings{}; + bool hasPendingChanges{ false }; + }; + + MenuState& GetMenuState() + { + static MenuState state{}; + return state; + } + + bool SettingsEqual(const HoldFast::Config::Settings& lhs, const HoldFast::Config::Settings& rhs) + { + return lhs.holdDuration == rhs.holdDuration && + lhs.startAction == rhs.startAction && + lhs.backAction == rhs.backAction; + } + + bool DrawActionCombo(const char* label, InputHandler::LongPressAction& value) + { + const auto preview = HoldFast::Config::ActionName(value); + const std::string previewText{ preview }; + if (!ImGuiMCP::BeginCombo(label, previewText.c_str())) { + return false; + } + + bool changed = false; + for (const auto& option : HoldFast::Config::kActionOptions) { + const bool isSelected = (option.action == value); + const std::string optionName{ option.name }; + if (ImGuiMCP::Selectable(optionName.c_str(), isSelected)) { + value = option.action; + changed = true; + } + } + ImGuiMCP::EndCombo(); + return changed; + } + + void SavePendingChanges() + { + auto& state = GetMenuState(); + if (!state.hasPendingChanges) { + return; + } + + state.stagedSettings.holdDuration = HoldFast::ClampHoldDuration( + state.stagedSettings.holdDuration, + InputHandler::kDefaultHoldDuration, + InputHandler::kMaxHoldDuration); + + const auto* plugin = SKSE::PluginDeclaration::GetSingleton(); + const auto pluginName = plugin ? plugin->GetName() : "HoldFast"; + + if (!HoldFast::Config::SaveSettings(state.stagedSettings)) { + logger::error("{}: failed to persist menu changes", pluginName); + return; + } + + HoldFast::Config::ApplySettings(*InputHandler::GetSingleton(), state.stagedSettings); + logger::info("{}: applied settings from SKSE Menu Framework", pluginName); + state.hasPendingChanges = false; + } + + void ReloadFromConfig() + { + auto& state = GetMenuState(); + state.stagedSettings = HoldFast::Config::LoadSettings(); + state.hasPendingChanges = false; + HoldFast::Config::ApplySettings(*InputHandler::GetSingleton(), state.stagedSettings); + } + + void ResetToDefaults() + { + auto& state = GetMenuState(); + const HoldFast::Config::Settings defaults{}; + state.stagedSettings = defaults; + const auto loaded = HoldFast::Config::LoadSettings(); + state.hasPendingChanges = !SettingsEqual(defaults, loaded); + } + + void __stdcall RenderSettings() + { + auto& state = GetMenuState(); + bool changed = false; + changed |= ImGuiMCP::SliderFloat("Hold duration", &state.stagedSettings.holdDuration, 0.1F, InputHandler::kMaxHoldDuration, "%.2fs"); + changed |= DrawActionCombo("Start long-press action", state.stagedSettings.startAction); + changed |= DrawActionCombo("Back long-press action", state.stagedSettings.backAction); + + if (changed) { + state.hasPendingChanges = true; + } + + if (ImGuiMCP::Button("Save to config")) { + SavePendingChanges(); + } + ImGuiMCP::SameLine(); + if (ImGuiMCP::Button("Reload from config")) { + ReloadFromConfig(); + } + ImGuiMCP::SameLine(); + if (ImGuiMCP::Button("Reset to defaults")) { + ResetToDefaults(); + } + } + +} + +void HoldFastMenuUI::Register() +{ + if (!SKSEMenuFramework::IsInstalled()) { + logger::info("SKSE Menu Framework not installed — in-game settings menu disabled (INI config still available)"); + return; + } + if (!EnsureFrameworkLoaded()) { + logger::warn("SKSE Menu Framework detected on disk but DLL is not loaded — integration disabled"); + return; + } + if (!GetProcAddress(menuFramework, "AddSectionItem") || + !GetProcAddress(menuFramework, "IsAnyBlockingWindowOpened")) { + logger::warn("SKSE Menu Framework required exports are unavailable — integration disabled"); + return; + } + + ReloadFromConfig(); + SKSEMenuFramework::SetSection("HoldFast"); + SKSEMenuFramework::AddSectionItem("Settings", RenderSettings); + logger::info("SKSE Menu Framework integration registered"); +} + +bool HoldFastMenuUI::IsBlockingInput() +{ + if (!SKSEMenuFramework::IsInstalled()) { + return false; + } + if (!EnsureFrameworkLoaded()) { + return false; + } + if (!GetProcAddress(menuFramework, "IsAnyBlockingWindowOpened")) { + return false; + } + return SKSEMenuFramework::IsAnyBlockingWindowOpened(); +} diff --git a/src/MenuUI.h b/src/MenuUI.h new file mode 100644 index 0000000..f7aae60 --- /dev/null +++ b/src/MenuUI.h @@ -0,0 +1,7 @@ +#pragma once + +namespace HoldFastMenuUI +{ + void Register(); + bool IsBlockingInput(); +} diff --git a/src/PCH.h b/src/PCH.h index 6b0b25a..a1b39f9 100644 --- a/src/PCH.h +++ b/src/PCH.h @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include diff --git a/src/Plugin.cpp b/src/Plugin.cpp index d7b1868..7bbdf99 100644 --- a/src/Plugin.cpp +++ b/src/Plugin.cpp @@ -1,13 +1,8 @@ #include "PCH.h" -#include -#include - +#include "Config.h" #include "InputHandler.h" -#include "Utils.h" - -using HoldFast::ClampHoldDuration; -using HoldFast::TrimWhitespace; +#include "MenuUI.h" void SetupLog() { @@ -37,144 +32,25 @@ void SetupLog() spdlog::flush_on(spdlog::level::info); } -float ReadHoldDuration(const CSimpleIniA& a_ini) -{ - const auto raw = static_cast(a_ini.GetDoubleValue("General", "fHoldDuration", InputHandler::kDefaultHoldDuration)); - const auto duration = ClampHoldDuration(raw, InputHandler::kDefaultHoldDuration, InputHandler::kMaxHoldDuration); - if (duration != raw) { - if (!std::isfinite(raw)) { - logger::warn("fHoldDuration is non-finite — using default {:.1f}", InputHandler::kDefaultHoldDuration); - } else if (raw <= 0.0F) { - logger::warn("fHoldDuration ({:.2f}) must be positive — using default {:.1f}", raw, InputHandler::kDefaultHoldDuration); - } else { - logger::warn("fHoldDuration ({:.2f}) exceeds maximum {:.1f} — capping", raw, InputHandler::kMaxHoldDuration); - } - } - return duration; -} - -using LongPressAction = InputHandler::LongPressAction; - -LongPressAction ReadLongPressAction(std::string_view raw, const char* iniKey) -{ - static const std::unordered_map kActionMap{ - { "map", LongPressAction::kMap }, - { "system", LongPressAction::kSystem }, - { "quests", LongPressAction::kQuests }, - { "stats", LongPressAction::kStats }, - { "inventory", LongPressAction::kInventory }, - { "magic", LongPressAction::kMagic }, - { "favorites", LongPressAction::kFavorites }, - { "favourites", LongPressAction::kFavorites }, - { "tweenmenu", LongPressAction::kTweenMenu }, - { "wait", LongPressAction::kWait }, - { "newsave", LongPressAction::kNewSave }, - { "quicksave", LongPressAction::kQuickSave }, - { "bestiary", LongPressAction::kBestiary }, - { "charactersheet", LongPressAction::kCharacterSheet }, - { "none", LongPressAction::kNone }, - }; - - const auto trimmed = TrimWhitespace(raw); - std::string lower{ trimmed }; - std::ranges::transform(lower, lower.begin(), [](unsigned char c) { - return static_cast(std::tolower(c)); - }); - - const auto it = kActionMap.find(lower); - if (it == kActionMap.end()) { - logger::warn("{}='{}' is not a recognised action (valid: Map, System, Quests, Stats, Inventory, Magic, Favorites/Favourites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, None) — disabling button", iniKey, raw); - return LongPressAction::kNone; - } - return it->second; -} - -using ButtonConfig = InputHandler::ButtonConfig; - -std::vector ReadButtons(const CSimpleIniA& a_ini) -{ - using Key = RE::BSWin32GamepadDevice::Key; - - struct ButtonDef - { - const char* iniKey; - std::uint32_t keyCode; - const char* name; - }; - - static const std::array kButtonDefs{ { - { .iniKey = "sButtonStartAction", .keyCode = static_cast(Key::kStart), .name = "Start" }, - { .iniKey = "sButtonBackAction", .keyCode = static_cast(Key::kBack), .name = "Back" }, - } }; - - std::vector result; - bool anyKeyPresent = false; - - for (const auto& def : kButtonDefs) { - const char* raw = a_ini.GetValue("General", def.iniKey, nullptr); - if (!raw || TrimWhitespace(raw).empty()) { - continue; // absent or blank → treated as absent, no warning - } - anyKeyPresent = true; - const auto action = ReadLongPressAction(raw, def.iniKey); - if (action == LongPressAction::kNone) { - continue; // kNone entries are excluded - } - result.push_back({ .keyCode = def.keyCode, .name = def.name, .action = action }); - } - - if (!anyKeyPresent) { - logger::info("No button action keys found — applying defaults (Start=Map, Back=System)"); - return { - { .keyCode = static_cast(Key::kStart), .name = "Start", .action = LongPressAction::kMap }, - { .keyCode = static_cast(Key::kBack), .name = "Back", .action = LongPressAction::kSystem }, - }; - } - - return result; -} - void OnInputLoaded() { auto* handler = InputHandler::GetSingleton(); - - CSimpleIniA ini; - const auto rc = ini.LoadFile(R"(Data\SKSE\Plugins\HoldFast.ini)"); - if (rc < SI_OK) { - logger::warn("HoldFast.ini not found or could not be parsed (rc={}) — using defaults", static_cast(rc)); + if (!handler) { + logger::error("Failed to get InputHandler singleton"); + return; } - const float holdDuration = ReadHoldDuration(ini); - handler->SetHoldDuration(holdDuration); - logger::info("Hold duration: {:.2f}s", holdDuration); + const auto settings = HoldFast::Config::LoadSettings(); + logger::info("Hold duration: {:.2f}s", settings.holdDuration); + logger::info("Start → {}", HoldFast::Config::ActionName(settings.startAction)); + logger::info("Back → {}", HoldFast::Config::ActionName(settings.backAction)); - auto buttons = ReadButtons(ini); + auto buttons = HoldFast::Config::BuildButtons(settings); if (buttons.empty()) { - logger::warn("No buttons configured — HoldFast is inactive"); - return; - } - - for (const auto& btn : buttons) { - static const std::unordered_map kActionNames{ - { LongPressAction::kMap, "Map" }, - { LongPressAction::kSystem, "System" }, - { LongPressAction::kQuests, "Quests" }, - { LongPressAction::kStats, "Stats" }, - { LongPressAction::kInventory, "Inventory" }, - { LongPressAction::kMagic, "Magic" }, - { LongPressAction::kFavorites, "Favorites" }, - { LongPressAction::kTweenMenu, "TweenMenu" }, - { LongPressAction::kWait, "Wait" }, - { LongPressAction::kNewSave, "NewSave" }, - { LongPressAction::kQuickSave, "QuickSave" }, - { LongPressAction::kBestiary, "Bestiary" }, - { LongPressAction::kCharacterSheet, "CharacterSheet" }, - }; - const auto it = kActionNames.find(btn.action); - const auto actionName = it != kActionNames.end() ? it->second : "Unknown"; - logger::info("{} → {}", btn.name, actionName); + logger::warn("No buttons configured — HoldFast long-press interception disabled"); } + handler->SetHoldDuration(settings.holdDuration); handler->SetButtons(std::move(buttons)); auto* inputDeviceMgr = RE::BSInputDeviceManager::GetSingleton(); @@ -230,5 +106,7 @@ SKSEPluginLoad(const SKSE::LoadInterface* a_skse) return false; } + HoldFastMenuUI::Register(); + return true; } From bfe086ff48a8d05940ba4cb6142d581139960613 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Sun, 7 Jun 2026 20:52:27 +0100 Subject: [PATCH 02/13] fix(menu): review changes --- src/InputHandler.cpp | 37 +++++++++++++++++++++---------------- src/InputHandler.h | 1 + src/MenuUI.cpp | 44 ++++++++++++++++++++++++++++++++++---------- test/PluginTests.cpp | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 26 deletions(-) diff --git a/src/InputHandler.cpp b/src/InputHandler.cpp index e6c8b87..d815261 100644 --- a/src/InputHandler.cpp +++ b/src/InputHandler.cpp @@ -116,16 +116,6 @@ RE::BSEventNotifyControl InputHandler::ProcessEvent( auto* ui = RE::UI::GetSingleton(); - // If SKSE Menu Framework owns input focus, pass input through and clear held-state - // captures so Start/Back interception cannot fight the settings UI. - if (HoldFastMenuUI::IsBlockingInput()) { - for (auto& bs : _buttons) { - bs.pressTime.reset(); - bs.triggered = false; - } - return RE::BSEventNotifyControl::kContinue; - } - // Fail-safe: if a tab restore is pending but the Journal is not open (or UI singleton // is unavailable), the Journal failed to open or the close event was not delivered — // restore sJournalTabIdx now rather than leaving the forced value in place indefinitely. @@ -136,6 +126,16 @@ RE::BSEventNotifyControl InputHandler::ProcessEvent( RestoreJournalTab(); } + // If SKSE Menu Framework owns input focus, pass input through and clear held-state + // captures so Start/Back interception cannot fight the settings UI. + if (HoldFastMenuUI::IsBlockingInput()) { + for (auto& bs : _buttons) { + bs.pressTime.reset(); + bs.triggered = false; + } + return RE::BSEventNotifyControl::kContinue; + } + // If any pausing menu is open, pass all input through and clear any captured press so it // can't fire a spurious dispatch once the menu closes. // Also pass through for kCharacterSheet: it uses kModal but not kPausesGame, so @@ -152,6 +152,16 @@ RE::BSEventNotifyControl InputHandler::ProcessEvent( return RE::BSEventNotifyControl::kContinue; } + // kStop halts the entire frame's event batch for all downstream sinks. With both Start + // and Back tracked by default, this fires on every press of either managed button. + // Pressing any other input while a managed button hold is in progress is suppressed from + // downstream sinks. This is intentional: hold detection requires exclusive ownership of + // those frames. Selective kStop per event is not feasible with CommonLib's batch API. + return ScanInputEvents(a_events) ? RE::BSEventNotifyControl::kStop : RE::BSEventNotifyControl::kContinue; +} + +bool InputHandler::ScanInputEvents(RE::InputEvent* const* a_events) +{ bool shouldBlock = false; for (auto* event = *a_events; event; event = event->next) { @@ -172,12 +182,7 @@ RE::BSEventNotifyControl InputHandler::ProcessEvent( } } - // kStop halts the entire frame's event batch for all downstream sinks. With both Start - // and Back tracked by default, this fires on every press of either managed button. - // Pressing any other input while a managed button hold is in progress is suppressed from - // downstream sinks. This is intentional: hold detection requires exclusive ownership of - // those frames. Selective kStop per event is not feasible with CommonLib's batch API. - return shouldBlock ? RE::BSEventNotifyControl::kStop : RE::BSEventNotifyControl::kContinue; + return shouldBlock; } bool InputHandler::ProcessButton(const RE::ButtonEvent* btn, ButtonState& state) diff --git a/src/InputHandler.h b/src/InputHandler.h index 45a505b..f9e43a8 100644 --- a/src/InputHandler.h +++ b/src/InputHandler.h @@ -85,6 +85,7 @@ class InputHandler : 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); diff --git a/src/MenuUI.cpp b/src/MenuUI.cpp index a51ac5e..4ea83a1 100644 --- a/src/MenuUI.cpp +++ b/src/MenuUI.cpp @@ -9,6 +9,12 @@ namespace { + bool IsFrameworkInstalled() + { + static const bool installed = SKSEMenuFramework::IsInstalled(); + return installed; + } + bool EnsureFrameworkLoaded() { if (menuFramework) { @@ -22,6 +28,25 @@ namespace return menuFramework != nullptr; } + bool HasBlockingWindowExport() + { + static bool exportResolved = false; + static bool hasExport = false; + + if (exportResolved) { + return hasExport; + } + + // If the framework DLL isn't loaded yet, keep retrying on future calls. + if (!EnsureFrameworkLoaded()) { + return false; + } + + hasExport = GetProcAddress(menuFramework, "IsAnyBlockingWindowOpened") != nullptr; + exportResolved = true; + return hasExport; + } + struct MenuState { HoldFast::Config::Settings stagedSettings{}; @@ -87,12 +112,14 @@ namespace state.hasPendingChanges = false; } - void ReloadFromConfig() + void ReloadFromConfig(bool applyRuntime) { auto& state = GetMenuState(); state.stagedSettings = HoldFast::Config::LoadSettings(); state.hasPendingChanges = false; - HoldFast::Config::ApplySettings(*InputHandler::GetSingleton(), state.stagedSettings); + if (applyRuntime) { + HoldFast::Config::ApplySettings(*InputHandler::GetSingleton(), state.stagedSettings); + } } void ResetToDefaults() @@ -121,7 +148,7 @@ namespace } ImGuiMCP::SameLine(); if (ImGuiMCP::Button("Reload from config")) { - ReloadFromConfig(); + ReloadFromConfig(true); } ImGuiMCP::SameLine(); if (ImGuiMCP::Button("Reset to defaults")) { @@ -133,7 +160,7 @@ namespace void HoldFastMenuUI::Register() { - if (!SKSEMenuFramework::IsInstalled()) { + if (!IsFrameworkInstalled()) { logger::info("SKSE Menu Framework not installed — in-game settings menu disabled (INI config still available)"); return; } @@ -147,7 +174,7 @@ void HoldFastMenuUI::Register() return; } - ReloadFromConfig(); + ReloadFromConfig(false); SKSEMenuFramework::SetSection("HoldFast"); SKSEMenuFramework::AddSectionItem("Settings", RenderSettings); logger::info("SKSE Menu Framework integration registered"); @@ -155,13 +182,10 @@ void HoldFastMenuUI::Register() bool HoldFastMenuUI::IsBlockingInput() { - if (!SKSEMenuFramework::IsInstalled()) { - return false; - } - if (!EnsureFrameworkLoaded()) { + if (!IsFrameworkInstalled()) { return false; } - if (!GetProcAddress(menuFramework, "IsAnyBlockingWindowOpened")) { + if (!HasBlockingWindowExport()) { return false; } return SKSEMenuFramework::IsAnyBlockingWindowOpened(); diff --git a/test/PluginTests.cpp b/test/PluginTests.cpp index 22e78cb..4e83c70 100644 --- a/test/PluginTests.cpp +++ b/test/PluginTests.cpp @@ -1,10 +1,13 @@ #include #include +#include "Config.h" #include "Utils.h" using HoldFast::ClampHoldDuration; using HoldFast::TrimWhitespace; +using HoldFast::Config::ActionName; +using HoldFast::Config::ParseAction; TEST_CASE("TrimWhitespace removes leading and trailing whitespace", "[utils]") { @@ -40,3 +43,34 @@ TEST_CASE("ClampHoldDuration clamps and validates values", "[utils]") CHECK(ClampHoldDuration(std::numeric_limits::infinity(), kDefault, kMax) == kDefault); CHECK(ClampHoldDuration(-std::numeric_limits::infinity(), kDefault, kMax) == kDefault); } + +TEST_CASE("ParseAction accepts case-insensitive and trimmed values", "[config]") +{ + using Action = InputHandler::LongPressAction; + + CHECK(ParseAction("Map", "sButtonStartAction", false) == Action::kMap); + CHECK(ParseAction(" map ", "sButtonStartAction", false) == Action::kMap); + CHECK(ParseAction("SyStEm", "sButtonStartAction", false) == Action::kSystem); + CHECK(ParseAction("\tQuickSave\r\n", "sButtonStartAction", false) == Action::kQuickSave); +} + +TEST_CASE("ParseAction supports favourites alias and invalid fallback", "[config]") +{ + using Action = InputHandler::LongPressAction; + + CHECK(ParseAction("Favorites", "sButtonBackAction", false) == Action::kFavorites); + CHECK(ParseAction("Favourites", "sButtonBackAction", false) == Action::kFavorites); + CHECK(ParseAction("not-an-action", "sButtonBackAction", false) == Action::kNone); + CHECK(ParseAction("", "sButtonBackAction", false) == Action::kNone); + CHECK(ParseAction(" ", "sButtonBackAction", false) == Action::kNone); +} + +TEST_CASE("ActionName maps enum values and falls back to None", "[config]") +{ + using Action = InputHandler::LongPressAction; + + CHECK(ActionName(Action::kMap) == "Map"); + CHECK(ActionName(Action::kQuickSave) == "QuickSave"); + CHECK(ActionName(Action::kCharacterSheet) == "CharacterSheet"); + CHECK(ActionName(static_cast(9999)) == "None"); +} From 41a52abd943caf9c4fdfdd4c0d30be34c98c8418 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Sun, 7 Jun 2026 21:30:19 +0100 Subject: [PATCH 03/13] fix(config): extract ParseAction and ActionName into ConfigParsing.cpp Fixes linker failure in PLUGIN_TESTS_ONLY build (r3369989644). ParseAction and ActionName are moved from Config.cpp into a new ConfigParsing.cpp that compiles without SKSE headers. The test target gains ConfigParsing.cpp as a source and the PLUGIN_TESTS_ONLY compile definition to guard out the logger call and PCH inclusion. --- CMakeLists.txt | 5 +++- src/Config.cpp | 50 ------------------------------- src/ConfigParsing.cpp | 69 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 51 deletions(-) create mode 100644 src/ConfigParsing.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index b0415a0..8e5238c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -19,7 +19,9 @@ if(PLUGIN_TESTS_ONLY) include(CTest) include(Catch) - add_executable(${PROJECT_NAME}Tests test/PluginTests.cpp) + add_executable(${PROJECT_NAME}Tests test/PluginTests.cpp src/ConfigParsing.cpp) + + target_compile_definitions(${PROJECT_NAME}Tests PRIVATE PLUGIN_TESTS_ONLY) target_include_directories(${PROJECT_NAME}Tests PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src") @@ -85,6 +87,7 @@ else() SOURCES src/Config.h src/Config.cpp + src/ConfigParsing.cpp src/InputHandler.h src/InputHandler.cpp src/MenuUI.h diff --git a/src/Config.cpp b/src/Config.cpp index c3b848b..d968b35 100644 --- a/src/Config.cpp +++ b/src/Config.cpp @@ -1,8 +1,5 @@ #include "PCH.h" -#include -#include - #include "Config.h" #include "Utils.h" @@ -99,50 +96,3 @@ void HoldFast::Config::ApplySettings(InputHandler& handler, const Settings& sett handler.SetButtons(BuildButtons(settings)); handler.UpdateShortPressBinding(); } - -InputHandler::LongPressAction HoldFast::Config::ParseAction(std::string_view raw, const char* sourceKey, bool logWarnings) -{ - static const std::unordered_map kActionMap{ - { "map", LongPressAction::kMap }, - { "system", LongPressAction::kSystem }, - { "quests", LongPressAction::kQuests }, - { "stats", LongPressAction::kStats }, - { "inventory", LongPressAction::kInventory }, - { "magic", LongPressAction::kMagic }, - { "favorites", LongPressAction::kFavorites }, - { "favourites", LongPressAction::kFavorites }, - { "tweenmenu", LongPressAction::kTweenMenu }, - { "wait", LongPressAction::kWait }, - { "newsave", LongPressAction::kNewSave }, - { "quicksave", LongPressAction::kQuickSave }, - { "bestiary", LongPressAction::kBestiary }, - { "charactersheet", LongPressAction::kCharacterSheet }, - { "none", LongPressAction::kNone }, - }; - - const auto trimmed = HoldFast::TrimWhitespace(raw); - std::string lower{ trimmed }; - std::ranges::transform(lower, lower.begin(), [](unsigned char c) { - return static_cast(std::tolower(c)); - }); - - const auto it = kActionMap.find(lower); - if (it != kActionMap.end()) { - return it->second; - } - if (logWarnings) { - logger::warn("{}='{}' is not a recognised action (valid: Map, System, Quests, Stats, Inventory, Magic, Favorites/Favourites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, None) — disabling button", - sourceKey, raw); - } - return LongPressAction::kNone; -} - -std::string_view HoldFast::Config::ActionName(LongPressAction action) -{ - for (const auto& option : kActionOptions) { - if (option.action == action) { - return option.name; - } - } - return "None"; -} diff --git a/src/ConfigParsing.cpp b/src/ConfigParsing.cpp new file mode 100644 index 0000000..55c4c17 --- /dev/null +++ b/src/ConfigParsing.cpp @@ -0,0 +1,69 @@ +#ifndef PLUGIN_TESTS_ONLY +# include "PCH.h" +#else +# include +# include +#endif + +#include +#include + +#include "Config.h" +#include "Utils.h" + +namespace +{ + using LongPressAction = InputHandler::LongPressAction; +} + +InputHandler::LongPressAction HoldFast::Config::ParseAction(std::string_view raw, const char* sourceKey, bool logWarnings) +{ + static const std::unordered_map kActionMap{ + { "map", LongPressAction::kMap }, + { "system", LongPressAction::kSystem }, + { "quests", LongPressAction::kQuests }, + { "stats", LongPressAction::kStats }, + { "inventory", LongPressAction::kInventory }, + { "magic", LongPressAction::kMagic }, + { "favorites", LongPressAction::kFavorites }, + { "favourites", LongPressAction::kFavorites }, + { "tweenmenu", LongPressAction::kTweenMenu }, + { "wait", LongPressAction::kWait }, + { "newsave", LongPressAction::kNewSave }, + { "quicksave", LongPressAction::kQuickSave }, + { "bestiary", LongPressAction::kBestiary }, + { "charactersheet", LongPressAction::kCharacterSheet }, + { "none", LongPressAction::kNone }, + }; + + const auto trimmed = HoldFast::TrimWhitespace(raw); + std::string lower{ trimmed }; + std::ranges::transform(lower, lower.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + + const auto it = kActionMap.find(lower); + if (it != kActionMap.end()) { + return it->second; + } +#ifndef PLUGIN_TESTS_ONLY + if (logWarnings) { + logger::warn("{}='{}' is not a recognised action (valid: Map, System, Quests, Stats, Inventory, Magic, Favorites/Favourites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, None) — disabling button", + sourceKey, raw); + } +#else + (void)logWarnings; + (void)sourceKey; +#endif + return LongPressAction::kNone; +} + +std::string_view HoldFast::Config::ActionName(LongPressAction action) +{ + for (const auto& option : kActionOptions) { + if (option.action == action) { + return option.name; + } + } + return "None"; +} From c8df5032fad1d60b3920fef43e4b0317f8881720 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Sun, 7 Jun 2026 21:32:06 +0100 Subject: [PATCH 04/13] fix(menu-ui): use backslash path in LoadLibraryW (r3369989651) --- src/MenuUI.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/MenuUI.cpp b/src/MenuUI.cpp index 4ea83a1..8ca80ec 100644 --- a/src/MenuUI.cpp +++ b/src/MenuUI.cpp @@ -24,7 +24,7 @@ namespace if (menuFramework) { return true; } - menuFramework = LoadLibraryW(L"Data/SKSE/Plugins/SKSEMenuFramework.dll"); + menuFramework = LoadLibraryW(LR"(Data\SKSE\Plugins\SKSEMenuFramework.dll)"); return menuFramework != nullptr; } From bf4040f4ea9bd184143e043d5487ccbc73296739 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Sun, 7 Jun 2026 21:33:17 +0100 Subject: [PATCH 05/13] fix(config): add missing vector include to Config.h (r3369989658) --- src/Config.h | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Config.h b/src/Config.h index 158a507..b82bd2a 100644 --- a/src/Config.h +++ b/src/Config.h @@ -2,6 +2,7 @@ #include #include +#include #include "InputHandler.h" From d0f69ea23810ac4cbd2f793a23a19e7ebc8718d1 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Sun, 7 Jun 2026 21:39:59 +0100 Subject: [PATCH 06/13] refactor(config): remove logWarnings param and ifdef guards from ParseAction --- CMakeLists.txt | 2 -- src/Config.cpp | 10 ++++++++-- src/Config.h | 2 +- src/ConfigParsing.cpp | 29 +++++------------------------ test/PluginTests.cpp | 18 +++++++++--------- 5 files changed, 23 insertions(+), 38 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 8e5238c..25e69cb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -21,8 +21,6 @@ if(PLUGIN_TESTS_ONLY) add_executable(${PROJECT_NAME}Tests test/PluginTests.cpp src/ConfigParsing.cpp) - target_compile_definitions(${PROJECT_NAME}Tests PRIVATE PLUGIN_TESTS_ONLY) - target_include_directories(${PROJECT_NAME}Tests PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src") target_link_libraries(${PROJECT_NAME}Tests PRIVATE Catch2::Catch2WithMain) diff --git a/src/Config.cpp b/src/Config.cpp index d968b35..ad93709 100644 --- a/src/Config.cpp +++ b/src/Config.cpp @@ -51,8 +51,14 @@ HoldFast::Config::Settings HoldFast::Config::LoadSettings() return settings; } - settings.startAction = hasStart ? ParseAction(rawStart, "sButtonStartAction", true) : LongPressAction::kNone; - settings.backAction = hasBack ? ParseAction(rawBack, "sButtonBackAction", true) : LongPressAction::kNone; + settings.startAction = hasStart ? ParseAction(rawStart) : LongPressAction::kNone; + if (hasStart && settings.startAction == LongPressAction::kNone && HoldFast::TrimWhitespace(rawStart) != "none") { + logger::warn("sButtonStartAction='{}' is not a recognised action (valid: Map, System, Quests, Stats, Inventory, Magic, Favorites/Favourites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, None) — disabling button", rawStart); + } + settings.backAction = hasBack ? ParseAction(rawBack) : LongPressAction::kNone; + if (hasBack && settings.backAction == LongPressAction::kNone && HoldFast::TrimWhitespace(rawBack) != "none") { + logger::warn("sButtonBackAction='{}' is not a recognised action (valid: Map, System, Quests, Stats, Inventory, Magic, Favorites/Favourites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, None) — disabling button", rawBack); + } return settings; } diff --git a/src/Config.h b/src/Config.h index b82bd2a..3b1d589 100644 --- a/src/Config.h +++ b/src/Config.h @@ -44,6 +44,6 @@ namespace HoldFast::Config [[nodiscard]] std::vector BuildButtons(const Settings& settings); void ApplySettings(InputHandler& handler, const Settings& settings); - [[nodiscard]] InputHandler::LongPressAction ParseAction(std::string_view raw, const char* sourceKey, bool logWarnings); + [[nodiscard]] InputHandler::LongPressAction ParseAction(std::string_view raw); [[nodiscard]] std::string_view ActionName(InputHandler::LongPressAction action); } diff --git a/src/ConfigParsing.cpp b/src/ConfigParsing.cpp index 55c4c17..a66bb44 100644 --- a/src/ConfigParsing.cpp +++ b/src/ConfigParsing.cpp @@ -1,10 +1,3 @@ -#ifndef PLUGIN_TESTS_ONLY -# include "PCH.h" -#else -# include -# include -#endif - #include #include @@ -16,7 +9,7 @@ namespace using LongPressAction = InputHandler::LongPressAction; } -InputHandler::LongPressAction HoldFast::Config::ParseAction(std::string_view raw, const char* sourceKey, bool logWarnings) +InputHandler::LongPressAction HoldFast::Config::ParseAction(std::string_view raw) { static const std::unordered_map kActionMap{ { "map", LongPressAction::kMap }, @@ -38,24 +31,12 @@ InputHandler::LongPressAction HoldFast::Config::ParseAction(std::string_view raw const auto trimmed = HoldFast::TrimWhitespace(raw); std::string lower{ trimmed }; - std::ranges::transform(lower, lower.begin(), [](unsigned char c) { - return static_cast(std::tolower(c)); - }); + for (auto& c : lower) { + c = static_cast(std::tolower(static_cast(c))); + } const auto it = kActionMap.find(lower); - if (it != kActionMap.end()) { - return it->second; - } -#ifndef PLUGIN_TESTS_ONLY - if (logWarnings) { - logger::warn("{}='{}' is not a recognised action (valid: Map, System, Quests, Stats, Inventory, Magic, Favorites/Favourites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, None) — disabling button", - sourceKey, raw); - } -#else - (void)logWarnings; - (void)sourceKey; -#endif - return LongPressAction::kNone; + return it != kActionMap.end() ? it->second : LongPressAction::kNone; } std::string_view HoldFast::Config::ActionName(LongPressAction action) diff --git a/test/PluginTests.cpp b/test/PluginTests.cpp index 4e83c70..6899fe4 100644 --- a/test/PluginTests.cpp +++ b/test/PluginTests.cpp @@ -48,21 +48,21 @@ TEST_CASE("ParseAction accepts case-insensitive and trimmed values", "[config]") { using Action = InputHandler::LongPressAction; - CHECK(ParseAction("Map", "sButtonStartAction", false) == Action::kMap); - CHECK(ParseAction(" map ", "sButtonStartAction", false) == Action::kMap); - CHECK(ParseAction("SyStEm", "sButtonStartAction", false) == Action::kSystem); - CHECK(ParseAction("\tQuickSave\r\n", "sButtonStartAction", false) == Action::kQuickSave); + CHECK(ParseAction("Map") == Action::kMap); + CHECK(ParseAction(" map ") == Action::kMap); + CHECK(ParseAction("SyStEm") == Action::kSystem); + CHECK(ParseAction("\tQuickSave\r\n") == Action::kQuickSave); } TEST_CASE("ParseAction supports favourites alias and invalid fallback", "[config]") { using Action = InputHandler::LongPressAction; - CHECK(ParseAction("Favorites", "sButtonBackAction", false) == Action::kFavorites); - CHECK(ParseAction("Favourites", "sButtonBackAction", false) == Action::kFavorites); - CHECK(ParseAction("not-an-action", "sButtonBackAction", false) == Action::kNone); - CHECK(ParseAction("", "sButtonBackAction", false) == Action::kNone); - CHECK(ParseAction(" ", "sButtonBackAction", false) == Action::kNone); + CHECK(ParseAction("Favorites") == Action::kFavorites); + CHECK(ParseAction("Favourites") == Action::kFavorites); + CHECK(ParseAction("not-an-action") == Action::kNone); + CHECK(ParseAction("") == Action::kNone); + CHECK(ParseAction(" ") == Action::kNone); } TEST_CASE("ActionName maps enum values and falls back to None", "[config]") From ce6cd67c51aaa21067c92eb329ab1ad691c3ba16 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Sun, 7 Jun 2026 21:50:52 +0100 Subject: [PATCH 07/13] fix(config): preserve INI format on save Use SetSpaces(false) to match the existing no-space = style. Use fmt::format with {:g} instead of SetDoubleValue to avoid the %f six-decimal output (0.5 stays 0.5, not 0.500000). --- src/Config.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Config.cpp b/src/Config.cpp index ad93709..4f2c23f 100644 --- a/src/Config.cpp +++ b/src/Config.cpp @@ -65,12 +65,14 @@ HoldFast::Config::Settings HoldFast::Config::LoadSettings() bool HoldFast::Config::SaveSettings(const Settings& settings) { CSimpleIniA ini; + ini.SetSpaces(false); ini.LoadFile(kIniPath); const auto startActionName = std::string{ ActionName(settings.startAction) }; const auto backActionName = std::string{ ActionName(settings.backAction) }; - ini.SetDoubleValue("General", "fHoldDuration", static_cast(settings.holdDuration)); + const auto holdDurationStr = fmt::format("{:g}", settings.holdDuration); + ini.SetValue("General", "fHoldDuration", holdDurationStr.c_str()); ini.SetValue("General", "sButtonStartAction", startActionName.c_str()); ini.SetValue("General", "sButtonBackAction", backActionName.c_str()); From 94f644200cc7e9f6a1465dc173dbd34bab396edf Mon Sep 17 00:00:00 2001 From: codepuncher Date: Sun, 7 Jun 2026 21:59:57 +0100 Subject: [PATCH 08/13] refactor(config): extract LongPressAction and ButtonConfig to RE-free header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes two review comments (r3370054355, r3370054365, r3370054338, r3370054350). LongPressAction enum, ButtonConfig struct, and hold-duration constants are moved to src/LongPressAction.h (stdlib-only, no RE:: dependency). InputHandler.h includes it and adds using aliases so InputHandler::LongPressAction and InputHandler::ButtonConfig remain valid throughout the plugin code. Config.h replaces its InputHandler.h include with LongPressAction.h and a forward declaration of InputHandler, making config parsing headers RE-free — the PLUGIN_TESTS_ONLY build can now compile without CommonLibSSE. Also fixes None/NONE/etc triggering a spurious invalid-action warning: the guard now lowercases the raw value before comparing against 'none'. --- CMakeLists.txt | 1 + src/Config.cpp | 37 +++++++++++++++++++++----------- src/Config.h | 50 ++++++++++++++++++++++--------------------- src/ConfigParsing.cpp | 7 +----- src/InputHandler.h | 33 ++++++---------------------- src/LongPressAction.h | 35 ++++++++++++++++++++++++++++++ src/MenuUI.cpp | 1 + test/PluginTests.cpp | 11 +++++++--- 8 files changed, 102 insertions(+), 73 deletions(-) create mode 100644 src/LongPressAction.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 25e69cb..6920134 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -88,6 +88,7 @@ else() src/ConfigParsing.cpp src/InputHandler.h src/InputHandler.cpp + src/LongPressAction.h src/MenuUI.h src/MenuUI.cpp src/Plugin.cpp diff --git a/src/Config.cpp b/src/Config.cpp index 4f2c23f..e3e76c7 100644 --- a/src/Config.cpp +++ b/src/Config.cpp @@ -1,30 +1,29 @@ #include "PCH.h" #include "Config.h" +#include "InputHandler.h" #include "Utils.h" namespace { constexpr auto kIniPath = R"(Data\SKSE\Plugins\HoldFast.ini)"; - using LongPressAction = InputHandler::LongPressAction; - [[nodiscard]] float ReadHoldDuration(const CSimpleIniA& ini) { - const auto raw = static_cast(ini.GetDoubleValue("General", "fHoldDuration", InputHandler::kDefaultHoldDuration)); - const auto duration = HoldFast::ClampHoldDuration(raw, InputHandler::kDefaultHoldDuration, InputHandler::kMaxHoldDuration); + const auto raw = static_cast(ini.GetDoubleValue("General", "fHoldDuration", HoldFast::kDefaultHoldDuration)); + const auto duration = HoldFast::ClampHoldDuration(raw, HoldFast::kDefaultHoldDuration, HoldFast::kMaxHoldDuration); if (duration == raw) { return duration; } if (!std::isfinite(raw)) { - logger::warn("fHoldDuration is non-finite — using default {:.1f}", InputHandler::kDefaultHoldDuration); + logger::warn("fHoldDuration is non-finite — using default {:.1f}", HoldFast::kDefaultHoldDuration); return duration; } if (raw <= 0.0F) { - logger::warn("fHoldDuration ({:.2f}) must be positive — using default {:.1f}", raw, InputHandler::kDefaultHoldDuration); + logger::warn("fHoldDuration ({:.2f}) must be positive — using default {:.1f}", raw, HoldFast::kDefaultHoldDuration); return duration; } - logger::warn("fHoldDuration ({:.2f}) exceeds maximum {:.1f} — capping", raw, InputHandler::kMaxHoldDuration); + logger::warn("fHoldDuration ({:.2f}) exceeds maximum {:.1f} — capping", raw, HoldFast::kMaxHoldDuration); return duration; } } @@ -52,12 +51,24 @@ HoldFast::Config::Settings HoldFast::Config::LoadSettings() } settings.startAction = hasStart ? ParseAction(rawStart) : LongPressAction::kNone; - if (hasStart && settings.startAction == LongPressAction::kNone && HoldFast::TrimWhitespace(rawStart) != "none") { - logger::warn("sButtonStartAction='{}' is not a recognised action (valid: Map, System, Quests, Stats, Inventory, Magic, Favorites/Favourites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, None) — disabling button", rawStart); + if (hasStart && settings.startAction == LongPressAction::kNone) { + std::string lower{ HoldFast::TrimWhitespace(rawStart) }; + for (auto& c : lower) { + c = static_cast(std::tolower(static_cast(c))); + } + if (lower != "none") { + logger::warn("sButtonStartAction='{}' is not a recognised action (valid: Map, System, Quests, Stats, Inventory, Magic, Favorites/Favourites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, None) — disabling button", rawStart); + } } settings.backAction = hasBack ? ParseAction(rawBack) : LongPressAction::kNone; - if (hasBack && settings.backAction == LongPressAction::kNone && HoldFast::TrimWhitespace(rawBack) != "none") { - logger::warn("sButtonBackAction='{}' is not a recognised action (valid: Map, System, Quests, Stats, Inventory, Magic, Favorites/Favourites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, None) — disabling button", rawBack); + if (hasBack && settings.backAction == LongPressAction::kNone) { + std::string lower{ HoldFast::TrimWhitespace(rawBack) }; + for (auto& c : lower) { + c = static_cast(std::tolower(static_cast(c))); + } + if (lower != "none") { + logger::warn("sButtonBackAction='{}' is not a recognised action (valid: Map, System, Quests, Stats, Inventory, Magic, Favorites/Favourites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, None) — disabling button", rawBack); + } } return settings; } @@ -84,11 +95,11 @@ bool HoldFast::Config::SaveSettings(const Settings& settings) return true; } -std::vector HoldFast::Config::BuildButtons(const Settings& settings) +std::vector HoldFast::Config::BuildButtons(const Settings& settings) { using Key = RE::BSWin32GamepadDevice::Key; - std::vector buttons; + std::vector buttons; if (settings.startAction != LongPressAction::kNone) { buttons.push_back({ .keyCode = static_cast(Key::kStart), .name = "Start", .action = settings.startAction }); } diff --git a/src/Config.h b/src/Config.h index 3b1d589..8d66b36 100644 --- a/src/Config.h +++ b/src/Config.h @@ -4,46 +4,48 @@ #include #include -#include "InputHandler.h" +#include "LongPressAction.h" + +class InputHandler; namespace HoldFast::Config { struct Settings { - float holdDuration{ InputHandler::kDefaultHoldDuration }; - InputHandler::LongPressAction startAction{ InputHandler::LongPressAction::kMap }; - InputHandler::LongPressAction backAction{ InputHandler::LongPressAction::kSystem }; + float holdDuration{ HoldFast::kDefaultHoldDuration }; + LongPressAction startAction{ LongPressAction::kMap }; + LongPressAction backAction{ LongPressAction::kSystem }; }; struct ActionOption { - std::string_view name; - InputHandler::LongPressAction action; + std::string_view name; + LongPressAction action; }; inline constexpr std::array kActionOptions{ { - { "Map", InputHandler::LongPressAction::kMap }, - { "System", InputHandler::LongPressAction::kSystem }, - { "Quests", InputHandler::LongPressAction::kQuests }, - { "Stats", InputHandler::LongPressAction::kStats }, - { "Inventory", InputHandler::LongPressAction::kInventory }, - { "Magic", InputHandler::LongPressAction::kMagic }, - { "Favorites", InputHandler::LongPressAction::kFavorites }, - { "TweenMenu", InputHandler::LongPressAction::kTweenMenu }, - { "Wait", InputHandler::LongPressAction::kWait }, - { "NewSave", InputHandler::LongPressAction::kNewSave }, - { "QuickSave", InputHandler::LongPressAction::kQuickSave }, - { "Bestiary", InputHandler::LongPressAction::kBestiary }, - { "CharacterSheet", InputHandler::LongPressAction::kCharacterSheet }, - { "None", InputHandler::LongPressAction::kNone }, + { "Map", LongPressAction::kMap }, + { "System", LongPressAction::kSystem }, + { "Quests", LongPressAction::kQuests }, + { "Stats", LongPressAction::kStats }, + { "Inventory", LongPressAction::kInventory }, + { "Magic", LongPressAction::kMagic }, + { "Favorites", LongPressAction::kFavorites }, + { "TweenMenu", LongPressAction::kTweenMenu }, + { "Wait", LongPressAction::kWait }, + { "NewSave", LongPressAction::kNewSave }, + { "QuickSave", LongPressAction::kQuickSave }, + { "Bestiary", LongPressAction::kBestiary }, + { "CharacterSheet", LongPressAction::kCharacterSheet }, + { "None", LongPressAction::kNone }, } }; [[nodiscard]] Settings LoadSettings(); [[nodiscard]] bool SaveSettings(const Settings& settings); - [[nodiscard]] std::vector BuildButtons(const Settings& settings); - void ApplySettings(InputHandler& handler, const Settings& settings); + [[nodiscard]] std::vector BuildButtons(const Settings& settings); + void ApplySettings(InputHandler& handler, const Settings& settings); - [[nodiscard]] InputHandler::LongPressAction ParseAction(std::string_view raw); - [[nodiscard]] std::string_view ActionName(InputHandler::LongPressAction action); + [[nodiscard]] LongPressAction ParseAction(std::string_view raw); + [[nodiscard]] std::string_view ActionName(LongPressAction action); } diff --git a/src/ConfigParsing.cpp b/src/ConfigParsing.cpp index a66bb44..4f86778 100644 --- a/src/ConfigParsing.cpp +++ b/src/ConfigParsing.cpp @@ -4,12 +4,7 @@ #include "Config.h" #include "Utils.h" -namespace -{ - using LongPressAction = InputHandler::LongPressAction; -} - -InputHandler::LongPressAction HoldFast::Config::ParseAction(std::string_view raw) +LongPressAction HoldFast::Config::ParseAction(std::string_view raw) { static const std::unordered_map kActionMap{ { "map", LongPressAction::kMap }, diff --git a/src/InputHandler.h b/src/InputHandler.h index f9e43a8..dd07592 100644 --- a/src/InputHandler.h +++ b/src/InputHandler.h @@ -1,12 +1,13 @@ #pragma once #include -#include #include #include #include #include +#include "LongPressAction.h" + class InputHandler : public RE::BSTEventSink, public RE::BSTEventSink @@ -25,33 +26,11 @@ class InputHandler : const RE::MenuOpenCloseEvent* a_event, RE::BSTEventSource* a_source) override; - static constexpr float kDefaultHoldDuration{ 0.5F }; - static constexpr float kMaxHoldDuration{ 5.0F }; - - enum class LongPressAction - { - kNone, - kMap, - kSystem, - kQuests, - kStats, - kInventory, - kMagic, - kFavorites, - kTweenMenu, - kWait, - kNewSave, - kQuickSave, - kBestiary, - kCharacterSheet, - }; + static constexpr float kDefaultHoldDuration{ HoldFast::kDefaultHoldDuration }; + static constexpr float kMaxHoldDuration{ HoldFast::kMaxHoldDuration }; - struct ButtonConfig - { - std::uint32_t keyCode{}; - std::string name; - LongPressAction action{ LongPressAction::kNone }; - }; + using LongPressAction = ::LongPressAction; + using ButtonConfig = ::ButtonConfig; void SetHoldDuration(float a_duration) noexcept { holdDuration = a_duration; } diff --git a/src/LongPressAction.h b/src/LongPressAction.h new file mode 100644 index 0000000..ebbdd7b --- /dev/null +++ b/src/LongPressAction.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include + +namespace HoldFast +{ + inline constexpr float kDefaultHoldDuration = 0.5F; + inline constexpr float kMaxHoldDuration = 5.0F; +} + +enum class LongPressAction +{ + kNone, + kMap, + kSystem, + kQuests, + kStats, + kInventory, + kMagic, + kFavorites, + kTweenMenu, + kWait, + kNewSave, + kQuickSave, + kBestiary, + kCharacterSheet, +}; + +struct ButtonConfig +{ + std::uint32_t keyCode{}; + std::string name; + LongPressAction action{ LongPressAction::kNone }; +}; diff --git a/src/MenuUI.cpp b/src/MenuUI.cpp index 8ca80ec..c495162 100644 --- a/src/MenuUI.cpp +++ b/src/MenuUI.cpp @@ -3,6 +3,7 @@ #include #include "Config.h" +#include "InputHandler.h" #include "MenuUI.h" #include "SKSEMCP/utils.hpp" #include "Utils.h" diff --git a/test/PluginTests.cpp b/test/PluginTests.cpp index 6899fe4..abaf981 100644 --- a/test/PluginTests.cpp +++ b/test/PluginTests.cpp @@ -46,7 +46,7 @@ TEST_CASE("ClampHoldDuration clamps and validates values", "[utils]") TEST_CASE("ParseAction accepts case-insensitive and trimmed values", "[config]") { - using Action = InputHandler::LongPressAction; + using Action = LongPressAction; CHECK(ParseAction("Map") == Action::kMap); CHECK(ParseAction(" map ") == Action::kMap); @@ -56,18 +56,23 @@ TEST_CASE("ParseAction accepts case-insensitive and trimmed values", "[config]") TEST_CASE("ParseAction supports favourites alias and invalid fallback", "[config]") { - using Action = InputHandler::LongPressAction; + using Action = LongPressAction; CHECK(ParseAction("Favorites") == Action::kFavorites); CHECK(ParseAction("Favourites") == Action::kFavorites); CHECK(ParseAction("not-an-action") == Action::kNone); CHECK(ParseAction("") == Action::kNone); CHECK(ParseAction(" ") == Action::kNone); + + // None in any casing/whitespace must parse to kNone without warning + CHECK(ParseAction("None") == Action::kNone); + CHECK(ParseAction("NONE") == Action::kNone); + CHECK(ParseAction(" None ") == Action::kNone); } TEST_CASE("ActionName maps enum values and falls back to None", "[config]") { - using Action = InputHandler::LongPressAction; + using Action = LongPressAction; CHECK(ActionName(Action::kMap) == "Map"); CHECK(ActionName(Action::kQuickSave) == "QuickSave"); From ff73bfb4bcd5b6d93828a18bdae10803689d52ed Mon Sep 17 00:00:00 2001 From: codepuncher Date: Sun, 7 Jun 2026 22:08:02 +0100 Subject: [PATCH 09/13] fix(plugin): remove unused includes and dead null-check --- src/MenuUI.cpp | 2 -- src/PCH.h | 1 - src/Plugin.cpp | 4 ---- 3 files changed, 7 deletions(-) diff --git a/src/MenuUI.cpp b/src/MenuUI.cpp index c495162..22eae36 100644 --- a/src/MenuUI.cpp +++ b/src/MenuUI.cpp @@ -1,7 +1,5 @@ #include "PCH.h" -#include - #include "Config.h" #include "InputHandler.h" #include "MenuUI.h" diff --git a/src/PCH.h b/src/PCH.h index a1b39f9..6b0b25a 100644 --- a/src/PCH.h +++ b/src/PCH.h @@ -15,7 +15,6 @@ #include #include #include -#include #include #include #include diff --git a/src/Plugin.cpp b/src/Plugin.cpp index 7bbdf99..540970d 100644 --- a/src/Plugin.cpp +++ b/src/Plugin.cpp @@ -35,10 +35,6 @@ void SetupLog() void OnInputLoaded() { auto* handler = InputHandler::GetSingleton(); - if (!handler) { - logger::error("Failed to get InputHandler singleton"); - return; - } const auto settings = HoldFast::Config::LoadSettings(); logger::info("Hold duration: {:.2f}s", settings.holdDuration); From b2be64ac14a565035cf9290701857516dcb70e14 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Sun, 7 Jun 2026 22:15:55 +0100 Subject: [PATCH 10/13] fix(config): add cctype include and document menuFramework source --- src/Config.cpp | 2 ++ src/MenuUI.cpp | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/src/Config.cpp b/src/Config.cpp index e3e76c7..97c3a5f 100644 --- a/src/Config.cpp +++ b/src/Config.cpp @@ -1,5 +1,7 @@ #include "PCH.h" +#include + #include "Config.h" #include "InputHandler.h" #include "Utils.h" diff --git a/src/MenuUI.cpp b/src/MenuUI.cpp index 22eae36..53b1de4 100644 --- a/src/MenuUI.cpp +++ b/src/MenuUI.cpp @@ -14,6 +14,10 @@ namespace return installed; } + // menuFramework (HMODULE) is declared as a file-scope static in + // SKSEMenuFramework.hpp (pulled in via SKSEMCP/utils.hpp). Do not + // redeclare it here — that would shadow the variable used by every + // GetProcAddress call inside that header. bool EnsureFrameworkLoaded() { if (menuFramework) { From da44bff29636584dd6d9c51137fb9563d852d8dd Mon Sep 17 00:00:00 2001 From: codepuncher Date: Sun, 7 Jun 2026 22:27:11 +0100 Subject: [PATCH 11/13] fix(menu): remove per-frame allocs and align hold duration minimum --- src/Config.cpp | 2 +- src/Config.h | 8 ++++---- src/ConfigParsing.cpp | 2 +- src/InputHandler.h | 1 + src/LongPressAction.h | 1 + src/MenuUI.cpp | 13 ++++++------- src/Utils.h | 6 +++--- test/PluginTests.cpp | 27 ++++++++++++++++----------- 8 files changed, 33 insertions(+), 27 deletions(-) diff --git a/src/Config.cpp b/src/Config.cpp index 97c3a5f..d75cdd1 100644 --- a/src/Config.cpp +++ b/src/Config.cpp @@ -13,7 +13,7 @@ namespace [[nodiscard]] float ReadHoldDuration(const CSimpleIniA& ini) { const auto raw = static_cast(ini.GetDoubleValue("General", "fHoldDuration", HoldFast::kDefaultHoldDuration)); - const auto duration = HoldFast::ClampHoldDuration(raw, HoldFast::kDefaultHoldDuration, HoldFast::kMaxHoldDuration); + const auto duration = HoldFast::ClampHoldDuration(raw, HoldFast::kDefaultHoldDuration, HoldFast::kMinHoldDuration, HoldFast::kMaxHoldDuration); if (duration == raw) { return duration; } diff --git a/src/Config.h b/src/Config.h index 8d66b36..8349897 100644 --- a/src/Config.h +++ b/src/Config.h @@ -19,8 +19,8 @@ namespace HoldFast::Config struct ActionOption { - std::string_view name; - LongPressAction action; + const char* name; + LongPressAction action; }; inline constexpr std::array kActionOptions{ { @@ -46,6 +46,6 @@ namespace HoldFast::Config [[nodiscard]] std::vector BuildButtons(const Settings& settings); void ApplySettings(InputHandler& handler, const Settings& settings); - [[nodiscard]] LongPressAction ParseAction(std::string_view raw); - [[nodiscard]] std::string_view ActionName(LongPressAction action); + [[nodiscard]] LongPressAction ParseAction(std::string_view raw); + [[nodiscard]] const char* ActionName(LongPressAction action); } diff --git a/src/ConfigParsing.cpp b/src/ConfigParsing.cpp index 4f86778..0dc7a55 100644 --- a/src/ConfigParsing.cpp +++ b/src/ConfigParsing.cpp @@ -34,7 +34,7 @@ LongPressAction HoldFast::Config::ParseAction(std::string_view raw) return it != kActionMap.end() ? it->second : LongPressAction::kNone; } -std::string_view HoldFast::Config::ActionName(LongPressAction action) +const char* HoldFast::Config::ActionName(LongPressAction action) { for (const auto& option : kActionOptions) { if (option.action == action) { diff --git a/src/InputHandler.h b/src/InputHandler.h index dd07592..3a37dfb 100644 --- a/src/InputHandler.h +++ b/src/InputHandler.h @@ -26,6 +26,7 @@ class InputHandler : const RE::MenuOpenCloseEvent* a_event, RE::BSTEventSource* a_source) override; + static constexpr float kMinHoldDuration{ HoldFast::kMinHoldDuration }; static constexpr float kDefaultHoldDuration{ HoldFast::kDefaultHoldDuration }; static constexpr float kMaxHoldDuration{ HoldFast::kMaxHoldDuration }; diff --git a/src/LongPressAction.h b/src/LongPressAction.h index ebbdd7b..0692798 100644 --- a/src/LongPressAction.h +++ b/src/LongPressAction.h @@ -5,6 +5,7 @@ namespace HoldFast { + inline constexpr float kMinHoldDuration = 0.1F; inline constexpr float kDefaultHoldDuration = 0.5F; inline constexpr float kMaxHoldDuration = 5.0F; } diff --git a/src/MenuUI.cpp b/src/MenuUI.cpp index 53b1de4..6ce247f 100644 --- a/src/MenuUI.cpp +++ b/src/MenuUI.cpp @@ -71,17 +71,15 @@ namespace bool DrawActionCombo(const char* label, InputHandler::LongPressAction& value) { - const auto preview = HoldFast::Config::ActionName(value); - const std::string previewText{ preview }; - if (!ImGuiMCP::BeginCombo(label, previewText.c_str())) { + const char* preview = HoldFast::Config::ActionName(value); + if (!ImGuiMCP::BeginCombo(label, preview)) { return false; } bool changed = false; for (const auto& option : HoldFast::Config::kActionOptions) { - const bool isSelected = (option.action == value); - const std::string optionName{ option.name }; - if (ImGuiMCP::Selectable(optionName.c_str(), isSelected)) { + const bool isSelected = (option.action == value); + if (ImGuiMCP::Selectable(option.name, isSelected)) { value = option.action; changed = true; } @@ -100,6 +98,7 @@ namespace state.stagedSettings.holdDuration = HoldFast::ClampHoldDuration( state.stagedSettings.holdDuration, InputHandler::kDefaultHoldDuration, + InputHandler::kMinHoldDuration, InputHandler::kMaxHoldDuration); const auto* plugin = SKSE::PluginDeclaration::GetSingleton(); @@ -138,7 +137,7 @@ namespace { auto& state = GetMenuState(); bool changed = false; - changed |= ImGuiMCP::SliderFloat("Hold duration", &state.stagedSettings.holdDuration, 0.1F, InputHandler::kMaxHoldDuration, "%.2fs"); + changed |= ImGuiMCP::SliderFloat("Hold duration", &state.stagedSettings.holdDuration, InputHandler::kMinHoldDuration, InputHandler::kMaxHoldDuration, "%.2fs"); changed |= DrawActionCombo("Start long-press action", state.stagedSettings.startAction); changed |= DrawActionCombo("Back long-press action", state.stagedSettings.backAction); diff --git a/src/Utils.h b/src/Utils.h index 5fb60cf..e364b37 100644 --- a/src/Utils.h +++ b/src/Utils.h @@ -16,10 +16,10 @@ namespace HoldFast return s.substr(first, s.find_last_not_of(" \t\r\n") - first + 1); } - [[nodiscard]] inline float ClampHoldDuration(float value, float defaultVal, float maxVal) + [[nodiscard]] inline float ClampHoldDuration(float value, float defaultVal, float minVal, float maxVal) { - assert(defaultVal > 0.0F && defaultVal <= maxVal); - if (!std::isfinite(value) || value <= 0.0F) { + assert(minVal > 0.0F && defaultVal >= minVal && defaultVal <= maxVal); + if (!std::isfinite(value) || value < minVal) { return defaultVal; } if (value > maxVal) { diff --git a/test/PluginTests.cpp b/test/PluginTests.cpp index abaf981..dde5c45 100644 --- a/test/PluginTests.cpp +++ b/test/PluginTests.cpp @@ -23,25 +23,30 @@ TEST_CASE("TrimWhitespace removes leading and trailing whitespace", "[utils]") TEST_CASE("ClampHoldDuration clamps and validates values", "[utils]") { + constexpr float kMin = 0.1F; constexpr float kDefault = 0.5F; constexpr float kMax = 5.0F; - CHECK(ClampHoldDuration(1.0F, kDefault, kMax) == 1.0F); - CHECK(ClampHoldDuration(2.5F, kDefault, kMax) == 2.5F); - CHECK(ClampHoldDuration(5.0F, kDefault, kMax) == 5.0F); + CHECK(ClampHoldDuration(1.0F, kDefault, kMin, kMax) == 1.0F); + CHECK(ClampHoldDuration(2.5F, kDefault, kMin, kMax) == 2.5F); + CHECK(ClampHoldDuration(5.0F, kDefault, kMin, kMax) == 5.0F); - // Below-zero and zero → default - CHECK(ClampHoldDuration(-1.0F, kDefault, kMax) == kDefault); - CHECK(ClampHoldDuration(0.0F, kDefault, kMax) == kDefault); + // At minimum boundary + CHECK(ClampHoldDuration(0.1F, kDefault, kMin, kMax) == 0.1F); + + // Below minimum → default + CHECK(ClampHoldDuration(0.05F, kDefault, kMin, kMax) == kDefault); + CHECK(ClampHoldDuration(-1.0F, kDefault, kMin, kMax) == kDefault); + CHECK(ClampHoldDuration(0.0F, kDefault, kMin, kMax) == kDefault); // Over max → cap at max - CHECK(ClampHoldDuration(5.1F, kDefault, kMax) == kMax); - CHECK(ClampHoldDuration(100.0F, kDefault, kMax) == kMax); + CHECK(ClampHoldDuration(5.1F, kDefault, kMin, kMax) == kMax); + CHECK(ClampHoldDuration(100.0F, kDefault, kMin, kMax) == kMax); // Non-finite → default - CHECK(ClampHoldDuration(std::numeric_limits::quiet_NaN(), kDefault, kMax) == kDefault); - CHECK(ClampHoldDuration(std::numeric_limits::infinity(), kDefault, kMax) == kDefault); - CHECK(ClampHoldDuration(-std::numeric_limits::infinity(), kDefault, kMax) == kDefault); + CHECK(ClampHoldDuration(std::numeric_limits::quiet_NaN(), kDefault, kMin, kMax) == kDefault); + CHECK(ClampHoldDuration(std::numeric_limits::infinity(), kDefault, kMin, kMax) == kDefault); + CHECK(ClampHoldDuration(-std::numeric_limits::infinity(), kDefault, kMin, kMax) == kDefault); } TEST_CASE("ParseAction accepts case-insensitive and trimmed values", "[config]") From 89ae6c024749d5a1d122c6e83f1365e3403c69a8 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Sun, 7 Jun 2026 22:48:37 +0100 Subject: [PATCH 12/13] fix(config): log correct warning for below-minimum hold duration --- src/Config.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Config.cpp b/src/Config.cpp index d75cdd1..9b44490 100644 --- a/src/Config.cpp +++ b/src/Config.cpp @@ -25,6 +25,10 @@ namespace logger::warn("fHoldDuration ({:.2f}) must be positive — using default {:.1f}", raw, HoldFast::kDefaultHoldDuration); return duration; } + if (raw < HoldFast::kMinHoldDuration) { + logger::warn("fHoldDuration ({:.2f}) is below minimum {:.1f} — using default {:.1f}", raw, HoldFast::kMinHoldDuration, HoldFast::kDefaultHoldDuration); + return duration; + } logger::warn("fHoldDuration ({:.2f}) exceeds maximum {:.1f} — capping", raw, HoldFast::kMaxHoldDuration); return duration; } From 04c05576d976972ecade182370aa7edac4d874e8 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Sun, 7 Jun 2026 22:54:15 +0100 Subject: [PATCH 13/13] fix(input): skip IsBlockingInput check when no buttons are tracked --- src/InputHandler.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/InputHandler.cpp b/src/InputHandler.cpp index d815261..b465a9d 100644 --- a/src/InputHandler.cpp +++ b/src/InputHandler.cpp @@ -128,7 +128,7 @@ RE::BSEventNotifyControl InputHandler::ProcessEvent( // If SKSE Menu Framework owns input focus, pass input through and clear held-state // captures so Start/Back interception cannot fight the settings UI. - if (HoldFastMenuUI::IsBlockingInput()) { + if (!_buttons.empty() && HoldFastMenuUI::IsBlockingInput()) { for (auto& bs : _buttons) { bs.pressTime.reset(); bs.triggered = false;