Prevent title-screen OS cursor trampoline
Stop the title-screen from forcing the Windows cursor back into a small centered box while the Commonwealth Online overlay owns input. Changes: - Intercept SetCursorPos and ClipCursor in the Fallout4.exe import table (IAT) and make them no-ops while the overlay owns the pointer. - Hook MainMenu BSInputEventUser vtable cursor/mouse-move slots to consume events when custom menu input is captured. - ReleaseMenuCursorConstraints(): clear constraints, unset forceOSCursorPos, expand cursor bounds to the full window, and clear OS ClipCursor. Re-assert each frame during AdvanceMovie. - Add InstallCursorApiHooks() and wire it into PrismaUI init; add logging. - Update changelog.md to document the fix. Binary compiled script and minor source formatting updated.
This commit is contained in:
@@ -27,6 +27,8 @@ namespace F4T::MainMenuInject
|
||||
constexpr auto kItemPressListenerPriority = 100;
|
||||
constexpr auto kBSInputEventUserOffset = 0x10;
|
||||
constexpr auto kBSInputOnThumbstickVtableIndex = 0x04;
|
||||
constexpr auto kBSInputOnCursorMoveVtableIndex = 0x05;
|
||||
constexpr auto kBSInputOnMouseMoveVtableIndex = 0x06;
|
||||
constexpr auto kBSInputOnButtonVtableIndex = 0x08;
|
||||
constexpr auto kEntryListPath = "MainPanel_mc.List_mc.entryList";
|
||||
constexpr auto kListWidgetPath = "MainPanel_mc.List_mc";
|
||||
@@ -754,6 +756,8 @@ namespace F4T::MainMenuInject
|
||||
|
||||
std::uintptr_t g_originalMainMenuOnButtonEvent = 0;
|
||||
std::uintptr_t g_originalMainMenuOnThumbstickEvent = 0;
|
||||
std::uintptr_t g_originalMainMenuOnCursorMoveEvent = 0;
|
||||
std::uintptr_t g_originalMainMenuOnMouseMoveEvent = 0;
|
||||
|
||||
RE::IMenu* MainMenuFromInputUser(RE::BSInputEventUser* a_inputUser)
|
||||
{
|
||||
@@ -766,6 +770,8 @@ namespace F4T::MainMenuInject
|
||||
|
||||
using MainMenuOnButtonEventFn = void (*)(RE::BSInputEventUser*, const RE::ButtonEvent*);
|
||||
using MainMenuOnThumbstickEventFn = void (*)(RE::BSInputEventUser*, const RE::ThumbstickEvent*);
|
||||
using MainMenuOnCursorMoveEventFn = void (*)(RE::BSInputEventUser*, const RE::CursorMoveEvent*);
|
||||
using MainMenuOnMouseMoveEventFn = void (*)(RE::BSInputEventUser*, const RE::MouseMoveEvent*);
|
||||
|
||||
void MainMenuOnButtonEventHook(RE::BSInputEventUser* a_this, const RE::ButtonEvent* a_event)
|
||||
{
|
||||
@@ -803,6 +809,34 @@ namespace F4T::MainMenuInject
|
||||
}
|
||||
}
|
||||
|
||||
// In mouse mode (no gamepad) the title-screen MainMenu processes cursor/mouse-move events to
|
||||
// drive its parallax and — crucially — repositions the OS cursor back into a small centered
|
||||
// box every time the mouse moves. That is the "trampoline". When a gamepad is active these
|
||||
// mouse handlers never fire, which is why the pointer moves freely with a controller plugged
|
||||
// in. While our overlay owns input we consume these events so the vanilla parallax/cursor
|
||||
// forcing never runs, letting the OS pointer reach the whole screen.
|
||||
void MainMenuOnCursorMoveEventHook(RE::BSInputEventUser* a_this, const RE::CursorMoveEvent* a_event)
|
||||
{
|
||||
if (F4T::PrismaUI::IsCustomMenuInputCaptured()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (g_originalMainMenuOnCursorMoveEvent != 0) {
|
||||
reinterpret_cast<MainMenuOnCursorMoveEventFn>(g_originalMainMenuOnCursorMoveEvent)(a_this, a_event);
|
||||
}
|
||||
}
|
||||
|
||||
void MainMenuOnMouseMoveEventHook(RE::BSInputEventUser* a_this, const RE::MouseMoveEvent* a_event)
|
||||
{
|
||||
if (F4T::PrismaUI::IsCustomMenuInputCaptured()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (g_originalMainMenuOnMouseMoveEvent != 0) {
|
||||
reinterpret_cast<MainMenuOnMouseMoveEventFn>(g_originalMainMenuOnMouseMoveEvent)(a_this, a_event);
|
||||
}
|
||||
}
|
||||
|
||||
void InstallMainMenuInputHooks()
|
||||
{
|
||||
if (g_mainMenuInputHooksInstalled) {
|
||||
@@ -814,6 +848,12 @@ namespace F4T::MainMenuInject
|
||||
g_originalMainMenuOnThumbstickEvent = inputVtable.write_vfunc(
|
||||
kBSInputOnThumbstickVtableIndex,
|
||||
&MainMenuOnThumbstickEventHook);
|
||||
g_originalMainMenuOnCursorMoveEvent = inputVtable.write_vfunc(
|
||||
kBSInputOnCursorMoveVtableIndex,
|
||||
&MainMenuOnCursorMoveEventHook);
|
||||
g_originalMainMenuOnMouseMoveEvent = inputVtable.write_vfunc(
|
||||
kBSInputOnMouseMoveVtableIndex,
|
||||
&MainMenuOnMouseMoveEventHook);
|
||||
g_originalMainMenuOnButtonEvent = inputVtable.write_vfunc(
|
||||
kBSInputOnButtonVtableIndex,
|
||||
&MainMenuOnButtonEventHook);
|
||||
@@ -824,7 +864,7 @@ namespace F4T::MainMenuInject
|
||||
}
|
||||
|
||||
g_mainMenuInputHooksInstalled = true;
|
||||
LogInfo("Installed MainMenu input hooks on BSInputEventUser vtable.");
|
||||
LogInfo("Installed MainMenu input hooks on BSInputEventUser vtable (thumbstick, cursor-move, mouse-move, button).");
|
||||
}
|
||||
|
||||
void PollMultiplayerActivation()
|
||||
|
||||
+182
-39
@@ -50,11 +50,11 @@ namespace F4T::PrismaUI
|
||||
bool g_hasLastCursorPos = false;
|
||||
POINT g_lastCursorPos{};
|
||||
|
||||
using MainMenuAdvanceMovieFn = void(RE::IMenu*, float, std::uint64_t);
|
||||
using MainMenuAdvanceMovieFn = void(RE::IMenu*, float, std::uint64_t);
|
||||
|
||||
std::uintptr_t g_originalMainMenuAdvanceMovie = 0;
|
||||
bool g_mainMenuAdvanceHookInstalled = false;
|
||||
bool g_menuAdvanceSinkRegistered = false;
|
||||
std::uintptr_t g_originalMainMenuAdvanceMovie = 0;
|
||||
bool g_mainMenuAdvanceHookInstalled = false;
|
||||
bool g_menuAdvanceSinkRegistered = false;
|
||||
|
||||
// Forward declarations (definitions appear later in this anonymous namespace;
|
||||
// several functions call each other out of definition order).
|
||||
@@ -69,6 +69,7 @@ namespace F4T::PrismaUI
|
||||
void InstallMainMenuCursorReleaseHook();
|
||||
void RegisterMenuAdvanceSink();
|
||||
void ReleaseMenuCursorConstraints();
|
||||
void InstallCursorApiHooks();
|
||||
|
||||
[[nodiscard]] bool ShouldOverrideMenuCursorConstraints()
|
||||
{
|
||||
@@ -171,37 +172,177 @@ namespace F4T::PrismaUI
|
||||
}
|
||||
}
|
||||
|
||||
void ReleaseMenuCursorConstraints()
|
||||
{
|
||||
auto* menuCursor = RE::MenuCursor::GetSingleton();
|
||||
if (!menuCursor) {
|
||||
return;
|
||||
}
|
||||
|
||||
menuCursor->ClearConstraints();
|
||||
|
||||
const auto* renderWindow = RE::BSGraphics::GetCurrentRendererWindow();
|
||||
if (!renderWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto width = static_cast<std::int32_t>(renderWindow->windowWidth);
|
||||
const auto height = static_cast<std::int32_t>(renderWindow->windowHeight);
|
||||
if (width <= 0 || height <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// MainMenu keeps a tight MenuCursor box for title-screen parallax; expand to the full
|
||||
// window while our overlay owns input so the OS pointer can reach every edge.
|
||||
menuCursor->SetCursorConstraintsRaw(0, 0, static_cast<std::uint32_t>(width), static_cast<std::uint32_t>(height));
|
||||
menuCursor->minCursorX = 0;
|
||||
menuCursor->minCursorY = 0;
|
||||
menuCursor->maxCursorX = width;
|
||||
menuCursor->maxCursorY = height;
|
||||
|
||||
::ClipCursor(nullptr);
|
||||
void ReleaseMenuCursorConstraints()
|
||||
{
|
||||
auto* menuCursor = RE::MenuCursor::GetSingleton();
|
||||
if (!menuCursor) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The title-screen MainMenu constrains its MenuCursor to a small centered box for
|
||||
// parallax, and — critically — it forces the OS cursor position to match that clamped
|
||||
// internal cursor every frame (forceOSCursorPos). That OS-cursor forcing is what makes
|
||||
// the Windows pointer "trampoline" back into the box even while our PrismaUI overlay owns
|
||||
// input. We don't use the game's internal cursor for hit-testing (PrismaUI reads the raw
|
||||
// OS cursor), so the robust fix is to stop the engine from writing the OS cursor position
|
||||
// while our overlay is active, in addition to widening the constraint bounds.
|
||||
menuCursor->forceOSCursorPos = false;
|
||||
|
||||
// Clear percentage-based parallax constraints (game may re-apply these each frame).
|
||||
menuCursor->ClearConstraints();
|
||||
|
||||
const auto* renderWindow = RE::BSGraphics::GetCurrentRendererWindow();
|
||||
if (!renderWindow) {
|
||||
// Even without the render window we already unset forceOSCursorPos and constraints.
|
||||
::ClipCursor(nullptr);
|
||||
return;
|
||||
}
|
||||
|
||||
const auto width = static_cast<std::int32_t>(renderWindow->windowWidth);
|
||||
const auto height = static_cast<std::int32_t>(renderWindow->windowHeight);
|
||||
if (width <= 0 || height <= 0) {
|
||||
::ClipCursor(nullptr);
|
||||
return;
|
||||
}
|
||||
|
||||
// Widen the raw cursor bounds to the full window so any residual clamping is a no-op.
|
||||
menuCursor->SetCursorConstraintsRaw(0, 0, static_cast<std::uint32_t>(width), static_cast<std::uint32_t>(height));
|
||||
menuCursor->minCursorX = 0;
|
||||
menuCursor->minCursorY = 0;
|
||||
menuCursor->maxCursorX = width;
|
||||
menuCursor->maxCursorY = height;
|
||||
|
||||
// Release any OS-level cursor clipping rectangle.
|
||||
::ClipCursor(nullptr);
|
||||
}
|
||||
|
||||
// ── Cursor API interception (game module only) ──────────────────
|
||||
//
|
||||
// In mouse mode the title-screen game code repositions the OS cursor (SetCursorPos) and/or
|
||||
// clips it (ClipCursor) to a small centered box every frame — the "trampoline". This does not
|
||||
// happen with a gamepad connected because the mouse path never runs. Consuming the MainMenu's
|
||||
// own mouse-move vfuncs did not stop it, so the forcing originates from a more central engine
|
||||
// path. Rather than chase the exact function, we intercept SetCursorPos/ClipCursor in the game
|
||||
// executable's import table and make them no-ops while our overlay owns the pointer. We only
|
||||
// patch Fallout4.exe's IAT, so PrismaUI's own cursor rendering (a separate module) is untouched.
|
||||
|
||||
using SetCursorPosFn = BOOL(WINAPI*)(int, int);
|
||||
using ClipCursorFn = BOOL(WINAPI*)(const RECT*);
|
||||
|
||||
SetCursorPosFn g_realSetCursorPos = nullptr;
|
||||
ClipCursorFn g_realClipCursor = nullptr;
|
||||
bool g_cursorApiHooksInstalled = false;
|
||||
|
||||
[[nodiscard]] bool OverlayOwnsPointer()
|
||||
{
|
||||
return g_mainMenuVisible || g_browserVisible;
|
||||
}
|
||||
|
||||
BOOL WINAPI SetCursorPosHook(int a_x, int a_y)
|
||||
{
|
||||
// While our overlay is up, ignore the game's attempts to move the OS cursor so the
|
||||
// Windows pointer stays where the user actually put it (no trampoline back to the box).
|
||||
if (OverlayOwnsPointer()) {
|
||||
return TRUE;
|
||||
}
|
||||
return g_realSetCursorPos ? g_realSetCursorPos(a_x, a_y) : TRUE;
|
||||
}
|
||||
|
||||
BOOL WINAPI ClipCursorHook(const RECT* a_rect)
|
||||
{
|
||||
// While our overlay is up, never let the game clip the pointer to a sub-rect.
|
||||
if (OverlayOwnsPointer()) {
|
||||
return g_realClipCursor ? g_realClipCursor(nullptr) : TRUE;
|
||||
}
|
||||
return g_realClipCursor ? g_realClipCursor(a_rect) : TRUE;
|
||||
}
|
||||
|
||||
// Replace one import thunk (matched by function name) in a module's IAT with a_hook.
|
||||
// Returns the original function pointer, or nullptr if the import was not found.
|
||||
void* PatchIATEntry(HMODULE a_module, const char* a_dllName, const char* a_funcName, void* a_hook)
|
||||
{
|
||||
auto* base = reinterpret_cast<std::uint8_t*>(a_module);
|
||||
auto* dos = reinterpret_cast<IMAGE_DOS_HEADER*>(base);
|
||||
if (!dos || dos->e_magic != IMAGE_DOS_SIGNATURE) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
auto* nt = reinterpret_cast<IMAGE_NT_HEADERS*>(base + dos->e_lfanew);
|
||||
if (nt->Signature != IMAGE_NT_SIGNATURE) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const auto importRVA = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress;
|
||||
if (importRVA == 0) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
auto* import = reinterpret_cast<IMAGE_IMPORT_DESCRIPTOR*>(base + importRVA);
|
||||
for (; import->Name != 0; ++import) {
|
||||
const auto* modName = reinterpret_cast<const char*>(base + import->Name);
|
||||
if (_stricmp(modName, a_dllName) != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
auto* thunk = reinterpret_cast<IMAGE_THUNK_DATA*>(base + import->FirstThunk);
|
||||
const auto nameThunkRVA = import->OriginalFirstThunk != 0 ? import->OriginalFirstThunk : import->FirstThunk;
|
||||
auto* nameThunk = reinterpret_cast<IMAGE_THUNK_DATA*>(base + nameThunkRVA);
|
||||
|
||||
for (; nameThunk->u1.AddressOfData != 0; ++nameThunk, ++thunk) {
|
||||
if ((nameThunk->u1.Ordinal & IMAGE_ORDINAL_FLAG) != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const auto* importByName = reinterpret_cast<IMAGE_IMPORT_BY_NAME*>(base + nameThunk->u1.AddressOfData);
|
||||
if (std::strcmp(reinterpret_cast<const char*>(importByName->Name), a_funcName) != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
auto* entry = reinterpret_cast<void**>(&thunk->u1.Function);
|
||||
DWORD oldProtect = 0;
|
||||
if (!::VirtualProtect(entry, sizeof(void*), PAGE_READWRITE, &oldProtect)) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void* original = *entry;
|
||||
*entry = a_hook;
|
||||
::VirtualProtect(entry, sizeof(void*), oldProtect, &oldProtect);
|
||||
return original;
|
||||
}
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void InstallCursorApiHooks()
|
||||
{
|
||||
if (g_cursorApiHooksInstalled) {
|
||||
return;
|
||||
}
|
||||
|
||||
auto* gameModule = ::GetModuleHandleW(nullptr);
|
||||
if (!gameModule) {
|
||||
LogWarning("Cursor API hook: game module handle unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
g_realSetCursorPos = reinterpret_cast<SetCursorPosFn>(
|
||||
PatchIATEntry(gameModule, "USER32.dll", "SetCursorPos", reinterpret_cast<void*>(&SetCursorPosHook)));
|
||||
g_realClipCursor = reinterpret_cast<ClipCursorFn>(
|
||||
PatchIATEntry(gameModule, "USER32.dll", "ClipCursor", reinterpret_cast<void*>(&ClipCursorHook)));
|
||||
|
||||
if (g_realSetCursorPos == nullptr && g_realClipCursor == nullptr) {
|
||||
LogWarning("Cursor API hook: neither SetCursorPos nor ClipCursor found in game IAT.");
|
||||
return;
|
||||
}
|
||||
|
||||
g_cursorApiHooksInstalled = true;
|
||||
LogInfo(std::format(
|
||||
"Installed game cursor API hooks (SetCursorPos={}, ClipCursor={}) to stop OS-cursor trampoline in the overlay.",
|
||||
g_realSetCursorPos != nullptr,
|
||||
g_realClipCursor != nullptr));
|
||||
}
|
||||
|
||||
void ApplyBrowserPointerMode(bool a_mouseCursorVisible)
|
||||
{
|
||||
g_menuMouseCursorVisible = a_mouseCursorVisible;
|
||||
@@ -209,24 +350,23 @@ namespace F4T::PrismaUI
|
||||
// Apply to whichever menu is currently visible
|
||||
PrismaView activeView = 0;
|
||||
const char* namespace_obj = nullptr;
|
||||
bool disableFocusMenu = true;
|
||||
|
||||
if (g_mainMenuVisible && g_api && g_api->IsValid(g_mainMenuView)) {
|
||||
activeView = g_mainMenuView;
|
||||
namespace_obj = "window.CO_App";
|
||||
// MainMenu is already open with a working title-screen cursor. Keep PrismaUI's
|
||||
// FocusMenu overlay suppressed so we do not fight vanilla cursor/parallax state.
|
||||
disableFocusMenu = true;
|
||||
} else if (g_browserVisible && g_api && g_api->IsValid(g_serverBrowserView)) {
|
||||
activeView = g_serverBrowserView;
|
||||
namespace_obj = "window.CO_App";
|
||||
disableFocusMenu = true;
|
||||
}
|
||||
|
||||
if (activeView != 0) {
|
||||
if (a_mouseCursorVisible) {
|
||||
// Focus so the PrismaUI cursor renders and mouse input routes to the view.
|
||||
// disableFocusMenu=true keeps PrismaUI's Scaleform FocusMenu suppressed; we rely on
|
||||
// the game cursor layer, but ReleaseMenuCursorConstraints() neutralizes the title
|
||||
// screen's OS-cursor forcing so the pointer can reach every edge.
|
||||
ReleaseMenuCursorConstraints();
|
||||
g_api->Focus(activeView, false, disableFocusMenu);
|
||||
g_api->Focus(activeView, false, true);
|
||||
} else {
|
||||
g_api->Unfocus(activeView);
|
||||
SuppressNativeGameCursor();
|
||||
@@ -730,6 +870,8 @@ namespace F4T::PrismaUI
|
||||
UpdateBrowserPointerMode();
|
||||
|
||||
if (g_mainMenuVisible || g_browserVisible) {
|
||||
// Re-assert every frame: the title-screen MainMenu re-applies its parallax cursor
|
||||
// constraints and OS-cursor forcing during AdvanceMovie, so we must keep clearing them.
|
||||
ReleaseMenuCursorConstraints();
|
||||
SuppressNativeGameCursor();
|
||||
}
|
||||
@@ -750,6 +892,7 @@ namespace F4T::PrismaUI
|
||||
{
|
||||
F4T::MainMenuInject::Install();
|
||||
InstallMainMenuCursorReleaseHook();
|
||||
InstallCursorApiHooks();
|
||||
RegisterMenuAdvanceSink();
|
||||
|
||||
g_api = PRISMA_UI_API::RequestPluginAPI<PRISMA_UI_API::IVPrismaUI4>();
|
||||
|
||||
Reference in New Issue
Block a user