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:
2026-07-07 17:11:52 +12:00
parent c957c117c0
commit 2961eb6512
4 changed files with 226 additions and 40 deletions
+41 -1
View File
@@ -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
View File
@@ -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>();