Compare commits

...
5 changed files with 897 additions and 5 deletions

No files matched your search

+256 -5
View File
@@ -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) {
+402
View File
@@ -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.
+5
View File
@@ -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;
+184
View File
@@ -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;
}