Skip to content

Add NVIDIA Smooth Motion Compatibility#2849

Open
ConfoundedHermit wants to merge 11 commits into
goatcorp:masterfrom
ConfoundedHermit:master
Open

Add NVIDIA Smooth Motion Compatibility#2849
ConfoundedHermit wants to merge 11 commits into
goatcorp:masterfrom
ConfoundedHermit:master

Conversation

@ConfoundedHermit

@ConfoundedHermit ConfoundedHermit commented Jun 5, 2026

Copy link
Copy Markdown

Add NVIDIA Smooth Motion Compatibility

Related Issue

#2190

Summary

When NVIDIA Smooth Motion (frame generation) was enabled alongside Dalamud in Final Fantasy XIV, the visible game image froze while the game continued running behind the stale frame and the Dalamud overlay disappeared. This was caused by two issues:

  1. Smooth Motion wraps the game's IDXGISwapChain behind an NVIDIA NvPresent COM wrapper, so Dalamud's hooks were attaching to / comparing against the wrong presentation object.
  2. Smooth Motion can call Present multiple times per game frame and off the framework thread, so building a full ImGui frame inside the Present detour left the visible frame stale and out of sync with the display path.

This PR fixes both by:

  • (a) Unwrapping the wrappers. Dalamud now detects and peels away the NvPresent (and stacked ReShade) COM wrappers so its hooks and swap-chain comparisons target the real game swap chain.
  • (b) Splitting frame construction from frame rendering. The backend now exposes Step() (build the ImGui frame) separately from Render() (composite already-built draw data), plus a combined RenderFrame() for the unchanged normal path. When NvPresent has been unwrapped, the ImGui frame is built on the framework-update thread via Step() (where running plugin Draw callbacks and driving the D3D11 immediate context is safe), and the Present detour only calls Render() to re-composite the latest prepared draw data. Smooth Motion's extra / out-of-cadence Present calls therefore simply re-present the most recent prepared frame instead of leaving a stale image.
  • (c) Protecting the immediate context. Because Present (and therefore Render()) can now run on the NVIDIA pacer thread rather than the game's main thread, Dalamud enables D3D11 multithread protection on the immediate context when NvPresent is unwrapped, so concurrent immediate-context access between the game/framework thread and the presenting thread is serialized by the driver.
  • (d) Coordinating with swap-chain resizes. A new frame lock plus an imGuiResizeInProgress flag ensure a swap-chain resize (ResizeBuffers, including the ReShade addon resize path) can never run concurrently with present-time rendering or framework-thread frame preparation.

For the normal (non-Smooth-Motion) path, behavior is effectively unchanged: the Present detour still builds and renders the frame together via the new RenderFrame() (a single, atomic Step() + Render()).

Note on approach: An earlier iteration drove Step() from the Present detour (with per-frame deduplication and scoped main-thread identity). That introduced renderer/driver state-machine corruption when plugin immediate-context work (e.g. DrawListTextureWrap) was driven from the pacer thread. That approach has been reverted in favor of the framework-thread Step() + multithread-protected Render() design described above.

Credit

Based on goaaats work in https://github.com/goaaats/Dalamud/tree/fix/smooth_motion

Known Issues

