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..6920134 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -19,7 +19,7 @@ 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_include_directories(${PROJECT_NAME}Tests PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src") @@ -83,8 +83,14 @@ else() AUTHOR "codepuncher" SOURCES + src/Config.h + src/Config.cpp + src/ConfigParsing.cpp src/InputHandler.h src/InputHandler.cpp + src/LongPressAction.h + src/MenuUI.h + src/MenuUI.cpp src/Plugin.cpp "${CMAKE_CURRENT_BINARY_DIR}/src/version.rc" USE_ADDRESS_LIBRARY) @@ -102,8 +108,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..9b44490 --- /dev/null +++ b/src/Config.cpp @@ -0,0 +1,123 @@ +#include "PCH.h" + +#include + +#include "Config.h" +#include "InputHandler.h" +#include "Utils.h" + +namespace +{ + constexpr auto kIniPath = R"(Data\SKSE\Plugins\HoldFast.ini)"; + + [[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::kMinHoldDuration, HoldFast::kMaxHoldDuration); + if (duration == raw) { + return duration; + } + if (!std::isfinite(raw)) { + 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, 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; + } +} + +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) : LongPressAction::kNone; + 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) { + 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; +} + +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) }; + + 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()); + + 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(); +} diff --git a/src/Config.h b/src/Config.h new file mode 100644 index 0000000..8349897 --- /dev/null +++ b/src/Config.h @@ -0,0 +1,51 @@ +#pragma once + +#include +#include +#include + +#include "LongPressAction.h" + +class InputHandler; + +namespace HoldFast::Config +{ + struct Settings + { + float holdDuration{ HoldFast::kDefaultHoldDuration }; + LongPressAction startAction{ LongPressAction::kMap }; + LongPressAction backAction{ LongPressAction::kSystem }; + }; + + struct ActionOption + { + const char* name; + LongPressAction action; + }; + + inline constexpr std::array kActionOptions{ { + { "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]] LongPressAction ParseAction(std::string_view raw); + [[nodiscard]] const char* ActionName(LongPressAction action); +} diff --git a/src/ConfigParsing.cpp b/src/ConfigParsing.cpp new file mode 100644 index 0000000..0dc7a55 --- /dev/null +++ b/src/ConfigParsing.cpp @@ -0,0 +1,45 @@ +#include +#include + +#include "Config.h" +#include "Utils.h" + +LongPressAction HoldFast::Config::ParseAction(std::string_view raw) +{ + 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 }; + for (auto& c : lower) { + c = static_cast(std::tolower(static_cast(c))); + } + + const auto it = kActionMap.find(lower); + return it != kActionMap.end() ? it->second : LongPressAction::kNone; +} + +const char* HoldFast::Config::ActionName(LongPressAction action) +{ + for (const auto& option : kActionOptions) { + if (option.action == action) { + return option.name; + } + } + return "None"; +} diff --git a/src/InputHandler.cpp b/src/InputHandler.cpp index 562915b..b465a9d 100644 --- a/src/InputHandler.cpp +++ b/src/InputHandler.cpp @@ -1,6 +1,7 @@ #include "PCH.h" #include "InputHandler.h" +#include "MenuUI.h" namespace { @@ -125,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 (!_buttons.empty() && 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 @@ -141,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) { @@ -161,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..3a37dfb 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,12 @@ 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 kMinHoldDuration{ HoldFast::kMinHoldDuration }; + 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; } @@ -85,6 +65,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/LongPressAction.h b/src/LongPressAction.h new file mode 100644 index 0000000..0692798 --- /dev/null +++ b/src/LongPressAction.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +namespace HoldFast +{ + inline constexpr float kMinHoldDuration = 0.1F; + 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 new file mode 100644 index 0000000..6ce247f --- /dev/null +++ b/src/MenuUI.cpp @@ -0,0 +1,194 @@ +#include "PCH.h" + +#include "Config.h" +#include "InputHandler.h" +#include "MenuUI.h" +#include "SKSEMCP/utils.hpp" +#include "Utils.h" + +namespace +{ + bool IsFrameworkInstalled() + { + static const bool installed = SKSEMenuFramework::IsInstalled(); + 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) { + return true; + } + menuFramework = GetModuleHandleW(L"SKSEMenuFramework.dll"); + if (menuFramework) { + return true; + } + menuFramework = LoadLibraryW(LR"(Data\SKSE\Plugins\SKSEMenuFramework.dll)"); + 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{}; + 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 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); + if (ImGuiMCP::Selectable(option.name, 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::kMinHoldDuration, + 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(bool applyRuntime) + { + auto& state = GetMenuState(); + state.stagedSettings = HoldFast::Config::LoadSettings(); + state.hasPendingChanges = false; + if (applyRuntime) { + 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, InputHandler::kMinHoldDuration, 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(true); + } + ImGuiMCP::SameLine(); + if (ImGuiMCP::Button("Reset to defaults")) { + ResetToDefaults(); + } + } + +} + +void HoldFastMenuUI::Register() +{ + if (!IsFrameworkInstalled()) { + 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(false); + SKSEMenuFramework::SetSection("HoldFast"); + SKSEMenuFramework::AddSectionItem("Settings", RenderSettings); + logger::info("SKSE Menu Framework integration registered"); +} + +bool HoldFastMenuUI::IsBlockingInput() +{ + if (!IsFrameworkInstalled()) { + return false; + } + if (!HasBlockingWindowExport()) { + 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/Plugin.cpp b/src/Plugin.cpp index d7b1868..540970d 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,21 @@ 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)); - } - - 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 +102,7 @@ SKSEPluginLoad(const SKSE::LoadInterface* a_skse) return false; } + HoldFastMenuUI::Register(); + return true; } 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 22e78cb..dde5c45 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]") { @@ -20,23 +23,64 @@ 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]") +{ + using Action = LongPressAction; + + 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 = 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 = LongPressAction; + + CHECK(ActionName(Action::kMap) == "Map"); + CHECK(ActionName(Action::kQuickSave) == "QuickSave"); + CHECK(ActionName(Action::kCharacterSheet) == "CharacterSheet"); + CHECK(ActionName(static_cast(9999)) == "None"); }