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:
@@ -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())) {
|
||||
// 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
@@ -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
|
||||
|
||||
@@ -8,5 +8,6 @@ namespace RE
|
||||
namespace F4T::MainMenuInject
|
||||
{
|
||||
bool IsMainMenuOnMainPanel(const RE::UI* a_ui);
|
||||
void SetMainMenuInputSuppressed(bool a_suppressed);
|
||||
void Install();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+300
-47
@@ -3,73 +3,280 @@
|
||||
#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
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void ForwardButtonPhase(const char* a_button, const char* a_phase)
|
||||
{
|
||||
if (!g_browserActive.load() || !a_event) {
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
g_buttonHeld[i] = (buttons & kGamepadButtons[i].mask) != 0;
|
||||
}
|
||||
|
||||
g_activeStickButton = StickAxesToButton(state.gamepad.thumbLX, state.gamepad.thumbLY);
|
||||
g_stickRepeatTime = std::chrono::steady_clock::now();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return a_event->eventType == RE::INPUT_EVENT_TYPE::kButton &&
|
||||
a_event->device == RE::INPUT_DEVICE::kGamepad;
|
||||
if (a_event->device == RE::INPUT_DEVICE::kGamepad) {
|
||||
return false;
|
||||
}
|
||||
|
||||
void OnButtonEvent(const RE::ButtonEvent* a_event) override
|
||||
{
|
||||
if (!a_event || !g_browserActive.load()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto keyCode = static_cast<std::uint32_t>(a_event->GetBSButtonCode());
|
||||
const auto* buttonName = GamepadButtonName(keyCode);
|
||||
const auto* buttonName = UserEventToButton(a_event->QUserEvent());
|
||||
if (!buttonName) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const char* phase = nullptr;
|
||||
@@ -80,30 +287,76 @@ namespace F4T::MenuInput
|
||||
}
|
||||
|
||||
if (phase) {
|
||||
F4T::ServerBrowser::ServerBrowserBridge::ForwardControllerInput(buttonName, 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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") {
|
||||
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" },
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user