From c2b296b2b3ff1bc5e126c1037471c3bb244b35f5 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Sun, 14 Jun 2026 22:25:21 +0100 Subject: [PATCH 01/34] feat(action): add MCM action --- CMakeLists.txt | 2 + HoldFast.ini | 12 +- README.md | 45 +++-- src/Config.cpp | 42 +++- src/Config.h | 7 +- src/ConfigParsing.cpp | 1 + src/InputHandler.cpp | 111 +++++++++- src/InputHandler.h | 39 ++-- src/LongPressAction.h | 3 + src/MCMNavigator.cpp | 457 ++++++++++++++++++++++++++++++++++++++++++ src/MCMNavigator.h | 26 +++ src/MenuUI.cpp | 55 ++++- src/PCH.h | 1 + src/Plugin.cpp | 2 + 14 files changed, 752 insertions(+), 51 deletions(-) create mode 100644 src/MCMNavigator.cpp create mode 100644 src/MCMNavigator.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 6920134..a1c16cd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -89,6 +89,8 @@ else() src/InputHandler.h src/InputHandler.cpp src/LongPressAction.h + src/MCMNavigator.h + src/MCMNavigator.cpp src/MenuUI.h src/MenuUI.cpp src/Plugin.cpp diff --git a/HoldFast.ini b/HoldFast.ini index 3349195..8d0f8bf 100644 --- a/HoldFast.ini +++ b/HoldFast.ini @@ -2,8 +2,16 @@ ; Duration in seconds a button must be held to trigger its long-press action (default: 0.5, max: 5.0) fHoldDuration=0.5 ; Long-press action for the Start (Menu) button. Short press performs the button's normal function. -; Valid values: Map, System, Quests, Stats, Inventory, Magic, Favorites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, None (case-insensitive) +; Valid values: Map, System, Quests, Stats, Inventory, Magic, Favorites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, MCM, None (case-insensitive) sButtonStartAction=Map +; Mod to open when sButtonStartAction=MCM. Leave as None to open the MCM mod list. +sButtonStartMCMModName=None +; Close the journal automatically when leaving MCM via Start. Only applies when sButtonStartAction=MCM. +bButtonStartMCMQuickexit=true ; Long-press action for the Back (View) button. Short press performs the button's normal function. -; Valid values: Map, System, Quests, Stats, Inventory, Magic, Favorites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, None (case-insensitive) +; Valid values: Map, System, Quests, Stats, Inventory, Magic, Favorites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, MCM, None (case-insensitive) sButtonBackAction=System +; Mod to open when sButtonBackAction=MCM. Leave as None to open the MCM mod list. +sButtonBackMCMModName=None +; Close the journal automatically when leaving MCM via Back. Only applies when sButtonBackAction=MCM. +bButtonBackMCMQuickexit=true diff --git a/README.md b/README.md index 3fb1d01..2992719 100644 --- a/README.md +++ b/README.md @@ -42,11 +42,19 @@ Edit `Data\SKSE\Plugins\HoldFast.ini`: ; Duration in seconds a button must be held to trigger its long-press action (default: 0.5, max: 5.0) fHoldDuration=0.5 ; Long-press action for the Start (Menu) button. Short press performs the button's normal function. -; Valid values: Map, System, Quests, Stats, Inventory, Magic, Favorites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, None (case-insensitive) +; Valid values: Map, System, Quests, Stats, Inventory, Magic, Favorites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, MCM, None (case-insensitive) sButtonStartAction=Map +; Mod to open when sButtonStartAction=MCM. Leave as None to open the MCM mod list. +sButtonStartMCMModName=None +; Close the journal automatically when leaving MCM via Start. Only applies when sButtonStartAction=MCM. +bButtonStartMCMQuickexit=true ; Long-press action for the Back (View) button. Short press performs the button's normal function. -; Valid values: Map, System, Quests, Stats, Inventory, Magic, Favorites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, None (case-insensitive) +; Valid values: Map, System, Quests, Stats, Inventory, Magic, Favorites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, MCM, None (case-insensitive) sButtonBackAction=System +; Mod to open when sButtonBackAction=MCM. Leave as None to open the MCM mod list. +sButtonBackMCMModName=None +; Close the journal automatically when leaving MCM via Back. Only applies when sButtonBackAction=MCM. +bButtonBackMCMQuickexit=true ``` **In-game settings (optional):** @@ -56,22 +64,23 @@ Use **Save to config** to persist and apply changes, **Reload from config** to d **Valid actions:** -| Value | What it does | -| ---------------- | ---------------------------------------------- | -| `Map` | Opens the map | -| `System` | Opens the Journal on the System tab | -| `Quests` | Opens the Journal on the Quests tab | -| `Stats` | Opens the Journal on the Stats tab | -| `Inventory` | Opens the inventory | -| `Magic` | Opens the magic menu | -| `Favorites` | Opens the favourites menu | -| `TweenMenu` | Opens the tween menu (Items/Magic/Map/Skills) | -| `Wait` | Opens the sleep/wait menu | -| `NewSave` | Performs a new save | -| `QuickSave` | Performs a quicksave | -| `Bestiary` | Opens The Dragonborn's Bestiary (requires mod) | -| `CharacterSheet` | Opens Character Menu SE (requires mod) | -| `None` | Button not intercepted | +| Value | What it does | +| ---------------- | ------------------------------------------------------------- | +| `Map` | Opens the map | +| `System` | Opens the Journal on the System tab | +| `Quests` | Opens the Journal on the Quests tab | +| `Stats` | Opens the Journal on the Stats tab | +| `Inventory` | Opens the inventory | +| `Magic` | Opens the magic menu | +| `Favorites` | Opens the favourites menu | +| `TweenMenu` | Opens the tween menu (Items/Magic/Map/Skills) | +| `Wait` | Opens the sleep/wait menu | +| `NewSave` | Performs a new save | +| `QuickSave` | Performs a quicksave | +| `Bestiary` | Opens The Dragonborn's Bestiary (requires mod) | +| `CharacterSheet` | Opens Character Menu SE (requires mod) | +| `MCM` | Opens the MCM. Optionally navigates to a specific mod's page. | +| `None` | Button not intercepted | Logs are written to: diff --git a/src/Config.cpp b/src/Config.cpp index 9b44490..545adac 100644 --- a/src/Config.cpp +++ b/src/Config.cpp @@ -32,6 +32,16 @@ namespace logger::warn("fHoldDuration ({:.2f}) exceeds maximum {:.1f} — capping", raw, HoldFast::kMaxHoldDuration); return duration; } + + [[nodiscard]] std::string GetMCMTarget(const CSimpleIniA& ini, const char* modKey) + { + const char* raw = ini.GetValue("General", modKey, nullptr); + if (!raw) { + return "None"; + } + const auto trimmed = HoldFast::TrimWhitespace(raw); + return trimmed.empty() ? "None" : std::string{ trimmed }; + } } HoldFast::Config::Settings HoldFast::Config::LoadSettings() @@ -56,6 +66,8 @@ HoldFast::Config::Settings HoldFast::Config::LoadSettings() return settings; } + constexpr auto kValidActions = "Map, System, Quests, Stats, Inventory, Magic, Favorites/Favourites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, MCM, None"; + settings.startAction = hasStart ? ParseAction(rawStart) : LongPressAction::kNone; if (hasStart && settings.startAction == LongPressAction::kNone) { std::string lower{ HoldFast::TrimWhitespace(rawStart) }; @@ -63,7 +75,7 @@ HoldFast::Config::Settings HoldFast::Config::LoadSettings() 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); + logger::warn("sButtonStartAction='{}' is not a recognised action (valid: {}) — disabling button", rawStart, kValidActions); } } settings.backAction = hasBack ? ParseAction(rawBack) : LongPressAction::kNone; @@ -73,9 +85,15 @@ HoldFast::Config::Settings HoldFast::Config::LoadSettings() 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); + logger::warn("sButtonBackAction='{}' is not a recognised action (valid: {}) — disabling button", rawBack, kValidActions); } } + + settings.startMCMModName = GetMCMTarget(ini, "sButtonStartMCMModName"); + settings.backMCMModName = GetMCMTarget(ini, "sButtonBackMCMModName"); + settings.startMCMQuickexit = ini.GetBoolValue("General", "bButtonStartMCMQuickexit", true); + settings.backMCMQuickexit = ini.GetBoolValue("General", "bButtonBackMCMQuickexit", true); + return settings; } @@ -92,6 +110,10 @@ bool HoldFast::Config::SaveSettings(const Settings& settings) ini.SetValue("General", "fHoldDuration", holdDurationStr.c_str()); ini.SetValue("General", "sButtonStartAction", startActionName.c_str()); ini.SetValue("General", "sButtonBackAction", backActionName.c_str()); + ini.SetValue("General", "sButtonStartMCMModName", settings.startMCMModName.c_str()); + ini.SetValue("General", "sButtonBackMCMModName", settings.backMCMModName.c_str()); + ini.SetBoolValue("General", "bButtonStartMCMQuickexit", settings.startMCMQuickexit); + ini.SetBoolValue("General", "bButtonBackMCMQuickexit", settings.backMCMQuickexit); const auto rc = ini.SaveFile(kIniPath); if (rc < SI_OK) { @@ -107,10 +129,22 @@ std::vector HoldFast::Config::BuildButtons(const Settings& setting std::vector buttons; if (settings.startAction != LongPressAction::kNone) { - buttons.push_back({ .keyCode = static_cast(Key::kStart), .name = "Start", .action = settings.startAction }); + buttons.push_back({ + .keyCode = static_cast(Key::kStart), + .name = "Start", + .action = settings.startAction, + .mcmModName = settings.startMCMModName, + .mcmQuickexit = settings.startMCMQuickexit, + }); } if (settings.backAction != LongPressAction::kNone) { - buttons.push_back({ .keyCode = static_cast(Key::kBack), .name = "Back", .action = settings.backAction }); + buttons.push_back({ + .keyCode = static_cast(Key::kBack), + .name = "Back", + .action = settings.backAction, + .mcmModName = settings.backMCMModName, + .mcmQuickexit = settings.backMCMQuickexit, + }); } return buttons; } diff --git a/src/Config.h b/src/Config.h index 8349897..d582fe1 100644 --- a/src/Config.h +++ b/src/Config.h @@ -15,6 +15,10 @@ namespace HoldFast::Config float holdDuration{ HoldFast::kDefaultHoldDuration }; LongPressAction startAction{ LongPressAction::kMap }; LongPressAction backAction{ LongPressAction::kSystem }; + std::string startMCMModName{ "None" }; + std::string backMCMModName{ "None" }; + bool startMCMQuickexit{ true }; + bool backMCMQuickexit{ true }; }; struct ActionOption @@ -23,7 +27,7 @@ namespace HoldFast::Config LongPressAction action; }; - inline constexpr std::array kActionOptions{ { + inline constexpr std::array kActionOptions{ { { "Map", LongPressAction::kMap }, { "System", LongPressAction::kSystem }, { "Quests", LongPressAction::kQuests }, @@ -37,6 +41,7 @@ namespace HoldFast::Config { "QuickSave", LongPressAction::kQuickSave }, { "Bestiary", LongPressAction::kBestiary }, { "CharacterSheet", LongPressAction::kCharacterSheet }, + { "MCM", LongPressAction::kMCM }, { "None", LongPressAction::kNone }, } }; diff --git a/src/ConfigParsing.cpp b/src/ConfigParsing.cpp index 0dc7a55..d5392e0 100644 --- a/src/ConfigParsing.cpp +++ b/src/ConfigParsing.cpp @@ -21,6 +21,7 @@ LongPressAction HoldFast::Config::ParseAction(std::string_view raw) { "quicksave", LongPressAction::kQuickSave }, { "bestiary", LongPressAction::kBestiary }, { "charactersheet", LongPressAction::kCharacterSheet }, + { "mcm", LongPressAction::kMCM }, { "none", LongPressAction::kNone }, }; diff --git a/src/InputHandler.cpp b/src/InputHandler.cpp index b465a9d..4f41af6 100644 --- a/src/InputHandler.cpp +++ b/src/InputHandler.cpp @@ -1,12 +1,14 @@ #include "PCH.h" #include "InputHandler.h" +#include "MCMNavigator.h" #include "MenuUI.h" namespace { constexpr auto kGfxCurrentTab = "_root.QuestJournalFader.Menu_mc.iCurrentTab"; constexpr auto kGfxRestoreSavedSettings = "_root.QuestJournalFader.Menu_mc.RestoreSavedSettings"; + constexpr auto kGfxConfigPanelOpen = "_root.QuestJournalFader.Menu_mc.ConfigPanelOpen"; constexpr auto kGfxSwitchPageToFront = "_root.QuestJournalFader.Menu_mc.SwitchPageToFront"; constexpr auto kGfxQJOEndPage = "_root.QuestJournalFader.Menu_mc.QuestsFader.Page_mc.QJO_EndPage"; constexpr auto kBestiaryMenuName = "BestiaryMenu"; @@ -95,13 +97,11 @@ RE::BSEventNotifyControl InputHandler::ProcessEvent( return RE::BSEventNotifyControl::kContinue; } - // Journal closed. _lastKnownTab was last written by the input snapshot (GameIsPaused - // block) which runs on every input while the journal is open — including the button - // press that triggers close. No reliable data is available here (uiMovie is null and - // sJournalTabIdx is always kSystem due to QJO's Hook_ProcessMessage). + // Journal closed. if (_tabRestorePending) { RestoreJournalTab(); } + ResetMCMQuickexitState(); UpdateShortPressBinding(); return RE::BSEventNotifyControl::kContinue; } @@ -144,6 +144,8 @@ RE::BSEventNotifyControl InputHandler::ProcessEvent( if (ui && (ui->GameIsPaused() || ui->IsMenuOpen(kCharacterSheetMenuName))) { if (ui->IsMenuOpen(RE::JournalMenu::MENU_NAME)) { SnapshotJournalTab(ui); + MCMNavigator::TryCacheFromOpenMCM(); + HandleMCMQuickexit(); } for (auto& bs : _buttons) { bs.pressTime.reset(); @@ -222,12 +224,13 @@ void InputHandler::DispatchLongPress(const ButtonState& state) if (state.action == LongPressAction::kNewSave) { logger::info("{}: dispatching NewSave", logCtx); - if (auto* saveLoadManager = RE::BGSSaveLoadManager::GetSingleton()) { - RE::SendHUDMessage::ShowHUDMessage("Saving..."); - saveLoadManager->Save(nullptr); + auto* saveLoadManager = RE::BGSSaveLoadManager::GetSingleton(); + if (!saveLoadManager) { + logger::error("{}: BGSSaveLoadManager unavailable — action not dispatched", logCtx); return; } - logger::error("{}: BGSSaveLoadManager unavailable — action not dispatched", logCtx); + RE::SendHUDMessage::ShowHUDMessage("Saving..."); + saveLoadManager->Save(nullptr); return; } @@ -268,6 +271,7 @@ void InputHandler::DispatchLongPress(const ButtonState& state) case LongPressAction::kQuests: case LongPressAction::kSystem: case LongPressAction::kStats: + case LongPressAction::kMCM: { logger::info("{}: opening Journal", logCtx); JournalTab targetTab = JournalTab::kQuest; @@ -275,6 +279,12 @@ void InputHandler::DispatchLongPress(const ButtonState& state) targetTab = JournalTab::kSystem; } else if (state.action == LongPressAction::kStats) { targetTab = JournalTab::kStats; + } else if (state.action == LongPressAction::kMCM) { + targetTab = JournalTab::kMCM; + _pendingMCMModName = state.mcmModName; + _mcmQuickexit = state.mcmQuickexit; + _mcmWasOpen = false; + _mcmModPageSeen = false; } OpenJournalOnTab(targetTab, state.name); if (!DispatchViaMenuOpenHandler(userEvents->journal, state.keyCode, logCtx)) { @@ -283,8 +293,9 @@ void InputHandler::DispatchLongPress(const ButtonState& state) } // Re-write target tab after menuOpenHandler->ProcessButton() resets sJournalTabIdx internally. // AddMessage is queued for the next frame so the Journal will read our value. + // For kMCM, write kSystem (2) — MCM is accessed via the System tab. if (sJournalTabIdx.get()) { - *sJournalTabIdx = static_cast(targetTab); + *sJournalTabIdx = JournalTabToIndex(targetTab); } return; } @@ -373,10 +384,28 @@ bool InputHandler::DispatchViaHandler( return true; } +std::uint32_t InputHandler::JournalTabToIndex(JournalTab tab) +{ + return tab == JournalTab::kMCM ? + static_cast(JournalTab::kSystem) : + static_cast(tab); +} + +void InputHandler::CloseJournal() +{ + auto* uiQueue = RE::UIMessageQueue::GetSingleton(); + if (!uiQueue) { + return; + } + uiQueue->AddMessage(RE::JournalMenu::MENU_NAME, RE::UI_MESSAGE_TYPE::kHide, nullptr); +} + void InputHandler::OpenJournalOnTab(JournalTab tab, const std::string& buttonName) { _pendingTab = tab; + const auto sJournalValue = JournalTabToIndex(tab); + if (!sJournalTabIdx.get()) { // sJournalTabIdx unavailable — skip write/restore bookkeeping for the relocation, // but keep _pendingTab set so InvokeScaleformTab still fires on opening=true. @@ -390,7 +419,7 @@ void InputHandler::OpenJournalOnTab(JournalTab tab, const std::string& buttonNam _savedTabIdx = static_cast(*sJournalTabIdx); } _tabRestorePending = true; - *sJournalTabIdx = static_cast(tab); + *sJournalTabIdx = sJournalValue; } void InputHandler::RestoreJournalTab() @@ -400,6 +429,7 @@ void InputHandler::RestoreJournalTab() } _tabRestorePending = false; _pendingTab.reset(); + ResetMCMQuickexitState(); } void InputHandler::SnapshotJournalTab(RE::UI* ui) @@ -442,7 +472,7 @@ void InputHandler::SnapshotJournalTab(RE::UI* ui) void InputHandler::InvokeScaleformTab(JournalTab tab) { - const auto tabIdx = static_cast(tab); + const auto tabIdx = JournalTabToIndex(tab); auto* ui = RE::UI::GetSingleton(); if (!ui) { @@ -485,6 +515,25 @@ void InputHandler::InvokeScaleformTab(JournalTab tab) kGfxRestoreSavedSettings, nullptr, args.data(), static_cast(args.size())); logger::info("Journal long press: RestoreSavedSettings({}) {}", tabIdx, ok ? "ok" : "FAIL"); + + if (tab != JournalTab::kMCM) { + return; + } + + const bool cpOk = journal->uiMovie->Invoke(kGfxConfigPanelOpen, nullptr, nullptr, 0); + if (!cpOk) { + logger::info("Journal long press: ConfigPanelOpen not found — SkyUI may not be installed"); + return; + } + + if (_pendingMCMModName.empty() || _pendingMCMModName == "None") { + return; + } + std::string modName = std::move(_pendingMCMModName); + _pendingMCMModName.clear(); + SKSE::GetTaskInterface()->AddUITask([mn = std::move(modName)]() noexcept { + MCMNavigator::NavigateToTarget(mn); + }); } void InputHandler::InvokeRestoreTabIfNeeded(JournalTab tab) @@ -549,6 +598,46 @@ void InputHandler::DetectQJOIfNeeded(RE::GFxMovieView* movie) logger::info("QJO detection: {}", *_qjoInstalled ? "QJO installed" : "vanilla Journal"); } +void InputHandler::ResetMCMQuickexitState() +{ + _mcmQuickexit = false; + _mcmWasOpen = false; + _mcmModPageSeen = false; +} + +void InputHandler::HandleMCMQuickexit() +{ + if (!_mcmQuickexit) { + return; + } + + if (!_mcmWasOpen) { + _mcmWasOpen = MCMNavigator::IsMCMOpen(); + return; + } + + const bool modOpen = MCMNavigator::IsAnyModOpen(); + + if (!_mcmModPageSeen) { + if (modOpen) { + _mcmModPageSeen = true; + return; + } + if (MCMNavigator::IsMCMOpen()) { + return; + } + ResetMCMQuickexitState(); + CloseJournal(); + return; + } + + if (modOpen) { + return; + } + ResetMCMQuickexitState(); + CloseJournal(); +} + void InputHandler::DispatchShortPress(const ButtonState& state, float held) { // Best-effort guard against stale pressTime from process suspension (e.g. Alt-Tab). diff --git a/src/InputHandler.h b/src/InputHandler.h index 3a37dfb..e916e9a 100644 --- a/src/InputHandler.h +++ b/src/InputHandler.h @@ -55,6 +55,9 @@ class InputHandler : kQuest = 0, kStats = 1, kSystem = 2, + // Sentinel: opens Journal on the System tab then navigates to the MCM overlay. + // Not a real sJournalTabIdx value — kSystem (2) is written to the relocation. + kMCM = 3, }; static inline REL::Relocation sJournalTabIdx{ RELOCATION_ID(520167, 406697) }; @@ -65,20 +68,24 @@ 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); - static bool DispatchViaFavoritesHandler(const RE::BSFixedString& userEvent, std::uint32_t keyCode, const std::string& logContext); - static bool DispatchViaHandler(RE::MenuEventHandler* handler, std::string_view handlerName, const RE::BSFixedString& userEvent, std::uint32_t keyCode, const std::string& logContext); - static void DispatchShortPress(const ButtonState& state, float held); - void DispatchLongPress(const ButtonState& state); - void OpenJournalOnTab(JournalTab tab, const std::string& buttonName); - void RestoreJournalTab(); - static void InvokeScaleformTab(JournalTab tab); - void InvokeRestoreTabIfNeeded(JournalTab tab); - void SnapshotJournalTab(RE::UI* ui); - void DetectQJOIfNeeded(RE::GFxMovieView* movie); + bool ScanInputEvents(RE::InputEvent* const* a_events); + bool ProcessButton(const RE::ButtonEvent* btn, ButtonState& state); + static bool DispatchViaMenuOpenHandler(const RE::BSFixedString& userEvent, std::uint32_t keyCode, const std::string& logContext); + static bool DispatchViaQuickSaveLoadHandler(const RE::BSFixedString& userEvent, std::uint32_t keyCode, const std::string& logContext); + static bool DispatchViaFavoritesHandler(const RE::BSFixedString& userEvent, std::uint32_t keyCode, const std::string& logContext); + static bool DispatchViaHandler(RE::MenuEventHandler* handler, std::string_view handlerName, const RE::BSFixedString& userEvent, std::uint32_t keyCode, const std::string& logContext); + static void DispatchShortPress(const ButtonState& state, float held); + static std::uint32_t JournalTabToIndex(JournalTab tab); + static void CloseJournal(); + void DispatchLongPress(const ButtonState& state); + void OpenJournalOnTab(JournalTab tab, const std::string& buttonName); + void RestoreJournalTab(); + void InvokeScaleformTab(JournalTab tab); + void InvokeRestoreTabIfNeeded(JournalTab tab); + void SnapshotJournalTab(RE::UI* ui); + void DetectQJOIfNeeded(RE::GFxMovieView* movie); + void HandleMCMQuickexit(); + void ResetMCMQuickexitState(); float holdDuration{ kDefaultHoldDuration }; std::vector _buttons; @@ -99,4 +106,8 @@ class InputHandler : std::optional _pendingTab{}; std::optional _lastKnownTab{}; std::optional _qjoInstalled{}; + std::string _pendingMCMModName; + bool _mcmQuickexit{ false }; + bool _mcmWasOpen{ false }; + bool _mcmModPageSeen{ false }; }; diff --git a/src/LongPressAction.h b/src/LongPressAction.h index 0692798..6dcf53f 100644 --- a/src/LongPressAction.h +++ b/src/LongPressAction.h @@ -26,6 +26,7 @@ enum class LongPressAction kQuickSave, kBestiary, kCharacterSheet, + kMCM, }; struct ButtonConfig @@ -33,4 +34,6 @@ struct ButtonConfig std::uint32_t keyCode{}; std::string name; LongPressAction action{ LongPressAction::kNone }; + std::string mcmModName{ "None" }; + bool mcmQuickexit{ true }; }; diff --git a/src/MCMNavigator.cpp b/src/MCMNavigator.cpp new file mode 100644 index 0000000..c0fc4d6 --- /dev/null +++ b/src/MCMNavigator.cpp @@ -0,0 +1,457 @@ +#include "PCH.h" + +#include "MCMNavigator.h" + +namespace MCMNavigator +{ + namespace + { + constexpr int kMaxRetries = 20; + constexpr int kModDelayMs = 50; + + constexpr auto kConfigPanel = "_root.ConfigPanelFader.configPanel."; + constexpr auto kModListPanel = "_root.ConfigPanelFader.configPanel.contentHolder.modListPanel."; + constexpr auto kModList = "_root.ConfigPanelFader.configPanel.contentHolder.modListPanel.modListFader.list."; + + bool CaseInsensitiveLess(const std::string& a, const std::string& b) + { + return std::ranges::lexicographical_compare( + a, b, + [](unsigned char x, unsigned char y) { return std::tolower(x) < std::tolower(y); }); + } + + std::string_view StripModNamePrefix(std::string_view name) + { + const auto pos = name.find("::"); + return pos != std::string_view::npos ? name.substr(pos + 2) : name; + } + + struct NavigationTarget + { + std::string modName; + int modRetries{ 0 }; + }; + + // Set once per dispatch, consumed by the async chain. + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + inline NavigationTarget g_target{}; + // Navigation in-flight guard. + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + inline std::atomic g_lock{ false }; + + // Protected by g_cacheMutex — render thread reads, game thread writes. + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + inline std::mutex g_cacheMutex{}; + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + inline std::vector g_modCache{}; + // Debounces cache population AddUITask calls. + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + inline std::atomic g_cachePending{ false }; + // Set once CacheModListFromPapyrus succeeds — prevents repeated VM lookups. + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + inline std::atomic g_skyUICacheDone{ false }; + + RE::GFxMovieView* GetJournalView() + { + auto* ui = RE::UI::GetSingleton(); + if (!ui) { + return nullptr; + } + auto menu = ui->GetMenu(RE::JournalMenu::MENU_NAME); + return menu ? menu->uiMovie.get() : nullptr; + } + + void AddUITask(std::function func) + { + SKSE::GetTaskInterface()->AddUITask(std::move(func)); + } + + void DelayCallForUI(std::function func, int delayMs) + { + std::thread([f = std::move(func), delayMs]() mutable { + std::this_thread::sleep_for(std::chrono::milliseconds(delayMs)); + AddUITask(std::move(f)); + }).detach(); + } + + std::vector CollectEntryNames(RE::GFxMovieView* view, const std::string& listPath, const char* memberName) + { + std::vector names; + if (!view) { + return names; + } + + RE::GFxValue listObj; + view->GetVariable(&listObj, listPath.c_str()); + if (!listObj.IsObject()) { + return names; + } + + RE::GFxValue entryList; + listObj.GetMember("_entryList", &entryList); + if (!entryList.IsArray()) { + return names; + } + + const auto length = entryList.GetArraySize(); + names.reserve(length); + for (std::uint32_t i = 0; i < length; i++) { + RE::GFxValue entry; + entryList.GetElement(i, &entry); + RE::GFxValue nameVal; + entry.GetMember(memberName, &nameVal); + if (nameVal.IsString()) { + names.emplace_back(nameVal.GetString()); + } + } + return names; + } + + // Uses doSetSelectedIndex + onItemPress to select and click the entry. + bool SelectEntryByName(const std::string& listPath, const char* varName, std::string_view targetName) + { + auto* view = GetJournalView(); + if (!view) { + return false; + } + + RE::GFxValue listObj; + view->GetVariable(&listObj, listPath.c_str()); + if (!listObj.IsObject()) { + return false; + } + + RE::GFxValue entryList; + listObj.GetMember("_entryList", &entryList); + if (!entryList.IsArray()) { + return false; + } + + const auto length = entryList.GetArraySize(); + if (length == 0) { + logger::debug("MCMNavigator: {} list is empty — consider increasing delay", varName); + return false; + } + + int index = -1; + for (std::uint32_t i = 0; i < length; i++) { + RE::GFxValue entry; + entryList.GetElement(i, &entry); + RE::GFxValue nameVal; + entry.GetMember(varName, &nameVal); + if (!nameVal.IsString()) { + continue; + } + if (targetName == nameVal.GetString()) { + index = static_cast(i); + break; + } + } + + if (index < 0) { + logger::warn("MCMNavigator: '{}' not found in {} list", targetName, varName); + return false; + } + + std::array args{ static_cast(index), 0.0 }; + listObj.Invoke("doSetSelectedIndex", nullptr, args.data(), 2); + listObj.Invoke("onItemPress", nullptr, args.data(), 2); + logger::debug("MCMNavigator: selected {} '{}' at index {}", varName, targetName, index); + return true; + } + + void TransitionToModList() + { + auto* view = GetJournalView(); + if (!view) { + return; + } + RE::GFxValue arg{ 4.0 }; // TRANSITION_TO_LIST state + const std::string setStatePath = std::string{ kModListPanel } + "setState"; + view->Invoke(setStatePath.c_str(), nullptr, &arg, 1); + } + + void OpenMod() + { + if (g_target.modRetries >= kMaxRetries) { + logger::warn("MCMNavigator: mod selection retry limit reached"); + g_target.modRetries = 0; + g_lock = false; + return; + } + + auto* view = GetJournalView(); + if (!view) { + g_lock = false; + return; + } + + // Poll disableSelection — list is animating, not ready yet. + // Also retry if the mod list path doesn't resolve (MCM may still be fading in). + RE::GFxValue disabled; + const std::string disablePath = std::string{ kModList } + "disableSelection"; + view->GetVariable(&disabled, disablePath.c_str()); + if (!disabled.IsBool() || disabled.GetBool()) { + g_target.modRetries++; + DelayCallForUI(OpenMod, kModDelayMs); + return; + } + + // List is ready — but _entryList may still be empty if SkyUI hasn't + // populated the data yet. Retry until we have at least one entry. + auto mods = CollectEntryNames(view, std::string{ kModList }, "text"); + if (mods.empty()) { + g_target.modRetries++; + DelayCallForUI(OpenMod, kModDelayMs); + return; + } + + std::ranges::sort(mods, CaseInsensitiveLess); + { + std::scoped_lock lock(g_cacheMutex); + if (g_modCache.empty()) { + g_modCache = std::move(mods); + logger::debug("MCMNavigator: cached {} mod names", g_modCache.size()); + } + } + + g_target.modRetries = 0; + if (!SelectEntryByName(kModList, "text", g_target.modName)) { + logger::warn("MCMNavigator: mod '{}' not found — MCM mod list shown", g_target.modName); + } + g_lock = false; + } + } + + bool IsMCMOpen() + { + auto* view = GetJournalView(); + if (!view) { + return false; + } + RE::GFxValue alpha; + const std::string alphaPath = std::string{ kConfigPanel } + "_alpha"; + view->GetVariable(&alpha, alphaPath.c_str()); + return alpha.IsNumber() && alpha.GetNumber() == 100.0; + } + + bool IsAnyModOpen() + { + auto* view = GetJournalView(); + if (!view) { + return false; + } + RE::GFxValue state; + const std::string statePath = std::string{ kModListPanel } + "_state"; + view->GetVariable(&state, statePath.c_str()); + return state.IsNumber() && state.GetNumber() == 2.0; + } + + bool IsModAlreadyOpen(std::string_view modName) + { + if (!IsAnyModOpen()) { + return false; + } + auto* view = GetJournalView(); + if (!view) { + return false; + } + RE::GFxValue titleText; + const std::string titlePath = std::string{ kModListPanel } + "_titleText"; + view->GetVariable(&titleText, titlePath.c_str()); + return titleText.IsString() && modName == titleText.GetString(); + } + + void CacheModListFromGFx() + { + auto* view = GetJournalView(); + if (!view || !IsMCMOpen()) { + return; + } + auto names = CollectEntryNames(view, std::string{ kModList }, "text"); + std::ranges::sort(names, CaseInsensitiveLess); + if (names.empty()) { + return; + } + std::scoped_lock lock(g_cacheMutex); + g_modCache = std::move(names); + } + + void TryCacheFromOpenMCM() + { + if (!IsMCMOpen()) { + return; + } + // Debounce — only one pending AddUITask at a time. + if (g_cachePending.exchange(true)) { + return; + } + AddUITask([]() { + if (!IsMCMOpen()) { + g_cachePending = false; + return; + } + CacheModListFromGFx(); + if (g_skyUICacheDone) { + g_cachePending = false; + return; + } + // CacheModListFromPapyrus uses Papyrus VM APIs — must run on the game thread. + const auto* taskIface = SKSE::GetTaskInterface(); + if (!taskIface) { + g_cachePending = false; + return; + } + taskIface->AddTask(CacheModListFromPapyrus); + }); + } + + std::vector GetCachedModNames() + { + std::scoped_lock lock(g_cacheMutex); + return g_modCache; + } + + void NavigateToTargetImpl(const std::string& modName) + { + if (modName.empty() || modName == "None") { + return; + } + + if (g_lock.exchange(true)) { + logger::debug("MCMNavigator: navigation already in flight — skipping"); + return; + } + + if (IsAnyModOpen() && !IsModAlreadyOpen(modName)) { + logger::debug("MCMNavigator: a different mod is open — transitioning to mod list"); + TransitionToModList(); + } + + g_target.modName = modName; + g_target.modRetries = 0; + + if (IsModAlreadyOpen(modName)) { + g_lock = false; + } else { + DelayCallForUI(OpenMod, kModDelayMs); + } + } + + void NavigateToTarget(const std::string& modName) noexcept + { + try { + NavigateToTargetImpl(modName); + } catch (...) { + g_lock = false; + } + } + + void ReadModArraysIntoCache(const RE::BSTSmartPointer& namesArr) + { + std::vector modNames; + modNames.reserve(namesArr->size()); + + for (RE::BSScript::Array::size_type i = 0; i < namesArr->size(); ++i) { + const auto& nameElem = (*namesArr)[i]; + if (!nameElem.IsString()) { + continue; + } + const auto modName = nameElem.GetString(); + if (modName.empty()) { + continue; + } + modNames.emplace_back(StripModNamePrefix(modName)); + } + + if (modNames.empty()) { + return; + } + + std::ranges::sort(modNames, CaseInsensitiveLess); + std::scoped_lock lock(g_cacheMutex); + g_modCache = std::move(modNames); + logger::info("MCMNavigator: cached {} mods from SKI_ConfigManager", g_modCache.size()); + } + + void CacheModListFromPapyrus() + { + // Always clear g_cachePending on exit — needed when called via EnsureCachePopulated's + // AddTask dispatch. Harmless double-clear when called from TryCacheFromOpenMCM's lambda. + struct ClearPending + { + ClearPending() = default; + ClearPending(const ClearPending&) = default; + ClearPending(ClearPending&&) = default; + ClearPending& operator=(const ClearPending&) = default; + ClearPending& operator=(ClearPending&&) = default; + ~ClearPending() { g_cachePending = false; } + } clearPending; + + auto* vm = RE::BSScript::Internal::VirtualMachine::GetSingleton(); + if (!vm) { + return; + } + + auto* quest = RE::TESForm::LookupByEditorID("SKI_ConfigManagerInstance"); + if (!quest) { + logger::info("MCMNavigator: SKI_ConfigManagerInstance not found — SkyUI not installed, skipping Papyrus cache"); + g_skyUICacheDone = true; + return; + } + + const auto* policy = vm->GetObjectHandlePolicy(); + if (!policy) { + return; + } + + const auto handle = policy->GetHandleForObject( + static_cast(RE::FormType::Quest), quest); + if (handle == policy->EmptyHandle()) { + logger::info("MCMNavigator: could not get VM handle for SKI_ConfigManagerInstance"); + return; + } + + RE::BSTSmartPointer managerObj; + if (!vm->FindBoundObject(handle, "SKI_ConfigManager", managerObj) || !managerObj) { + logger::info("MCMNavigator: SKI_ConfigManager script not yet bound — will retry when MCM opens"); + return; + } + + const auto* namesVar = managerObj->GetVariable("_modNames"); + if (!namesVar || !namesVar->IsArray()) { + // MCM Unlocked replaces SKI_ConfigManager and removes _modNames/_modConfigs. + // DispatchStaticCall only works for native Papyrus functions — MCMUnlocked's + // GetModName / GetMarkerScript are scripted functions and cannot be called this + // way. Fall back to the Flash cache (TryCacheFromOpenMCM), which populates mod + // names the first time the user opens the Journal with MCM Unlocked. + logger::info("MCMNavigator: MCM Unlocked detected — Flash cache will populate on first MCM open"); + g_skyUICacheDone = true; + return; + } + + const auto namesArr = namesVar->GetArray(); + if (!namesArr) { + return; + } + + ReadModArraysIntoCache(namesArr); + g_skyUICacheDone = true; + } + + void EnsureCachePopulated() + { + if (g_skyUICacheDone) { + return; + } + if (g_cachePending.exchange(true)) { + return; + } + const auto* taskIface = SKSE::GetTaskInterface(); + if (!taskIface) { + g_cachePending = false; + return; + } + taskIface->AddTask(CacheModListFromPapyrus); + } +} diff --git a/src/MCMNavigator.h b/src/MCMNavigator.h new file mode 100644 index 0000000..bf8138e --- /dev/null +++ b/src/MCMNavigator.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include +#include + +namespace MCMNavigator +{ + bool IsMCMOpen(); + + bool IsAnyModOpen(); + + void NavigateToTarget(const std::string& modName) noexcept; + + // Debounced — safe to call every frame. + void TryCacheFromOpenMCM(); + + std::vector GetCachedModNames(); + + // Reads SKI_ConfigManager's _modNames from SKI_ConfigManagerInstance. + // Must be called on the game thread. No-op if SkyUI is not installed or script not yet bound. + void CacheModListFromPapyrus(); + + // Async. Safe to call from any thread. No-op once the cache is populated. + void EnsureCachePopulated(); +} diff --git a/src/MenuUI.cpp b/src/MenuUI.cpp index 6ce247f..76421f3 100644 --- a/src/MenuUI.cpp +++ b/src/MenuUI.cpp @@ -2,6 +2,7 @@ #include "Config.h" #include "InputHandler.h" +#include "MCMNavigator.h" #include "MenuUI.h" #include "SKSEMCP/utils.hpp" #include "Utils.h" @@ -66,7 +67,11 @@ namespace { return lhs.holdDuration == rhs.holdDuration && lhs.startAction == rhs.startAction && - lhs.backAction == rhs.backAction; + lhs.backAction == rhs.backAction && + lhs.startMCMModName == rhs.startMCMModName && + lhs.backMCMModName == rhs.backMCMModName && + lhs.startMCMQuickexit == rhs.startMCMQuickexit && + lhs.backMCMQuickexit == rhs.backMCMQuickexit; } bool DrawActionCombo(const char* label, InputHandler::LongPressAction& value) @@ -133,13 +138,61 @@ namespace state.hasPendingChanges = !SettingsEqual(defaults, loaded); } + void DrawMCMTargetInputs(const char* modLabel, std::string& modName, bool& changed) + { + MCMNavigator::EnsureCachePopulated(); + + const auto cachedMods = MCMNavigator::GetCachedModNames(); + const char* preview = modName.empty() || modName == "None" ? "None" : modName.c_str(); + + if (!ImGuiMCP::BeginCombo(modLabel, preview)) { + return; + } + + const bool noneSelected = modName.empty() || modName == "None"; + if (ImGuiMCP::Selectable("None", noneSelected)) { + modName = "None"; + changed = true; + } + + if (cachedMods.empty()) { + ImGuiMCP::Selectable("(Open MCM once to populate)", false, ImGuiMCP::ImGuiSelectableFlags_Disabled); + ImGuiMCP::EndCombo(); + return; + } + + for (const auto& opt : cachedMods) { + if (ImGuiMCP::Selectable(opt.c_str(), opt == modName)) { + modName = opt; + changed = true; + break; + } + } + + ImGuiMCP::EndCombo(); + } + 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); + if (state.stagedSettings.startAction == InputHandler::LongPressAction::kMCM) { + DrawMCMTargetInputs( + "Start MCM mod name", + state.stagedSettings.startMCMModName, + changed); + changed |= ImGuiMCP::Checkbox("Close journal after leaving MCM##start", &state.stagedSettings.startMCMQuickexit); + } changed |= DrawActionCombo("Back long-press action", state.stagedSettings.backAction); + if (state.stagedSettings.backAction == InputHandler::LongPressAction::kMCM) { + DrawMCMTargetInputs( + "Back MCM mod name", + state.stagedSettings.backMCMModName, + changed); + changed |= ImGuiMCP::Checkbox("Close journal after leaving MCM##back", &state.stagedSettings.backMCMQuickexit); + } if (changed) { state.hasPendingChanges = true; diff --git a/src/PCH.h b/src/PCH.h index 6b0b25a..1d1d983 100644 --- a/src/PCH.h +++ b/src/PCH.h @@ -13,6 +13,7 @@ #include #include +#include #include #include #include diff --git a/src/Plugin.cpp b/src/Plugin.cpp index 540970d..48364b9 100644 --- a/src/Plugin.cpp +++ b/src/Plugin.cpp @@ -2,6 +2,7 @@ #include "Config.h" #include "InputHandler.h" +#include "MCMNavigator.h" #include "MenuUI.h" void SetupLog() @@ -93,6 +94,7 @@ SKSEPluginLoad(const SKSE::LoadInterface* a_skse) case SKSE::MessagingInterface::kPostLoadGame: case SKSE::MessagingInterface::kNewGame: InputHandler::GetSingleton()->UpdateShortPressBinding(); + SKSE::GetTaskInterface()->AddTask(MCMNavigator::CacheModListFromPapyrus); break; default: break; From f042f3658d138d99afe71bdeca8f99eca05e9488 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Mon, 15 Jun 2026 10:42:45 +0100 Subject: [PATCH 02/34] chore(navigator): add missing standard library includes --- src/MCMNavigator.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/MCMNavigator.cpp b/src/MCMNavigator.cpp index c0fc4d6..64803ff 100644 --- a/src/MCMNavigator.cpp +++ b/src/MCMNavigator.cpp @@ -2,6 +2,10 @@ #include "MCMNavigator.h" +#include +#include +#include + namespace MCMNavigator { namespace From 82218f53c6dc09411cc4ff37d41228d6747d72e1 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Mon, 15 Jun 2026 12:19:59 +0100 Subject: [PATCH 03/34] fix(navigator): add null guards for GetTaskInterface and missing cctype include --- src/InputHandler.cpp | 6 +++++- src/MCMNavigator.cpp | 9 +++++++-- src/Plugin.cpp | 4 +++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/InputHandler.cpp b/src/InputHandler.cpp index 4f41af6..91a0cf3 100644 --- a/src/InputHandler.cpp +++ b/src/InputHandler.cpp @@ -531,7 +531,11 @@ void InputHandler::InvokeScaleformTab(JournalTab tab) } std::string modName = std::move(_pendingMCMModName); _pendingMCMModName.clear(); - SKSE::GetTaskInterface()->AddUITask([mn = std::move(modName)]() noexcept { + const auto* taskIface = SKSE::GetTaskInterface(); + if (!taskIface) { + return; + } + taskIface->AddUITask([mn = std::move(modName)]() noexcept { MCMNavigator::NavigateToTarget(mn); }); } diff --git a/src/MCMNavigator.cpp b/src/MCMNavigator.cpp index 64803ff..a2e2705 100644 --- a/src/MCMNavigator.cpp +++ b/src/MCMNavigator.cpp @@ -2,9 +2,9 @@ #include "MCMNavigator.h" +#include #include #include -#include namespace MCMNavigator { @@ -67,7 +67,12 @@ namespace MCMNavigator void AddUITask(std::function func) { - SKSE::GetTaskInterface()->AddUITask(std::move(func)); + const auto* taskIface = SKSE::GetTaskInterface(); + if (!taskIface) { + logger::warn("MCMNavigator: task interface unavailable"); + return; + } + taskIface->AddUITask(std::move(func)); } void DelayCallForUI(std::function func, int delayMs) diff --git a/src/Plugin.cpp b/src/Plugin.cpp index 48364b9..232f987 100644 --- a/src/Plugin.cpp +++ b/src/Plugin.cpp @@ -94,7 +94,9 @@ SKSEPluginLoad(const SKSE::LoadInterface* a_skse) case SKSE::MessagingInterface::kPostLoadGame: case SKSE::MessagingInterface::kNewGame: InputHandler::GetSingleton()->UpdateShortPressBinding(); - SKSE::GetTaskInterface()->AddTask(MCMNavigator::CacheModListFromPapyrus); + if (const auto* taskIface = SKSE::GetTaskInterface()) { + taskIface->AddTask(MCMNavigator::CacheModListFromPapyrus); + } break; default: break; From fcd33222c0879b1eac6016735bf2990e42099114 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Mon, 15 Jun 2026 12:45:56 +0100 Subject: [PATCH 04/34] fix(navigator): throttle EnsureCachePopulated to schedule at most once per session --- src/MCMNavigator.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/MCMNavigator.cpp b/src/MCMNavigator.cpp index a2e2705..20df440 100644 --- a/src/MCMNavigator.cpp +++ b/src/MCMNavigator.cpp @@ -54,6 +54,11 @@ namespace MCMNavigator // Set once CacheModListFromPapyrus succeeds — prevents repeated VM lookups. // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) inline std::atomic g_skyUICacheDone{ false }; + // Set once EnsureCachePopulated successfully schedules CacheModListFromPapyrus. + // Prevents the every-frame settings UI call from re-scheduling on each retry failure. + // TryCacheFromOpenMCM uses g_cachePending for its own debounce and handles retries. + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + inline std::atomic g_papyrusEagerScheduled{ false }; RE::GFxMovieView* GetJournalView() { @@ -453,12 +458,12 @@ namespace MCMNavigator if (g_skyUICacheDone) { return; } - if (g_cachePending.exchange(true)) { + if (g_papyrusEagerScheduled.exchange(true)) { return; } const auto* taskIface = SKSE::GetTaskInterface(); if (!taskIface) { - g_cachePending = false; + g_papyrusEagerScheduled = false; return; } taskIface->AddTask(CacheModListFromPapyrus); From c0aad62110526bd108a27756b25f3078259f6f2e Mon Sep 17 00:00:00 2001 From: codepuncher Date: Mon, 15 Jun 2026 13:28:40 +0100 Subject: [PATCH 05/34] fix(navigator): replace detached threads in DelayCallForUI with frame-skipping AddUITask --- src/MCMNavigator.cpp | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/MCMNavigator.cpp b/src/MCMNavigator.cpp index 20df440..c695a2b 100644 --- a/src/MCMNavigator.cpp +++ b/src/MCMNavigator.cpp @@ -11,7 +11,7 @@ namespace MCMNavigator namespace { constexpr int kMaxRetries = 20; - constexpr int kModDelayMs = 50; + constexpr int kModRetryFrames = 3; // ~50ms at 60fps; enough for MCM list to finish animating constexpr auto kConfigPanel = "_root.ConfigPanelFader.configPanel."; constexpr auto kModListPanel = "_root.ConfigPanelFader.configPanel.contentHolder.modListPanel."; @@ -80,12 +80,15 @@ namespace MCMNavigator taskIface->AddUITask(std::move(func)); } - void DelayCallForUI(std::function func, int delayMs) + void DelayCallForUI(std::function func, int framesLeft) { - std::thread([f = std::move(func), delayMs]() mutable { - std::this_thread::sleep_for(std::chrono::milliseconds(delayMs)); - AddUITask(std::move(f)); - }).detach(); + if (framesLeft <= 0) { + AddUITask(std::move(func)); + return; + } + AddUITask([func = std::move(func), framesLeft]() mutable { + DelayCallForUI(std::move(func), framesLeft - 1); + }); } std::vector CollectEntryNames(RE::GFxMovieView* view, const std::string& listPath, const char* memberName) @@ -207,7 +210,7 @@ namespace MCMNavigator view->GetVariable(&disabled, disablePath.c_str()); if (!disabled.IsBool() || disabled.GetBool()) { g_target.modRetries++; - DelayCallForUI(OpenMod, kModDelayMs); + DelayCallForUI(OpenMod, kModRetryFrames); return; } @@ -216,7 +219,7 @@ namespace MCMNavigator auto mods = CollectEntryNames(view, std::string{ kModList }, "text"); if (mods.empty()) { g_target.modRetries++; - DelayCallForUI(OpenMod, kModDelayMs); + DelayCallForUI(OpenMod, kModRetryFrames); return; } @@ -348,7 +351,7 @@ namespace MCMNavigator if (IsModAlreadyOpen(modName)) { g_lock = false; } else { - DelayCallForUI(OpenMod, kModDelayMs); + DelayCallForUI(OpenMod, kModRetryFrames); } } From 9a00cf9910d68b3a263ab1ca12f3de7cab404392 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Mon, 15 Jun 2026 14:29:35 +0100 Subject: [PATCH 06/34] fix(config): normalize case-insensitive none to canonical None in GetMCMTarget --- src/Config.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Config.cpp b/src/Config.cpp index 545adac..8d75d40 100644 --- a/src/Config.cpp +++ b/src/Config.cpp @@ -40,7 +40,12 @@ namespace return "None"; } const auto trimmed = HoldFast::TrimWhitespace(raw); - return trimmed.empty() ? "None" : std::string{ trimmed }; + if (trimmed.empty()) { + return "None"; + } + std::string lower{ trimmed }; + std::ranges::transform(lower, lower.begin(), [](unsigned char c) { return std::tolower(c); }); + return lower == "none" ? "None" : std::string{ trimmed }; } } From d814d3ef107d7911ba5072b5964378b4c7a95b4f Mon Sep 17 00:00:00 2001 From: codepuncher Date: Mon, 15 Jun 2026 15:05:47 +0100 Subject: [PATCH 07/34] fix(navigator): use case-insensitive comparison for mod name matching --- src/MCMNavigator.cpp | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/MCMNavigator.cpp b/src/MCMNavigator.cpp index c695a2b..f7248ef 100644 --- a/src/MCMNavigator.cpp +++ b/src/MCMNavigator.cpp @@ -24,6 +24,13 @@ namespace MCMNavigator [](unsigned char x, unsigned char y) { return std::tolower(x) < std::tolower(y); }); } + bool CaseInsensitiveEqual(std::string_view a, std::string_view b) + { + return std::ranges::equal( + a, b, + [](unsigned char x, unsigned char y) { return std::tolower(x) == std::tolower(y); }); + } + std::string_view StripModNamePrefix(std::string_view name) { const auto pos = name.find("::"); @@ -159,7 +166,7 @@ namespace MCMNavigator if (!nameVal.IsString()) { continue; } - if (targetName == nameVal.GetString()) { + if (CaseInsensitiveEqual(targetName, nameVal.GetString())) { index = static_cast(i); break; } @@ -276,7 +283,7 @@ namespace MCMNavigator RE::GFxValue titleText; const std::string titlePath = std::string{ kModListPanel } + "_titleText"; view->GetVariable(&titleText, titlePath.c_str()); - return titleText.IsString() && modName == titleText.GetString(); + return titleText.IsString() && CaseInsensitiveEqual(modName, titleText.GetString()); } void CacheModListFromGFx() From 5bcade6ffc123e8034a3b31e655d28040372b97d Mon Sep 17 00:00:00 2001 From: codepuncher Date: Mon, 15 Jun 2026 15:07:02 +0100 Subject: [PATCH 08/34] chore(navigator): update EnsureCachePopulated comment to reflect one-shot behaviour --- src/MCMNavigator.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/MCMNavigator.h b/src/MCMNavigator.h index bf8138e..d98bbce 100644 --- a/src/MCMNavigator.h +++ b/src/MCMNavigator.h @@ -21,6 +21,8 @@ namespace MCMNavigator // Must be called on the game thread. No-op if SkyUI is not installed or script not yet bound. void CacheModListFromPapyrus(); - // Async. Safe to call from any thread. No-op once the cache is populated. + // Async. Safe to call from any thread. Schedules CacheModListFromPapyrus at most once; + // subsequent calls are no-ops regardless of whether caching succeeded. + // TryCacheFromOpenMCM handles retries when the Journal is open. void EnsureCachePopulated(); } From 882c01ee60b0f8a53a831b650f6128d9c052ee73 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Mon, 15 Jun 2026 15:52:18 +0100 Subject: [PATCH 09/34] fix(navigator): guard TryCacheFromOpenMCM against unavailable task interface before exchange --- src/MCMNavigator.cpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/MCMNavigator.cpp b/src/MCMNavigator.cpp index f7248ef..48492b7 100644 --- a/src/MCMNavigator.cpp +++ b/src/MCMNavigator.cpp @@ -306,11 +306,15 @@ namespace MCMNavigator if (!IsMCMOpen()) { return; } + const auto* taskIface = SKSE::GetTaskInterface(); + if (!taskIface) { + return; + } // Debounce — only one pending AddUITask at a time. if (g_cachePending.exchange(true)) { return; } - AddUITask([]() { + taskIface->AddUITask([]() { if (!IsMCMOpen()) { g_cachePending = false; return; @@ -321,12 +325,12 @@ namespace MCMNavigator return; } // CacheModListFromPapyrus uses Papyrus VM APIs — must run on the game thread. - const auto* taskIface = SKSE::GetTaskInterface(); - if (!taskIface) { + const auto* inner = SKSE::GetTaskInterface(); + if (!inner) { g_cachePending = false; return; } - taskIface->AddTask(CacheModListFromPapyrus); + inner->AddTask(CacheModListFromPapyrus); }); } From 3d08e6ecc59c4bdaabd62d3032ab59dd19f9a340 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Mon, 15 Jun 2026 15:53:51 +0100 Subject: [PATCH 10/34] fix(navigator): release g_lock if task interface unavailable before first dispatch --- src/MCMNavigator.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/MCMNavigator.cpp b/src/MCMNavigator.cpp index 48492b7..d59433a 100644 --- a/src/MCMNavigator.cpp +++ b/src/MCMNavigator.cpp @@ -361,6 +361,9 @@ namespace MCMNavigator if (IsModAlreadyOpen(modName)) { g_lock = false; + } else if (!SKSE::GetTaskInterface()) { + logger::warn("MCMNavigator: task interface unavailable — navigation cancelled"); + g_lock = false; } else { DelayCallForUI(OpenMod, kModRetryFrames); } From b63326cf18407cd139edd436f765e0400a865564 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Mon, 15 Jun 2026 16:12:36 +0100 Subject: [PATCH 11/34] fix(ui): defer GetCachedModNames until combo is open to avoid per-frame mutex and copy --- src/MenuUI.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/MenuUI.cpp b/src/MenuUI.cpp index 76421f3..d072e9d 100644 --- a/src/MenuUI.cpp +++ b/src/MenuUI.cpp @@ -142,13 +142,14 @@ namespace { MCMNavigator::EnsureCachePopulated(); - const auto cachedMods = MCMNavigator::GetCachedModNames(); const char* preview = modName.empty() || modName == "None" ? "None" : modName.c_str(); if (!ImGuiMCP::BeginCombo(modLabel, preview)) { return; } + const auto cachedMods = MCMNavigator::GetCachedModNames(); + const bool noneSelected = modName.empty() || modName == "None"; if (ImGuiMCP::Selectable("None", noneSelected)) { modName = "None"; From 6ce65f236695a4fb3a0bb9e271f08eb277f190b8 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Mon, 15 Jun 2026 16:13:36 +0100 Subject: [PATCH 12/34] fix(navigator): use listPath instead of varName in SelectEntryByName log messages --- src/MCMNavigator.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/MCMNavigator.cpp b/src/MCMNavigator.cpp index d59433a..2732d9e 100644 --- a/src/MCMNavigator.cpp +++ b/src/MCMNavigator.cpp @@ -153,7 +153,7 @@ namespace MCMNavigator const auto length = entryList.GetArraySize(); if (length == 0) { - logger::debug("MCMNavigator: {} list is empty — consider increasing delay", varName); + logger::debug("MCMNavigator: {} list is empty — consider increasing delay", listPath); return false; } @@ -173,14 +173,14 @@ namespace MCMNavigator } if (index < 0) { - logger::warn("MCMNavigator: '{}' not found in {} list", targetName, varName); + logger::warn("MCMNavigator: '{}' not found in {}", targetName, listPath); return false; } std::array args{ static_cast(index), 0.0 }; listObj.Invoke("doSetSelectedIndex", nullptr, args.data(), 2); listObj.Invoke("onItemPress", nullptr, args.data(), 2); - logger::debug("MCMNavigator: selected {} '{}' at index {}", varName, targetName, index); + logger::debug("MCMNavigator: selected '{}' at index {} in {}", targetName, index, listPath); return true; } From 277feb4a07479872a83e71cd406da4596c75775a Mon Sep 17 00:00:00 2001 From: codepuncher Date: Mon, 15 Jun 2026 16:20:14 +0100 Subject: [PATCH 13/34] fix(handler): gate MCM quickexit on overlay closed not just mod page closed --- src/InputHandler.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/InputHandler.cpp b/src/InputHandler.cpp index 91a0cf3..4c4e4a1 100644 --- a/src/InputHandler.cpp +++ b/src/InputHandler.cpp @@ -638,6 +638,9 @@ void InputHandler::HandleMCMQuickexit() if (modOpen) { return; } + if (MCMNavigator::IsMCMOpen()) { + return; + } ResetMCMQuickexitState(); CloseJournal(); } From a22695ec46a3025766d65b0b00d2d6f240a1db9c Mon Sep 17 00:00:00 2001 From: codepuncher Date: Mon, 15 Jun 2026 16:22:39 +0100 Subject: [PATCH 14/34] fix(navigator): short-circuit CacheModListFromPapyrus when cache is already done --- src/MCMNavigator.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/MCMNavigator.cpp b/src/MCMNavigator.cpp index 2732d9e..b9e261e 100644 --- a/src/MCMNavigator.cpp +++ b/src/MCMNavigator.cpp @@ -407,6 +407,9 @@ namespace MCMNavigator void CacheModListFromPapyrus() { + if (g_skyUICacheDone) { + return; + } // Always clear g_cachePending on exit — needed when called via EnsureCachePopulated's // AddTask dispatch. Harmless double-clear when called from TryCacheFromOpenMCM's lambda. struct ClearPending From 230a56f81afca01f39b262edb7889bd85ae09acc Mon Sep 17 00:00:00 2001 From: codepuncher Date: Mon, 15 Jun 2026 16:31:13 +0100 Subject: [PATCH 15/34] chore(navigator): clarify EnsureCachePopulated comment to reflect task-interface retry --- src/MCMNavigator.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/MCMNavigator.h b/src/MCMNavigator.h index d98bbce..286043f 100644 --- a/src/MCMNavigator.h +++ b/src/MCMNavigator.h @@ -21,8 +21,8 @@ namespace MCMNavigator // Must be called on the game thread. No-op if SkyUI is not installed or script not yet bound. void CacheModListFromPapyrus(); - // Async. Safe to call from any thread. Schedules CacheModListFromPapyrus at most once; - // subsequent calls are no-ops regardless of whether caching succeeded. + // Async. Safe to call from any thread. Schedules CacheModListFromPapyrus once per session; + // retries only if the task interface was unavailable at the time of the call. // TryCacheFromOpenMCM handles retries when the Journal is open. void EnsureCachePopulated(); } From 865d4cffb1ce7f965b2b562a596f139e69a3911a Mon Sep 17 00:00:00 2001 From: codepuncher Date: Mon, 15 Jun 2026 16:32:00 +0100 Subject: [PATCH 16/34] chore(navigator): correct g_modCache comment to reflect both game and UI thread writes --- src/MCMNavigator.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/MCMNavigator.cpp b/src/MCMNavigator.cpp index b9e261e..948fec8 100644 --- a/src/MCMNavigator.cpp +++ b/src/MCMNavigator.cpp @@ -50,7 +50,7 @@ namespace MCMNavigator // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) inline std::atomic g_lock{ false }; - // Protected by g_cacheMutex — render thread reads, game thread writes. + // Protected by g_cacheMutex — render thread reads, game/UI thread writes. // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) inline std::mutex g_cacheMutex{}; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) From 6002e01d22f6337375430e896d6d97b6564c5a8a Mon Sep 17 00:00:00 2001 From: codepuncher Date: Mon, 15 Jun 2026 16:35:45 +0100 Subject: [PATCH 17/34] chore(navigator): clarify g_skyUICacheDone comment to cover all terminal conditions --- src/MCMNavigator.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/MCMNavigator.cpp b/src/MCMNavigator.cpp index 948fec8..d679de2 100644 --- a/src/MCMNavigator.cpp +++ b/src/MCMNavigator.cpp @@ -58,7 +58,8 @@ namespace MCMNavigator // Debounces cache population AddUITask calls. // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) inline std::atomic g_cachePending{ false }; - // Set once CacheModListFromPapyrus succeeds — prevents repeated VM lookups. + // Set when the Papyrus cache path is done — either successfully populated, SkyUI absent, + // or MCM Unlocked detected. Prevents further Papyrus lookups regardless of outcome. // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) inline std::atomic g_skyUICacheDone{ false }; // Set once EnsureCachePopulated successfully schedules CacheModListFromPapyrus. From 1dc2ed673a3a1438f7b77b6811dccc43688937c6 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Mon, 15 Jun 2026 17:08:55 +0100 Subject: [PATCH 18/34] fix(config): cast tolower result to char to avoid implicit narrowing conversion --- src/Config.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Config.cpp b/src/Config.cpp index 8d75d40..fce8934 100644 --- a/src/Config.cpp +++ b/src/Config.cpp @@ -44,7 +44,7 @@ namespace return "None"; } std::string lower{ trimmed }; - std::ranges::transform(lower, lower.begin(), [](unsigned char c) { return std::tolower(c); }); + std::ranges::transform(lower, lower.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); return lower == "none" ? "None" : std::string{ trimmed }; } } From df1f74ad63057c6d821d3ffcb89b08b257782b31 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Mon, 15 Jun 2026 17:59:30 +0100 Subject: [PATCH 19/34] fix(handler): defer _pendingMCMModName move until task interface is confirmed available --- src/InputHandler.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/InputHandler.cpp b/src/InputHandler.cpp index 4c4e4a1..745a60d 100644 --- a/src/InputHandler.cpp +++ b/src/InputHandler.cpp @@ -529,12 +529,12 @@ void InputHandler::InvokeScaleformTab(JournalTab tab) if (_pendingMCMModName.empty() || _pendingMCMModName == "None") { return; } - std::string modName = std::move(_pendingMCMModName); - _pendingMCMModName.clear(); const auto* taskIface = SKSE::GetTaskInterface(); if (!taskIface) { return; } + std::string modName = std::move(_pendingMCMModName); + _pendingMCMModName.clear(); taskIface->AddUITask([mn = std::move(modName)]() noexcept { MCMNavigator::NavigateToTarget(mn); }); From fa6739fc34a2f297830924c723e13e514f6f253a Mon Sep 17 00:00:00 2001 From: codepuncher Date: Mon, 15 Jun 2026 18:08:52 +0100 Subject: [PATCH 20/34] fix(navigator): remove ClearPending from CacheModListFromPapyrus to avoid clearing TryCacheFromOpenMCM debounce --- src/MCMNavigator.cpp | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/MCMNavigator.cpp b/src/MCMNavigator.cpp index d679de2..b25a8e8 100644 --- a/src/MCMNavigator.cpp +++ b/src/MCMNavigator.cpp @@ -55,7 +55,7 @@ namespace MCMNavigator inline std::mutex g_cacheMutex{}; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) inline std::vector g_modCache{}; - // Debounces cache population AddUITask calls. + // Owned by TryCacheFromOpenMCM — debounces its AddUITask so at most one is pending at a time. // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) inline std::atomic g_cachePending{ false }; // Set when the Papyrus cache path is done — either successfully populated, SkyUI absent, @@ -332,6 +332,7 @@ namespace MCMNavigator return; } inner->AddTask(CacheModListFromPapyrus); + g_cachePending = false; }); } @@ -411,17 +412,6 @@ namespace MCMNavigator if (g_skyUICacheDone) { return; } - // Always clear g_cachePending on exit — needed when called via EnsureCachePopulated's - // AddTask dispatch. Harmless double-clear when called from TryCacheFromOpenMCM's lambda. - struct ClearPending - { - ClearPending() = default; - ClearPending(const ClearPending&) = default; - ClearPending(ClearPending&&) = default; - ClearPending& operator=(const ClearPending&) = default; - ClearPending& operator=(ClearPending&&) = default; - ~ClearPending() { g_cachePending = false; } - } clearPending; auto* vm = RE::BSScript::Internal::VirtualMachine::GetSingleton(); if (!vm) { From 0e2af56c8e8182ebf5ecf82468cfd70f50b0c7f7 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Mon, 15 Jun 2026 18:17:10 +0100 Subject: [PATCH 21/34] fix(navigator): release g_lock if task interface becomes unavailable mid-retry chain --- src/MCMNavigator.cpp | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/MCMNavigator.cpp b/src/MCMNavigator.cpp index b25a8e8..6aaf421 100644 --- a/src/MCMNavigator.cpp +++ b/src/MCMNavigator.cpp @@ -78,24 +78,26 @@ namespace MCMNavigator return menu ? menu->uiMovie.get() : nullptr; } - void AddUITask(std::function func) + bool AddUITask(std::function func) { const auto* taskIface = SKSE::GetTaskInterface(); if (!taskIface) { logger::warn("MCMNavigator: task interface unavailable"); - return; + return false; } taskIface->AddUITask(std::move(func)); + return true; } - void DelayCallForUI(std::function func, int framesLeft) + bool DelayCallForUI(std::function func, int framesLeft) { if (framesLeft <= 0) { - AddUITask(std::move(func)); - return; + return AddUITask(std::move(func)); } - AddUITask([func = std::move(func), framesLeft]() mutable { - DelayCallForUI(std::move(func), framesLeft - 1); + return AddUITask([func = std::move(func), framesLeft]() mutable { + if (!DelayCallForUI(std::move(func), framesLeft - 1)) { + g_lock = false; + } }); } @@ -218,7 +220,9 @@ namespace MCMNavigator view->GetVariable(&disabled, disablePath.c_str()); if (!disabled.IsBool() || disabled.GetBool()) { g_target.modRetries++; - DelayCallForUI(OpenMod, kModRetryFrames); + if (!DelayCallForUI(OpenMod, kModRetryFrames)) { + g_lock = false; + } return; } @@ -227,7 +231,9 @@ namespace MCMNavigator auto mods = CollectEntryNames(view, std::string{ kModList }, "text"); if (mods.empty()) { g_target.modRetries++; - DelayCallForUI(OpenMod, kModRetryFrames); + if (!DelayCallForUI(OpenMod, kModRetryFrames)) { + g_lock = false; + } return; } From 96d554624126f53b7949bac501887bfd25d3dd26 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Mon, 15 Jun 2026 18:18:38 +0100 Subject: [PATCH 22/34] fix(navigator): downgrade repeatable CacheModListFromPapyrus log messages to debug --- src/MCMNavigator.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/MCMNavigator.cpp b/src/MCMNavigator.cpp index 6aaf421..5e40968 100644 --- a/src/MCMNavigator.cpp +++ b/src/MCMNavigator.cpp @@ -439,13 +439,13 @@ namespace MCMNavigator const auto handle = policy->GetHandleForObject( static_cast(RE::FormType::Quest), quest); if (handle == policy->EmptyHandle()) { - logger::info("MCMNavigator: could not get VM handle for SKI_ConfigManagerInstance"); + logger::debug("MCMNavigator: could not get VM handle for SKI_ConfigManagerInstance"); return; } RE::BSTSmartPointer managerObj; if (!vm->FindBoundObject(handle, "SKI_ConfigManager", managerObj) || !managerObj) { - logger::info("MCMNavigator: SKI_ConfigManager script not yet bound — will retry when MCM opens"); + logger::debug("MCMNavigator: SKI_ConfigManager script not yet bound — will retry when MCM opens"); return; } From 2c7444bd7636b1e3cd490793e609557b41d62151 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Mon, 15 Jun 2026 18:32:30 +0100 Subject: [PATCH 23/34] fix(ui): use case-insensitive comparison for combo selected-state in DrawMCMTargetInputs --- src/MCMNavigator.cpp | 13 +++---------- src/MenuUI.cpp | 2 +- src/Utils.h | 9 +++++++++ 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/MCMNavigator.cpp b/src/MCMNavigator.cpp index 5e40968..83a0780 100644 --- a/src/MCMNavigator.cpp +++ b/src/MCMNavigator.cpp @@ -1,8 +1,8 @@ #include "PCH.h" #include "MCMNavigator.h" +#include "Utils.h" -#include #include #include @@ -24,13 +24,6 @@ namespace MCMNavigator [](unsigned char x, unsigned char y) { return std::tolower(x) < std::tolower(y); }); } - bool CaseInsensitiveEqual(std::string_view a, std::string_view b) - { - return std::ranges::equal( - a, b, - [](unsigned char x, unsigned char y) { return std::tolower(x) == std::tolower(y); }); - } - std::string_view StripModNamePrefix(std::string_view name) { const auto pos = name.find("::"); @@ -169,7 +162,7 @@ namespace MCMNavigator if (!nameVal.IsString()) { continue; } - if (CaseInsensitiveEqual(targetName, nameVal.GetString())) { + if (HoldFast::CaseInsensitiveEqual(targetName, nameVal.GetString())) { index = static_cast(i); break; } @@ -290,7 +283,7 @@ namespace MCMNavigator RE::GFxValue titleText; const std::string titlePath = std::string{ kModListPanel } + "_titleText"; view->GetVariable(&titleText, titlePath.c_str()); - return titleText.IsString() && CaseInsensitiveEqual(modName, titleText.GetString()); + return titleText.IsString() && HoldFast::CaseInsensitiveEqual(modName, titleText.GetString()); } void CacheModListFromGFx() diff --git a/src/MenuUI.cpp b/src/MenuUI.cpp index d072e9d..34fe327 100644 --- a/src/MenuUI.cpp +++ b/src/MenuUI.cpp @@ -163,7 +163,7 @@ namespace } for (const auto& opt : cachedMods) { - if (ImGuiMCP::Selectable(opt.c_str(), opt == modName)) { + if (ImGuiMCP::Selectable(opt.c_str(), HoldFast::CaseInsensitiveEqual(opt, modName))) { modName = opt; changed = true; break; diff --git a/src/Utils.h b/src/Utils.h index e364b37..6bf45fb 100644 --- a/src/Utils.h +++ b/src/Utils.h @@ -1,6 +1,8 @@ #pragma once +#include #include +#include #include #include @@ -16,6 +18,13 @@ namespace HoldFast return s.substr(first, s.find_last_not_of(" \t\r\n") - first + 1); } + [[nodiscard]] inline bool CaseInsensitiveEqual(std::string_view a, std::string_view b) + { + return std::ranges::equal( + a, b, + [](unsigned char x, unsigned char y) { return std::tolower(x) == std::tolower(y); }); + } + [[nodiscard]] inline float ClampHoldDuration(float value, float defaultVal, float minVal, float maxVal) { assert(minVal > 0.0F && defaultVal >= minVal && defaultVal <= maxVal); From 753a2b2377b06e09b8752466ea6ee788930cbad2 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Tue, 16 Jun 2026 09:42:37 +0100 Subject: [PATCH 24/34] fix(navigator): check DelayCallForUI return in NavigateToTargetImpl to avoid stuck g_lock --- src/MCMNavigator.cpp | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/MCMNavigator.cpp b/src/MCMNavigator.cpp index 83a0780..5f85a9b 100644 --- a/src/MCMNavigator.cpp +++ b/src/MCMNavigator.cpp @@ -362,11 +362,9 @@ namespace MCMNavigator if (IsModAlreadyOpen(modName)) { g_lock = false; - } else if (!SKSE::GetTaskInterface()) { + } else if (!DelayCallForUI(OpenMod, kModRetryFrames)) { logger::warn("MCMNavigator: task interface unavailable — navigation cancelled"); g_lock = false; - } else { - DelayCallForUI(OpenMod, kModRetryFrames); } } From 6ae96792987048a02c3e1a6b875a30f3ca0505ef Mon Sep 17 00:00:00 2001 From: codepuncher Date: Tue, 16 Jun 2026 10:24:44 +0100 Subject: [PATCH 25/34] fix(plugin): use EnsureCachePopulated instead of direct AddTask in messaging listener --- src/Plugin.cpp | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Plugin.cpp b/src/Plugin.cpp index 232f987..1dad1d4 100644 --- a/src/Plugin.cpp +++ b/src/Plugin.cpp @@ -94,9 +94,7 @@ SKSEPluginLoad(const SKSE::LoadInterface* a_skse) case SKSE::MessagingInterface::kPostLoadGame: case SKSE::MessagingInterface::kNewGame: InputHandler::GetSingleton()->UpdateShortPressBinding(); - if (const auto* taskIface = SKSE::GetTaskInterface()) { - taskIface->AddTask(MCMNavigator::CacheModListFromPapyrus); - } + MCMNavigator::EnsureCachePopulated(); break; default: break; From 20a5afc7ac754ad79c8693899a5e5ef347db3f77 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Tue, 16 Jun 2026 10:27:53 +0100 Subject: [PATCH 26/34] chore(test): add ParseAction and ActionName test cases for kMCM --- test/PluginTests.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/PluginTests.cpp b/test/PluginTests.cpp index dde5c45..8475e56 100644 --- a/test/PluginTests.cpp +++ b/test/PluginTests.cpp @@ -75,11 +75,21 @@ TEST_CASE("ParseAction supports favourites alias and invalid fallback", "[config CHECK(ParseAction(" None ") == Action::kNone); } +TEST_CASE("ParseAction handles MCM action", "[config]") +{ + using Action = LongPressAction; + + CHECK(ParseAction("MCM") == Action::kMCM); + CHECK(ParseAction("mcm") == Action::kMCM); + CHECK(ParseAction(" Mcm ") == Action::kMCM); +} + TEST_CASE("ActionName maps enum values and falls back to None", "[config]") { using Action = LongPressAction; CHECK(ActionName(Action::kMap) == "Map"); + CHECK(ActionName(Action::kMCM) == "MCM"); CHECK(ActionName(Action::kQuickSave) == "QuickSave"); CHECK(ActionName(Action::kCharacterSheet) == "CharacterSheet"); CHECK(ActionName(static_cast(9999)) == "None"); From 67d84568c17f2e90bb3674690e654a94082f407a Mon Sep 17 00:00:00 2001 From: codepuncher Date: Tue, 16 Jun 2026 10:31:16 +0100 Subject: [PATCH 27/34] fix(navigator): add g_papyrusPending debounce to prevent flooding game-task queue --- src/MCMNavigator.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/MCMNavigator.cpp b/src/MCMNavigator.cpp index 5f85a9b..6552c7f 100644 --- a/src/MCMNavigator.cpp +++ b/src/MCMNavigator.cpp @@ -60,6 +60,11 @@ namespace MCMNavigator // TryCacheFromOpenMCM uses g_cachePending for its own debounce and handles retries. // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) inline std::atomic g_papyrusEagerScheduled{ false }; + // Set while a CacheModListFromPapyrus game-thread task is pending. + // Prevents TryCacheFromOpenMCM from flooding the game-task queue when + // SKI_ConfigManager is not yet bound. + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + inline std::atomic g_papyrusPending{ false }; RE::GFxMovieView* GetJournalView() { @@ -330,6 +335,10 @@ namespace MCMNavigator g_cachePending = false; return; } + if (g_papyrusPending.exchange(true)) { + g_cachePending = false; + return; + } inner->AddTask(CacheModListFromPapyrus); g_cachePending = false; }); @@ -409,6 +418,7 @@ namespace MCMNavigator if (g_skyUICacheDone) { return; } + g_papyrusPending = false; auto* vm = RE::BSScript::Internal::VirtualMachine::GetSingleton(); if (!vm) { From 8498be994083d1b59ae73587337cf8ce88477fc1 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Tue, 16 Jun 2026 10:53:02 +0100 Subject: [PATCH 28/34] fix(navigator): always clear g_papyrusPending on exit from CacheModListFromPapyrus --- src/MCMNavigator.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/MCMNavigator.cpp b/src/MCMNavigator.cpp index 6552c7f..e2afcdf 100644 --- a/src/MCMNavigator.cpp +++ b/src/MCMNavigator.cpp @@ -415,10 +415,19 @@ namespace MCMNavigator void CacheModListFromPapyrus() { + const struct ClearPapyrusPending + { + ClearPapyrusPending() = default; + ClearPapyrusPending(const ClearPapyrusPending&) = default; + ClearPapyrusPending(ClearPapyrusPending&&) = default; + ClearPapyrusPending& operator=(const ClearPapyrusPending&) = default; + ClearPapyrusPending& operator=(ClearPapyrusPending&&) = default; + ~ClearPapyrusPending() { g_papyrusPending = false; } + } clearPapyrusPending; + if (g_skyUICacheDone) { return; } - g_papyrusPending = false; auto* vm = RE::BSScript::Internal::VirtualMachine::GetSingleton(); if (!vm) { From 3c653cf16918c34f79f715a98ec7b4878c964b33 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Tue, 16 Jun 2026 12:32:46 +0100 Subject: [PATCH 29/34] fix(navigator): set g_papyrusPending in EnsureCachePopulated before scheduling task --- src/MCMNavigator.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/MCMNavigator.cpp b/src/MCMNavigator.cpp index e2afcdf..bd5b5e8 100644 --- a/src/MCMNavigator.cpp +++ b/src/MCMNavigator.cpp @@ -493,6 +493,7 @@ namespace MCMNavigator g_papyrusEagerScheduled = false; return; } + g_papyrusPending = true; taskIface->AddTask(CacheModListFromPapyrus); } } From ea7d78884d539d051c1ac22ad0d455a61ab2c538 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Tue, 16 Jun 2026 13:26:32 +0100 Subject: [PATCH 30/34] fix(navigator): use AddTask game-thread delay in DelayCallForUI to guarantee real frame spacing --- src/MCMNavigator.cpp | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/MCMNavigator.cpp b/src/MCMNavigator.cpp index bd5b5e8..5867016 100644 --- a/src/MCMNavigator.cpp +++ b/src/MCMNavigator.cpp @@ -87,16 +87,21 @@ namespace MCMNavigator return true; } - bool DelayCallForUI(std::function func, int framesLeft) + bool DelayCallForUI(std::function func, int gameFramesLeft) { - if (framesLeft <= 0) { + if (gameFramesLeft <= 0) { return AddUITask(std::move(func)); } - return AddUITask([func = std::move(func), framesLeft]() mutable { - if (!DelayCallForUI(std::move(func), framesLeft - 1)) { + const auto* taskIface = SKSE::GetTaskInterface(); + if (!taskIface) { + return false; + } + taskIface->AddTask([func = std::move(func), gameFramesLeft]() mutable { + if (!DelayCallForUI(std::move(func), gameFramesLeft - 1)) { g_lock = false; } }); + return true; } std::vector CollectEntryNames(RE::GFxMovieView* view, const std::string& listPath, const char* memberName) From 7c4ec9512a203478ad4b6ea01a6a6446d6c3b341 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Tue, 16 Jun 2026 13:28:15 +0100 Subject: [PATCH 31/34] fix(handler): revert quickexit to close journal on mod page exit not MCM overlay exit --- src/InputHandler.cpp | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/InputHandler.cpp b/src/InputHandler.cpp index 745a60d..f9bc5b9 100644 --- a/src/InputHandler.cpp +++ b/src/InputHandler.cpp @@ -638,9 +638,6 @@ void InputHandler::HandleMCMQuickexit() if (modOpen) { return; } - if (MCMNavigator::IsMCMOpen()) { - return; - } ResetMCMQuickexitState(); CloseJournal(); } From e2db47caf33a24bed1380a89fb61d5f346897b28 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Tue, 16 Jun 2026 17:05:31 +0100 Subject: [PATCH 32/34] fix(navigator): replace retry counter with wall-clock deadline in OpenMod --- src/MCMNavigator.cpp | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/MCMNavigator.cpp b/src/MCMNavigator.cpp index 5867016..30da0fd 100644 --- a/src/MCMNavigator.cpp +++ b/src/MCMNavigator.cpp @@ -3,6 +3,7 @@ #include "MCMNavigator.h" #include "Utils.h" +#include #include #include @@ -10,8 +11,8 @@ namespace MCMNavigator { namespace { - constexpr int kMaxRetries = 20; - constexpr int kModRetryFrames = 3; // ~50ms at 60fps; enough for MCM list to finish animating + constexpr int kModRetryFrames = 3; + constexpr auto kNavTimeout = std::chrono::seconds(3); constexpr auto kConfigPanel = "_root.ConfigPanelFader.configPanel."; constexpr auto kModListPanel = "_root.ConfigPanelFader.configPanel.contentHolder.modListPanel."; @@ -32,8 +33,8 @@ namespace MCMNavigator struct NavigationTarget { - std::string modName; - int modRetries{ 0 }; + std::string modName; + std::chrono::steady_clock::time_point deadline; }; // Set once per dispatch, consumed by the async chain. @@ -203,9 +204,8 @@ namespace MCMNavigator void OpenMod() { - if (g_target.modRetries >= kMaxRetries) { - logger::warn("MCMNavigator: mod selection retry limit reached"); - g_target.modRetries = 0; + if (std::chrono::steady_clock::now() > g_target.deadline) { + logger::warn("MCMNavigator: mod selection timed out waiting for MCM to be ready"); g_lock = false; return; } @@ -222,7 +222,6 @@ namespace MCMNavigator const std::string disablePath = std::string{ kModList } + "disableSelection"; view->GetVariable(&disabled, disablePath.c_str()); if (!disabled.IsBool() || disabled.GetBool()) { - g_target.modRetries++; if (!DelayCallForUI(OpenMod, kModRetryFrames)) { g_lock = false; } @@ -233,7 +232,6 @@ namespace MCMNavigator // populated the data yet. Retry until we have at least one entry. auto mods = CollectEntryNames(view, std::string{ kModList }, "text"); if (mods.empty()) { - g_target.modRetries++; if (!DelayCallForUI(OpenMod, kModRetryFrames)) { g_lock = false; } @@ -249,7 +247,6 @@ namespace MCMNavigator } } - g_target.modRetries = 0; if (!SelectEntryByName(kModList, "text", g_target.modName)) { logger::warn("MCMNavigator: mod '{}' not found — MCM mod list shown", g_target.modName); } @@ -372,7 +369,7 @@ namespace MCMNavigator } g_target.modName = modName; - g_target.modRetries = 0; + g_target.deadline = std::chrono::steady_clock::now() + kNavTimeout; if (IsModAlreadyOpen(modName)) { g_lock = false; From 300fa3bd84913f5fda1d88feb59e63eece11fd92 Mon Sep 17 00:00:00 2001 From: codepuncher Date: Tue, 16 Jun 2026 17:31:36 +0100 Subject: [PATCH 33/34] fix(navigator): guard AddTask in EnsureCachePopulated with g_papyrusPending exchange --- src/MCMNavigator.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/MCMNavigator.cpp b/src/MCMNavigator.cpp index 30da0fd..abf834b 100644 --- a/src/MCMNavigator.cpp +++ b/src/MCMNavigator.cpp @@ -495,7 +495,8 @@ namespace MCMNavigator g_papyrusEagerScheduled = false; return; } - g_papyrusPending = true; - taskIface->AddTask(CacheModListFromPapyrus); + if (!g_papyrusPending.exchange(true)) { + taskIface->AddTask(CacheModListFromPapyrus); + } } } From a62b7ddaf1b3817c6460681f12f61161bdafa9dc Mon Sep 17 00:00:00 2001 From: codepuncher Date: Tue, 16 Jun 2026 17:58:27 +0100 Subject: [PATCH 34/34] chore(ui): clarify quickexit label to say MCM mod page not MCM --- HoldFast.ini | 4 ++-- README.md | 4 ++-- src/MenuUI.cpp | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/HoldFast.ini b/HoldFast.ini index 8d0f8bf..faa00f8 100644 --- a/HoldFast.ini +++ b/HoldFast.ini @@ -6,12 +6,12 @@ fHoldDuration=0.5 sButtonStartAction=Map ; Mod to open when sButtonStartAction=MCM. Leave as None to open the MCM mod list. sButtonStartMCMModName=None -; Close the journal automatically when leaving MCM via Start. Only applies when sButtonStartAction=MCM. +; Close the journal automatically when leaving a MCM mod page via Start. Only applies when sButtonStartAction=MCM. bButtonStartMCMQuickexit=true ; Long-press action for the Back (View) button. Short press performs the button's normal function. ; Valid values: Map, System, Quests, Stats, Inventory, Magic, Favorites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, MCM, None (case-insensitive) sButtonBackAction=System ; Mod to open when sButtonBackAction=MCM. Leave as None to open the MCM mod list. sButtonBackMCMModName=None -; Close the journal automatically when leaving MCM via Back. Only applies when sButtonBackAction=MCM. +; Close the journal automatically when leaving a MCM mod page via Back. Only applies when sButtonBackAction=MCM. bButtonBackMCMQuickexit=true diff --git a/README.md b/README.md index 2992719..64b802d 100644 --- a/README.md +++ b/README.md @@ -46,14 +46,14 @@ fHoldDuration=0.5 sButtonStartAction=Map ; Mod to open when sButtonStartAction=MCM. Leave as None to open the MCM mod list. sButtonStartMCMModName=None -; Close the journal automatically when leaving MCM via Start. Only applies when sButtonStartAction=MCM. +; Close the journal automatically when leaving a MCM mod page via Start. Only applies when sButtonStartAction=MCM. bButtonStartMCMQuickexit=true ; Long-press action for the Back (View) button. Short press performs the button's normal function. ; Valid values: Map, System, Quests, Stats, Inventory, Magic, Favorites, TweenMenu, Wait, NewSave, QuickSave, Bestiary, CharacterSheet, MCM, None (case-insensitive) sButtonBackAction=System ; Mod to open when sButtonBackAction=MCM. Leave as None to open the MCM mod list. sButtonBackMCMModName=None -; Close the journal automatically when leaving MCM via Back. Only applies when sButtonBackAction=MCM. +; Close the journal automatically when leaving a MCM mod page via Back. Only applies when sButtonBackAction=MCM. bButtonBackMCMQuickexit=true ``` diff --git a/src/MenuUI.cpp b/src/MenuUI.cpp index 34fe327..551d19c 100644 --- a/src/MenuUI.cpp +++ b/src/MenuUI.cpp @@ -184,7 +184,7 @@ namespace "Start MCM mod name", state.stagedSettings.startMCMModName, changed); - changed |= ImGuiMCP::Checkbox("Close journal after leaving MCM##start", &state.stagedSettings.startMCMQuickexit); + changed |= ImGuiMCP::Checkbox("Close journal after leaving MCM mod page##start", &state.stagedSettings.startMCMQuickexit); } changed |= DrawActionCombo("Back long-press action", state.stagedSettings.backAction); if (state.stagedSettings.backAction == InputHandler::LongPressAction::kMCM) { @@ -192,7 +192,7 @@ namespace "Back MCM mod name", state.stagedSettings.backMCMModName, changed); - changed |= ImGuiMCP::Checkbox("Close journal after leaving MCM##back", &state.stagedSettings.backMCMQuickexit); + changed |= ImGuiMCP::Checkbox("Close journal after leaving MCM mod page##back", &state.stagedSettings.backMCMQuickexit); } if (changed) {