Skip to content

VcXsrv SDL2 Relative Mouse Mode Fix

millin edited this page Jul 22, 2026 · 1 revision

VcXsrv SDL2 Relative Mouse Mode Fix

Objective: Fix VcXsrv's missing support for SDL2 relative mouse mode, covering both the data layer (XI_RawMotion events) and the window management layer (cursor confinement and hiding), so that SDL2 programs can achieve a complete capture/release experience without SDL_MOUSE_RELATIVE_MODE_WARP=1.

Date: 2026-07-02
Upstream: https://github.com/marchaesen/vcxsrv
Issue: #77 | PR: #78
Dev Branch: https://github.com/vivimillin/vcxsrv/tree/feature/raw-input-mouse


1. Root Cause Analysis

1.1 Symptoms

When running SDL2 programs (AssaultCube, etc.) under VcXsrv + WSL with relative mouse mode enabled:

Configuration Mouse Behavior Audio Behavior
SDL_MOUSE_RELATIVE_MODE_WARP=0 (default) Cursor points to sky/ground or locks to corners, uncontrollable Normal
SDL_MOUSE_RELATIVE_MODE_WARP=1 (manual workaround) Mouse locks correctly, free aim control Severe stutter during high-frequency movement

1.2 Layer 1: XI_RawMotion Events Completely Missing

When SDL2's X11 backend enables relative mouse mode, it prefers XI_RawMotion events from the XInput2 extension for relative displacement:

SDL_SetRelativeMouseMode(SDL_TRUE)
    → XIGrabDevice()               // Grab input device
    → XISelectEvents(XI_RawMotion) // Register relative motion event listener

// On mouse movement
X server → XI_RawMotion(dx, dy)   // Hardware-level relative displacement
    → SDL2 extracts event.motion.xrel / yrel

VcXsrv's XInput2 extension is fully present at the protocol layer (XIGrabDevice, XISelectEvents both succeed), and X11_SetRelativeMouseMode returns success;

But the Windows input layer (hw/xwin) never passes mouse relative displacement to the X server. XI_RawMotion events are completely missing at the data layer.

Layer VcXsrv Status Note
XInput2 protocol extension Fully supported libXi fully compiled, XIGrabDevice and XISelectEvents work normally
XI_RawMotion event generation Completely missing hw/xwin layer only handles WM_MOUSEMOVE (absolute coordinates), never passes mouse relative displacement to X server

SDL2's auto-fallback mechanism only triggers when SetRelativeMouseMode returns failure. Here the function returns success (it only checks whether XInput2 is initialized), so the auto-fallback is not triggered. SDL2 mistakenly believes relative mode is available, but in reality receives no relative displacement data at all.

1.3 Layer 2: Absolute Coordinates Polluting the Relative Event Stream

GetPointerEvents() generates RawDeviceEvent (i.e. XI_RawMotion) for all MotionNotify events unless the POINTER_NORAW flag is set.

winEnqueueMotion() uses POINTER_ABSOLUTE | POINTER_SCREEN, without setting POINTER_NORAW. This causes absolute coordinates from WM_MOUSEMOVE (e.g. x=500, y=300) to also generate XI_RawMotion(500, 300), mixing with true relative increments and producing the "gun pointing at sky/ground" chaos in SDL2.

1.4 Layer 3: Master Valuator Mode Error

SDL2 queries the valuator mode of the master device (Virtual core pointer, device 2), not the slave device (Windows pointer, device 6).

InitPointerDeviceStruct sets the slave to Relative, but the master remains at the default Absolute. In Absolute mode, SDL2 calculates deltas using prev - current, but current is a relative increment rather than an absolute position, producing completely wrong values.

1.5 The Cost of WARP=1

When SDL_MOUSE_RELATIVE_MODE_WARP=1 is set manually, SDL2 bypasses the XI2 path and uses the legacy XWarpPointer to simulate relative mode:

Mouse moves → absolute coordinates arrive at SDL2
    → SDL2 calculates delta
    → XWarpPointer(center) resets cursor
    → triggers new MotionNotify
    → one X11 round-trip per movement

Under WSL2's VM NAT networking, each round-trip takes 2-5ms. High-frequency mouse operations generate hundreds of warp calls per second, blocking the main thread and causing audio underrun.

