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
+34 -13
View File
@@ -397,25 +397,46 @@ listWidget.Invoke("addEventListener", nullptr, args.data(), args.size());
## Step 5 — Handle gamepad / Enter with `OnButtonEvent`
Mouse clicks go through `ITEM_PRESS`. Keyboard Enter and gamepad A go through `StartMenuBase::OnButtonEvent` (vtable index **`0x08`**).
Mouse clicks go through `ITEM_PRESS`. Keyboard Enter and gamepad A go through `IMenu::OnButtonEvent`, which lives on the **`BSInputEventUser` secondary vtable** (not the primary `StartMenuBase` vtable).
Do **not** hook `StartMenuBase::VTABLE[0x08]` for button input — on the primary vtable that index is `IMenu::SetIsTopButtonBar`, so the main menu will keep receiving navigation while your overlay is open.
Patch the MainMenu input vtable when the plugin loads (once), using `REL::Relocation` and `write_vfunc` so the write goes through `WriteSafeData` (the vtable lives in read-only memory — **do not** assign to vtable slots directly):
```cpp
void OnButtonEventHook(RE::IMenu* a_this, const RE::ButtonEvent* a_event)
constexpr auto kBSInputOnThumbstickVtableIndex = 0x04;
constexpr auto kBSInputOnButtonVtableIndex = 0x08;
void InstallMainMenuInputHooks()
{
if (a_this && a_event && a_event->QJustPressed() && !a_event->QDisabled()) {
if (a_event->QUserEvent() == "Accept"sv) {
auto* ui = RE::UI::GetSingleton();
auto mainMenu = ui ? ui->GetMenu<RE::MainMenu>() : nullptr;
if (mainMenu && mainMenu.get() == a_this &&
TryHandleCustomSelection(mainMenu.get())) {
return; // consumed — do not call vanilla
}
// MainMenu::VTABLE[1] is the BSInputEventUser secondary vtable.
REL::Relocation<std::uintptr_t> inputVtable{ RE::MainMenu::VTABLE[1] };
g_originalOnThumbstick = inputVtable.write_vfunc(
kBSInputOnThumbstickVtableIndex, &MainMenuOnThumbstickEventHook);
g_originalOnButton = inputVtable.write_vfunc(
kBSInputOnButtonVtableIndex, &MainMenuOnButtonEventHook);
}
```
While your overlay is open, also set `mainMenu->inputEventHandlingEnabled = false` so `ProcessUserEvent` does not keep driving the vanilla list.
```cpp
void MainMenuOnButtonEventHook(RE::BSInputEventUser* a_this, const RE::ButtonEvent* a_event)
{
if (BrowserIsVisible()) {
ForwardBrowserButton(a_event);
return;
}
if (a_event && a_event->QJustPressed() && a_event->QUserEvent() == "Accept"sv) {
auto* menu = reinterpret_cast<RE::IMenu*>(reinterpret_cast<char*>(a_this) - kBSInputEventUserOffset);
auto mainMenu = RE::UI::GetSingleton()->GetMenu<RE::MainMenu>();
if (mainMenu && mainMenu.get() == menu && TryHandleCustomSelection(mainMenu.get())) {
return; // consumed — do not call vanilla
}
}
if (g_originalOnButtonEvent) {
g_originalOnButtonEvent(a_this, a_event);
}
CallOriginalOnButtonEvent(a_this, a_event);
}
```
+105
View File
@@ -7,6 +7,111 @@ failed experiments, successful tests, and next steps.
---
## 2026-06-18 - XInput polling for server browser gamepad
### Summary
Gamepad input still did not reach the PrismaUI browser because Fallout routes controller through the main-menu input stack, while keyboard arrows worked via the web view `keydown` handler. Added direct XInput polling while the browser is open.
### Files Changed
- `plugin/src/F4TMenuInput.cpp`
### Details
- Poll `XInputGetState` each game-thread tick while the server browser is active and forward button/stick changes through the existing `controllerInput` JS channel.
- Allocate a `BSInputEnableLayer` that disables `kMenu` user events while the browser is open so the title menu stops consuming gamepad input.
- Menu vtable hooks now only block/supplement menu-translated keyboard events; gamepad is handled by polling.
### Testing
- Plugin rebuild succeeded; in-game controller retest required.
### Known Issues
- None currently known.
### Next Steps
- Verify D-pad, A/B/X/Y, shoulders, and left stick navigate/close the server browser.
---
## 2026-06-18 - Fix loading-screen crash from vtable patch
### Summary
Fixed a crash during the "Please stand by" / main-menu load caused by writing hook pointers directly into the read-only `BSInputEventUser` vtable.
### Files Changed
- `plugin/src/F4TMainMenuInject.cpp`
- `docs/MainMenu_Button_Injection_Guide.md`
### Details
- `CommonwealthOnline.log` stopped after `Running catch-up injection for already-open MainMenu` — crash was in the old `InstallMainMenuInputHooks` before the success log.
- Replaced raw `slots[i] = hook` with `REL::Relocation{ RE::MainMenu::VTABLE[1] }.write_vfunc(...)`.
- Hooks install once at plugin `Install()` instead of per menu open/close.
### Testing
- Plugin rebuild succeeded; in-game retest required.
### Known Issues
- None currently known.
### Next Steps
- Confirm title screen loads and controller drives the server browser.
---
## 2026-06-18 - Fix Multiplayer Menu Controller Input Capture
### Summary
Fixed controller input staying on the vanilla main menu while the server browser was open. The previous hooks targeted the wrong vtable slots on `StartMenuBase` (index `0x08` is `SetIsTopButtonBar`, not `OnButtonEvent`).
### Files Changed
- `plugin/src/F4TMainMenuInject.cpp`
- `plugin/include/F4TMainMenuInject.h`
- `plugin/src/F4TPrismaUI.cpp`
### Details
- Hook `BSInputEventUser::OnButtonEvent` / `OnThumbstickEvent` through the MainMenu secondary vtable at object offset `0x10`.
- While the browser is visible, forward gamepad/keyboard menu events to the PrismaUI browser and suppress vanilla `ProcessUserEvent` via `inputEventHandlingEnabled = false`.
- Restore hooks when MainMenu closes.
### Testing
- Plugin rebuild succeeded; in-game controller test still required.
### Known Issues
- None currently known beyond prior direct-connect text entry limitation.
### Next Steps
- Verify in-game with a controller on the title screen.
---
## 2026-06-18 - Multiplayer Menu Controller Support
### Summary
Wired full gamepad and keyboard-menu navigation for the PrismaUI server browser while it is open over the title-screen main menu. Main-menu input is now consumed so the vanilla list does not steal focus, and the browser UI handles tabs, filters, server list, and direct connect from a controller.
### Files Changed
- `plugin/src/F4TMenuInput.cpp`
- `plugin/include/F4TMenuInput.h`
- `plugin/src/F4TMainMenuInject.cpp`
- `ui/views/CommonwealthOnline/browser/app.js`
- `ui/views/CommonwealthOnline/browser/components/controller-prompts.js`
- `ui/views/CommonwealthOnline/browser/components/direct-connect.js`
- `ui/views/CommonwealthOnline/browser/styles.css`
### Details
- Added `ForwardButtonEvent` / `ForwardThumbstickEvent` helpers that map gamepad buttons, left-stick directions, and menu user events (`Accept`, `Cancel`, `Up`, etc.) into the existing `controllerInput` browser event channel.
- Hooked `StartMenuBase::OnThumbstickEvent` and extended `OnButtonEvent` to capture input while the server browser is visible, blocking vanilla main-menu navigation underneath.
- Browser UI: direct-connect field focus, filter change on A, updated controller prompts, and keyboard nav parity for direct connect.
### Testing
- Not run in-game locally (requires Fallout 4 + F4SE + rebuilt plugin + staged PrismaUI views).
### Known Issues
- Text entry in Direct Connect still requires keyboard/mouse; controller can select fields and press A to connect with existing values.
### Next Steps
- Add on-screen keyboard or character entry flow for direct-connect address editing with a gamepad.
---
## 2026-06-17 - Fallout 4/Data Build Layout + Creation Kit Assets
### Summary
+1
View File
@@ -8,5 +8,6 @@ namespace RE
namespace F4T::MainMenuInject
{
bool IsMainMenuOnMainPanel(const RE::UI* a_ui);
void SetMainMenuInputSuppressed(bool a_suppressed);
void Install();
}
+8
View File
@@ -1,7 +1,15 @@
#pragma once
namespace RE
{
class ButtonEvent;
class ThumbstickEvent;
}
namespace F4T::MenuInput
{
void Register();
void SetBrowserActive(bool a_active);
bool ForwardButtonEvent(const RE::ButtonEvent* a_event);
bool ForwardThumbstickEvent(const RE::ThumbstickEvent* a_event);
}
+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();
+58 -7
View File
@@ -36,6 +36,7 @@
connectionStatus: "Online",
isRefreshing: false,
directConnect: { address: "127.0.0.1", port: "7777" },
directConnectFieldIndex: 0,
joinOverlay: null
};
@@ -236,7 +237,10 @@
state.selectedTabIndex = CO_Tabs.TAB_ORDER.indexOf(tabName);
if (state.selectedTabIndex < 0) state.selectedTabIndex = 0;
if (tabName === "server-browser") state.focusZone = "server-list";
else if (tabName === "direct-connect") state.focusZone = "tabs";
else if (tabName === "direct-connect") {
state.focusZone = "tabs";
state.directConnectFieldIndex = 0;
}
sendAction("switchTab", { tabName: tabName });
render();
}
@@ -334,11 +338,13 @@
}
function onKeyDown(e) {
if (state.joinOverlay && state.joinOverlay.error && e.key === "Escape") {
hideJoinOverlay();
return;
if (state.joinOverlay && state.joinOverlay.error) {
if (e.key === "Escape" || e.key === "Enter" || e.key === "b" || e.key === "B") {
hideJoinOverlay();
return;
}
}
if (state.activeTab === "direct-connect") return;
if (e.repeat) return;
handleControllerButton(mapKeyToButton(e.key), "down");
}
@@ -353,7 +359,9 @@
function handleControllerButton(button, phase) {
if (!button || phase !== "down") return;
if (state.joinOverlay) {
if (button === "b") hideJoinOverlay();
if (button === "b" || (state.joinOverlay.error && button === "a")) {
hideJoinOverlay();
}
return;
}
@@ -372,6 +380,20 @@
}
if (state.activeTab === "direct-connect") {
if (button === "b") {
sendAction("closeBrowser", {});
return;
}
if (button === "dpad_up") {
state.directConnectFieldIndex = Math.max(0, state.directConnectFieldIndex - 1);
render();
return;
}
if (button === "dpad_down") {
state.directConnectFieldIndex = Math.min(1, state.directConnectFieldIndex + 1);
render();
return;
}
if (button === "a") {
var vals = CO_DirectConnect.getValues(root);
joinDirectLocal(vals.address, vals.port);
@@ -396,6 +418,35 @@
navigateFocus(button);
}
function cycleFilterValue(filterKey, options) {
var current = state.filters[filterKey];
var index = options.indexOf(current);
if (index < 0) index = 0;
var next = options[(index + 1) % options.length];
state.filters[filterKey] = next;
sendAction("setFilter", { filterName: filterKey, value: next });
applyLocalFilters();
}
function activateFocusedFilter() {
var maxFilter = CO_Filters.FILTER_ROWS.length;
if (state.selectedFilterIndex === maxFilter) {
resetLocalFilters();
return;
}
var row = CO_Filters.FILTER_ROWS[state.selectedFilterIndex];
if (!row) return;
if (row.type === "select") {
cycleFilterValue(row.key, row.options);
} else if (row.type === "checkbox") {
state.filters[row.key] = !state.filters[row.key];
sendAction("setFilter", { filterName: row.key, value: state.filters[row.key] });
applyLocalFilters();
}
}
function joinSelected() {
var sel = getSelectedServer();
if (!sel) return;
@@ -448,7 +499,7 @@
var maxFilter = CO_Filters.FILTER_ROWS.length;
if (button === "dpad_up") state.selectedFilterIndex = Math.max(0, state.selectedFilterIndex - 1);
if (button === "dpad_down") state.selectedFilterIndex = Math.min(maxFilter, state.selectedFilterIndex + 1);
if (button === "a" && state.selectedFilterIndex === maxFilter) resetLocalFilters();
if (button === "a") activateFocusedFilter();
render();
return;
}
@@ -20,7 +20,10 @@
getPrompts: function (state) {
if (state.joinOverlay) {
if (state.joinOverlay.error) {
return [{ key: "B", label: "Dismiss" }];
return [
{ key: "B", label: "Dismiss" },
{ key: "A", label: "Dismiss" }
];
}
return [];
}
@@ -32,12 +35,13 @@
];
}
var base = [
{ key: "A", label: "Join" },
{ key: "X", label: "Refresh" },
{ key: "Y", label: "Favorite" },
{ key: "B", label: "Back" }
];
if (state.focusZone === "filters") {
var isReset = state.selectedFilterIndex === CO_Filters.FILTER_ROWS.length;
return [
{ key: "A", label: isReset ? "Reset" : "Change" },
{ key: "B", label: "Back" }
];
}
if (state.focusZone === "tabs") {
return [
@@ -47,7 +51,12 @@
];
}
return base;
return [
{ key: "A", label: "Join" },
{ key: "X", label: "Refresh" },
{ key: "Y", label: "Favorite" },
{ key: "B", label: "Back" }
];
}
};
})();
@@ -11,13 +11,15 @@
var address = state.directConnect.address || "127.0.0.1";
var port = state.directConnect.port || "7777";
var addressFocused = state.directConnectFieldIndex === 0 ? " focused" : "";
var portFocused = state.directConnectFieldIndex === 1 ? " focused" : "";
container.innerHTML =
'<div class="co-direct-row">' +
'<div class="co-direct-row' + addressFocused + '">' +
'<label for="co-dc-address">Server Address</label>' +
'<input class="co-filter-input" id="co-dc-address" type="text" value="' + escapeAttr(address) + '">' +
"</div>" +
'<div class="co-direct-row">' +
'<div class="co-direct-row' + portFocused + '">' +
'<label for="co-dc-port">Port</label>' +
'<input class="co-filter-input" id="co-dc-port" type="text" value="' + escapeAttr(port) + '">' +
"</div>" +
@@ -630,6 +630,11 @@ body {
margin-bottom: 12px;
}
.co-direct-row.focused .co-filter-input {
outline: 1px solid var(--co-green);
background: rgba(var(--co-r), var(--co-g), var(--co-b), 0.08);
}
.co-direct-row label {
display: block;
font-size: var(--co-font-xs);