diff --git a/app/src/main/cpp/fakeinput_steam.cpp b/app/src/main/cpp/fakeinput_steam.cpp index d88eb5dc4..c4a1da0ac 100644 --- a/app/src/main/cpp/fakeinput_steam.cpp +++ b/app/src/main/cpp/fakeinput_steam.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -1236,6 +1237,7 @@ struct DeckHidraw { FakeInputRingHeader *ring = nullptr; size_t mapping_size = 0; uint8_t pending_feature = 0; + bool touch = false; // Steam's touch controller (below), not the Deck }; // What the Deck has and an Xbox pad does not, after the ring's events: motion (PadMotion) and the @@ -1335,13 +1337,15 @@ __attribute__((visibility("hidden"))) static bool is_deck_hidraw_path(const char return pathname && fake_deck_enabled() && !strcmp(pathname, DECK_HIDRAW_PATH); } -// The app's own pads are the Deck now; their evdev nodes are withdrawn from everyone. +// The app's first pad is the Deck now: its evdev node is withdrawn from everyone. Further players' +// pads are left to the client alone, which hands them to games through Steam Input. __attribute__((visibility("hidden"))) static bool is_withdrawn_pad_path(const char *pathname) { if (!fake_deck_enabled() || !pathname || strncmp(pathname, "/dev/input/", 11)) return false; const char *event = strrchr(pathname, '/') + 1; if (strncmp(event, "event", 5) && strncmp(event, "js", 2)) return false; int number = get_event_number(event); - return number >= 0 && number < UINPUT_EVENT_BASE; + if (number < 0 || number >= UINPUT_EVENT_BASE) return false; + return number == 0 || !process_is_steam_client(); } static inline void put16(uint8_t *at, int value) { @@ -1503,6 +1507,321 @@ __attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags) { return pair[0]; } + +// ---- Steam's touch controller ---- +// +// docs/development/steam-touch-controller.md. Steam builds its touch controller (Steam Input's +// Mobile Touch type, the one Steam Link's on-screen controls are) for any HID device with the ids +// 0000:11fb. With FAKE_TOUCHCTL_RING set, the client is offered one as /dev/hidraw17 beside the +// Deck, its sysfs written by the app (SteamDeckPad.kt). The app draws the controls; this side only +// carries the device's traffic through the file FAKE_TOUCHCTL_RING names, which both map: +// +// - the app writes the 40-byte input report (under a seqlock), whether the device is plugged in, +// and the battery level; +// - this side writes what the client tells the device: the app and action set it is on (output +// report 4), the action set layers added and removed (5, 6), rumble (1, also passed to the app's +// vibration like any pad's) and the setting report (3), and how many readers have it open. +// +// Plugged in and out by the app (while its controls are up and no physical pad is in use), as +// Steam Link withdraws its touch controls: an unplugged device ends its open stream, refuses new +// opens, and a udev event (libblsession's stand-in monitor, udevmon.c) tells the client's HID +// discovery either way. Only the client is offered it; a game reads Steam Input's virtual pad. +static constexpr const char *TOUCH_HIDRAW_PATH = "/dev/hidraw17"; +static constexpr unsigned int TOUCH_HIDRAW_MINOR = 17; +static constexpr int TOUCH_REPORT_BYTES = 40; +static constexpr int TOUCH_REPORT_INTERVAL_US = 8000; +static constexpr const char *TOUCH_NAME = "Mobile Touch Control"; +static constexpr const char *TOUCH_SERIAL = "DROIDDECK0001"; +static constexpr const char *TOUCH_DEVPATH = "/devices/droiddeck/usb2/2-1:1.0/0003:0000:11FB.0002/hidraw/hidraw17"; +// Generic Desktop / Game Pad: for a vendor id that is not Valve's the client opens only a device +// with a gamepad usage. One 40-byte input report, one 64-byte output and one 64-byte feature +// report, no report ids. Must match SteamDeckPad.kt. +static const uint8_t kTouchReportDescriptor[] = { + 0x05, 0x01, 0x09, 0x05, 0xa1, 0x01, + 0x09, 0x02, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x28, 0x81, 0x02, + 0x09, 0x03, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0x91, 0x02, + 0x09, 0x04, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0}; + +// The shared file. Must match SteamTouchDevice.kt. +struct TouchRingFile { + uint32_t magic; // TCH1 + uint32_t version; + uint64_t report_seq; // odd while the app writes report + uint8_t report[TOUCH_REPORT_BYTES]; + uint32_t plugged; + uint32_t battery; // percent + // Written here. + uint64_t state_seq; // odd while being written + uint32_t opened; + uint32_t appid; + uint32_t action_set; + uint32_t layer_count; + uint32_t layers[8]; + uint32_t rumble_seq; + uint16_t rumble_low; + uint16_t rumble_high; + uint32_t rumble_ms; + uint32_t setting30; + uint32_t action_seq; +}; +static constexpr uint32_t TOUCH_RING_MAGIC = 0x31484354; +static constexpr size_t TOUCH_RING_SIZE = 256; +static_assert(offsetof(TouchRingFile, report) == 16, "TouchRingFile layout"); +static_assert(offsetof(TouchRingFile, plugged) == 56, "TouchRingFile layout"); +static_assert(offsetof(TouchRingFile, state_seq) == 64, "TouchRingFile layout"); +static_assert(offsetof(TouchRingFile, layers) == 88, "TouchRingFile layout"); +static_assert(offsetof(TouchRingFile, rumble_seq) == 120, "TouchRingFile layout"); +static_assert(offsetof(TouchRingFile, action_seq) == 136, "TouchRingFile layout"); +static_assert(sizeof(TouchRingFile) <= TOUCH_RING_SIZE, "TouchRingFile layout"); + +__attribute__((visibility("hidden"))) static TouchRingFile *touch_ring() { + static TouchRingFile *ring = nullptr; + static std::once_flag once; + std::call_once(once, [] { + const char *path = getenv("FAKE_TOUCHCTL_RING"); + if (!path || !*path || !fake_deck_enabled()) return; + static auto my_open = reinterpret_cast(dlsym(RTLD_NEXT, "open")); + int fd = my_open(path, O_RDWR | O_CLOEXEC); + if (fd < 0) { + Logger::log("touch: %s not openable (%s)\n", path, strerror(errno)); + return; + } + void *mapping = mmap(nullptr, TOUCH_RING_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + syscall(SYS_close, fd); + if (mapping == MAP_FAILED || static_cast(mapping)->magic != TOUCH_RING_MAGIC) { + Logger::log("touch: %s %s\n", path, mapping == MAP_FAILED ? "could not be mapped" : "has no valid header"); + if (mapping != MAP_FAILED) munmap(mapping, TOUCH_RING_SIZE); + return; + } + ring = static_cast(mapping); + }); + return ring; +} + +__attribute__((visibility("hidden"))) static bool touch_plugged() { + TouchRingFile *ring = touch_ring(); + return ring && __atomic_load_n(&ring->plugged, __ATOMIC_ACQUIRE) != 0; +} + +__attribute__((visibility("hidden"))) static bool is_touch_hidraw_path(const char *pathname) { + return pathname && !strcmp(pathname, TOUCH_HIDRAW_PATH) && process_is_steam_client() && touch_ring(); +} + +// The state the client gave the device, written under the ring's seqlock. +template +__attribute__((visibility("hidden"))) static void touch_state(F &&write) { + TouchRingFile *ring = touch_ring(); + if (!ring) return; + static std::mutex mutex; + std::lock_guard guard(mutex); + __atomic_fetch_add(&ring->state_seq, 1, __ATOMIC_ACQ_REL); + write(*ring); + __atomic_fetch_add(&ring->state_seq, 1, __ATOMIC_ACQ_REL); +} + +static inline uint32_t get32(const uint8_t *at) { + return static_cast(at[0]) | static_cast(at[1]) << 8 | static_cast(at[2]) << 16 | + static_cast(at[3]) << 24; +} + +// An output report from the client (Steam Link's CVirtualController::HandleFeatureReports): the +// type, then its fields. +__attribute__((visibility("hidden"))) static void touch_output(const uint8_t *data, size_t size) { + if (size < 9) return; + static std::atomic seen[8]; + uint8_t type = data[0]; + unsigned n = type < 8 ? ++seen[type] : 0; + if (n && (n & (n - 1)) == 0) + Logger::log("touch: output report %u (x%u): app %u, %u\n", type, n, get32(data + 1), get32(data + 5)); + switch (type) { + case 1: { // rumble: low, high, duration + uint16_t low = data[1] | data[2] << 8, high = data[3] | data[4] << 8; + uint32_t ms = get32(data + 5); + touch_state([&](TouchRingFile &r) { + r.rumble_low = low; + r.rumble_high = high; + r.rumble_ms = ms; + r.rumble_seq++; + }); + send_vibration(low, high, static_cast(std::min(ms, 0xffff)), 0); + break; + } + case 3: // a setting: 0x30 and a value + if (data[1] == 0x30) touch_state([&](TouchRingFile &r) { r.setting30 = data[2] | data[3] << 8; }); + break; + case 4: // the app and its action set + touch_state([&](TouchRingFile &r) { + r.appid = get32(data + 1); + r.action_set = get32(data + 5); + r.layer_count = 0; + r.action_seq++; + }); + break; + case 5: // a layer added to the app's action set + case 6: // and removed + touch_state([&](TouchRingFile &r) { + uint32_t layer = get32(data + 5); + uint32_t count = std::min(r.layer_count, 8); + uint32_t kept = 0; + for (uint32_t i = 0; i < count; i++) + if (r.layers[i] != layer) r.layers[kept++] = r.layers[i]; + if (type == 5 && kept < 8) r.layers[kept++] = layer; + r.layer_count = kept; + r.action_seq++; + }); + break; + } +} + +__attribute__((visibility("hidden"))) static bool read_touch_report(TouchRingFile *ring, uint8_t *out) { + for (int attempt = 0; attempt < 8; attempt++) { + uint64_t seq = __atomic_load_n(&ring->report_seq, __ATOMIC_ACQUIRE); + if (seq & 1) continue; + memcpy(out, ring->report, TOUCH_REPORT_BYTES); + __atomic_thread_fence(__ATOMIC_ACQUIRE); + if (seq == __atomic_load_n(&ring->report_seq, __ATOMIC_RELAXED)) return true; + } + return false; +} + +__attribute__((visibility("hidden"))) static void *touch_report_thread(void *arg) { + auto *holder = static_cast *>(arg); + std::shared_ptr self = *holder; + delete holder; + TouchRingFile *ring = touch_ring(); + uint8_t report[TOUCH_REPORT_BYTES] = {}; + while (touch_plugged()) { + read_touch_report(ring, report); + 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); + } + // Ending the stream is the unplug: the client's read fails and it lets the controller go. + syscall(SYS_close, self->peer); + touch_state([](TouchRingFile &r) { if (r.opened) r.opened--; }); + return nullptr; +} + +__attribute__((visibility("hidden"))) static int open_touch_hidraw(int flags) { + if (!touch_plugged()) { + errno = ENOENT; + return -1; + } + int pair[2]; + if (socketpair(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0, pair) < 0) return -1; + int buffer = TOUCH_REPORT_BYTES * 4; + setsockopt(pair[1], SOL_SOCKET, SO_SNDBUF, &buffer, sizeof(buffer)); + if (!(flags & O_CLOEXEC)) fcntl(pair[0], F_SETFD, 0); + if (flags & O_NONBLOCK) fcntl(pair[0], F_SETFL, fcntl(pair[0], F_GETFL) | O_NONBLOCK); + auto touch = std::make_shared(); + touch->peer = pair[1]; + touch->touch = true; + { + std::lock_guard guard(controller_mutex()); + deck_map()[pair[0]] = touch; + } + touch_state([](TouchRingFile &r) { r.opened++; }); + pthread_t thread; + auto *arg = new std::shared_ptr(touch); + if (pthread_create(&thread, nullptr, touch_report_thread, arg) != 0) { + delete arg; + std::lock_guard guard(controller_mutex()); + deck_map().erase(pair[0]); + syscall(SYS_close, pair[0]); + syscall(SYS_close, pair[1]); + touch_state([](TouchRingFile &r) { if (r.opened) r.opened--; }); + errno = ENOMEM; + return -1; + } + pthread_detach(thread); + Logger::log("touch: %s opened as fd %d\n", TOUCH_HIDRAW_PATH, pair[0]); + return pair[0]; +} + +static int copy_ioctl_string(ioctl_request_t op, void *argp, const char *value); + +__attribute__((visibility("hidden"))) static int ioctl_touch(DeckHidraw &touch, ioctl_request_t op, void *argp) { + if (_IOC_TYPE(op) != 'H') { + errno = ENOTTY; + return -1; + } + size_t size = _IOC_SIZE(op); + switch (_IOC_NR(op)) { + case 0x01: // HIDIOCGRDESCSIZE + *static_cast(argp) = sizeof(kTouchReportDescriptor); + return 0; + case 0x02: { // HIDIOCGRDESC + auto *descriptor = static_cast(argp); + uint32_t want; + memcpy(&want, descriptor, sizeof(want)); + memcpy(descriptor + 4, kTouchReportDescriptor, std::min(want, sizeof(kTouchReportDescriptor))); + return 0; + } + case 0x03: { // HIDIOCGRAWINFO + struct { + uint32_t bustype; + int16_t vendor; + int16_t product; + } info = {BUS_USB, 0x0000, static_cast(0x11fb)}; + memcpy(argp, &info, sizeof(info)); + return 0; + } + case 0x04: return copy_ioctl_string(op, argp, TOUCH_NAME); // HIDIOCGRAWNAME + case 0x05: return copy_ioctl_string(op, argp, "usb-droiddeck-2/input0"); // HIDIOCGRAWPHYS + case 0x08: return copy_ioctl_string(op, argp, TOUCH_SERIAL); // HIDIOCGRAWUNIQ + case 0x06: // HIDIOCSFEATURE: Steam Link's controller ignores these + touch.pending_feature = size > 0 ? static_cast(argp)[0] : 0; + return static_cast(size); + case 0x07: { // HIDIOCGFEATURE: report 2 is the battery + auto *buf = static_cast(argp); + uint8_t id = size ? buf[0] : 0; + memset(buf, 0, size); + if (size) buf[0] = id; + if (size > 2) { + TouchRingFile *ring = touch_ring(); + buf[1] = 2; + buf[2] = static_cast(ring && ring->battery ? std::min(ring->battery, 100) : 100); + } + return static_cast(std::min(size, 18)); + } + default: + errno = EINVAL; + return -1; + } +} + +// Plugging in and out: the app flips `plugged`; this tells the client's HID discovery, which +// otherwise never looks again (the sandbox gives it no kernel uevents). +__attribute__((visibility("hidden"))) static void touch_uevent(const char *action) { + using inject_fn = int (*)(const char *, const char *, const char *, const char *, unsigned, unsigned); + static auto inject = reinterpret_cast(dlsym(RTLD_DEFAULT, "bl_udevmon_inject")); + int sent = inject ? inject(action, TOUCH_DEVPATH, "hidraw", TOUCH_HIDRAW_PATH, DECK_HIDRAW_MAJOR, TOUCH_HIDRAW_MINOR) : -1; + Logger::log("touch: %s (udev event to %d monitor%s)\n", action, sent, sent == 1 ? "" : "s"); +} + +__attribute__((visibility("hidden"))) static void *touch_plug_thread(void *) { + bool was = touch_plugged(); + Logger::log("touch: %s at start\n", was ? "plugged in" : "unplugged"); + for (;;) { + struct timespec interval = {0, 50 * 1000 * 1000}; + nanosleep(&interval, nullptr); + bool now = touch_plugged(); + if (now == was) continue; + was = now; + touch_uevent(now ? "add" : "remove"); + } + return nullptr; +} + +__attribute__((constructor)) static void start_touch_controller() { + config(); + if (!process_is_steam_client() || !touch_ring()) return; + pthread_t thread; + if (pthread_create(&thread, nullptr, touch_plug_thread, nullptr) == 0) pthread_detach(thread); +} + // libudev (systemd's sd-device) accepts a device only where its directory is on sysfs, which it // checks with fstatfs on the directory it resolved. The Deck's sysfs is the app's own files bound // into /sys, so those paths are said to be sysfs; nothing else is touched. @@ -1843,6 +2162,7 @@ EXPORT int open(const char *pathname, int flags, ...) { if (fake_uinput_enabled() && is_uinput_path(pathname)) return open_uinput(flags); if (is_deck_hidraw_path(pathname)) return open_deck_hidraw(flags); + if (is_touch_hidraw_path(pathname)) return open_touch_hidraw(flags); if (is_withdrawn_pad_path(pathname)) { errno = ENOENT; return -1; @@ -1917,6 +2237,7 @@ EXPORT int openat(int dirfd, const char *pathname, int flags, ...) { if (fake_uinput_enabled() && is_uinput_path(pathname)) return open_uinput(flags); if (is_deck_hidraw_path(pathname)) return open_deck_hidraw(flags); + if (is_touch_hidraw_path(pathname)) return open_touch_hidraw(flags); if (is_withdrawn_pad_path(pathname)) { errno = ENOENT; return -1; @@ -1977,6 +2298,12 @@ static int fake_stat_path(const char *pathname, S *statbuf, Real real) { errno = ENOENT; return -1; } + if (is_touch_hidraw_path(pathname)) { + memset(statbuf, 0, sizeof(*statbuf)); + statbuf->st_mode = S_IFCHR | 0666; + statbuf->st_rdev = makedev(DECK_HIDRAW_MAJOR, TOUCH_HIDRAW_MINOR); + return 0; + } if (is_deck_hidraw_path(pathname)) { memset(statbuf, 0, sizeof(*statbuf)); statbuf->st_mode = S_IFCHR | 0666; @@ -2028,7 +2355,7 @@ static int fake_stat_fd(int fd, S *buf, Real real) { std::lock_guard guard(controller_mutex()); if (ret == 0 && deck_map().count(fd)) { buf->st_mode = (buf->st_mode & ~S_IFMT) | S_IFCHR; - buf->st_rdev = makedev(DECK_HIDRAW_MAJOR, DECK_HIDRAW_MINOR); + buf->st_rdev = makedev(DECK_HIDRAW_MAJOR, deck_map()[fd]->touch ? TOUCH_HIDRAW_MINOR : DECK_HIDRAW_MINOR); return ret; } auto controller = controller_map().find(fd); @@ -2074,6 +2401,7 @@ EXPORT int access(const char *pathname, int mode) { static auto my_access = reinterpret_cast(dlsym(RTLD_NEXT, "access")); if (fake_uinput_enabled() && is_uinput_path(pathname)) return 0; if (is_deck_hidraw_path(pathname) && process_is_steam_client()) return 0; + if (is_touch_hidraw_path(pathname)) return 0; if (is_withdrawn_pad_path(pathname) || is_deck_hidraw_path(pathname)) { errno = ENOENT; return -1; @@ -2110,6 +2438,7 @@ EXPORT int faccessat(int dirfd, const char *pathname, int mode, int flags) { static auto my_faccessat = reinterpret_cast(dlsym(RTLD_NEXT, "faccessat")); if (fake_uinput_enabled() && is_uinput_path(pathname)) return 0; if (is_deck_hidraw_path(pathname) && process_is_steam_client()) return 0; + if (is_touch_hidraw_path(pathname)) return 0; if (is_withdrawn_pad_path(pathname) || is_deck_hidraw_path(pathname)) { errno = ENOENT; return -1; @@ -2231,7 +2560,8 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) { auto maker = uinput_map().find(fd); if (maker != uinput_map().end()) return ioctl_uinput(*maker->second, op, argp); auto deck = deck_map().find(fd); - if (deck != deck_map().end()) return ioctl_deck(*deck->second, op, argp); + if (deck != deck_map().end()) + return deck->second->touch ? ioctl_touch(*deck->second, op, argp) : ioctl_deck(*deck->second, op, argp); auto controller = controller_map().find(fd); if (controller == controller_map().end()) { guard.unlock(); @@ -2557,7 +2887,10 @@ EXPORT ssize_t write(int fd, const void *buf, size_t count) { std::unique_lock guard(controller_mutex()); auto made = uinput_map().find(fd); if (made != uinput_map().end()) return write_uinput(*made->second, buf, count); - if (deck_map().count(fd)) return static_cast(count); + if (deck_map().count(fd)) { + if (deck_map()[fd]->touch) touch_output(static_cast(buf), count); + return static_cast(count); + } auto controller = controller_map().find(fd); if (controller != controller_map().end()) { if (fake_fd_is_stale(fd)) { @@ -2582,7 +2915,11 @@ EXPORT ssize_t writev(int fd, const struct iovec *iov, int iovcnt) { std::unique_lock guard(controller_mutex()); if (deck_map().count(fd)) { ssize_t total = 0; - for (int i = 0; i < iovcnt; i++) total += static_cast(iov[i].iov_len); + bool touch = deck_map()[fd]->touch; + for (int i = 0; i < iovcnt; i++) { + if (touch) touch_output(static_cast(iov[i].iov_base), iov[i].iov_len); + total += static_cast(iov[i].iov_len); + } return total; } auto made = uinput_map().find(fd); diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 363305fa5..96a1b9d4c 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -145,6 +145,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (resumed && !pipUi && SessionState.deckPad) padMotion?.start() else padMotion?.stop() } private var onScreenControls: OnScreenControls? = null + /** Steam's touch controls (SteamTouchControls), made once the session offers the device. */ + private var steamTouchControls: com.droiddeck.launcher.input.SteamTouchControls? = null + private var steamTouchAvailable by mutableStateOf(false) + private val steamTouchRetry = Runnable { updateOnScreenControls() } + /** While Steam's touch controls are up: how the game's touch config says touches outside them + * act (Steam Link's input and mouse modes), in place of the drawer's touch mode. */ + private var steamTouchOptions: com.droiddeck.launcher.input.SteamTouchConfig.Options? = null + /** The touch mode touches are routed by: the drawer's, or the game's while Steam's controls are up. */ + private var routedTouchMode = SessionPrefs.TOUCH_AUTO private var keyboard: KeyboardHost? = null private var controllerSettings by mutableStateOf(null) private val sessionClipboard by lazy { @@ -295,6 +304,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } override fun onInputDeviceRemoved(deviceId: Int) { Log.i(TAG, "input device $deviceId disconnected") + padBridge?.onDeviceRemoved(deviceId) updateOnScreenControls() updatePointerCapture() } @@ -562,7 +572,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { SessionPrefs.setBackActionsInverted(this@SessionActivity, inverted) backActionsInverted = inverted }, - onOsc = { v -> SessionPrefs.setOscMode(this@SessionActivity, v); readPrefs(); updateOnScreenControls() }, + onOsc = { v -> + SessionPrefs.setOscMode(this@SessionActivity, v) + readPrefs() + updateOnScreenControls() + if (v == SessionPrefs.OSC_STEAM_TOUCH && com.droiddeck.launcher.input.SteamTouchDevice.current == null) + Toast.makeText(this@SessionActivity, R.string.osc_style_next_session, Toast.LENGTH_LONG).show() + }, + onEditSteamTouch = if (steamTouchAvailable && oscMode == SessionPrefs.OSC_STEAM_TOUCH && onScreenButtonsVisible) ({ + drawerOpen = false + steamTouchControls?.startEditing() + }) else null, onSuspendPolicy = { policy -> SessionPrefs.setSuspendPolicy(this@SessionActivity, SessionState.mode, policy) suspendPolicy = policy @@ -797,6 +817,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (nextTouchMode == SessionPrefs.TOUCH_OFF) cursorVisible = false } touchMode = nextTouchMode + routedTouchMode = effectiveTouchMode() frameGen = FrameGen.mode(this) fexPreset = SessionPrefs.fexPreset(this) suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode) @@ -1707,8 +1728,32 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } } + /** The touch mode in force: the game's own while Steam's touch controls are shown. */ + private fun effectiveTouchMode(): String { + val options = steamTouchOptions + if (options == null || steamTouchControls?.visibility != View.VISIBLE) return SessionPrefs.touchMode(this) + return when { + options.inputMode == com.droiddeck.launcher.input.SteamTouchConfig.INPUT_CONTROLLER -> SessionPrefs.TOUCH_OFF + options.mouseMode == com.droiddeck.launcher.input.SteamTouchConfig.MOUSE_RELATIVE -> SessionPrefs.TOUCH_PAD + else -> SessionPrefs.TOUCH_DIRECT + } + } + + private fun applyTouchRouting() { + val next = effectiveTouchMode() + if (::touchpad.isInitialized) { + val speed = steamTouchOptions?.trackpadSensitivity?.takeIf { steamTouchControls?.visibility == View.VISIBLE } ?: 1f + touchpad.sensitivity = 1.15f * speed + } + if (next == routedTouchMode) return + if (::touchpad.isInitialized) touchpad.cancel() + if (CompositorHost.isStarted) WaylandCompositor.nativeSendTouch(3, -1, 0, 0) + if (next == SessionPrefs.TOUCH_OFF) cursorVisible = false + routedTouchMode = next + } + /** Touchpad on the desktop, direct in Steam, unless the drawer says otherwise. */ - private fun usingTouchpad(): Boolean = when (SessionPrefs.touchMode(this)) { + private fun usingTouchpad(): Boolean = when (routedTouchMode) { SessionPrefs.TOUCH_PAD -> true SessionPrefs.TOUCH_DIRECT -> false else -> SessionState.mode != SessionService.MODE_STEAM || SessionState.steamUi == "desktop" @@ -1730,7 +1775,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun onTouchEvent(event: MotionEvent): Boolean { if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE)) return onMouse(event) // Child views get the event first, so menus, keyboards and the on-screen pad still work. - if (touchMode == SessionPrefs.TOUCH_OFF) return true + if (routedTouchMode == SessionPrefs.TOUCH_OFF) return true if (usingTouchpad()) { val rect = drawnRect() ?: return false if (touchpad.bounds != rect) { @@ -1849,6 +1894,41 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { what, d.name, d.vendorId, d.productId, d.id, d.sources)) } + /** Steam's touch controls, once the session has made the device (SessionService). */ + private fun steamTouch(): com.droiddeck.launcher.input.SteamTouchControls? { + val device = com.droiddeck.launcher.input.SteamTouchDevice.current ?: return null + steamTouchControls?.let { view -> + if (view.device === device) return view + // A new session made a new device: the view of the old one goes. + view.releaseAll() + (view.parent as? android.view.ViewGroup)?.removeView(view) + steamTouchControls = null + } + val root = onScreenControls?.parent as? android.view.ViewGroup ?: return null + val view = com.droiddeck.launcher.input.SteamTouchControls(this, device, + onMenu = { drawerOpen = true }, onKeyboard = ::togglePcKeyboard) + view.visibility = View.GONE + view.onOptions = { options -> + steamTouchOptions = options + applyTouchRouting() + } + // Paste: the clipboard is shared with the session's X servers; Ctrl+V types it. + view.onPaste = { + sendGuestKey(29, true); sendGuestKey(47, true) // KEY_LEFTCTRL, KEY_V + sendGuestKey(47, false); sendGuestKey(29, false) + } + // The client reads a controller's config when it connects: after a save, plug the device + // out and back in so it takes the new one. + view.onSaved = { + device.plugged = false + uiHandler.postDelayed({ updateOnScreenControls() }, 700) + } + root.addView(view, root.indexOfChild(onScreenControls) + 1) + steamTouchControls = view + steamTouchAvailable = true + return view + } + private fun updateOnScreenControls() { val forced = File(Environment.getExternalStorageDirectory(), "Download/droiddeck-osc") .takeIf { it.isFile } @@ -1859,20 +1939,49 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { onScreenButtonsVisible = false controls.releaseAll() controls.visibility = View.GONE + steamTouchControls?.let { it.releaseAll(); it.visibility = View.GONE } + com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false return } val buttonsOnly = forced == SessionPrefs.OSC_STEAM_QAM + // Steam's touch controller stands in for the app's pad, and is plugged into the client only + // while it is shown - as Steam Link withdraws its controls when a physical pad is in use. + val wantsSteam = forced == SessionPrefs.OSC_STEAM_TOUCH && SessionState.mode == SessionService.MODE_STEAM + val steam = if (wantsSteam) steamTouch() else null + // The service makes the device while the session starts, after this activity: the app's + // own pad until then, and another look in a moment. + if (wantsSteam && steam == null && !isFinishing && !isDestroyed) { + uiHandler.removeCallbacks(steamTouchRetry) + uiHandler.postDelayed(steamTouchRetry, 1000) + } + if (steam == null) { + steamTouchControls?.let { it.releaseAll(); it.visibility = View.GONE } + com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false + } controls.setButtonsOnly(buttonsOnly) val show = when (forced) { SessionPrefs.OSC_ALWAYS -> true SessionPrefs.OSC_STEAM_QAM -> true SessionPrefs.OSC_NEVER -> false + // Shown throughout a Steam session: a handheld's built-in pad would keep Auto off for good. + // Outside Steam it is Auto. + SessionPrefs.OSC_STEAM_TOUCH -> SessionState.mode == SessionService.MODE_STEAM || + (!PadBridge.anyControllerConnected() && SessionState.mode != SessionService.MODE_DESKTOP) // Auto: the touch pad when there is no controller - except on the desktop, where the // screen is a touchpad for the pointer and a pad over it would be in the way. A game // started from the rail, or Steam, gets it; the drawer turns it on anywhere. else -> !PadBridge.anyControllerConnected() && SessionState.mode != SessionService.MODE_DESKTOP } onScreenButtonsVisible = show + if (steam != null) { + if (controls.visibility == View.VISIBLE) { controls.releaseAll(); controls.visibility = View.GONE } + if (!show) steam.releaseAll() + steam.visibility = if (show) View.VISIBLE else View.GONE + com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = show + applyTouchRouting() + Log.i(TAG, "on-screen controls: Steam's touch controller " + if (show) "shown" else "hidden") + return + } if (show == (controls.visibility == View.VISIBLE)) return if (!show) controls.releaseAll() controls.visibility = if (show) View.VISIBLE else View.GONE @@ -2096,6 +2205,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } override fun onDestroy() { + uiHandler.removeCallbacks(steamTouchRetry) if (runtimeRemovalBlocked) { super.onDestroy(); return } // Deliberately does NOT end the session: this activity can be destroyed while the user is // in another app, and the whole point of the service is that Steam survives that. diff --git a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java index e7a781576..85d2d80a9 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java +++ b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java @@ -10,6 +10,8 @@ import android.view.MotionEvent; import com.droiddeck.launcher.session.SessionState; import java.io.File; +import java.util.Arrays; +import java.util.concurrent.atomic.AtomicIntegerArray; /** * A physical controller, republished as a synthetic evdev device the Steam client can see. @@ -25,11 +27,14 @@ import java.io.File; * to the client instead ({@link com.droiddeck.launcher.session.SteamDeckPad}), which has a Quick * Access button of its own; an Xbox pad has none, so there the client's menu is reached with the * Guide-then-A chord the client itself answers. + * + *

Each further controller is another player: slots 1 to 3, an Xbox 360 pad each, which in a + * Steam session only the client reads (libfakeinput) and hands games through Steam Input. */ public final class PadBridge { private static final String TAG = "PadBridge"; - /** Slot 0 is the one the session exports; extra slots would each show as another pad. */ - private static final int SLOT = 0; + /** Player slots: the session prepares a ring for each (SessionService). */ + public static final int SLOTS = 4; private static final float DEAD_ZONE = 0.12f; // The client takes A as part of the chord only once it has had Guide held for a while, and a // client starved of CPU needs longer. Too short, and it acts on A as well, selecting whatever it @@ -50,8 +55,13 @@ public final class PadBridge { chordHandler = new Handler(thread.getLooper()); } + private final File fakeInputDir; private final FakeInputWriter writer; private final PadState state = new PadState(); + /** The input device id holding each slot, or {@link #NO_CONTROLLER}. */ + private final int[] owners = new int[SLOTS]; + private final FakeInputWriter[] playerWriters = new FakeInputWriter[SLOTS]; + private final PadState[] playerStates = new PadState[SLOTS]; private final PadState effectiveState = new PadState(); private final Handler mainHandler = new Handler(Looper.getMainLooper()); private boolean open; @@ -79,29 +89,50 @@ public final class PadBridge { /** No physical controller is driving the pad: nothing yet, or the on-screen controls were last. */ public static final int NO_CONTROLLER = -1; /** - * The Android input device id of the physical controller that last drove the pad, or - * {@link #NO_CONTROLLER}. Every pad feeds the one exported slot, so this is how a game's rumble - * on that slot finds the motors in the player's hands (RumbleComponent). + * The Android input device id of the physical controller that last drove any slot, or + * {@link #NO_CONTROLLER}: where rumble goes when it names no slot of ours (RumbleComponent). */ private static volatile int activeControllerId = NO_CONTROLLER; + /** Per slot, the controller its rumble belongs to; slot 0's is none while the on-screen controls lead. */ + private static final AtomicIntegerArray slotControllers = new AtomicIntegerArray(SLOTS); + static { + for (int slot = 0; slot < SLOTS; slot++) slotControllers.set(slot, NO_CONTROLLER); + } public static int activeControllerId() { return activeControllerId; } + /** The controller playing in this ring slot, or the last one used for any other slot. */ + public static int controllerForSlot(int slot) { + return slot >= 0 && slot < SLOTS ? slotControllers.get(slot) : activeControllerId; + } + public PadBridge(File fakeInputDir) { - writer = new FakeInputWriter(fakeInputDir.getAbsolutePath(), SLOT); + this.fakeInputDir = fakeInputDir; + writer = new FakeInputWriter(fakeInputDir.getAbsolutePath(), 0); + Arrays.fill(owners, NO_CONTROLLER); + for (int slot = 1; slot < SLOTS; slot++) playerStates[slot] = new PadState(); } /** Opens the ring; safe to call more than once. */ public synchronized boolean start() { if (!open) { open = writer.open(); - Log.i(TAG, "ring slot " + SLOT + (open ? " open" : " NOT open")); + Log.i(TAG, "ring slot 0" + (open ? " open" : " NOT open")); } return open; } public synchronized void stop() { activeControllerId = NO_CONTROLLER; + for (int slot = 0; slot < SLOTS; slot++) { + owners[slot] = NO_CONTROLLER; + slotControllers.set(slot, NO_CONTROLLER); + if (playerWriters[slot] != null) { + playerWriters[slot].destroy(); + playerWriters[slot] = null; + } + if (playerStates[slot] != null) playerStates[slot].clear(); + } systemGuidePressed = false; systemQamPressed = false; qamChordActive = false; @@ -179,37 +210,39 @@ public final class PadBridge { /** @return true when the event was a pad button and has been consumed. */ public synchronized boolean onKeyEvent(KeyEvent event) { if (!isControllerKey(event)) return false; - noteDevice(event.getDevice()); + int slot = slotFor(event.getDevice()); + PadState pad = stateFor(slot); + noteDevice(event.getDevice(), slot); boolean pressed = event.getAction() == KeyEvent.ACTION_DOWN; switch (event.getKeyCode()) { - case KeyEvent.KEYCODE_BUTTON_A: state.press(0, pressed); break; - case KeyEvent.KEYCODE_BUTTON_B: state.press(1, pressed); break; - case KeyEvent.KEYCODE_BUTTON_X: state.press(2, pressed); break; - case KeyEvent.KEYCODE_BUTTON_Y: state.press(3, pressed); break; - case KeyEvent.KEYCODE_BUTTON_L1: state.press(4, pressed); break; - case KeyEvent.KEYCODE_BUTTON_R1: state.press(5, pressed); break; + case KeyEvent.KEYCODE_BUTTON_A: pad.press(0, pressed); break; + case KeyEvent.KEYCODE_BUTTON_B: pad.press(1, pressed); break; + case KeyEvent.KEYCODE_BUTTON_X: pad.press(2, pressed); break; + case KeyEvent.KEYCODE_BUTTON_Y: pad.press(3, pressed); break; + case KeyEvent.KEYCODE_BUTTON_L1: pad.press(4, pressed); break; + case KeyEvent.KEYCODE_BUTTON_R1: pad.press(5, pressed); break; case KeyEvent.KEYCODE_BUTTON_SELECT: - case KeyEvent.KEYCODE_BACK: state.press(6, pressed); break; + case KeyEvent.KEYCODE_BACK: pad.press(6, pressed); break; case KeyEvent.KEYCODE_BUTTON_START: - case KeyEvent.KEYCODE_MENU: state.press(7, pressed); break; - case KeyEvent.KEYCODE_BUTTON_THUMBL: state.press(8, pressed); break; - case KeyEvent.KEYCODE_BUTTON_THUMBR: state.press(9, pressed); break; + case KeyEvent.KEYCODE_MENU: pad.press(7, pressed); break; + case KeyEvent.KEYCODE_BUTTON_THUMBL: pad.press(8, pressed); break; + case KeyEvent.KEYCODE_BUTTON_THUMBR: pad.press(9, pressed); break; // The client's own in-game menu is opened by this one; the interposer publishes it as // BTN_MODE, which SDL reports as the "guide" button. case KeyEvent.KEYCODE_BUTTON_MODE: - case KeyEvent.KEYCODE_HOME: state.press(PadState.GUIDE, pressed); break; - case KeyEvent.KEYCODE_BUTTON_L2: state.leftTrigger = pressed ? 1f : 0f; break; - case KeyEvent.KEYCODE_BUTTON_R2: state.rightTrigger = pressed ? 1f : 0f; break; - case KeyEvent.KEYCODE_DPAD_UP: state.up = pressed; break; - case KeyEvent.KEYCODE_DPAD_RIGHT: state.right = pressed; break; - case KeyEvent.KEYCODE_DPAD_DOWN: state.down = pressed; break; - case KeyEvent.KEYCODE_DPAD_LEFT: state.left = pressed; break; + case KeyEvent.KEYCODE_HOME: pad.press(PadState.GUIDE, pressed); break; + case KeyEvent.KEYCODE_BUTTON_L2: pad.leftTrigger = pressed ? 1f : 0f; break; + case KeyEvent.KEYCODE_BUTTON_R2: pad.rightTrigger = pressed ? 1f : 0f; break; + case KeyEvent.KEYCODE_DPAD_UP: pad.up = pressed; break; + case KeyEvent.KEYCODE_DPAD_RIGHT: pad.right = pressed; break; + case KeyEvent.KEYCODE_DPAD_DOWN: pad.down = pressed; break; + case KeyEvent.KEYCODE_DPAD_LEFT: pad.left = pressed; break; default: return false; } if (pressed) notePlayerInput(); statButtons++; scheduleStats(); - publish(); + publish(slot); return true; } @@ -217,33 +250,35 @@ public final class PadBridge { public synchronized boolean onMotionEvent(MotionEvent event) { if (!isFromController(event.getDevice())) return false; if (event.getAction() != MotionEvent.ACTION_MOVE) return false; - noteDevice(event.getDevice()); + int slot = slotFor(event.getDevice()); + PadState pad = stateFor(slot); + noteDevice(event.getDevice(), slot); statAxes++; scheduleStats(); - state.leftX = axis(event, MotionEvent.AXIS_X); + pad.leftX = axis(event, MotionEvent.AXIS_X); // Android's Y axis grows downwards and evdev's ABS_Y does too, so no flip here: what the // pad reports as "down" is what the client is told. - state.leftY = axis(event, MotionEvent.AXIS_Y); - state.rightX = axis(event, MotionEvent.AXIS_Z); - state.rightY = axis(event, MotionEvent.AXIS_RZ); + pad.leftY = axis(event, MotionEvent.AXIS_Y); + pad.rightX = axis(event, MotionEvent.AXIS_Z); + pad.rightY = axis(event, MotionEvent.AXIS_RZ); float lt = event.getAxisValue(MotionEvent.AXIS_LTRIGGER); float rt = event.getAxisValue(MotionEvent.AXIS_RTRIGGER); // Some pads only report the triggers on BRAKE/GAS. if (lt == 0f) lt = event.getAxisValue(MotionEvent.AXIS_BRAKE); if (rt == 0f) rt = event.getAxisValue(MotionEvent.AXIS_GAS); - state.leftTrigger = lt; - state.rightTrigger = rt; + pad.leftTrigger = lt; + pad.rightTrigger = rt; float hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X); float hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y); - state.up = hatY < -0.5f; - state.right = hatX > 0.5f; - state.down = hatY > 0.5f; - state.left = hatX < -0.5f; - if (Math.max(Math.max(Math.abs(state.leftX), Math.abs(state.leftY)), Math.max(Math.abs(state.rightX), Math.abs(state.rightY))) > 0.5f - || lt > 0.5f || rt > 0.5f || state.up || state.right || state.down || state.left) { + pad.up = hatY < -0.5f; + pad.right = hatX > 0.5f; + pad.down = hatY > 0.5f; + pad.left = hatX < -0.5f; + if (Math.max(Math.max(Math.abs(pad.leftX), Math.abs(pad.leftY)), Math.max(Math.abs(pad.rightX), Math.abs(pad.rightY))) > 0.5f + || lt > 0.5f || rt > 0.5f || pad.up || pad.right || pad.down || pad.left) { notePlayerInput(); } - publish(); + publish(slot); return true; } @@ -254,6 +289,7 @@ public final class PadBridge { public synchronized void applyTouch(java.util.function.Consumer mutation) { mutation.accept(state); activeControllerId = NO_CONTROLLER; + slotControllers.set(0, NO_CONTROLLER); notePlayerInput(); statOnScreen++; scheduleStats(); @@ -278,6 +314,18 @@ public final class PadBridge { public synchronized void releaseAll() { state.clear(); publish(); + for (int slot = 1; slot < SLOTS; slot++) { + if (playerWriters[slot] == null) continue; + playerStates[slot].clear(); + publish(slot); + } + } + + /** A controller went away: its player's buttons are let go, and a further player's pad is unplugged. */ + public synchronized void onDeviceRemoved(int deviceId) { + for (int slot = 0; slot < SLOTS; slot++) { + if (owners[slot] == deviceId) free(slot); + } } /** Opens the client's Quick Access menu: a tap of the Deck's button, or the Guide-then-A chord. */ @@ -333,16 +381,56 @@ public final class PadBridge { publish(); } + /** The slot this controller plays in: the one it has, else the first free one, else player 1's. */ + private int slotFor(InputDevice device) { + int id = device.getId(); + if (owners[0] == id) return 0; + for (int slot = 1; slot < SLOTS; slot++) { + if (owners[slot] == id) return slot; + } + for (int slot = 0; slot < SLOTS; slot++) { + if (owners[slot] != NO_CONTROLLER && InputDevice.getDevice(owners[slot]) == null) free(slot); + } + for (int slot = 0; slot < SLOTS; slot++) { + if (owners[slot] != NO_CONTROLLER) continue; + owners[slot] = id; + Log.i(TAG, "\"" + device.getName() + "\" (id " + id + ") is player " + (slot + 1)); + return slot; + } + return 0; + } + + private PadState stateFor(int slot) { + return slot == 0 ? state : playerStates[slot]; + } + + private void free(int slot) { + Log.i(TAG, "player " + (slot + 1) + " (id " + owners[slot] + ") left"); + owners[slot] = NO_CONTROLLER; + slotControllers.set(slot, NO_CONTROLLER); + if (slot == 0) { + state.clear(); + publish(); + return; + } + playerStates[slot].clear(); + if (playerWriters[slot] != null) { + playerWriters[slot].destroy(); + playerWriters[slot] = null; + } + } + /** Per event, so the common case - the same pad as last time - is a single compare. */ - private void noteDevice(InputDevice device) { + private void noteDevice(InputDevice device, int slot) { activeControllerId = device.getId(); + slotControllers.set(slot, device.getId()); if (device.getId() == lastDeviceId) return; lastDeviceId = device.getId(); statDevice = device.getName(); if (seenDevices.add(device.getId())) { - Log.i(TAG, String.format(java.util.Locale.ROOT, "first input from \"%s\" (%04x:%04x, id %d, sources 0x%x); ring %s, deck pad %b", + Log.i(TAG, String.format(java.util.Locale.ROOT, "first input from \"%s\" (%04x:%04x, id %d, sources 0x%x); slot %d, ring %s, deck pad %b", device.getName(), device.getVendorId(), device.getProductId(), device.getId(), device.getSources(), - open ? "open" : "not open yet", SessionState.getDeckPad())); + slot, open ? "open" : "not open yet", SessionState.getDeckPad())); } } @@ -362,6 +450,23 @@ public final class PadBridge { statButtons = statAxes = statOnScreen = statDropped = 0; } + private void publish(int slot) { + if (slot == 0) { + publish(); + return; + } + FakeInputWriter player = playerWriters[slot]; + if (player == null) { + player = new FakeInputWriter(fakeInputDir.getAbsolutePath(), slot); + if (!player.open()) { + statDropped++; + return; + } + playerWriters[slot] = player; + } + player.writePad(playerStates[slot]); + } + private void publish() { if (!open && !start()) { statDropped++; diff --git a/app/src/main/java/com/droiddeck/launcher/input/PadMotion.kt b/app/src/main/java/com/droiddeck/launcher/input/PadMotion.kt index 31707a9a0..2c63badc7 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/PadMotion.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/PadMotion.kt @@ -60,6 +60,7 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) : Handler(worker.looper).post { gyro.fill(0) FakeInputWriter.writeMotion(SLOT, accel, gyro) + SteamTouchDevice.current?.setMotion(accel, gyro) } worker.quitSafely() } @@ -85,6 +86,7 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) : target[1] = counts(-z * scale) target[2] = counts(y * scale) FakeInputWriter.writeMotion(SLOT, accel, gyro) + SteamTouchDevice.current?.setMotion(accel, gyro) } override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit diff --git a/app/src/main/java/com/droiddeck/launcher/input/SteamTouchBindings.kt b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchBindings.kt new file mode 100644 index 000000000..f5f46d88c --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchBindings.kt @@ -0,0 +1,285 @@ +package com.droiddeck.launcher.input + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.LruCache +import java.io.File + +/** + * What each touch control is bound to, and the icon it shows - Steam's, as its configurator keeps + * them. + * + * A Steam Input binding is one string: `,