Compare commits

...
Author SHA1 Message Date
xXJsonDeruloXx 3112402562 wip: proot fast path tweak 2026-10-14 12:16:24 -04:00
x bc0530463e Merge branch 'feat/steam-touch-controller' into local/touch-build 2026-10-08 21:50:42 -04:00
xXJsonDeruloXx 2d07c3df04 refactor: Steam touch controls become an On-screen controls choice instead of a second dropdown 2026-10-08 21:50:34 -04:00
x 05a5f745f5 Merge branch 'fix/runtime-removal-stranded' into local/touch-build 2026-10-08 21:39:32 -04:00
xXJsonDeruloXx 1ce0a43db0 fix: runtime removal sets undeletable leftovers aside instead of stranding reinstall 2026-10-08 21:28:50 -04:00
xXJsonDeruloXx b5f7ac6700 feat: Steam touch controls in DroidDeck's style, D-mark guide button, shake to hide 2026-10-08 20:28:44 -04:00
xXJsonDeruloXx 03c8a0e4c2 feat: Steam touch controls drawn as Steam Link draws them; Steam Link's touch menu and layout tray 2026-10-08 19:21:42 -04:00
xXJsonDeruloXx 1bd561531a feat: Steam Link's touch menu, per-game input modes, action set editing, add/copy/paste, oval controls, paste control 2026-10-08 18:19:52 -04:00
maxjivi05 64d72913fa 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.
2026-10-08 16:25:54 -04:00
maxjivi05 1b31899896 Merge remote-tracking branch 'origin/main' into feat/steam-touch-controller 2026-10-08 14:57:02 -04:00
xXJsonDeruloXx 38a84a8e8f feat: Steam touch icon picker as in-session panels; layout colour; docs 2026-10-08 09:20:07 -04:00
xXJsonDeruloXx c2dd90d1b6 feat: Steam's touch icons, labels and colours on the touch controls; pick and save them like Steam 2026-10-08 08:05:07 -04:00
xXJsonDeruloXx e637a2e0bd docs: Steam's touch controls 2026-10-08 06:38:22 -04:00
xXJsonDeruloXx 261dee73c4 fix: Steam touch layout decoding, saved layouts and new sessions 2026-10-07 22:43:09 -04:00
xXJsonDeruloXx ac8455925e fix: Steam touch controls appear once the session makes the device; tidier default layout 2026-10-07 22:23:13 -04:00
xXJsonDeruloXx 270dfbdcc3 feat: Steam's touch controller as on-screen controls (beta) 2026-10-07 22:11:58 -04:00
27 changed files with 4313 additions and 107 deletions

No files matched your search

