Skip to content
Open
13 changes: 13 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, and the upstream hint rule, to games that hold the X pointer.

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

Expand Down Expand Up @@ -331,3 +332,15 @@ 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, wlserver: a pointer constraint is relative only while the game holds the X pointer

A game that hides the system cursor, draws its own and grabs the pointer to its window (Stardew Valley, Balatro) gets a lock
from Xwayland, which emulates the game's warps with a cursor position hint. Upstream kept any hinted lock relative and 0128
added every other constraint, so gamescope held the host's pointer lock and DroidDeck turned every direct touch swipe into
camera motion: dragging in those games only moved their cursor. Without relative mode such a lock still follows gamescope's
relative motion and touches still place the pointer. gamescope now compares the motion it sends (seat movement, or relative
motion while the seat is locked) with how the X pointer moves. A hinted constraint starts relative, as upstream, also when its hint arrives with the game's first warp, and is released
after 30 moving frames on which the X pointer follows. Any constraint goes relative once the X pointer moves less than a quarter
of that motion on three frames within half a second, as it does when the game recentres it or clips it to a small rectangle
(PEAK). `tools/tests/test_gamescope_pointer_hold.py` compiles the rule from this patch.
161 changes: 161 additions & 0 deletions tools/gamescope/patches/0133-constraint-relative-when-held.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp
--- a/src/steamcompmgr.cpp
+++ b/src/steamcompmgr.cpp
@@ -3165,6 +3165,116 @@
wlserver_unlock();
}

+struct PointerHold
+{
+ int nAwayFrames = 0;
+ int nFreeFrames = 0;
+ uint64_t ulFirstAway = 0;
+ bool bHeld = false;
+};
+
+static bool pointer_hold_step( PointerHold &state, bool bConstrained, double flSeatDX, double flSeatDY, double flPointerDX, double flPointerDY, uint64_t ulNow )
+{
+ if ( !bConstrained )
+ {
+ 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 ( state.nAwayFrames == 0 || ulNow - state.ulFirstAway > 500'000'000ul )
+ {
+ state.nAwayFrames = 0;
+ state.ulFirstAway = ulNow;
+ }
+ state.nFreeFrames = 0;
+ if ( ++state.nAwayFrames >= 3 )
+ state.bHeld = true;
+ }
+ else if ( state.bHeld && ++state.nFreeFrames >= 30 )
+ {
+ state = PointerHold{};
+ }
+
+ return state.bHeld;
+}
+
+static bool s_bPointerHeld = false;
+
+static void update_pointer_hold( global_focus_t *pFocus, bool bConstrained, bool bHinted )
+{
+ 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;
+ static bool s_bHinted = false;
+
+ wlserver_lock();
+ const double flX = wlserver.mouse_surface_cursorx;
+ const double flY = wlserver.mouse_surface_cursory;
+ const double flSentX = wlserver.flSentDX;
+ const double flSentY = wlserver.flSentDY;
+ wlserver.flSentDX = 0.0;
+ wlserver.flSentDY = 0.0;
+ wlserver_unlock();
+
+ if ( !bConstrained || !pFocus->cursor )
+ {
+ s_bTracking = false;
+ s_bHinted = false;
+ s_bPointerHeld = pointer_hold_step( s_Hold, false, 0.0, 0.0, 0.0, 0.0, 0 );
+ return;
+ }
+
+ if ( s_bTracking && bHinted && !s_bHinted )
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
+ {
+ s_Hold = PointerHold{};
+ s_Hold.bHeld = true;
+ s_bPointerHeld = true;
+ }
+ s_bHinted = bHinted;
+
+ double flDX = flX - s_flSeatX;
+ double flDY = flY - s_flSeatY;
+ if ( flDX == 0.0 && flDY == 0.0 )
+ {
+ flDX = flSentX;
+ flDY = flSentY;
+ }
+
+ if ( s_bTracking && flDX == 0.0 && flDY == 0.0 )
+ return;
+
+ int nRootX = 0;
+ int nRootY = 0;
+ pFocus->cursor->QueryRootPosition( &nRootX, &nRootY );
+
+ if ( s_bTracking )
+ {
+ s_bPointerHeld = pointer_hold_step( s_Hold, true, flDX, flDY, nRootX - s_nRootX, nRootY - s_nRootY, get_time_in_nanos() );
+ }
+ else
+ {
+ s_Hold = PointerHold{};
+ s_Hold.bHeld = bHinted;
+ s_bPointerHeld = bHinted;
+ }
+
+ s_bTracking = true;
+ s_flSeatX = flX;
+ s_flSeatY = flY;
+ s_nRootX = nRootX;
+ s_nRootY = nRootY;
+}
+
bool ShouldDrawCursor()
{
if ( relative_mouse_forced() )
@@ -11029,9 +11139,15 @@
nRelativeMouse != 2 && !bExcludedAppId && !bHasPointerConstraint && !window_is_steam( pPaintFocus->inputFocusWindow ) );
}

