From e7af0f24a1ffb1a34a4b536d9be251f0c59ddfba Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Wed, 23 Sep 2026 06:02:13 -0400 Subject: [PATCH 1/4] proot: find tracees by pid, resolve deep paths at once, share clone3 threads' state Every stop looked its tracee up, and swept for terminated ones, by walking the list of all tracees - one per thread, so hundreds to thousands in a Steam session. Tracees are now hashed by pid, and the sweep runs only after a termination. Canonicalizing a guest path lstat()ed every component, with an ever longer host path. For a clean path under the rootfs binding alone, the directory holding the last component is now opened once and taken as canonical when the kernel's path for it is the one PRoot would build; anything else walks component by component as before. The final component of a call that does not follow it is no longer lstat()ed, its result was never used. clone3(2) was unknown, so the threads glibc 2.34+ creates with it got their own copy of the cwd and emulated heap: a chdir() in one thread was invisible to the others. Its flags are now read from struct clone_args. --- app/src/main/cpp/proot/src/cli/cli.c | 2 +- app/src/main/cpp/proot/src/path/canon.c | 127 +++++++++++++++++- .../main/cpp/proot/src/syscall/sysnums-arm.h | 1 + .../cpp/proot/src/syscall/sysnums-arm64.h | 1 + .../main/cpp/proot/src/syscall/sysnums.list | 1 + app/src/main/cpp/proot/src/tracee/event.c | 2 +- app/src/main/cpp/proot/src/tracee/tracee.c | 39 +++++- app/src/main/cpp/proot/src/tracee/tracee.h | 4 + 8 files changed, 167 insertions(+), 10 deletions(-) diff --git a/app/src/main/cpp/proot/src/cli/cli.c b/app/src/main/cpp/proot/src/cli/cli.c index 71a17648e..9c967ccc2 100644 --- a/app/src/main/cpp/proot/src/cli/cli.c +++ b/app/src/main/cpp/proot/src/cli/cli.c @@ -385,7 +385,7 @@ int main(int argc, char *const argv[]) { tracee = get_tracee(NULL, 0, true); if (tracee == NULL) goto error; - tracee->pid = getpid(); + set_tracee_pid(tracee, getpid()); /* Set verboseness from env variable, may be overriden by option */ { diff --git a/app/src/main/cpp/proot/src/path/canon.c b/app/src/main/cpp/proot/src/path/canon.c index 08c6fe6fc..c17756d2e 100644 --- a/app/src/main/cpp/proot/src/path/canon.c +++ b/app/src/main/cpp/proot/src/path/canon.c @@ -22,6 +22,7 @@ #include /* assert(3), */ #include /* E*, */ +#include /* open(2), O_*, */ #include /* PATH_MAX, */ #include /* sscanf(3), */ #include /* string(3), */ @@ -153,6 +154,98 @@ static inline int substitute_binding_stat(Tracee *tracee, Finality finality, return (S_ISLNK(statl.st_mode) ? 1 : 0); } +/* Below this many directories, lstat(2)-ing each costs less than + * resolve_parent_at_once(). */ +#define RESOLVE_AT_ONCE_MIN_DEPTH 3 + +/** + * Canonicalize at once the directory holding the last component of + * the absolute @user_path, instead of with an lstat(2) per component: + * the kernel resolves it, and it is canonical if the kernel's path is + * the one PRoot would build, ie. no link on the way. Only for a + * lexically clean path under the rootfs binding alone. This function + * returns the offset of the last component in @user_path and puts the + * directory in @guest_path, or returns 0 when the component-wise walk + * has to be used. + */ +static size_t resolve_parent_at_once(Tracee *tracee, const char *user_path, + char guest_path[PATH_MAX]) { + char parent[PATH_MAX]; + char expected[PATH_MAX]; + char resolved[PATH_MAX]; + char link[32]; + const Binding *binding; + const char *last; + const char *cursor; + size_t parent_length; + size_t prefix_length; + size_t expected_length; + ssize_t length; + int depth; + int fd; + + if (tracee->glue_type != 0) + return 0; + + last = strrchr(user_path, '/'); + if (last == NULL || last == user_path || last[1] == '\0' || + strcmp(last + 1, ".") == 0 || strcmp(last + 1, "..") == 0) + return 0; + + parent_length = last - user_path; + if (parent_length >= PATH_MAX) + return 0; + + /* No empty, "." or ".." component. */ + depth = 0; + for (cursor = user_path; cursor < last;) { + const char *end; + + end = memchr(cursor + 1, '/', last - cursor); + if (end == NULL) + end = last; + if (end == cursor + 1 || (end == cursor + 2 && cursor[1] == '.') || + (end == cursor + 3 && cursor[1] == '.' && cursor[2] == '.')) + return 0; + depth++; + cursor = end; + } + if (depth < RESOLVE_AT_ONCE_MIN_DEPTH) + return 0; + + memcpy(parent, user_path, parent_length); + parent[parent_length] = '\0'; + + /* Bindings are ordered deepest first, so the rootfs binding + * matching means no other binding is on the way. */ + binding = get_binding(tracee, GUEST, parent); + if (binding == NULL || binding->guest.length != 1) + return 0; + + /* A rootfs at "/" adds no prefix. */ + prefix_length = (binding->host.length == 1 ? 0 : binding->host.length); + expected_length = prefix_length + parent_length; + if (expected_length >= PATH_MAX) + return 0; + memcpy(expected, binding->host.path, prefix_length); + strcpy(expected + prefix_length, parent); + + fd = open(expected, O_PATH | O_DIRECTORY | O_CLOEXEC); + if (fd < 0) + return 0; + + snprintf(link, sizeof(link), "/proc/self/fd/%d", fd); + length = readlink(link, resolved, sizeof(resolved)); + close(fd); + + if (length < 0 || (size_t)length != expected_length || + memcmp(resolved, expected, expected_length) != 0) + return 0; + + strcpy(guest_path, parent); + return last + 1 - user_path; +} + /** * Copy in @guest_path the canonicalization (see `man 3 realpath`) of * @user_path regarding to @tracee->root. The path to canonicalize @@ -169,6 +262,7 @@ int canonicalize(Tracee *tracee, const char *user_path, bool deref_final, char host_path[PATH_MAX]; Finality finality; const char *cursor; + size_t offset; int status; /* Avoid infinite loop on circular links. */ @@ -183,15 +277,20 @@ int canonicalize(Tracee *tracee, const char *user_path, bool deref_final, } else strcpy(guest_path, "/"); - /* Resolve bindings for the initial '/' component or user_path, - * which is not handled in the loop below. - * In particular HOST_PATH extensions are called from there. */ - status = substitute_binding_stat(tracee, NOT_FINAL, guest_path, host_path); - if (status < 0) - return status; + offset = (user_path[0] == '/' + ? resolve_parent_at_once(tracee, user_path, guest_path) + : 0); + if (offset == 0) { + /* Resolve bindings for the initial '/' component or user_path, + * which is not handled in the loop below. + * In particular HOST_PATH extensions are called from there. */ + status = substitute_binding_stat(tracee, NOT_FINAL, guest_path, host_path); + if (status < 0) + return status; + } /* Canonicalize recursely 'user_path' into 'guest_path'. */ - cursor = user_path; + cursor = user_path + offset; finality = NOT_FINAL; while (!IS_FINAL(finality)) { Comparison comparison; @@ -217,6 +316,20 @@ int canonicalize(Tracee *tracee, const char *user_path, bool deref_final, join_paths(scratch_path, guest_path, component); + /* A final component that is not dereferenced needs no + * lstat(2): whether it is a link only matters to follow + * it. Glue is still built from that lstat(2). */ + if (finality == FINAL_NORMAL && !deref_final && tracee->glue_type == 0) { + strcpy(host_path, scratch_path); + status = substitute_binding(tracee, GUEST, host_path); + if (status < 0) + return status; + + strcpy(scratch_path, guest_path); + join_paths(guest_path, scratch_path, component); + continue; + } + /* Resolve bindings and check that a non-final * component exists and either is a directory or is a * symlink. For this latter case, we check that the diff --git a/app/src/main/cpp/proot/src/syscall/sysnums-arm.h b/app/src/main/cpp/proot/src/syscall/sysnums-arm.h index 1cf64c4f6..95a8efbab 100644 --- a/app/src/main/cpp/proot/src/syscall/sysnums-arm.h +++ b/app/src/main/cpp/proot/src/syscall/sysnums-arm.h @@ -356,5 +356,6 @@ static const Sysnum sysnums_arm[] = { [395] = PR_pkey_alloc, [396] = PR_pkey_free, [397] = PR_statx, + [435] = PR_clone3, [439] = PR_faccessat2, }; diff --git a/app/src/main/cpp/proot/src/syscall/sysnums-arm64.h b/app/src/main/cpp/proot/src/syscall/sysnums-arm64.h index a73583e24..b04a2e55b 100644 --- a/app/src/main/cpp/proot/src/syscall/sysnums-arm64.h +++ b/app/src/main/cpp/proot/src/syscall/sysnums-arm64.h @@ -278,5 +278,6 @@ static const Sysnum sysnums_arm64[] = { [289] = PR_pkey_alloc, [290] = PR_pkey_free, [291] = PR_statx, + [435] = PR_clone3, [439] = PR_faccessat2, }; diff --git a/app/src/main/cpp/proot/src/syscall/sysnums.list b/app/src/main/cpp/proot/src/syscall/sysnums.list index 1010504e9..1651352a3 100644 --- a/app/src/main/cpp/proot/src/syscall/sysnums.list +++ b/app/src/main/cpp/proot/src/syscall/sysnums.list @@ -39,6 +39,7 @@ SYSNUM(clock_gettime) SYSNUM(clock_nanosleep) SYSNUM(clock_settime) SYSNUM(clone) +SYSNUM(clone3) SYSNUM(close) SYSNUM(connect) SYSNUM(copy_file_range) diff --git a/app/src/main/cpp/proot/src/tracee/event.c b/app/src/main/cpp/proot/src/tracee/event.c index d5041c3ce..2e7141a9d 100644 --- a/app/src/main/cpp/proot/src/tracee/event.c +++ b/app/src/main/cpp/proot/src/tracee/event.c @@ -101,7 +101,7 @@ int launch_process(Tracee *tracee, char *const argv[]) { default: /* parent */ /* We know the pid of the first tracee now. */ - tracee->pid = pid; + set_tracee_pid(tracee, pid); return 0; } diff --git a/app/src/main/cpp/proot/src/tracee/tracee.c b/app/src/main/cpp/proot/src/tracee/tracee.c index e93be3eef..8e35d23ef 100644 --- a/app/src/main/cpp/proot/src/tracee/tracee.c +++ b/app/src/main/cpp/proot/src/tracee/tracee.c @@ -40,6 +40,7 @@ #include "ptrace/wait.h" #include "syscall/sysnum.h" #include "tracee/event.h" +#include "tracee/mem.h" #include "tracee/reg.h" #include "tracee/tracee.h" @@ -47,6 +48,18 @@ static Tracees tracees; +/* Tracees by pid: every event looks its tracee up, and a session has + * a tracee per thread. */ +#define TRACEE_BUCKETS 1024 +static Tracees buckets[TRACEE_BUCKETS]; + +/* Whether a tracee was terminated since the last sweep. */ +static bool terminations_pending = false; + +static Tracees *bucket_of(pid_t pid) { + return &buckets[(unsigned int)pid % TRACEE_BUCKETS]; +} + /** * Remove @zombie from its parent's list of zombies. Note: this is a * talloc destructor. @@ -85,6 +98,7 @@ static int remove_tracee(Tracee *tracee) { int event; LIST_REMOVE(tracee, link); + LIST_REMOVE(tracee, bucket_link); /* Clean objects that are linked to this tracee's life * span. */ @@ -217,6 +231,7 @@ static Tracee *new_tracee(pid_t pid) { tracee->pid = pid; LIST_INSERT_HEAD(&tracees, tracee, link); + LIST_INSERT_HEAD(bucket_of(pid), tracee, bucket_link); tracee->life_context = talloc_new(tracee); @@ -314,7 +329,7 @@ Tracee *get_tracee(const Tracee *current_tracee, pid_t pid, bool create) { if (current_tracee != NULL && current_tracee->pid == pid) return (Tracee *)current_tracee; - LIST_FOREACH(tracee, &tracees, link) { + LIST_FOREACH(tracee, bucket_of(pid), bucket_link) { if (tracee->pid == pid) { /* Flush then allocate a new memory collector. */ TALLOC_FREE(tracee->ctx); @@ -327,11 +342,21 @@ Tracee *get_tracee(const Tracee *current_tracee, pid_t pid, bool create) { return (create ? new_tracee(pid) : NULL); } +/** + * Change the pid of @tracee, which get_tracee() looks it up by. + */ +void set_tracee_pid(Tracee *tracee, pid_t pid) { + tracee->pid = pid; + LIST_REMOVE(tracee, bucket_link); + LIST_INSERT_HEAD(bucket_of(pid), tracee, bucket_link); +} + /** * Mark tracee as terminated and optionally take action. */ void terminate_tracee(Tracee *tracee) { tracee->terminated = true; + terminations_pending = true; /* Case where the terminated tracee is marked to kill all tracees on exit. @@ -348,6 +373,10 @@ void terminate_tracee(Tracee *tracee) { void free_terminated_tracees() { Tracee *next; + if (!terminations_pending) + return; + terminations_pending = false; + /* Items can't be deleted when using LIST_FOREACH. */ next = tracees.lh_first; while (next != NULL) { @@ -383,6 +412,14 @@ int new_child(Tracee *parent, word_t clone_flags) { status = fetch_regs(parent); if (status >= 0 && get_sysnum(parent, CURRENT) == PR_clone) clone_flags = peek_reg(parent, CURRENT, SYSARG_1); + else if (status >= 0 && get_sysnum(parent, CURRENT) == PR_clone3) { + /* clone3(2) passes the usual flags as the first word of + * its struct clone_args. glibc 2.34 and later create + * every thread with it. */ + word_t flags = peek_word(parent, peek_reg(parent, CURRENT, SYSARG_1)); + if (errno == 0) + clone_flags = flags; + } /* Get the pid of the parent's new child. */ status = ptrace(PTRACE_GETEVENTMSG, parent->pid, NULL, &pid); diff --git a/app/src/main/cpp/proot/src/tracee/tracee.h b/app/src/main/cpp/proot/src/tracee/tracee.h index 9f1f9cb38..4012a01da 100644 --- a/app/src/main/cpp/proot/src/tracee/tracee.h +++ b/app/src/main/cpp/proot/src/tracee/tracee.h @@ -79,6 +79,9 @@ typedef struct tracee { /* Link for the list of all tracees. */ LIST_ENTRY(tracee) link; + /* Link for the tracees whose pid hashes alike. */ + LIST_ENTRY(tracee) bucket_link; + /* Process identifier. */ pid_t pid; @@ -257,6 +260,7 @@ typedef struct tracee { #define TRACEE(a) talloc_get_type_abort(talloc_parent(talloc_parent(a)), Tracee) extern Tracee *get_tracee(const Tracee *tracee, pid_t pid, bool create); +extern void set_tracee_pid(Tracee *tracee, pid_t pid); extern Tracee *get_stopped_ptracee(const Tracee *ptracer, pid_t pid, bool only_with_pevent, word_t wait_options); extern bool has_ptracees(const Tracee *ptracer, pid_t pid, word_t wait_options); From bbc5f864751da846e5e7e321e47508fa79814b4b Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Wed, 23 Sep 2026 22:14:09 -0400 Subject: [PATCH 2/4] input: fix touchscreen gestures, mouse wheel, D-pad keys, guide tap and GameScope pad hotplug - Touchscreen mode tracks pointers by id and delays the press 50 ms, so a second finger starts a two-finger scroll or right-click tap instead of a click; cancel and detach release every held button. - Uncaptured mice move through one path with scaled relative deltas; fractional wheel values accumulate into whole notches; captured touchpads use position differences. - Relative mouse mode no longer delivers buttons and wheel twice in Wayland sessions. - D-pad keycodes set the D-pad (they set unused button bits before), and controller key repeats no longer move Android focus. - Guide: a tap reaches the guest (Steam opens its menu), a 2 s hold opens the WinNative menu. - GameScope: pad slots outlive devices, since the guest never learns of a pad added mid-game; a disconnect leaves the slot neutral for the next pad. - fakeinput waits at most 2 ms between ring checks (was 16 ms). --- .../linuxfs/usr/local/lib/libwninput.so | Bin 68280 -> 68280 bytes app/src/main/cpp/winlator/fakeinput.cpp | 10 +- .../display/XServerDisplayActivity.java | 49 ++- .../display/winhandler/WinHandler.java | 65 ++- .../display/xserver/InputDeviceManager.java | 9 +- .../main/runtime/display/xserver/XServer.java | 4 + .../input/controls/ExternalController.java | 44 +- .../runtime/input/ui/InputControlsView.java | 9 +- app/src/main/runtime/input/ui/TouchpadView.kt | 416 +++++++++++++----- 9 files changed, 434 insertions(+), 172 deletions(-) diff --git a/app/src/main/assets/linuxfs/usr/local/lib/libwninput.so b/app/src/main/assets/linuxfs/usr/local/lib/libwninput.so index ce29ca7783afc621e81c304da2e7ded6c3aeb41d..a179e8f0d4afa5aed4a84aafdd534b145f7f1107 100755 GIT binary patch delta 63 zcmV-F0Kosal?1qz1hCiv6tHp4_N5N&lLjo7QFd_M2uw5Ut+VU_Ia2`(vnEv=2L+Fi V(!m3>MqCpR1PB0eAhTU;Tb^++865xs delta 63 zcmV-F0Kosal?1qz1hCiv6pJ$=Bpv!Z@CyO=*))ZXG4p|>7_;mFIa2{cvnEv=2L*qG V(t#ecMqCpR1Uvw7EVEr~Tb?i&7!3db diff --git a/app/src/main/cpp/winlator/fakeinput.cpp b/app/src/main/cpp/winlator/fakeinput.cpp index d09365167..ea54a96e0 100644 --- a/app/src/main/cpp/winlator/fakeinput.cpp +++ b/app/src/main/cpp/winlator/fakeinput.cpp @@ -1152,6 +1152,10 @@ EXPORT int close(int fd) { return my_close(fd); } +// Nothing wakes a reader when the app publishes, so a waiting reader looks at the ring again +// at least this often: it bounds how late a press reaches a game that blocks on the device. +static constexpr int kMaxRingWaitMs = 2; + EXPORT ssize_t read(int fd, void *buf, size_t count) { std::unique_lock guard(controller_mutex); auto controller = controller_map.find(fd); @@ -1244,7 +1248,7 @@ EXPORT ssize_t read(int fd, void *buf, size_t count) { int result = nanosleep(&sleep_time, nullptr); guard.lock(); if (result < 0) return -1; - if (backoff_ns < 16 * 1000 * 1000) backoff_ns *= 2; + if (backoff_ns < kMaxRingWaitMs * 1000 * 1000) backoff_ns *= 2; } } @@ -1390,7 +1394,7 @@ static int poll_fake(struct pollfd *fds, nfds_t nfds, int timeout, if (deadline_ms >= 0 && monotonic_ms() >= deadline_ms) return 0; - if (backoff_ms < 16) + if (backoff_ms < kMaxRingWaitMs) backoff_ms *= 2; } } @@ -1565,7 +1569,7 @@ EXPORT int select(int nfds, fd_set *readfds, fd_set *writefds, if (deadline_ms >= 0 && monotonic_ms() >= deadline_ms) return 0; - if (backoff_ms < 16) + if (backoff_ms < kMaxRingWaitMs) backoff_ms *= 2; } } diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index 00461f0a6..2ca2cf4ce 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -344,7 +344,7 @@ public class XServerDisplayActivity extends FixedFontScaleAppCompatActivity private boolean isVolumeDownPressed = false; private boolean guideHoldPending = false; private long guideMenuOpenedAt = 0L; - private static final long GUIDE_HOLD_OPEN_MS = 450L; + private static final long GUIDE_HOLD_OPEN_MS = 2000L; private static final long GUIDE_HOLD_TAIL_MS = 1200L; private final Runnable guideHoldOpenRunnable = new Runnable() { @Override @@ -473,6 +473,8 @@ public boolean isInputSuspended() { return isPaused; } + public boolean isGamescopeMode() { return gamescopeMode; } + private boolean isAnyControllerConnected() { if (winHandler != null && winHandler.hasSdlPads()) return true; for (int id : android.view.InputDevice.getDeviceIds()) { @@ -3257,7 +3259,7 @@ private void handleCapturedPointer(MotionEvent event) { boolean handled = false; int actionButton = event.getActionButton(); - switch (event.getAction()) { + switch (event.getActionMasked()) { case MotionEvent.ACTION_BUTTON_PRESS: if (actionButton == MotionEvent.BUTTON_PRIMARY) { xServer.injectPointerButtonPress(Pointer.Button.BUTTON_LEFT); @@ -3291,19 +3293,23 @@ private void handleCapturedPointer(MotionEvent event) { handled = true; break; case MotionEvent.ACTION_SCROLL: - float scrollY = event.getAxisValue(MotionEvent.AXIS_VSCROLL); - if (scrollY <= -1.0f) { - xServer.injectPointerButtonPress(Pointer.Button.BUTTON_SCROLL_DOWN); - xServer.injectPointerButtonRelease(Pointer.Button.BUTTON_SCROLL_DOWN); - } else if (scrollY >= 1.0f) { - xServer.injectPointerButtonPress(Pointer.Button.BUTTON_SCROLL_UP); - xServer.injectPointerButtonRelease(Pointer.Button.BUTTON_SCROLL_UP); - } + touchpadView.onMouseWheel(event.getAxisValue(MotionEvent.AXIS_VSCROLL)); handled = true; break; + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_POINTER_DOWN: + case MotionEvent.ACTION_POINTER_UP: + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + capturedTouchpadX = Float.NaN; + break; } } + /** Where a captured touchpad's first finger was: touchpads report positions, not motion. */ + private float capturedTouchpadX = Float.NaN; + private float capturedTouchpadY = Float.NaN; + private int[] getCapturedPointerDelta(MotionEvent event) { // Sum batched samples; skipping history drops movement at low refresh rates. final int historySize = event.getHistorySize(); @@ -3315,7 +3321,14 @@ private int[] getCapturedPointerDelta(MotionEvent event) { } dx += event.getAxisValue(MotionEvent.AXIS_RELATIVE_X); dy += event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y); - if (dx == 0.0f && dy == 0.0f) { + if (event.isFromSource(InputDevice.SOURCE_TOUCHPAD)) { + if (dx == 0.0f && dy == 0.0f && !Float.isNaN(capturedTouchpadX)) { + dx = event.getX() - capturedTouchpadX; + dy = event.getY() - capturedTouchpadY; + } + capturedTouchpadX = event.getX(); + capturedTouchpadY = event.getY(); + } else if (dx == 0.0f && dy == 0.0f) { for (int i = 0; i < historySize; i++) { dx += event.getHistoricalX(i); dy += event.getHistoricalY(i); @@ -10632,8 +10645,10 @@ && consumeControllerTestMotionEvent(event)) { @Override public boolean dispatchKeyEvent(KeyEvent event) { if (isSteamControllerShadowEvent(event.getDevice())) return true; + // A held guide button repeats; only a fresh press may close the menu it opened. + boolean freshKey = event.getKeyCode() != KeyEvent.KEYCODE_BUTTON_MODE || event.getRepeatCount() == 0; if (ExternalController.isGameController(event.getDevice()) - && handleControllerMenuKey(event.getKeyCode(), event.getAction() == KeyEvent.ACTION_DOWN, event.getEventTime())) return true; + && handleControllerMenuKey(event.getKeyCode(), event.getAction() == KeyEvent.ACTION_DOWN && freshKey, event.getEventTime())) return true; if (controllerTestComposeView != null && com.winlator.cmod.shared.ui.controllertest.ControllerTestBus.isActive() && consumeControllerTestKeyEvent(event)) { @@ -10674,8 +10689,8 @@ && consumeControllerTestKeyEvent(event)) { } if (event.getKeyCode() == KeyEvent.KEYCODE_BUTTON_MODE) { - // Menu closed: hold the guide button to open (a quick tap does nothing). Timer-based, so a - // missed release can never leave it stuck. + // Menu closed: holding the guide button opens it; a shorter press reaches the guest as a + // tap on release. Timer-based, so a missed release can never leave it stuck. if (event.getAction() == KeyEvent.ACTION_DOWN) { if (!guideHoldPending) { guideHoldPending = true; @@ -10683,6 +10698,9 @@ && consumeControllerTestKeyEvent(event)) { handler.postDelayed(guideHoldOpenRunnable, GUIDE_HOLD_OPEN_MS); } } else if (event.getAction() == KeyEvent.ACTION_UP) { + if (guideHoldPending && winHandler != null && ExternalController.isGameController(event.getDevice())) { + winHandler.tapGuide(event.getDeviceId()); + } guideHoldPending = false; handler.removeCallbacks(guideHoldOpenRunnable); } @@ -10781,6 +10799,9 @@ private void handleSteamMenuInput(ExternalController pad, int[] pressedKeyCodes) handler.postDelayed(guideHoldOpenRunnable, GUIDE_HOLD_OPEN_MS); } if (previous.contains(KeyEvent.KEYCODE_BUTTON_MODE) && !pressed.contains(KeyEvent.KEYCODE_BUTTON_MODE)) { + if (guideHoldPending && winHandler != null && isSteamControllerInputEnabled()) { + winHandler.tapGuide(pad.getDeviceId()); + } guideHoldPending = false; handler.removeCallbacks(guideHoldOpenRunnable); } diff --git a/app/src/main/runtime/display/winhandler/WinHandler.java b/app/src/main/runtime/display/winhandler/WinHandler.java index abefea429..e7026c0a9 100644 --- a/app/src/main/runtime/display/winhandler/WinHandler.java +++ b/app/src/main/runtime/display/winhandler/WinHandler.java @@ -711,11 +711,25 @@ public void sendGamepadState() { if (xServer != null && xServer.getRenderer() != null) xServer.getRenderer().requestRenderCoalesced(VulkanRenderer.WAKE_WINHANDLER); } + /** + * A GameScope guest learns of pads only as it starts (its udev monitor never reports one added + * later), so there no slot is ever removed: a disconnect or a move leaves it present and + * neutral, and whichever pad takes it next reaches the game through it. + */ + private boolean slotsOutliveDevices() { + return this.activity.isGamescopeMode(); + } + public void representVirtualGamepad() { Integer slot = this.deviceToSlot.get(OSC_DEVICE_ID); if (slot == null || slot < 0 || slot >= MAX_CONTROLLERS) { return; } + if (slotsOutliveDevices()) { + ensureWriterForSlot(slot); + if (this.writers[slot] != null) this.writers[slot].requestFullResend(); + return; + } if (this.writers[slot] != null) { this.writers[slot].destroy(); this.writers[slot] = null; @@ -827,6 +841,38 @@ private void writeVirtualGamepadState(boolean applyGyroOverlay, boolean allowHid } } + private static final long GUIDE_TAP_MS = 120; + private volatile Runnable pendingGuideRelease; + + /** A guide press released before the hold opens the menu: the guest gets a tap (Steam opens its menu). */ + public void tapGuide(int deviceId) { + if (Looper.myLooper() != Looper.getMainLooper()) { + this.inputHandler.post(() -> tapGuide(deviceId)); + return; + } + ExternalController controller = getController(deviceId); + if (controller == null) return; + // A tap still in flight ends first, so two quick taps reach the guest as two presses. + Runnable inFlight = this.pendingGuideRelease; + if (inFlight != null) { + this.inputHandler.removeCallbacks(inFlight); + inFlight.run(); + } + setGuidePressed(controller, true); + Runnable release = () -> { + pendingGuideRelease = null; + setGuidePressed(controller, false); + }; + pendingGuideRelease = release; + this.inputHandler.postDelayed(release, GUIDE_TAP_MS); + } + + private void setGuidePressed(ExternalController controller, boolean pressed) { + controller.state.setPressed(GamepadState.BUTTON_GUIDE, pressed); + controller.remappedState.setPressed(GamepadState.BUTTON_GUIDE, pressed); + sendGamepadState(controller); + } + public void sendGamepadState(ExternalController controller) { if (controller != null) { this.currentController = controller; @@ -988,7 +1034,7 @@ private boolean moveVirtualGamepadToSlot(int targetSlot, boolean releaseVacatedS ensureWriterForSlot(targetSlot); if (this.writers[currentSlot] != null) { - if (releaseVacatedSlot && !isPhysicalSlotOccupied(currentSlot)) { + if (releaseVacatedSlot && !isPhysicalSlotOccupied(currentSlot) && !slotsOutliveDevices()) { // The virtual pad is leaving this slot for good (consolidation, not a // hand-off to an incoming physical pad). Tear it down so winebus sees the // device disappear instead of a phantom stuck-at-neutral controller. @@ -1165,7 +1211,10 @@ private void releaseSlot(int deviceId) { if (this.fallbackSlot == slot) { this.fallbackSlot = -1; } - if (this.writers[slot] != null) { + if (this.writers[slot] != null && slotsOutliveDevices()) { + // Released to neutral and kept for the next pad; the guest would never find a new one. + this.writers[slot].reset(); + } else if (this.writers[slot] != null) { // Remove the discovery node so winebus sees a disconnect; event bytes live in // the slot ring and are not replayed by reopening this path. this.writers[slot].destroy(); @@ -1534,6 +1583,11 @@ private void releaseShadowedValveSlots() { public void closeFakeInputWriter() { cancelPendingVirtualGamepadRebalance(); + Runnable guideRelease = this.pendingGuideRelease; + if (guideRelease != null) { + this.inputHandler.removeCallbacks(guideRelease); + this.pendingGuideRelease = null; + } cancelAllPendingDeviceReleases(); if (this.inputManager != null && this.inputDeviceListener != null) { this.inputManager.unregisterInputDeviceListener(this.inputDeviceListener); @@ -1629,7 +1683,12 @@ public boolean onKeyEvent(KeyEvent event) { boolean handled = false; int deviceId = event.getDeviceId(); ExternalController controller = getController(deviceId); - if (controller != null && event.getRepeatCount() == 0) { + if (controller != null && event.getRepeatCount() > 0) { + // A held button repeats. Its state is already set; passed on, a repeat would move Android's + // focus (D-pad) or be treated as another key. Guide stays with the activity's hold timer. + return event.getKeyCode() != KeyEvent.KEYCODE_BUTTON_MODE; + } + if (controller != null) { int action = event.getAction(); if (action == 0 || action == 1) { handled = controller.updateStateFromKeyEvent(event); diff --git a/app/src/main/runtime/display/xserver/InputDeviceManager.java b/app/src/main/runtime/display/xserver/InputDeviceManager.java index 55db3439b..7e13133f3 100644 --- a/app/src/main/runtime/display/xserver/InputDeviceManager.java +++ b/app/src/main/runtime/display/xserver/InputDeviceManager.java @@ -157,6 +157,8 @@ public void sendEnterLeaveNotify(Window windowA, Window windowB, PointerWindowEv @Override public void onPointerButtonPress(Pointer.Button button) { if (xServer.isRelativeMouseMovement()) { + // A Wayland session's input sink already hands the button to the compositor. + if (xServer.hasInputSink()) return; WinHandler winHandler = xServer.getWinHandler(); int wheelDelta = button == Pointer.Button.BUTTON_SCROLL_UP @@ -198,8 +200,11 @@ public void onPointerButtonPress(Pointer.Button button) { @Override public void onPointerButtonRelease(Pointer.Button button) { if (xServer.isRelativeMouseMovement()) { - WinHandler winHandler = xServer.getWinHandler(); - winHandler.mouseEvent(MouseEventFlags.getFlagFor(button, false), 0, 0, 0); + if (xServer.hasInputSink()) return; + int flags = MouseEventFlags.getFlagFor(button, false); + // A wheel step is whole on press; its release carries nothing. + if (flags == 0 || flags == MouseEventFlags.WHEEL) return; + xServer.getWinHandler().mouseEvent(flags, 0, 0, 0); } else { Bitmask eventMask = createPointerEventMask(); Window grabWindow = xServer.grabManager.getWindow(); diff --git a/app/src/main/runtime/display/xserver/XServer.java b/app/src/main/runtime/display/xserver/XServer.java index a222c8989..7be074a2b 100644 --- a/app/src/main/runtime/display/xserver/XServer.java +++ b/app/src/main/runtime/display/xserver/XServer.java @@ -117,6 +117,10 @@ public void setInputSink(InputSink sink) { inputSink = sink; } + public boolean hasInputSink() { + return inputSink != null; + } + private void sinkPointerMove() { InputSink sink = inputSink; if (sink != null) sink.onPointerMove(pointer.getX(), pointer.getY()); diff --git a/app/src/main/runtime/input/controls/ExternalController.java b/app/src/main/runtime/input/controls/ExternalController.java index 6bdc9a44b..5deea6951 100644 --- a/app/src/main/runtime/input/controls/ExternalController.java +++ b/app/src/main/runtime/input/controls/ExternalController.java @@ -403,9 +403,25 @@ public boolean updateStateFromMotionEvent(MotionEvent event) { } public boolean updateStateFromKeyEvent(KeyEvent event) { - boolean z = false; boolean pressed = event.getAction() == 0; int keyCode = event.getKeyCode(); + switch (keyCode) { + case KeyEvent.KEYCODE_DPAD_UP: + this.state.dpad[0] = pressed; + return true; + case KeyEvent.KEYCODE_DPAD_RIGHT: + this.state.dpad[1] = pressed; + return true; + case KeyEvent.KEYCODE_DPAD_DOWN: + this.state.dpad[2] = pressed; + return true; + case KeyEvent.KEYCODE_DPAD_LEFT: + this.state.dpad[3] = pressed; + return true; + case KeyEvent.KEYCODE_BUTTON_MODE: + // Left to the activity: a tap goes to the guest on release, a hold opens the menu. + return false; + } int buttonIdx = getButtonIdxByKeyCode(keyCode); if (buttonIdx != -1) { if (buttonIdx == 10 || buttonIdx == 11) { @@ -427,32 +443,6 @@ public boolean updateStateFromKeyEvent(KeyEvent event) { this.state.setPressed(buttonIdx, pressed); return true; } - switch (keyCode) { - case 19: - this.state.dpad[0] = pressed && Math.abs(this.state.thumbLY) < 0.15f; - break; - case 20: - boolean[] zArr = this.state.dpad; - if (pressed && Math.abs(this.state.thumbLY) < 0.15f) { - z = true; - } - zArr[2] = z; - break; - case 21: - boolean[] zArr2 = this.state.dpad; - if (pressed && Math.abs(this.state.thumbLX) < 0.15f) { - z = true; - } - zArr2[3] = z; - break; - case 22: - boolean[] zArr3 = this.state.dpad; - if (pressed && Math.abs(this.state.thumbLX) < 0.15f) { - z = true; - } - zArr3[1] = z; - break; - } return true; } diff --git a/app/src/main/runtime/input/ui/InputControlsView.java b/app/src/main/runtime/input/ui/InputControlsView.java index fd03d6680..c01eb835e 100644 --- a/app/src/main/runtime/input/ui/InputControlsView.java +++ b/app/src/main/runtime/input/ui/InputControlsView.java @@ -685,7 +685,7 @@ public int getMaxWidth() { @Override protected void onDetachedFromWindow() { - cancelContinuousMouseMove(); + cancelActiveTouches(); if (mouseMoveTimer != null) { mouseMoveTimer.cancel(); mouseMoveTimer = null; @@ -701,14 +701,17 @@ private void createMouseMoveTimer() { if (xServer == null) return; WinHandler winHandler = xServer.getWinHandler(); if (mouseMoveTimer == null && profile != null) { - final float cursorSpeed = profile.getCursorSpeed(); mouseMoveTimer = new Timer(); mouseMoveTimer.schedule( new TimerTask() { @Override public void run() { if (getContext() instanceof XServerDisplayActivity && ((XServerDisplayActivity)getContext()).isInputSuspended()) return; - if (mouseMoveOffsetX != 0 || mouseMoveOffsetY != 0) { int dx = (int) (mouseMoveOffsetX * cursorSpeed * 20); + ControlsProfile currentProfile = profile; + if (currentProfile == null) return; + if (mouseMoveOffsetX != 0 || mouseMoveOffsetY != 0) { + float cursorSpeed = currentProfile.getCursorSpeed(); + int dx = (int) (mouseMoveOffsetX * cursorSpeed * 20); int dy = (int) (mouseMoveOffsetY * cursorSpeed * 20); if (xServer.isRelativeMouseMovement()) { xServer.updatePointerForDisplayDelta(dx, dy); diff --git a/app/src/main/runtime/input/ui/TouchpadView.kt b/app/src/main/runtime/input/ui/TouchpadView.kt index 3e3f7b683..1a9f35386 100644 --- a/app/src/main/runtime/input/ui/TouchpadView.kt +++ b/app/src/main/runtime/input/ui/TouchpadView.kt @@ -6,9 +6,11 @@ import android.graphics.drawable.ColorDrawable import android.graphics.drawable.StateListDrawable import android.os.Handler import android.os.Looper +import android.view.InputDevice import android.view.MotionEvent import android.view.PointerIcon import android.view.View +import android.view.ViewConfiguration import android.widget.FrameLayout import com.winlator.cmod.R import com.winlator.cmod.runtime.display.XServerDisplayActivity @@ -42,6 +44,7 @@ class TouchpadView( const val MODE_MAP_TO_RIGHT_STICK = 2 private const val TOUCHSCREEN_DOUBLE_TAP_MS = 500L private const val TOUCHSCREEN_DOUBLE_TAP_DISTANCE = 100f + private const val TOUCHSCREEN_PRESS_DELAY_MS = 50L } private var continueClick = true @@ -69,6 +72,26 @@ class TouchpadView( private var sinkTapTime = 0L private var sinkTapX = 0f private var sinkTapY = 0f + private var sinkTouchX = 0f + private var sinkTouchY = 0f + private var touchLeftPressed = false + private var touchPrimaryId = -1 + private var touchSecondId = -1 + private var twoFingerGesture = false + private var pressPending = false + private val pressDown = FloatArray(4) + private val pressCurrent = FloatArray(4) + private val pendingPressRunnable = Runnable { firePendingPress() } + private var twoFingerMoved = false + private var twoFingerLastY = 0f + private val twoFingerStart = FloatArray(4) + private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop + private val twoFingerScrollStep = 40f * resources.displayMetrics.density + private var wheelAccum = 0f + private var mouseLastX = Float.NaN + private var mouseLastY = Float.NaN + private var mouseRemainderX = 0f + private var mouseRemainderY = 0f private var screenTouchMode = MODE_TRACKPAD private var rtsGesturesEnabled = false private val xform = XForm.getInstance() @@ -112,8 +135,7 @@ class TouchpadView( override fun onDetachedFromWindow() { super.onDetachedFromWindow() - rtsGestureEngine.releaseAll() - screenTouchStick.releaseAll() + resetInputState() } private fun updateXform(outerWidth: Int, outerHeight: Int, innerWidth: Int, innerHeight: Int) { @@ -182,6 +204,7 @@ class TouchpadView( if (!mouseEnabled) return true resetTouchscreenTimeout() if (event.getToolType(0) == MotionEvent.TOOL_TYPE_STYLUS) return handleStylusEvent(event) + if (event.isFromSource(InputDevice.SOURCE_MOUSE)) return handleMouseTouchEvent(event) val action = event.actionMasked if (action == MotionEvent.ACTION_DOWN || activeTouchHandler == null) { activeTouchHandler = selectTouchHandler() @@ -262,10 +285,13 @@ class TouchpadView( val actionIndex = event.actionIndex val pointerId = event.getPointerId(actionIndex) val actionMasked = event.actionMasked + if (actionMasked == MotionEvent.ACTION_CANCEL) { + releaseTouches() + return true + } if (actionMasked != MotionEvent.ACTION_MOVE && (pointerId >= MAX_FINGERS || pointerIdsToIgnore.contains(pointerId))) return true when (actionMasked) { MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> { - if (event.isFromSource(8194)) return true scrollAccumY = 0.0f scrolling = false fingers[pointerId] = Finger(event.getX(actionIndex), event.getY(actionIndex)) @@ -316,64 +342,120 @@ class TouchpadView( } } MotionEvent.ACTION_MOVE -> { - if (event.isFromSource(8194)) { - if (isInputSuspended) return true - val transformedPoint = XForm.transformPoint(xform, event.x, event.y) - if (xServer.isRelativeMouseMovement) { - xServer.winHandler.mouseEvent(MouseEventFlags.MOVE, transformedPoint[0].toInt(), transformedPoint[1].toInt(), 0) - updateVisibleRelativeCursor(transformedPoint[0].toInt(), transformedPoint[1].toInt()) - } else { - xServer.injectPointerMove(transformedPoint[0].toInt(), transformedPoint[1].toInt()) - } - } else { - for (i in 0 until 4) { - if (fingers[i] != null) { - if (pointerIdsToIgnore.contains(i)) { - fingers[i] = null - numFingers = (numFingers - 1).toByte() - continue - } - val pointerIndex = event.findPointerIndex(i) - if (pointerIndex >= 0) { - fingers[i]!!.update(event.getX(pointerIndex), event.getY(pointerIndex)) - handleFingerMove(fingers[i]!!) - } else { - handleFingerUp(fingers[i]!!) - fingers[i] = null - numFingers = (numFingers - 1).toByte() - } + for (i in 0 until 4) { + if (fingers[i] != null) { + val pointerIndex = event.findPointerIndex(i) + if (pointerIndex >= 0) { + fingers[i]!!.update(event.getX(pointerIndex), event.getY(pointerIndex)) + handleFingerMove(fingers[i]!!) + } else { + handleFingerUp(fingers[i]!!) + fingers[i] = null + numFingers = (numFingers - 1).toByte() } } } } - MotionEvent.ACTION_CANCEL -> { - longPressHandler.removeCallbacks(longPressRunnable) - longPressActive = false - for (i in 0 until 4) fingers[i] = null - numFingers = 0 - } } return true } + /** A mouse the app doesn't capture: its buttons arrive through [onExternalMouseEvent], its drags come here. */ + private fun handleMouseTouchEvent(event: MotionEvent): Boolean { + if (event.actionMasked == MotionEvent.ACTION_MOVE && !isInputSuspended) moveExternalMouse(event) + return true + } + + private fun moveExternalMouse(event: MotionEvent) { + val transformedPoint = XForm.transformPoint(xform, event.x, event.y) + if (xServer.isRelativeMouseMovement) { + var dx = event.getAxisValue(MotionEvent.AXIS_RELATIVE_X) + var dy = event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y) + for (i in 0 until event.historySize) { + dx += event.getHistoricalAxisValue(MotionEvent.AXIS_RELATIVE_X, i) + dy += event.getHistoricalAxisValue(MotionEvent.AXIS_RELATIVE_Y, i) + } + if (dx == 0f && dy == 0f && !mouseLastX.isNaN()) { + dx = event.x - mouseLastX + dy = event.y - mouseLastY + } + val delta = computeDeltaPoint(0f, 0f, dx, dy) + mouseRemainderX += delta[0] + mouseRemainderY += delta[1] + val moveX = mouseRemainderX.toInt() + val moveY = mouseRemainderY.toInt() + mouseRemainderX -= moveX + mouseRemainderY -= moveY + if (moveX != 0 || moveY != 0) xServer.winHandler.mouseEvent(MouseEventFlags.MOVE, moveX, moveY, 0) + updateVisibleRelativeCursor(transformedPoint[0].toInt(), transformedPoint[1].toInt()) + } else { + xServer.injectPointerMove(transformedPoint[0].toInt(), transformedPoint[1].toInt()) + } + mouseLastX = event.x + mouseLastY = event.y + } + + /** Wheel motion in notches; high-resolution wheels and touchpads send fractions of one. */ + fun onMouseWheel(amount: Float) { + if (amount == 0f || amount.isNaN()) return + if (wheelAccum != 0f && Math.signum(wheelAccum) != Math.signum(amount)) wheelAccum = 0f + wheelAccum += amount + while (wheelAccum >= 1f) { + clickPointerButton(Pointer.Button.BUTTON_SCROLL_UP) + wheelAccum -= 1f + } + while (wheelAccum <= -1f) { + clickPointerButton(Pointer.Button.BUTTON_SCROLL_DOWN) + wheelAccum += 1f + } + } + + private fun clickPointerButton(button: Pointer.Button) { + xServer.injectPointerButtonPress(button) + xServer.injectPointerButtonRelease(button) + } + + /** + * The first finger clicks and drags where it touches. A second finger ends that press: the + * pair then scrolls as it moves, and right-clicks if it lifts without having moved. + */ private fun handleTouchscreenEvent(event: MotionEvent): Boolean { if (isInputSuspended) return true - val action = event.actionMasked - val ignorePointerId = event.getPointerId(event.actionIndex) - if (action != MotionEvent.ACTION_MOVE && (ignorePointerId >= MAX_FINGERS || pointerIdsToIgnore.contains(ignorePointerId))) return true - when (action) { - 0, 5 -> { handleTouchDown(event); return true } - 1, 6 -> { if (event.pointerCount == 2) handleTwoFingerTap(event) else handleTouchUp(event); return true } - 2 -> { if (event.pointerCount == 2) handleTwoFingerScroll(event) else handleTouchMove(event); return true } - 3 -> { - if (sinkTouchActive) { - sinkTouchActive = false - touchscreenSink?.onTouch(2, event.rawX, event.rawY) + val index = event.actionIndex + val pointerId = event.getPointerId(index) + when (event.actionMasked) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> { + if (pointerId >= MAX_FINGERS || pointerIdsToIgnore.contains(pointerId) || twoFingerGesture) return true + if (touchPrimaryId == -1) { + touchPrimaryId = pointerId + handleTouchDown(event, index) + } else if (touchSecondId == -1) { + touchSecondId = pointerId + startTwoFingerGesture(event) } - xServer.injectPointerButtonRelease(Pointer.Button.BUTTON_LEFT) - xServer.injectPointerButtonRelease(Pointer.Button.BUTTON_RIGHT) - return true } + MotionEvent.ACTION_MOVE -> { + if (twoFingerGesture) { + handleTwoFingerMove(event) + } else if (touchPrimaryId != -1) { + val primaryIndex = event.findPointerIndex(touchPrimaryId) + if (primaryIndex >= 0) handleTouchMove(event, primaryIndex) + } + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> { + if (twoFingerGesture) { + if (pointerId == touchPrimaryId || pointerId == touchSecondId) { + if (!twoFingerMoved && tapToClickEnabled) clickPointerButton(Pointer.Button.BUTTON_RIGHT) + twoFingerMoved = true + if (pointerId == touchPrimaryId) touchPrimaryId = -1 else touchSecondId = -1 + if (touchPrimaryId == -1 && touchSecondId == -1) twoFingerGesture = false + } + } else if (pointerId == touchPrimaryId) { + handleTouchUp(event, index) + } + if (event.actionMasked == MotionEvent.ACTION_UP) releaseTouchscreenPointers() + } + MotionEvent.ACTION_CANCEL -> releaseTouches() } return true } @@ -389,86 +471,174 @@ class TouchpadView( var touchscreenSink: TouchscreenSink? = null - private fun handleTouchDown(event: MotionEvent) { - if (sinkTouchActive) return - if (event.pointerCount == 1 && tapToClickEnabled && !isInputSuspended && sinkTouchDown(event)) return - val transformedPoint = XForm.transformPoint(xform, event.x, event.y) + private fun rawX(event: MotionEvent, index: Int): Float = event.getX(index) + event.rawX - event.x + + private fun rawY(event: MotionEvent, index: Int): Float = event.getY(index) + event.rawY - event.y + + private fun readTouch(event: MotionEvent, index: Int, into: FloatArray) { + into[0] = event.getX(index) + into[1] = event.getY(index) + into[2] = rawX(event, index) + into[3] = rawY(event, index) + } + + /** The press waits briefly so a second finger landing with the first starts a gesture, not a click. */ + private fun handleTouchDown(event: MotionEvent, index: Int) { + readTouch(event, index, pressDown) + pressDown.copyInto(pressCurrent) + pressPending = true + postDelayed(pendingPressRunnable, TOUCHSCREEN_PRESS_DELAY_MS) + } + + private fun firePendingPress() { + if (!pressPending) return + cancelPendingPress() + if (isInputSuspended) return + pressAt(pressDown[0], pressDown[1], pressDown[2], pressDown[3]) + if (!pressCurrent.contentEquals(pressDown)) moveTouch() + } + + private fun cancelPendingPress() { + pressPending = false + removeCallbacks(pendingPressRunnable) + } + + private fun pressAt(x: Float, y: Float, rawX: Float, rawY: Float) { + if (tapToClickEnabled && sinkTouchDown(rawX, rawY)) return + val transformedPoint = XForm.transformPoint(xform, x, y) var tx = transformedPoint[0].toInt() var ty = transformedPoint[1].toInt() - if (event.pointerCount == 1) { - val now = System.currentTimeMillis() - val near = Math.hypot((event.x - lastTapRawX).toDouble(), (event.y - lastTapRawY).toDouble()) < TOUCHSCREEN_DOUBLE_TAP_DISTANCE - if (now - lastTapDownTime < TOUCHSCREEN_DOUBLE_TAP_MS && near) { - tx = lastTapTransX - ty = lastTapTransY - } - lastTapDownTime = now - lastTapRawX = event.x - lastTapRawY = event.y - lastTapTransX = tx - lastTapTransY = ty - } - if (!isInputSuspended) { - xServer.injectPointerMove(tx, ty) - if (event.pointerCount == 1 && tapToClickEnabled) xServer.injectPointerButtonPress(Pointer.Button.BUTTON_LEFT) + val now = System.currentTimeMillis() + val near = Math.hypot((x - lastTapRawX).toDouble(), (y - lastTapRawY).toDouble()) < TOUCHSCREEN_DOUBLE_TAP_DISTANCE + if (now - lastTapDownTime < TOUCHSCREEN_DOUBLE_TAP_MS && near) { + tx = lastTapTransX + ty = lastTapTransY + } + lastTapDownTime = now + lastTapRawX = x + lastTapRawY = y + lastTapTransX = tx + lastTapTransY = ty + xServer.injectPointerMove(tx, ty) + if (tapToClickEnabled) { + xServer.injectPointerButtonPress(Pointer.Button.BUTTON_LEFT) + touchLeftPressed = true } } /** A second tap close to the first lands exactly on it, so a double tap is a double click. */ - private fun sinkTouchDown(event: MotionEvent): Boolean { + private fun sinkTouchDown(rawX: Float, rawY: Float): Boolean { val sink = touchscreenSink ?: return false val now = System.currentTimeMillis() - val near = Math.hypot((event.rawX - sinkTapX).toDouble(), (event.rawY - sinkTapY).toDouble()) < TOUCHSCREEN_DOUBLE_TAP_DISTANCE + val near = Math.hypot((rawX - sinkTapX).toDouble(), (rawY - sinkTapY).toDouble()) < TOUCHSCREEN_DOUBLE_TAP_DISTANCE if (now - sinkTapTime >= TOUCHSCREEN_DOUBLE_TAP_MS || !near) { - sinkTapX = event.rawX - sinkTapY = event.rawY + sinkTapX = rawX + sinkTapY = rawY } sinkTapTime = now + sinkTouchX = sinkTapX + sinkTouchY = sinkTapY sinkTouchActive = sink.onTouch(0, sinkTapX, sinkTapY) return sinkTouchActive } - private fun handleTouchMove(event: MotionEvent) { - if (isInputSuspended) return + private fun handleTouchMove(event: MotionEvent, index: Int) { + readTouch(event, index, pressCurrent) + if (pressPending) { + val travel = Math.hypot((pressCurrent[0] - pressDown[0]).toDouble(), (pressCurrent[1] - pressDown[1]).toDouble()) + if (travel > touchSlop) firePendingPress() + return + } + moveTouch() + } + + private fun moveTouch() { if (sinkTouchActive) { - touchscreenSink?.onTouch(1, event.rawX, event.rawY) + sinkTouchX = pressCurrent[2] + sinkTouchY = pressCurrent[3] + touchscreenSink?.onTouch(1, sinkTouchX, sinkTouchY) return } - val transformedPoint = XForm.transformPoint(xform, event.x, event.y) + val transformedPoint = XForm.transformPoint(xform, pressCurrent[0], pressCurrent[1]) xServer.injectPointerMove(transformedPoint[0].toInt(), transformedPoint[1].toInt()) } - private fun handleTouchUp(event: MotionEvent) { + private fun handleTouchUp(event: MotionEvent, index: Int) { + readTouch(event, index, pressCurrent) + firePendingPress() if (sinkTouchActive) { - sinkTouchActive = false - touchscreenSink?.onTouch(2, event.rawX, event.rawY) + sinkTouchX = pressCurrent[2] + sinkTouchY = pressCurrent[3] + } + releaseTouchscreenPointers() + } + + /** Ends (or never starts) the first finger's press; the pointer stays there for the pair's clicks. */ + private fun startTwoFingerGesture(event: MotionEvent) { + val pressed = !pressPending + endTouchscreenPress() + if (!pressed && touchscreenSink?.onTouch(1, pressCurrent[2], pressCurrent[3]) != true) { + val transformedPoint = XForm.transformPoint(xform, pressCurrent[0], pressCurrent[1]) + xServer.injectPointerMove(transformedPoint[0].toInt(), transformedPoint[1].toInt()) + } + twoFingerGesture = true + twoFingerMoved = false + scrollAccumY = 0f + val first = event.findPointerIndex(touchPrimaryId) + val second = event.findPointerIndex(touchSecondId) + if (first < 0 || second < 0) { + twoFingerMoved = true return } - if (!isInputSuspended) xServer.injectPointerButtonRelease(Pointer.Button.BUTTON_LEFT) + twoFingerStart[0] = event.getX(first) + twoFingerStart[1] = event.getY(first) + twoFingerStart[2] = event.getX(second) + twoFingerStart[3] = event.getY(second) + twoFingerLastY = (twoFingerStart[1] + twoFingerStart[3]) * 0.5f } - private fun handleTwoFingerScroll(event: MotionEvent) { - if (isInputSuspended) return - val activeFingers = fingers.filterNotNull() - if (activeFingers.size < 2) return - val finger1 = activeFingers[0] - val finger2 = activeFingers[1] - val scrollDistance = finger1.y - finger2.y - if (Math.abs(scrollDistance) > 10) { - val button = if (scrollDistance > 0) Pointer.Button.BUTTON_SCROLL_UP else Pointer.Button.BUTTON_SCROLL_DOWN - xServer.injectPointerButtonPress(button) - xServer.injectPointerButtonRelease(button) + private fun handleTwoFingerMove(event: MotionEvent) { + val first = event.findPointerIndex(touchPrimaryId) + val second = event.findPointerIndex(touchSecondId) + if (first < 0 || second < 0) return + val y1 = event.getY(first) + val y2 = event.getY(second) + if (!twoFingerMoved && + (Math.hypot((event.getX(first) - twoFingerStart[0]).toDouble(), (y1 - twoFingerStart[1]).toDouble()) > touchSlop || + Math.hypot((event.getX(second) - twoFingerStart[2]).toDouble(), (y2 - twoFingerStart[3]).toDouble()) > touchSlop) + ) { + twoFingerMoved = true + } + val y = (y1 + y2) * 0.5f + scrollAccumY += y - twoFingerLastY + twoFingerLastY = y + while (scrollAccumY <= -twoFingerScrollStep) { + clickPointerButton(Pointer.Button.BUTTON_SCROLL_DOWN) + scrollAccumY += twoFingerScrollStep + } + while (scrollAccumY >= twoFingerScrollStep) { + clickPointerButton(Pointer.Button.BUTTON_SCROLL_UP) + scrollAccumY -= twoFingerScrollStep } } - private fun handleTwoFingerTap(event: MotionEvent) { - if (event.pointerCount == 2 && tapToClickEnabled && !isInputSuspended) { - if (xServer.pointer.isButtonPressed(Pointer.Button.BUTTON_LEFT)) { - xServer.injectPointerButtonRelease(Pointer.Button.BUTTON_LEFT) - } - xServer.injectPointerButtonPress(Pointer.Button.BUTTON_RIGHT) - xServer.injectPointerButtonRelease(Pointer.Button.BUTTON_RIGHT) + private fun endTouchscreenPress() { + cancelPendingPress() + if (sinkTouchActive) { + sinkTouchActive = false + touchscreenSink?.onTouch(2, sinkTouchX, sinkTouchY) } + if (touchLeftPressed) { + touchLeftPressed = false + xServer.injectPointerButtonRelease(Pointer.Button.BUTTON_LEFT) + } + } + + private fun releaseTouchscreenPointers() { + endTouchscreenPress() + touchPrimaryId = -1 + touchSecondId = -1 + twoFingerGesture = false } private fun handleFingerUp(finger1: Finger) { @@ -612,32 +782,18 @@ class TouchpadView( val actionButton = event.actionButton when (event.action) { 2, 7 -> { - val transformedPoint = XForm.transformPoint(xform, event.x, event.y) - if (xServer.isRelativeMouseMovement) { - xServer.winHandler.mouseEvent(MouseEventFlags.MOVE, transformedPoint[0].toInt(), transformedPoint[1].toInt(), 0) - updateVisibleRelativeCursor(transformedPoint[0].toInt(), transformedPoint[1].toInt()) - } else { - xServer.injectPointerMove(transformedPoint[0].toInt(), transformedPoint[1].toInt()) - } + moveExternalMouse(event) return true } 8 -> { - val scrollY = event.getAxisValue(9) - if (scrollY <= -1.0f) { - if (xServer.isRelativeMouseMovement) xServer.winHandler.mouseEvent(MouseEventFlags.WHEEL, 0, 0, scrollY.toInt()) - else { - xServer.injectPointerButtonPress(Pointer.Button.BUTTON_SCROLL_DOWN) - xServer.injectPointerButtonRelease(Pointer.Button.BUTTON_SCROLL_DOWN) - } - } else if (scrollY >= 1.0f) { - if (xServer.isRelativeMouseMovement) xServer.winHandler.mouseEvent(MouseEventFlags.WHEEL, 0, 0, scrollY.toInt()) - else { - xServer.injectPointerButtonPress(Pointer.Button.BUTTON_SCROLL_UP) - xServer.injectPointerButtonRelease(Pointer.Button.BUTTON_SCROLL_UP) - } - } + onMouseWheel(event.getAxisValue(MotionEvent.AXIS_VSCROLL)) return true } + 9, 10 -> { + mouseLastX = Float.NaN + mouseLastY = Float.NaN + return false + } 11 -> { if (actionButton == 1) { if (xServer.isRelativeMouseMovement) xServer.winHandler.mouseEvent(MouseEventFlags.LEFTDOWN, 0, 0, 0) else xServer.injectPointerButtonPress(Pointer.Button.BUTTON_LEFT) @@ -717,9 +873,19 @@ class TouchpadView( private val pointerIdsToIgnore = mutableSetOf() + /** Pointers the on-screen controls hold; one that slides onto a control lets go of what it pressed here. */ fun setPointerIdsToIgnore(ids: Set) { pointerIdsToIgnore.clear() pointerIdsToIgnore.addAll(ids) + if (touchPrimaryId in ids || touchSecondId in ids) releaseTouchscreenPointers() + for (i in 0 until MAX_FINGERS) { + val finger = fingers[i] ?: continue + if (i !in ids) continue + releasePointerButtonLeft(finger) + releasePointerButtonRight(finger) + fingers[i] = null + numFingers = (numFingers - 1).toByte() + } } var tapToClickEnabled = true @@ -742,6 +908,15 @@ class TouchpadView( fun resetInputState() { screenTouchStick.releaseAll() rtsGestureEngine.releaseAll() + releaseTouches() + wheelAccum = 0f + mouseRemainderX = 0f + mouseRemainderY = 0f + } + + private fun releaseTouches() { + longPressHandler.removeCallbacks(longPressRunnable) + longPressActive = false continueClick = false scrolling = false scrollAccumY = 0f @@ -751,6 +926,7 @@ class TouchpadView( numFingers = 0 fingerPointerButtonLeft = null fingerPointerButtonRight = null + releaseTouchscreenPointers() if (xServer.pointer.isButtonPressed(Pointer.Button.BUTTON_LEFT)) { xServer.injectPointerButtonRelease(Pointer.Button.BUTTON_LEFT) From b672f1e0cbcc16d40d24d4499dd2ae175dfeaf47 Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Thu, 24 Sep 2026 05:59:48 -0400 Subject: [PATCH 3/4] input: deliver stick, mouse and on-screen control motion without waiting for vsync Android batches joystick, captured-mouse and touch MOVE events to the next frame. The display activity now asks for unbuffered dispatch of joystick, trackball and position sources (re-asserted after focus changes, which reset it), and the on-screen controls ask for it per gesture when a control takes the touch. Measured on a RedMagic in GameScope (uinput write to fakeinput ring): pad axes 8.7 -> 1.3 ms median, on-screen stick 8.8 -> 0.9 ms median. --- .../runtime/display/XServerDisplayActivity.java | 15 +++++++++++++++ .../main/runtime/input/ui/InputControlsView.java | 3 +++ 2 files changed, 18 insertions(+) diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index 2ca2cf4ce..681ef27f5 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -7413,6 +7413,21 @@ private boolean requiresDisplayReady(int itemId) { } } + /** Sticks, triggers, hats and captured mice/touchpads: held back to the next frame, they arrive up to a frame late. */ + private static final int UNBUFFERED_INPUT_SOURCES = InputDevice.SOURCE_CLASS_JOYSTICK + | InputDevice.SOURCE_CLASS_TRACKBALL | InputDevice.SOURCE_CLASS_POSITION; + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return; + View decor = getWindow().getDecorView(); + decor.requestUnbufferedDispatch(UNBUFFERED_INPUT_SOURCES); + // A focus change recomputes the window's request from the focused view, dropping this one. + decor.getViewTreeObserver().addOnGlobalFocusChangeListener((oldFocus, newFocus) -> + decor.post(() -> decor.requestUnbufferedDispatch(UNBUFFERED_INPUT_SOURCES))); + } + @Override public void onWindowFocusChanged(boolean hasFocus) { super.onWindowFocusChanged(hasFocus); diff --git a/app/src/main/runtime/input/ui/InputControlsView.java b/app/src/main/runtime/input/ui/InputControlsView.java index c01eb835e..cc42e34f7 100644 --- a/app/src/main/runtime/input/ui/InputControlsView.java +++ b/app/src/main/runtime/input/ui/InputControlsView.java @@ -990,6 +990,8 @@ public boolean onTouchEvent(MotionEvent event) { } batchingUpdates = false; + // Held back to the next frame, a stick drag reaches the game up to a frame late. + if (eventHandled) requestUnbufferedDispatch(event); if (eventHandled || staleReleased) flushGamepadState(); syncCapturedPointers(); if (!eventHandled) dispatchUnhandledTouch(event); @@ -1068,6 +1070,7 @@ public boolean onTouchEvent(MotionEvent event) { batchingUpdates = false; WinHandler winHandler = xServer != null ? xServer.getWinHandler() : null; + if (anyControlHandled) requestUnbufferedDispatch(event); if (anyControlHandled && winHandler != null) { winHandler.sendGamepadState(); } From 41d04769a914cd16e8e96e44a517c4d480a2eab3 Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Thu, 24 Sep 2026 05:59:48 -0400 Subject: [PATCH 4/4] gamescope: new GameScope containers start on DirectAudio Covers the installer's first container and the Containers screen. The container's audio setting can still be changed afterwards. --- app/src/main/runtime/container/ContainerCreation.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/runtime/container/ContainerCreation.kt b/app/src/main/runtime/container/ContainerCreation.kt index 49b821a90..991fe4f63 100644 --- a/app/src/main/runtime/container/ContainerCreation.kt +++ b/app/src/main/runtime/container/ContainerCreation.kt @@ -4,6 +4,7 @@ import android.content.Context import android.os.Handler import android.os.Looper import com.winlator.cmod.feature.library.LinuxApps +import com.winlator.cmod.runtime.audio.directaudio.DirectAudioDriver import com.winlator.cmod.runtime.compat.box64.Box64Preset import com.winlator.cmod.runtime.compat.fexcore.FEXCorePreset import com.winlator.cmod.runtime.content.ContentProfile @@ -296,12 +297,14 @@ object ContainerCreation { runtime: ContentProfile?, ): Container? { val name = uniqueName(containerManager, GAMESCOPE_CONTAINER_NAME) + val wineVersion = runtime?.let { ContentsManager.getEntryName(it) } ?: WineInfo.MAIN_WINE_VERSION.identifier() + // Starts on DirectAudio; the container's settings can change it later. + val data = buildLaunchReadyData(context, contentsManager, name, wineVersion) + .put("audioDriver", DirectAudioDriver.IDENTIFIER) val created = if (runtime != null) { - val data = buildLaunchReadyData(context, contentsManager, name, ContentsManager.getEntryName(runtime)) containerManager.createContainer(data, contentsManager) } else { - val data = buildLaunchReadyData(context, contentsManager, name, WineInfo.MAIN_WINE_VERSION.identifier()) containerManager.createPrefixlessContainer(data) } val container = created ?: return null