OptiInput launch deadlock: state mutex held across GetProcAddress(dinput8) vs. hkSetWindowsHookExW under the loader lock (Forza Horizon 5 freezes on every launch)
Game name and version:
Forza Horizon 5 (Steam), game version 688109.
Mods and mod versions used
Stated up front: not a stock build. The two captures below come from DLSS-NR forks of OptiScaler, installed as dxgi.dll:
- Dagherbou/OptiScaler_DLSSNR
v0.2.0-dlssnr(master of 2026-09-03, bannerv10.0.0-dev (9918b95)), and - gabrielmaialva33/OptiScaler_DLSSNR nightly
53277c47(2026-09-13), whoseOptiScaler/menu/input/is identical to current master731f3b79(the diff is empty), so the second capture is the input layer as it is on master today.
No other mods. Steam overlay on. The game's own Streamline 2.0.1 with DLSS-G. The cycle runs entirely in OptiScaler/menu/input/, the overlay menu's Present path and ntdll; the forks' additions are on neither thread's stack. I have not reproduced it on a stock master build yet (there are no nightlies and I cannot build locally); I can do that through a CI build if it helps.
GPU
RTX 5090 (driver 610.88)
OS
Windows 11 (build 26200)
Used automated or manual install?
- Automated
- Manual (
dxgi.dllin the game folder)
If on AMD/Intel and Automated, used DLSS inputs?
- n/a (NVIDIA, manual). Upscaler input: the game's DLSS through Streamline.
Did you check the Wiki and Compatibility List?
- Yes. The Forza Horizon 5 entry (last tested 0.9, before the new input system) says
dxgi.dll"crashes on launch" for some setups and recommends the ASI-loader route. Heredxgi.dllloads fine; the symptom is a hang, not a crash, and it is specific to the input system introduced ina06cdfc8(2026-06-14), so the 0.9.x releases are not affected. Some of the "crashes on launch" may have been this. - No
Please describe the issue and steps to reproduce it
Symptom. With the overlay menu enabled (OverlayMenu=auto, which is on for a dxgi.dll install), the game window appears and stops responding before the first frame, on every launch here (2 of 2 with the v0.2.0 build, and the nightly's trial launch). No crash, no error. OptiScaler.log ends 12 ms after Initialize completed hooksInstalled:1 ... and never reaches DirectInput8Create hook installed. OverlayMenu=false avoids it: the classic menu initialises the input layer later, after the launch burst.
Steps. OptiScaler as dxgi.dll in the game folder, OverlayMenu=auto (or true), start the game from Steam. On this machine the freeze is deterministic for the timing reason below; elsewhere it depends on when the game loads dinput8.dll relative to the first Present.
The cycle. Two captures, cdb attached non-invasively at the freeze, Microsoft symbols for the system modules. OptiScaler is the dxgi.dll proxy here and ships no symbols, so in the transcripts its frames appear as dxgi+0x... (once as dxgi!wod32Message, the debugger matching the real dxgi's export table); the names below come from the log-format strings the code near each offset references. Both captures show the same cycle.
Thread A holds OptiInput's state mutex and waits for the loader:
ForzaHorizon5 -> sl.interposer -> sl.dlss_g -> sl.common
-> OptiScaler WrappedIDXGISwapChain4::Present -> LocalPresent -> MenuOverlayDx::Present -> RenderImGui_DX12
-> MenuCommon::Init -> OptiInput::Initialize takes _state.Mutex input_system.cpp:778
-> UpdateDirectInputIntegrationLocked called under it input_system.cpp:844-846
-> InstallDirectInputExportHookLocked GetProcAddress under it input_system_directinput.cpp:553
-> KERNELBASE!GetProcAddress(dinput8.dll, "DirectInput8Create")
-> ntdll!LdrGetProcedureAddressForCaller -> ntdll!LdrpDrainWorkQueue -> ntdll!NtWaitForSingleObjectdinput8.dll is already mapped, so GetModuleHandleW finds it, but its DllMain has not run yet; the loader makes GetProcAddress wait for that initialisation. BeginFrame (input_system.cpp:1165, then BeginFrameLocked at 1152-1154) calls the same three integration updates under the mutex every frame, so the window is not only at Initialize.
Thread B holds the loader lock and waits for the state mutex:
ForzaHorizon5!tm_api_update -> KERNELBASE!LoadLibraryExW -> gameoverlayrenderer64 (the Steam overlay's hook)
-> OptiScaler NtdllHooks::hkLdrLoadDll -> ntdll!LdrLoadDll -> LdrpPrepareModuleForExecution -> LdrpInitializeNode
-> DINPUT8!DllMain -> DINPUT8!DllProcessAttach -> SetWindowsHookExW (detoured, so no USER32 frame)
-> OptiScaler OptiInput::hkSetWindowsHookExW takes _state.Mutex input_system_windows_hooks.cpp:744
-> MSVCP140!_Mtx_lock -> ntdll!RtlAcquireSRWLockExclusive -> ntdll!NtWaitForAlertByThreadIddinput8's DllProcessAttach installs a Windows hook; OptiScaler's detour takes the input mutex. Cycle closed. The window thread then blocks in the WndProc subclass on the same mutex, which is what Windows reports as "not responding"; the remaining threads queue behind the loader (the Steam overlay's WinRT activations, WebView2, the game's own start-up threads; the main thread is joining one of them).
Why Forza, and why every time. Forza's SDK thread loads dinput8 about half a second after the first swapchain exists, exactly while the overlay's first Present initialises the input layer; the window is a few milliseconds wide and Forza hits it every launch. Any game that loads dinput8, or any module whose DllMain calls a detoured input API, during its first frames can hit it.
Same shape elsewhere. The GameInput and XInput integrations also resolve modules and exports and run a Detours transaction under the state mutex; the HID CreateFile detours resolve hid.dll under it on their first call. 82eb45fb (HID mouse hooks compiled out) does not change the cycle: the nightly carries it and froze identically.
Evidence (gist: https://gist.github.com/VGeorge24/82b46606656058ea70ff37f806c2e413): stock-0.2.0-9918b95-transcript-extract.txt and nightly-53277c47-transcript-extract.txt (module list, registers of the waiting main thread, all thread stacks, !locks, unique stacks; the process handle table is omitted apart from the one entry cited), plus both OptiScaler.log files. Thread A is thread 60 in both captures; thread B is thread 111 (stock) and 112 (nightly).
A fix, tested. In a private fork I changed the input layer so it never waits for the loader while holding the state mutex, hooks kept:
BeginFrameandInitializerun the three integration updates with the state mutex released. The updates serialise on their own mutex, which no detour takes; they resolve modules and exports and attach detours with no state lock held, and publish into_stateunder the mutex afterwards. The HID detours resolve thehid.dllexports before taking the mutex, once.- Detours transactions are serialised on a small leaf mutex held from Begin to Commit. Detours fails a second pending transaction on another thread rather than waiting; the state mutex used to serialise them by accident.
Result on this machine: three consecutive clean launches with the overlay menu on and a hang catcher armed; DirectInput8Create hook installed follows Initialize completed by 0.9 to 3.1 s, from a later frame's update outside the mutex; no measurable cost.
The change ports onto current master (731f3b79) with a handful of small conflicts. I can open a PR if you want it; if you would rather fix it your own way, the captures above should be enough.
Source: optiscaler/OptiScaler