+ if ( vblank )
+ update_pointer_hold( pPaintFocus, bHasPointerConstraint && bImageEmpty && nRelativeMouse != 2 && !bExcludedAppId,
+ pPaintFocus->cursor && pPaintFocus->cursor->IsConstrained() );
+
+ const bool bGameHoldsPointer = bHasPointerConstraint && 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 &&
diff --git a/src/wlserver.cpp b/src/wlserver.cpp
--- a/src/wlserver.cpp
+++ b/src/wlserver.cpp
@@ -346,6 +346,8 @@
{
assert( wlserver_is_lock_held() );

+ wlserver.flSentDX += unaccel_dx;
+ wlserver.flSentDY += unaccel_dy;
wlr_relative_pointer_manager_v1_send_relative_motion( wlserver.relative_pointer_manager, wlserver.wlr.seat, 0, unaccel_dx, unaccel_dy, unaccel_dx, unaccel_dy );
}

diff --git a/src/wlserver.hpp b/src/wlserver.hpp
--- a/src/wlserver.hpp
+++ b/src/wlserver.hpp
@@ -171,6 +171,8 @@
bool bFollowClientPointer = false;
double flFollowDX = 0.0;
double flFollowDY = 0.0;
+ double flSentDX = 0.0;
+ double flSentDY = 0.0;

bool button_held[ WLSERVER_BUTTON_COUNT ];
std::set <uint32_t> touch_down_ids;
137 changes: 137 additions & 0 deletions tools/tests/test_gamescope_pointer_hold.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest


ROOT = Path(__file__).resolve().parents[2]
PATCH = ROOT / "tools/gamescope/patches/0133-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"),
])


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 constrained = true) {
bool held = state.bHeld;
for (int i = 0; i < frames; i++) {
now += FRAME;
held = pointer_hold_step(state, constrained, 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, 29, 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, 29, 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 hinted;
hinted.bHeld = true;
assert(run(hinted, 29, 0.0, 15.0, 0.0, 15.0, now));
assert(!run(hinted, 1, 0.0, 15.0, 0.0, 15.0, now));

PointerHold hintedCamera;
hintedCamera.bHeld = true;
assert(run(hintedCamera, 600, 30.0, -8.0, 0.0, 0.0, now));

PointerHold chained;
for (int i = 0; i < 6; i++) {
assert(!run(chained, 1, 50.0, 0.0, 0.0, 0.0, now));
now += 400'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));

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_starts_relative_and_others_wait_for_the_hold(self):
added = [line.strip() for line in added_source().splitlines()]
self.assertIn("update_pointer_hold( pPaintFocus, bHasPointerConstraint && bImageEmpty && nRelativeMouse != 2 && !bExcludedAppId,", added)
self.assertIn("pPaintFocus->cursor && pPaintFocus->cursor->IsConstrained() );", added)
self.assertIn("s_Hold.bHeld = bHinted;", added)
self.assertIn("if ( s_bTracking && bHinted && !s_bHinted )", added)
self.assertIn("const bool bGameHoldsPointer = bHasPointerConstraint && s_bPointerHeld;", added)
self.assertIn(": ( bForcedRelativeMouse || s_bFollowClientPointer || ( bImageEmpty && bGameHoldsPointer && !bExcludedAppId ) );", added)

def test_the_motion_sent_to_clients_is_counted(self):
added = [line.strip() for line in added_source().splitlines()]
self.assertIn("wlserver.flSentDX += unaccel_dx;", added)
self.assertIn("wlserver.flSentDY += unaccel_dy;", 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