NVIDIA Driver 610.47

  • Issue: NVIDIA Driver 610.47 introduced G-Sync and Smooth Motion issues which impairs performance of Smooth Motion, and they have reported that this will be addressed in an upcoming driver update.
  • Workaround: Use an older driver version - such as v596.49 which has been tested and confirmed working - or use hotfix driver v610.52. (Or don't use Smooth Motion)

Changes Made

Added

  • Dalamud/Interface/Internal/Unwrapper/ComHookUnwrapper.cs - New abstract base class that generalizes the old ReShade-specific unwrapping into a reusable COM-peeling strategy. It scans the early pointer-sized fields after a wrapper object's vtable, validates candidate objects with defensive readable/executable userspace memory checks (IsValidReadableMemoryAddress / IsValidExecutableMemoryAddress), and QueryInterface-validates before swapping the ComPtr to the underlying object. Subclasses implement IsRelevantComObject to decide whether a given wrapper applies. This is the old ReShade unwrap logic refactored so multiple injectors (ReShade, NvPresent) share one safe implementation. The peeling loop is hardened against infinite loops with a HashSet<nint> of visited objects (breaking on a revisit) and skips swapping when a candidate QueryInterface resolves back to the object currently being peeled, so it only reports success when it actually advances to a distinct underlying object. The memory-validation helpers reject negative sizes and compute the checked region from the real RegionSize (clamped to the remaining size) instead of a rounded-up size, fixing the per-region advance and avoiding over-reading.

  • Dalamud/Interface/Internal/Unwrapper/NvPresentUnwrapper.cs - New (sealed) ComHookUnwrapper subclass that recognizes NVIDIA wrappers by checking whether the first few vtable function pointers belong to a loaded module whose name contains NvPresent. This is what lets Dalamud reach the real game swap chain when Smooth Motion has wrapped it.

  • Dalamud/Interface/Internal/Unwrapper/ReShadeUnwrapper.cs - New ComHookUnwrapper subclass holding the ReShade-specific detection (checking for ReShadeRegisterAddon / ReShadeUnregisterAddon / ReShadeRegisterEvent / ReShadeUnregisterEvent exports). Behaviorally identical to the old static unwrapper, so existing ReShade users see no regression; it now shares the common ComHookUnwrapper peeling implementation.

Removed

  • Dalamud/Interface/Internal/ReShadeHandling/ReShadeUnwrapper.cs - Deleted the old static ReShadeUnwrapper class. Its unwrap logic was refactored into the shared ComHookUnwrapper base class (with the ReShade-specific detection moving to the new Unwrapper/ReShadeUnwrapper.cs), leaving the original file with no remaining callers. Removing it avoids leaving dead/duplicated COM-peeling code behind.

Modified

  • Dalamud/Interface/ImGuiBackend/IImGuiBackend.cs - Extended the backend contract (and clarified the XML doc comments to match the new build/draw split):

    • Added void Step() - "Builds an ImGui frame without drawing it."
    • Added void RenderFrame() - "Builds and draws an ImGui frame atomically." (used by the normal, non-Smooth-Motion path).
    • The existing void Render() now means "Draws the most recently built ImGui frame." rather than "build and render".
  • Dalamud/Interface/ImGuiBackend/Dx11Win32Backend.cs - Implemented the Step/Render split and immediate-context protection:

    • Added a Lock frameLock (renamed from the earlier stepLock) taken by Step(), Render(), RenderFrame(), OnPreResize(), and OnPostResize() so the frame being built is never observed mid-rebuild by a Present call arriving on the pacer thread, a resize can never run concurrently with frame construction or present-time rendering, and use of the non-thread-safe global ImGui state is serialized.
    • Step() / StepInternal() performs all frame construction (OnNewFrame, NewRenderFrame / NewInputFrame events, ImGui.NewFrame(), ImGuizmo.BeginFrame(), BuildUi, ImGui.Render()) and stores a snapshot of the resulting draw data in an ImDrawData drawData field. A new bool hasDrawData flag guards this snapshot: when ImGui.GetDrawData() returns null/empty the field is left default and hasDrawData is cleared, so a missing draw-data handle is handled gracefully instead of dereferencing a null pointer. Step() calls ImGui.UpdatePlatformWindows() after StepInternal() (the call was moved out of StepInternal()).
    • Render() / RenderInternal() only renders the previously stored draw data: it early-returns when hasDrawData is false, draws the main viewport via the new private RenderMainViewportInternal() (which pins drawData via a fixed pointer and is itself guarded by hasDrawData), then calls ImGui.RenderPlatformWindowsDefault(). It no longer builds a new ImGui frame. This is the core change that makes Smooth Motion's out-of-cadence Present calls safe (they just re-composite the latest prepared frame).
    • RenderFrame() runs StepInternal() + RenderMainViewportInternal() + ImGui.UpdatePlatformWindows() + ImGui.RenderPlatformWindowsDefault() atomically under frameLock for the unchanged normal path.
    • OnPreResize() and OnPostResize() now take frameLock and clear the cached drawData / hasDrawData before delegating to the renderer, so a resize discards any stale prepared frame rather than re-presenting draw data sized for the old swap chain.
    • In the constructor, when SwapChainHelper.IsNvPresentUnwrapped is set, enables D3D11 multithread protection on the immediate context via a new private EnableD3D11MultithreadProtection helper. Since ID3D11Multithread is not exposed by TerraFX, it is obtained by QueryInterface on the immediate context and its SetMultithreadProtected / GetMultithreadProtected entries are invoked through the vtable. Logs whether protection was enabled.
  • Dalamud/Interface/Internal/InterfaceManager.cs -

    • New fields: Lock imGuiFrameLock, and the bool flags hooksInitialized, hasPreparedImGuiFrame, and imGuiResizeInProgress.
    • FrameworkOnUpdate now keeps its framework-update subscription instead of unsubscribing after SetupHooks(). The hooksInitialized flag gates the one-time hook setup (still delayed until GetNetworkModuleProxy() is non-null). After hooks are set up, it returns early unless the backend exists, the atlas is built, and SwapChainHelper.IsNvPresentUnwrapped is true. In the NvPresent case it then, under imGuiFrameLock (and skipping if imGuiResizeInProgress), invalidates any previously prepared frame via the new InvalidatePreparedImGuiFrame() helper, calls PrepareImGuiFrame(backend) + backend.Step() to build the next frame on the framework thread, and sets hasPreparedImGuiFrame.
    • Added a new InvalidatePreparedImGuiFrame() helper that, when a prepared frame is pending, clears hasPreparedImGuiFrame and calls PostImGuiRender() to release everything the frame referenced. This consolidates the previous inline "composite the prepared frame before replacing it" bookkeeping into one place so the framework-thread path and all resize paths invalidate the cached frame consistently.
    • RenderDalamudDraw (the DXGI Present detour) is wrapped in a try/finally that always resets IsMainThreadInPresent. Under imGuiFrameLock it skips when imGuiResizeInProgress. When NvPresent is unwrapped it calls activeBackend.Render() only (returning early if no frame has been prepared yet), re-compositing the cached draw data. Otherwise (normal path) it calls PrepareImGuiFrame(activeBackend) + activeBackend.RenderFrame() + PostImGuiRender().
    • Extracted a new PrepareImGuiFrame(IImGuiBackend activeBackend) method holding the pre-render bookkeeping that used to be inline in the detour: draining the runBeforeImGuiRender queue and computing the ImGuiConfigFlags.ViewportsEnable flag.
    • DisposeService now unsubscribes from framework.Update to avoid a dangling handler after disposal.
    • In swap-chain setup, calls SwapChainHelper.UnwrapNvPresent() before installing hooks (logging Unwrapped NvPresent / No NvPresent wrap detected), and again after a successful ReShade unwrap (logging Unwrapped NvPresent after ReShade) to handle stacked wrapper orders.
  • Dalamud/Interface/Internal/InterfaceManager.AsHook.cs - In the ResizeBuffers detour, takes imGuiFrameLock, sets imGuiResizeInProgress = true, and calls InvalidatePreparedImGuiFrame() to drop any cached frame before the swap chain changes, then wraps the ResizeBuffers event, OnPreResize, the original ResizeBuffers call, and OnPostResize in a try/finally that clears imGuiResizeInProgress. This prevents present-time rendering or framework-thread frame preparation from running during a resize.

  • Dalamud/Interface/Internal/InterfaceManager.AsReShadeAddon.cs - The ReShade addon resize callbacks now participate in the same coordination: the pre-resize handler takes imGuiFrameLock, sets imGuiResizeInProgress = true, and calls InvalidatePreparedImGuiFrame() before OnPreResize; the post-resize handler calls InvalidatePreparedImGuiFrame() before OnPostResize and clears imGuiResizeInProgress under the lock (including the early-out path when GetDesc fails).

  • Dalamud/Interface/ImGuiBackend/Helpers/ReShadePeeler.cs - Applied the same robustness fixes as the new ComHookUnwrapper to the existing helper: the peeling loop now tracks visited objects in a HashSet<nint> (breaking on a revisit) and skips a candidate whose QueryInterface resolves back to the object currently being peeled, returning success only when it actually advances; IsValidReadableMemoryAddress / IsValidExecutableMemoryAddress reject negative sizes and compute the checked region from the real RegionSize (clamped to the remaining size) instead of a rounded-up size.

  • Dalamud/Interface/Internal/SwapChainHelper.cs - Switched to the new Unwrapper namespace (and added a using for the ReShadeHandling namespace), changed UnwrapReShade() to instantiate new ReShadeUnwrapper(), added a new IsNvPresentUnwrapped property, and added a new UnwrapNvPresent() method that wraps the current GameDeviceSwapChain in a ComPtr<IDXGISwapChain>, runs NvPresentUnwrapper.Unwrap(), updates the cached foundGameDeviceSwapChain to the unwrapped object, and sets IsNvPresentUnwrapped so later hook/compare logic and the backend's multithread-protection step target / react to the real swap chain.

@ConfoundedHermit ConfoundedHermit marked this pull request as ready for review June 5, 2026 14:56
@ConfoundedHermit ConfoundedHermit requested a review from a team as a code owner June 5, 2026 14:56
@ConfoundedHermit ConfoundedHermit marked this pull request as draft June 6, 2026 18:02
@ConfoundedHermit ConfoundedHermit marked this pull request as ready for review June 6, 2026 19:53
@ConfoundedHermit ConfoundedHermit marked this pull request as draft June 6, 2026 22:52
@ConfoundedHermit

ConfoundedHermit commented Jun 7, 2026

Copy link
Copy Markdown
Author

There was an issue introduced in a crash fix (and carried over in subsequent commits) so a different method is being used now and has been tested successfully.

@ConfoundedHermit ConfoundedHermit marked this pull request as ready for review June 7, 2026 01:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant