Skip to content
Open
11 changes: 11 additions & 0 deletions tools/gamescope/PATCHES.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ For games that confine a visible cursor and warp it, which the automatic rule ca
The rule only counted a lock with a cursor position hint. A game that hides the cursor and confines the
pointer through a grab (Wine's ClipCursor) has a constraint with no hint, so the nested pointer stayed
absolute, the constraint held the X pointer still and no raw motion was produced: the camera froze.
0133 narrows this to constraints whose game holds the X pointer.

## 0129 steamcompmgr, wayland: release a pointer lock at the game's pointer

Expand Down Expand Up @@ -331,3 +332,13 @@ With `--follow-game-window-mode`, the outer window of a nested SDL gamescope fol
asking for `_NET_WM_STATE_FULLSCREEN`, or a Steam game covering the X screen without minimise or maximise functions or
decorations in `_MOTIF_WM_HINTS`, makes the outer window fullscreen on the desktop. Anything else makes it a normal window of the
game window's size, or a maximised one when that size does not fit the desktop with its title bar and panel. A change is applied only when the wanted mode changes, so a manual resize or Super+F stays.

## 0133 steamcompmgr: a constraint without a hint is relative only while the game holds the X pointer

0128 made every hidden-cursor constraint relative, which also caught games that hide the system cursor, draw their own and
grab the pointer to their window (Balatro, Stardew Valley). Xwayland turns that grab into a lock without a cursor position
hint, and DroidDeck turns a direct touch swipe under a lock into camera motion, so dragging in those games only moved the
pointer. A constraint with a hint stays relative at once, as upstream. One without a hint goes relative once the X pointer
stops following the seat, moving less than a quarter of the seat's motion on three frames within half a second, as it does
when the game recentres it or clips it to a small rectangle (PEAK). It stops after 60 moving frames that the X pointer
follows again, or when the constraint ends. `tools/tests/test_gamescope_pointer_hold.py` compiles the rule from this patch.
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp
index c847a48..c759ff9 100644
--- a/src/steamcompmgr.cpp
+++ b/src/steamcompmgr.cpp
@@ -3165,6 +3165,89 @@
wlserver_unlock();
}

+struct PointerHold
+{
+ int nAwayFrames = 0;
+ int nFreeFrames = 0;
+ uint64_t ulLastAway = 0;
+ bool bHeld = false;
+};
+
+static bool pointer_hold_step( PointerHold &state, bool bUnhinted, double flSeatDX, double flSeatDY, double flPointerDX, double flPointerDY, uint64_t ulNow )
+{
+ if ( !bUnhinted )
+ {
+ state = PointerHold{};
+ return false;
+ }
+
+ const double flSeat = std::max( std::abs( flSeatDX ), std::abs( flSeatDY ) );
+ if ( flSeat < 4.0 )
+ return state.bHeld;
+
+ const double flMiss = std::max( std::abs( flSeatDX - flPointerDX ), std::abs( flSeatDY - flPointerDY ) );
+ if ( flMiss > flSeat * 0.75 )
+ {
+ if ( ulNow - state.ulLastAway > 500'000'000ul )
+ state.nAwayFrames = 0;
+ state.ulLastAway = ulNow;
+ state.nFreeFrames = 0;
+ if ( ++state.nAwayFrames >= 3 )
+ state.bHeld = true;
+ }
+ else if ( state.bHeld && ++state.nFreeFrames >= 60 )
+ {
+ state = PointerHold{};
+ }
+
+ return state.bHeld;
+}
+
+static bool game_holds_pointer( bool bHinted, bool bUnhinted, bool bHeld )
+{
+ return bHinted || ( bUnhinted && bHeld );
+}
+
+static bool s_bPointerHeld = false;
+
+static void update_pointer_hold( global_focus_t *pFocus, bool bUnhinted )
+{
+ static PointerHold s_Hold;
+ static bool s_bTracking = false;
+ static double s_flSeatX = 0.0;
+ static double s_flSeatY = 0.0;
+ static int s_nRootX = 0;
+ static int s_nRootY = 0;
+
+ if ( !bUnhinted || !pFocus->cursor )
+ {
+ s_bTracking = false;
+ s_bPointerHeld = pointer_hold_step( s_Hold, false, 0.0, 0.0, 0.0, 0.0, 0 );
+ return;
+ }
+
+ wlserver_lock();
+ const double flX = wlserver.mouse_surface_cursorx;
+ const double flY = wlserver.mouse_surface_cursory;
+ wlserver_unlock();
+
+ if ( s_bTracking && flX == s_flSeatX && flY == s_flSeatY )
+ return;
+
+ int nRootX = 0;
+ int nRootY = 0;
+ pFocus->cursor->QueryRootPosition( &nRootX, &nRootY );
+
+ if ( s_bTracking )
+ s_bPointerHeld = pointer_hold_step( s_Hold, true, flX - s_flSeatX, flY - s_flSeatY, nRootX - s_nRootX, nRootY - s_nRootY, get_time_in_nanos() );
+
+ s_bTracking = true;
+ s_flSeatX = flX;
+ s_flSeatY = flY;
+ s_nRootX = nRootX;
+ s_nRootY = nRootY;
+}
+
bool ShouldDrawCursor()
{
if ( relative_mouse_forced() )
@@ -11029,9 +11112,16 @@
nRelativeMouse != 2 && !bExcludedAppId && !bHasPointerConstraint && !window_is_steam( pPaintFocus->inputFocusWindow ) );
}

