Skip to content
Merged
7 changes: 3 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,10 @@ jobs:
steps:
- uses: actions/checkout@v6

- uses: DoozyX/clang-format-lint-action@v0.20
- uses: jidicula/clang-format-action@v4.18.0
with:
source: src
extensions: cpp,h
clangFormatVersion: 19
clang-format-version: '22'
Comment thread
codepuncher marked this conversation as resolved.
check-path: src

clang-tidy:
needs: clang-format
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# QuickMap

Hold the gamepad **Start button** for a configurable duration to open the **world map**. A short press opens the Journal Menu (or Tween Menu) as normal.
Hold the gamepad **Start button** for a configurable duration to open the **world map**. A short press opens whatever menu Start is bound to in your controls.

> **Gamepad only.** Vanilla behaviour is fully preserved.
> **Gamepad only.** Default behaviour is fully preserved.
Comment thread
codepuncher marked this conversation as resolved.

Inspired by Red Dead Redemption 2's hold-Start-to-open-map mechanic.

Expand Down
4 changes: 2 additions & 2 deletions docs/nexus-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Short Description

Hold the gamepad Start button to open the world map. Short press opens the Journal (or whatever Start is bound to) as normal.
Hold the gamepad Start button to open the world map. Short press opens whatever menu Start is bound to in your controls.

---

Expand All @@ -27,7 +27,7 @@ Hold the gamepad Start button to open the world map. Short press opens the Journ

[size=4][b]Overview[/b][/size]

Hold the gamepad [b]Start button[/b] for a configurable duration to open the [b]world map[/b] directly. A short press opens the Journal Menu (or Tween Menu) as normal — vanilla behaviour is fully preserved.
Hold the gamepad [b]Start button[/b] for a configurable duration to open the [b]world map[/b] directly. A short press opens whatever menu Start is bound to in your controls.

[color=#ffcc00][b]Gamepad only.[/b][/color]

Expand Down
6 changes: 3 additions & 3 deletions scripts/format.sh
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail

if command -v clang-format-19 &>/dev/null; then
FMT=clang-format-19
if command -v clang-format-22 &>/dev/null; then
FMT=clang-format-22
else
echo "warning: clang-format-19 not found, using $(clang-format --version); CI uses v19" >&2
echo "warning: clang-format-22 not found, using $(clang-format --version); CI uses v22" >&2
FMT=clang-format
fi

Expand Down
66 changes: 38 additions & 28 deletions src/InputHandler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,40 +10,31 @@ InputHandler* InputHandler::GetSingleton()
return &instance;
}

