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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 11 additions & 3 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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)
Expand All @@ -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}
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |
Expand Down
5 changes: 5 additions & 0 deletions docs/nexus-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions lib/skse-mcp
Submodule skse-mcp added at 547719
123 changes: 123 additions & 0 deletions src/Config.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
#include "PCH.h"

#include <cctype>

#include "Config.h"
#include "InputHandler.h"
#include "Utils.h"

Comment thread
codepuncher marked this conversation as resolved.
namespace
{
constexpr auto kIniPath = R"(Data\SKSE\Plugins\HoldFast.ini)";

[[nodiscard]] float ReadHoldDuration(const CSimpleIniA& ini)
{
const auto raw = static_cast<float>(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);
Comment thread
codepuncher marked this conversation as resolved.
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<int>(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<char>(std::tolower(static_cast<unsigned char>(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);
}
}
Comment thread
codepuncher marked this conversation as resolved.
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<char>(std::tolower(static_cast<unsigned char>(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);
}
}
Comment thread
codepuncher marked this conversation as resolved.
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<int>(rc));
return false;
}
return true;
}

std::vector<ButtonConfig> HoldFast::Config::BuildButtons(const Settings& settings)
{
using Key = RE::BSWin32GamepadDevice::Key;

std::vector<ButtonConfig> buttons;
if (settings.startAction != LongPressAction::kNone) {
buttons.push_back({ .keyCode = static_cast<std::uint32_t>(Key::kStart), .name = "Start", .action = settings.startAction });
}
if (settings.backAction != LongPressAction::kNone) {
buttons.push_back({ .keyCode = static_cast<std::uint32_t>(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();
}
51 changes: 51 additions & 0 deletions src/Config.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#pragma once

#include <array>
#include <string_view>
#include <vector>

#include "LongPressAction.h"

class InputHandler;

Comment thread
codepuncher marked this conversation as resolved.
namespace HoldFast::Config
Comment thread
codepuncher marked this conversation as resolved.
{
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<ActionOption, 14> 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<ButtonConfig> BuildButtons(const Settings& settings);
void ApplySettings(InputHandler& handler, const Settings& settings);

[[nodiscard]] LongPressAction ParseAction(std::string_view raw);
[[nodiscard]] const char* ActionName(LongPressAction action);
}
45 changes: 45 additions & 0 deletions src/ConfigParsing.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#include <cctype>
#include <unordered_map>

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

LongPressAction HoldFast::Config::ParseAction(std::string_view raw)
{
static const std::unordered_map<std::string, LongPressAction> 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<char>(std::tolower(static_cast<unsigned char>(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";
}
28 changes: 22 additions & 6 deletions src/InputHandler.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#include "PCH.h"

#include "InputHandler.h"
#include "MenuUI.h"

namespace
{
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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)
Expand Down
Loading
Loading