1.6 Layer 4: DDX Layer Missing Cursor Confinement and Hide

After the Raw Input data layer is fixed, SDL2 relative mouse mode can already receive correct XI_RawMotion data. But VcXsrv's DDX layer (hw/xwin) has no Windows-side logic to respond to X11 pointer grab events: XGrabPointer is handled at the DIX layer, but Windows ClipCursor and ShowCursor are never called. This causes:

Symptom Description
Cursor not confined Mouse can still move out of window during capture
Cursor not hidden System cursor remains visible during capture
Accidental resize trigger Cursor at window edge drags sizing border and changes window size
Cursor skipping escape Rapid mouse movement briefly escapes the confined area (Windows silently cancels ClipCursor)

2. Fix Principles

Use the Windows Raw Input API to obtain hardware-level relative displacement and feed it into the X server's relative event pipeline; simultaneously hook grab callbacks to implement cursor confinement and hiding.

2.1 Raw Input Data Pipeline

Mouse moves
    → Windows generates WM_INPUT message
    → GetRawInputData() extracts lLastX, lLastY (relative displacement)
    → winEnqueueRawMotion(dx, dy)
    → QueuePointerEvents(..., POINTER_RELATIVE)
    → X server generates XI_RawMotion
    → SDL2 receives (dx, dy) correctly
    → No XWarpPointer needed, no message storm

Files to modify:

File Changes
xorg-server/hw/xwin/win.h Add winEnqueueRawMotion() declaration
xorg-server/hw/xwin/winmouse.c Add winEnqueueRawMotion() implementation; define POINTER_NORAW; fix master valuator mode
xorg-server/hw/xwin/winwndproc.c Register Raw Input device + handle WM_INPUT messages

Four coordinated changes:

  1. Receive: Register Windows WM_INPUT (Raw Input), capture hardware-level relative displacement lLastX/lLastY
  2. Inject: Add winEnqueueRawMotion(), feed relative increments into QueuePointerEvents(..., POINTER_RELATIVE)
  3. Pollution prevention: Add POINTER_NORAW to winEnqueueMotion(), prevent absolute events from generating XI_RawMotion
  4. Semantic fix: Set master pointer valuator mode to Relative, so SDL2 correctly parses raw values

2.2 Grab Hook and Cursor Management

VcXsrv never implemented cursor confinement and hiding on grab. The fix:

  1. Hook master/slave device grab callbacks: Replace ActivateGrab/DeactivateGrab, trigger ClipCursor and ShowCursor on confining grab
  2. Respect confineTo: Only ClipCursor and HideCursor when grab->confineTo is non-NULL
  3. Map X11 window to Windows hwnd: Use winGetWindowPriv() via grab's confineTo window to get precise HWND, avoiding GetForegroundWindow() startup race
  4. 1px padding: Shrink clipping rectangle by 1px, preventing cursor from landing on sizing border
  5. 10ms timer: Re-apply ClipCursor, defending against Windows silently canceling it (cursor skipping, Win key, etc.)
  6. Handle WM_ACTIVATEAPP: Temporarily release ClipCursor on app deactivation (Alt+Tab), restore on activation

Files to modify:

File Changes
xorg-server/hw/xwin/win.h Add WIN_CLIPCURSOR_TIMER_ID constant; declare g_hwndGrabWindow extern
xorg-server/hw/xwin/winmouse.c Hook master/slave ActivateGrab/DeactivateGrab; implement ClipCursor + ShowCursor logic; timer management
xorg-server/hw/xwin/winwndproc.c Handle WIN_CLIPCURSOR_TIMER_ID in WM_TIMER; update WM_ACTIVATEAPP for grab-aware cursor management; remove erroneous cursor release code in WM_KILLFOCUS

3. Modified File Summary

File Raw Input Related Cursor Lock Related
xorg-server/hw/xwin/win.h winEnqueueRawMotion() declaration WIN_CLIPCURSOR_TIMER_ID constant; g_hwndGrabWindow extern
xorg-server/hw/xwin/winmouse.c POINTER_NORAW definition; master valuator mode fix; winEnqueueRawMotion() implementation Hook master/slave ActivateGrab/DeactivateGrab; ClipCursor/ShowCursor logic; timer management
xorg-server/hw/xwin/winwndproc.c WM_CREATE registers Raw Input device; WM_INPUT handler WM_TIMER re-applies ClipCursor; WM_ACTIVATEAPP grab-aware rewrite; remove WM_KILLFOCUS erroneous code

