mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45e71a14a6 | ||
|
|
b0cc2ce5c3 | ||
|
|
c24e32f0d7 | ||
|
|
5952753e23 | ||
|
|
d3a3015f36 | ||
|
|
8817d6e1f1 | ||
|
|
6a08b46c6c | ||
|
|
6e2a235e1b | ||
|
|
341bb13297 |
No files matched your search
@@ -1216,8 +1216,70 @@ struct DeckHidraw {
|
||||
FakeInputRingHeader *ring = nullptr;
|
||||
size_t mapping_size = 0;
|
||||
uint8_t pending_feature = 0;
|
||||
bool touch = false; // the Mobile Touch experiment below, not the Deck
|
||||
int touch_index = 0;
|
||||
};
|
||||
|
||||
// ---- EXPERIMENT: Steam's Mobile Touch controller as a local device ----
|
||||
//
|
||||
// docs/development/steam-touch-controller.md. Steam Link hands the host its touch controls as a
|
||||
// remote HID device, 0000:11fb ("Mobile Touch Control"), which Steam's controller code classes as
|
||||
// k_eControllerType_MobileTouch by its ids alone. FAKE_EVDEV_TOUCHCTL=1 offers the client the same
|
||||
// ids as a local hidraw node, to see whether it builds the touch controller with no stream. The
|
||||
// 40-byte input report is read from TOUCHCTL_REPORT_FILE (written by hand while experimenting); every
|
||||
// output and feature report the client sends is logged and appended to TOUCHCTL_OUT_FILE.
|
||||
// FAKE_EVDEV_TOUCHCTL=<n> offers n of them: /dev/hidraw17, 18, ... with serials MT-DROIDDECK0001, 0002,
|
||||
// ..., each reading its own report file (touchctl.report, touchctl1.report, ...).
|
||||
static constexpr unsigned int TOUCH_HIDRAW_MINOR = 17;
|
||||
static constexpr int TOUCH_MAX = 4;
|
||||
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 std::string touch_serial(int index) {
|
||||
char serial[32];
|
||||
snprintf(serial, sizeof(serial), "MT-DROIDDECK%04d", index + 1);
|
||||
return serial;
|
||||
}
|
||||
static std::string touch_report_file(int index) {
|
||||
return index == 0 ? std::string("/tmp/touchctl.report") : "/tmp/touchctl" + std::to_string(index) + ".report";
|
||||
}
|
||||
static constexpr const char *TOUCHCTL_OUT_FILE = "/tmp/touchctl.out";
|
||||
// While this file exists the device is unplugged: the open stream ends and new opens fail.
|
||||
static constexpr const char *TOUCHCTL_OFF_FILE = "/tmp/touchctl.off";
|
||||
// Generic Desktop / Game Pad: one 40-byte input report, one 64-byte output report, one 64-byte feature report.
|
||||
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};
|
||||
|
||||
static int touchctl_count() {
|
||||
static int count = -1;
|
||||
if (count < 0) count = getenv("FAKE_EVDEV_TOUCHCTL") ? std::max(0, std::min(TOUCH_MAX, atoi(getenv("FAKE_EVDEV_TOUCHCTL")))) : 0;
|
||||
return count;
|
||||
}
|
||||
static bool fake_touchctl_enabled() { return touchctl_count() > 0; }
|
||||
|
||||
__attribute__((visibility("hidden"))) static void touchctl_note(const char *what, const uint8_t *data, size_t size) {
|
||||
static std::atomic<unsigned> count{0};
|
||||
unsigned n = ++count;
|
||||
char hex[3 * 72 + 1] = {};
|
||||
size_t shown = std::min<size_t>(size, 72);
|
||||
for (size_t i = 0; i < shown; i++) snprintf(hex + 3 * i, 4, "%02x ", data[i]);
|
||||
if (n <= 200 || (n & (n - 1)) == 0) Logger::log("touchctl: %s (%zu bytes): %s\n", what, size, hex);
|
||||
static auto my_open = reinterpret_cast<int (*)(const char *, int, ...)>(dlsym(RTLD_NEXT, "open"));
|
||||
int fd = my_open(TOUCHCTL_OUT_FILE, O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0644);
|
||||
if (fd >= 0) {
|
||||
char line[3 * 72 + 64];
|
||||
struct timespec now;
|
||||
clock_gettime(CLOCK_REALTIME, &now);
|
||||
int length = snprintf(line, sizeof(line), "%ld.%03ld %s %s\n", static_cast<long>(now.tv_sec),
|
||||
now.tv_nsec / 1000000, what, hex);
|
||||
syscall(SYS_write, fd, line, length);
|
||||
syscall(SYS_close, fd);
|
||||
}
|
||||
}
|
||||
|
||||
// What the Deck has and an Xbox pad does not, after the ring's events: motion (PadMotion) and the
|
||||
// back grips and trackpads (the second screen's Deck controls, DeckControls), already in the
|
||||
// Deck's axes and units, under a seqlock (FakeInputWriter.writeMotion / writeDeckControls).
|
||||
@@ -1307,7 +1369,8 @@ __attribute__((visibility("hidden"))) static bool process_is_steam_client() {
|
||||
__attribute__((constructor)) static void log_client_start() {
|
||||
config();
|
||||
if (process_is_steam_client())
|
||||
Logger::log("loaded in the Steam client: deck %s, ring slot 0 %s\n", fake_deck_enabled() ? "on" : "off",
|
||||
Logger::log("loaded in the Steam client: deck %s, touchctl %s, ring slot 0 %s\n", fake_deck_enabled() ? "on" : "off",
|
||||
fake_touchctl_enabled() ? "on" : "off",
|
||||
get_ring_path_for_slot(0).empty() ? "(none configured)" : get_ring_path_for_slot(0).c_str());
|
||||
}
|
||||
|
||||
@@ -1483,6 +1546,172 @@ __attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags) {
|
||||
return pair[0];
|
||||
}
|
||||
|
||||
__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;
|
||||
static auto my_open = reinterpret_cast<int (*)(const char *, int, ...)>(dlsym(RTLD_NEXT, "open"));
|
||||
uint8_t last[TOUCH_REPORT_BYTES] = {};
|
||||
for (;;) {
|
||||
if (access(TOUCHCTL_OFF_FILE, F_OK) == 0) {
|
||||
Logger::log("touchctl: unplugged\n");
|
||||
break;
|
||||
}
|
||||
uint8_t report[TOUCH_REPORT_BYTES] = {};
|
||||
int fd = my_open(touch_report_file(self->touch_index).c_str(), O_RDONLY | O_CLOEXEC);
|
||||
if (fd >= 0) {
|
||||
syscall(SYS_read, fd, report, sizeof(report));
|
||||
syscall(SYS_close, fd);
|
||||
}
|
||||
if (memcmp(report, last, sizeof(report))) {
|
||||
touchctl_note(self->touch_index ? "input#1+" : "input", report, sizeof(report));
|
||||
memcpy(last, report, sizeof(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);
|
||||
}
|
||||
Logger::log("touchctl: reader closed\n");
|
||||
syscall(SYS_close, self->peer);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Which of the experiment's sysfs files the client reads, each once: where its scan stops.
|
||||
__attribute__((visibility("hidden"))) static void touchctl_trace(int dirfd, const char *pathname) {
|
||||
if (!fake_touchctl_enabled() || !pathname || !process_is_steam_client()) return;
|
||||
std::string path;
|
||||
if (pathname[0] == '/' || dirfd == AT_FDCWD) {
|
||||
path = pathname;
|
||||
} else {
|
||||
char link[64], dir[PATH_MAX];
|
||||
snprintf(link, sizeof(link), "/proc/self/fd/%d", dirfd);
|
||||
ssize_t length = readlink(link, dir, sizeof(dir) - 1);
|
||||
if (length <= 0) return;
|
||||
path.assign(dir, length);
|
||||
path += '/';
|
||||
path += pathname;
|
||||
}
|
||||
if (path.find("usb2") == std::string::npos && path.find("usb3") == std::string::npos && path.find("hidraw17") == std::string::npos &&
|
||||
path.find("11FB") == std::string::npos && path.find("240:17") == std::string::npos)
|
||||
return;
|
||||
static std::mutex mutex;
|
||||
static auto *seen = new std::vector<std::string>();
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(mutex);
|
||||
if (seen->size() >= 128 || std::find(seen->begin(), seen->end(), path) != seen->end()) return;
|
||||
seen->push_back(path);
|
||||
}
|
||||
Logger::log("touchctl: client read %s\n", path.c_str());
|
||||
}
|
||||
|
||||
// The index of the experiment's node at pathname, or -1.
|
||||
__attribute__((visibility("hidden"))) static int touch_hidraw_index(const char *pathname) {
|
||||
if (!pathname || !fake_touchctl_enabled() || strncmp(pathname, "/dev/hidraw", 11)) return -1;
|
||||
char *end = nullptr;
|
||||
long minor = strtol(pathname + 11, &end, 10);
|
||||
if (!end || *end || minor < TOUCH_HIDRAW_MINOR || minor >= TOUCH_HIDRAW_MINOR + touchctl_count()) return -1;
|
||||
return static_cast<int>(minor - TOUCH_HIDRAW_MINOR);
|
||||
}
|
||||
__attribute__((visibility("hidden"))) static bool is_touch_hidraw_path(const char *pathname) {
|
||||
return touch_hidraw_index(pathname) >= 0;
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static int open_touch_hidraw(int flags, int index) {
|
||||
if (access(TOUCHCTL_OFF_FILE, F_OK) == 0) {
|
||||
Logger::log("touchctl: open refused while unplugged\n");
|
||||
errno = ENOENT;
|
||||
return -1;
|
||||
}
|
||||
if (!process_is_steam_client()) {
|
||||
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;
|
||||
touch->touch_index = index;
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> guard(controller_mutex());
|
||||
deck_map()[pair[0]] = touch;
|
||||
}
|
||||
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]);
|
||||
errno = ENOMEM;
|
||||
return -1;
|
||||
}
|
||||
pthread_detach(thread);
|
||||
Logger::log("touchctl: /dev/hidraw%u opened as fd %d\n", TOUCH_HIDRAW_MINOR + index, 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:
|
||||
*static_cast<int *>(argp) = sizeof(kTouchReportDescriptor);
|
||||
return 0;
|
||||
case 0x02: {
|
||||
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: {
|
||||
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);
|
||||
case 0x05: return copy_ioctl_string(op, argp, ("usb-droiddeck-" + std::to_string(2 + touch.touch_index) + "/input0").c_str());
|
||||
case 0x08: return copy_ioctl_string(op, argp, touch_serial(touch.touch_index).c_str());
|
||||
case 0x06: // HIDIOCSFEATURE
|
||||
touchctl_note("set-feature", static_cast<uint8_t *>(argp), size);
|
||||
touch.pending_feature = size > 1 ? static_cast<uint8_t *>(argp)[1] : 0;
|
||||
return static_cast<int>(size);
|
||||
case 0x07: { // HIDIOCGFEATURE: report 2 is the battery in Steam Link's controller
|
||||
auto *buf = static_cast<uint8_t *>(argp);
|
||||
touchctl_note("get-feature", buf, std::min<size_t>(size, 8));
|
||||
uint8_t id = buf[0];
|
||||
memset(buf, 0, size);
|
||||
buf[0] = id;
|
||||
if (size > 2) {
|
||||
buf[1] = 2;
|
||||
buf[2] = 100;
|
||||
}
|
||||
return static_cast<int>(std::min<size_t>(size, 18));
|
||||
}
|
||||
default:
|
||||
Logger::log("touchctl: unhandled hidraw ioctl 0x%02x\n", _IOC_NR(op));
|
||||
errno = EINVAL;
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 +2052,8 @@ 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);
|
||||
touchctl_trace(AT_FDCWD, pathname);
|
||||
if (is_touch_hidraw_path(pathname)) return open_touch_hidraw(flags, touch_hidraw_index(pathname));
|
||||
if (is_withdrawn_pad_path(pathname)) {
|
||||
errno = ENOENT;
|
||||
return -1;
|
||||
@@ -1897,6 +2128,8 @@ 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);
|
||||
touchctl_trace(dirfd, pathname);
|
||||
if (is_touch_hidraw_path(pathname)) return open_touch_hidraw(flags, touch_hidraw_index(pathname));
|
||||
if (is_withdrawn_pad_path(pathname)) {
|
||||
errno = ENOENT;
|
||||
return -1;
|
||||
@@ -1957,6 +2190,12 @@ static int fake_stat_path(const char *pathname, S *statbuf, Real real) {
|
||||
errno = ENOENT;
|
||||
return -1;
|
||||
}
|
||||
if (is_touch_hidraw_path(pathname) && process_is_steam_client()) {
|
||||
memset(statbuf, 0, sizeof(*statbuf));
|
||||
statbuf->st_mode = S_IFCHR | 0666;
|
||||
statbuf->st_rdev = makedev(DECK_HIDRAW_MAJOR, TOUCH_HIDRAW_MINOR + touch_hidraw_index(pathname));
|
||||
return 0;
|
||||
}
|
||||
if (is_deck_hidraw_path(pathname)) {
|
||||
memset(statbuf, 0, sizeof(*statbuf));
|
||||
statbuf->st_mode = S_IFCHR | 0666;
|
||||
@@ -2008,7 +2247,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_map()[fd]->touch_index : DECK_HIDRAW_MINOR);
|
||||
return ret;
|
||||
}
|
||||
auto controller = controller_map().find(fd);
|
||||
@@ -2054,6 +2293,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) && process_is_steam_client()) return 0;
|
||||
if (is_withdrawn_pad_path(pathname) || is_deck_hidraw_path(pathname)) {
|
||||
errno = ENOENT;
|
||||
return -1;
|
||||
@@ -2090,6 +2330,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) && process_is_steam_client()) return 0;
|
||||
if (is_withdrawn_pad_path(pathname) || is_deck_hidraw_path(pathname)) {
|
||||
errno = ENOENT;
|
||||
return -1;
|
||||
@@ -2157,6 +2398,8 @@ EXPORT int inotify_add_watch(int fd, const char *pathname, uint32_t mask) {
|
||||
}
|
||||
|
||||
int ret = my_inotify_add_watch(fd, pathname, mask);
|
||||
if (fake_touchctl_enabled() && process_is_steam_client())
|
||||
Logger::log("touchctl: client watches %s (mask 0x%x) -> %d\n", pathname ? pathname : "(null)", mask, ret);
|
||||
if (fake_path)
|
||||
free(fake_path);
|
||||
return ret;
|
||||
@@ -2211,7 +2454,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 +2781,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) touchctl_note("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 +2809,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) touchctl_note("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);
|
||||
|
||||
@@ -44,6 +44,12 @@ object SteamDeckPad {
|
||||
private const val HIDRAW = "$HID/hidraw/$NODE"
|
||||
private const val SERIAL = "DROIDDECK0001"
|
||||
|
||||
// EXPERIMENT (docs/development/steam-touch-controller.md): Steam's Mobile Touch controller,
|
||||
// 0000:11fb, as a second local device. libfakeinput serves the node only with
|
||||
// FAKE_EVDEV_TOUCHCTL=1 (fakeinput_steam.cpp: TOUCH_HIDRAW_*).
|
||||
private const val TOUCH_MINOR = 17
|
||||
private const val TOUCH_DEVICES = 2
|
||||
|
||||
/** 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,
|
||||
@@ -51,6 +57,14 @@ object SteamDeckPad {
|
||||
0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0,
|
||||
).map { it.toByte() }.toByteArray()
|
||||
|
||||
/** Must match libfakeinput's kTouchReportDescriptor. */
|
||||
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()
|
||||
|
||||
/** Where [prepare] puts the stand-in listings; bound into the guest at the same path. */
|
||||
fun listingDir(sessionRoot: File) = File(sessionRoot, "sys/deck/listing")
|
||||
|
||||
@@ -107,6 +121,42 @@ 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")
|
||||
|
||||
// EXPERIMENT: Mobile Touch controllers beside the Deck, two of them (hidraw17, 18).
|
||||
for (index in 0 until TOUCH_DEVICES) {
|
||||
val minor = TOUCH_MINOR + index
|
||||
val node = "hidraw$minor"
|
||||
val bus = 2 + index
|
||||
val usb = "$GUEST_DEVICES/usb$bus"
|
||||
val iface = "$usb/$bus-1:1.0"
|
||||
val hid = "$iface/0003:0000:11FB.000${2 + index}"
|
||||
val hidraw = "$hid/hidraw/$node"
|
||||
val serial = "MT-DROIDDECK%04d".format(index + 1)
|
||||
write(usb, "uevent", "DEVTYPE=usb_device\nPRODUCT=0/11fb/0\nTYPE=0/0/0\nBUSNUM=00$bus\nDEVNUM=002\n")
|
||||
write(usb, "idVendor", "0000\n")
|
||||
write(usb, "idProduct", "11fb\n")
|
||||
write(usb, "bcdDevice", "0000\n")
|
||||
write(usb, "manufacturer", "Valve Software\n")
|
||||
write(usb, "product", "Mobile Touch Control\n")
|
||||
write(usb, "serial", "$serial\n")
|
||||
link(File(dir(usb), "subsystem"), "/sys/bus/usb")
|
||||
write(iface, "uevent", "DEVTYPE=usb_interface\nPRODUCT=0/11fb/0\nINTERFACE=3/0/0\n")
|
||||
write(iface, "bInterfaceNumber", "00\n")
|
||||
write(iface, "bInterfaceClass", "03\n")
|
||||
link(File(dir(iface), "subsystem"), "/sys/bus/usb")
|
||||
write(hid, "uevent", "DRIVER=hid-generic\nHID_ID=0003:00000000:000011FB\n" +
|
||||
"HID_NAME=Mobile Touch Control\nHID_PHYS=usb-droiddeck-$bus/input0\n" +
|
||||
"HID_UNIQ=$serial\nMODALIAS=hid:b0003g0001v00000000p000011FB\n")
|
||||
File(dir(hid), "report_descriptor").writeBytes(TOUCH_REPORT_DESCRIPTOR)
|
||||
link(File(dir(hid), "subsystem"), "/sys/bus/hid")
|
||||
write(hidraw, "uevent", "MAJOR=$MAJOR\nMINOR=$minor\nDEVNAME=$node\n")
|
||||
write(hidraw, "dev", "$MAJOR:$minor\n")
|
||||
link(File(dir(hidraw), "subsystem"), "/sys/class/hidraw")
|
||||
link(File(dir(hidraw), "device"), hid)
|
||||
link(File(hidrawClass, node), hidraw)
|
||||
link(File(context.cacheDir, "drm/sys/$MAJOR:$minor"), hidraw)
|
||||
File(udevData, "c$MAJOR:$minor").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
# Steam's touch controller in a DroidDeck session
|
||||
|
||||
Research notes, started 2026-10-07. The question: instead of DroidDeck drawing its own on-screen
|
||||
pad and keeping its own profiles, can the session use the touch controller Steam already has for
|
||||
the Steam Link app, so that touch layouts are per game, synced to the Steam account and shared
|
||||
through Steam's community configs?
|
||||
|
||||
This page records what the Steam client contains, the candidate approaches, and the experiments
|
||||
run on the Thor to decide between them; details are in the [Experiment log](#experiment-log).
|
||||
|
||||
## Conclusion
|
||||
|
||||
**Yes, and without streaming anything.** Steam's touch controller is a Steam Input controller type
|
||||
(Mobile Touch, `controller_mobile_touch`) that Steam builds for any HID device with the ids
|
||||
0000:11fb. Steam Link only supplies that device remotely; libfakeinput can supply it locally, the
|
||||
way it already supplies the Deck pad. Shown on the Thor with an experimental build:
|
||||
|
||||
- Steam opens a local `/dev/hidraw17` 0000:11fb, builds the touch controller (type 43, the same
|
||||
attributes as Steam Link's), loads the game's touch config for it (Valve's touch templates,
|
||||
official per-game touch configs, the user's own synced ones, community ones), and games get
|
||||
its input through Steam Input's virtual pad. A touch-controller stick report flew the ship in
|
||||
Geometry Wars.
|
||||
- Steam tells the device which app and action set are active (output report 4) and when action
|
||||
set layers come and go (5, 6), live, plus rumble (1). That is what decides which layout to show.
|
||||
- Big Picture treats it as a touch controller: the in-game menu shows the touch layout preview,
|
||||
bindings are edited in Steam's own configurator.
|
||||
- It can be plugged and unplugged mid-session (injected udev events), so it can step aside when
|
||||
a physical pad is used, as Steam Link does.
|
||||
- The layout is part of the config file (`touch_layout`, a `CVirtualControllerLayouts` protobuf),
|
||||
so it syncs and is shared with the config. Steam loads a touch config written to disk.
|
||||
|
||||
**Recommended: approach D below.** DroidDeck draws the controls (from Steam's layout) and sends
|
||||
the device's 40-byte report; Steam does everything else. It needs no stream, no pairing, no video
|
||||
and no patching of Steam, and the parts it depends on are the ones Valve has to keep stable for
|
||||
the Steam Link apps already installed on phones and TVs: the device's ids and report format.
|
||||
|
||||
Not settled yet: saving an edited layout goes through a file write rather than a Steam call (Steam
|
||||
loads the written file and queues it for Steam Cloud like its own autosaves); the overlay must
|
||||
reproduce Steam Link's default layout and its hiding of unbound controls; touch menus and gyro
|
||||
were not exercised.
|
||||
|
||||
## How Steam Link's touch controller works
|
||||
|
||||
Sources: the arm64 client DroidDeck installs (`steam_client_steamdeck_publicbeta_linuxarm64`,
|
||||
manifest version 1791249696), the protobuf definitions in
|
||||
[SteamDatabase/Protobufs](https://github.com/SteamDatabase/Protobufs)
|
||||
(`steam/steammessages_remoteplay.proto`, `steam/steammessages_virtualcontroller.proto`) and Valve's
|
||||
[touch controller guide](https://steamcommunity.com/app/353380/discussions/4/1735462352489233412).
|
||||
|
||||
The work is split between the streaming client (what runs on the phone) and the Steam client being
|
||||
streamed from (the host):
|
||||
|
||||
- **Client: drawing and editing.** `streaming_client` holds `CVirtualController`, built from
|
||||
`streaming/client/virtualcontroller.cpp`: it draws the controls over the video, runs the layout
|
||||
editor (tray, colour picker, move/scale), and sends raw touches to the host as
|
||||
`CInputTouchFingerDownMsg` / `Motion` / `Up` (finger id, x and y normalised to the video).
|
||||
- **Host: the controller.** `steamui.so` holds the stream server (`CStreamServer`,
|
||||
`IStreamServerInputDelegate`). `steamclient.so` holds `CMobileTouchControllerAbstraction`, a
|
||||
Steam Input controller backend next to `CSteamOSHandheldAbstraction` (the Deck),
|
||||
`CSteamControllerAbstraction` and `CGenericGamepadControllerAbstraction`. Its display name is
|
||||
"Steam Link Touch Controller" and its config type is `controller_mobile_touch`.
|
||||
- **Host: the config.** The host owns the touch config and pushes it to the client:
|
||||
`k_EStreamControlTouchConfigActive` (appid, revision, creator),
|
||||
`GetTouchConfigData`/`SetTouchConfigData` (config data and layout blobs),
|
||||
`SaveTouchConfigLayout` (the client sends back an edited layout), `Get`/`SetTouchIconData`,
|
||||
`TouchActionSetActive` and `TouchActionSetLayerAdded`/`Removed`. Touch configs are Steam Input
|
||||
configs like any other: per game, saved to the account, publishable. SteamInputDB counted
|
||||
10,540 community configs for Mobile Touch in March 2026.
|
||||
|
||||
Every piece of this is in the arm64 package DroidDeck already installs: the host side in
|
||||
`steamrtarm64/steamclient.so` and `steamui.so`, and the full renderer and editor in
|
||||
`steamrtarm64/streaming_client`. The package also carries an `androidarm64/libsteamclient.so`
|
||||
with the same touch strings.
|
||||
|
||||
## Where DroidDeck is today
|
||||
|
||||
The session's pad is presented to the Steam client as a Steam Deck controller (28de:1205 through a
|
||||
served `hidraw` node and a written sysfs, see `SteamDeckPad.kt` and `fakeinput_steam.cpp`), so Deck
|
||||
configs, per-game bindings and Deck community configs already apply. What Steam's touch controller
|
||||
would add: control placement, icons, touch menus and on-device layout editing defined by Steam, and
|
||||
the touch-specific community configs.
|
||||
|
||||
## Candidate approaches
|
||||
|
||||
| | How | Verdict |
|
||||
|---|---|---|
|
||||
| **A. Local Remote Play** | DroidDeck streams from its own Steam client the way the Steam Link app does (pairing, start stream, touch input negotiated). | **Works, not worth it.** The official Steam Link app on the Thor pairs with the session's Steam over loopback and its touch controls drive the session (experiment 2). But the host captures and software-encodes video (libx264, 72-85 % CPU, frames froze), the Steam Link app cannot turn video off, and an input-only client of our own means reimplementing the Remote Play transport and crypto. The protocol does have `enable_video_streaming` in the streaming request. Kept as a fallback only. |
|
||||
| **B. In-process injection** | Feed touches into the path between the stream server (`steamui.so`) and `CMobileTouchControllerAbstraction` (`steamclient.so`). | **Not needed.** That path is a HID device; D supplies the device instead of patching Steam. |
|
||||
| **C. Data only** | Read Steam's touch configs and layouts, draw them ourselves, keep feeding the Deck pad. | **Rejected.** Touch configs bind straight to keys, mouse and actions; honouring them would mean reimplementing Steam Input. (D still reads the layout from the config, but Steam does the bindings.) |
|
||||
| **D. Local Mobile Touch device** | libfakeinput serves 0000:11fb as a local hidraw node; DroidDeck draws Steam's layout and writes the 40-byte report. | **Works (experiments 3, 4). Recommended.** |
|
||||
|
||||
## Experiments
|
||||
|
||||
1. **Host side alive?** Yes: discovery answers on loopback and Steam Link pairs.
|
||||
2. **Touch controller over a stream?** Yes, end to end, with Steam Link on the same device.
|
||||
3. **Loopback cost.** Too high: software video encode (part of 2).
|
||||
4. **Input without video.** Not run: needs our own Remote Play client; superseded by D.
|
||||
5. **Valve's renderer as an overlay.** Not run: `streaming_client` draws over decoded video and
|
||||
lives behind the stream; superseded by D.
|
||||
6. **Injection points.** The touch controller enters Steam as a HID device (0000:11fb), which is
|
||||
approach D.
|
||||
7. **Local 0000:11fb device.** Works: controller built, config loaded, games driven (section 3).
|
||||
8. **Hotplug.** Works with injected udev events (section 4).
|
||||
9. **Layout written to disk.** Steam loads it (section 5).
|
||||
|
||||
## Experiment log
|
||||
|
||||
### 1. Host side alive: yes (2026-10-07, Thor, Steam 1791249696)
|
||||
|
||||
- With a Steam session READY, the client listens on TCP 27036 and UDP 27036 (`/proc/net`, app
|
||||
uid). A discovery probe built from `steammessages_remoteclient_discovery.proto` sent to
|
||||
`127.0.0.1:27036` from the device gets a `CMsgRemoteClientBroadcastStatus` back: hostname
|
||||
`DroidDeck`, `enabled_services` 98 (game streaming on), the signed-in user, `steam_deck`
|
||||
unset, `gaming_device_type` 541.
|
||||
- The official Steam Link app (Play Store, on the Thor's bottom screen while the session runs on
|
||||
the top one) finds "DroidDeck" by itself, shows a PIN, and the session's Big Picture shows
|
||||
**Authorize Device**. After the PIN the app runs its network test and lists DroidDeck as
|
||||
paired. Pairing to the session's own client over loopback works with no changes.
|
||||
- Launching Steam Link on display 0 instead puts DroidDeck's session activity into PiP; use
|
||||
`am start --display 4`. On first start Steam Link sits on its splash until BLUETOOTH_CONNECT
|
||||
is answered, and the permission dialog does not render on the second screen (granted with
|
||||
`pm grant`).
|
||||
|
||||
### 2. Touch controller over the stream: works end to end
|
||||
|
||||
Starting a stream (`streaming_log.txt`, `console_log.txt` in the session's Steam `logs/`):
|
||||
|
||||
```
|
||||
CLIENT: Sending HID device 2020/0112/-1 Xbox Wireless Controller at sdl://3
|
||||
CLIENT: Sending HID device 0000/11fb/-1 Mobile Touch Control at touch://0
|
||||
...
|
||||
Remote Device Found type: 0000 11fb path: touch://0 Product: Mobile Touch Control
|
||||
!! Steam controller device opened for index 1.
|
||||
Controller device closed after hid_read failure
|
||||
Controller 1 disconnected
|
||||
```
|
||||
|
||||
- **The touch controller reaches the host as a remote HID device, 0000:11fb**, opened by the
|
||||
same controller code that opens the Deck pad, and turned into a virtual controller. The
|
||||
streaming client is what makes the reports; Steam on the host only sees a HID device.
|
||||
- Video fails on the host: `PipeWire: Could not connect PipeWire context` (the session runs no
|
||||
PipeWire), so the app gets audio but a black screen and keeps its loading spinner.
|
||||
- The app then re-sends only the Thor's built-in pad and drops the touch device; it seems to
|
||||
withdraw touch controls when a physical controller is present, or until video arrives.
|
||||
|
||||
With video fixed (below), **Steam's touch controller runs against the session's own client**:
|
||||
|
||||
- Steam Link withholds its touch controls while a physical pad is attached (the Thor's own pad
|
||||
shows up as "Xbox Wireless Controller"). A four-finger tap opens its stream menu, which has
|
||||
**Enable Touch Controls** (injected with `sendevent` on `/dev/input/event5`, the bottom panel).
|
||||
- The client then re-sends `touch://0`; the host opens it as controller type 43, product 0x11fb,
|
||||
serial `MT-<Steam Link device id>`, capabilities `0000007f83045bff`, creates a virtual controller
|
||||
for it, and sends the client `TouchConfigActive`, `TouchActionSetActive` and
|
||||
`SetTouchConfigData`. The app draws Big Picture's touch layout (d-pad, ABXY, Steam button,
|
||||
keyboard, `...`).
|
||||
- Touching the controls drives the session: the touch Steam button opens Big Picture's main
|
||||
menu on the Thor's top screen, touch B closes it, and the session's glyphs switch to the touch
|
||||
controller's. (D-pad taps from `input tap` did not register; not chased.)
|
||||
- Cost: the host encodes with libx264 in software (`/dev/video-enc0` missing), 4 threads,
|
||||
1240x698. CPU sat at 72-85 % in the overlay and `k_EStreamControlVideoOverflow` flooded the log;
|
||||
the app's picture froze within a minute while input kept working. **Video loopback is not a
|
||||
shippable path**; only the input half of the stream is useful.
|
||||
|
||||
Video fix used for this test only: PipeWire and WirePlumber are in the rootfs and gamescope is
|
||||
built with PipeWire, so a `gamescope` wrapper on `PATH` (via `Download/droiddeck-env`) that starts
|
||||
`pipewire` and `wireplumber` first gives gamescope a capture node (`stream available on node ID`)
|
||||
and the client a picture. (Editing `droiddeck-session` on the device does not stick: the app
|
||||
re-syncs the overlay each session.)
|
||||
|
||||
### Where Steam keeps touch configs
|
||||
|
||||
In the session's Steam, `steamapps/common/Steam Controller Configs/<account id>/config/`:
|
||||
|
||||
- `<appid>/controller_mobile_touch.vdf` for each game with a saved touch config (dozens already
|
||||
there for this account, synced down from Steam Cloud), plus named copies like
|
||||
`<appid>/default touch_0.vdf`;
|
||||
- `configset_controller_mobile_touch.vdf`: which config each app uses (`autosave` = the per-app
|
||||
file above, otherwise a template or workshop id);
|
||||
- `configset_MT-<serial>.vdf`, `preferences_MT-<serial>.vdf` and `config/MT-<serial>_gyro.vdf`:
|
||||
per touch device.
|
||||
|
||||
A touch config is an ordinary `controller_mappings` VDF (`controller_type` `controller_mobile_touch`,
|
||||
bindings per action set and layer) with one extra key, **`touch_layout`: the on-screen layout as
|
||||
a hex-encoded `CVirtualControllerLayouts` protobuf**: one layout per action set, each a list of
|
||||
elements (`EControllerElementType`, visible, x/y position normalised to the screen, x/y scale)
|
||||
and a colour, plus input mode, mouse mode and pinch-zoom settings. Bindings and layout travel
|
||||
together in the one file that syncs and is shared as a community config.
|
||||
|
||||
### The touch device's HID protocol
|
||||
|
||||
From Steam Link for Android (`libmain.so`, exported symbols; `CVirtualController` is both the
|
||||
renderer and the HID device behind `touch://0`):
|
||||
|
||||
- **Input report: 40 bytes** (`CVirtualController::Read`), kept at `this+200` and marked dirty
|
||||
at `this+240` by every setter. Bytes 0-7: button/touch bits (e.g. bit 27, 19, 20: trackpads
|
||||
0, 1, 2 touched). Bytes 16, 20, 24: trackpads 0-2 as int16 x (centred, `x*65535 ^ 0x8000`) and
|
||||
y (inverted). Remaining fields (sticks, triggers, gyro) not yet mapped.
|
||||
`CHIDDeviceReportGenerator::BInjectGamepadStateMobileTouch` is a stub; the report is the
|
||||
controller's own format, not the generic SDL gamepad state.
|
||||
- **Output reports from Steam** (`Write` → `QueueFeatureReport` → `HandleFeatureReports`),
|
||||
at least 17 bytes, first byte the type:
|
||||
- 1: rumble (u16 low, u16 high, u32 duration);
|
||||
- 3: setting `0x30` with a u16 value (a flag at `this+1488`, likely gyro);
|
||||
- **4: action set change** (u32 appid, u32 action set id): switches the layout shown;
|
||||
- **5 / 6: action set layer added / removed** (u32 appid, u32 layer id).
|
||||
- **Feature report 2**: battery (level, from `SDL_GetPowerInfo`), 17 bytes.
|
||||
|
||||
So Steam drives action sets, layers and rumble over HID. The only things that come solely over
|
||||
the stream are the config and layout blobs (`SetTouchConfigData`), the active config's
|
||||
appid/revision/creator (`TouchConfigActive`) and custom icons (`SetTouchIconData`); the first two
|
||||
are on disk in the VDF above.
|
||||
|
||||
### 3. Without a stream: Steam takes a local 0000:11fb device (works)
|
||||
|
||||
Experimental build on this branch: `FAKE_EVDEV_TOUCHCTL=1` (in `Download/droiddeck-env`) makes
|
||||
libfakeinput serve `/dev/hidraw17` as 0000:11fb "Mobile Touch Control" next to the Deck pad, with
|
||||
its sysfs written by `SteamDeckPad.prepare` (a second USB device, `usb2`). The 40-byte input report
|
||||
is read from `/tmp/touchctl.report` in the session; every output and feature report the client
|
||||
sends is logged to `pad.log` and appended to `/tmp/touchctl.out`.
|
||||
|
||||
- With a vendor-page report descriptor (`06 ff ff 09 01`, as the Deck's) the client walks the
|
||||
whole sysfs tree, reads the descriptor and never opens the node. **With Generic Desktop / Game
|
||||
Pad (`05 01 09 05`) it opens it**: for a non-Valve vendor id the client wants a gamepad usage.
|
||||
- Steam then builds the touch controller exactly as it does for Steam Link:
|
||||
|
||||
```
|
||||
Local Device Found type: 0000 11fb path: /dev/hidraw17 serial_number: MT-DROIDDECK0001
|
||||
!! Controller 0 attributes: Type: 43 ProductID: 4603 Serial: MT-MT-DROIDDECK0001
|
||||
Capabilities: 0000007f83045bff
|
||||
```
|
||||
|
||||
and speaks the touch protocol to it: `get-feature 02` (battery), output `03 30 ..` (the setting
|
||||
report), and **output `04 01 03 00 00 01 00 00 00`: action set 1 of app 769** (Big Picture).
|
||||
- Writing button bits into the report drives the client: Steam (`0x2000`) opens Big Picture's
|
||||
main menu, B (`0x20`) closes it. Opening the menu made Steam send `04` with app 443510 (the
|
||||
Steam menu's own touch config; it is one of the app folders in the config directory) and
|
||||
closing it `04` with 769 / 1 again: **the active app and action set arrive over HID, live.**
|
||||
|
||||
Button bits (Steam Link's `CVirtualController` table at `0x463e70`, indexed by
|
||||
`EControllerElementType - 1`), the same layout as the Deck report's low word:
|
||||
|
||||
| Element | Bit | | Element | Bit |
|
||||
|---|---|---|---|---|
|
||||
| A | 7 | | Select | 12 |
|
||||
| B | 5 | | Steam | 13 |
|
||||
| X | 6 | | Start | 14 |
|
||||
| Y | 4 | | Left stick click | 22 |
|
||||
| Left bumper | 3 | | Right stick click | 26 |
|
||||
| Right bumper | 2 | | Macro 0-7 | 32-39 |
|
||||
| Left trigger | 1 | | 1-finger / 2-finger macro | 48, 49 |
|
||||
| Right trigger | 0 | | Trackpad 0 / 1 / 2 touched | 27 / 19 / 20 |
|
||||
|
||||
Report layout, all little-endian (Steam Link `CVirtualController`: `TouchControl_JoyButton`,
|
||||
`TouchControl_JoyStick`, `SetTrackpad`, `UpdateControllerState`):
|
||||
|
||||
| Bytes | Field |
|
||||
|---|---|
|
||||
| 0-7 | u64 buttons and touch flags (table above; d-pad as the Deck's: up 8, right 9, left 10, down 11; left / right stick touched 46 / 47) |
|
||||
| 8-15 | int16 left X, left Y, right X, right Y: `x * 32767`, Y stored inverted (`~(y * 32767)`) |
|
||||
| 16-27 | three trackpads, int16 x (`x * 65535 ^ 0x8000`) and y (`0x7fff - y * 65535`) |
|
||||
| 28-33 | accelerometer xyz, int16, ±2 g (19.61 m/s²) to ±32767 |
|
||||
| 34-39 | gyro xyz, int16 |
|
||||
|
||||
**Games, through Steam Input, with no stream.** With the session started on Geometry Wars:
|
||||
Retro Evolved (8400) and the game launched by pressing A twice on the local device, the client
|
||||
loaded `controller_base/templates/controller_mobile_touch_gamepad_joystick.vdf` for app 8400 on the
|
||||
touch controller (Valve's stock touch template), told the device `04 d0 20 00 00 ...` (app 8400),
|
||||
and a left-stick report flew the ship across the screen. Steam's own path end to end:
|
||||
DroidDeck → `/dev/hidraw17` → Mobile Touch controller → the game's touch config → Steam Input's
|
||||
virtual Xbox pad → the game. D-pad bits navigated the Steam menu the same way.
|
||||
|
||||
What the stream gives that the local device does not:
|
||||
|
||||
- **The active config and its layout.** Steam Link asks for it (`GetTouchConfigData`) and the host
|
||||
answers with the config and the layout. Locally the device is told only the app id and action
|
||||
set (report 4), so the overlay has to resolve the config itself: `configset_controller_mobile_touch.vdf`
|
||||
per app (`autosave` = `<appid>/controller_mobile_touch.vdf`, otherwise a template or workshop
|
||||
config), and the console log line `Loaded Config for ... App ID <n>, Controller <i>: <path>`
|
||||
names the file Steam actually loaded. Stock templates carry no `touch_layout`; Steam Link draws
|
||||
its built-in default layout for them (`CVirtualController::ResetLayoutToDefault`), hiding
|
||||
elements the config leaves unbound (`UpdateElementAvailability`).
|
||||
- **Saving an edited layout.** Steam Link sends `SaveTouchConfigLayout`; the host writes it into
|
||||
the app's autosave config, which is what syncs. The local device has no such message. Big
|
||||
Picture's `SteamClient.Input` API (seen in `steamui/sp.js`) has the config editing calls
|
||||
(`StartEditingControllerConfigurationForAppIDAndControllerIndex`,
|
||||
`SetEditingControllerConfigurationMiscSetting`, `SaveEditingControllerConfiguration`,
|
||||
`GetConfigForAppAndController`, `QueryControllerConfigsForApp`, `SetSelectedConfigForApp`,
|
||||
`GetTouchMenuIconsForApp`, `RegisterForTouchMenuMessages`) but no layout call; whether
|
||||
`touch_layout` goes through the misc-setting call is untested. The session's devtools port only
|
||||
admits guest processes (`BL_CDP_GUARD`), so this needs the agent bridge's guest commands rather
|
||||
than a probe from adb.
|
||||
- **Custom icons** (`SetTouchIconData`).
|
||||
|
||||
Everything else (bindings, action sets, layers, touch menus, rumble, the configurator, community
|
||||
configs, cloud sync) is Steam's own and works with the local device.
|
||||
|
||||
### 4. Two controllers, and hotplug
|
||||
|
||||
With the touch device present beside the Deck pad, Steam creates a virtual pad for each
|
||||
(`Created virtual controller at slot 0 for controller 0` / `slot 1 for controller 1`): a game
|
||||
sees two players. Steam Link avoids this by withdrawing its touch controls while a physical pad is
|
||||
in use, and a local device has to do the same, which needs hotplug.
|
||||
|
||||
- **Unplug works as is.** Ending the device's report stream (`/tmp/touchctl.off` in the experiment)
|
||||
gives `Controller device closed after hid_read failure`, `Controller 0 disconnected`, and the
|
||||
virtual pad is destroyed. Steam re-enumerates at once, and an open refused then keeps it off.
|
||||
- **Plugging back in needs an event.** The client watches only `/dev/input` with inotify (already
|
||||
redirected by libfakeinput) and never rescans hidraw on its own; an inotify event in
|
||||
`/dev/input` did not trigger a HID rescan. Its HID discovery is SDL's, driven by a udev monitor,
|
||||
and the session's monitor is `udevmon.c`'s silent stand-in (the sandbox refuses netlink).
|
||||
- **Injected udev events work.** The experiment extends the stand-in: a file in `/tmp/udev-inject`
|
||||
(`ACTION`, `DEVPATH`, `SUBSYSTEM=hidraw`, `DEVNAME`, `MAJOR`, `MINOR`) is sent by every process
|
||||
with a stand-in as udevd's message format (`libudev` header, magic `0xfeedcafe`, properties plus
|
||||
`SEQNUM`), and the stand-in's `recvmsg` (through `ntsync.c`, which owns the symbol) reports the
|
||||
udev multicast group as the sender and root credentials, as libudev requires. An injected `add`
|
||||
for `hidraw17` made the client open the node again within a second and rebuild the touch
|
||||
controller (type 43) and its virtual pad, mid-session. (The sender address has to be written
|
||||
from the room `msg_namelen` had before the call; a unix socket sets it to 0.)
|
||||
|
||||
So DroidDeck can show and hide Steam's touch controller the way Steam Link does: plug the device
|
||||
in when the on-screen controls are shown, unplug it when a physical pad takes over.
|
||||
|
||||
### 5. Configs and layouts written by DroidDeck
|
||||
|
||||
With the session stopped, a `config/8400/controller_mobile_touch.vdf` was written (Valve's gamepad
|
||||
template plus a `touch_layout` taken from another config) and `"8400" { "autosave" "1" }` added to
|
||||
`configset_controller_mobile_touch.vdf`. Launching the game:
|
||||
|
||||
```
|
||||
Loaded Config for Local Selection Path for App ID 8400, Controller 0:
|
||||
.../Steam Controller Configs/392297941/config/8400/controller_mobile_touch.vdf
|
||||
```
|
||||
|
||||
Steam used it as the game's touch config and left the file (and its `touch_layout`) as written.
|
||||
**Steam Cloud picked it up too**: Steam Controller Configs sync as app 241100's cloud files, and
|
||||
`userdata/<account>/241100/remotecache.vdf` gained an entry for
|
||||
`392297941/config/8400/controller_mobile_touch.vdf` (`syncstate` 3, `remotetime` 0: queued for
|
||||
upload), the same bookkeeping as the account's other touch configs. Both files were put back
|
||||
afterwards.
|
||||
|
||||
Also seen: pressing A on the touch device logged `Seating controller 1 in slot 0`. Steam moves the
|
||||
controller in use to the first player slot, which softens the two-controller problem even before
|
||||
the device is unplugged.
|
||||
|
||||
### 6. Steam Link's default layout
|
||||
|
||||
Stock templates carry no `touch_layout`. Steam Link then places each control from a built-in table
|
||||
(`CVirtualController::BInitializeDefaultElement`, table at `0x463d48`, positions on a 1280x720
|
||||
reference):
|
||||
|
||||
| Element | Position (of 1280x720) |
|
||||
|---|---|
|
||||
| Thumb (menu) | 75, 75 |
|
||||
| Select / Steam / Start | 516, 75 / 636, 75 / 756, 75 |
|
||||
| Paste / Keyboard | 1115, 75 / 1205, 75 |
|
||||
| Magnifying glass / record / playback | 1205, 165 / 255 / 345 |
|
||||
| D-pad | 200, 525 |
|
||||
| X / Y / B / A | 1003, 527 / 1083, 447 / 1163, 527 / 1083, 607 |
|
||||
|
||||
Sticks, triggers, bumpers and macros are not in the table; they appear when the config binds them
|
||||
(`UpdateElementAvailability`), and the overlay has to reproduce that rule.
|
||||
|
||||
## What building D would take
|
||||
|
||||
- **libfakeinput:** the 0000:11fb node as in the experiment (Generic Desktop / Game Pad report
|
||||
descriptor, 40-byte input reports, output reports 1, 3-6 and feature report 2), fed from a
|
||||
ring the app writes, like the Deck pad's.
|
||||
- **SteamDeckPad.kt:** the second device's sysfs, as in the experiment.
|
||||
- **udevmon.c:** event injection for plug and unplug, with a proper channel from the app instead of
|
||||
the experiment's file drop.
|
||||
- **The overlay (Kotlin):** read the active config (configset per app, else the template Steam
|
||||
logged loading), decode `touch_layout` (or the default table above), show the elements of the
|
||||
action set Steam names in report 4 and the layers in 5/6, hit-test touches into the report
|
||||
(buttons, sticks, trackpads; motion from PadMotion into the gyro fields), rumble from report 1.
|
||||
Hide the unbound controls as Steam Link does.
|
||||
- **Editing:** move/scale in the overlay, saved by writing `touch_layout` into the app's autosave
|
||||
config, as Steam does when Steam Link saves a layout.
|
||||
- **Policy:** plug the device in while the on-screen controls are up and no physical pad is in use;
|
||||
unplug it otherwise.
|
||||
|
||||
The experimental pieces on this branch (`FAKE_EVDEV_TOUCHCTL`, `/tmp/touchctl.*`,
|
||||
`/tmp/udev-inject`) are research scaffolding to be replaced, not shipped.
|
||||
|
||||
### 7. Several controllers
|
||||
|
||||
`FAKE_EVDEV_TOUCHCTL=2` serves two touch devices (`/dev/hidraw17` and `18`, serials
|
||||
`MT-DROIDDECK0001` and `0002`, each with its own sysfs `usb2`/`usb3` and report file). With the
|
||||
Deck pad that is three controllers, and Steam takes all three:
|
||||
|
||||
```
|
||||
Controller 15 Type 4 (Deck pad, /dev/hidraw16)
|
||||
Controller 0 Type 43 Serial MT-MT-DROIDDECK0001
|
||||
Controller 1 Type 43 Serial MT-MT-DROIDDECK0002
|
||||
Created virtual controller at slot 0 / 1 / 2
|
||||
uinput: "Microsoft X-Box 360 pad 0" / "pad 1" / "pad 2" is /dev/input/event16 / 17 / 18
|
||||
```
|
||||
|
||||
Each gets its own Steam Input config and its own virtual pad, so a game sees three players; a
|
||||
Steam press on the second device alone opened the Steam menu (Steam answered it with report 4 for
|
||||
the menu's action set). Several touch controllers would allow, say, a second player on the Thor's
|
||||
bottom screen. The other physical pads DroidDeck passes through (ring slots 1-3 as evdev pads) sit
|
||||
beside these as ordinary gamepads; no external pad was connected for this test.
|
||||
@@ -1237,9 +1237,14 @@ 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");
|
||||
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;
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#include <dirent.h>
|
||||
#include <endian.h>
|
||||
#include <stdio.h>
|
||||
#include <time.h>
|
||||
|
||||
struct stand_in {
|
||||
int fd; /* the descriptor handed out, -1 when the slot is free */
|
||||
@@ -95,8 +99,11 @@ static int is_wine_process(void) {
|
||||
return known == 1;
|
||||
}
|
||||
|
||||
static void start_injector(void);
|
||||
|
||||
static int make_stand_in(int type) {
|
||||
int sv[2];
|
||||
start_injector();
|
||||
int saved = errno;
|
||||
int flags = SOCK_DGRAM | (type & (SOCK_CLOEXEC | SOCK_NONBLOCK));
|
||||
if (socketpair(AF_UNIX, flags, 0, sv) != 0) {
|
||||
@@ -189,3 +196,180 @@ int setsockopt(int fd, int level, int optname, const void *optval, socklen_t opt
|
||||
}
|
||||
return real_setsockopt(fd, level, optname, optval, optlen);
|
||||
}
|
||||
|
||||
/* EXPERIMENT (docs/development/steam-touch-controller.md): hotplug through the stand-in.
|
||||
*
|
||||
* A file dropped in INJECT_DIR is a udev event: KEY=VALUE lines (ACTION, DEVPATH, SUBSYSTEM,
|
||||
* DEVNAME, MAJOR, MINOR...). It is sent to every stand-in as udevd would send it to the "udev"
|
||||
* group, and recvmsg below dresses it as udevd's: the udev multicast group as the sender address
|
||||
* and root credentials, without which libudev drops a message. SDL's HID discovery counts an
|
||||
* add or remove and enumerates again, so a hidraw node can come and go while the client runs. */
|
||||
#define INJECT_DIR "/tmp/udev-inject"
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
static unsigned murmur_hash2(const char *key, size_t len) {
|
||||
/* systemd's string_hash32 (MurmurHash2, seed 0) */
|
||||
const unsigned m = 0x5bd1e995;
|
||||
const int r = 24;
|
||||
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 >> r; 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;
|
||||
}
|
||||
|
||||
static void inject_file(const char *path, unsigned long long seqnum) {
|
||||
char text[2048];
|
||||
FILE *f = fopen(path, "re");
|
||||
if (f == NULL) return;
|
||||
size_t n = fread(text, 1, sizeof(text) - 1, f);
|
||||
fclose(f);
|
||||
text[n] = '\0';
|
||||
char props[2300];
|
||||
size_t used = 0;
|
||||
char subsystem[64] = "";
|
||||
char *save = NULL;
|
||||
for (char *line = strtok_r(text, "\n", &save); line != NULL; line = strtok_r(NULL, "\n", &save)) {
|
||||
size_t len = strlen(line);
|
||||
if (len == 0 || used + len + 1 > sizeof(props) - 64) continue;
|
||||
if (strncmp(line, "SUBSYSTEM=", 10) == 0) snprintf(subsystem, sizeof(subsystem), "%s", line + 10);
|
||||
memcpy(props + used, line, len + 1);
|
||||
used += len + 1;
|
||||
}
|
||||
used += (size_t)snprintf(props + used, sizeof(props) - used, "SEQNUM=%llu", seqnum) + 1;
|
||||
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, 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) + used, MSG_DONTWAIT | MSG_NOSIGNAL) > 0) sent++;
|
||||
}
|
||||
pthread_mutex_unlock(&lock);
|
||||
FILE *done = fopen(INJECT_DIR "/.log", "ae");
|
||||
if (done) {
|
||||
char exe[PATH_MAX] = "";
|
||||
ssize_t len = readlink("/proc/self/exe", exe, sizeof(exe) - 1);
|
||||
if (len > 0) exe[len] = '\0';
|
||||
fprintf(done, "%s: pid %d (%s) sent to %d stand-in(s)\n", path, (int)getpid(), exe, sent);
|
||||
fclose(done);
|
||||
}
|
||||
}
|
||||
|
||||
static void *injector(void *arg) {
|
||||
(void)arg;
|
||||
unsigned long long seqnum = 900000;
|
||||
/* Every process with a stand-in sends each event file once (the client is one of several), so
|
||||
* files are never claimed; whoever sees one older than a few seconds removes it. */
|
||||
static char seen[32][128];
|
||||
size_t next_seen = 0;
|
||||
for (;;) {
|
||||
DIR *dir = opendir(INJECT_DIR);
|
||||
if (dir != NULL) {
|
||||
struct dirent *entry;
|
||||
while ((entry = readdir(dir)) != NULL) {
|
||||
if (entry->d_name[0] == '.') continue;
|
||||
char path[300];
|
||||
snprintf(path, sizeof(path), INJECT_DIR "/%s", entry->d_name);
|
||||
struct stat st;
|
||||
if (stat(path, &st) != 0) continue;
|
||||
if (time(NULL) - st.st_mtime > 5) {
|
||||
unlink(path);
|
||||
continue;
|
||||
}
|
||||
char key[128];
|
||||
snprintf(key, sizeof(key), "%s:%lu", entry->d_name, (unsigned long)st.st_ino);
|
||||
int done = 0;
|
||||
for (size_t i = 0; i < 32; i++)
|
||||
if (strcmp(seen[i], key) == 0) done = 1;
|
||||
if (done) continue;
|
||||
snprintf(seen[next_seen++ % 32], sizeof(seen[0]), "%s", key);
|
||||
inject_file(path, ++seqnum);
|
||||
}
|
||||
closedir(dir);
|
||||
}
|
||||
struct timespec interval = {0, 100 * 1000 * 1000};
|
||||
nanosleep(&interval, NULL);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void spawn_injector(void) {
|
||||
pthread_t thread;
|
||||
if (pthread_create(&thread, NULL, injector, NULL) == 0) pthread_detach(thread);
|
||||
}
|
||||
|
||||
static void start_injector(void) {
|
||||
static pthread_once_t once = PTHREAD_ONCE_INIT;
|
||||
pthread_once(&once, spawn_injector);
|
||||
}
|
||||
|
||||
/* For ntsync.c's recvmsg(), which owns the symbol: 1 when fd was a stand-in and was answered here
|
||||
* (*result holds what recvmsg returns), 0 when it is not one. */
|
||||
__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) {
|
||||
FILE *log = fopen(INJECT_DIR "/.recv", "ae");
|
||||
if (log) {
|
||||
fprintf(log, "pid %d fd %d got %zd bytes, name room %u, control room %zu\n", (int)getpid(), fd, n,
|
||||
(unsigned)name_room, control_room);
|
||||
fclose(log);
|
||||
}
|
||||
}
|
||||
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 multicast 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;
|
||||
}
|
||||
Reference in new issue
Block a user