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` ## 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 ```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()) { // MainMenu::VTABLE[1] is the BSInputEventUser secondary vtable.
if (a_event->QUserEvent() == "Accept"sv) { REL::Relocation<std::uintptr_t> inputVtable{ RE::MainMenu::VTABLE[1] };
auto* ui = RE::UI::GetSingleton(); g_originalOnThumbstick = inputVtable.write_vfunc(
auto mainMenu = ui ? ui->GetMenu<RE::MainMenu>() : nullptr; kBSInputOnThumbstickVtableIndex, &MainMenuOnThumbstickEventHook);
if (mainMenu && mainMenu.get() == a_this && g_originalOnButton = inputVtable.write_vfunc(
TryHandleCustomSelection(mainMenu.get())) { kBSInputOnButtonVtableIndex, &MainMenuOnButtonEventHook);
return; // consumed — do not call vanilla }
} ```
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) { CallOriginalOnButtonEvent(a_this, a_event);
g_originalOnButtonEvent(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 ## 2026-06-17 - Fallout 4/Data Build Layout + Creation Kit Assets
### Summary ### Summary
+1
View File
@@ -8,5 +8,6 @@ namespace RE
namespace F4T::MainMenuInject namespace F4T::MainMenuInject
{ {
bool IsMainMenuOnMainPanel(const RE::UI* a_ui); bool IsMainMenuOnMainPanel(const RE::UI* a_ui);
void SetMainMenuInputSuppressed(bool a_suppressed);
void Install(); void Install();
} }
+8
View File
@@ -1,7 +1,15 @@
#pragma once #pragma once
namespace RE
{
class ButtonEvent;
class ThumbstickEvent;
}
namespace F4T::MenuInput namespace F4T::MenuInput
{ {
void Register(); void Register();
void SetBrowserActive(bool a_active); 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 "pch.h"
#include "F4TMainMenuInject.h" #include "F4TMainMenuInject.h"
#include "F4TMenuInput.h"
#include "F4TNetworking.h" #include "F4TNetworking.h"
#include "F4TPrismaUI.h" #include "F4TPrismaUI.h"
@@ -24,7 +25,9 @@ namespace F4T::MainMenuInject
constexpr auto kLoadIndex = 4; constexpr auto kLoadIndex = 4;
constexpr auto kResyncThrottleFrames = 30; constexpr auto kResyncThrottleFrames = 30;
constexpr auto kItemPressListenerPriority = 100; 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 kEntryListPath = "MainPanel_mc.List_mc.entryList";
constexpr auto kListWidgetPath = "MainPanel_mc.List_mc"; constexpr auto kListWidgetPath = "MainPanel_mc.List_mc";
constexpr std::array<std::string_view, 3> kItemPressEventCandidates{ constexpr std::array<std::string_view, 3> kItemPressEventCandidates{
@@ -36,7 +39,7 @@ namespace F4T::MainMenuInject
bool g_hooksInstalled = false; bool g_hooksInstalled = false;
bool g_resyncTaskStarted = false; bool g_resyncTaskStarted = false;
bool g_itemPressListenerRegistered = false; bool g_itemPressListenerRegistered = false;
bool g_buttonEventHookInstalled = false; bool g_mainMenuInputHooksInstalled = false;
int g_resyncFrameCounter = 0; int g_resyncFrameCounter = 0;
int g_injectFailLogCooldown = 0; int g_injectFailLogCooldown = 0;
@@ -618,29 +621,87 @@ namespace F4T::MainMenuInject
eventNames.size())); eventNames.size()));
} }
using OnButtonEventFn = void (*)(RE::IMenu*, const RE::ButtonEvent*); std::uintptr_t g_originalMainMenuOnButtonEvent = 0;
OnButtonEventFn g_originalOnButtonEvent = nullptr; 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()) { if (a_this && a_event && a_event->QJustPressed() && !a_event->QDisabled()) {
const auto& userEvent = a_event->QUserEvent(); const auto& userEvent = a_event->QUserEvent();
if (userEvent == "Accept"sv) { if (userEvent == "Accept"sv) {
const auto* ui = RE::UI::GetSingleton(); const auto* ui = RE::UI::GetSingleton();
const auto mainMenu = ui ? ui->GetMenu<RE::MainMenu>() : nullptr; 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; return;
} }
} }
} }
if (g_originalOnButtonEvent) { if (g_originalMainMenuOnButtonEvent != 0) {
g_originalOnButtonEvent(a_this, a_event); 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() void PollMultiplayerActivation()
{ {
if (F4T::PrismaUI::IsBrowserVisible()) {
return;
}
const auto* ui = RE::UI::GetSingleton(); const auto* ui = RE::UI::GetSingleton();
if (!ui || !ui->GetMenuOpen<RE::MainMenu>() || !IsMainMenuOnMainPanel(ui)) { if (!ui || !ui->GetMenuOpen<RE::MainMenu>() || !IsMainMenuOnMainPanel(ui)) {
return; return;
@@ -768,6 +829,17 @@ namespace F4T::MainMenuInject
return IsMainStateForMenu(mainMenu.get()); 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() void Install()
{ {
if (g_hooksInstalled) { if (g_hooksInstalled) {
@@ -783,12 +855,7 @@ namespace F4T::MainMenuInject
return; return;
} }
g_originalOnButtonEvent = reinterpret_cast<OnButtonEventFn>( InstallMainMenuInputHooks();
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.");
}
auto* ui = RE::UI::GetSingleton(); auto* ui = RE::UI::GetSingleton();
if (ui) { if (ui) {
+311 -58
View File
@@ -3,107 +3,360 @@
#include "F4TMenuInput.h" #include "F4TMenuInput.h"
#include "F4TServerBrowserBridge.h" #include "F4TServerBrowserBridge.h"
#include <F4SE/InputMap.h> #include <REX/W32/XINPUT.h>
#include <Windows.h>
namespace F4T::MenuInput namespace F4T::MenuInput
{ {
namespace namespace
{ {
std::atomic_bool g_browserActive{ false }; 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) { std::uint16_t mask;
case F4SE::InputMap::kGamepadButtonOffset_DPAD_UP: const char* name;
return "dpad_up"; };
case F4SE::InputMap::kGamepadButtonOffset_DPAD_DOWN:
return "dpad_down"; constexpr GamepadButtonDef kGamepadButtons[] = {
case F4SE::InputMap::kGamepadButtonOffset_DPAD_LEFT: { REX::W32::XINPUT_GAMEPAD_DPAD_UP, "dpad_up" },
return "dpad_left"; { REX::W32::XINPUT_GAMEPAD_DPAD_DOWN, "dpad_down" },
case F4SE::InputMap::kGamepadButtonOffset_DPAD_RIGHT: { REX::W32::XINPUT_GAMEPAD_DPAD_LEFT, "dpad_left" },
return "dpad_right"; { REX::W32::XINPUT_GAMEPAD_DPAD_RIGHT, "dpad_right" },
case F4SE::InputMap::kGamepadButtonOffset_A: { 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"; return "a";
case F4SE::InputMap::kGamepadButtonOffset_B: }
if (a_userEvent == "Cancel"sv || a_userEvent == "MenuCancel"sv) {
return "b"; return "b";
case F4SE::InputMap::kGamepadButtonOffset_X: }
if (a_userEvent == "XButton"sv) {
return "x"; return "x";
case F4SE::InputMap::kGamepadButtonOffset_Y: }
if (a_userEvent == "YButton"sv) {
return "y"; return "y";
case F4SE::InputMap::kGamepadButtonOffset_LEFT_SHOULDER: }
if (a_userEvent == "LeftShoulder"sv || a_userEvent == "LBumper"sv) {
return "lb"; return "lb";
case F4SE::InputMap::kGamepadButtonOffset_RIGHT_SHOULDER: }
if (a_userEvent == "RightShoulder"sv || a_userEvent == "RBumper"sv) {
return "rb"; 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: default:
return nullptr; return nullptr;
} }
} }
class GamepadInputHandler : const char* StickAxesToButton(std::int16_t a_x, std::int16_t a_y)
public RE::BSInputEventUser
{ {
public: constexpr auto deadzone = REX::W32::XINPUT_GAMEPAD_LEFT_THUMB_DEADZONE;
static GamepadInputHandler* GetSingleton() const auto up = a_y > deadzone;
{ const auto down = a_y < -static_cast<std::int16_t>(deadzone);
static GamepadInputHandler instance; const auto left = a_x < -static_cast<std::int16_t>(deadzone);
return std::addressof(instance); 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 return nullptr;
{ }
if (!g_browserActive.load() || !a_event) {
return false;
}
return a_event->eventType == RE::INPUT_EVENT_TYPE::kButton && void ForwardButtonPhase(const char* a_button, const char* a_phase)
a_event->device == RE::INPUT_DEVICE::kGamepad; {
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 REX::W32::XINPUT_STATE state{};
{ if (xinputGetState(0, std::addressof(state)) != ERROR_SUCCESS) {
if (!a_event || !g_browserActive.load()) { return;
return; }
}
const auto keyCode = static_cast<std::uint32_t>(a_event->GetBSButtonCode()); const auto buttons = state.gamepad.buttons;
const auto* buttonName = GamepadButtonName(keyCode); for (std::size_t i = 0; i < std::size(kGamepadButtons); ++i) {
if (!buttonName) { g_buttonHeld[i] = (buttons & kGamepadButtons[i].mask) != 0;
return; }
}
const char* phase = nullptr; g_activeStickButton = StickAxesToButton(state.gamepad.thumbLX, state.gamepad.thumbLY);
if (a_event->QJustPressed()) { g_stickRepeatTime = std::chrono::steady_clock::now();
phase = "down"; }
} else if (a_event->QReleased()) {
phase = "up";
}
if (phase) { void AcquireMenuInputBlock()
F4T::ServerBrowser::ServerBrowserBridge::ForwardControllerInput(buttonName, phase); {
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() void Register()
{ {
if (g_sinkRegistered.exchange(true)) { if (g_registered.exchange(true)) {
return; return;
} }
auto* menuControls = RE::MenuControls::GetSingleton(); const auto* taskInterface = F4SE::GetTaskInterface();
if (menuControls) { if (taskInterface) {
menuControls->handlers.push_back(GamepadInputHandler::GetSingleton()); taskInterface->AddTaskPermanent([]() {
REX::INFO("[MenuInput] Registered gamepad handler for server browser."); PollGamepad();
});
REX::INFO("[MenuInput] Registered XInput polling for server browser.");
} else { } else {
REX::WARN("[MenuInput] MenuControls unavailable; gamepad navigation disabled."); REX::WARN("[MenuInput] F4SE task interface unavailable; gamepad polling disabled.");
g_sinkRegistered.store(false);
} }
} }
void SetBrowserActive(bool a_active) void SetBrowserActive(bool a_active)
{ {
g_browserActive.store(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; const bool wasVisible = g_visible;
g_visible = false; g_visible = false;
F4T::MenuInput::SetBrowserActive(false); F4T::MenuInput::SetBrowserActive(false);
F4T::MainMenuInject::SetMainMenuInputSuppressed(false);
if (!g_api || !g_api->IsValid(g_serverBrowserView)) { if (!g_api || !g_api->IsValid(g_serverBrowserView)) {
return; return;
@@ -154,6 +155,7 @@ namespace F4T::PrismaUI
g_pendingShow = false; g_pendingShow = false;
g_visible = true; g_visible = true;
F4T::MenuInput::SetBrowserActive(true); F4T::MenuInput::SetBrowserActive(true);
F4T::MainMenuInject::SetMainMenuInputSuppressed(true);
g_api->Show(g_serverBrowserView); g_api->Show(g_serverBrowserView);
g_api->Focus(g_serverBrowserView, false, false); g_api->Focus(g_serverBrowserView, false, false);
F4T::ServerBrowser::ServerBrowserBridge::OnBrowserShown(); F4T::ServerBrowser::ServerBrowserBridge::OnBrowserShown();
+58 -7
View File
@@ -36,6 +36,7 @@
connectionStatus: "Online", connectionStatus: "Online",
isRefreshing: false, isRefreshing: false,
directConnect: { address: "127.0.0.1", port: "7777" }, directConnect: { address: "127.0.0.1", port: "7777" },
directConnectFieldIndex: 0,
joinOverlay: null joinOverlay: null
}; };
@@ -236,7 +237,10 @@
state.selectedTabIndex = CO_Tabs.TAB_ORDER.indexOf(tabName); state.selectedTabIndex = CO_Tabs.TAB_ORDER.indexOf(tabName);
if (state.selectedTabIndex < 0) state.selectedTabIndex = 0; if (state.selectedTabIndex < 0) state.selectedTabIndex = 0;
if (tabName === "server-browser") state.focusZone = "server-list"; 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 }); sendAction("switchTab", { tabName: tabName });
render(); render();
} }
@@ -334,11 +338,13 @@
} }
function onKeyDown(e) { function onKeyDown(e) {
if (state.joinOverlay && state.joinOverlay.error && e.key === "Escape") { if (state.joinOverlay && state.joinOverlay.error) {
hideJoinOverlay(); if (e.key === "Escape" || e.key === "Enter" || e.key === "b" || e.key === "B") {
return; hideJoinOverlay();
return;
}
} }
if (state.activeTab === "direct-connect") return; if (e.repeat) return;
handleControllerButton(mapKeyToButton(e.key), "down"); handleControllerButton(mapKeyToButton(e.key), "down");
} }
@@ -353,7 +359,9 @@
function handleControllerButton(button, phase) { function handleControllerButton(button, phase) {
if (!button || phase !== "down") return; if (!button || phase !== "down") return;
if (state.joinOverlay) { if (state.joinOverlay) {
if (button === "b") hideJoinOverlay(); if (button === "b" || (state.joinOverlay.error && button === "a")) {
hideJoinOverlay();
}
return; return;
} }
@@ -372,6 +380,20 @@
} }
if (state.activeTab === "direct-connect") { 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") { if (button === "a") {
var vals = CO_DirectConnect.getValues(root); var vals = CO_DirectConnect.getValues(root);
joinDirectLocal(vals.address, vals.port); joinDirectLocal(vals.address, vals.port);
@@ -396,6 +418,35 @@
navigateFocus(button); 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() { function joinSelected() {
var sel = getSelectedServer(); var sel = getSelectedServer();
if (!sel) return; if (!sel) return;
@@ -448,7 +499,7 @@
var maxFilter = CO_Filters.FILTER_ROWS.length; var maxFilter = CO_Filters.FILTER_ROWS.length;
if (button === "dpad_up") state.selectedFilterIndex = Math.max(0, state.selectedFilterIndex - 1); 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 === "dpad_down") state.selectedFilterIndex = Math.min(maxFilter, state.selectedFilterIndex + 1);
if (button === "a" && state.selectedFilterIndex === maxFilter) resetLocalFilters(); if (button === "a") activateFocusedFilter();
render(); render();
return; return;
} }
@@ -20,7 +20,10 @@
getPrompts: function (state) { getPrompts: function (state) {
if (state.joinOverlay) { if (state.joinOverlay) {
if (state.joinOverlay.error) { if (state.joinOverlay.error) {
return [{ key: "B", label: "Dismiss" }]; return [
{ key: "B", label: "Dismiss" },
{ key: "A", label: "Dismiss" }
];
} }
return []; return [];
} }
@@ -32,12 +35,13 @@
]; ];
} }
var base = [ if (state.focusZone === "filters") {
{ key: "A", label: "Join" }, var isReset = state.selectedFilterIndex === CO_Filters.FILTER_ROWS.length;
{ key: "X", label: "Refresh" }, return [
{ key: "Y", label: "Favorite" }, { key: "A", label: isReset ? "Reset" : "Change" },
{ key: "B", label: "Back" } { key: "B", label: "Back" }
]; ];
}
if (state.focusZone === "tabs") { if (state.focusZone === "tabs") {
return [ 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 address = state.directConnect.address || "127.0.0.1";
var port = state.directConnect.port || "7777"; var port = state.directConnect.port || "7777";
var addressFocused = state.directConnectFieldIndex === 0 ? " focused" : "";
var portFocused = state.directConnectFieldIndex === 1 ? " focused" : "";
container.innerHTML = container.innerHTML =
'<div class="co-direct-row">' + '<div class="co-direct-row' + addressFocused + '">' +
'<label for="co-dc-address">Server Address</label>' + '<label for="co-dc-address">Server Address</label>' +
'<input class="co-filter-input" id="co-dc-address" type="text" value="' + escapeAttr(address) + '">' + '<input class="co-filter-input" id="co-dc-address" type="text" value="' + escapeAttr(address) + '">' +
"</div>" + "</div>" +
'<div class="co-direct-row">' + '<div class="co-direct-row' + portFocused + '">' +
'<label for="co-dc-port">Port</label>' + '<label for="co-dc-port">Port</label>' +
'<input class="co-filter-input" id="co-dc-port" type="text" value="' + escapeAttr(port) + '">' + '<input class="co-filter-input" id="co-dc-port" type="text" value="' + escapeAttr(port) + '">' +
"</div>" + "</div>" +
@@ -630,6 +630,11 @@ body {
margin-bottom: 12px; 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 { .co-direct-row label {
display: block; display: block;
font-size: var(--co-font-xs); font-size: var(--co-font-xs);