4. Step One: Raw Input and Relative Mouse Mode Implementation

All code below is based on the marchaesen/vcxsrv main branch, consistent with the diff attached to issue #77 / PR #78.

4.1 win.h

4.1.1 Add winEnqueueRawMotion() Declaration

Location: After winEnqueueMotion declaration (around line 793)

void
 winEnqueueMotion(int x, int y);

void                                    /* NEW */
 winEnqueueRawMotion(int dx, int dy);  /* NEW */

/*
 * winscrinit.c
 */

4.2 winmouse.c

4.2.1 Define POINTER_NORAW

Location: After #include (around line 28)

/* Prevent POINTER_ABSOLUTE events from generating XI_RawMotion.
 * Only POINTER_RELATIVE events should produce XI_RawMotion for
 * XInput2 relative-mode clients (SDL2, etc.). */
#ifndef POINTER_NORAW
#define POINTER_NORAW 0x20
#endif

POINTER_NORAW is checked in Xorg's dix/getevents.c, but VcXsrv's headers do not export this constant. Value 0x20 is the Xorg internal convention.

4.2.2 Fix Master Pointer Valuator Mode

Location: In DEVICE_INIT case, after InitPointerDeviceStruct() call and before free(map) (around line 260)

        /* XInput2 clients query the master pointer's valuator mode
         * (Virtual core pointer, device 2) to determine how to
         * interpret XI_RawMotion values. Mode must be Relative for
         * SDL2 relative mouse mode to work correctly.
         * The slave (Windows pointer, device 6) mode is set
         * automatically by InitPointerDeviceStruct, but the master
         * mode must be updated explicitly.
         */
        if (inputInfo.pointer && inputInfo.pointer->valuator &&
            inputInfo.pointer->valuator->numAxes >= 2) {
            inputInfo.pointer->valuator->axes[0].mode = Relative;
            inputInfo.pointer->valuator->axes[1].mode = Relative;
        }

inputInfo is an X Server global variable, declared in inputstr.h, which winmouse.c indirectly includes via inputstr.h. Relative has value 1, defined in XI.h.

4.2.3 Modify winEnqueueMotion()

Add valuator_mask_zero() and POINTER_NORAW to the original function:

void
winEnqueueMotion(int x, int y)
{
    int valuators[2];
    ValuatorMask mask;

    valuator_mask_zero(&mask);          /* NEW */

    valuators[0] = x;
    valuators[1] = y;

    valuator_mask_set_range(&mask, 0, 2, valuators);
    QueuePointerEvents(g_pwinPointer, MotionNotify, 0,
                       POINTER_ABSOLUTE | POINTER_SCREEN | POINTER_NORAW,  /* ADD POINTER_NORAW */
                       &mask);
}

valuator_mask_zero() ensures no garbage bits in the stack-allocated ValuatorMask. POINTER_NORAW prevents GetPointerEvents() from generating RawDeviceEvent (XI_RawMotion) for this absolute coordinate event.

4.2.4 Add winEnqueueRawMotion()

Add after winEnqueueMotion():

/*
 * Enqueue a raw motion event with relative coordinates.
 *
 * WM_INPUT delivers hardware-level relative displacement (lLastX/Y)
 * via GetRawInputData. We inject it into the X server's
 * POINTER_RELATIVE pipeline so XInput2 generates XI_RawMotion
 * events for clients in relative mouse mode.
 */
void
winEnqueueRawMotion(int dx, int dy)
{
    int valuators[2];
    ValuatorMask mask;

    valuator_mask_zero(&mask);

    valuators[0] = dx;
    valuators[1] = dy;

    valuator_mask_set_range(&mask, 0, 2, valuators);
    QueuePointerEvents(g_pwinPointer, MotionNotify, 0,
                       POINTER_RELATIVE, &mask);
}

POINTER_RELATIVE (without POINTER_NORAW) allows GetPointerEvents() to generate RawDeviceEvent, which becomes the XI_RawMotion event delivered to SDL2.


4.3 winwndproc.c

4.3.1 WM_CREATE — Register Raw Input Device

