Mac games and cursor warping weirdness

On the first internal test of Uru for Mac, I got some weird feedback from testers. As the player character was walking, the game wasn’t responding smoothly to the mouse. As the player moved the mouse to turn the player, the player would turn in fits and starts.

The way Uru handles the mouse is pretty common for games, especially third person games. You hold down the mouse button, and then move the mouse left or right to make the character move to the left of the right. The mouse cursor is hidden – but the cursor is still there under the hood. That means the cursor may run into the side of the screen, which would cause the cursor not to move and the character to stop moving. To get around that, if the mouse reaches the side of the screen, it’s moved back to center. Effectively this allows for infinite turning of the character without worrying about the mouse hitting the screen edges.

On macOS, the cursor can be re-centered using the command CGWarpMouseCursorPosition. The docs even mention this exact use case!

You can use this function to “warp” or alter the cursor position without generating or posting an event. For example, this function is often used to move the cursor position back to the center of the screen by games that do not want the cursor pinned by display edges.

I created a build of Uru that always showed the macOS cursor, and then ran it. And indeed, there was something wrong with the native mouse handling. Every time I used CGWarpMouseCursorPosition the mouse seemed stuck momentarily. I kept my mouse movements even, but the cursor seemed to not move for a split second after warping. This was causing the chunky mouse behavior testers were noticing.

See the mouse sticking on reset?

My first hunch was still mouse acceleration. Maybe the warp function was causing mouse acceleration to reset. That would cause the cursor to start moving slowly again every warp until it reached speed. But when logging the cursor’s X position I found something even weirder. After warp, the cursor wasn’t moving at all.

2023-02-05 12:21:03.912695-0800 plClient[56084:1669557] Reset!
2023-02-05 12:21:03.927519-0800 plClient[56084:1669557] X pos: 0.500000
2023-02-05 12:21:03.927660-0800 plClient[56084:1669557] X pos: 0.500000
2023-02-05 12:21:03.944247-0800 plClient[56084:1669557] X pos: 0.500000
2023-02-05 12:21:03.960842-0800 plClient[56084:1669557] X pos: 0.500000
2023-02-05 12:21:03.977446-0800 plClient[56084:1669557] X pos: 0.500000
2023-02-05 12:21:03.994232-0800 plClient[56084:1669557] X pos: 0.500000
2023-02-05 12:21:04.011021-0800 plClient[56084:1669557] X pos: 0.500000
2023-02-05 12:21:04.015652-0800 plClient[56084:1669557] X pos: 0.500000
2023-02-05 12:21:04.027695-0800 plClient[56084:1669557] X pos: 0.500000
2023-02-05 12:21:04.031597-0800 plClient[56084:1669557] X pos: 0.500000
2023-02-05 12:21:04.044252-0800 plClient[56084:1669557] X pos: 0.500000
2023-02-05 12:21:04.047567-0800 plClient[56084:1669557] X pos: 0.500000
2023-02-05 12:21:04.060862-0800 plClient[56084:1669557] X pos: 0.500000
2023-02-05 12:21:04.063555-0800 plClient[56084:1669557] X pos: 0.500000
2023-02-05 12:21:04.077461-0800 plClient[56084:1669557] X pos: 0.500000
2023-02-05 12:21:04.079589-0800 plClient[56084:1669557] X pos: 0.500000
2023-02-05 12:21:04.096340-0800 plClient[56084:1669557] X pos: 0.500000
2023-02-05 12:21:04.111001-0800 plClient[56084:1669557] X pos: 0.500000
2023-02-05 12:21:04.113572-0800 plClient[56084:1669557] X pos: 0.500000
2023-02-05 12:21:04.127380-0800 plClient[56084:1669557] X pos: 0.500000
2023-02-05 12:21:04.129576-0800 plClient[56084:1669557] X pos: 0.500000
2023-02-05 12:21:04.144342-0800 plClient[56084:1669557] X pos: 0.500000
2023-02-05 12:21:04.146081-0800 plClient[56084:1669557] X pos: 0.500000
2023-02-05 12:21:04.160777-0800 plClient[56084:1669557] X pos: 0.500000
2023-02-05 12:21:04.162525-0800 plClient[56084:1669557] X pos: 0.500000
2023-02-05 12:21:04.177489-0800 plClient[56084:1669557] X pos: 0.518046
2023-02-05 12:21:04.177664-0800 plClient[56084:1669557] X pos: 0.537489
2023-02-05 12:21:04.194020-0800 plClient[56084:1669557] X pos: 0.574978
2023-02-05 12:21:04.210724-0800 plClient[56084:1669557] X pos: 0.606649
2023-02-05 12:21:04.227363-0800 plClient[56084:1669557] X pos: 0.654614
2023-02-05 12:21:04.243930-0800 plClient[56084:1669557] X pos: 0.704136
2023-02-05 12:21:04.260511-0800 plClient[56084:1669557] X pos: 0.761408
2023-02-05 12:21:04.265584-0800 plClient[56084:1669557] X pos: 0.782302
2023-02-05 12:21:04.277291-0800 plClient[56084:1669557] X pos: 0.801745
2023-02-05 12:21:04.281584-0800 plClient[56084:1669557] X pos: 0.834301
2023-02-05 12:21:04.293937-0800 plClient[56084:1669557] X pos: 0.852347
2023-02-05 12:21:04.297583-0800 plClient[56084:1669557] X pos: 0.871790
2023-02-05 12:21:04.312268-0800 plClient[56084:1669557] X pos: 0.891234
2023-02-05 12:21:04.313994-0800 plClient[56084:1669557] X pos: 0.912127

When resetting/warping the cursor, the cursor stays stuck at the midpoint of 0.5 for a full 0.25 seconds. It’s not slowly accelerating. The cursor actually isn’t moving at all after a warp.

I was fortunate enough to find this Stack Overflow describing exactly whats going on. It seems that, after warping the cursor position, macOS will ignore hardware mouse events for 0.25 seconds. The CGEventSourceSetLocalEventsSuppressionInterval can be used to control this behavior.

By default, the system does not suppress local hardware events from the keyboard or mouse during a short interval after a Quartz event is posted. You can use the function CGEventSourceSetLocalEventsFilterDuringSuppressionState to modify this behavior.

This function sets the period of time in seconds that local hardware events may be suppressed after posting a Quartz event created with the specified event source. The default suppression interval is 0.25 seconds.

Note that the documentation seems contradictory. It says that by default the system does not suppress local hardware events. The last paragraph is actually correct. By default the system will suppress hardware events for 0.25 seconds after a warp.

With the following C code, we can fix the issue and get the desired behavior.

CGEventSourceRef eventSourceRef = CGEventSourceCreate(kCGEventSourceStateCombinedSessionState);
CGEventSourceSetLocalEventsSuppressionInterval(eventSourceRef, 0.0);
CFRelease(eventSourceRef);

So – if you are porting a Windows game to macOS and getting weird behavior with CGWarpMouseCursorPosition – CGEventSourceSetLocalEventsSuppressionInterval might be your fix.