+ const bool bHintedConstraint = pPaintFocus->cursor && pPaintFocus->cursor->IsConstrained();
+ const bool bUnhintedConstraint = bHasPointerConstraint && !bHintedConstraint;
+ if ( vblank )
+ update_pointer_hold( pPaintFocus, bUnhintedConstraint && bImageEmpty && nRelativeMouse != 2 && !bExcludedAppId );
+
+ const bool bGameHoldsPointer = game_holds_pointer( bHintedConstraint, bUnhintedConstraint, s_bPointerHeld );
+
const bool bRelativeMouseMode = nRelativeMouse == 2
? false
- : ( bForcedRelativeMouse || s_bFollowClientPointer || ( bImageEmpty && bHasPointerConstraint && !bExcludedAppId ) );
+ : ( bForcedRelativeMouse || s_bFollowClientPointer || ( bImageEmpty && bGameHoldsPointer && !bExcludedAppId ) );

static bool s_bRelativeMouseMode = false;
if ( s_bRelativeMouseMode && !bRelativeMouseMode && pPaintFocus->cursor &&
121 changes: 121 additions & 0 deletions tools/tests/test_gamescope_pointer_hold.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest


ROOT = Path(__file__).resolve().parents[2]
PATCH = ROOT / "tools/gamescope/patches/0133-unhinted-constraint-relative-when-held.patch"


def added_source():
return "\n".join(line[1:] for line in PATCH.read_text().splitlines() if line.startswith("+") and not line.startswith("+++"))


def block(text, start):
begin = text.index(start)
return text[begin:text.index("\n}", begin) + 3]


def rules():
text = added_source()
return "\n".join([
block(text, "struct PointerHold"),
block(text, "static bool pointer_hold_step"),
block(text, "static bool game_holds_pointer"),
])


PREAMBLE = r'''
#include <algorithm>
#include <cassert>
#include <cmath>
#include <cstdint>
#include <cstdio>
'''

MAIN = r'''
static const uint64_t FRAME = 16'666'667ul;
static bool run(PointerHold &state, int frames, double seatDX, double seatDY, double pointerDX, double pointerDY, uint64_t &now, bool unhinted = true) {
bool held = state.bHeld;
for (int i = 0; i < frames; i++) {
now += FRAME;
held = pointer_hold_step(state, unhinted, seatDX, seatDY, pointerDX, pointerDY, now);
}
return held;
}
int main() {
uint64_t now = 1'000'000'000ul;
PointerHold cursorGame;
assert(!run(cursorGame, 600, 12.0, -9.0, 12.0, -9.0, now));
assert(!run(cursorGame, 600, 40.0, 3.0, 39.0, 3.0, now));
assert(!run(cursorGame, 1, 30.0, 0.0, 0.0, 0.0, now));
assert(!run(cursorGame, 1, 30.0, 0.0, 30.0, 0.0, now));
assert(!run(cursorGame, 1, 30.0, 0.0, 0.0, 0.0, now));
assert(!run(cursorGame, 120, 2.0, 1.0, 0.0, 0.0, now));
PointerHold offset;
assert(!run(offset, 600, 25.0, 25.0, 25.0, 25.0, now));
PointerHold recentring;
assert(!run(recentring, 2, -40.0, 12.0, 0.0, 0.0, now));
assert(run(recentring, 1, -40.0, 12.0, 1.0, 0.0, now));
assert(run(recentring, 600, 0.0, 0.0, 0.0, 0.0, now));
assert(run(recentring, 59, 10.0, 4.0, 10.0, 4.0, now));
assert(run(recentring, 1, 20.0, 0.0, 0.0, 0.0, now));
assert(run(recentring, 59, 10.0, 4.0, 10.0, 4.0, now));
assert(!run(recentring, 1, 10.0, 4.0, 10.0, 4.0, now));
PointerHold clipped;
assert(run(clipped, 3, 0.0, 30.0, 0.0, 2.0, now));
PointerHold spaced;
for (int i = 0; i < 10; i++) {
assert(!run(spaced, 1, 50.0, 0.0, 0.0, 0.0, now));
now += 600'000'000ul;
}
PointerHold released;
assert(run(released, 3, 0.0, -25.0, 0.0, 0.0, now));
assert(!run(released, 1, 0.0, -25.0, 0.0, 0.0, now, false));
assert(!run(released, 2, 0.0, -25.0, 0.0, 0.0, now));
assert(game_holds_pointer(true, false, false));
assert(game_holds_pointer(false, true, true));
assert(!game_holds_pointer(false, true, false));
assert(!game_holds_pointer(false, false, true));
puts("ok");
return 0;
}
'''


@unittest.skipUnless(shutil.which("c++"), "needs a C++ compiler")
class PointerHoldTest(unittest.TestCase):
def test_only_a_game_that_holds_the_x_pointer_goes_relative(self):
with tempfile.TemporaryDirectory() as tmp:
source = Path(tmp) / "hold.cpp"
binary = Path(tmp) / "hold"
source.write_text(PREAMBLE + rules() + MAIN)
subprocess.run(["c++", "-std=c++17", "-Wall", "-Wextra", "-Werror", str(source), "-o", str(binary)], check=True)
self.assertEqual(subprocess.run([str(binary)], capture_output=True, text=True, check=True).stdout.strip(), "ok")

def test_a_hinted_constraint_stays_relative_and_an_unhinted_one_waits_for_the_hold(self):
added = [line.strip() for line in added_source().splitlines()]
self.assertIn("const bool bHintedConstraint = pPaintFocus->cursor && pPaintFocus->cursor->IsConstrained();", added)
self.assertIn("const bool bUnhintedConstraint = bHasPointerConstraint && !bHintedConstraint;", added)
self.assertIn("const bool bGameHoldsPointer = game_holds_pointer( bHintedConstraint, bUnhintedConstraint, s_bPointerHeld );", added)
self.assertIn(": ( bForcedRelativeMouse || s_bFollowClientPointer || ( bImageEmpty && bGameHoldsPointer && !bExcludedAppId ) );", added)

def test_the_forced_and_follow_rules_are_unchanged(self):
removed = [line[1:].strip() for line in PATCH.read_text().splitlines() if line.startswith("-") and not line.startswith("---")]
self.assertEqual(removed, [": ( bForcedRelativeMouse || s_bFollowClientPointer || ( bImageEmpty && bHasPointerConstraint && !bExcludedAppId ) );"])


if __name__ == "__main__":
unittest.main()
Loading