+343 -6
View File
@@ -11,6 +11,7 @@
#include <atomic>
#include <cerrno>
#include <climits>
#include <cstddef>
#include <cstdint>
#include <cstdlib>
#include <cstring>
@@ -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
@@ -1315,13 +1317,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) {
@@ -1483,6 +1487,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<int (*)(const char *, int, ...)>(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<TouchRingFile *>(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<TouchRingFile *>(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 <typename F>
__attribute__((visibility("hidden"))) static void touch_state(F &&write) {
TouchRingFile *ring = touch_ring();
if (!ring) return;
static std::mutex mutex;
std::lock_guard<std::mutex> 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<uint32_t>(at[0]) | static_cast<uint32_t>(at[1]) << 8 | static_cast<uint32_t>(at[2]) << 16 |
static_cast<uint32_t>(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<unsigned> 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<uint16_t>(std::min<uint32_t>(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<uint32_t>(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<std::shared_ptr<DeckHidraw> *>(arg);
std::shared_ptr<DeckHidraw> 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<DeckHidraw>();
touch->peer = pair[1];
touch->touch = true;
{
std::lock_guard<std::recursive_mutex> guard(controller_mutex());
deck_map()[pair[0]] = touch;
}
touch_state([](TouchRingFile &r) { r.opened++; });
pthread_t thread;
auto *arg = new std::shared_ptr<DeckHidraw>(touch);
if (pthread_create(&thread, nullptr, touch_report_thread, arg) != 0) {
delete arg;
std::lock_guard<std::recursive_mutex> 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<int *>(argp) = sizeof(kTouchReportDescriptor);
return 0;
case 0x02: { // HIDIOCGRDESC
auto *descriptor = static_cast<uint8_t *>(argp);
uint32_t want;
memcpy(&want, descriptor, sizeof(want));
memcpy(descriptor + 4, kTouchReportDescriptor, std::min<size_t>(want, sizeof(kTouchReportDescriptor)));
return 0;
}
case 0x03: { // HIDIOCGRAWINFO
struct {
uint32_t bustype;
int16_t vendor;
int16_t product;
} info = {BUS_USB, 0x0000, static_cast<int16_t>(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<uint8_t *>(argp)[0] : 0;
return static_cast<int>(size);
case 0x07: { // HIDIOCGFEATURE: report 2 is the battery
auto *buf = static_cast<uint8_t *>(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<uint8_t>(ring && ring->battery ? std::min<uint32_t>(ring->battery, 100) : 100);
}
return static_cast<int>(std::min<size_t>(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<inject_fn>(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 +2142,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 +2217,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 +2278,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 +2335,7 @@ static int fake_stat_fd(int fd, S *buf, Real real) {
std::lock_guard<std::recursive_mutex> 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 +2381,7 @@ EXPORT int access(const char *pathname, int mode) {
static auto my_access = reinterpret_cast<decltype(&::access)>(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 +2418,7 @@ EXPORT int faccessat(int dirfd, const char *pathname, int mode, int flags) {
static auto my_faccessat = reinterpret_cast<decltype(&::faccessat)>(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 +2540,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 +2867,10 @@ EXPORT ssize_t write(int fd, const void *buf, size_t count) {
std::unique_lock<std::recursive_mutex> 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<ssize_t>(count);
if (deck_map().count(fd)) {
if (deck_map()[fd]->touch) touch_output(static_cast<const uint8_t *>(buf), count);
return static_cast<ssize_t>(count);
}
auto controller = controller_map().find(fd);
if (controller != controller_map().end()) {
if (fake_fd_is_stale(fd)) {
@@ -2562,7 +2895,11 @@ EXPORT ssize_t writev(int fd, const struct iovec *iov, int iovcnt) {
std::unique_lock<std::recursive_mutex> guard(controller_mutex());
if (deck_map().count(fd)) {
ssize_t total = 0;
for (int i = 0; i < iovcnt; i++) total += static_cast<ssize_t>(iov[i].iov_len);
bool touch = deck_map()[fd]->touch;
for (int i = 0; i < iovcnt; i++) {
if (touch) touch_output(static_cast<const uint8_t *>(iov[i].iov_base), iov[i].iov_len);
total += static_cast<ssize_t>(iov[i].iov_len);
}
return total;
}
auto made = uinput_map().find(fd);
@@ -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<ControllerPrefs.Settings?>(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()
}
@@ -560,7 +570,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
@@ -795,6 +815,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)
@@ -1654,8 +1675,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"
@@ -1677,7 +1722,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) {
@@ -1796,6 +1841,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 }
@@ -1806,20 +1886,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
@@ -2038,6 +2147,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.
@@ -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.
*
* <p>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;
@@ -155,38 +186,40 @@ public final class PadBridge {
/** @return true when the event was a pad button and has been consumed. */
public synchronized boolean onKeyEvent(KeyEvent event) {
if (!isFromController(event.getDevice())) return false;
noteDevice(event.getDevice());
if (!isFromController(event.getDevice()) || !isPadKey(event.getKeyCode())) return false;
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;
}
@@ -194,33 +227,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;
}
@@ -231,6 +266,7 @@ public final class PadBridge {
public synchronized void applyTouch(java.util.function.Consumer<PadState> mutation) {
mutation.accept(state);
activeControllerId = NO_CONTROLLER;
slotControllers.set(0, NO_CONTROLLER);
notePlayerInput();
statOnScreen++;
scheduleStats();
@@ -255,6 +291,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. */
@@ -310,16 +358,71 @@ public final class PadBridge {
publish();
}
private static boolean isPadKey(int keyCode) {
switch (keyCode) {
case KeyEvent.KEYCODE_BACK:
case KeyEvent.KEYCODE_MENU:
case KeyEvent.KEYCODE_HOME:
case KeyEvent.KEYCODE_DPAD_UP:
case KeyEvent.KEYCODE_DPAD_RIGHT:
case KeyEvent.KEYCODE_DPAD_DOWN:
case KeyEvent.KEYCODE_DPAD_LEFT:
return true;
default:
return KeyEvent.isGamepadButton(keyCode);
}
}
/** 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()));
}
}
@@ -339,6 +442,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++;
@@ -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
@@ -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: `<action>, <label>, <icon>, <#foreground #background>`, e.g.
* `xinput_button A, Jump, ghost_045_move_0400.png, #232323 #E4E4E4`. The icon is a file name looked
* up as Steam does (steamui.so): the game's own `TouchMenuIcons` folder, then the binding icon
* library `tenfoot/resource/images/library/controller/binding_icons`. Steam draws it filled with
* the foreground colour through its own alpha (mask-image) on a button of the background colour.
* Steam Link is sent these per control by the client it streams from; here they are read from the
* game's touch config, and editing one writes the binding string back, as the configurator does.
*/
object SteamTouchBindings {
/** Steam's binding icon colours (the configurator's palette, steamui sp.js), and its defaults. */
val PALETTE = listOf(
"#FFFFFF", "#E4E4E4", "#AAAAAA", "#787878", "#434343", "#222222", "#0000AD", "#0045AD", "#0074AD",
"#00ADAD", "#33AD69", "#00AD3D", "#00AD00", "#48B119", "#74AD00", "#96AD00", "#ADA200", "#AD5D00",
"#AD3A00", "#AD0000", "#AD0051", "#AD007F", "#AD00AD", "#6800AD", "#4800AD",
)
const val DEFAULT_FOREGROUND = "#232323"
const val DEFAULT_BACKGROUND = "#E4E4E4"
/** A parsed binding string. Empty strings for absent fields. */
data class Binding(val action: String, val label: String, val icon: String, val foreground: String, val background: String) {
fun compose(): String =
if (icon.isEmpty() && foreground.isEmpty() && background.isEmpty()) "$action, $label, "
else "$action, $label, $icon, ${listOf(foreground, background).filter { it.isNotEmpty() }.joinToString(" ")}"
val hasIcon get() = icon.isNotEmpty()
}
fun parse(binding: String): Binding {
val parts = binding.split(',').map { it.trim() }
val colors = parts.getOrNull(3).orEmpty().split(' ').filter { it.startsWith("#") }
return Binding(parts.getOrNull(0).orEmpty(), parts.getOrNull(1).orEmpty(), parts.getOrNull(2).orEmpty(),
colors.getOrNull(0).orEmpty(), colors.getOrNull(1).orEmpty())
}
/** Where a control's binding lives: its input in the group the preset puts on its source. */
data class Ref(val groupId: String, val input: String)
/** The source and input names a control reads (D-pad: its four directions). */
private fun inputsOf(type: Int): List<Pair<String, String>> = when (type) {
SteamTouchConfig.A -> listOf("button_diamond" to "button_a")
SteamTouchConfig.B -> listOf("button_diamond" to "button_b")
SteamTouchConfig.X -> listOf("button_diamond" to "button_x")
SteamTouchConfig.Y -> listOf("button_diamond" to "button_y")
SteamTouchConfig.SELECT -> listOf("switch" to "button_menu")
SteamTouchConfig.START -> listOf("switch" to "button_escape")
SteamTouchConfig.BUMPER_LEFT -> listOf("switch" to "left_bumper")
SteamTouchConfig.BUMPER_RIGHT -> listOf("switch" to "right_bumper")
SteamTouchConfig.TRIGGER_LEFT -> listOf("left_trigger" to "click")
SteamTouchConfig.TRIGGER_RIGHT -> listOf("right_trigger" to "click")
SteamTouchConfig.JOYSTICK_LEFT_BUTTON -> listOf("joystick" to "click")
SteamTouchConfig.JOYSTICK_RIGHT_BUTTON -> listOf("right_joystick" to "click")
SteamTouchConfig.MACRO_1_FINGER -> listOf("switch" to "button_macro_1finger")
SteamTouchConfig.MACRO_2_FINGER -> listOf("switch" to "button_macro_2finger")
SteamTouchConfig.DPAD -> DPAD_INPUTS.map { "dpad" to it }
in SteamTouchConfig.MACRO_0..SteamTouchConfig.MACRO_0 + 7 -> listOf("switch" to "button_macro${type - SteamTouchConfig.MACRO_0}")
else -> emptyList()
}
/** D-pad directions, in the order [refsFor] returns them: up, down, left, right. */
val DPAD_INPUTS = listOf("dpad_north", "dpad_south", "dpad_west", "dpad_east")
/**
* Where control [type]'s bindings are for preset [preset] (the action set less one) and its
* active [layers]: a layer's group wins over the base's for the same source.
*/
fun refsFor(mappings: KeyValues.Node, type: Int, preset: Int, layers: List<Int> = emptyList()): List<Ref?> {
val presets = mappings.children("preset").associateBy { it.string("id")?.toIntOrNull() ?: -1 }
val stack = layers.reversed().mapNotNull { presets[it - 1] } + listOfNotNull(presets[preset] ?: presets.values.firstOrNull())
return inputsOf(type).map { (source, input) ->
stack.firstNotNullOfOrNull { p -> groupFor(p, source)?.let { Ref(it, input) } }
}
}
private fun groupFor(preset: KeyValues.Node, source: String): String? =
preset.child("group_source_bindings")?.entries?.firstOrNull { (_, v) ->
val parts = (v as? String)?.split(' ') ?: return@firstOrNull false
parts.getOrNull(0) == source && parts.getOrNull(1) == "active" && !parts.contains("modeshift")
}?.first
/** The binding at [ref]: its first activator's first binding (Full_Press when there is one). */
fun bindingAt(mappings: KeyValues.Node, ref: Ref): Binding? {
val group = mappings.children("group").firstOrNull { it.string("id") == ref.groupId } ?: return null
val activators = group.child("inputs")?.child(ref.input)?.child("activators") ?: return null
val activator = activators.child("Full_Press") ?: activators.entries.firstNotNullOfOrNull { it.second as? KeyValues.Node }
return activator?.child("bindings")?.string("binding")?.let(::parse)
}
/** A short glyph for a binding with no icon or label: what it presses. */
fun glyph(binding: Binding?): String? {
val words = binding?.action?.split(' ') ?: return null
val arg = words.getOrNull(1)?.uppercase() ?: return null
return when (words[0]) {
"xinput_button" -> XINPUT_GLYPHS[arg] ?: arg
"key_press" -> KEY_GLYPHS[arg] ?: arg.take(4)
"mouse_button" -> when (arg) { "LEFT" -> "LMB"; "RIGHT" -> "RMB"; "MIDDLE" -> "MMB"; else -> "M" + arg.take(2) }
"mouse_wheel" -> if (arg.contains("UP")) "▲" else "▼"
else -> null
}
}
private val XINPUT_GLYPHS = mapOf(
"SHOULDER_LEFT" to "LB", "SHOULDER_RIGHT" to "RB", "TRIGGER_LEFT" to "LT", "TRIGGER_RIGHT" to "RT",
"JOYSTICK_LEFT" to "L3", "JOYSTICK_RIGHT" to "R3", "START" to "☰", "SELECT" to "⧉", "BACK" to "⧉",
"DPAD_UP" to "▲", "DPAD_DOWN" to "▼", "DPAD_LEFT" to "◀", "DPAD_RIGHT" to "▶",
)
private val KEY_GLYPHS = mapOf(
"RETURN" to "⏎", "SPACE" to "␣", "ESCAPE" to "Esc", "TAB" to "Tab", "BACKSPACE" to "⌫",
"LEFT_SHIFT" to "⇧", "RIGHT_SHIFT" to "⇧", "LEFT_CONTROL" to "Ctrl", "RIGHT_CONTROL" to "Ctrl",
"LEFT_ALT" to "Alt", "RIGHT_ALT" to "Alt", "UP_ARROW" to "↑", "DOWN_ARROW" to "↓",
"LEFT_ARROW" to "←", "RIGHT_ARROW" to "→",
)
// ---- Writing a binding back ----
/**
* Rewrites every binding of [ref]'s input in config [text] (VDF) with [change], keeping the rest
* of the file byte for byte. Returns the new text, or null when the input is not there.
*/
fun rewrite(text: String, ref: Ref, change: (Binding) -> Binding): String? {
val tokens = Vdf.tokens(text)
val group = Vdf.blocks(tokens, 0, tokens.size, "group").firstOrNull { (s, e) ->
Vdf.value(tokens, s, e, "id") == ref.groupId
} ?: return null
val inputs = Vdf.blocks(tokens, group.first, group.second, "inputs").firstOrNull() ?: return null
val input = Vdf.blocks(tokens, inputs.first, inputs.second, ref.input).firstOrNull() ?: return null
val targets = (input.first until input.second).filter { i ->
tokens[i].text.equals("binding", true) && tokens[i].quoted && i + 1 < input.second && tokens[i + 1].quoted
}.map { tokens[it + 1] }
if (targets.isEmpty()) return null
val out = StringBuilder(text)
for (t in targets.sortedByDescending { it.start }) out.replace(t.start, t.end, change(parse(t.text)).compose())
return out.toString()
}
/** A VDF tokenizer that keeps where each token sits, for edits that leave the rest untouched. */
private object Vdf {
class Token(val text: String, val quoted: Boolean, val start: Int, val end: Int)
fun tokens(text: String): List<Token> {
val out = mutableListOf<Token>()
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 += Token(c.toString(), false, i, i + 1); i++ }
c == '"' -> {
val start = i + 1
i++
while (i < text.length && text[i] != '"') { if (text[i] == '\\') i++; i++ }
out += Token(text.substring(start, minOf(i, text.length)), true, start, minOf(i, text.length))
i++
}
else -> {
val start = i
while (i < text.length && !text[i].isWhitespace() && text[i] != '{' && text[i] != '}' && text[i] != '"') i++
out += Token(text.substring(start, i), false, start, i)
}
}
}
return out
}
/** The bodies (token index ranges inside the braces) of direct children named [key] of the
* block spanning [from, to). */
fun blocks(tokens: List<Token>, from: Int, to: Int, key: String): List<Pair<Int, Int>> {
val found = mutableListOf<Pair<Int, Int>>()
var i = from
var depth = 0
while (i < to) {
val t = tokens[i]
if (t.text == "{" && !t.quoted) depth++
else if (t.text == "}" && !t.quoted) depth--
else if (depth == 0 && t.quoted && t.text.equals(key, true) && i + 1 < to && tokens[i + 1].text == "{" && !tokens[i + 1].quoted) {
val open = i + 1
var d = 0
var j = open
while (j < to) {
if (tokens[j].text == "{" && !tokens[j].quoted) d++
if (tokens[j].text == "}" && !tokens[j].quoted) { d--; if (d == 0) break }
j++
}
found += (open + 1) to j
i = j + 1
continue
}
i++
}
// The whole file is one top-level block ("controller_mappings"): look inside it too.
if (found.isEmpty() && from == 0) {
val top = tokens.indexOfFirst { it.text == "{" && !it.quoted }
if (top > 0) {
var d = 0
var j = top
while (j < to) {
if (tokens[j].text == "{" && !tokens[j].quoted) d++
if (tokens[j].text == "}" && !tokens[j].quoted) { d--; if (d == 0) break }
j++
}
return blocks(tokens, top + 1, j, key)
}
}
return found
}
/** A direct key-value of the block spanning [from, to). */
fun value(tokens: List<Token>, from: Int, to: Int, key: String): String? {
var depth = 0
var i = from
while (i < to) {
val t = tokens[i]
if (t.text == "{" && !t.quoted) depth++
else if (t.text == "}" && !t.quoted) depth--
else if (depth == 0 && t.quoted && t.text.equals(key, true) && i + 1 < to && tokens[i + 1].quoted) return tokens[i + 1].text
i++
}
return null
}
}
// ---- Icons ----
private val cache = object : LruCache<String, Bitmap>(24 * 1024 * 1024) {
override fun sizeOf(key: String, value: Bitmap) = value.byteCount
}
private val missing = HashSet<String>()
fun libraryDir(context: Context) = File(SteamTouchConfig.steamRoot(context), "tenfoot/resource/images/library/controller/binding_icons")
/** The game's own icons folder (`TouchMenuIcons` in its install directory), when it has one. */
fun gameIconsDir(context: Context, appId: Int): File? = try {
val manifest = File(SteamTouchConfig.steamRoot(context), "steamapps/appmanifest_$appId.acf")
val installDir = KeyValues.parse(manifest.readText()).let { it.child("AppState") ?: it }.string("installdir")
installDir?.let { File(SteamTouchConfig.steamRoot(context), "steamapps/common/$it/TouchMenuIcons") }?.takeIf { it.isDirectory }
} catch (_: Exception) {
null
}
fun iconFile(context: Context, appId: Int, name: String): File? {
if (name.isEmpty() || name.contains('/')) return null
gameIconsDir(context, appId)?.let { File(it, name) }?.takeIf { it.isFile }?.let { return it }
File(libraryDir(context), name).takeIf { it.isFile }?.let { return it }
return File(SteamTouchConfig.steamRoot(context), "steamui/images/controller/$name").takeIf { it.isFile }
}
/** An icon, decoded at about [size] px. Blocking (reads a file); cached. */
fun icon(context: Context, appId: Int, name: String, size: Int = 128): Bitmap? {
val key = "$appId/$name/$size"
cache.get(key)?.let { return it }
if (key in missing) return null
val file = iconFile(context, appId, name)
val bitmap = file?.let {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(it.path, bounds)
var sample = 1
while (bounds.outWidth / (sample * 2) >= size) sample *= 2
BitmapFactory.decodeFile(it.path, BitmapFactory.Options().apply { inSampleSize = sample })
}
if (bitmap == null) missing += key else cache.put(key, bitmap)
return bitmap
}
fun cachedIcon(appId: Int, name: String, size: Int = 128): Bitmap? = cache.get("$appId/$name/$size")
/** Icon names for the picker: the game's own first, then Steam's library by category. */
fun iconNames(context: Context, appId: Int): List<String> {
val own = gameIconsDir(context, appId)?.list()?.filter { it.endsWith(".png", true) }?.sorted().orEmpty()
val library = libraryDir(context).list()?.filter { it.endsWith(".png", true) && it != "special_blank.png" }?.sorted().orEmpty()
return own + library.filter { it !in own }
}
}
@@ -0,0 +1,654 @@
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
const val MAGNIFYING_GLASS = 29
const val PASTE = 34
/** Controls a layout may place though no binding asks for them (Steam Link's tray). */
val OPTIONAL = setOf(PASTE)
/** 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<Element>, 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<Layout>, val rest: ByteArray) {
fun forActionSet(id: Int): Layout? = layouts.firstOrNull { it.actionSet == id }
/** The per-game options Steam Link keeps beside the layouts (CVirtualControllerLayouts
* fields 2-4): input mode, mouse mode, trackpad sensitivity. */
val options: Options get() = Options.decode(rest)
}
/** EInputMode: 1 mouse, 2 controller, 3 both. EMouseMode: 2 absolute cursor (direct touch),
* 3 touch, 4 relative (trackpad); 0 = not set. */
data class Options(val inputMode: Int = INPUT_CONTROLLER, val mouseMode: Int = 0, val trackpadSensitivity: Float = 1f) {
/** [rest] with these options in place of its own fields 2-4, the rest kept as it was. */
fun encodeInto(rest: ByteArray): ByteArray {
val out = ByteArrayOutputStream()
val p = Proto(rest)
while (p.more()) {
val start = p.pos
val (field, wire) = p.key()
p.skip(wire)
if (field !in 2..4) out.write(rest, start, p.pos - start)
}
tag(out, 2, 0); varint(out, inputMode.toLong())
if (mouseMode != 0) { tag(out, 3, 0); varint(out, mouseMode.toLong()) }
tag(out, 4, 5); fixed(out, trackpadSensitivity)
return out.toByteArray()
}
companion object {
fun decode(rest: ByteArray): Options {
var input = INPUT_CONTROLLER
var mouse = 0
var sensitivity = 1f
val p = Proto(rest)
while (p.more()) {
val (field, wire) = p.key()
when {
field == 2 && wire == 0 -> input = p.varint().toInt()
field == 3 && wire == 0 -> mouse = p.varint().toInt()
field == 4 && wire == 5 -> sensitivity = p.float()
else -> p.skip(wire)
}
}
return Options(input, mouse, sensitivity)
}
}
}
const val INPUT_MOUSE = 1
const val INPUT_CONTROLLER = 2
const val INPUT_BOTH = 3
const val MOUSE_ABSOLUTE = 2
const val MOUSE_TOUCH = 3
const val MOUSE_RELATIVE = 4
/** The config's action sets in preset order: id as the device is told (preset id + 1) and title. */
fun actionSets(config: Config): List<Pair<Int, String>> {
val mappings = config.mappings ?: return listOf(1 to "Default")
val titles = mappings.child("actions")?.entries?.mapNotNull { (key, v) ->
(v as? KeyValues.Node)?.let { key to (it.string("title") ?: key) }
}?.toMap().orEmpty()
val sets = mappings.children("preset").mapNotNull { preset ->
val id = preset.string("id")?.toIntOrNull() ?: return@mapNotNull null
val name = preset.string("name") ?: ""
(id + 1) to (titles[name]?.takeUnless { it.startsWith("#") } ?: name.ifEmpty { "Set ${id + 1}" })
}.sortedBy { it.first }
return sets.ifEmpty { listOf(1 to "Default") }
}
/** 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<Int, Set<Int>>,
/** The parsed config, for its bindings (SteamTouchBindings). */
val mappings: KeyValues.Node? = null,
) {
/** The config's own title, as Steam Link's tray shows it ("Default", "Gamepad"…). */
val title: String? get() = mappings?.string("title")?.let { t ->
if (t.startsWith("#")) mappings.child("localization")?.child("english")?.string("title") ?: "Default" else t
}
/** The controls to show for [actionSet] (as the device is told it) and [layers]. */
fun availableFor(actionSet: Int, layers: List<Int>): Set<Int> {
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 ----
/** A game's name from its app manifest, when it is installed. */
fun gameName(context: Context, appId: Int): String? = try {
val kv = KeyValues.parse(File(steamRoot(context), "steamapps/appmanifest_$appId.acf").readText())
(kv.child("AppState") ?: kv).string("name")
} catch (_: Exception) {
null
}
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)
// The app's own saved config is what the client loads when there is one (its configset
// entry); otherwise what it logged loading - a template, an official or a workshop config.
val file = dir?.let { autosaveConfig(it, appId) }
?: 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), 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 <n>, Controller <i>: <path>`, 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
}
private fun autosaveConfig(dir: File, appId: Int): File? = try {
val set = KeyValues.parse(File(dir, CONFIGSET).readText()).child("controller_config")?.child(appId.toString())
if (set?.string("autosave") == "1") File(dir, "$appId/controller_mobile_touch.vdf").takeIf { it.isFile } else null
} 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<Int, Set<Int>> {
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<Int>()
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<Int>): Element? {
val leftStick = JOYSTICK_LEFT in available
val rightStick = JOYSTICK_RIGHT in available
val faceY = if (rightStick) -0.30f else 0f
val p: Pair<Float, Float> = 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
PASTE -> 1115f / 1280 to 75f / 720
DPAD -> if (leftStick) 0.147f to 0.47f 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.205f
TRIGGER_LEFT -> 0.16f to 0.205f
BUMPER_RIGHT -> 0.94f to 0.205f
TRIGGER_RIGHT -> 0.84f to 0.205f
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<Int>): List<Element> {
val available = config.availableFor(actionSet, layers)
val layout = config.layouts?.forActionSet(layoutIdOf(actionSet))
val placed = layout?.elements?.associateBy { it.type }.orEmpty()
val optional = OPTIONAL.filter { placed[it]?.visible == true && it !in available }
return (available + optional).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 (`<appid>/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<Element>,
color: FloatArray? = null,
bindings: Map<SteamTouchBindings.Ref, (SteamTouchBindings.Binding) -> SteamTouchBindings.Binding> = emptyMap(),
): File? = saveLayouts(context, appId, config, mapOf(layoutIdOf(actionSet) to elements), color?.let { c -> setOf(layoutIdOf(actionSet)).associateWith { c } } ?: emptyMap(), bindings)
/**
* Saves the layouts of several action sets (by layout id) at once, with their colours, any
* binding edits and the per-game [options], into the game's autosave touch config.
*/
fun saveLayouts(
context: Context,
appId: Int,
config: Config,
sets: Map<Int, List<Element>>,
colors: Map<Int, FloatArray> = emptyMap(),
bindings: Map<SteamTouchBindings.Ref, (SteamTouchBindings.Binding) -> SteamTouchBindings.Binding> = emptyMap(),
options: Options? = null,
): File? {
val dir = configDir(context) ?: return null
// Icons, labels and colours live in the binding strings (SteamTouchBindings), as the
// configurator writes them.
val source = bindings.entries.fold(config.text ?: return null) { text, (ref, change) ->
SteamTouchBindings.rewrite(text, ref, change) ?: text
}
val target = File(dir, "$appId/controller_mobile_touch.vdf")
val old = config.layouts
val keep = old?.layouts?.filter { it.actionSet !in sets.keys }.orEmpty()
val written = sets.map { (id, elements) ->
Layout(id, elements, colors[id] ?: old?.forActionSet(id)?.color ?: floatArrayOf(1f, 1f, 1f, 0.4f), old?.forActionSet(id)?.version)
}
val rest = old?.rest ?: byteArrayOf(0x10, 0x02) // input_mode = controller
val layouts = Layouts((keep + written).sortedBy { it.actionSet }, options?.encodeInto(rest) ?: rest)
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 layouts ${sets.keys} 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<Layout>()
val rest = ByteArrayOutputStream()
val p = Proto(bytes)
while (p.more()) {
val start = p.pos
val (field, wire) = p.key()
if (field == 1 && wire == 2) layouts += decodeLayout(p.bytes())
else { p.skip(wire); rest.write(bytes, start, p.pos - 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<Element>()
val p = Proto(bytes)
while (p.more()) {
val (field, wire) = p.key()
when {
field == 1 && wire == 0 -> version = p.varint().toInt()
field == 2 && wire == 0 -> actionSet = p.varint().toInt()
field == 4 && wire == 2 -> elements += decodeElement(p.bytes())
field == 5 && wire == 2 -> color = decodeColor(p.bytes())
else -> p.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
val p = Proto(bytes)
while (p.more()) {
val (field, wire) = p.key()
when {
field == 1 && wire == 0 -> type = p.varint().toInt()
field == 2 && wire == 0 -> visible = p.varint() != 0L
field == 3 && wire == 5 -> x = p.float()
field == 4 && wire == 5 -> y = p.float()
field == 5 && wire == 5 -> sx = p.float()
field == 6 && wire == 5 -> sy = p.float()
else -> p.skip(wire)
}
}
return Element(type, visible, x, y, sx, sy)
}
private fun decodeColor(bytes: ByteArray): FloatArray {
val c = floatArrayOf(1f, 1f, 1f, 1f)
val p = Proto(bytes)
while (p.more()) {
val (field, wire) = p.key()
if (field in 1..4 && wire == 5) c[field - 1] = p.float() else p.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
}
}
fun more() = pos < data.size
fun key(): Pair<Int, Int> = varint().toInt().let { (it ushr 3) to (it and 7) }
}
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<Pair<String, Any>> = 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<Node> = 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<String> {
val out = mutableListOf<String>()
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
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,171 @@
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<Int>,
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) {
// Sent only while the client asks for it (setting report 0x30, which Steam Link reads as
// "gyro on") and the player has not turned motion off - as Steam Link does.
if (!plugged || !gyroRequested || !motionEnabled) return
val next = report.copyOf()
for (i in 0 until 3) {
// Accelerometer: the Deck's 1 g = 16384 is the touch report's ±2 g = ±32767 already.
putShort(next, 28 + 2 * i, accel[i])
// Gyro: the Deck's 2000 °/s = 32768; the touch report's full scale is 1000 °/s
// (Steam Link: rad/s × 180/π / 1000 × 32767).
putShort(next, 34 + 2 * i, (gyro[i] * 2).coerceIn(-32767, 32767).toShort())
}
publish(next)
}
/** Whether the client has asked the device for motion (setting report 0x30 non-zero). */
val gyroRequested get() = buffer.getInt(OFF_SETTING30) != 0
/** The player's motion switch (Steam Link's gyroscope option). */
@Volatile var motionEnabled = true
set(value) {
field = value
if (!value) synchronized(this) {
val next = report.copyOf()
for (i in 28 until 40) next[i] = 0
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_SETTING30 = 132
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
}
}
}
@@ -0,0 +1,274 @@
package com.droiddeck.launcher.input
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.drawable.GradientDrawable
import android.os.Handler
import android.os.Looper
import android.text.InputType
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.EditText
import android.widget.GridView
import android.widget.HorizontalScrollView
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import java.util.concurrent.Executors
/**
* Picks a touch control's icon, label and colours the way Steam's configurator does: an icon from
* the game's own `TouchMenuIcons` and Steam's binding icon library, a foreground and a background
* from Steam's palette. [onPicked] gets the binding with the new fields (the action untouched).
*/
class SteamTouchIconPicker(
private val context: Context,
private val host: ViewGroup,
private val appId: Int,
private val title: String,
private val current: SteamTouchBindings.Binding,
private val onPicked: (SteamTouchBindings.Binding) -> Unit,
) {
private val main = Handler(Looper.getMainLooper())
private val loader = Executors.newFixedThreadPool(2)
private var icon = current.icon
private var foreground = current.foreground.ifEmpty { SteamTouchBindings.DEFAULT_FOREGROUND }
private var background = current.background.ifEmpty { SteamTouchBindings.DEFAULT_BACKGROUND }
private val density = context.resources.displayMetrics.density
private fun dp(v: Int) = (v * density).toInt()
fun show() {
// Side by side, for a landscape screen: what is picked on the left, the icons on the right.
val root = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL; setPadding(dp(16), dp(8), dp(16), 0) }
val left = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL }
val label = EditText(context).apply {
hint = "Label (optional)"
setText(current.label)
inputType = InputType.TYPE_CLASS_TEXT
setSingleLine()
}
left.addView(label)
val preview = ImageView(context)
val names = SteamTouchBindings.iconNames(context, appId)
val grid = GridView(context).apply {
numColumns = GridView.AUTO_FIT
columnWidth = dp(56)
stretchMode = GridView.STRETCH_COLUMN_WIDTH
verticalSpacing = dp(6)
horizontalSpacing = dp(6)
}
val adapter = IconAdapter(names)
grid.adapter = adapter
grid.setOnItemClickListener { _, _, position, _ ->
icon = names[position]
adapter.notifyDataSetChanged()
updatePreview(preview)
}
left.addView(LinearLayout(context).apply {
gravity = Gravity.CENTER_VERTICAL
setPadding(0, dp(8), 0, 0)
addView(preview, LinearLayout.LayoutParams(dp(56), dp(56)))
addView(TextView(context).apply {
text = if (names.isEmpty()) "Steam's icons were not found" else "${names.size} icons from Steam"
setPadding(dp(12), 0, 0, 0)
})
})
left.addView(paletteRow("Icon colour", { foreground }) { foreground = it; adapter.notifyDataSetChanged(); updatePreview(preview) })
left.addView(paletteRow("Button colour", { background }) { background = it; adapter.notifyDataSetChanged(); updatePreview(preview) })
root.addView(left, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
root.addView(grid, LinearLayout.LayoutParams(0, dp(250), 1.2f).apply { marginStart = dp(16) })
updatePreview(preview)
showPanel(host, title, root, listOf(
"No icon" to {
onPicked(current.copy(label = label.text.toString().replace(",", " ").trim(), icon = "", foreground = "", background = ""))
},
"Cancel" to {},
"Use" to {
onPicked(current.copy(label = label.text.toString().replace(",", " ").trim(), icon = icon,
foreground = if (icon.isEmpty()) "" else foreground, background = if (icon.isEmpty()) "" else background))
},
)) { loader.shutdown() }
}
private fun paletteRow(name: String, value: () -> String, onPick: (String) -> Unit): View {
val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER_VERTICAL }
val swatches = mutableListOf<View>()
fun refresh() = swatches.forEachIndexed { i, v ->
(v.background as GradientDrawable).setStroke(dp(if (SteamTouchBindings.PALETTE[i].equals(value(), true)) 3 else 1),
if (SteamTouchBindings.PALETTE[i].equals(value(), true)) Color.rgb(102, 192, 244) else Color.GRAY)
}
SteamTouchBindings.PALETTE.forEach { hex ->
val swatch = View(context).apply {
background = GradientDrawable().apply { shape = GradientDrawable.OVAL; setColor(Color.parseColor(hex)) }
setOnClickListener { onPick(hex); refresh() }
}
swatches += swatch
row.addView(swatch, LinearLayout.LayoutParams(dp(30), dp(30)).apply { marginEnd = dp(6) })
}
refresh()
return LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setPadding(0, dp(8), 0, 0)
addView(TextView(context).apply { text = name })
addView(HorizontalScrollView(context).apply { addView(row) })
}
}
private fun updatePreview(view: ImageView) {
if (icon.isEmpty()) { view.setImageDrawable(null); view.background = null; return }
loader.execute {
val bitmap = SteamTouchBindings.icon(context, appId, icon, 128)?.let { render(it, dp(56)) }
main.post { view.setImageBitmap(bitmap) }
}
}
/** The icon as Steam draws it: on a disc of the background colour, filled with the foreground
* through its own alpha. */
private fun render(icon: Bitmap, size: Int): Bitmap {
val out = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(out)
val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
paint.color = Color.parseColor(background)
canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint)
drawSteamIcon(canvas, icon, android.graphics.RectF(size * 0.18f, size * 0.18f, size * 0.82f, size * 0.82f), Color.parseColor(foreground), paint)
return out
}
private inner class IconAdapter(val names: List<String>) : BaseAdapter() {
private val loading = HashSet<String>()
private var refreshQueued = false
/** Loads an icon in the background; the grid redraws from the cache once some have come in. */
private fun load(name: String) {
if (!loading.add(name)) return
loader.execute {
SteamTouchBindings.icon(context, appId, name, 96)
main.post {
loading.remove(name)
if (refreshQueued) return@post
refreshQueued = true
main.postDelayed({ refreshQueued = false; notifyDataSetChanged() }, 120)
}
}
}
override fun getCount() = names.size
override fun getItem(position: Int) = names[position]
override fun getItemId(position: Int) = position.toLong()
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
val view = (convertView as? ImageView) ?: ImageView(context).apply {
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(56))
setPadding(dp(8), dp(8), dp(8), dp(8))
}
val name = names[position]
view.tag = name
view.background = GradientDrawable().apply {
cornerRadius = dp(8).toFloat()
setColor(Color.parseColor(background))
if (name == icon) setStroke(dp(3), Color.rgb(102, 192, 244))
}
view.colorFilter = null
val cached = SteamTouchBindings.cachedIcon(appId, name, 96)
if (cached != null) {
val t = tinted(name, cached)
if ((view.drawable as? android.graphics.drawable.BitmapDrawable)?.bitmap !== t) view.setImageBitmap(t)
} else {
view.setImageDrawable(null)
load(name)
}
return view
}
// One thumbnail per icon and colour, reused when the grid rebinds.
private val thumbs = android.util.LruCache<String, Bitmap>(64)
private fun tinted(name: String, icon: Bitmap): Bitmap {
val key = "$name|$foreground"
thumbs.get(key)?.let { return it }
val out = Bitmap.createBitmap(icon.width, icon.height, Bitmap.Config.ARGB_8888)
drawSteamIcon(Canvas(out), icon, android.graphics.RectF(0f, 0f, icon.width.toFloat(), icon.height.toFloat()),
Color.parseColor(foreground), Paint(Paint.FILTER_BITMAP_FLAG))
thumbs.put(key, out)
return out
}
}
companion object {
/**
* A panel over the session, in the session's own window: a dialog's window of its own left
* new bitmaps and layers undrawn in the session window afterwards on the Thor.
*/
fun showPanel(host: ViewGroup, title: String, content: View, buttons: List<Pair<String, () -> Unit>>, onClose: () -> Unit = {}): () -> Unit {
val context = host.context
val density = context.resources.displayMetrics.density
fun dp(v: Int) = (v * density).toInt()
val scrim = android.widget.FrameLayout(context).apply {
setBackgroundColor(Color.argb(140, 0, 0, 0))
isClickable = true
isFocusable = true
}
// DroidDeck's panel: dark surface, rounded, a hairline border.
val card = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
background = GradientDrawable().apply {
cornerRadius = dp(18).toFloat(); setColor(Color.rgb(18, 20, 23)); setStroke(dp(1), Color.rgb(38, 42, 49))
}
setPadding(dp(12), dp(14), dp(12), dp(10))
isClickable = true
}
card.addView(TextView(context).apply {
text = title
textSize = 15f
setTypeface(android.graphics.Typeface.DEFAULT_BOLD)
gravity = Gravity.CENTER
setTextColor(Color.WHITE)
setPadding(dp(16), 0, dp(16), dp(4))
})
card.addView(content)
val row = LinearLayout(context).apply { gravity = Gravity.CENTER; setPadding(dp(8), dp(10), dp(8), 0) }
fun close() {
(scrim.parent as? ViewGroup)?.removeView(scrim)
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager
imm.hideSoftInputFromWindow(host.windowToken, 0)
onClose()
}
buttons.forEachIndexed { i, (name, action) ->
row.addView(TextView(context).apply {
text = name
gravity = Gravity.CENTER
textSize = 14f
setTextColor(Color.WHITE)
val primary = i == buttons.size - 1
setTextColor(if (primary) Color.rgb(3, 17, 31) else Color.rgb(242, 244, 247))
background = GradientDrawable().apply {
cornerRadius = dp(12).toFloat()
if (primary) setColor(Color.rgb(26, 159, 255)) else { setColor(Color.rgb(26, 29, 34)); setStroke(dp(1), Color.rgb(52, 58, 67)) }
}
setPadding(dp(20), dp(10), dp(20), dp(10))
setOnClickListener { close(); action() }
}, LinearLayout.LayoutParams(0, dp(44), 1f).apply { marginStart = dp(6); marginEnd = dp(6) })
}
card.addView(row)
val width = minOf(dp(640), (context.resources.displayMetrics.widthPixels * 0.9f).toInt())
scrim.addView(card, android.widget.FrameLayout.LayoutParams(width, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER))
scrim.setOnClickListener { close() }
host.addView(scrim, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
return { if (scrim.parent != null) close() }
}
/** Steam's binding icon look (steamui: the icon multiplied with its foreground colour,
* background-blend-mode: multiply, keeping the icon's own alpha). */
fun drawSteamIcon(canvas: Canvas, icon: Bitmap, into: android.graphics.RectF, foreground: Int, paint: Paint) {
val tint = Paint(paint).apply { colorFilter = PorterDuffColorFilter(foreground, PorterDuff.Mode.MULTIPLY) }
canvas.drawBitmap(icon, null, into, tint)
}
}
}
@@ -227,7 +227,8 @@ public final class LinuxRuntimeInstaller {
// Its quarantined home is not user data to carry into an update.
File pendingRemoval = removalDirectory(LinuxRuntime.rootDir(context));
if (pendingRemoval.exists() && listener != null) listener.onProgress(context.getString(R.string.rtinst_removing_leftovers), -1);
RuntimeFileTree.delete(pendingRemoval, null);
if (!discard(pendingRemoval, null)) throw new IOException("Cannot clear the previous runtime: " + pendingRemoval);
emptyTrash(LinuxRuntime.rootDir(context).getParentFile());
String downloading = context.getString(R.string.rtinst_downloading);
if (listener != null) listener.onProgress(Step.DOWNLOADING, downloading, 0);
// Downloader reports a 0..1 fraction, or -1 while the total size is unknown.
@@ -255,8 +256,7 @@ public final class LinuxRuntimeInstaller {
File staging = new File(root.getParentFile(), LinuxRuntime.DIR + ".new");
File old = new File(root.getParentFile(), LinuxRuntime.DIR + ".old");
recoverInterruptedSwap(root, staging, old);
RuntimeFileTree.delete(staging, null);
if (!staging.mkdirs()) return false;
if (!discard(staging, null) || !staging.mkdirs()) return false;
if (listener != null) listener.onProgress(context.getString(R.string.rtinst_extracting), -1);
if (!extract(context, archive, staging, listener)) {
RuntimeFileTree.delete(staging, null);
@@ -264,7 +264,10 @@ public final class LinuxRuntimeInstaller {
}
FileUtils.writeString(new File(staging, VERSION_FILE), release.version);
RuntimeFileTree.delete(old, null);
if (!discard(old, null)) {
discard(staging, null);
return false;
}
if (root.isDirectory() && !root.renameTo(old)) {
RuntimeFileTree.delete(staging, null);
return false;
@@ -292,7 +295,7 @@ public final class LinuxRuntimeInstaller {
if (old.isDirectory()) old.renameTo(root);
return false;
}
RuntimeFileTree.delete(old, null);
discard(old, null);
return LinuxRuntime.isInstalled(context);
} catch (Exception e) {
Log.e(TAG, "install", e);
@@ -315,6 +318,35 @@ public final class LinuxRuntimeInstaller {
}
}
/** Where entries the app cannot unlink are set aside, out of the way of the runtime's names. */
static final String TRASH = ".runtime-trash";
/**
* Gets {@code tree} out from under its name. What can be deleted is; if something inside
* cannot be (a file made by root or {@code su} carries another SELinux category, and the app
* may not unlink it), the remainder is moved aside into {@link #TRASH} instead of blocking
* every later install and removal on it. False only when even that move is refused.
*/
static boolean discard(File tree, java.util.function.LongConsumer progress) {
if (RuntimeFileTree.deleteWhatCan(tree, progress)) return true;
File trash = new File(tree.getParentFile(), TRASH);
trash.mkdirs();
File aside = new File(trash, tree.getName() + "-" + System.currentTimeMillis());
if (tree.renameTo(aside)) {
Log.w(TAG, "could not delete all of " + tree + "; set the rest aside in " + aside);
return true;
}
Log.w(TAG, "could not delete or set aside " + tree);
return !tree.exists();
}
/** Another try at what {@link #discard} set aside. Whatever still cannot go stays there. */
static void emptyTrash(File parent) {
File trash = new File(parent, TRASH);
if (trash.exists() && !RuntimeFileTree.deleteWhatCan(trash, null))
Log.i(TAG, "some runtime leftovers still cannot be deleted: " + trash);
}
private static boolean isEmptyDir(File dir) {
String[] names = dir.list();
return dir.isDirectory() && names != null && names.length == 0;
@@ -369,15 +401,21 @@ public final class LinuxRuntimeInstaller {
// Invalidate the installation before traversing it. A process killed halfway
// through leaves a named removal to resume, never a launchable partial runtime.
if (root.exists()) {
RuntimeFileTree.delete(pending, progress);
if (!root.renameTo(pending)) throw new IOException("Cannot prepare the runtime for removal");
if (!discard(pending, progress) || !root.renameTo(pending))
throw new IOException("Cannot prepare the runtime for removal");
} else if (!pending.isDirectory() && !pending.mkdirs()) {
throw new IOException("Cannot prepare the runtime for removal");
}
// Keep the quarantine until all leftovers are gone, so a failure stays retryable.
RuntimeFileTree.delete(new File(root.getParentFile(), root.getName() + ".new"), progress);
RuntimeFileTree.delete(new File(root.getParentFile(), root.getName() + ".old"), progress);
RuntimeFileTree.delete(pending, progress);
// An entry the app may not delete is set aside rather than failing the removal:
// a quarantine that can never clear would block reinstalling for good.
for (File leftover : new File[]{
new File(root.getParentFile(), root.getName() + ".new"),
new File(root.getParentFile(), root.getName() + ".old"),
pending}) {
if (!discard(leftover, progress)) throw new IOException("Cannot remove " + leftover);
}
emptyTrash(root.getParentFile());
job.ok = true;
report(text(app, R.string.rtinst_removed), 100);
return true;
@@ -24,8 +24,15 @@ object ProotFastPath {
/** MAX_BINDS in fastpath.c: past it the library turns itself off, so the session keeps it off. */
const val MAX_BINDS = 256
/**
* Only the app's own proot (libproot.so, built with patches/0014) lets the trampoline through.
* The runtime's fallback copy (LinuxRuntime.prootBinary) is Termux's, which traps those calls
* too and translates their host paths a second time: every lookup then fails inside the
* process and goes to proot anyway, paying for both.
*/
fun enabled(context: Context): Boolean =
SessionPrefs.prootFastPath(context) && !SessionPrefs.prootNoSeccomp(context)
SessionPrefs.prootFastPath(context) && !SessionPrefs.prootNoSeccomp(context) &&
LinuxRuntime.prootBinary(context).parent == context.applicationInfo.nativeLibraryDir
/** The key for this rootfs and these binds, or null when the library cannot be told them. */
fun key(root: File, binds: List<String>): String? {
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.runtime;
import java.io.File;
import java.io.IOException;
import java.nio.file.AccessDeniedException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.LinkOption;
@@ -9,11 +10,15 @@ import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.function.LongConsumer;
import java.util.function.Predicate;
/** Checked removal of an app-owned runtime tree, including read-only directories. */
final class RuntimeFileTree {
private RuntimeFileTree() {}
/** Unit tests stand in for an entry the app cannot unlink (a file another SELinux user made). */
static volatile Predicate<Path> refuseForTest;
static void delete(File root, LongConsumer progress) throws IOException {
delete(root.toPath(), progress, new long[]{0});
}
@@ -30,10 +35,48 @@ final class RuntimeFileTree {
for (Path child : children) delete(child, progress, removed);
}
}
Files.delete(path);
unlink(path);
if (progress != null) progress.accept(++removed[0]);
}
/**
* Removes what it can of {@code root} and carries on past entries it cannot, rather than
* stopping at the first. Returns true when nothing is left.
*/
static boolean deleteWhatCan(File root, LongConsumer progress) {
return deleteWhatCan(root.toPath(), progress, new long[]{0});
}
private static boolean deleteWhatCan(Path path, LongConsumer progress, long[] removed) {
BasicFileAttributes attrs;
try { attrs = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); }
catch (NoSuchFileException e) { return true; }
catch (IOException e) { return false; }
boolean emptied = true;
if (attrs.isDirectory()) {
try {
makeWritable(path.toFile());
try (DirectoryStream<Path> children = Files.newDirectoryStream(path)) {
for (Path child : children) emptied &= deleteWhatCan(child, progress, removed);
}
} catch (IOException | RuntimeException e) {
emptied = false;
}
}
if (!emptied) return false;
try { unlink(path); }
catch (NoSuchFileException e) { return true; }
catch (IOException e) { return false; }
if (progress != null) progress.accept(++removed[0]);
return true;
}
private static void unlink(Path path) throws IOException {
Predicate<Path> refuse = refuseForTest;
if (refuse != null && refuse.test(path)) throw new AccessDeniedException(path.toString());
Files.delete(path);
}
private static void makeWritable(File dir) throws IOException {
if (!dir.setReadable(true, true) || !dir.setWritable(true, true) || !dir.setExecutable(true, true))
throw new IOException("Cannot make directory removable: " + dir);
@@ -22,19 +22,19 @@ import kotlin.math.max
* When a game plays a force-feedback effect on the fake pad (or Steam Input sends the Deck's
* rumble report), the fake evdev layer inside the guest connects to the abstract socket [NAME]
* and sends one packet: strong, weak, duration in ms and the pad slot, four little-endian 16-bit
* values. This listens for those and plays the effect on the physical controller that last drove
* the pad (PadBridge.activeControllerId) when Android exposes its motors - strong and weak
* separately where it has two - and otherwise on the device's own vibrator, with the stronger
* motor's strength: the on-screen pad, or a controller without rumble. Best effort both ways: a
* missing listener costs the guest nothing, and a bad packet is dropped.
* values. This listens for those and plays the effect on the physical controller playing in that
* slot (PadBridge.controllerForSlot) when Android exposes its motors - strong and weak separately
* where it has two - and otherwise on the device's own vibrator, with the stronger motor's
* strength: the on-screen pad, or a controller without rumble. Best effort both ways: a missing
* listener costs the guest nothing, and a bad packet is dropped.
*/
class RumbleComponent : SessionPart() {
@Volatile private var server: LocalServerSocket? = null
private var phone: Motors? = null
/** Where the last effect went, so the next one (or a stop) can end it there. */
private var playing: Motors? = null
private var controllerId = PadBridge.NO_CONTROLLER
private var controller: Motors? = null
/** Per slot, where its last effect went, so the next one (or a stop) can end it there. */
private val playing = HashMap<Int, Motors>()
/** Each controller's motors, looked up once; null when it has none. */
private val controllers = HashMap<Int, Motors?>()
private var preferences: SharedPreferences? = null
private var enabled = false
private val preferenceListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
@@ -46,7 +46,7 @@ class RumbleComponent : SessionPart() {
@Synchronized private fun refreshEnabled() {
enabled = app()?.let { ControllerPrefs.rumbleEnabled(it) } == true
if (!enabled) { playing?.cancel(); playing = null }
if (!enabled) cancelAll()
}
override fun start() {
@@ -64,7 +64,7 @@ class RumbleComponent : SessionPart() {
try {
val bytes = ByteArray(8)
DataInputStream(client.inputStream).readFully(bytes)
buzz(u16(bytes, 0), u16(bytes, 2), u16(bytes, 4))
buzz(u16(bytes, 0), u16(bytes, 2), u16(bytes, 4), u16(bytes, 6))
} catch (e: Exception) {
// A partial packet or a closed peer: nothing to play.
} finally {
@@ -79,11 +79,9 @@ class RumbleComponent : SessionPart() {
preferences?.unregisterOnSharedPreferenceChangeListener(preferenceListener)
preferences = null
enabled = false
playing?.cancel()
playing = null
cancelAll()
phone = null
controller = null
controllerId = PadBridge.NO_CONTROLLER
controllers.clear()
val s = server
server = null
try { s?.close() } catch (e: Exception) { /* the accept loop ends on the next wake */ }
@@ -91,13 +89,14 @@ class RumbleComponent : SessionPart() {
private fun u16(b: ByteArray, at: Int): Int = (b[at].toInt() and 0xFF) or ((b[at + 1].toInt() and 0xFF) shl 8)
@Synchronized private fun buzz(strong: Int, weak: Int, ms: Int) {
@Synchronized private fun buzz(strong: Int, weak: Int, ms: Int, slot: Int) {
if (!enabled) return
val target = target()
if (target !== playing) {
playing?.cancel()
playing = target
target?.let { Log.i(TAG, "rumble: playing on ${it.name}") }
val target = target(slot)
val previous = playing[slot]
if (target !== previous) {
previous?.cancel()
if (target == null) playing.remove(slot) else playing[slot] = target
target?.let { Log.i(TAG, "rumble: slot $slot playing on ${it.name}") }
}
if (target == null) return
if ((strong == 0 && weak == 0) || ms == 0) { target.cancel(); return }
@@ -108,14 +107,17 @@ class RumbleComponent : SessionPart() {
}
}
/** The active controller's motors when it has any, else the phone's. */
private fun target(): Motors? {
val id = PadBridge.activeControllerId()
if (id != controllerId) {
controllerId = id
controller = if (id == PadBridge.NO_CONTROLLER) null else try { controllerMotors(id) } catch (e: Exception) { null }
}
return controller ?: phone
/** The motors of the controller playing in this slot when it has any, else the phone's. */
private fun target(slot: Int): Motors? {
val id = PadBridge.controllerForSlot(slot)
if (id == PadBridge.NO_CONTROLLER) return phone
if (!controllers.containsKey(id)) controllers[id] = try { controllerMotors(id) } catch (e: Exception) { null }
return controllers[id] ?: phone
}
private fun cancelAll() {
playing.values.toSet().forEach { it.cancel() }
playing.clear()
}
/** One place an effect can play. Strengths are the evdev 0..65535 magnitudes. */
@@ -22,6 +22,8 @@ object SessionPrefs {
const val OSC_ALWAYS = "always"
const val OSC_STEAM_QAM = "steam-qam"
const val OSC_NEVER = "never"
/** Steam's touch controller (SteamTouchControls) in place of the app's pad; Steam sessions only. */
const val OSC_STEAM_TOUCH = "steam-touch"
/** 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
@@ -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) {
@@ -1496,6 +1502,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()) {
@@ -1756,6 +1763,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
@@ -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<String> {
fun prepare(context: Context, sessionRoot: File, touch: Boolean = false): List<String> {
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) {
@@ -43,7 +43,7 @@ private fun Swatch(color: Int) {
fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPrefs.Settings, a: ControllerActions) {
ChoiceRow(
host, "controller-osc", stringResource(R.string.mode_osc), stringResource(R.string.ctrl_osc_hint),
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)),
listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_STEAM_TOUCH to stringResource(R.string.osc_steam_touch), SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never)),
oscMode, note = stringResource(R.string.mode_osc_note), onPick = a.onOsc,
)
val tintOpen = host.open == "controller-tint"
@@ -270,6 +270,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
listOf(
SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto),
SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always),
SessionPrefs.OSC_STEAM_TOUCH to stringResource(R.string.osc_steam_touch),
SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam),
SessionPrefs.OSC_NEVER to stringResource(R.string.common_never),
), s.oscMode,
@@ -217,6 +217,8 @@ class DrawerActions(
val onSteamMenu: (() -> Unit)?,
val onQam: (() -> Unit)?,
val onOsc: (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 = {},
@@ -418,9 +420,14 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.drawer_touch_auto, a.touchAuto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)),
a.touchMode, chipModifier = focus.track(page, "touch"), onPick = a.onTouch)
ChoiceRow(host, "osc", stringResource(R.string.mode_osc), null,
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))
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_TOUCH to stringResource(R.string.osc_steam_touch), 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)
// Steam's touch controller is Steam's alone; elsewhere that choice behaves as Auto.
if (!a.steam && a.oscMode == SessionPrefs.OSC_STEAM_TOUCH) SessionPrefs.OSC_AUTO else a.oscMode,
chipModifier = focus.track(page, "osc"), onPick = a.onOsc)
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)
+4
View File
@@ -257,6 +257,10 @@
<string name="mode_osc">On-screen controls</string>
<string name="mode_osc_qam">Steam + QAM</string>
<string name="mode_osc_note">Auto shows the controls when no controller is connected.</string>
<string name="osc_steam_touch">Steam touch (beta)</string>
<string name="osc_style_next_session">Steam\'s touch controls start with the next Steam session</string>
<string name="osc_steam_layout">Steam touch layout</string>
<string name="osc_steam_layout_hint">Move, resize or hide this game\'s touch controls; saved to Steam</string>
<string name="mode_controller">Controller</string>
<string name="mode_controller_hint">How Steam sees your controller. Applies next session.</string>
<string name="mode_controller_deck">Steam Deck controller</string>
@@ -0,0 +1,118 @@
package com.droiddeck.launcher.input
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
class SteamTouchBindingsTest {
private val config = """
"controller_mappings"
{
"version" "3"
"controller_type" "controller_mobile_touch"
"group"
{
"id" "0"
"mode" "four_buttons"
"inputs"
{
"button_a"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button A, , "
}
}
}
}
"button_b"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "key_press ESCAPE, Pause, ghost_050_menu_0020.png, #FFFFFF #AD0000"
}
}
}
}
}
}
"group"
{
"id" "9"
"mode" "dpad"
"inputs"
{
"dpad_north"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button DPAD_UP, , "
}
}
}
}
}
}
"preset"
{
"id" "0"
"name" "Default"
"group_source_bindings"
{
"0" "button_diamond active"
"9" "dpad active"
}
}
}
"""
private val mappings = KeyValues.parse(config).child("controller_mappings")!!
@Test
fun parsesAndComposesSteamsFormat() {
val b = SteamTouchBindings.parse("key_press ESCAPE, Pause, ghost_050_menu_0020.png, #FFFFFF #AD0000")
assertEquals("key_press ESCAPE", b.action)
assertEquals("Pause", b.label)
assertEquals("ghost_050_menu_0020.png", b.icon)
assertEquals("#FFFFFF", b.foreground)
assertEquals("#AD0000", b.background)
assertEquals("key_press ESCAPE, Pause, ghost_050_menu_0020.png, #FFFFFF #AD0000", b.compose())
assertEquals("xinput_button A, , ", SteamTouchBindings.parse("xinput_button A, , ").compose())
}
@Test
fun findsEachControlsBinding() {
val b = SteamTouchBindings.refsFor(mappings, SteamTouchConfig.B, 0).single()!!
assertEquals(SteamTouchBindings.Ref("0", "button_b"), b)
assertEquals("ghost_050_menu_0020.png", SteamTouchBindings.bindingAt(mappings, b)!!.icon)
val dpad = SteamTouchBindings.refsFor(mappings, SteamTouchConfig.DPAD, 0)
assertEquals(4, dpad.size)
assertEquals("xinput_button DPAD_UP", SteamTouchBindings.bindingAt(mappings, dpad[0]!!)!!.action)
assertNull(SteamTouchBindings.refsFor(mappings, SteamTouchConfig.BUMPER_LEFT, 0).single())
assertEquals("Esc", SteamTouchBindings.glyph(SteamTouchBindings.parse("key_press ESCAPE, , ")))
}
@Test
fun rewritesOnlyThatBinding() {
val ref = SteamTouchBindings.Ref("0", "button_a")
val out = SteamTouchBindings.rewrite(config, ref) { it.copy(label = "Jump", icon = "ghost_045_move_0400.png", foreground = "#232323", background = "#E4E4E4") }
assertNotNull(out)
assertEquals(config.replace("\"xinput_button A, , \"", "\"xinput_button A, Jump, ghost_045_move_0400.png, #232323 #E4E4E4\""), out)
val reparsed = KeyValues.parse(out!!).child("controller_mappings")!!
assertEquals("Jump", SteamTouchBindings.bindingAt(reparsed, ref)!!.label)
assertNull(SteamTouchBindings.rewrite(config, SteamTouchBindings.Ref("5", "button_a")) { it })
}
}
@@ -0,0 +1,69 @@
package com.droiddeck.launcher.input
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SteamTouchConfigTest {
// A layout DroidDeck saved for Geometry Wars on the Thor, as Steam keeps it.
private val saved = "0aec0310012218080010011d0000703d255555d53d2d0000803f350000803f2218080110011d6666fe3e255555d53d2d0000803f350000803f2218080210011dba490c3e255c8f423f2d0000803f350000803f2218080410011d9a99593f255c8f423f2d0000803f350000803f2218080510011d8fc2353f25ae47613f2d0000803f350000803f2218080610011d2b87163e25d7a3f03e2d0000803f350000803f2218080710011dba49003f25f958053f2d293ab23f35293ab23f2218080810011d9a99683f25d227dd3e2d0000803f350000803f2218080910011d9a99483f25d227dd3e2d0000803f350000803f2218080a10011d9a99583f254444a43e2d0000803f350000803f2218080b10011d6666ce3e255555d53d2d0000803f350000803f2218080c10011d3333173f255555d53d2d0000803f350000803f2218080d10011d0ad7233e2585eb513e2d0000803f350000803f2218080e10011d3d0a573f2585eb513e2d0000803f350000803f2218080f10011d8fc2753d2585eb513e2d0000803f350000803f2218081010011dd7a3703f2585eb513e2d0000803f350000803f2218081c10011d0000713f255555d53d2d0000803f350000803f2218080310001de17a943e25ae47613f2d0000803f350000803f2a140d0000803f150000803f1d0000803f25cdcccc3e1002"
@Test
fun decodesSavedLayout() {
val layouts = SteamTouchConfig.decodeLayouts(SteamTouchConfig.hexToBytes(saved))
assertEquals(1, layouts.layouts.size)
val layout = layouts.forActionSet(1)!!
val a = layout.elements.first { it.type == SteamTouchConfig.A }
assertEquals(0.501f, a.x, 0.002f)
assertEquals(1.39f, a.xScale, 0.01f)
assertFalse(layout.elements.first { it.type == SteamTouchConfig.JOYSTICK_LEFT_BUTTON }.visible)
}
@Test
fun roundTrips() {
val bytes = SteamTouchConfig.hexToBytes(saved)
assertEquals(saved, SteamTouchConfig.bytesToHex(SteamTouchConfig.encodeLayouts(SteamTouchConfig.decodeLayouts(bytes))))
}
@Test
fun appliesLayoutToElements() {
val layouts = SteamTouchConfig.decodeLayouts(SteamTouchConfig.hexToBytes(saved))
val all = (0..31).toSet()
val config = SteamTouchConfig.Config(null, null, layouts, mapOf(0 to all))
val shown = SteamTouchConfig.elementsFor(config, 0, emptyList())
val a = shown.first { it.type == SteamTouchConfig.A }
assertEquals(0.501f, a.x, 0.002f)
assertTrue(shown.none { it.type == SteamTouchConfig.JOYSTICK_LEFT_BUTTON })
}
@Test
fun optionsRoundTripAndKeepTheRest() {
val layouts = SteamTouchConfig.decodeLayouts(SteamTouchConfig.hexToBytes(saved))
assertEquals(SteamTouchConfig.INPUT_CONTROLLER, layouts.options.inputMode)
val next = SteamTouchConfig.Options(SteamTouchConfig.INPUT_BOTH, SteamTouchConfig.MOUSE_RELATIVE, 1.75f)
val rest = next.encodeInto(layouts.rest)
assertEquals(next, SteamTouchConfig.Options.decode(rest))
val again = SteamTouchConfig.decodeLayouts(SteamTouchConfig.encodeLayouts(SteamTouchConfig.Layouts(layouts.layouts, rest)))
assertEquals(next, again.options)
assertEquals(layouts.layouts.size, again.layouts.size)
}
@Test
fun listsActionSets() {
val kv = KeyValues.parse("""
"controller_mappings"
{
"actions"
{
"Default" { "title" "Desktop" }
"Preset_1000001" { "title" "Gamepad" }
}
"preset" { "id" "0" "name" "Default" }
"preset" { "id" "1" "name" "Preset_1000001" }
}
""").child("controller_mappings")
val config = SteamTouchConfig.Config(null, null, null, emptyMap(), kv)
assertEquals(listOf(1 to "Desktop", 2 to "Gamepad"), SteamTouchConfig.actionSets(config))
}
}
@@ -16,6 +16,7 @@ class RuntimeRemovalTest {
private lateinit var dir: File
@Before fun setUp() { dir = Files.createTempDirectory("runtime-removal").toFile() }
@After fun tearDown() {
RuntimeFileTree.refuseForTest = null
dir.setWritable(true)
RuntimeFileTree.delete(dir, null)
}
@@ -160,4 +161,40 @@ class RuntimeRemovalTest {
assertTrue(LinuxRuntimeInstaller.beginUninstall(null, root)!!.run(null))
assertNull(LinuxRuntimeInstaller.removalError())
}
@Test fun anEntryTheAppCannotDeleteIsSetAsideInsteadOfStrandingTheRemoval() {
val root = runtime()
File(root, "root/.steampath").writeText("made by su")
RuntimeFileTree.refuseForTest = java.util.function.Predicate { it.fileName.toString() == ".steampath" }
assertTrue(LinuxRuntimeInstaller.beginUninstall(null, root)!!.run(null))
assertNull(LinuxRuntimeInstaller.removalError())
assertFalse(root.exists())
assertFalse(File(dir, "linuxfs.removing").exists())
// Only the undeletable entry and its parents remain, out of the way.
val trash = File(dir, LinuxRuntimeInstaller.TRASH)
assertEquals(listOf(".steampath"), trash.walk().filter { it.isFile }.map { it.name }.toList())
assertEquals(setOf(LinuxRuntimeInstaller.TRASH), dir.list()!!.toSet())
// Once it can be deleted, the next removal clears the trash too.
RuntimeFileTree.refuseForTest = null
assertTrue(LinuxRuntimeInstaller.beginUninstall(null, root)!!.run(null))
assertFalse(trash.exists())
}
@Test fun aStuckQuarantineFromAnEarlierBuildClearsOnTheNextRemoval() {
val pending = File(dir, "linuxfs.removing").apply { File(this, "root").mkdirs() }
File(pending, "root/.steampid").writeText("1")
File(pending, "root/game").writeText("game")
RuntimeFileTree.refuseForTest = java.util.function.Predicate { it.fileName.toString() == ".steampid" }
assertTrue(LinuxRuntimeInstaller.beginUninstall(null, File(dir, "linuxfs"))!!.run(null))
assertFalse(pending.exists())
}
@Test fun deleteWhatCanKeepsGoingPastAnEntryItCannotDelete() {
val root = runtime()
RuntimeFileTree.refuseForTest = java.util.function.Predicate { it.fileName.toString() == "gamescope" }
assertFalse(RuntimeFileTree.deleteWhatCan(root, null))
assertEquals(listOf("gamescope"), root.walk().filter { it.isFile }.map { it.name }.toList())
assertFalse(File(root, "root").exists())
}
}
@@ -115,16 +115,41 @@ class RumbleComponentTest {
assertFalse(pad.vibrating)
}
@After fun clearActiveController() { activeController(PadBridge.NO_CONTROLLER) }
private fun activeController(id: Int) {
PadBridge::class.java.getDeclaredField("activeControllerId").apply { isAccessible = true }.setInt(null, id)
@Test fun eachPlayerSlotRumblesItsOwnControllerAndVirtualPadsUseTheLastActive() {
val first = FakeMotors()
val second = FakeMotors()
rumble.controllerMotors = { id -> if (id == PAD_ID) first else if (id == SECOND_PAD_ID) second else null }
activeController(PAD_ID)
activeController(SECOND_PAD_ID, slot = 1)
effect(strong = 100, weak = 0, slot = 1)
assertEquals(listOf(Triple(100, 0, 5000L)), second.played)
assertTrue(first.played.isEmpty())
effect(strong = 200, weak = 0)
assertEquals(listOf(Triple(200, 0, 5000L)), first.played)
assertTrue("one player's effect ended another's", second.vibrating)
effect(strong = 0, weak = 0, slot = 1)
assertFalse(second.vibrating)
assertTrue(first.vibrating)
effect(strong = 300, weak = 0, slot = 16)
assertEquals(Triple(300, 0, 5000L), second.played.last())
assertFalse(shadowOf(vibrator).isVibrating)
}
private fun effect(strong: Int = 65535, weak: Int = 65535) {
@After fun clearActiveController() {
for (slot in 0 until PadBridge.SLOTS) activeController(PadBridge.NO_CONTROLLER, slot)
}
private fun activeController(id: Int, slot: Int = 0) {
PadBridge::class.java.getDeclaredField("activeControllerId").apply { isAccessible = true }.setInt(null, id)
val slots = PadBridge::class.java.getDeclaredField("slotControllers").apply { isAccessible = true }.get(null)
(slots as java.util.concurrent.atomic.AtomicIntegerArray).set(slot, id)
}
private fun effect(strong: Int = 65535, weak: Int = 65535, slot: Int = 0) {
// Inject a decoded force-feedback packet; transport is outside these tests.
RumbleComponent::class.java.getDeclaredMethod("buzz", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType)
.apply { isAccessible = true }.invoke(rumble, strong, weak, 5000)
val int = Int::class.javaPrimitiveType
RumbleComponent::class.java.getDeclaredMethod("buzz", int, int, int, int)
.apply { isAccessible = true }.invoke(rumble, strong, weak, 5000, slot)
}
private class FakeMotors : RumbleComponent.Motors {
@@ -135,7 +160,10 @@ class RumbleComponentTest {
override fun cancel() { vibrating = false }
}
private companion object { const val PAD_ID = 7 }
private companion object {
const val PAD_ID = 7
const val SECOND_PAD_ID = 8
}
@Implements(LocalServerSocket::class)
class NoPacketsSocket {
+128
View File
@@ -0,0 +1,128 @@
# Steam's touch controls
Drawer → Controller → **On-screen controls: Steam touch (beta)** replaces DroidDeck's own on-screen pad, in a
Steam session, with Steam's touch controller: the one the Steam Link apps use. Bindings, action
sets, per-game configs, Valve's touch templates, official and community touch configs and Steam
Cloud sync are all Steam's; DroidDeck draws the controls and turns touches into the controller's
input. The research behind this (how Steam Link's touch controller works, the experiments on the
Thor) is in `steam-touch-controller.md` on the `research/steam-touch-controller` branch.
## How it fits together
```
SteamTouchControls (overlay) → SteamTouchDevice (shared file) → libfakeinput /dev/hidraw17
↑ layout ↑ app / action set ↓ 0000:11fb
SteamTouchConfig (Steam's configs) ←─── output reports ←── Steam client: Mobile Touch controller
↓ Steam Input
virtual pad → game
```
- **The device.** Steam builds its touch controller (Steam Input's Mobile Touch type) for any HID
device with the ids 0000:11fb. libfakeinput offers one to the client as `/dev/hidraw17` beside
the Deck pad, with its sysfs written by `SteamDeckPad.prepare(touch = true)`. It needs a Generic
Desktop / Game Pad report descriptor: for a non-Valve vendor id the client ignores a vendor-page
device. Offered only with the Deck pad (Steam controller set to Deck);
`Download/droiddeck-no-steam-touch` turns it off.
- **The shared file** (`touchctl` in the session's pad directory, `FAKE_TOUCHCTL_RING`;
`SteamTouchDevice.kt` / `TouchRingFile` in `fakeinput_steam.cpp`). The app writes the 40-byte input
report under a seqlock, whether the device is plugged in, and the battery; libfakeinput writes
back what the client tells the device.
- **The report** (Steam Link's `CVirtualController`): bytes 0-7 button bits (the Deck's low word for
face buttons, bumpers, triggers, d-pad, Steam, menu buttons; 22/26 stick clicks; 46/47 sticks
touched; 27/19/20 trackpads touched; 32-39 macros; 48/49 one- and two-finger macros), 8-15
sticks (int16, Y up), 16-27 three trackpads, 28-33 accelerometer, 34-39 gyro (from PadMotion,
in the Deck's units).
- **Output reports from the client:** 4 = app id and action set (decides which layout is shown),
5/6 = action set layer added/removed, 1 = rumble (also sent to the app's vibration like any
pad's), 3 = a setting. Feature report 2 is the battery.
- **Plugging.** The device is plugged in only while Steam's controls are shown: always in a Steam
session with that choice, since a handheld's built-in pad would keep Auto off for good. Unplugging
ends the device's report stream (the client lets the controller go); either way libfakeinput
sends a udev event through `udevmon.c`'s stand-in monitor (`bl_udevmon_inject`), since the
sandbox gives the client no kernel uevents and its HID discovery would never look again.
## Layouts
`SteamTouchConfig.load` finds the game's touch config: the app's own saved config when the
configset (`configset_controller_mobile_touch.vdf`) points at one, otherwise the file the client's
console log says it loaded for the touch controller (a template, an official or a workshop config).
`touch_layout` in it is a hex `CVirtualControllerLayouts` protobuf: per action set, each control's
type, visibility, centre (fractions of the screen) and scale. Controls with no layout go where
Steam Link puts them by default; only controls the config's active sources bind are shown.
**Editing** (drawer → Steam touch layout → Edit): drag, pinch or −/+ to resize, Hide, Reset,
Cancel, Save. Save writes `touch_layout` into the game's autosave config
(`<account>/config/<appid>/controller_mobile_touch.vdf`, made from the config in use) and points the
configset at it, as the client does when Steam Link saves a layout; the device is then replugged so
the client reads it again. Steam Cloud uploads the file like any autosaved config (seen in
`userdata/<account>/241100/remotecache.vdf`).
## Verified on the Thor
- Big Picture: Steam shows "Controller Connected - Remote Play Touch Controller"; the Steam button
opens the menu, the d-pad moves focus.
- A game (Geometry Wars: Retro Evolved): Valve's Gamepad touch template loaded, the stick flies
the ship.
- A saved layout survives a session restart and was uploaded to Steam Cloud.
- Touch menus: a touch menu bound to a trackpad is drawn by Steam's own overlay when the trackpad
is held.
- Unplug and replug mid-session; two touch devices at once (research branch).
## Open
- Gyro: motion reaches the device's report, but a `gyro_to_joystick` binding did not move the stick
in the one test made (injected rates, sensors paused). Needs a look at what the client wants
(calibration, an enable setting).
- Custom touch icons (`SetTouchIconData` on Steam Link) are not drawn; controls use text labels.
- Not tried on a retail phone with SELinux enforcing (the Deck pad's sysfs works there).
## Icons, labels and colours
Each control shows what its binding carries, as Steam Link does: a binding is
`<action>, <label>, <icon>, <#foreground #background>` (e.g.
`xinput_button A, Attack, ghost_010_wpn_0070.png, #FFFFFF #AD0000`). Icons are looked up as Steam does:
the game's own `TouchMenuIcons` folder, then `tenfoot/resource/images/library/controller/binding_icons`
(459 icons). They are drawn as Steam draws binding icons: the icon multiplied by the foreground colour
on a disc of the background colour. A binding with no icon shows its label, else a glyph for what it
presses (LB, Esc, ⏎…), else the button's own name.
In the layout editor, **Icon** opens a picker for the selected control (a D-pad direction: the one
tapped): label, an icon from the game's own and Steam's library, foreground and background from Steam's
binding-icon palette. **Colour** sets the layout's colour and opacity. Save rewrites only those binding
strings in the game's touch config (byte for byte otherwise) along with `touch_layout`. Verified with
Castle Crashers (its config ships icons on the shoulders and stick click): icons drawn; A set to a knife,
white on red, "Attack" and B to another icon; both written in Steam's format and loaded back after a
session restart.
The pickers are panels in the session's window, not dialogs.
## Steam Link's menu and editor
- **Menu button (…)**: touch input Controller / Mouse / Controller + mouse; mouse as direct touch or
trackpad; trackpad speed. Like Steam Link these are per game, saved in the game's `touch_layout`
(`CVirtualControllerLayouts` fields 2-4), so they sync with the config. In Controller mode touches
outside the controls do nothing; in Mouse mode only the menu button is drawn and the screen is the
mouse. Also: vibrate on touch, fade when not touched, gyroscope (Steam Link's global options, kept
per device), the layout editor and DroidDeck's own menu.
- **Editor**: ◀ ▶ steps through every action set of the game's config (each saved to its own layout),
Add (the tray: bound controls not on screen, and the optional Paste button), More (colour and
opacity, copy a layout, paste it into another game, reset), W−/W+/H−/H+ and axis-wise pinch for a
control's width and height (ovals).
- **Paste** control: Ctrl+V into the session (the Android clipboard is shared with it).
Not done: Steam Link's magnifying glass / pinch zoom, record and playback of input, shake to fade, and
its custom tray drag-in animation. Gyro: the report now carries motion only while the client asks
(setting report 0x30) at Steam Link's scale (1000 °/s full scale); a `gyro_to_joystick` binding still
did not move a game's stick with injected rates, so gyro remains unconfirmed.
## DroidDeck's look
With feature parity reached, the overlay keeps Steam Link's controls (outlined, white, Steam's face
colours) but is DroidDeck's own: the guide button carries the split-D mark (artwork/droiddeck-mark.svg)
instead of Steam's logo, and the menu, tray and pickers use the app's palette (ui/Theme.kt: surfaces
#121417 / #1A1D22, hairlines #262A31, text #F2F4F7 / #9AA3AF, accent #1A9FFF), rounded corners and
switches rather than Steam Link's navy panels and blue gradient tiles.
**Shake to hide**: a toggle in the menu (Steam Link's shake_fade). Three jolts above 2.3 g within
about a second hide the controls (touches then reach the game as with the controls off); shaking
again brings them back.
+6
View File
@@ -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;
+112
View File
@@ -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 <string.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <endian.h>
#include <stdio.h>
#include <unistd.h>
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;
}