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
+3
View File
@@ -9,6 +9,9 @@ For testing notes, milestone summaries, known issues, and next steps, see [`docs
## [Unreleased]
### Fixed
- Mouse cursor no longer trampolines back to a centered box in the custom main menu (previously only worked with a gamepad connected). In mouse mode the title-screen engine repositions/clips the OS cursor to a small centered box every frame; with a gamepad active that path never runs, so the pointer was free. Fixed by intercepting `SetCursorPos` and `ClipCursor` in the Fallout4.exe import table (IAT) and making them no-ops while the Commonwealth Online overlay owns the pointer, so the game can no longer move or box in the Windows cursor. Only the game module's imports are patched, leaving PrismaUI's own cursor rendering untouched. Also hooks the `MainMenu` `OnCursorMoveEvent`/`OnMouseMoveEvent` vtable slots and keeps the `MenuCursor` release (`forceOSCursorPos = false`, cleared parallax constraints, widened bounds) as defense-in-depth.
### Added
- **Custom main menu overlay**: PrismaUI full-screen overlay of Commonwealth Online main menu (MULTIPLAYER, CREATIONS, SETTINGS, HELP, QUIT) auto-shows on Fallout 4 title screen, replacing the vanilla list menu.
- **Main menu input capture**: Keyboard (↑↓ / ENTER / ESC) and gamepad (dpad / A / B) navigation of custom main menu overlay; vanilla menu input is blocked so the underlying CONTINUE/NEW/LOAD/SETTINGS rows remain unreachable.
+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()
+151 -8
View File
@@ -69,6 +69,7 @@ namespace F4T::PrismaUI
void InstallMainMenuCursorReleaseHook();
void RegisterMenuAdvanceSink();
void ReleaseMenuCursorConstraints();
void InstallCursorApiHooks();
[[nodiscard]] bool ShouldOverrideMenuCursorConstraints()
{
@@ -178,30 +179,170 @@ namespace F4T::PrismaUI
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;
}
// 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.
// 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>();