From 270dfbdcc39e61cee0e24fe22cfaf87cf68933a6 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Wed, 7 Oct 2026 22:11:58 -0400 Subject: [PATCH] feat: Steam's touch controller as on-screen controls (beta) --- app/src/main/cpp/fakeinput_steam.cpp | 343 ++++++++++- .../com/droiddeck/launcher/SessionActivity.kt | 55 ++ .../com/droiddeck/launcher/input/PadMotion.kt | 2 + .../launcher/input/SteamTouchConfig.kt | 532 ++++++++++++++++ .../launcher/input/SteamTouchControls.kt | 566 ++++++++++++++++++ .../launcher/input/SteamTouchDevice.kt | 151 +++++ .../launcher/session/SessionPrefs.kt | 9 + .../launcher/session/SessionService.kt | 10 +- .../launcher/session/SteamDeckPad.kt | 48 +- .../droiddeck/launcher/ui/SessionOverlay.kt | 13 + app/src/main/res/values/strings.xml | 7 + tools/linuxfs/preload/ntsync.c | 6 + tools/linuxfs/preload/udevmon.c | 112 ++++ 13 files changed, 1848 insertions(+), 6 deletions(-) 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 diff --git a/app/src/main/cpp/fakeinput_steam.cpp b/app/src/main/cpp/fakeinput_steam.cpp index d1c69d2a4..cb203d5f5 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 @@ -1216,6 +1217,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 @@ -1483,6 +1485,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. @@ -1823,6 +2140,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; @@ -1897,6 +2215,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; @@ -1957,6 +2276,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; @@ -2008,7 +2333,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); @@ -2054,6 +2379,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; @@ -2090,6 +2416,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; @@ -2211,7 +2538,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(); @@ -2537,7 +2865,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)) { @@ -2562,7 +2893,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 364bef141..c24a38e5a 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -144,6 +144,10 @@ 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 oscStyle by mutableStateOf(SessionPrefs.OSC_STYLE_DROIDDECK) + private var steamTouchAvailable by mutableStateOf(false) private var keyboard: KeyboardHost? = null private var controllerSettings by mutableStateOf(null) private val sessionClipboard by lazy { @@ -559,6 +563,18 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { backActionsInverted = inverted }, onOsc = { v -> SessionPrefs.setOscMode(this@SessionActivity, v); readPrefs(); updateOnScreenControls() }, + oscStyle = if (SessionState.mode == SessionService.MODE_STEAM) oscStyle else null, + onOscStyle = { v -> + SessionPrefs.setOscStyle(this@SessionActivity, v) + readPrefs() + updateOnScreenControls() + if (v == SessionPrefs.OSC_STYLE_STEAM && com.droiddeck.launcher.input.SteamTouchDevice.current == null) + Toast.makeText(this@SessionActivity, R.string.osc_style_next_session, Toast.LENGTH_LONG).show() + }, + onEditSteamTouch = if (steamTouchAvailable && oscStyle == SessionPrefs.OSC_STYLE_STEAM && onScreenButtonsVisible) ({ + drawerOpen = false + steamTouchControls?.startEditing() + }) else null, onSuspendPolicy = { policy -> SessionPrefs.setSuspendPolicy(this@SessionActivity, SessionState.mode, policy) suspendPolicy = policy @@ -798,6 +814,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode) steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this) oscMode = SessionPrefs.oscMode(this) + oscStyle = SessionPrefs.oscStyle(this) backActionsInverted = SessionPrefs.backActionsInverted(this) } @@ -1799,6 +1816,26 @@ 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? { + steamTouchControls?.let { return it } + val device = com.droiddeck.launcher.input.SteamTouchDevice.current ?: return 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 + // 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 } @@ -1809,9 +1846,19 @@ 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 steam = if (!buttonsOnly && SessionState.mode == SessionService.MODE_STEAM && + SessionPrefs.oscStyle(this) == SessionPrefs.OSC_STYLE_STEAM) steamTouch() else null + 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 @@ -1823,6 +1870,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { 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 + 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 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/SteamTouchConfig.kt b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchConfig.kt new file mode 100644 index 000000000..e42a1785a --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchConfig.kt @@ -0,0 +1,532 @@ +package com.droiddeck.launcher.input + +import android.content.Context +import android.util.Log +import com.droiddeck.launcher.runtime.LinuxRuntime +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.RandomAccessFile + +/** + * Steam's touch configs, read and written where Steam keeps them. + * + * A touch config is a Steam Input config (`controller_mobile_touch`) like any other - bindings per + * action set, synced to the account, shared as community configs - with one extra key, + * `touch_layout`: where each on-screen control sits, per action set, as a hex-encoded + * `CVirtualControllerLayouts` protobuf (steammessages_virtualcontroller.proto). Steam Link is + * handed both by the client it streams from; here the client tells the device only the app and + * action set it is on (SteamTouchDevice), so the config is found on disk: the file the client's + * console log says it loaded for the touch controller, else the app's entry in + * `configset_controller_mobile_touch.vdf`. With no layout in it, controls go where Steam Link puts + * them by default. Saving a layout writes `touch_layout` into the app's autosave config, as the + * client does when Steam Link saves one; Steam Cloud picks that file up like any autosave. + * (docs/development/steam-touch-controller.md.) + */ +object SteamTouchConfig { + private const val TAG = "SteamTouch" + + // EControllerElementType. + const val THUMB = 0 + const val STEAM = 1 + const val JOYSTICK_LEFT = 2 + const val JOYSTICK_LEFT_BUTTON = 3 + const val JOYSTICK_RIGHT = 4 + const val JOYSTICK_RIGHT_BUTTON = 5 + const val DPAD = 6 + const val A = 7 + const val B = 8 + const val X = 9 + const val Y = 10 + const val SELECT = 11 + const val START = 12 + const val TRIGGER_LEFT = 13 + const val TRIGGER_RIGHT = 14 + const val BUMPER_LEFT = 15 + const val BUMPER_RIGHT = 16 + const val MACRO_0 = 17 // 17..24 = macros 0-7 + const val TRACKPAD_CENTER = 25 + const val TRACKPAD_LEFT = 26 + const val TRACKPAD_RIGHT = 27 + const val KEYBOARD = 28 + const val MACRO_1_FINGER = 30 + const val MACRO_2_FINGER = 31 + + /** A control's place: centre as a fraction of the screen, and its scale. */ + data class Element(val type: Int, val visible: Boolean, val x: Float, val y: Float, val xScale: Float = 1f, val yScale: Float = 1f) + + data class Layout(val actionSet: Int, val elements: List, val color: FloatArray?, val version: Int?) + + /** A decoded `touch_layout`; [rest] is every other top-level field, written back untouched. */ + class Layouts(val layouts: List, val rest: ByteArray) { + fun forActionSet(id: Int): Layout? = layouts.firstOrNull { it.actionSet == id } + } + + /** A config as the overlay needs it. */ + class Config( + val file: File?, + val text: String?, + val layouts: Layouts?, + /** Bound controls per preset (action set id - 1). */ + val available: Map>, + ) { + /** The controls to show for [actionSet] (as the device is told it) and [layers]. */ + fun availableFor(actionSet: Int, layers: List): Set { + val base = available[presetOf(actionSet)] ?: available.values.firstOrNull() ?: DEFAULT_SET + return layers.fold(base) { set, layer -> set + (available[presetOf(layer)] ?: emptySet()) } + ALWAYS + } + } + + /** Big Picture's own config uses Steam's basic UI bindings: the default set. */ + private val DEFAULT_SET = setOf(DPAD, A, B, X, Y, SELECT, START) + private val ALWAYS = setOf(STEAM, THUMB, KEYBOARD) + + /** The layout entry for the action set the device was told about (the client counts from 1, + * 0 for an app with none). */ + fun layoutIdOf(actionSet: Int) = if (actionSet <= 0) 1 else actionSet + private fun presetOf(actionSet: Int) = layoutIdOf(actionSet) - 1 + + // ---- Where Steam keeps them ---- + + fun steamRoot(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam") + + fun configDir(context: Context): File? { + val base = File(steamRoot(context), "steamapps/common/Steam Controller Configs") + val account = accountId(context) + if (account != null) File(base, "$account/config").takeIf { it.isDirectory }?.let { return it } + return base.listFiles()?.filter { it.name.all(Char::isDigit) && File(it, "config").isDirectory } + ?.maxByOrNull { it.lastModified() }?.let { File(it, "config") } + } + + /** The signed-in account (loginusers.vdf's MostRecent) as a 32-bit account id. */ + private fun accountId(context: Context): Long? = try { + val users = KeyValues.parse(File(steamRoot(context), "config/loginusers.vdf").readText()) + val node = users.child("users") + val entries = node?.entries?.mapNotNull { (k, v) -> (v as? KeyValues.Node)?.let { k to it } }.orEmpty() + val chosen = entries.firstOrNull { it.second.string("MostRecent") == "1" } ?: entries.firstOrNull() + chosen?.first?.toLongOrNull()?.minus(76561197960265728L) + } catch (_: Exception) { + null + } + + /** The config the client has for app [appId] on the touch controller. Blocking: reads files. */ + fun load(context: Context, appId: Int): Config { + val dir = configDir(context) + val file = loggedConfig(context, appId)?.takeIf { isTouchConfig(it) } + ?: dir?.let { configsetConfig(context, it, appId) } + if (file == null) { + Log.i(TAG, "steam touch: app $appId has no touch config on disk; default layout") + return Config(null, null, null, emptyMap()) + } + return try { + val text = file.readText() + val kv = KeyValues.parse(text.removePrefix("")) + val mappings = kv.child("controller_mappings") ?: kv + val layouts = mappings.string("touch_layout")?.let { hex -> decodeLayouts(hexToBytes(hex)) } + Log.i(TAG, "steam touch: app $appId uses ${file.path} (${layouts?.layouts?.size ?: 0} layouts)") + Config(file, text, layouts, availability(mappings)) + } catch (e: Exception) { + Log.w(TAG, "steam touch: ${file.path} unreadable: $e") + Config(file, null, null, emptyMap()) + } + } + + private fun isTouchConfig(file: File) = file.isFile && + file.bufferedReader().use { r -> generateSequence { r.readLine() }.take(40).any { it.contains("controller_mobile_touch") } } + + /** From the client's console log: the last config it loaded for [appId] on the touch controller + * (`Loaded Config for ... App ID , Controller : `, where controller i reported + * type 43). Guest paths come back as the app's own. */ + private fun loggedConfig(context: Context, appId: Int): File? = try { + val log = File(steamRoot(context), "logs/console_log.txt") + val tail = RandomAccessFile(log, "r").use { raf -> + val start = maxOf(0L, raf.length() - 2_000_000L) + raf.seek(start) + val bytes = ByteArray((raf.length() - start).toInt()) + raf.readFully(bytes) + String(bytes) + } + val touchIndex = Regex("""!! Controller (\d+) attributes:\s*\n\s*Type: 43""").findAll(tail).lastOrNull()?.groupValues?.get(1) + if (touchIndex == null) null else { + val loaded = Regex("""Loaded Config for [^\n]*? Path for App ID $appId, Controller $touchIndex: ([^\n]+)""") + .findAll(tail).lastOrNull()?.groupValues?.get(1)?.trim() + loaded?.let { guestPath -> File(LinuxRuntime.rootDir(context), guestPath.trimStart('/').replace("//", "/")) } + } + } catch (_: Exception) { + null + } + + /** The app's entry in configset_controller_mobile_touch.vdf. */ + private fun configsetConfig(context: Context, dir: File, appId: Int): File? = try { + val set = KeyValues.parse(File(dir, CONFIGSET).readText()).child("controller_config")?.child(appId.toString()) + when { + set == null -> null + set.string("autosave") == "1" -> File(dir, "$appId/controller_mobile_touch.vdf") + set.string("template") != null -> File(steamRoot(context), "controller_base/templates/" + set.string("template")) + else -> null + }?.takeIf { it.isFile } + } catch (_: Exception) { + null + } + + private const val CONFIGSET = "configset_controller_mobile_touch.vdf" + + // ---- What a config binds ---- + + /** Per preset, the controls its active sources bind - what Steam Link shows (UpdateElementAvailability). */ + private fun availability(mappings: KeyValues.Node): Map> { + val groups = mappings.children("group").associateBy({ it.string("id") ?: "" }) { group -> + group.child("inputs")?.entries?.filter { (_, v) -> (v as? KeyValues.Node)?.let(::hasBinding) == true } + ?.map { it.first }?.toSet().orEmpty() + } + return mappings.children("preset").associate { preset -> + val id = preset.string("id")?.toIntOrNull() ?: 0 + val set = mutableSetOf() + preset.child("group_source_bindings")?.entries?.forEach { (groupId, value) -> + val parts = (value as? String)?.split(' ') ?: return@forEach + if (parts.getOrNull(1) != "active" || parts.contains("modeshift")) return@forEach + val bound = groups[groupId].orEmpty() + when (parts[0]) { + "button_diamond" -> mapOf("button_a" to A, "button_b" to B, "button_x" to X, "button_y" to Y) + .forEach { (input, element) -> if (input in bound) set += element } + "dpad" -> set += DPAD + "joystick" -> { set += JOYSTICK_LEFT; if ("click" in bound) set += JOYSTICK_LEFT_BUTTON } + "right_joystick" -> { set += JOYSTICK_RIGHT; if ("click" in bound) set += JOYSTICK_RIGHT_BUTTON } + "left_trigger" -> set += TRIGGER_LEFT + "right_trigger" -> set += TRIGGER_RIGHT + "left_trackpad" -> set += TRACKPAD_LEFT + "right_trackpad" -> set += TRACKPAD_RIGHT + "center_trackpad" -> set += TRACKPAD_CENTER + "switch" -> bound.forEach { input -> + when (input) { + "button_escape" -> set += START + "button_menu" -> set += SELECT + "left_bumper" -> set += BUMPER_LEFT + "right_bumper" -> set += BUMPER_RIGHT + "button_macro_1finger" -> set += MACRO_1_FINGER + "button_macro_2finger" -> set += MACRO_2_FINGER + else -> Regex("button_macro(\\d)").matchEntire(input)?.let { set += MACRO_0 + it.groupValues[1].toInt() } + } + } + } + } + id to set.toSet() + } + } + + private fun hasBinding(input: KeyValues.Node): Boolean = + input.child("activators")?.entries?.any { (_, activator) -> + (activator as? KeyValues.Node)?.child("bindings")?.entries?.any { (k, v) -> k == "binding" && (v as? String)?.isNotBlank() == true } == true + } == true + + // ---- Steam Link's defaults ---- + + /** Where Steam Link puts a control with no layout (CVirtualController::BInitializeDefaultElement, + * on a 1280x720 screen), and the same scheme for the controls its table leaves out, made room + * for when a config binds sticks. */ + fun defaultElement(type: Int, available: Set): Element? { + val leftStick = JOYSTICK_LEFT in available + val rightStick = JOYSTICK_RIGHT in available + val faceY = if (rightStick) -0.33f else 0f + val p: Pair = when (type) { + THUMB -> 75f / 1280 to 75f / 720 + STEAM -> 636f / 1280 to 75f / 720 + SELECT -> 516f / 1280 to 75f / 720 + START -> 756f / 1280 to 75f / 720 + KEYBOARD -> 1205f / 1280 to 75f / 720 + DPAD -> if (leftStick) 0.147f to 0.43f else 200f / 1280 to 525f / 720 + A -> 1083f / 1280 to 607f / 720 + faceY + B -> 1163f / 1280 to 527f / 720 + faceY + X -> 1003f / 1280 to 527f / 720 + faceY + Y -> 1083f / 1280 to 447f / 720 + faceY + JOYSTICK_LEFT -> 0.137f to 0.76f + JOYSTICK_RIGHT -> 0.85f to 0.76f + JOYSTICK_LEFT_BUTTON -> 0.29f to 0.88f + JOYSTICK_RIGHT_BUTTON -> 0.71f to 0.88f + BUMPER_LEFT -> 0.06f to 0.25f + TRIGGER_LEFT -> 0.16f to 0.25f + BUMPER_RIGHT -> 0.94f to 0.25f + TRIGGER_RIGHT -> 0.84f to 0.25f + TRACKPAD_LEFT -> 0.3f to 0.55f + TRACKPAD_CENTER -> 0.5f to 0.55f + TRACKPAD_RIGHT -> 0.7f to 0.55f + MACRO_1_FINGER -> 0.42f to 0.92f + MACRO_2_FINGER -> 0.58f to 0.92f + in MACRO_0..MACRO_0 + 7 -> (0.3f + 0.057f * (type - MACRO_0)) to 0.22f + else -> return null + } + return Element(type, true, p.first, p.second) + } + + /** The controls to draw: the layout's own where it has one, Steam Link's defaults otherwise, + * only those the config binds. */ + fun elementsFor(config: Config, actionSet: Int, layers: List): List { + val available = config.availableFor(actionSet, layers) + val layout = config.layouts?.forActionSet(layoutIdOf(actionSet)) + val placed = layout?.elements?.associateBy { it.type }.orEmpty() + return available.mapNotNull { type -> + val own = placed[type] + when { + own != null && !own.visible -> null + own != null -> own + else -> defaultElement(type, available) + } + }.sortedBy { it.type } + } + + fun layoutColor(config: Config, actionSet: Int): FloatArray? = config.layouts?.forActionSet(layoutIdOf(actionSet))?.color + + // ---- Saving ---- + + /** + * Saves [elements] as the layout of [actionSet] in app [appId]'s touch config: into the app's + * autosave config (`/controller_mobile_touch.vdf`, made from the config in use when the + * app has none) and selected for the app in the configset, as the client saves a layout Steam + * Link sends it. Returns the file written, or null. + */ + fun saveLayout(context: Context, appId: Int, config: Config, actionSet: Int, elements: List): File? { + val dir = configDir(context) ?: return null + val source = config.text ?: return null + val target = File(dir, "$appId/controller_mobile_touch.vdf") + val id = layoutIdOf(actionSet) + val old = config.layouts + val keep = old?.layouts?.filter { it.actionSet != id }.orEmpty() + val color = old?.forActionSet(id)?.color ?: floatArrayOf(1f, 1f, 1f, 0.4f) + val layouts = Layouts(keep + Layout(id, elements, color, old?.forActionSet(id)?.version), + old?.rest ?: byteArrayOf(0x10, 0x02)) // input_mode = controller + val hex = bytesToHex(encodeLayouts(layouts)) + val line = "\t\"touch_layout\"\t\t\"$hex\"" + val existing = Regex("""\n\s*"touch_layout"\s+"[0-9a-fA-F]*"""") + val text = if (existing.containsMatchIn(source)) source.replace(existing, "\n$line") + else source.replaceFirst(Regex("""("controller_type"\s+"controller_mobile_touch"[^\n]*)"""), "$1\n$line") + if (!text.contains("touch_layout")) return null + target.parentFile?.mkdirs() + val tmp = File(target.path + ".tmp") + tmp.writeText(text) + if (!tmp.renameTo(target)) return null + selectAutosave(dir, appId) + Log.i(TAG, "steam touch: saved layout ${id} for app $appId to ${target.path}") + return target + } + + /** Points the app's configset entry at its autosave config. */ + private fun selectAutosave(dir: File, appId: Int) { + val file = File(dir, CONFIGSET) + val text = if (file.isFile) file.readText() else "\"controller_config\"\n{\n}\n" + val entry = "\t\"$appId\"\n\t{\n\t\t\"autosave\"\t\t\"1\"\n\t}" + val block = Regex("""\n\s*"$appId"\s*\{[^}]*\}""") + val next = if (block.containsMatchIn(text)) text.replace(block, "\n$entry") + else text.substring(0, text.lastIndexOf('}')).trimEnd() + "\n$entry\n}\n" + File(file.path + ".tmp").apply { writeText(next) }.renameTo(file) + } + + // ---- touch_layout: CVirtualControllerLayouts ---- + + fun decodeLayouts(bytes: ByteArray): Layouts { + val layouts = mutableListOf() + val rest = ByteArrayOutputStream() + Proto(bytes).forEachField { field, wire, start, end, reader -> + if (field == 1 && wire == 2) layouts += decodeLayout(reader.bytes()) else rest.write(bytes, start, end - start) + } + return Layouts(layouts, rest.toByteArray()) + } + + private fun decodeLayout(bytes: ByteArray): Layout { + var actionSet = 0 + var version: Int? = null + var color: FloatArray? = null + val elements = mutableListOf() + Proto(bytes).forEachField { field, wire, _, _, r -> + when { + field == 1 && wire == 0 -> version = r.varint().toInt() + field == 2 && wire == 0 -> actionSet = r.varint().toInt() + field == 4 && wire == 2 -> elements += decodeElement(r.bytes()) + field == 5 && wire == 2 -> color = decodeColor(r.bytes()) + else -> r.skip(wire) + } + } + return Layout(actionSet, elements, color, version) + } + + private fun decodeElement(bytes: ByteArray): Element { + var type = -1 + var visible = false + var x = 0f + var y = 0f + var sx = 1f + var sy = 1f + Proto(bytes).forEachField { field, wire, _, _, r -> + when { + field == 1 && wire == 0 -> type = r.varint().toInt() + field == 2 && wire == 0 -> visible = r.varint() != 0L + field == 3 && wire == 5 -> x = r.float() + field == 4 && wire == 5 -> y = r.float() + field == 5 && wire == 5 -> sx = r.float() + field == 6 && wire == 5 -> sy = r.float() + else -> r.skip(wire) + } + } + return Element(type, visible, x, y, sx, sy) + } + + private fun decodeColor(bytes: ByteArray): FloatArray { + val c = floatArrayOf(1f, 1f, 1f, 1f) + Proto(bytes).forEachField { field, wire, _, _, r -> + if (field in 1..4 && wire == 5) c[field - 1] = r.float() else r.skip(wire) + } + return c + } + + fun encodeLayouts(layouts: Layouts): ByteArray { + val out = ByteArrayOutputStream() + layouts.layouts.forEach { layout -> + val l = ByteArrayOutputStream() + layout.version?.let { tag(l, 1, 0); varint(l, it.toLong()) } + tag(l, 2, 0); varint(l, layout.actionSet.toLong()) + layout.elements.forEach { e -> + val m = ByteArrayOutputStream() + tag(m, 1, 0); varint(m, e.type.toLong()) + tag(m, 2, 0); varint(m, if (e.visible) 1 else 0) + tag(m, 3, 5); fixed(m, e.x) + tag(m, 4, 5); fixed(m, e.y) + tag(m, 5, 5); fixed(m, e.xScale) + tag(m, 6, 5); fixed(m, e.yScale) + tag(l, 4, 2); bytes(l, m.toByteArray()) + } + layout.color?.let { c -> + val m = ByteArrayOutputStream() + for (i in 0 until 4) { tag(m, i + 1, 5); fixed(m, c[i]) } + tag(l, 5, 2); bytes(l, m.toByteArray()) + } + tag(out, 1, 2); bytes(out, l.toByteArray()) + } + out.write(layouts.rest) + return out.toByteArray() + } + + private fun tag(out: ByteArrayOutputStream, field: Int, wire: Int) = varint(out, (field shl 3 or wire).toLong()) + private fun varint(out: ByteArrayOutputStream, value: Long) { + var v = value + while (true) { + if (v and 0x7fL.inv() == 0L) { out.write(v.toInt()); return } + out.write((v and 0x7f or 0x80).toInt()) + v = v ushr 7 + } + } + private fun fixed(out: ByteArrayOutputStream, f: Float) { + val bits = java.lang.Float.floatToIntBits(f) + for (i in 0 until 4) out.write(bits ushr (8 * i) and 0xff) + } + private fun bytes(out: ByteArrayOutputStream, b: ByteArray) { varint(out, b.size.toLong()); out.write(b) } + + private class Proto(val data: ByteArray) { + var pos = 0 + fun varint(): Long { + var shift = 0 + var result = 0L + while (pos < data.size) { + val b = data[pos++].toInt() and 0xff + result = result or ((b and 0x7f).toLong() shl shift) + if (b and 0x80 == 0) break + shift += 7 + } + return result + } + fun float(): Float { + var bits = 0 + for (i in 0 until 4) bits = bits or ((data[pos + i].toInt() and 0xff) shl (8 * i)) + pos += 4 + return java.lang.Float.intBitsToFloat(bits) + } + fun bytes(): ByteArray { + val n = varint().toInt() + return data.copyOfRange(pos, pos + n).also { pos += n } + } + fun skip(wire: Int) { + when (wire) { + 0 -> varint() + 1 -> pos += 8 + 2 -> pos += varint().toInt() + 5 -> pos += 4 + else -> pos = data.size + } + } + inline fun forEachField(block: (field: Int, wire: Int, start: Int, end: Int, reader: Proto) -> Unit) { + while (pos < data.size) { + val start = pos + val key = varint().toInt() + val field = key ushr 3 + val wire = key and 7 + val mark = pos + // Each handler reads or skips the field; the raw span is measured from a copy. + val probe = Proto(data).also { it.pos = mark; it.skip(wire) } + block(field, wire, start, probe.pos, this) + pos = probe.pos + } + } + } + + fun hexToBytes(hex: String) = ByteArray(hex.length / 2) { hex.substring(2 * it, 2 * it + 2).toInt(16).toByte() } + fun bytesToHex(b: ByteArray) = b.joinToString("") { "%02x".format(it.toInt() and 0xff) } +} + +/** Valve's KeyValues text format, as far as controller configs use it. */ +class KeyValues private constructor() { + class Node(val entries: MutableList> = mutableListOf()) { + fun child(key: String): Node? = entries.firstOrNull { it.first.equals(key, true) && it.second is Node }?.second as Node? + fun children(key: String): List = entries.filter { it.first.equals(key, true) && it.second is Node }.map { it.second as Node } + fun string(key: String): String? = entries.firstOrNull { it.first.equals(key, true) && it.second is String }?.second as String? + } + + companion object { + fun parse(text: String): Node { + val tokens = tokenize(text) + var i = 0 + fun node(): Node { + val n = Node() + while (i < tokens.size) { + val t = tokens[i++] + if (t == "}" ) return n + if (t == "{") continue + if (i >= tokens.size) break + val next = tokens[i] + if (next == "{") { i++; n.entries += t to node() } else { i++; n.entries += t to next } + } + return n + } + return node() + } + + private fun tokenize(text: String): List { + val out = mutableListOf() + var i = 0 + while (i < text.length) { + val c = text[i] + when { + c.isWhitespace() -> i++ + c == '/' && i + 1 < text.length && text[i + 1] == '/' -> while (i < text.length && text[i] != '\n') i++ + c == '{' || c == '}' -> { out += c.toString(); i++ } + c == '"' -> { + val sb = StringBuilder() + i++ + while (i < text.length && text[i] != '"') { + if (text[i] == '\\' && i + 1 < text.length) { + i++ + sb.append(when (text[i]) { 'n' -> '\n'; 't' -> '\t'; else -> text[i] }) + } else sb.append(text[i]) + i++ + } + i++ + out += sb.toString() + } + c == '[' -> { while (i < text.length && text[i] != ']') i++; i++ } + else -> { + val start = i + while (i < text.length && !text[i].isWhitespace() && text[i] != '{' && text[i] != '}' && text[i] != '"') i++ + out += text.substring(start, i) + } + } + } + return out + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/SteamTouchControls.kt b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchControls.kt new file mode 100644 index 000000000..06e2fc3bc --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchControls.kt @@ -0,0 +1,566 @@ +package com.droiddeck.launcher.input + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.MotionEvent +import android.view.View +import android.widget.Toast +import com.droiddeck.launcher.input.SteamTouchConfig.Element +import java.util.concurrent.Executors +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.hypot +import kotlin.math.max +import kotlin.math.min + +/** + * Steam's touch controls, drawn by the app. + * + * The layout is Steam's: the game's touch config for the action set the client says is active + * (SteamTouchDevice.State, SteamTouchConfig), placed where the config's layout puts each control or + * where Steam Link would by default, and only the controls the config binds. Touches become the + * touch controller's input report; the client does the rest - bindings, action sets, the virtual + * pad the game reads. [startEditing] moves, resizes and hides controls and saves the layout into + * the game's touch config, as Steam Link does. + */ +@SuppressLint("ViewConstructor") +class SteamTouchControls( + context: Context, + private val device: SteamTouchDevice, + private val onMenu: () -> Unit, + private val onKeyboard: () -> Unit, +) : View(context) { + + private val handler = Handler(Looper.getMainLooper()) + private val loader = Executors.newSingleThreadExecutor() + + private var config = SteamTouchConfig.Config(null, null, null, emptyMap()) + private var configApp = -1 + private var appId = 0 + private var actionSet = 0 + private var layers: List = emptyList() + private var actionSeq = -1 + private var elements: List = emptyList() + private var tint = Color.WHITE + private var alphaScale = 0.45f + + // Touch state. + private class Finger(val element: Element?, var x: Float, var y: Float, val toolbar: Int = -1) + private val fingers = HashMap() + + // Editing. + private var editing = false + private var editElements: MutableList = mutableListOf() + private var hidden: MutableList = mutableListOf() + private var selected = -1 + private var dragOffset = 0f to 0f + private var pinchStart = 0f + private var pinchScale = 1f + private var saving = false + + private val fill = Paint(Paint.ANTI_ALIAS_FLAG) + private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE } + private val text = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER; isFakeBoldText = true } + + private val poll = object : Runnable { + override fun run() { + refreshState() + handler.postDelayed(this, POLL_MS) + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + handler.post(poll) + } + + override fun onDetachedFromWindow() { + handler.removeCallbacks(poll) + releaseAll() + super.onDetachedFromWindow() + } + + /** Follows what the client tells the device: a new app loads its config, a new action set or + * layer redraws. */ + private fun refreshState() { + val state = device.state() + val app = if (state.appId == 0) BIG_PICTURE else state.appId + if (app != configApp) { + configApp = app + reload(app) + } + if (state.actionSeq != actionSeq) { + actionSeq = state.actionSeq + actionSet = state.actionSet + layers = state.layers + appId = app + rebuild() + } + } + + private fun reload(app: Int) { + loader.execute { + val loaded = SteamTouchConfig.load(context, app) + handler.post { + if (app != configApp) return@post + config = loaded + appId = app + rebuild() + } + } + } + + /** Re-reads the config for the current app (after a save). */ + fun reloadConfig() { + configApp = -1 + refreshState() + } + + private fun rebuild() { + elements = SteamTouchConfig.elementsFor(config, actionSet, layers) + val color = SteamTouchConfig.layoutColor(config, actionSet) + tint = if (color != null) Color.rgb((color[0] * 255).toInt(), (color[1] * 255).toInt(), (color[2] * 255).toInt()) else Color.WHITE + alphaScale = color?.get(3)?.coerceIn(0.15f, 1f) ?: 0.45f + if (!editing) { + Log.i(TAG, "steam touch: app $appId, action set $actionSet, layers $layers: ${elements.size} controls") + releaseAll() + } + invalidate() + } + + // ---- Geometry ---- + + private fun unit() = min(width / 1280f, height / 720f) + + private fun radius(e: Element): Float = baseRadius(e.type) * e.xScale * unit() + + private fun baseRadius(type: Int) = when (type) { + SteamTouchConfig.DPAD -> 100f + SteamTouchConfig.JOYSTICK_LEFT, SteamTouchConfig.JOYSTICK_RIGHT -> 105f + SteamTouchConfig.TRACKPAD_LEFT, SteamTouchConfig.TRACKPAD_RIGHT, SteamTouchConfig.TRACKPAD_CENTER -> 115f + SteamTouchConfig.A, SteamTouchConfig.B, SteamTouchConfig.X, SteamTouchConfig.Y -> 40f + SteamTouchConfig.TRIGGER_LEFT, SteamTouchConfig.TRIGGER_RIGHT, SteamTouchConfig.BUMPER_LEFT, SteamTouchConfig.BUMPER_RIGHT -> 38f + else -> 32f + } + + private fun cx(e: Element) = e.x * width + private fun cy(e: Element) = e.y * height + + private fun hit(list: List, x: Float, y: Float): Element? = + list.filter { hypot(x - cx(it), y - cy(it)) <= radius(it) * 1.15f } + .minByOrNull { hypot(x - cx(it), y - cy(it)) / radius(it) } + + // ---- Drawing ---- + + override fun onDraw(canvas: Canvas) { + val shown = if (editing) editElements else elements + shown.forEach { draw(canvas, it, editing && editElements.indexOf(it) == selected) } + if (editing) drawToolbar(canvas) + } + + private fun pressedTypes(): Set = fingers.values.mapNotNull { it.element?.type }.toSet() + + private fun draw(canvas: Canvas, e: Element, isSelected: Boolean) { + val x = cx(e) + val y = cy(e) + val r = radius(e) + val pressed = !editing && e.type in pressedTypes() + val baseAlpha = (255 * alphaScale).toInt() + fill.color = Color.argb(if (pressed) min(255, baseAlpha + 90) else baseAlpha / 2, 20, 24, 30) + stroke.color = if (isSelected) Color.rgb(102, 192, 244) else Color.argb(min(255, baseAlpha + 60), Color.red(tint), Color.green(tint), Color.blue(tint)) + stroke.strokeWidth = if (isSelected) 4f * unit() + 2f else 2f * unit() + 1f + text.color = Color.argb(min(255, baseAlpha + 110), 255, 255, 255) + text.textSize = r * 0.7f + when (e.type) { + SteamTouchConfig.DPAD -> drawDpad(canvas, e, x, y, r) + SteamTouchConfig.JOYSTICK_LEFT, SteamTouchConfig.JOYSTICK_RIGHT -> { + canvas.drawCircle(x, y, r, fill) + canvas.drawCircle(x, y, r, stroke) + val f = fingers.values.firstOrNull { it.element?.type == e.type } + var kx = x + var ky = y + if (f != null && !editing) { + val d = hypot(f.x - x, f.y - y) + val k = if (d > r) r / d else 1f + kx = x + (f.x - x) * k + ky = y + (f.y - y) * k + } + fill.color = Color.argb(min(255, baseAlpha + 40), 60, 66, 76) + canvas.drawCircle(kx, ky, r * 0.42f, fill) + canvas.drawCircle(kx, ky, r * 0.42f, stroke) + } + SteamTouchConfig.TRACKPAD_LEFT, SteamTouchConfig.TRACKPAD_RIGHT, SteamTouchConfig.TRACKPAD_CENTER -> { + val rect = RectF(x - r, y - r, x + r, y + r) + canvas.drawRoundRect(rect, r * 0.25f, r * 0.25f, fill) + canvas.drawRoundRect(rect, r * 0.25f, r * 0.25f, stroke) + } + else -> { + canvas.drawCircle(x, y, r, fill) + canvas.drawCircle(x, y, r, stroke) + val label = label(e.type) + LABEL_COLORS[e.type]?.let { text.color = it } + canvas.drawText(label, x, y - (text.descent() + text.ascent()) / 2, text) + } + } + } + + private fun drawDpad(canvas: Canvas, e: Element, x: Float, y: Float, r: Float) { + val arm = r * 0.36f + val dirs = if (editing) 0 else dpadBits(e) + val arms = listOf(DPAD_UP to (0f to -1f), DPAD_DOWN to (0f to 1f), DPAD_LEFT to (-1f to 0f), DPAD_RIGHT to (1f to 0f)) + for ((bit, d) in arms) { + val (dx, dy) = d + val cxArm = x + dx * r * 0.6f + val cyArm = y + dy * r * 0.6f + val rect = RectF(cxArm - if (dx == 0f) arm else r * 0.4f, cyArm - if (dy == 0f) arm else r * 0.4f, + cxArm + if (dx == 0f) arm else r * 0.4f, cyArm + if (dy == 0f) arm else r * 0.4f) + val old = fill.color + if (dirs and bit != 0L) fill.color = Color.argb(200, 20, 24, 30) + canvas.drawRoundRect(rect, arm * 0.4f, arm * 0.4f, fill) + canvas.drawRoundRect(rect, arm * 0.4f, arm * 0.4f, stroke) + fill.color = old + val tri = Path() + val s = arm * 0.5f + val tx = cxArm + dx * r * 0.08f + val ty = cyArm + dy * r * 0.08f + if (dx == 0f) { tri.moveTo(tx, ty + dy * s); tri.lineTo(tx - s, ty - dy * s); tri.lineTo(tx + s, ty - dy * s) } + else { tri.moveTo(tx + dx * s, ty); tri.lineTo(tx - dx * s, ty - s); tri.lineTo(tx - dx * s, ty + s) } + tri.close() + val tp = Paint(text).apply { style = Paint.Style.FILL } + canvas.drawPath(tri, tp) + } + } + + private fun label(type: Int) = when (type) { + SteamTouchConfig.A -> "A" + SteamTouchConfig.B -> "B" + SteamTouchConfig.X -> "X" + SteamTouchConfig.Y -> "Y" + SteamTouchConfig.STEAM -> "STEAM" + SteamTouchConfig.THUMB -> "…" + SteamTouchConfig.KEYBOARD -> "⌨" + SteamTouchConfig.SELECT -> "⧉" + SteamTouchConfig.START -> "☰" + SteamTouchConfig.BUMPER_LEFT -> "LB" + SteamTouchConfig.BUMPER_RIGHT -> "RB" + SteamTouchConfig.TRIGGER_LEFT -> "LT" + SteamTouchConfig.TRIGGER_RIGHT -> "RT" + SteamTouchConfig.JOYSTICK_LEFT_BUTTON -> "L3" + SteamTouchConfig.JOYSTICK_RIGHT_BUTTON -> "R3" + SteamTouchConfig.MACRO_1_FINGER -> "1F" + SteamTouchConfig.MACRO_2_FINGER -> "2F" + in SteamTouchConfig.MACRO_0..SteamTouchConfig.MACRO_0 + 7 -> "M${type - SteamTouchConfig.MACRO_0 + 1}" + else -> "?" + }.let { if (it == "STEAM") "S" else it } + + // ---- The report ---- + + private fun dpadBits(e: Element): Long { + var bits = 0L + fingers.values.filter { it.element?.type == SteamTouchConfig.DPAD }.forEach { f -> + val dx = f.x - cx(e) + val dy = f.y - cy(e) + if (hypot(dx, dy) < radius(e) * 0.22f) return@forEach + val angle = Math.toDegrees(atan2(-dy, dx).toDouble()).let { if (it < 0) it + 360 else it } + // Eight sectors: each direction covers 67.5°, diagonals press two. + if (angle < 67.5 || angle > 292.5) bits = bits or DPAD_RIGHT + if (angle in 22.5..157.5) bits = bits or DPAD_UP + if (angle in 112.5..247.5) bits = bits or DPAD_LEFT + if (angle in 202.5..337.5) bits = bits or DPAD_DOWN + } + return bits + } + + private fun publish() { + var buttons = 0L + val sticks = ShortArray(4) + val pads = ShortArray(6) + for (f in fingers.values) { + val e = f.element ?: continue + val r = radius(e) + when (e.type) { + SteamTouchConfig.DPAD -> buttons = buttons or dpadBits(e) + SteamTouchConfig.JOYSTICK_LEFT, SteamTouchConfig.JOYSTICK_RIGHT -> { + val right = e.type == SteamTouchConfig.JOYSTICK_RIGHT + var dx = (f.x - cx(e)) / r + var dy = (f.y - cy(e)) / r + val d = hypot(dx, dy) + if (d > 1f) { dx /= d; dy /= d } + sticks[if (right) 2 else 0] = (dx * 32767).toInt().coerceIn(-32767, 32767).toShort() + sticks[if (right) 3 else 1] = (-dy * 32767).toInt().coerceIn(-32767, 32767).toShort() + buttons = buttons or if (right) STICK_RIGHT_TOUCHED else STICK_LEFT_TOUCHED + } + SteamTouchConfig.TRACKPAD_CENTER, SteamTouchConfig.TRACKPAD_LEFT, SteamTouchConfig.TRACKPAD_RIGHT -> { + val index = e.type - SteamTouchConfig.TRACKPAD_CENTER // centre, left, right + val nx = ((f.x - (cx(e) - r)) / (2 * r)).coerceIn(0f, 1f) + val ny = ((f.y - (cy(e) - r)) / (2 * r)).coerceIn(0f, 1f) + pads[2 * index] = (((nx * 65535).toInt().coerceIn(0, 65535)) xor 0x8000).toShort() + pads[2 * index + 1] = (((ny * 65535).toInt().coerceIn(0, 65535)) xor 0x7fff).toShort() + buttons = buttons or TRACKPAD_TOUCHED[index] + } + else -> BUTTON_BITS[e.type]?.let { buttons = buttons or it } + } + } + device.setControls(buttons, sticks, pads) + invalidate() + } + + fun releaseAll() { + fingers.clear() + device.setControls(0L, ShortArray(4), ShortArray(6)) + invalidate() + } + + // ---- Touch ---- + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + if (editing) return onEditTouch(event) + val index = event.actionIndex + when (event.actionMasked) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> { + val x = event.getX(index) + val y = event.getY(index) + val e = hit(elements, x, y) ?: return event.actionMasked == MotionEvent.ACTION_POINTER_DOWN + fingers[event.getPointerId(index)] = Finger(e, x, y) + requestUnbufferedDispatch(event) + publish() + return true + } + MotionEvent.ACTION_MOVE -> { + for (i in 0 until event.pointerCount) { + val f = fingers[event.getPointerId(i)] ?: continue + f.x = event.getX(i) + f.y = event.getY(i) + // A finger on a button can slide onto another, as on Steam Link. + val e = f.element ?: continue + if (BUTTON_BITS.containsKey(e.type)) { + val over = hit(elements, f.x, f.y) + if (over != null && over !== e && BUTTON_BITS.containsKey(over.type)) + fingers[event.getPointerId(i)] = Finger(over, f.x, f.y) + } + } + publish() + return true + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP, MotionEvent.ACTION_CANCEL -> { + val id = event.getPointerId(index) + val f = fingers.remove(id) + if (event.actionMasked == MotionEvent.ACTION_CANCEL) fingers.clear() + val e = f?.element + if (event.actionMasked != MotionEvent.ACTION_CANCEL && e != null && hypot(event.getX(index) - cx(e), event.getY(index) - cy(e)) <= radius(e) * 1.3f) { + when (e.type) { + SteamTouchConfig.THUMB -> onMenu() + SteamTouchConfig.KEYBOARD -> onKeyboard() + } + } + publish() + return true + } + } + return true + } + + // ---- Editing ---- + + val isEditing get() = editing + + /** Moves, resizes and hides the current action set's controls; Save writes them to the game's + * touch config. */ + fun startEditing() { + releaseAll() + editing = true + val available = config.availableFor(actionSet, layers) + editElements = available.mapNotNull { type -> + elements.firstOrNull { it.type == type } ?: SteamTouchConfig.defaultElement(type, available) + }.sortedBy { it.type }.toMutableList() + hidden = available.filter { type -> editElements.none { it.type == type } } + .mapNotNull { SteamTouchConfig.defaultElement(it, available)?.copy(visible = false) }.toMutableList() + selected = -1 + invalidate() + } + + private fun stopEditing() { + editing = false + selected = -1 + rebuild() + } + + private val toolbarLabels = listOf("−", "+", "Hide", "Reset", "Cancel", "Save") + + private fun toolbarRects(): List { + val u = max(unit(), 0.6f) + val w = 120f * u + val h = 56f * u + val gap = 12f * u + val total = toolbarLabels.size * w + (toolbarLabels.size - 1) * gap + val left = (width - total) / 2 + val top = height * 0.16f + return toolbarLabels.indices.map { i -> RectF(left + i * (w + gap), top, left + i * (w + gap) + w, top + h) } + } + + private fun drawToolbar(canvas: Canvas) { + val bar = Paint(Paint.ANTI_ALIAS_FLAG) + val rects = toolbarRects() + val title = Paint(text).apply { color = Color.WHITE; textSize = 26f * max(unit(), 0.6f) } + canvas.drawText(if (saving) "Saving…" else "Editing touch layout for ${if (appId == BIG_PICTURE) "Steam" else "app $appId"} - drag, pinch or use −/+", + width / 2f, rects[0].top - 18f * unit(), title) + rects.forEachIndexed { i, r -> + val enabled = i > 2 || selected >= 0 + bar.color = when (i) { + 5 -> Color.rgb(26, 159, 255) + else -> Color.argb(if (enabled) 230 else 120, 50, 56, 66) + } + canvas.drawRoundRect(r, 10f, 10f, bar) + val t = Paint(text).apply { color = Color.WHITE; textSize = r.height() * 0.42f } + canvas.drawText(toolbarLabels[i], r.centerX(), r.centerY() - (t.descent() + t.ascent()) / 2, t) + } + } + + private fun onEditTouch(event: MotionEvent): Boolean { + val index = event.actionIndex + val x = event.getX(index) + val y = event.getY(index) + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + val tool = toolbarRects().indexOfFirst { it.contains(x, y) } + if (tool >= 0) { + toolbar(tool) + return true + } + val e = hit(editElements, x, y) + selected = if (e != null) editElements.indexOf(e) else -1 + if (e != null) dragOffset = (x - cx(e)) to (y - cy(e)) + invalidate() + } + MotionEvent.ACTION_POINTER_DOWN -> if (selected >= 0 && event.pointerCount == 2) { + pinchStart = hypot(event.getX(0) - event.getX(1), event.getY(0) - event.getY(1)) + pinchScale = editElements[selected].xScale + } + MotionEvent.ACTION_MOVE -> if (selected >= 0) { + val e = editElements[selected] + if (event.pointerCount >= 2 && pinchStart > 0f) { + val d = hypot(event.getX(0) - event.getX(1), event.getY(0) - event.getY(1)) + val s = (pinchScale * d / pinchStart).coerceIn(0.4f, 3f) + editElements[selected] = e.copy(xScale = s, yScale = s) + } else if (event.pointerCount == 1) { + val nx = ((event.getX(0) - dragOffset.first) / width).coerceIn(0f, 1f) + val ny = ((event.getY(0) - dragOffset.second) / height).coerceIn(0f, 1f) + editElements[selected] = e.copy(x = nx, y = ny) + } + invalidate() + } + MotionEvent.ACTION_POINTER_UP -> pinchStart = 0f + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> pinchStart = 0f + } + return true + } + + private fun toolbar(tool: Int) { + if (saving) return + when (tool) { + 0, 1 -> if (selected >= 0) { + val e = editElements[selected] + val s = (e.xScale * if (tool == 0) 0.85f else 1.18f).coerceIn(0.4f, 3f) + editElements[selected] = e.copy(xScale = s, yScale = s) + } + 2 -> if (selected >= 0) { + hidden += editElements.removeAt(selected).copy(visible = false) + selected = -1 + } + 3 -> { + val available = config.availableFor(actionSet, layers) + editElements = available.mapNotNull { SteamTouchConfig.defaultElement(it, available) }.sortedBy { it.type }.toMutableList() + hidden.clear() + selected = -1 + } + 4 -> stopEditing() + 5 -> save() + } + invalidate() + } + + private fun save() { + saving = true + invalidate() + val app = appId + val set = actionSet + val cfg = config + val layout = editElements.map { it.copy(visible = true) } + hidden.filter { h -> editElements.none { it.type == h.type } } + loader.execute { + val file = try { + SteamTouchConfig.saveLayout(context, app, cfg, set, layout) + } catch (e: Exception) { + Log.w(TAG, "steam touch: saving failed: $e") + null + } + handler.post { + saving = false + if (file == null) { + Toast.makeText(context, "Could not save the touch layout", Toast.LENGTH_SHORT).show() + invalidate() + return@post + } + Toast.makeText(context, "Touch layout saved to Steam", Toast.LENGTH_SHORT).show() + onSaved() + stopEditing() + reloadConfig() + } + } + } + + /** Called after a save: the client reads the config again when the controller reconnects. */ + var onSaved: () -> Unit = {} + + companion object { + private const val TAG = "SteamTouch" + private const val POLL_MS = 150L + const val BIG_PICTURE = 769 + + private const val DPAD_UP = 1L shl 8 + private const val DPAD_RIGHT = 1L shl 9 + private const val DPAD_LEFT = 1L shl 10 + private const val DPAD_DOWN = 1L shl 11 + private const val STICK_LEFT_TOUCHED = 1L shl 46 + private const val STICK_RIGHT_TOUCHED = 1L shl 47 + private val TRACKPAD_TOUCHED = longArrayOf(1L shl 27, 1L shl 19, 1L shl 20) + + /** Steam Link's button bits (CVirtualController's table, by element type). */ + private val BUTTON_BITS: Map = buildMap { + put(SteamTouchConfig.STEAM, 1L shl 13) + put(SteamTouchConfig.JOYSTICK_LEFT_BUTTON, 1L shl 22) + put(SteamTouchConfig.JOYSTICK_RIGHT_BUTTON, 1L shl 26) + put(SteamTouchConfig.A, 1L shl 7) + put(SteamTouchConfig.B, 1L shl 5) + put(SteamTouchConfig.X, 1L shl 6) + put(SteamTouchConfig.Y, 1L shl 4) + put(SteamTouchConfig.SELECT, 1L shl 12) + put(SteamTouchConfig.START, 1L shl 14) + put(SteamTouchConfig.TRIGGER_LEFT, 1L shl 1) + put(SteamTouchConfig.TRIGGER_RIGHT, 1L shl 0) + put(SteamTouchConfig.BUMPER_LEFT, 1L shl 3) + put(SteamTouchConfig.BUMPER_RIGHT, 1L shl 2) + for (i in 0 until 8) put(SteamTouchConfig.MACRO_0 + i, 1L shl (32 + i)) + put(SteamTouchConfig.MACRO_1_FINGER, 1L shl 48) + put(SteamTouchConfig.MACRO_2_FINGER, 1L shl 49) + // Not bits: the menu and the keyboard are the app's own. + put(SteamTouchConfig.THUMB, 0L) + put(SteamTouchConfig.KEYBOARD, 0L) + } + + private val LABEL_COLORS = mapOf( + SteamTouchConfig.A to Color.rgb(96, 200, 90), + SteamTouchConfig.B to Color.rgb(230, 80, 70), + SteamTouchConfig.X to Color.rgb(70, 140, 240), + SteamTouchConfig.Y to Color.rgb(240, 200, 60), + ) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/SteamTouchDevice.kt b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchDevice.kt new file mode 100644 index 000000000..37b687bd4 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchDevice.kt @@ -0,0 +1,151 @@ +package com.droiddeck.launcher.input + +import android.util.Log +import java.io.File +import java.io.RandomAccessFile +import java.nio.ByteOrder +import java.nio.ByteBuffer +import java.nio.channels.FileChannel + +/** + * Steam's touch controller, as the app's side of it. + * + * libfakeinput offers the Steam client a HID device with the ids of Steam Link's touch controls + * (0000:11fb), and the client builds its own touch controller for it: Steam Input's Mobile Touch + * type, with the game's touch config, action sets, community configs and cloud sync all Steam's + * (docs/development/steam-touch-controller.md). This class is the file the two share + * (fakeinput_steam.cpp: TouchRingFile): the app writes the device's 40-byte input report and + * whether it is plugged in; libfakeinput writes back what the client told the device - the app and + * action set it is on, its layers, rumble. + */ +class SteamTouchDevice private constructor(private val buffer: ByteBuffer) { + + /** What the client last told the device. */ + data class State( + val opened: Int, + val appId: Int, + val actionSet: Int, + val layers: List, + val actionSeq: Int, + ) + + // The input report (Steam Link's CVirtualController, docs/development/steam-touch-controller.md): + // bytes 0-7 buttons, 8-15 sticks, 16-27 trackpads, 28-33 accelerometer, 34-39 gyro. + private val report = ByteArray(REPORT_BYTES) + private var reportSeq = 0L + + /** The controls part of the report: button bits, sticks (LX, LY, RX, RY, already in the report's + * units) and trackpads (centre, left, right: x, y each). */ + @Synchronized + fun setControls(buttons: Long, sticks: ShortArray, pads: ShortArray) { + val next = report.copyOf() + for (i in 0 until 8) next[i] = (buttons ushr (8 * i)).toByte() + for (i in 0 until 4) putShort(next, 8 + 2 * i, sticks[i]) + for (i in 0 until 6) putShort(next, 16 + 2 * i, pads[i]) + publish(next) + } + + /** Motion in the Deck's axes and units (PadMotion), which the touch report shares. */ + @Synchronized + fun setMotion(accel: ShortArray, gyro: ShortArray) { + if (!plugged) return + val next = report.copyOf() + for (i in 0 until 3) { + putShort(next, 28 + 2 * i, accel[i]) + putShort(next, 34 + 2 * i, gyro[i]) + } + publish(next) + } + + private fun putShort(into: ByteArray, at: Int, value: Short) { + into[at] = value.toInt().toByte() + into[at + 1] = (value.toInt() shr 8).toByte() + } + + /** Writes the report under the seqlock libfakeinput reads it with. */ + private fun publish(next: ByteArray) { + if (next.contentEquals(report)) return + next.copyInto(report) + buffer.putLong(OFF_REPORT_SEQ, ++reportSeq) + for (i in 0 until REPORT_BYTES) buffer.put(OFF_REPORT + i, report[i]) + buffer.putLong(OFF_REPORT_SEQ, ++reportSeq) + } + + var plugged: Boolean + get() = buffer.getInt(OFF_PLUGGED) != 0 + set(value) { + if (value == plugged) return + if (!value) synchronized(this) { publish(ByteArray(REPORT_BYTES)) } + buffer.putInt(OFF_PLUGGED, if (value) 1 else 0) + Log.i(TAG, "steam touch: " + if (value) "plugged in" else "unplugged") + } + + fun setBattery(percent: Int) = buffer.putInt(OFF_BATTERY, percent.coerceIn(0, 100)) + + fun state(): State { + repeat(8) { + val seq = buffer.getLong(OFF_STATE_SEQ) + if (seq and 1L != 0L) return@repeat + val count = buffer.getInt(OFF_LAYER_COUNT).coerceIn(0, 8) + val read = State( + opened = buffer.getInt(OFF_OPENED), + appId = buffer.getInt(OFF_APPID), + actionSet = buffer.getInt(OFF_ACTION_SET), + layers = (0 until count).map { buffer.getInt(OFF_LAYERS + 4 * it) }, + actionSeq = buffer.getInt(OFF_ACTION_SEQ), + ) + if (seq == buffer.getLong(OFF_STATE_SEQ)) return read + } + return State(0, 0, 0, emptyList(), -1) + } + + companion object { + private const val TAG = "SteamTouch" + const val REPORT_BYTES = 40 + private const val SIZE = 256 + private const val MAGIC = 0x31484354 // TCH1 + private const val OFF_REPORT_SEQ = 8 + private const val OFF_REPORT = 16 + private const val OFF_PLUGGED = 56 + private const val OFF_BATTERY = 60 + private const val OFF_STATE_SEQ = 64 + private const val OFF_OPENED = 72 + private const val OFF_APPID = 76 + private const val OFF_ACTION_SET = 80 + private const val OFF_LAYER_COUNT = 84 + private const val OFF_LAYERS = 88 + private const val OFF_ACTION_SEQ = 136 + + @Volatile + var current: SteamTouchDevice? = null + private set + + /** The shared file in the session's pad directory, made fresh each session. */ + fun ringFile(fakeInputDir: File) = File(fakeInputDir, "touchctl") + + /** Creates the file for a new session (unplugged) and maps it. */ + fun prepare(fakeInputDir: File): File? = try { + val file = ringFile(fakeInputDir) + file.delete() + RandomAccessFile(file, "rw").use { raf -> + raf.setLength(SIZE.toLong()) + val map = raf.channel.map(FileChannel.MapMode.READ_WRITE, 0, SIZE.toLong()).order(ByteOrder.LITTLE_ENDIAN) + map.putInt(4, 1) + map.putInt(0, MAGIC) + current = SteamTouchDevice(map) + } + file.setReadable(true, false) + file.setWritable(true, false) + file + } catch (e: Exception) { + Log.w(TAG, "steam touch: no shared file: $e") + current = null + null + } + + fun release() { + current?.plugged = false + current = null + } + } +} 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 1df9babf6..7ac8175dc 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -22,6 +22,9 @@ object SessionPrefs { const val OSC_ALWAYS = "always" const val OSC_STEAM_QAM = "steam-qam" const val OSC_NEVER = "never" + /** Whose on-screen controls: the app's own pad, or Steam's touch controller (SteamTouchControls). */ + const val OSC_STYLE_DROIDDECK = "droiddeck" + const val OSC_STYLE_STEAM = "steam" /** What Back does in a Steam session, first press then second: the labels of the two orders. */ val BACK_MENU_THEN_QAM = R.string.back_menu_then_qam @@ -111,6 +114,12 @@ object SessionPrefs { prefs(context).edit().putString("osc", mode).apply() } + fun oscStyle(context: Context): String = prefs(context).getString("oscStyle", OSC_STYLE_DROIDDECK) ?: OSC_STYLE_DROIDDECK + + fun setOscStyle(context: Context, style: String) { + prefs(context).edit().putString("oscStyle", style).apply() + } + /** * The imported glibc Turnip every session draws with inside the runtime - Steam, its games and * the desktop alike, as they run on the same GPU; "" = the driver built into the runtime. 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 0ea919b1c..a948063be 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -744,7 +744,9 @@ class SessionService : Service() { val wantsDeck = uinput && SessionState.mode == MODE_STEAM && SessionPrefs.steamController(this) == SessionPrefs.CONTROLLER_DECK && !File(Environment.getExternalStorageDirectory(), NO_DECK_PAD_SWITCH).exists() - deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!) else emptyList() + // Steam's touch controller rides on the Deck's sysfs and is offered only beside it. + val wantsTouch = wantsDeck && !File(Environment.getExternalStorageDirectory(), NO_STEAM_TOUCH_SWITCH).exists() + deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!, wantsTouch) else emptyList() 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() @@ -754,6 +756,10 @@ class SessionService : Service() { guest.add("FAKE_EVDEV_UINPUT=1") if (SessionState.deckPad) { guest.add("FAKE_EVDEV_DECK=1") + // Steam's touch controller (SteamTouchDevice): the file the app and libfakeinput share. + if (wantsTouch) com.droiddeck.launcher.input.SteamTouchDevice.prepare(fakeInputDir)?.let { + guest.add("FAKE_TOUCHCTL_RING=" + it.path) + } guest.add("FAKE_DECK_SYSFS_LISTING=" + SteamDeckPad.listingDir(fakeInputDir.parentFile!!.parentFile!!).path) } } else if (SessionState.mode == MODE_STEAM) { @@ -1493,6 +1499,7 @@ class SessionService : Service() { } components.clear() FakeInputWriter.releaseAllRingSlots() + com.droiddeck.launcher.input.SteamTouchDevice.release() val steamClientMayRun = SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP val finishAfterTeardown: () -> Unit = { if (prootPid > 1 || auxiliary.isNotEmpty()) { @@ -1753,6 +1760,7 @@ class SessionService : Service() { private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad" private const val NO_UINPUT_SWITCH = "Download/droiddeck-no-uinput" private const val NO_DECK_PAD_SWITCH = "Download/droiddeck-no-deck-pad" + private const val NO_STEAM_TOUCH_SWITCH = "Download/droiddeck-no-steam-touch" /** libfakeinput numbers the pads made through its /dev/uinput stand-in from here. */ private const val FIRST_VIRTUAL_PAD = 16 diff --git a/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt b/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt index c4e3b06d8..928a397f3 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt @@ -44,6 +44,24 @@ object SteamDeckPad { private const val HIDRAW = "$HID/hidraw/$NODE" private const val SERIAL = "DROIDDECK0001" + // Steam's touch controller (SteamTouchDevice), a second device beside the Deck's: 0000:11fb, + // "Mobile Touch Control", on a USB device of its own. Must match libfakeinput (TOUCH_*). + private const val TOUCH_MINOR = 17 + private const val TOUCH_NODE = "hidraw$TOUCH_MINOR" + private const val TOUCH_USB = "$GUEST_DEVICES/usb2" + private const val TOUCH_INTERFACE = "$TOUCH_USB/2-1:1.0" + private const val TOUCH_HID = "$TOUCH_INTERFACE/0003:0000:11FB.0002" + private const val TOUCH_HIDRAW = "$TOUCH_HID/hidraw/$TOUCH_NODE" + private const val TOUCH_SERIAL = "DROIDDECK0001" + + /** libfakeinput's kTouchReportDescriptor: Generic Desktop / Game Pad, 40-byte input report. */ + private val TOUCH_REPORT_DESCRIPTOR = intArrayOf( + 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, + ).map { it.toByte() }.toByteArray() + /** InputPlumber's CONTROLLER_DESCRIPTOR, as libfakeinput answers HIDIOCGRDESC. */ private val REPORT_DESCRIPTOR = intArrayOf( 0x06, 0xff, 0xff, 0x09, 0x01, 0xa1, 0x01, 0x09, 0x02, 0x09, 0x03, 0x15, 0x00, @@ -55,7 +73,7 @@ object SteamDeckPad { fun listingDir(sessionRoot: File) = File(sessionRoot, "sys/deck/listing") /** Writes the tree and returns the `host:guest` binds for it, or none if it could not be made. */ - fun prepare(context: Context, sessionRoot: File): List { + fun prepare(context: Context, sessionRoot: File, touch: Boolean = false): List { val base = File(sessionRoot, "sys/deck") val devices = File(base, "devices") val hidrawClass = File(base, "class-hidraw") @@ -107,6 +125,34 @@ object SteamDeckPad { val udevData = File(LinuxRuntime.rootDir(context), "run/udev/data").apply { mkdirs() } File(udevData, "c$MAJOR:$MINOR").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n") + if (touch) { + write(TOUCH_USB, "uevent", "DEVTYPE=usb_device\nPRODUCT=0/11fb/0\nTYPE=0/0/0\nBUSNUM=002\nDEVNUM=002\n") + write(TOUCH_USB, "idVendor", "0000\n") + write(TOUCH_USB, "idProduct", "11fb\n") + write(TOUCH_USB, "bcdDevice", "0000\n") + write(TOUCH_USB, "manufacturer", "Valve Software\n") + write(TOUCH_USB, "product", "Mobile Touch Control\n") + write(TOUCH_USB, "serial", "$TOUCH_SERIAL\n") + link(File(dir(TOUCH_USB), "subsystem"), "/sys/bus/usb") + write(TOUCH_INTERFACE, "uevent", "DEVTYPE=usb_interface\nPRODUCT=0/11fb/0\nINTERFACE=3/0/0\n") + write(TOUCH_INTERFACE, "bInterfaceNumber", "00\n") + write(TOUCH_INTERFACE, "bInterfaceClass", "03\n") + link(File(dir(TOUCH_INTERFACE), "subsystem"), "/sys/bus/usb") + write(TOUCH_HID, "uevent", "DRIVER=hid-generic\nHID_ID=0003:00000000:000011FB\n" + + "HID_NAME=Mobile Touch Control\nHID_PHYS=usb-droiddeck-2/input0\n" + + "HID_UNIQ=$TOUCH_SERIAL\nMODALIAS=hid:b0003g0001v00000000p000011FB\n") + File(dir(TOUCH_HID), "report_descriptor").writeBytes(TOUCH_REPORT_DESCRIPTOR) + link(File(dir(TOUCH_HID), "subsystem"), "/sys/bus/hid") + write(TOUCH_HIDRAW, "uevent", "MAJOR=$MAJOR\nMINOR=$TOUCH_MINOR\nDEVNAME=$TOUCH_NODE\n") + write(TOUCH_HIDRAW, "dev", "$MAJOR:$TOUCH_MINOR\n") + link(File(dir(TOUCH_HIDRAW), "subsystem"), "/sys/class/hidraw") + link(File(dir(TOUCH_HIDRAW), "device"), TOUCH_HID) + link(File(hidrawClass, TOUCH_NODE), TOUCH_HIDRAW) + link(File(context.cacheDir, "drm/sys/$MAJOR:$TOUCH_MINOR"), TOUCH_HIDRAW) + File(udevData, "c$MAJOR:$TOUCH_MINOR").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n") + Log.i(TAG, "deck pad: /dev/$TOUCH_NODE described as Steam's touch controller (0000:11fb)") + } + Log.i(TAG, "deck pad: /dev/$NODE described as a Steam Deck controller (28de:1205)") listOf(devices.path + ":" + GUEST_DEVICES, hidrawClass.path + ":/sys/class/hidraw") } catch (e: Exception) { 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 38cd3b41a..853f54eea 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -216,6 +216,11 @@ class DrawerActions( val onSteamMenu: (() -> Unit)?, val onQam: (() -> Unit)?, val onOsc: (String) -> Unit, + /** Whose on-screen controls (SessionPrefs.OSC_STYLE_*); null where Steam's are not offered. */ + val oscStyle: String? = null, + val onOscStyle: (String) -> Unit = {}, + /** Edits the Steam touch layout of the game in front, when Steam's controls are up. */ + val onEditSteamTouch: (() -> Unit)? = null, val controller: com.droiddeck.launcher.input.ControllerPrefs.Settings? = null, val onRumble: (Boolean) -> Unit = {}, val onSteamButton: (Boolean) -> Unit = {}, @@ -420,6 +425,14 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont if (a.steam) listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never)) else listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never)), a.oscMode, chipModifier = focus.track(page, "osc"), onPick = a.onOsc) + a.oscStyle?.let { style -> + ChoiceRow(host, "osc-style", stringResource(R.string.osc_style), stringResource(R.string.osc_style_hint), + listOf(SessionPrefs.OSC_STYLE_DROIDDECK to stringResource(R.string.osc_style_droiddeck), SessionPrefs.OSC_STYLE_STEAM to stringResource(R.string.osc_style_steam)), + style, chipModifier = focus.track(page, "osc-style"), onPick = a.onOscStyle) + } + a.onEditSteamTouch?.let { edit -> + ActionRow(stringResource(R.string.osc_steam_layout), stringResource(R.string.osc_steam_layout_hint), stringResource(R.string.ctrl_edit), edit) + } a.controller?.let { c -> ToggleRow(host, "rumble", stringResource(R.string.ctrl_rumble), null, c.rumble, chipModifier = focus.track(page, "rumble"), onChange = a.onRumble) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 71799ae61..edce07e9b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -255,6 +255,13 @@ On-screen controls Steam + QAM Auto shows the controls when no controller is connected. + Touch controls + DroidDeck\'s own pad, or Steam\'s touch controller: per-game layouts and configs from Steam + DroidDeck + Steam (beta) + Steam\'s touch controls start with the next Steam session + Steam touch layout + Move, resize or hide this game\'s touch controls; saved to Steam Controller How Steam sees your controller. Applies next session. Steam Deck controller diff --git a/tools/linuxfs/preload/ntsync.c b/tools/linuxfs/preload/ntsync.c index 4b8256fc1..dc21b117f 100644 --- a/tools/linuxfs/preload/ntsync.c +++ b/tools/linuxfs/preload/ntsync.c @@ -1237,9 +1237,15 @@ static void ns_received(struct msghdr *msg) { } } +int bl_udevmon_recvmsg(int fd, struct msghdr *msg, int flags, ssize_t (*real_recvmsg)(int, struct msghdr *, int), + ssize_t *result) __attribute__((visibility("hidden"))); + ssize_t recvmsg(int fd, struct msghdr *msg, int flags) { static ssize_t (*real)(int, struct msghdr *, int); if (!real) real = (ssize_t (*)(int, struct msghdr *, int))dlsym(RTLD_NEXT, "recvmsg"); + /* udevmon.c's stand-in netlink sockets first: their messages are dressed as udevd's. */ + ssize_t answered; + if (msg && bl_udevmon_recvmsg(fd, msg, flags, real, &answered)) return answered; ssize_t r = real(fd, msg, flags); if (r >= 0 && msg && msg->msg_control && msg->msg_controllen && ns_on()) { int saved = errno; diff --git a/tools/linuxfs/preload/udevmon.c b/tools/linuxfs/preload/udevmon.c index 39e568b3b..86baed110 100644 --- a/tools/linuxfs/preload/udevmon.c +++ b/tools/linuxfs/preload/udevmon.c @@ -17,6 +17,9 @@ * container anyway. The real socket is tried first, so this stays out of the way wherever a * monitor could genuinely be created. * + * The stand-in can also carry events after all (bl_udevmon_inject, below): libfakeinput plugs + * Steam's touch controller in and out of the client that way. + * * Wine is left without one. Its HID bus reads the session's pads itself only when its SDL bus * cannot start, and that is the path inputudev.c describes them on. Given a monitor the SDL bus * comes up and takes the pads over from it. @@ -31,6 +34,8 @@ #include #include #include +#include +#include #include struct stand_in { @@ -189,3 +194,110 @@ int setsockopt(int fd, int level, int optname, const void *optval, socklen_t opt } return real_setsockopt(fd, level, optname, optval, optlen); } + +/* udevd's message to the "udev" multicast group (systemd, device-monitor.c): this header, then the + * properties as NUL-separated KEY=VALUE strings. */ +struct monitor_netlink_header { + char prefix[8]; + unsigned magic; + unsigned header_size; + unsigned properties_off; + unsigned properties_len; + unsigned filter_subsystem_hash; + unsigned filter_devtype_hash; + unsigned filter_tag_bloom_hi; + unsigned filter_tag_bloom_lo; +}; + +/* systemd's string_hash32: MurmurHash2, seed 0. */ +static unsigned murmur_hash2(const char *key, size_t len) { + const unsigned m = 0x5bd1e995; + unsigned h = (unsigned)len; + const unsigned char *data = (const unsigned char *)key; + while (len >= 4) { + unsigned k; + memcpy(&k, data, 4); + k *= m; + k ^= k >> 24; + k *= m; + h *= m; + h ^= k; + data += 4; + len -= 4; + } + switch (len) { + case 3: h ^= data[2] << 16; /* fallthrough */ + case 2: h ^= data[1] << 8; /* fallthrough */ + case 1: h ^= data[0]; h *= m; + } + h ^= h >> 13; + h *= m; + h ^= h >> 15; + return h; +} + +/* Sends a device event to every stand-in in this process, as udevd would; returns how many got it. + * For libfakeinput (found with dlsym), which knows when its devices come and go. */ +__attribute__((visibility("default"))) int bl_udevmon_inject(const char *action, const char *devpath, + const char *subsystem, const char *devname, + unsigned major, unsigned minor) { + static unsigned long long seqnum = 900000; + char props[1024]; + int used = snprintf(props, sizeof(props), "ACTION=%s%cDEVPATH=%s%cSUBSYSTEM=%s%cDEVNAME=%s%cMAJOR=%u%cMINOR=%u%cSEQNUM=%llu", + action, 0, devpath, 0, subsystem, 0, devname, 0, major, 0, minor, 0, + __atomic_add_fetch(&seqnum, 1, __ATOMIC_RELAXED)); + if (used <= 0 || (size_t)used >= sizeof(props)) return -1; + used++; /* the last string's NUL */ + struct monitor_netlink_header header; + memset(&header, 0, sizeof(header)); + memcpy(header.prefix, "libudev", 8); + header.magic = htobe32(0xfeedcafe); + header.header_size = sizeof(header); + header.properties_off = sizeof(header); + header.properties_len = (unsigned)used; + header.filter_subsystem_hash = htobe32(murmur_hash2(subsystem, strlen(subsystem))); + char message[sizeof(header) + sizeof(props)]; + memcpy(message, &header, sizeof(header)); + memcpy(message + sizeof(header), props, (size_t)used); + int sent = 0; + pthread_mutex_lock(&lock); + for (size_t i = 0; i < sizeof(stand_ins) / sizeof(stand_ins[0]); i++) { + if (stand_ins[i].fd == -1) continue; + if (send(stand_ins[i].peer, message, sizeof(header) + (size_t)used, MSG_DONTWAIT | MSG_NOSIGNAL) > 0) sent++; + } + pthread_mutex_unlock(&lock); + return sent; +} + +/* For ntsync.c's recvmsg(), which owns the symbol: 1 when fd is a stand-in, answered here (*result + * is what recvmsg returns), 0 otherwise. A message is dressed as udevd's: libudev drops one that + * does not come from the udev multicast group with root credentials. The sender address goes in + * the room msg_namelen had before the call; a unix socket sets it to 0. */ +__attribute__((visibility("hidden"))) int bl_udevmon_recvmsg(int fd, struct msghdr *msg, int flags, + ssize_t (*real_recvmsg)(int, struct msghdr *, int), + ssize_t *result) { + if (!is_stand_in(fd)) return 0; + size_t control_room = msg->msg_controllen; + socklen_t name_room = msg->msg_namelen; + ssize_t n = real_recvmsg(fd, msg, flags); + *result = n; + if (n < 0) return 1; + if (msg->msg_name != NULL && name_room >= sizeof(struct sockaddr_nl)) { + struct sockaddr_nl nl; + memset(&nl, 0, sizeof(nl)); + nl.nl_family = AF_NETLINK; + nl.nl_groups = 2; /* udev's group */ + memcpy(msg->msg_name, &nl, sizeof(nl)); + msg->msg_namelen = sizeof(nl); + } + if (msg->msg_control != NULL && control_room >= CMSG_SPACE(sizeof(struct ucred))) { + struct cmsghdr *cmsg = (struct cmsghdr *)msg->msg_control; + cmsg->cmsg_level = SOL_SOCKET; + cmsg->cmsg_type = SCM_CREDENTIALS; + cmsg->cmsg_len = CMSG_LEN(sizeof(struct ucred)); + struct ucred cred = {.pid = 1, .uid = 0, .gid = 0}; + memcpy(CMSG_DATA(cmsg), &cred, sizeof(cred)); + msg->msg_controllen = CMSG_SPACE(sizeof(struct ucred)); + } + return 1; +}