From 0da45e53cb5b833eb1ceddc59efe9b3cf92eae84 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:12:46 -0400 Subject: [PATCH] Steam's touch controller as on-screen controls (beta) (#431) * feat: Steam's touch controller as on-screen controls (beta) * fix: Steam touch controls appear once the session makes the device; tidier default layout * fix: Steam touch layout decoding, saved layouts and new sessions * docs: Steam's touch controls * feat: Steam's touch icons, labels and colours on the touch controls; pick and save them like Steam * feat: Steam touch icon picker as in-session panels; layout colour; docs * Give each physical controller its own player slot Every controller fed the one exported pad, so two pads played as one. The first controller keeps slot 0 (the Deck in a Steam session); further ones get slots 1-3 as separate Xbox 360 pads, created on first input and removed on disconnect. In Deck mode only the Steam client sees those pads and hands them to games through Steam Input. Rumble follows the slot it names. * feat: Steam Link's touch menu, per-game input modes, action set editing, add/copy/paste, oval controls, paste control * feat: Steam touch controls drawn as Steam Link draws them; Steam Link's touch menu and layout tray * feat: Steam touch controls in DroidDeck's style, D-mark guide button, shake to hide * refactor: Steam touch controls become an On-screen controls choice instead of a second dropdown --------- Co-authored-by: maxjivi05 --- app/src/main/cpp/fakeinput_steam.cpp | 349 +++- .../com/droiddeck/launcher/SessionActivity.kt | 116 +- .../droiddeck/launcher/input/PadBridge.java | 191 +- .../com/droiddeck/launcher/input/PadMotion.kt | 2 + .../launcher/input/SteamTouchBindings.kt | 285 +++ .../launcher/input/SteamTouchConfig.kt | 654 +++++++ .../launcher/input/SteamTouchControls.kt | 1597 +++++++++++++++++ .../launcher/input/SteamTouchDevice.kt | 171 ++ .../launcher/input/SteamTouchIconPicker.kt | 274 +++ .../launcher/session/RumbleComponent.kt | 60 +- .../launcher/session/SessionPrefs.kt | 2 + .../launcher/session/SessionService.kt | 10 +- .../launcher/session/SteamDeckPad.kt | 48 +- .../launcher/ui/ControllerSettings.kt | 2 +- .../launcher/ui/ModeSettingsDialog.kt | 1 + .../droiddeck/launcher/ui/SessionOverlay.kt | 11 +- app/src/main/res/values/strings.xml | 4 + .../launcher/input/SteamTouchBindingsTest.kt | 118 ++ .../launcher/input/SteamTouchConfigTest.kt | 69 + .../launcher/session/RumbleComponentTest.kt | 44 +- docs/development/steam-touch-controls.md | 128 ++ tools/linuxfs/preload/ntsync.c | 6 + tools/linuxfs/preload/udevmon.c | 112 ++ 23 files changed, 4160 insertions(+), 94 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/input/SteamTouchBindings.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/input/SteamTouchConfig.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/input/SteamTouchControls.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/input/SteamTouchDevice.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/input/SteamTouchIconPicker.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/input/SteamTouchBindingsTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/input/SteamTouchConfigTest.kt create mode 100644 docs/development/steam-touch-controls.md 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: `,