feat: Steam's touch controller as on-screen controls (beta)

This commit is contained in:
xXJsonDeruloXx committed 2026-10-07 22:11:58 -04:00
1 parent e070bd72e0
commit 270dfbdcc3
13 files changed
+1848 -6

No files matched your search

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