diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 4d6406ce7f..2acdb47305 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -4036,8 +4036,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } if (event.actionMasked == MotionEvent.ACTION_DOWN) lockedSwipeUsed = false if (programLocksPointer() && event.actionMasked == MotionEvent.ACTION_MOVE) { + if (lockedSwipeUsed) directTouchHold.cancelPendingHold() else directTouchHold.cancel() lockedSwipeUsed = true - directTouchHold.cancelPendingHold() val scale = sceneScale(rect) sendSceneDelta((event.x - swipeX) * scale, (event.y - swipeY) * scale) return true diff --git a/tools/gamescope/PATCHES.md b/tools/gamescope/PATCHES.md index 4736d32320..813325477b 100644 --- a/tools/gamescope/PATCHES.md +++ b/tools/gamescope/PATCHES.md @@ -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 @@ -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. diff --git a/tools/gamescope/patches/0133-constraint-relative-when-held.patch b/tools/gamescope/patches/0133-constraint-relative-when-held.patch new file mode 100644 index 0000000000..ef5de69142 --- /dev/null +++ b/tools/gamescope/patches/0133-constraint-relative-when-held.patch @@ -0,0 +1,173 @@ +diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp +--- a/src/steamcompmgr.cpp ++++ b/src/steamcompmgr.cpp +@@ -3165,6 +3165,128 @@ + 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; ++ static const steamcompmgr_win_t *s_pWindow = nullptr; ++ static const struct wlr_pointer_constraint_v1 *s_pConstraint = nullptr; ++ ++ 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; ++ } ++ ++ const steamcompmgr_win_t *pWindow = pFocus->inputFocusWindow; ++ const struct wlr_pointer_constraint_v1 *pConstraint = wlserver.GetCursorConstraint(); ++ if ( pWindow != s_pWindow || pConstraint != s_pConstraint ) ++ { ++ s_bTracking = false; ++ s_bHinted = false; ++ } ++ s_pWindow = pWindow; ++ s_pConstraint = pConstraint; ++ ++ if ( s_bTracking && bHinted && !s_bHinted ) ++ { ++ 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 +11151,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 touch_down_ids; diff --git a/tools/tests/test_gamescope_pointer_hold.py b/tools/tests/test_gamescope_pointer_hold.py new file mode 100644 index 0000000000..59b4eb7ea3 --- /dev/null +++ b/tools/tests/test_gamescope_pointer_hold.py @@ -0,0 +1,145 @@ +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 +#include +#include +#include +#include +''' + +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_a_new_window_or_constraint_starts_its_own_hold(self): + tracker = block(added_source(), "static void update_pointer_hold") + reset = tracker.index("if ( pWindow != s_pWindow || pConstraint != s_pConstraint )") + self.assertIn("const steamcompmgr_win_t *pWindow = pFocus->inputFocusWindow;", tracker) + self.assertIn("wlserver.GetCursorConstraint();", tracker) + self.assertLess(reset, tracker.index("if ( s_bTracking && bHinted && !s_bHinted )")) + self.assertIn("s_bTracking = false;", tracker[reset:reset + 120]) + + 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()