Insert after tray icon initialization and before return 0:

        /* Register for WM_INPUT (Raw Input) mouse events.
         * These provide hardware-level relative displacement
         * (lLastX/Y), independent of cursor position or screen
         * boundaries, needed for XInput2 XI_RawMotion.
         * dwFlags=0: receive only when focused; do NOT use
         * RIDEV_NOLEGACY (breaks WM_MOUSEMOVE for absolute mode).
         */
        {
            RAWINPUTDEVICE rid;
            rid.usUsagePage = 0x01;       /* Generic Desktop Controls */
            rid.usUsage = 0x02;           /* Mouse */
            rid.dwFlags = 0;              /* Focused window only */
            rid.hwndTarget = hwnd;
            if (!RegisterRawInputDevices(&rid, 1, sizeof(rid))) {
                ErrorF("winWindowProc - WM_CREATE: "
                       "RegisterRawInputDevices failed (error %lu)\n",
                       GetLastError());
            }
        }

rid.dwFlags = 0 is deliberate:

  • No RIDEV_INPUTSINK: avoid receiving input when window is unfocused, preventing duplicate events
  • No RIDEV_NOLEGACY: preserve WM_MOUSEMOVE for absolute coordinate mode

4.3.2 WM_INPUT — Handle Raw Input Messages

Insert before case WM_MOUSEMOVE::

    case WM_INPUT:
    {
        RAWINPUT raw;
        UINT rawSize = sizeof(raw);

        /* We can't do anything without privates or pointer */
        if (s_pScreenPriv == NULL || s_pScreenInfo->fIgnoreInput)
            break;
        if (g_pwinPointer == NULL)
            break;

        if (GetRawInputData((HRAWINPUT)lParam, RID_INPUT, &raw,
                            &rawSize, sizeof(RAWINPUTHEADER)) == (UINT)-1) {
            ErrorF("winWindowProc - WM_INPUT: GetRawInputData failed\n");
            break;
        }

        if (raw.header.dwType == RIM_TYPEMOUSE) {
            LONG dx = raw.data.mouse.lLastX;
            LONG dy = raw.data.mouse.lLastY;

            if (dx != 0 || dy != 0) {
                /* Filter absolute-position devices (touchscreens,
                 * tablets). They use the same Raw Input API but
                 * deliver absolute coordinates, not relative deltas. */
                if (!(raw.data.mouse.usFlags & MOUSE_MOVE_ABSOLUTE)) {
                    winEnqueueRawMotion((int)dx, (int)dy);
                }
            }
        }

        /* Must break (not return 0) so DefWindowProc cleans up
         * Raw Input resources. Return 0 causes resource leak and
         * eventual Raw Input failure. */
        break;
    }

Must use break, never return 0: DefWindowProc() is responsible for cleaning up WM_INPUT internal resources. return 0 skips cleanup, the Raw Input queue gradually fills up, and GetRawInputData eventually stops working.


5. Step Two: Mouse Cursor Confinement and Hiding

5.1 win.h

5.1.1 Add WIN_CLIPCURSOR_TIMER_ID Constant

Location: After #define WIN_POLLING_MOUSE_TIMER_ID 2 (around line 88)

#define WIN_CLIPCURSOR_TIMER_ID        3

5.1.2 Declare g_hwndGrabWindow Extern

Location: After extern DeviceIntPtr g_pwinKeyboard; (around line 531)

extern HWND g_hwndGrabWindow;

5.2 winmouse.c

5.2.1 Add #include "winwindow.h"

Location: After #include "inpututils.h" (around line 28)

#include "winwindow.h"   /* for winGetWindowPriv, winPrivWinRec */

winwindow.h provides the winPrivWinRec structure and winGetWindowPriv() macro for obtaining the Windows HWND from an X11 WindowPtr.

5.2.2 Add Static Variables and Helper Functions

Location: After static CARD8 const *g_winMouseButtonMap = NULL; (around line 35), before winMouseProc function

/* Master device grab callback hooks (for Core XGrabPointer) */
static void (*winOrigMasterActivateGrab)(DeviceIntPtr, GrabPtr, TimeStamp, Bool) = NULL;
static void (*winOrigMasterDeactivateGrab)(DeviceIntPtr) = NULL;

