From 6e49c0c2879abcfbbfa40b977eb63bf3d7a32f4c Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:53:25 -0400 Subject: [PATCH] feat: low-latency controller and touch toggles in the session drawer (#808) --- app/src/main/cpp/fakeinput_steam.cpp | 64 +++++++++++++------ .../com/droiddeck/launcher/SessionActivity.kt | 28 +++++++- .../launcher/input/FakeInputWriter.java | 21 ++++++ .../launcher/session/SessionPrefs.kt | 12 ++++ .../launcher/session/SessionService.kt | 1 + .../droiddeck/launcher/ui/SessionOverlay.kt | 12 ++++ app/src/main/res/values/strings.xml | 5 ++ 7 files changed, 121 insertions(+), 22 deletions(-) diff --git a/app/src/main/cpp/fakeinput_steam.cpp b/app/src/main/cpp/fakeinput_steam.cpp index c70d460a9..d1864a277 100644 --- a/app/src/main/cpp/fakeinput_steam.cpp +++ b/app/src/main/cpp/fakeinput_steam.cpp @@ -1140,8 +1140,12 @@ struct DeckImu { uint16_t pressure[2]; uint32_t controls; uint32_t pad_type; + uint32_t latency; }; static_assert(offsetof(DeckImu, pad_type) == 44, "DeckImu::pad_type matches FakeInputWriter"); +static_assert(offsetof(DeckImu, latency) == 48, "DeckImu::latency matches FakeInputWriter"); +static constexpr uint32_t LATENCY_CONTROLLER = 1, LATENCY_TOUCH = 2; +static constexpr int LOW_LATENCY_REPORT_INTERVAL_US = 1000; static constexpr uint32_t DECK_EXTRA_L4 = 1, DECK_EXTRA_R4 = 2, DECK_EXTRA_L5 = 4, DECK_EXTRA_R5 = 8, DECK_EXTRA_LPAD_TOUCH = 16, DECK_EXTRA_RPAD_TOUCH = 32, DECK_EXTRA_LPAD_CLICK = 64, @@ -1303,6 +1307,27 @@ __attribute__((visibility("hidden"))) static uint32_t live_pad_type() { return value & PAD_TYPE_SET ? value & 0xff : session_pad_type(); } +__attribute__((visibility("hidden"))) static bool low_latency(uint32_t which) { + const uint8_t *blocks = ring_blocks(); + const DeckImu *imu = blocks ? reinterpret_cast(blocks + FAKE_INPUT_RING_SIZE) : nullptr; + return imu && (__atomic_load_n(&imu->latency, __ATOMIC_ACQUIRE) & which); +} + +__attribute__((visibility("hidden"))) static int controller_report_interval_us(int normal_us) { + return low_latency(LATENCY_CONTROLLER) ? LOW_LATENCY_REPORT_INTERVAL_US : normal_us; +} + +__attribute__((visibility("hidden"))) static void sleep_us(int us) { + struct timespec interval = {0, us * 1000L}; + nanosleep(&interval, nullptr); +} + +__attribute__((visibility("hidden"))) static uint64_t monotonic_us() { + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return static_cast(now.tv_sec) * 1000000ULL + static_cast(now.tv_nsec) / 1000ULL; +} + static bool fake_triton_enabled() { static int enabled = -1; if (enabled < 0) @@ -1453,8 +1478,7 @@ __attribute__((visibility("hidden"))) static void *deck_report_thread(void *arg) if (send(self->peer, report, sizeof(report), MSG_DONTWAIT | MSG_NOSIGNAL) < 0 && errno != EAGAIN && errno != EWOULDBLOCK) break; - struct timespec interval = {0, DECK_REPORT_INTERVAL_US * 1000L}; - nanosleep(&interval, nullptr); + sleep_us(controller_report_interval_us(DECK_REPORT_INTERVAL_US)); } syscall(SYS_close, self->peer); munmap(self->ring, self->mapping_size); @@ -1486,7 +1510,7 @@ __attribute__((visibility("hidden"))) static void put_sony_touch(uint8_t *at, So } __attribute__((visibility("hidden"))) static void -build_sony_report(uint8_t *report, uint8_t model, uint32_t packet, uint32_t buttons, const int32_t *axes, +build_sony_report(uint8_t *report, uint8_t model, uint32_t packet, uint64_t elapsed_us, uint32_t buttons, const int32_t *axes, const DeckExtras &extras, SonyTouch &touch) { memset(report, 0, SONY_REPORT_BYTES); report[0] = 0x01; @@ -1517,7 +1541,7 @@ build_sony_report(uint8_t *report, uint8_t model, uint32_t packet, uint32_t butt p[6] = static_cast(system | ((packet & 0x3f) << 2)); p[7] = left_trigger; p[8] = right_trigger; - put16(p + 9, static_cast(packet * 750)); + put16(p + 9, static_cast(elapsed_us * 3 / 16)); for (int i = 0; i < 3; i++) { put16(p + 12 + i * 2, gyro[i]); put16(p + 18 + i * 2, accel[i]); @@ -1539,7 +1563,7 @@ build_sony_report(uint8_t *report, uint8_t model, uint32_t packet, uint32_t butt put16(p + 15 + i * 2, gyro[i]); put16(p + 21 + i * 2, accel[i]); } - put32(p + 27, packet * 12000); + put32(p + 27, static_cast(elapsed_us * 3)); put_sony_touch(p + 32, touch, 0, left, extras.pads[0], extras.pads[1], SONY_TOUCH_HEIGHT_DUALSENSE); put_sony_touch(p + 36, touch, 1, right, extras.pads[2], extras.pads[3], SONY_TOUCH_HEIGHT_DUALSENSE); p[52] = 0x2a; @@ -1558,6 +1582,7 @@ __attribute__((visibility("hidden"))) static void *sony_report_thread(void *arg) int32_t axes[8] = {}; DeckExtras extras; SonyTouch touch; + const uint64_t start_us = monotonic_us(); while (live_pad_type() == type) { if (read_snapshot(self->ring, snap) && snap.generation == ring_generation(self->ring)) { buttons = snap.buttons; @@ -1565,12 +1590,11 @@ __attribute__((visibility("hidden"))) static void *sony_report_thread(void *arg) } read_deck_extras(*self, extras); uint8_t report[SONY_REPORT_BYTES]; - build_sony_report(report, self->sony, ++packet, buttons, axes, extras, touch); + build_sony_report(report, self->sony, ++packet, monotonic_us() - start_us, buttons, axes, extras, touch); if (send(self->peer, report, sizeof(report), MSG_DONTWAIT | MSG_NOSIGNAL) < 0 && errno != EAGAIN && errno != EWOULDBLOCK) break; - struct timespec interval = {0, DECK_REPORT_INTERVAL_US * 1000L}; - nanosleep(&interval, nullptr); + sleep_us(controller_report_interval_us(DECK_REPORT_INTERVAL_US)); } syscall(SYS_close, self->peer); munmap(self->ring, self->mapping_size); @@ -1684,8 +1708,7 @@ __attribute__((visibility("hidden"))) static void *triton_report_thread(void *ar errno != EAGAIN && errno != EWOULDBLOCK) break; } - struct timespec interval = {0, TRITON_REPORT_INTERVAL_US * 1000L}; - nanosleep(&interval, nullptr); + sleep_us(controller_report_interval_us(TRITON_REPORT_INTERVAL_US)); } syscall(SYS_close, self->peer); munmap(self->ring, self->mapping_size); @@ -1709,7 +1732,7 @@ static constexpr int16_t TRITON_WIRED_PRODUCT = 0x1302; static constexpr int32_t TRITON_STICK_TOUCH = 2000; __attribute__((visibility("hidden"))) static void -build_triton_report(uint8_t *report, uint32_t packet, uint32_t buttons, const int32_t *axes, +build_triton_report(uint8_t *report, uint32_t packet, uint64_t elapsed_us, uint32_t buttons, const int32_t *axes, const DeckExtras &extras) { memset(report, 0, TRITON_STATE_BYTES); report[0] = static_cast(packet); @@ -1752,7 +1775,7 @@ build_triton_report(uint8_t *report, uint32_t packet, uint32_t buttons, const in put16(report + 23, extras.pads[2]); put16(report + 25, extras.pads[3]); put16(report + 27, extras.pressure[1]); - put32(report + 29, packet * TRITON_REPORT_INTERVAL_US); + put32(report + 29, static_cast(elapsed_us)); for (int i = 0; i < 3; i++) { put16(report + 33 + i * 2, extras.accel[i]); put16(report + 39 + i * 2, extras.gyro[i]); @@ -1768,6 +1791,7 @@ __attribute__((visibility("hidden"))) static void *triton_pad_thread(void *arg) uint32_t buttons = 0; int32_t axes[8] = {}; DeckExtras extras; + const uint64_t start_us = monotonic_us(); while (live_pad_type() == PAD_TYPE_STEAM) { if (read_snapshot(self->ring, snap) && snap.generation == ring_generation(self->ring)) { buttons = snap.buttons; @@ -1776,12 +1800,11 @@ __attribute__((visibility("hidden"))) static void *triton_pad_thread(void *arg) read_deck_extras(*self, extras); uint8_t report[TRITON_REPORT_ID_BYTES + TRITON_STATE_BYTES]; report[0] = TRITON_STATE_WIRED; - build_triton_report(report + 1, ++packet, buttons, axes, extras); + build_triton_report(report + 1, ++packet, monotonic_us() - start_us, buttons, axes, extras); if (send(self->peer, report, sizeof(report), MSG_DONTWAIT | MSG_NOSIGNAL) < 0 && errno != EAGAIN && errno != EWOULDBLOCK) break; - struct timespec interval = {0, TRITON_REPORT_INTERVAL_US * 1000L}; - nanosleep(&interval, nullptr); + sleep_us(controller_report_interval_us(TRITON_REPORT_INTERVAL_US)); } syscall(SYS_close, self->peer); munmap(self->ring, self->mapping_size); @@ -2019,8 +2042,7 @@ __attribute__((visibility("hidden"))) static void *touch_report_thread(void *arg if (send(self->peer, report, sizeof(report), MSG_DONTWAIT | MSG_NOSIGNAL) < 0 && errno != EAGAIN && errno != EWOULDBLOCK) break; - struct timespec interval = {0, TOUCH_REPORT_INTERVAL_US * 1000L}; - nanosleep(&interval, nullptr); + sleep_us(low_latency(LATENCY_TOUCH) ? LOW_LATENCY_REPORT_INTERVAL_US : TOUCH_REPORT_INTERVAL_US); } syscall(SYS_close, self->peer); touch_state([](TouchRingFile &r) { if (r.opened) r.opened--; }); @@ -3545,6 +3567,7 @@ EXPORT int close(int fd) { } static constexpr int kMaxRingWaitMs = 2; +static constexpr int kLowLatencyRingWaitUs = 250; EXPORT ssize_t read(int fd, void *buf, size_t count) { if (!watched(fd)) return syscall(SYS_read, fd, buf, count); @@ -3633,7 +3656,8 @@ 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 < kMaxRingWaitMs * 1000 * 1000) backoff_ns *= 2; + if (low_latency(LATENCY_CONTROLLER)) backoff_ns = kLowLatencyRingWaitUs * 1000; + else if (backoff_ns < kMaxRingWaitMs * 1000 * 1000) backoff_ns *= 2; } } @@ -3815,7 +3839,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 < kMaxRingWaitMs) + if (backoff_ms < kMaxRingWaitMs && !low_latency(LATENCY_CONTROLLER)) backoff_ms *= 2; } } @@ -3988,7 +4012,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 < kMaxRingWaitMs) + if (backoff_ms < kMaxRingWaitMs && !low_latency(LATENCY_CONTROLLER)) backoff_ms *= 2; } } diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index bdd3fc131..531b29732 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -261,6 +261,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var drawerPage by mutableStateOf(SessionDrawerPage.CONTROLLER) private var drawerControllerActive by mutableStateOf(false) private var backActionsInverted by mutableStateOf(false) + private var lowLatencyController by mutableStateOf(false) + private var lowLatencyTouch by mutableStateOf(false) private var padType by mutableStateOf(SessionPrefs.CONTROLLER_DECK) private var pcKeyboardOpen by mutableStateOf(false) private var achievementsInFront by mutableStateOf(null) @@ -631,6 +633,18 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { SessionPrefs.setBackActionsInverted(this@SessionActivity, inverted) backActionsInverted = inverted }, + lowLatencyController = lowLatencyController, + lowLatencyTouch = lowLatencyTouch, + onLowLatencyController = { on -> + SessionPrefs.setLowLatencyController(this@SessionActivity, on) + lowLatencyController = on + applyLowLatency() + }, + onLowLatencyTouch = { on -> + SessionPrefs.setLowLatencyTouch(this@SessionActivity, on) + lowLatencyTouch = on + applyLowLatency() + }, onOsc = { v -> SessionPrefs.setOscMode(this@SessionActivity, v) readPrefs() @@ -1101,6 +1115,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this) oscMode = SessionPrefs.oscMode(this) backActionsInverted = SessionPrefs.backActionsInverted(this) + lowLatencyController = SessionPrefs.lowLatencyController(this) + lowLatencyTouch = SessionPrefs.lowLatencyTouch(this) padType = SessionPrefs.steamController(this) } @@ -2442,8 +2458,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { super.onAttachedToWindow() if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return val decor = window.decorView - decor.requestUnbufferedDispatch(UNBUFFERED_SOURCES) - decor.viewTreeObserver.addOnGlobalFocusChangeListener { _, _ -> decor.post { decor.requestUnbufferedDispatch(UNBUFFERED_SOURCES) } } + decor.requestUnbufferedDispatch(unbufferedSources()) + decor.viewTreeObserver.addOnGlobalFocusChangeListener { _, _ -> decor.post { decor.requestUnbufferedDispatch(unbufferedSources()) } } + } + + private fun unbufferedSources() = + if (lowLatencyTouch) UNBUFFERED_SOURCES or InputDevice.SOURCE_CLASS_POINTER else UNBUFFERED_SOURCES + + private fun applyLowLatency() { + com.droiddeck.launcher.input.FakeInputWriter.setLowLatency(lowLatencyController, lowLatencyTouch) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) window.decorView.requestUnbufferedDispatch(unbufferedSources()) } override fun onWindowFocusChanged(hasFocus: Boolean) { diff --git a/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java b/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java index f698f481d..c3cc11f47 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java +++ b/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java @@ -79,6 +79,10 @@ public class FakeInputWriter { private static final int DECK_CONTROLS_OFFSET = IMU_OFFSET + 40; private static final int PAD_TYPE_OFFSET = IMU_OFFSET + 44; private static final int PAD_TYPE_SET = 0x80000000; + private static final int LATENCY_OFFSET = IMU_OFFSET + 48; + public static final int LATENCY_CONTROLLER = 1; + public static final int LATENCY_TOUCH = 2; + private static volatile int latency = 0; public static final int PAD_TYPE_DECK = 0; public static final int PAD_TYPE_STEAM = 1; public static final int PAD_TYPE_XBOX = 2; @@ -214,6 +218,7 @@ public class FakeInputWriter { data.putInt(DECK_CONTROLS_OFFSET, 0); int type = padType; data.putInt(PAD_TYPE_OFFSET, type >= 0 ? PAD_TYPE_SET | type : 0); + data.putInt(LATENCY_OFFSET, latency); data.putInt(TRITON_OFFSET, TRITON_MAGIC); data.putInt(TRITON_CONNECTED_OFFSET, tritonConnected ? 1 : 0); data.putLong(TRITON_INPUT_SEQ_OFFSET, 0L); @@ -514,6 +519,22 @@ public class FakeInputWriter { } } + public static void setLowLatency(boolean controller, boolean touch) { + int flags = (controller ? LATENCY_CONTROLLER : 0) | (touch ? LATENCY_TOUCH : 0); + latency = flags; + RingSlot ringSlot; + synchronized (RING_LOCK) { + ringSlot = RING_SLOTS[0]; + } + if (ringSlot == null) return; + synchronized (ringSlot) { + ByteBuffer ring = ringSlot.data; + if (ring == null) return; + nativeStoreFence(); + ring.putInt(LATENCY_OFFSET, flags); + } + } + public static void setTritonConnected(int slot, boolean connected) { tritonConnected = connected; RingSlot ringSlot = tritonSlot(slot); diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 1afb4fcfc..be3cc2568 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -130,6 +130,18 @@ object SessionPrefs { private fun mangoappSession(context: Context) = SessionState.mode == SessionService.MODE_STEAM && mangoapp(context) + fun lowLatencyController(context: Context): Boolean = prefs(context).getBoolean("lowLatencyController", false) + + fun setLowLatencyController(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("lowLatencyController", on).apply() + } + + fun lowLatencyTouch(context: Context): Boolean = prefs(context).getBoolean("lowLatencyTouch", false) + + fun setLowLatencyTouch(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("lowLatencyTouch", on).apply() + } + fun backActionsInverted(context: Context): Boolean = prefs(context).getBoolean("backActionsInverted", false) fun setBackActionsInverted(context: Context, inverted: Boolean) { diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 1435ae445..7a3ac4fd4 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -610,6 +610,7 @@ class SessionService : Service() { SessionState.deckPad = deckBinds.isNotEmpty() if (wantsDeck && !SessionState.deckPad) Log.w(TAG, "deck pad: not available this session; the pad stays an Xbox 360 controller") logControllersAtStart() + com.droiddeck.launcher.input.FakeInputWriter.setLowLatency(SessionPrefs.lowLatencyController(this), SessionPrefs.lowLatencyTouch(this)) if (uinput) { guest.add("FAKE_EVDEV_UINPUT=1") if (SessionState.deckPad) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index 3716418de..775804a62 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -233,6 +233,10 @@ class DrawerActions( val onSuspendPolicy: (String) -> Unit, val onSteamDownloadsInBackground: (Boolean) -> Unit = {}, val onBackActionsInverted: (Boolean) -> Unit, + val lowLatencyController: Boolean = false, + val lowLatencyTouch: Boolean = false, + val onLowLatencyController: (Boolean) -> Unit = {}, + val onLowLatencyTouch: (Boolean) -> Unit = {}, val onTouch: (String) -> Unit, val onFexPreset: (String) -> Unit, val onUpscaler: (Int) -> Unit = {}, @@ -497,6 +501,14 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont stringResource(R.string.unfolded_controls_hint), a.unfoldedControls, chipModifier = focus.track(page, "unfolded-controls"), onChange = a.onUnfoldedControls) } + SettingsGroup(stringResource(R.string.drawer_latency)) { + ToggleRow(host, "low-latency-controller", stringResource(R.string.drawer_low_latency_controller), + stringResource(R.string.drawer_low_latency_controller_hint), a.lowLatencyController, + chipModifier = focus.track(page, "low-latency-controller"), onChange = a.onLowLatencyController) + ToggleRow(host, "low-latency-touch", stringResource(R.string.drawer_low_latency_touch), + stringResource(R.string.drawer_low_latency_touch_hint), a.lowLatencyTouch, + chipModifier = focus.track(page, "low-latency-touch"), onChange = a.onLowLatencyTouch) + } SettingsGroup(stringResource(R.string.drawer_keyboard)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(8.dp)) { DrawerOutlineButton(stringResource(R.string.drawer_pc_keyboard), modifier = Modifier.weight(1f).height(48.dp).then(focus.track(page, "hardware"))) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8a79cbc1d..d6b5b7517 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -622,6 +622,11 @@ A negative mip bias: sharper textures, some shimmer. Auto matches how far the session is enlarged to the screen. Auto (%1$s) Keyboard + Latency + Low-latency controller + Pad reports at 1000 Hz instead of 250 Hz. Slightly more CPU. + Low-latency touch + Touches skip frame batching; Steam touch controls at 1000 Hz. Slightly more CPU. PC keyboard Android Second screen