Add controller support and safe main menu hooks

Enable gamepad navigation for the PrismaUI server browser and install MainMenu input hooks safely. Poll XInput each game-thread tick and forward button/stick changes to the browser (ForwardButtonEvent / ForwardThumbstickEvent), allocate an input-enable layer to suppress vanilla kMenu events while the browser is open, and register polling via the F4SE task interface. Replace direct vtable writes that caused a loading-screen crash with REL::Relocation::write_vfunc on the MainMenu BSInputEventUser secondary vtable and install OnButton/OnThumbstick hooks once at plugin Install(). Add MainMenu API to suppress inputEventHandling and call it when showing/hiding the browser. Also update PrismaUI browser JS to support controller focus for direct-connect, filter activation/cycling, improved controller prompts, and add CSS for focused direct-connect fields. Files touched include plugin/src/F4TMenuInput.cpp, plugin/src/F4TMainMenuInject.cpp, plugin/include headers, PrismaUI view JS/components, styles, and documentation.
This commit is contained in:
2026-06-18 14:26:33 +12:00
parent 3d14918ee8
commit f720d35d2a
11 changed files with 626 additions and 102 deletions
+81 -14
View File
@@ -1,6 +1,7 @@
#include "pch.h"
#include "F4TMainMenuInject.h"
#include "F4TMenuInput.h"
#include "F4TNetworking.h"
#include "F4TPrismaUI.h"
@@ -24,7 +25,9 @@ namespace F4T::MainMenuInject
constexpr auto kLoadIndex = 4;
constexpr auto kResyncThrottleFrames = 30;
constexpr auto kItemPressListenerPriority = 100;
constexpr auto kOnButtonEventVtableIndex = 0x08;
constexpr auto kBSInputEventUserOffset = 0x10;
constexpr auto kBSInputOnThumbstickVtableIndex = 0x04;
constexpr auto kBSInputOnButtonVtableIndex = 0x08;
constexpr auto kEntryListPath = "MainPanel_mc.List_mc.entryList";
constexpr auto kListWidgetPath = "MainPanel_mc.List_mc";
constexpr std::array<std::string_view, 3> kItemPressEventCandidates{
@@ -36,7 +39,7 @@ namespace F4T::MainMenuInject
bool g_hooksInstalled = false;
bool g_resyncTaskStarted = false;
bool g_itemPressListenerRegistered = false;
bool g_buttonEventHookInstalled = false;
bool g_mainMenuInputHooksInstalled = false;
int g_resyncFrameCounter = 0;
int g_injectFailLogCooldown = 0;
@@ -618,29 +621,87 @@ namespace F4T::MainMenuInject
eventNames.size()));
}
using OnButtonEventFn = void (*)(RE::IMenu*, const RE::ButtonEvent*);
OnButtonEventFn g_originalOnButtonEvent = nullptr;
std::uintptr_t g_originalMainMenuOnButtonEvent = 0;
std::uintptr_t g_originalMainMenuOnThumbstickEvent = 0;
void OnButtonEventHook(RE::IMenu* a_this, const RE::ButtonEvent* a_event)
RE::IMenu* MainMenuFromInputUser(RE::BSInputEventUser* a_inputUser)
{
if (!a_inputUser) {
return nullptr;
}
return reinterpret_cast<RE::IMenu*>(reinterpret_cast<char*>(a_inputUser) - kBSInputEventUserOffset);
}
using MainMenuOnButtonEventFn = void (*)(RE::BSInputEventUser*, const RE::ButtonEvent*);
using MainMenuOnThumbstickEventFn = void (*)(RE::BSInputEventUser*, const RE::ThumbstickEvent*);
void MainMenuOnButtonEventHook(RE::BSInputEventUser* a_this, const RE::ButtonEvent* a_event)
{
if (F4T::PrismaUI::IsBrowserVisible()) {
F4T::MenuInput::ForwardButtonEvent(a_event);
return;
}
if (a_this && a_event && a_event->QJustPressed() && !a_event->QDisabled()) {
const auto& userEvent = a_event->QUserEvent();
if (userEvent == "Accept"sv) {
const auto* ui = RE::UI::GetSingleton();
const auto mainMenu = ui ? ui->GetMenu<RE::MainMenu>() : nullptr;
if (mainMenu && mainMenu.get() == a_this && TryHandleMultiplayerSelection(mainMenu.get())) {
const auto* menu = MainMenuFromInputUser(a_this);
if (mainMenu && mainMenu.get() == menu && TryHandleMultiplayerSelection(mainMenu.get())) {
return;
}
}
}
if (g_originalOnButtonEvent) {
g_originalOnButtonEvent(a_this, a_event);
if (g_originalMainMenuOnButtonEvent != 0) {
reinterpret_cast<MainMenuOnButtonEventFn>(g_originalMainMenuOnButtonEvent)(a_this, a_event);
}
}
void MainMenuOnThumbstickEventHook(RE::BSInputEventUser* a_this, const RE::ThumbstickEvent* a_event)
{
if (F4T::PrismaUI::IsBrowserVisible()) {
F4T::MenuInput::ForwardThumbstickEvent(a_event);
return;
}
if (g_originalMainMenuOnThumbstickEvent != 0) {
reinterpret_cast<MainMenuOnThumbstickEventFn>(g_originalMainMenuOnThumbstickEvent)(a_this, a_event);
}
}
void InstallMainMenuInputHooks()
{
if (g_mainMenuInputHooksInstalled) {
return;
}
// MainMenu::VTABLE[1] is the BSInputEventUser secondary vtable.
REL::Relocation<std::uintptr_t> inputVtable{ RE::MainMenu::VTABLE[1] };
g_originalMainMenuOnThumbstickEvent = inputVtable.write_vfunc(
kBSInputOnThumbstickVtableIndex,
&MainMenuOnThumbstickEventHook);
g_originalMainMenuOnButtonEvent = inputVtable.write_vfunc(
kBSInputOnButtonVtableIndex,
&MainMenuOnButtonEventHook);
if (g_originalMainMenuOnButtonEvent == 0) {
LogWarning("MainMenu input OnButtonEvent hook failed to install.");
return;
}
g_mainMenuInputHooksInstalled = true;
LogInfo("Installed MainMenu input hooks on BSInputEventUser vtable.");
}
void PollMultiplayerActivation()
{
if (F4T::PrismaUI::IsBrowserVisible()) {
return;
}
const auto* ui = RE::UI::GetSingleton();
if (!ui || !ui->GetMenuOpen<RE::MainMenu>() || !IsMainMenuOnMainPanel(ui)) {
return;
@@ -768,6 +829,17 @@ namespace F4T::MainMenuInject
return IsMainStateForMenu(mainMenu.get());
}
void SetMainMenuInputSuppressed(bool a_suppressed)
{
const auto* ui = RE::UI::GetSingleton();
const auto mainMenu = ui ? ui->GetMenu<RE::MainMenu>() : nullptr;
if (!mainMenu) {
return;
}
mainMenu->inputEventHandlingEnabled = !a_suppressed;
}
void Install()
{
if (g_hooksInstalled) {
@@ -783,12 +855,7 @@ namespace F4T::MainMenuInject
return;
}
g_originalOnButtonEvent = reinterpret_cast<OnButtonEventFn>(
startMenuVtable.write_vfunc(kOnButtonEventVtableIndex, &OnButtonEventHook));
g_buttonEventHookInstalled = g_originalOnButtonEvent != nullptr;
if (!g_buttonEventHookInstalled) {
LogWarning("StartMenuBase::OnButtonEvent hook failed to install; Accept on MULTIPLAYER may not open the browser.");
}
InstallMainMenuInputHooks();
auto* ui = RE::UI::GetSingleton();
if (ui) {
+311 -58
View File
@@ -3,107 +3,360 @@
#include "F4TMenuInput.h"
#include "F4TServerBrowserBridge.h"
#include <F4SE/InputMap.h>
#include <REX/W32/XINPUT.h>
#include <Windows.h>
namespace F4T::MenuInput
{
namespace
{
std::atomic_bool g_browserActive{ false };
std::atomic_bool g_sinkRegistered{ false };
std::atomic_bool g_registered{ false };
RE::BSTSmartPointer<RE::BSInputEnableLayer> g_menuInputBlockLayer;
const char* GamepadButtonName(std::uint32_t a_keyCode)
using XInputGetStateFn = DWORD(WINAPI*)(DWORD, REX::W32::XINPUT_STATE*);
struct GamepadButtonDef
{
switch (a_keyCode) {
case F4SE::InputMap::kGamepadButtonOffset_DPAD_UP:
return "dpad_up";
case F4SE::InputMap::kGamepadButtonOffset_DPAD_DOWN:
return "dpad_down";
case F4SE::InputMap::kGamepadButtonOffset_DPAD_LEFT:
return "dpad_left";
case F4SE::InputMap::kGamepadButtonOffset_DPAD_RIGHT:
return "dpad_right";
case F4SE::InputMap::kGamepadButtonOffset_A:
std::uint16_t mask;
const char* name;
};
constexpr GamepadButtonDef kGamepadButtons[] = {
{ REX::W32::XINPUT_GAMEPAD_DPAD_UP, "dpad_up" },
{ REX::W32::XINPUT_GAMEPAD_DPAD_DOWN, "dpad_down" },
{ REX::W32::XINPUT_GAMEPAD_DPAD_LEFT, "dpad_left" },
{ REX::W32::XINPUT_GAMEPAD_DPAD_RIGHT, "dpad_right" },
{ REX::W32::XINPUT_GAMEPAD_A, "a" },
{ REX::W32::XINPUT_GAMEPAD_B, "b" },
{ REX::W32::XINPUT_GAMEPAD_X, "x" },
{ REX::W32::XINPUT_GAMEPAD_Y, "y" },
{ REX::W32::XINPUT_GAMEPAD_LEFT_SHOULDER, "lb" },
{ REX::W32::XINPUT_GAMEPAD_RIGHT_SHOULDER, "rb" },
};
bool g_buttonHeld[std::size(kGamepadButtons)]{};
const char* g_activeStickButton = nullptr;
std::chrono::steady_clock::time_point g_stickRepeatTime{};
XInputGetStateFn GetXInputGetState()
{
static XInputGetStateFn fn = nullptr;
static bool tried = false;
if (!tried) {
tried = true;
const char* dllNames[] = { "xinput1_4.dll", "xinput1_3.dll", "xinput9_1_0.dll" };
for (const auto* dllName : dllNames) {
const auto module = ::LoadLibraryA(dllName);
if (!module) {
continue;
}
fn = reinterpret_cast<XInputGetStateFn>(::GetProcAddress(module, "XInputGetState"));
if (fn) {
REX::INFO("[MenuInput] Using {} for gamepad polling.", dllName);
break;
}
}
if (!fn) {
REX::WARN("[MenuInput] XInputGetState unavailable; gamepad browser navigation disabled.");
}
}
return fn;
}
const char* UserEventToButton(std::string_view a_userEvent)
{
if (a_userEvent == "Accept"sv) {
return "a";
case F4SE::InputMap::kGamepadButtonOffset_B:
}
if (a_userEvent == "Cancel"sv || a_userEvent == "MenuCancel"sv) {
return "b";
case F4SE::InputMap::kGamepadButtonOffset_X:
}
if (a_userEvent == "XButton"sv) {
return "x";
case F4SE::InputMap::kGamepadButtonOffset_Y:
}
if (a_userEvent == "YButton"sv) {
return "y";
case F4SE::InputMap::kGamepadButtonOffset_LEFT_SHOULDER:
}
if (a_userEvent == "LeftShoulder"sv || a_userEvent == "LBumper"sv) {
return "lb";
case F4SE::InputMap::kGamepadButtonOffset_RIGHT_SHOULDER:
}
if (a_userEvent == "RightShoulder"sv || a_userEvent == "RBumper"sv) {
return "rb";
}
if (a_userEvent == "Up"sv) {
return "dpad_up";
}
if (a_userEvent == "Down"sv) {
return "dpad_down";
}
if (a_userEvent == "Left"sv) {
return "dpad_left";
}
if (a_userEvent == "Right"sv) {
return "dpad_right";
}
return nullptr;
}
const char* ThumbstickDirectionToButton(RE::DIRECTION_VAL a_direction)
{
switch (a_direction) {
case RE::DIRECTION_VAL::kUp:
return "dpad_up";
case RE::DIRECTION_VAL::kDown:
return "dpad_down";
case RE::DIRECTION_VAL::kLeft:
return "dpad_left";
case RE::DIRECTION_VAL::kRight:
return "dpad_right";
default:
return nullptr;
}
}
class GamepadInputHandler :
public RE::BSInputEventUser
const char* StickAxesToButton(std::int16_t a_x, std::int16_t a_y)
{
public:
static GamepadInputHandler* GetSingleton()
{
static GamepadInputHandler instance;
return std::addressof(instance);
constexpr auto deadzone = REX::W32::XINPUT_GAMEPAD_LEFT_THUMB_DEADZONE;
const auto up = a_y > deadzone;
const auto down = a_y < -static_cast<std::int16_t>(deadzone);
const auto left = a_x < -static_cast<std::int16_t>(deadzone);
const auto right = a_x > deadzone;
if (up && !left && !right) {
return "dpad_up";
}
if (down && !left && !right) {
return "dpad_down";
}
if (left && !up && !down) {
return "dpad_left";
}
if (right && !up && !down) {
return "dpad_right";
}
bool ShouldHandleEvent(const RE::InputEvent* a_event) override
{
if (!g_browserActive.load() || !a_event) {
return false;
}
return nullptr;
}
return a_event->eventType == RE::INPUT_EVENT_TYPE::kButton &&
a_event->device == RE::INPUT_DEVICE::kGamepad;
void ForwardButtonPhase(const char* a_button, const char* a_phase)
{
if (a_button && a_phase) {
F4T::ServerBrowser::ServerBrowserBridge::ForwardControllerInput(a_button, a_phase);
}
}
void SyncPolledGamepadBaseline()
{
for (auto& held : g_buttonHeld) {
held = false;
}
g_activeStickButton = nullptr;
g_stickRepeatTime = {};
const auto* xinputGetState = GetXInputGetState();
if (!xinputGetState) {
return;
}
void OnButtonEvent(const RE::ButtonEvent* a_event) override
{
if (!a_event || !g_browserActive.load()) {
return;
}
REX::W32::XINPUT_STATE state{};
if (xinputGetState(0, std::addressof(state)) != ERROR_SUCCESS) {
return;
}
const auto keyCode = static_cast<std::uint32_t>(a_event->GetBSButtonCode());
const auto* buttonName = GamepadButtonName(keyCode);
if (!buttonName) {
return;
}
const auto buttons = state.gamepad.buttons;
for (std::size_t i = 0; i < std::size(kGamepadButtons); ++i) {
g_buttonHeld[i] = (buttons & kGamepadButtons[i].mask) != 0;
}
const char* phase = nullptr;
if (a_event->QJustPressed()) {
phase = "down";
} else if (a_event->QReleased()) {
phase = "up";
}
g_activeStickButton = StickAxesToButton(state.gamepad.thumbLX, state.gamepad.thumbLY);
g_stickRepeatTime = std::chrono::steady_clock::now();
}
if (phase) {
F4T::ServerBrowser::ServerBrowserBridge::ForwardControllerInput(buttonName, phase);
void AcquireMenuInputBlock()
{
if (g_menuInputBlockLayer) {
return;
}
auto* inputEnableManager = RE::BSInputEnableManager::GetSingleton();
if (!inputEnableManager) {
REX::WARN("[MenuInput] BSInputEnableManager unavailable; main menu may keep gamepad focus.");
return;
}
RE::BSTSmartPointer<RE::BSInputEnableLayer> layer;
if (!inputEnableManager->AllocateNewLayer(layer, "CommonwealthOnline_ServerBrowser")) {
REX::WARN("[MenuInput] Failed to allocate input layer for server browser.");
return;
}
inputEnableManager->EnableUserEvent(
layer->layerID,
RE::UserEvents::USER_EVENT_FLAG::kMenu,
false,
RE::UserEvents::SENDER_ID::kScript);
g_menuInputBlockLayer = std::move(layer);
}
void ReleaseMenuInputBlock()
{
g_menuInputBlockLayer.reset();
}
void PollGamepad()
{
if (!g_browserActive.load()) {
return;
}
const auto* xinputGetState = GetXInputGetState();
if (!xinputGetState) {
return;
}
REX::W32::XINPUT_STATE state{};
if (xinputGetState(0, std::addressof(state)) != ERROR_SUCCESS) {
return;
}
const auto buttons = state.gamepad.buttons;
for (std::size_t i = 0; i < std::size(kGamepadButtons); ++i) {
const auto down = (buttons & kGamepadButtons[i].mask) != 0;
if (down != g_buttonHeld[i]) {
ForwardButtonPhase(kGamepadButtons[i].name, down ? "down" : "up");
g_buttonHeld[i] = down;
}
}
};
const auto* stickButton = StickAxesToButton(state.gamepad.thumbLX, state.gamepad.thumbLY);
if (stickButton != g_activeStickButton) {
if (g_activeStickButton) {
ForwardButtonPhase(g_activeStickButton, "up");
}
g_activeStickButton = stickButton;
if (stickButton) {
ForwardButtonPhase(stickButton, "down");
}
g_stickRepeatTime = std::chrono::steady_clock::now();
return;
}
if (!stickButton) {
return;
}
const auto now = std::chrono::steady_clock::now();
if (now - g_stickRepeatTime < 400ms) {
return;
}
ForwardButtonPhase(stickButton, "down");
g_stickRepeatTime = now - 320ms;
}
}
bool ForwardButtonEvent(const RE::ButtonEvent* a_event)
{
if (!a_event || !g_browserActive.load()) {
return false;
}
if (a_event->device == RE::INPUT_DEVICE::kGamepad) {
return false;
}
const auto* buttonName = UserEventToButton(a_event->QUserEvent());
if (!buttonName) {
return false;
}
const char* phase = nullptr;
if (a_event->QJustPressed()) {
phase = "down";
} else if (a_event->QReleased()) {
phase = "up";
}
if (phase) {
ForwardButtonPhase(buttonName, phase);
return true;
}
return false;
}
bool ForwardThumbstickEvent(const RE::ThumbstickEvent* a_event)
{
if (!a_event || !g_browserActive.load()) {
return false;
}
if (a_event->idCode != static_cast<std::int32_t>(RE::ThumbstickEvent::THUMBSTICK_ID::kLeft)) {
return false;
}
if (a_event->currDir == a_event->prevDir) {
return false;
}
bool forwarded = false;
if (a_event->prevDir != RE::DIRECTION_VAL::kNone) {
if (const auto* buttonName = ThumbstickDirectionToButton(a_event->prevDir)) {
ForwardButtonPhase(buttonName, "up");
forwarded = true;
}
}
if (a_event->currDir != RE::DIRECTION_VAL::kNone) {
if (const auto* buttonName = ThumbstickDirectionToButton(a_event->currDir)) {
ForwardButtonPhase(buttonName, "down");
forwarded = true;
}
}
return forwarded;
}
void Register()
{
if (g_sinkRegistered.exchange(true)) {
if (g_registered.exchange(true)) {
return;
}
auto* menuControls = RE::MenuControls::GetSingleton();
if (menuControls) {
menuControls->handlers.push_back(GamepadInputHandler::GetSingleton());
REX::INFO("[MenuInput] Registered gamepad handler for server browser.");
const auto* taskInterface = F4SE::GetTaskInterface();
if (taskInterface) {
taskInterface->AddTaskPermanent([]() {
PollGamepad();
});
REX::INFO("[MenuInput] Registered XInput polling for server browser.");
} else {
REX::WARN("[MenuInput] MenuControls unavailable; gamepad navigation disabled.");
g_sinkRegistered.store(false);
REX::WARN("[MenuInput] F4SE task interface unavailable; gamepad polling disabled.");
}
}
void SetBrowserActive(bool a_active)
{
g_browserActive.store(a_active);
if (a_active) {
SyncPolledGamepadBaseline();
AcquireMenuInputBlock();
return;
}
for (auto& held : g_buttonHeld) {
held = false;
}
g_activeStickButton = nullptr;
g_stickRepeatTime = {};
ReleaseMenuInputBlock();
}
}
+2
View File
@@ -106,6 +106,7 @@ namespace F4T::PrismaUI
const bool wasVisible = g_visible;
g_visible = false;
F4T::MenuInput::SetBrowserActive(false);
F4T::MainMenuInject::SetMainMenuInputSuppressed(false);
if (!g_api || !g_api->IsValid(g_serverBrowserView)) {
return;
@@ -154,6 +155,7 @@ namespace F4T::PrismaUI
g_pendingShow = false;
g_visible = true;
F4T::MenuInput::SetBrowserActive(true);
F4T::MainMenuInject::SetMainMenuInputSuppressed(true);
g_api->Show(g_serverBrowserView);
g_api->Focus(g_serverBrowserView, false, false);
F4T::ServerBrowser::ServerBrowserBridge::OnBrowserShown();