/* Slave device grab callback hooks (for XI2 XGrabDevice) */
static void (*winOrigSlaveActivateGrab)(DeviceIntPtr, GrabPtr, TimeStamp, Bool) = NULL;
static void (*winOrigSlaveDeactivateGrab)(DeviceIntPtr) = NULL;

/* HWND of the vcxsrv window that currently has the mouse. */
HWND g_hwndGrabWindow = NULL;

/* Timer ID for periodic ClipCursor re-apply */
static UINT_PTR g_grabTimerId = 0;

Then add the following helper functions immediately after (before winMouseProc):

/*
 * Apply cursor constraint to foreground window
 */
static void
winApplyCursorConstraint(void)
{
    HWND hwnd = g_hwndGrabWindow;
    RECT rect;
    if (!hwnd)
        hwnd = GetForegroundWindow();
    if (!hwnd)
        return;

    GetClientRect(hwnd, &rect);
    ClientToScreen(hwnd, (LPPOINT)&rect.left);
    ClientToScreen(hwnd, (LPPOINT)&rect.right);
    /* 1px padding: keep cursor away from sizing border */
    rect.left   += 1;
    rect.top    += 1;
    rect.right  -= 1;
    rect.bottom -= 1;
    ClipCursor(&rect);
}

/*
 * Release cursor constraint
 */
static void
winReleaseCursorConstraint(void)
{
    ClipCursor(NULL);
}

/*
 * Force-hide cursor during grab
 */
static void
winGrabHideCursor(void)
{
    while (ShowCursor(FALSE) >= 0)
        ;
}

/*
 * Force-show cursor after grab release
 */
static void
winGrabShowCursor(void)
{
    while (ShowCursor(TRUE) < 0)
        ;
}

5.2.3 Add Master Device Hook Functions

Location: After helper functions, before winMouseProc

/*
 * Master device ActivateGrab hook.
 * SDL2 X11 backend uses XGrabPointer (Core protocol), which operates
 * on the MASTER pointer device. This hook handles that path.
 */
static void
winMasterActivateGrab(DeviceIntPtr dev, GrabPtr grab, TimeStamp time, Bool autoGrab)
{
    if (winOrigMasterActivateGrab)
        winOrigMasterActivateGrab(dev, grab, time, autoGrab);

    /* Only confine cursor position if grab specifies a confine window. */
    if (grab && grab->confineTo) {
        /* Map X11 grab window to Windows hwnd for precise constraint. */
        WindowPtr pGrabWin = grab->confineTo ? grab->confineTo : grab->window;
        winPrivWinPtr pWinPriv = winGetWindowPriv(pGrabWin);
        if (pWinPriv && pWinPriv->hWnd) {
            g_hwndGrabWindow = pWinPriv->hWnd;
        }
        winApplyCursorConstraint();
        /* Windows may silently cancel ClipCursor (cursor skipping,
         * Win key, system notifications, etc.). This timer defends against it. */
        if (!g_grabTimerId && g_hwndGrabWindow) {
            g_grabTimerId = SetTimer(g_hwndGrabWindow, WIN_CLIPCURSOR_TIMER_ID, 10, NULL);
        }
        winGrabHideCursor();
    }
    /* confineTo = NULL: no Windows-side effect */
}

/*
 * Master device DeactivateGrab hook.
 */
static void
winMasterDeactivateGrab(DeviceIntPtr dev)
{
    /* dev->deviceGrab.grab points to the grab being released. */
    GrabPtr grab = dev->deviceGrab.grab;
    /* Confining grab: release Windows cursor state */
    if (grab && grab->confineTo) {
        winReleaseCursorConstraint();
        winGrabShowCursor();
        /* Stop periodic timer */
        if (g_grabTimerId) {
            KillTimer(g_hwndGrabWindow, g_grabTimerId);
            g_grabTimerId = 0;
        }
    }

    if (winOrigMasterDeactivateGrab)
        winOrigMasterDeactivateGrab(dev);
}

5.2.4 Add Slave Device Hook Functions

Location: After winMasterDeactivateGrab, before winMouseProc

/*
 * Slave device ActivateGrab hook.
 * XI2 XGrabDevice may operate on slave devices directly.
 */
