feat: experimental local Mobile Touch controller (0000:11fb) behind FAKE_EVDEV_TOUCHCTL

This commit is contained in:
xXJsonDeruloXx committed 2026-10-07 20:10:03 -04:00
1 parent 6a08b46c6c
commit 8817d6e1f1
3 files changed
+308 -5

No files matched your search

+224 -5
View File
@@ -1216,8 +1216,58 @@ 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
};
// ---- 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.
static constexpr const char *TOUCH_HIDRAW_PATH = "/dev/hidraw17";
static constexpr unsigned int TOUCH_HIDRAW_MINOR = 17;
static constexpr int TOUCH_REPORT_BYTES = 40;
static constexpr int TOUCH_REPORT_INTERVAL_US = 8000;
static constexpr const char *TOUCH_NAME = "Mobile Touch Control";
static constexpr const char *TOUCH_SERIAL = "MT-DROIDDECK0001";
static constexpr const char *TOUCHCTL_REPORT_FILE = "/tmp/touchctl.report";
static constexpr const char *TOUCHCTL_OUT_FILE = "/tmp/touchctl.out";
// 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 bool fake_touchctl_enabled() {
static int enabled = -1;
if (enabled < 0) enabled = getenv("FAKE_EVDEV_TOUCHCTL") && atoi(getenv("FAKE_EVDEV_TOUCHCTL")) ? 1 : 0;
return enabled == 1;
}
__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 +1357,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 +1534,154 @@ __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 (;;) {
uint8_t report[TOUCH_REPORT_BYTES] = {};
int fd = my_open(TOUCHCTL_REPORT_FILE, 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("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("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());
}
__attribute__((visibility("hidden"))) static bool is_touch_hidraw_path(const char *pathname) {
return pathname && fake_touchctl_enabled() && !strcmp(pathname, TOUCH_HIDRAW_PATH);
}
__attribute__((visibility("hidden"))) static int open_touch_hidraw(int flags) {
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;
{
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: %s opened as fd %d\n", TOUCH_HIDRAW_PATH, pair[0]);
return pair[0];
}
static int copy_ioctl_string(ioctl_request_t op, void *argp, const char *value);
__attribute__((visibility("hidden"))) static int ioctl_touch(DeckHidraw &touch, ioctl_request_t op, void *argp) {
if (_IOC_TYPE(op) != 'H') {
errno = ENOTTY;
return -1;
}
size_t size = _IOC_SIZE(op);
switch (_IOC_NR(op)) {
case 0x01:
*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-2/input0");
case 0x08: return copy_ioctl_string(op, argp, TOUCH_SERIAL);
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 +2022,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);
if (is_withdrawn_pad_path(pathname)) {
errno = ENOENT;
return -1;
@@ -1897,6 +2098,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);
if (is_withdrawn_pad_path(pathname)) {
errno = ENOENT;
return -1;
@@ -1957,6 +2160,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);
return 0;
}
if (is_deck_hidraw_path(pathname)) {
memset(statbuf, 0, sizeof(*statbuf));
statbuf->st_mode = S_IFCHR | 0666;
@@ -2008,7 +2217,7 @@ static int fake_stat_fd(int fd, S *buf, Real real) {
std::lock_guard<std::recursive_mutex> guard(controller_mutex());
if (ret == 0 && deck_map().count(fd)) {
buf->st_mode = (buf->st_mode & ~S_IFMT) | S_IFCHR;
buf->st_rdev = makedev(DECK_HIDRAW_MAJOR, DECK_HIDRAW_MINOR);
buf->st_rdev = makedev(DECK_HIDRAW_MAJOR, deck_map()[fd]->touch ? TOUCH_HIDRAW_MINOR : DECK_HIDRAW_MINOR);
return ret;
}
auto controller = controller_map().find(fd);
@@ -2054,6 +2263,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 +2300,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;
@@ -2211,7 +2422,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 +2749,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 +2777,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,16 @@ 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_NODE = "hidraw$TOUCH_MINOR"
private const val TOUCH_USB = "$GUEST_DEVICES/usb2"
private const val TOUCH_INTERFACE = "$TOUCH_USB/2-1:1.0"
private const val TOUCH_HID = "$TOUCH_INTERFACE/0003:0000:11FB.0002"
private const val TOUCH_HIDRAW = "$TOUCH_HID/hidraw/$TOUCH_NODE"
/** 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 +61,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 +125,32 @@ 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: the Mobile Touch controller beside the Deck.
write(TOUCH_USB, "uevent", "DEVTYPE=usb_device\nPRODUCT=0/11fb/0\nTYPE=0/0/0\nBUSNUM=002\nDEVNUM=002\n")
write(TOUCH_USB, "idVendor", "0000\n")
write(TOUCH_USB, "idProduct", "11fb\n")
write(TOUCH_USB, "bcdDevice", "0000\n")
write(TOUCH_USB, "manufacturer", "Valve Software\n")
write(TOUCH_USB, "product", "Mobile Touch Control\n")
write(TOUCH_USB, "serial", "MT-DROIDDECK0001\n")
link(File(dir(TOUCH_USB), "subsystem"), "/sys/bus/usb")
write(TOUCH_INTERFACE, "uevent", "DEVTYPE=usb_interface\nPRODUCT=0/11fb/0\nINTERFACE=3/0/0\n")
write(TOUCH_INTERFACE, "bInterfaceNumber", "00\n")
write(TOUCH_INTERFACE, "bInterfaceClass", "03\n")
link(File(dir(TOUCH_INTERFACE), "subsystem"), "/sys/bus/usb")
write(TOUCH_HID, "uevent", "DRIVER=hid-generic\nHID_ID=0003:00000000:000011FB\n" +
"HID_NAME=Mobile Touch Control\nHID_PHYS=usb-droiddeck-2/input0\n" +
"HID_UNIQ=MT-DROIDDECK0001\nMODALIAS=hid:b0003g0001v00000000p000011FB\n")
File(dir(TOUCH_HID), "report_descriptor").writeBytes(TOUCH_REPORT_DESCRIPTOR)
link(File(dir(TOUCH_HID), "subsystem"), "/sys/bus/hid")
write(TOUCH_HIDRAW, "uevent", "MAJOR=$MAJOR\nMINOR=$TOUCH_MINOR\nDEVNAME=$TOUCH_NODE\n")
write(TOUCH_HIDRAW, "dev", "$MAJOR:$TOUCH_MINOR\n")
link(File(dir(TOUCH_HIDRAW), "subsystem"), "/sys/class/hidraw")
link(File(dir(TOUCH_HIDRAW), "device"), TOUCH_HID)
link(File(hidrawClass, TOUCH_NODE), TOUCH_HIDRAW)
link(File(context.cacheDir, "drm/sys/$MAJOR:$TOUCH_MINOR"), TOUCH_HIDRAW)
File(udevData, "c$MAJOR:$TOUCH_MINOR").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
Log.i(TAG, "deck pad: /dev/$NODE described as a Steam Deck controller (28de:1205)")
listOf(devices.path + ":" + GUEST_DEVICES, hidrawClass.path + ":/sys/class/hidraw")
} catch (e: Exception) {
@@ -180,3 +180,43 @@ So Steam drives action sets, layers and rumble over HID. The only things that co
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 |