void InputHandler::UpdateShortPressMenu()
void InputHandler::UpdateShortPressUserEvent()
{
auto* controlMap = RE::ControlMap::GetSingleton();
if (!controlMap) {
logger::error("ControlMap unavailable — short press will have no effect");
shortPressMenuName.clear();
shortPressUserEvent = "";
return;
}

constexpr auto kStart = static_cast<std::uint32_t>(RE::BSWin32GamepadDevice::Key::kStart);
const auto userEvent = controlMap->GetUserEventName(kStart, RE::INPUT_DEVICE::kGamepad);
shortPressUserEvent = controlMap->GetUserEventName(kStart, RE::INPUT_DEVICE::kGamepad);

if (userEvent == "Tween Menu") {
shortPressMenuName = RE::TweenMenu::MENU_NAME;
} else if (userEvent == "Journal") {
shortPressMenuName = RE::JournalMenu::MENU_NAME;
if (shortPressUserEvent.empty()) {
logger::warn("Start has no binding in kGameplay context — short press disabled");
} else {
if (userEvent.empty()) {
logger::warn("Start has no binding in kGameplay context — short press disabled");
} else {
logger::warn("Unknown user event '{}' for Start — short press disabled", userEvent);
}
shortPressMenuName.clear();
logger::info("Start short press user event: '{}'", shortPressUserEvent);
}

logger::info("Start short press bound to '{}'", shortPressMenuName.empty() ? "(none)" : shortPressMenuName);
}

RE::BSEventNotifyControl InputHandler::ProcessEvent(
const RE::MenuOpenCloseEvent* a_event,
RE::BSTEventSource<RE::MenuOpenCloseEvent>* /*a_eventSource*/)
{
if (a_event && !a_event->opening && a_event->menuName == RE::JournalMenu::MENU_NAME) {
UpdateShortPressMenu();
UpdateShortPressUserEvent();
}
return RE::BSEventNotifyControl::kContinue;
}
Expand Down Expand Up @@ -97,12 +88,12 @@ bool InputHandler::ProcessStartButton(const RE::ButtonEvent* btn)

if (btn->IsHeld() && _pressTime) {
if (!_mapTriggered && btn->HeldDuration() >= holdDuration) {
_mapTriggered = true;
auto* uiQueue = RE::UIMessageQueue::GetSingleton();
if (!uiQueue) {
logger::error("UIMessageQueue unavailable — hold threshold reached but map not opened");
} else {
uiQueue->AddMessage(RE::MapMenu::MENU_NAME, RE::UI_MESSAGE_TYPE::kShow, nullptr);
_mapTriggered = true;
}
}
return true;
Expand Down Expand Up @@ -132,22 +123,41 @@ void InputHandler::DispatchShortPress(float held) const
return;
}

auto* uiQueue = RE::UIMessageQueue::GetSingleton();
if (!uiQueue) {
logger::error("UIMessageQueue unavailable — Start press consumed but no menu opened");
if (shortPressUserEvent.empty()) {
logger::warn("Start short press has no binding — press consumed but no menu opened");
return;
}

if (shortPressMenuName.empty()) {
logger::warn("Start short press has no known binding — press consumed but no menu opened");
auto* menuControls = RE::MenuControls::GetSingleton();
if (!menuControls || !menuControls->menuOpenHandler) {
logger::error("MenuControls or menuOpenHandler unavailable — short press consumed but no menu opened");
return;
}

if (shortPressMenuName == RE::JournalMenu::MENU_NAME) {
using func_t = void (*)(bool);
static REL::Relocation<func_t> openJournal{ RELOCATION_ID(52428, 53327) };
openJournal(true);
} else {
uiQueue->AddMessage(shortPressMenuName, RE::UI_MESSAGE_TYPE::kShow, nullptr);
constexpr auto kStart = static_cast<std::uint32_t>(RE::BSWin32GamepadDevice::Key::kStart);
auto deleter = [](RE::ButtonEvent* e) {
e->~ButtonEvent();
RE::free(e);
};
std::unique_ptr<RE::ButtonEvent, decltype(deleter)> syntheticEvent{
RE::ButtonEvent::Create(
RE::INPUT_DEVICE::kGamepad,
shortPressUserEvent,
kStart,
1.0F, // value=1.0 → IsPressed()=true, IsDown()=true
0.0F // heldDownSecs=0.0 → IsDown()=true
),
deleter
};
if (!syntheticEvent) {
logger::error("Failed to allocate synthetic ButtonEvent — short press consumed but no menu opened");
return;
}

if (!menuControls->menuOpenHandler->CanProcess(syntheticEvent.get())) {
logger::warn("MenuOpenHandler rejected short press — press consumed but no menu opened");
return;
}

menuControls->menuOpenHandler->ProcessButton(syntheticEvent.get());
}
19 changes: 12 additions & 7 deletions src/InputHandler.h
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ class InputHandler :
public:
static InputHandler* GetSingleton();

InputHandler(const InputHandler&) = delete;
InputHandler(InputHandler&&) = delete;
InputHandler& operator=(const InputHandler&) = delete;
InputHandler& operator=(InputHandler&&) = delete;

RE::BSEventNotifyControl ProcessEvent(
RE::InputEvent* const* a_events,
RE::BSTEventSource<RE::InputEvent*>* a_source) override;
Expand All @@ -17,22 +22,22 @@ class InputHandler :

static constexpr float kDefaultHoldDuration{ 0.5F };

float holdDuration{ kDefaultHoldDuration };
std::string shortPressMenuName;
void SetHoldDuration(float a_duration) noexcept { holdDuration = a_duration; }

// Queries ControlMap for the menu bound to Start and caches it in shortPressMenuName.
// Queries ControlMap for the user event bound to Start and caches it in shortPressUserEvent.
// Call at kInputLoaded, kPostLoadGame, and kNewGame.
Comment thread
codepuncher marked this conversation as resolved.
void UpdateShortPressMenu();
void UpdateShortPressUserEvent();

~InputHandler() override = default;

private:
InputHandler() = default;
~InputHandler() = default;
InputHandler(const InputHandler&) = delete;
InputHandler& operator=(const InputHandler&) = delete;

bool ProcessStartButton(const RE::ButtonEvent* btn);
void DispatchShortPress(float held) const;

float holdDuration{ kDefaultHoldDuration };
RE::BSFixedString shortPressUserEvent;
std::optional<std::chrono::steady_clock::time_point> _pressTime;
bool _mapTriggered{ false };
};
11 changes: 6 additions & 5 deletions src/Plugin.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,10 @@ float ReadHoldDuration()

void OnInputLoaded()
{
auto* handler = InputHandler::GetSingleton();
handler->holdDuration = ReadHoldDuration();
logger::info("Hold duration: {:.2f}s", handler->holdDuration);
auto* handler = InputHandler::GetSingleton();
const float holdDuration = ReadHoldDuration();
handler->SetHoldDuration(holdDuration);
logger::info("Hold duration: {:.2f}s", holdDuration);

auto* inputDeviceMgr = RE::BSInputDeviceManager::GetSingleton();
if (!inputDeviceMgr) {
Expand All @@ -57,7 +58,7 @@ void OnInputLoaded()
ui->AddEventSink<RE::MenuOpenCloseEvent>(handler);
}

handler->UpdateShortPressMenu();
handler->UpdateShortPressUserEvent();
}

SKSEPluginLoad(const SKSE::LoadInterface* a_skse)
Expand Down Expand Up @@ -85,7 +86,7 @@ SKSEPluginLoad(const SKSE::LoadInterface* a_skse)
break;
case SKSE::MessagingInterface::kPostLoadGame:
case SKSE::MessagingInterface::kNewGame:
InputHandler::GetSingleton()->UpdateShortPressMenu();
InputHandler::GetSingleton()->UpdateShortPressUserEvent();
break;
default:
break;
Expand Down
Loading