static void
winSlaveActivateGrab(DeviceIntPtr dev, GrabPtr grab, TimeStamp time, Bool autoGrab)
{
    if (winOrigSlaveActivateGrab)
        winOrigSlaveActivateGrab(dev, grab, time, autoGrab);

    /* Only confine cursor position if grab specifies a confine window. */
    if (grab && grab->confineTo) {
        /* Map X11 grab window to Windows hwnd for precise constraint. */
        WindowPtr pGrabWin = grab->confineTo ? grab->confineTo : grab->window;
        winPrivWinPtr pWinPriv = winGetWindowPriv(pGrabWin);
        if (pWinPriv && pWinPriv->hWnd) {
            g_hwndGrabWindow = pWinPriv->hWnd;
        }
        winApplyCursorConstraint();
        /* Windows may silently cancel ClipCursor (cursor skipping,
         * Win key, system notifications, etc.). This timer defends against it. */
        if (!g_grabTimerId && g_hwndGrabWindow) {
            g_grabTimerId = SetTimer(g_hwndGrabWindow, WIN_CLIPCURSOR_TIMER_ID, 10, NULL);
        }
        winGrabHideCursor();
    }
    /* confineTo = NULL: no Windows-side effect */
}

/*
 * Slave device DeactivateGrab hook.
 */
static void
winSlaveDeactivateGrab(DeviceIntPtr dev)
{
    /* dev->deviceGrab.grab points to the grab being released. */
    GrabPtr grab = dev->deviceGrab.grab;
    /* Confining grab: release Windows cursor state */
    if (grab && grab->confineTo) {
        winReleaseCursorConstraint();
        winGrabShowCursor();
        /* Stop periodic timer */
        if (g_grabTimerId) {
            KillTimer(g_hwndGrabWindow, g_grabTimerId);
            g_grabTimerId = 0;
        }
    }

    if (winOrigSlaveDeactivateGrab)
        winOrigSlaveDeactivateGrab(dev);
}

5.2.5 Install Hooks in DEVICE_INIT

Location: After InitPointerDeviceStruct() call and before free(map) (around line 225)

        /* Hook master device (for Core XGrabPointer used by SDL2) */
        {
            DeviceIntPtr master = inputInfo.pointer;
            if (master && master != pDeviceInt) {
                winOrigMasterActivateGrab = master->deviceGrab.ActivateGrab;
                winOrigMasterDeactivateGrab = master->deviceGrab.DeactivateGrab;
                master->deviceGrab.ActivateGrab = winMasterActivateGrab;
                master->deviceGrab.DeactivateGrab = winMasterDeactivateGrab;
            }
        }

        /* Hook slave device too (for XI2 XGrabDevice) */
        winOrigSlaveActivateGrab = pDeviceInt->deviceGrab.ActivateGrab;
        winOrigSlaveDeactivateGrab = pDeviceInt->deviceGrab.DeactivateGrab;
        pDeviceInt->deviceGrab.ActivateGrab = winSlaveActivateGrab;
        pDeviceInt->deviceGrab.DeactivateGrab = winSlaveDeactivateGrab;

5.2.6 DEVICE_OFF Safe Release

Location: In case DEVICE_OFF:, after pDevice->on = FALSE; (around line 247)

    case DEVICE_OFF:
        pDevice->on = FALSE;

        /* Force release cursor constraint on device shutdown */
        winReleaseCursorConstraint();
        winGrabShowCursor();
        if (g_grabTimerId) {
            KillTimer(g_hwndGrabWindow, g_grabTimerId);
            g_grabTimerId = 0;
        }
        break;

5.3 winwndproc.c

5.3.1 WM_TIMER — Re-apply ClipCursor

In the existing case WM_TIMER: switch (wParam), after case WIN_POLLING_MOUSE_TIMER_ID: (around line 1050)

        case WIN_CLIPCURSOR_TIMER_ID:
        {
            if (inputInfo.pointer &&
                inputInfo.pointer->deviceGrab.grab &&
                g_hwndGrabWindow) {
                /* Windows may silently cancel it (cursor skipping,
                 * Win key, system notifications, etc.). */
                RECT rect;
                GetClientRect(g_hwndGrabWindow, &rect);
                ClientToScreen(g_hwndGrabWindow, (LPPOINT)&rect.left);
                ClientToScreen(g_hwndGrabWindow, (LPPOINT)&rect.right);
                /* 1px padding: keep cursor away from sizing border */
                rect.left   += 1;
                rect.top    += 1;
                rect.right  -= 1;
                rect.bottom -= 1;
                ClipCursor(&rect);
            }
        }

5.3.2 Remove Erroneous Cursor Release in WM_KILLFOCUS

Location: In case WM_KILLFOCUS: (around lines 1174-1180)

Remove the following code:

        /* REMOVE this code — keyboard focus loss does not mean grab release */
        /* Safety: release cursor constraint if focus leaves while grabbed */
        if (inputInfo.pointer && inputInfo.pointer->deviceGrab.grab) {
            ClipCursor(NULL);
            while (ShowCursor(TRUE) < 0)
                ;
        }

After removal, WM_KILLFOCUS only handles keyboard logic:

    case WM_KILLFOCUS:
        if (s_pScreenPriv == NULL || s_pScreenInfo->fIgnoreInput)
            break;

        /* Release any pressed keys */
        winKeybdReleaseKeys();

        /* Remove our keyboard hook if it is installed */
        winRemoveKeyboardHookLL();

        return 0;

5.3.3 Rewrite WM_ACTIVATEAPP

Location: Entire case WM_ACTIVATEAPP: block (around line 1228)

Before (original code):

    case WM_ACTIVATEAPP:
        /* ... guard checks ... */
        s_pScreenPriv->fActive = wParam;

        /* Reshow the Windows mouse cursor if we are being deactivated */
        if (g_fSoftwareCursor && !s_pScreenPriv->fActive && !g_fCursor) {
            g_fCursor = TRUE;
            ShowCursor(TRUE);
        }
        /* ... pwinActivateApp call ... */
        return 0;

After:

    case WM_ACTIVATEAPP:
        if (s_pScreenPriv == NULL || s_pScreenInfo->fIgnoreInput)
            break;

        winDebug("winWindowProc - WM_ACTIVATEAPP\n");

        /* Activate or deactivate */
        s_pScreenPriv->fActive = wParam;

        if (!s_pScreenPriv->fActive) {
            /* DEACTIVATING */
            if (inputInfo.pointer && inputInfo.pointer->deviceGrab.grab) {
                ClipCursor(NULL);
                while (ShowCursor(TRUE) < 0)
                    ;
            }
            else {
                /* Original logic: restore hover-hidden cursor */
                if (g_fSoftwareCursor && !g_fCursor) {
                    g_fCursor = TRUE;
                    ShowCursor(TRUE);
                }
            }
        }
        else {
            /* ACTIVATING */
            if (inputInfo.pointer && inputInfo.pointer->deviceGrab.grab) {
                if (g_hwndGrabWindow) {
                    RECT rect;
                    GetClientRect(g_hwndGrabWindow, &rect);
                    ClientToScreen(g_hwndGrabWindow, (LPPOINT)&rect.left);
                    ClientToScreen(g_hwndGrabWindow, (LPPOINT)&rect.right);
                    /* 1px padding: keep cursor away from sizing border */
                    rect.left   += 1;
                    rect.top    += 1;
                    rect.right  -= 1;
                    rect.bottom -= 1;
                    ClipCursor(&rect);
                }
                while (ShowCursor(FALSE) >= 0)
                    ;
            }
        }

        /* Call engine specific screen activation/deactivation function */
        (*s_pScreenPriv->pwinActivateApp) (s_pScreen);

        return 0;

6. Technical Notes

6.1 Why POINTER_NORAW is Needed

dix/getevents.c's GetPointerEvents() generates RawDeviceEvent (i.e. XI_RawMotion) for all MotionNotify unless POINTER_NORAW is set:

if ((flags & POINTER_NORAW) == 0) {
    raw = &events->raw_event;        // Create XI_RawMotion
    set_raw_valuators(raw, &mask, TRUE, raw->valuators.data_raw);
}

Without POINTER_NORAW, WM_MOUSEMOVE (absolute coordinates x=500, y=300) generates XI_RawMotion(500, 300), mixing with the true relative increment XI_RawMotion(2, -1) from WM_INPUT, causing erratic behavior in SDL2.

6.2 Why Master Valuator Mode Must be Fixed

SDL2 queries the master device (device 2) valuator mode:

// SDL_x11xinput2.c
devinfo = xinput2_get_device_info(videodata, rawev->deviceid);
// deviceid = 2 (Virtual core pointer, master)
devinfo->relative[axis] = (v->mode == XIModeRelative);

When mode is Absolute, SDL2 takes the prev - current branch:

processed = devinfo->prev_coords[i] - coords[i];
// prev = last increment(2), current = new increment(3)
// processed = 2 - 3 = -1  // Completely wrong!

When mode is Relative, SDL2 uses the value directly:

processed = coords[i];  // Use dx=3 directly, correct

6.3 Why valuator_mask_zero() is Needed

ValuatorMask is a stack-allocated struct. valuator_mask_set_range() sets bits but does not clear existing ones. Without zero(), residual bits on the stack are read by set_raw_valuators() as extra axis data, polluting the XI_RawMotion valuator array.

6.4 Why WM_INPUT Must break Rather Than return 0

Windows documentation explicitly states: WM_INPUT handling must have DefWindowProc() complete internal resource release. return 0 bypasses DefWindowProc(), causing resource leakage that eventually stops Raw Input from working.

6.5 Why rid.dwFlags = 0

  • RIDEV_NOLEGACY would block WM_MOUSEMOVE, breaking existing functionality that depends on absolute coordinates
  • RIDEV_INPUTSINK would allow unfocused windows to receive input, producing duplicate events
  • dwFlags = 0 keeps WM_INPUT and WM_MOUSEMOVE coexisting, serving relative mode and absolute mode respectively

6.6 Why Hook Master Device, Not Slave

SDL2's X11 backend uses XGrabPointer (Core protocol), which operates on the master pointer device (inputInfo.pointer, device 2 "Virtual core pointer"), not the slave device (device 6 "Windows pointer"). Hooking ActivateGrab/DeactivateGrab on the slave device would never trigger for Core grab.

6.7 Why Only Act When confineTo is Set

X11 automatically creates an implicit grab (confineTo = NULL) when a mouse button is pressed. An explicit grab (like SDL2 relative mode) sets confineTo = window. Both implicit and explicit grab Activate/Deactivate events are received by the hook function.

Correct approach: Only set ClipCursor + HideCursor when grab->confineTo != NULL; implicit grabs with confineTo = NULL perform no Windows-side cursor operations. Unconditionally calling HideCursor would incorrectly hide the cursor in scenarios where it should remain visible (normal mouse operation).

6.8 Why Use while(ShowCursor(FALSE) >= 0) Loop

ShowCursor is a reference-counted API. A single call may fail to hide the cursor when the initial count is greater than 0. The while loop pushes the count below zero, ensuring the cursor is hidden regardless of initial state. This is independent of the g_fCursor mechanism in winwndproc.c.

6.9 Defense Against Windows Silently Canceling ClipCursor

The 10ms timer is a pragmatic workaround given that VcXsrv cannot use SetCursorPos(center) (which would break X11 cursor semantics). Game engines typically reset the cursor to center every frame via SetCursorPos, so cursor skip is fundamentally not an issue for them.

6.10 Why Remove ClipCursor from WM_KILLFOCUS

The original code released ClipCursor in WM_KILLFOCUS. This is wrong: keyboard focus loss does not mean grab release. The grab remains active at the DIX layer, and the cursor should remain confined. WM_ACTIVATEAPP(deactivate) handles the legitimate case of entire application deactivation.


7. Verification

7.1 XInput2 Event Verification

xinput test-xi2 --root

Move the mouse. Output should show:

EVENT type 17 (RawMotion)
    device: 2 (6)
    detail: 0
    flags:
    valuators:
          0: 2.00 (2.00)
          1: -1.00 (-1.00)

Values should match actual mouse movement direction and speed.

7.2 Functional Test (AssaultCube)

Without setting the WARP variable:

  1. Launch AssaultCube
  2. Click to enter capture mode
  3. Cursor should be hidden and confined within the window
  4. Move mouse freely to control aim, should not escape
  5. Press Escape or click to release — cursor should restore and move freely

7.3 Edge Case Tests

Test Expected Result
Alt+Tab switch Cursor temporarily released during capture; re-locked upon return
Rapid mouse movement Occasional momentary escape, pulled back by timer within 10ms
Window edge No accidental resize (1px padding prevents reaching sizing border)
Non-capture program (xterm) Normal mouse behavior, unaffected

7.4 Regression Test

xterm &

Normal (non-capture) mouse behavior should be completely unaffected.

Clone this wiki locally