mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-17 12:47:21 -04:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3112402562 | ||
|
|
bc0530463e | ||
|
|
2d07c3df04 | ||
|
|
05a5f745f5 | ||
|
|
1ce0a43db0 | ||
|
|
b5f7ac6700 | ||
|
|
03c8a0e4c2 | ||
|
|
1bd561531a | ||
|
|
64d72913fa | ||
|
|
1b31899896 | ||
|
|
38a84a8e8f | ||
|
|
c2dd90d1b6 | ||
|
|
e637a2e0bd | ||
|
|
261dee73c4 | ||
|
|
ac8455925e | ||
|
|
270dfbdcc3 |
No files matched your search
@@ -11,6 +11,7 @@
|
||||
#include <atomic>
|
||||
#include <cerrno>
|
||||
#include <climits>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
@@ -1216,6 +1217,7 @@ struct DeckHidraw {
|
||||
FakeInputRingHeader *ring = nullptr;
|
||||
size_t mapping_size = 0;
|
||||
uint8_t pending_feature = 0;
|
||||
bool touch = false; // Steam's touch controller (below), not the Deck
|
||||
};
|
||||
|
||||
// What the Deck has and an Xbox pad does not, after the ring's events: motion (PadMotion) and the
|
||||
@@ -1315,13 +1317,15 @@ __attribute__((visibility("hidden"))) static bool is_deck_hidraw_path(const char
|
||||
return pathname && fake_deck_enabled() && !strcmp(pathname, DECK_HIDRAW_PATH);
|
||||
}
|
||||
|
||||
// The app's own pads are the Deck now; their evdev nodes are withdrawn from everyone.
|
||||
// The app's first pad is the Deck now: its evdev node is withdrawn from everyone. Further players'
|
||||
// pads are left to the client alone, which hands them to games through Steam Input.
|
||||
__attribute__((visibility("hidden"))) static bool is_withdrawn_pad_path(const char *pathname) {
|
||||
if (!fake_deck_enabled() || !pathname || strncmp(pathname, "/dev/input/", 11)) return false;
|
||||
const char *event = strrchr(pathname, '/') + 1;
|
||||
if (strncmp(event, "event", 5) && strncmp(event, "js", 2)) return false;
|
||||
int number = get_event_number(event);
|
||||
return number >= 0 && number < UINPUT_EVENT_BASE;
|
||||
if (number < 0 || number >= UINPUT_EVENT_BASE) return false;
|
||||
return number == 0 || !process_is_steam_client();
|
||||
}
|
||||
|
||||
static inline void put16(uint8_t *at, int value) {
|
||||
@@ -1483,6 +1487,321 @@ __attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags) {
|
||||
return pair[0];
|
||||
}
|
||||
|
||||
|
||||
// ---- Steam's touch controller ----
|
||||
//
|
||||
// docs/development/steam-touch-controller.md. Steam builds its touch controller (Steam Input's
|
||||
// Mobile Touch type, the one Steam Link's on-screen controls are) for any HID device with the ids
|
||||
// 0000:11fb. With FAKE_TOUCHCTL_RING set, the client is offered one as /dev/hidraw17 beside the
|
||||
// Deck, its sysfs written by the app (SteamDeckPad.kt). The app draws the controls; this side only
|
||||
// carries the device's traffic through the file FAKE_TOUCHCTL_RING names, which both map:
|
||||
//
|
||||
// - the app writes the 40-byte input report (under a seqlock), whether the device is plugged in,
|
||||
// and the battery level;
|
||||
// - this side writes what the client tells the device: the app and action set it is on (output
|
||||
// report 4), the action set layers added and removed (5, 6), rumble (1, also passed to the app's
|
||||
// vibration like any pad's) and the setting report (3), and how many readers have it open.
|
||||
//
|
||||
// Plugged in and out by the app (while its controls are up and no physical pad is in use), as
|
||||
// Steam Link withdraws its touch controls: an unplugged device ends its open stream, refuses new
|
||||
// opens, and a udev event (libblsession's stand-in monitor, udevmon.c) tells the client's HID
|
||||
// discovery either way. Only the client is offered it; a game reads Steam Input's virtual pad.
|
||||
static constexpr const char *TOUCH_HIDRAW_PATH = "/dev/hidraw17";
|
||||
static constexpr unsigned int TOUCH_HIDRAW_MINOR = 17;
|
||||
static constexpr int TOUCH_REPORT_BYTES = 40;
|
||||
static constexpr int TOUCH_REPORT_INTERVAL_US = 8000;
|
||||
static constexpr const char *TOUCH_NAME = "Mobile Touch Control";
|
||||
static constexpr const char *TOUCH_SERIAL = "DROIDDECK0001";
|
||||
static constexpr const char *TOUCH_DEVPATH = "/devices/droiddeck/usb2/2-1:1.0/0003:0000:11FB.0002/hidraw/hidraw17";
|
||||
// Generic Desktop / Game Pad: for a vendor id that is not Valve's the client opens only a device
|
||||
// with a gamepad usage. One 40-byte input report, one 64-byte output and one 64-byte feature
|
||||
// report, no report ids. Must match SteamDeckPad.kt.
|
||||
static const uint8_t kTouchReportDescriptor[] = {
|
||||
0x05, 0x01, 0x09, 0x05, 0xa1, 0x01,
|
||||
0x09, 0x02, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x28, 0x81, 0x02,
|
||||
0x09, 0x03, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0x91, 0x02,
|
||||
0x09, 0x04, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0};
|
||||
|
||||
// The shared file. Must match SteamTouchDevice.kt.
|
||||
struct TouchRingFile {
|
||||
uint32_t magic; // TCH1
|
||||
uint32_t version;
|
||||
uint64_t report_seq; // odd while the app writes report
|
||||
uint8_t report[TOUCH_REPORT_BYTES];
|
||||
uint32_t plugged;
|
||||
uint32_t battery; // percent
|
||||
// Written here.
|
||||
uint64_t state_seq; // odd while being written
|
||||
uint32_t opened;
|
||||
uint32_t appid;
|
||||
uint32_t action_set;
|
||||
uint32_t layer_count;
|
||||
uint32_t layers[8];
|
||||
uint32_t rumble_seq;
|
||||
uint16_t rumble_low;
|
||||
uint16_t rumble_high;
|
||||
uint32_t rumble_ms;
|
||||
uint32_t setting30;
|
||||
uint32_t action_seq;
|
||||
};
|
||||
static constexpr uint32_t TOUCH_RING_MAGIC = 0x31484354;
|
||||
static constexpr size_t TOUCH_RING_SIZE = 256;
|
||||
static_assert(offsetof(TouchRingFile, report) == 16, "TouchRingFile layout");
|
||||
static_assert(offsetof(TouchRingFile, plugged) == 56, "TouchRingFile layout");
|
||||
static_assert(offsetof(TouchRingFile, state_seq) == 64, "TouchRingFile layout");
|
||||
static_assert(offsetof(TouchRingFile, layers) == 88, "TouchRingFile layout");
|
||||
static_assert(offsetof(TouchRingFile, rumble_seq) == 120, "TouchRingFile layout");
|
||||
static_assert(offsetof(TouchRingFile, action_seq) == 136, "TouchRingFile layout");
|
||||
static_assert(sizeof(TouchRingFile) <= TOUCH_RING_SIZE, "TouchRingFile layout");
|
||||
|
||||
__attribute__((visibility("hidden"))) static TouchRingFile *touch_ring() {
|
||||
static TouchRingFile *ring = nullptr;
|
||||
static std::once_flag once;
|
||||
std::call_once(once, [] {
|
||||
const char *path = getenv("FAKE_TOUCHCTL_RING");
|
||||
if (!path || !*path || !fake_deck_enabled()) return;
|
||||
static auto my_open = reinterpret_cast<int (*)(const char *, int, ...)>(dlsym(RTLD_NEXT, "open"));
|
||||
int fd = my_open(path, O_RDWR | O_CLOEXEC);
|
||||
if (fd < 0) {
|
||||
Logger::log("touch: %s not openable (%s)\n", path, strerror(errno));
|
||||
return;
|
||||
}
|
||||
void *mapping = mmap(nullptr, TOUCH_RING_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
|
||||
syscall(SYS_close, fd);
|
||||
if (mapping == MAP_FAILED || static_cast<TouchRingFile *>(mapping)->magic != TOUCH_RING_MAGIC) {
|
||||
Logger::log("touch: %s %s\n", path, mapping == MAP_FAILED ? "could not be mapped" : "has no valid header");
|
||||
if (mapping != MAP_FAILED) munmap(mapping, TOUCH_RING_SIZE);
|
||||
return;
|
||||
}
|
||||
ring = static_cast<TouchRingFile *>(mapping);
|
||||
});
|
||||
return ring;
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static bool touch_plugged() {
|
||||
TouchRingFile *ring = touch_ring();
|
||||
return ring && __atomic_load_n(&ring->plugged, __ATOMIC_ACQUIRE) != 0;
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static bool is_touch_hidraw_path(const char *pathname) {
|
||||
return pathname && !strcmp(pathname, TOUCH_HIDRAW_PATH) && process_is_steam_client() && touch_ring();
|
||||
}
|
||||
|
||||
// The state the client gave the device, written under the ring's seqlock.
|
||||
template <typename F>
|
||||
__attribute__((visibility("hidden"))) static void touch_state(F &&write) {
|
||||
TouchRingFile *ring = touch_ring();
|
||||
if (!ring) return;
|
||||
static std::mutex mutex;
|
||||
std::lock_guard<std::mutex> guard(mutex);
|
||||
__atomic_fetch_add(&ring->state_seq, 1, __ATOMIC_ACQ_REL);
|
||||
write(*ring);
|
||||
__atomic_fetch_add(&ring->state_seq, 1, __ATOMIC_ACQ_REL);
|
||||
}
|
||||
|
||||
static inline uint32_t get32(const uint8_t *at) {
|
||||
return static_cast<uint32_t>(at[0]) | static_cast<uint32_t>(at[1]) << 8 | static_cast<uint32_t>(at[2]) << 16 |
|
||||
static_cast<uint32_t>(at[3]) << 24;
|
||||
}
|
||||
|
||||
// An output report from the client (Steam Link's CVirtualController::HandleFeatureReports): the
|
||||
// type, then its fields.
|
||||
__attribute__((visibility("hidden"))) static void touch_output(const uint8_t *data, size_t size) {
|
||||
if (size < 9) return;
|
||||
static std::atomic<unsigned> seen[8];
|
||||
uint8_t type = data[0];
|
||||
unsigned n = type < 8 ? ++seen[type] : 0;
|
||||
if (n && (n & (n - 1)) == 0)
|
||||
Logger::log("touch: output report %u (x%u): app %u, %u\n", type, n, get32(data + 1), get32(data + 5));
|
||||
switch (type) {
|
||||
case 1: { // rumble: low, high, duration
|
||||
uint16_t low = data[1] | data[2] << 8, high = data[3] | data[4] << 8;
|
||||
uint32_t ms = get32(data + 5);
|
||||
touch_state([&](TouchRingFile &r) {
|
||||
r.rumble_low = low;
|
||||
r.rumble_high = high;
|
||||
r.rumble_ms = ms;
|
||||
r.rumble_seq++;
|
||||
});
|
||||
send_vibration(low, high, static_cast<uint16_t>(std::min<uint32_t>(ms, 0xffff)), 0);
|
||||
break;
|
||||
}
|
||||
case 3: // a setting: 0x30 and a value
|
||||
if (data[1] == 0x30) touch_state([&](TouchRingFile &r) { r.setting30 = data[2] | data[3] << 8; });
|
||||
break;
|
||||
case 4: // the app and its action set
|
||||
touch_state([&](TouchRingFile &r) {
|
||||
r.appid = get32(data + 1);
|
||||
r.action_set = get32(data + 5);
|
||||
r.layer_count = 0;
|
||||
r.action_seq++;
|
||||
});
|
||||
break;
|
||||
case 5: // a layer added to the app's action set
|
||||
case 6: // and removed
|
||||
touch_state([&](TouchRingFile &r) {
|
||||
uint32_t layer = get32(data + 5);
|
||||
uint32_t count = std::min<uint32_t>(r.layer_count, 8);
|
||||
uint32_t kept = 0;
|
||||
for (uint32_t i = 0; i < count; i++)
|
||||
if (r.layers[i] != layer) r.layers[kept++] = r.layers[i];
|
||||
if (type == 5 && kept < 8) r.layers[kept++] = layer;
|
||||
r.layer_count = kept;
|
||||
r.action_seq++;
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static bool read_touch_report(TouchRingFile *ring, uint8_t *out) {
|
||||
for (int attempt = 0; attempt < 8; attempt++) {
|
||||
uint64_t seq = __atomic_load_n(&ring->report_seq, __ATOMIC_ACQUIRE);
|
||||
if (seq & 1) continue;
|
||||
memcpy(out, ring->report, TOUCH_REPORT_BYTES);
|
||||
__atomic_thread_fence(__ATOMIC_ACQUIRE);
|
||||
if (seq == __atomic_load_n(&ring->report_seq, __ATOMIC_RELAXED)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static void *touch_report_thread(void *arg) {
|
||||
auto *holder = static_cast<std::shared_ptr<DeckHidraw> *>(arg);
|
||||
std::shared_ptr<DeckHidraw> self = *holder;
|
||||
delete holder;
|
||||
TouchRingFile *ring = touch_ring();
|
||||
uint8_t report[TOUCH_REPORT_BYTES] = {};
|
||||
while (touch_plugged()) {
|
||||
read_touch_report(ring, report);
|
||||
if (send(self->peer, report, sizeof(report), MSG_DONTWAIT | MSG_NOSIGNAL) < 0 &&
|
||||
errno != EAGAIN && errno != EWOULDBLOCK)
|
||||
break;
|
||||
struct timespec interval = {0, TOUCH_REPORT_INTERVAL_US * 1000L};
|
||||
nanosleep(&interval, nullptr);
|
||||
}
|
||||
// Ending the stream is the unplug: the client's read fails and it lets the controller go.
|
||||
syscall(SYS_close, self->peer);
|
||||
touch_state([](TouchRingFile &r) { if (r.opened) r.opened--; });
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static int open_touch_hidraw(int flags) {
|
||||
if (!touch_plugged()) {
|
||||
errno = ENOENT;
|
||||
return -1;
|
||||
}
|
||||
int pair[2];
|
||||
if (socketpair(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0, pair) < 0) return -1;
|
||||
int buffer = TOUCH_REPORT_BYTES * 4;
|
||||
setsockopt(pair[1], SOL_SOCKET, SO_SNDBUF, &buffer, sizeof(buffer));
|
||||
if (!(flags & O_CLOEXEC)) fcntl(pair[0], F_SETFD, 0);
|
||||
if (flags & O_NONBLOCK) fcntl(pair[0], F_SETFL, fcntl(pair[0], F_GETFL) | O_NONBLOCK);
|
||||
auto touch = std::make_shared<DeckHidraw>();
|
||||
touch->peer = pair[1];
|
||||
touch->touch = true;
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> guard(controller_mutex());
|
||||
deck_map()[pair[0]] = touch;
|
||||
}
|
||||
touch_state([](TouchRingFile &r) { r.opened++; });
|
||||
pthread_t thread;
|
||||
auto *arg = new std::shared_ptr<DeckHidraw>(touch);
|
||||
if (pthread_create(&thread, nullptr, touch_report_thread, arg) != 0) {
|
||||
delete arg;
|
||||
std::lock_guard<std::recursive_mutex> guard(controller_mutex());
|
||||
deck_map().erase(pair[0]);
|
||||
syscall(SYS_close, pair[0]);
|
||||
syscall(SYS_close, pair[1]);
|
||||
touch_state([](TouchRingFile &r) { if (r.opened) r.opened--; });
|
||||
errno = ENOMEM;
|
||||
return -1;
|
||||
}
|
||||
pthread_detach(thread);
|
||||
Logger::log("touch: %s opened as fd %d\n", TOUCH_HIDRAW_PATH, pair[0]);
|
||||
return pair[0];
|
||||
}
|
||||
|
||||
static int copy_ioctl_string(ioctl_request_t op, void *argp, const char *value);
|
||||
|
||||
__attribute__((visibility("hidden"))) static int ioctl_touch(DeckHidraw &touch, ioctl_request_t op, void *argp) {
|
||||
if (_IOC_TYPE(op) != 'H') {
|
||||
errno = ENOTTY;
|
||||
return -1;
|
||||
}
|
||||
size_t size = _IOC_SIZE(op);
|
||||
switch (_IOC_NR(op)) {
|
||||
case 0x01: // HIDIOCGRDESCSIZE
|
||||
*static_cast<int *>(argp) = sizeof(kTouchReportDescriptor);
|
||||
return 0;
|
||||
case 0x02: { // HIDIOCGRDESC
|
||||
auto *descriptor = static_cast<uint8_t *>(argp);
|
||||
uint32_t want;
|
||||
memcpy(&want, descriptor, sizeof(want));
|
||||
memcpy(descriptor + 4, kTouchReportDescriptor, std::min<size_t>(want, sizeof(kTouchReportDescriptor)));
|
||||
return 0;
|
||||
}
|
||||
case 0x03: { // HIDIOCGRAWINFO
|
||||
struct {
|
||||
uint32_t bustype;
|
||||
int16_t vendor;
|
||||
int16_t product;
|
||||
} info = {BUS_USB, 0x0000, static_cast<int16_t>(0x11fb)};
|
||||
memcpy(argp, &info, sizeof(info));
|
||||
return 0;
|
||||
}
|
||||
case 0x04: return copy_ioctl_string(op, argp, TOUCH_NAME); // HIDIOCGRAWNAME
|
||||
case 0x05: return copy_ioctl_string(op, argp, "usb-droiddeck-2/input0"); // HIDIOCGRAWPHYS
|
||||
case 0x08: return copy_ioctl_string(op, argp, TOUCH_SERIAL); // HIDIOCGRAWUNIQ
|
||||
case 0x06: // HIDIOCSFEATURE: Steam Link's controller ignores these
|
||||
touch.pending_feature = size > 0 ? static_cast<uint8_t *>(argp)[0] : 0;
|
||||
return static_cast<int>(size);
|
||||
case 0x07: { // HIDIOCGFEATURE: report 2 is the battery
|
||||
auto *buf = static_cast<uint8_t *>(argp);
|
||||
uint8_t id = size ? buf[0] : 0;
|
||||
memset(buf, 0, size);
|
||||
if (size) buf[0] = id;
|
||||
if (size > 2) {
|
||||
TouchRingFile *ring = touch_ring();
|
||||
buf[1] = 2;
|
||||
buf[2] = static_cast<uint8_t>(ring && ring->battery ? std::min<uint32_t>(ring->battery, 100) : 100);
|
||||
}
|
||||
return static_cast<int>(std::min<size_t>(size, 18));
|
||||
}
|
||||
default:
|
||||
errno = EINVAL;
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Plugging in and out: the app flips `plugged`; this tells the client's HID discovery, which
|
||||
// otherwise never looks again (the sandbox gives it no kernel uevents).
|
||||
__attribute__((visibility("hidden"))) static void touch_uevent(const char *action) {
|
||||
using inject_fn = int (*)(const char *, const char *, const char *, const char *, unsigned, unsigned);
|
||||
static auto inject = reinterpret_cast<inject_fn>(dlsym(RTLD_DEFAULT, "bl_udevmon_inject"));
|
||||
int sent = inject ? inject(action, TOUCH_DEVPATH, "hidraw", TOUCH_HIDRAW_PATH, DECK_HIDRAW_MAJOR, TOUCH_HIDRAW_MINOR) : -1;
|
||||
Logger::log("touch: %s (udev event to %d monitor%s)\n", action, sent, sent == 1 ? "" : "s");
|
||||
}
|
||||
|
||||
__attribute__((visibility("hidden"))) static void *touch_plug_thread(void *) {
|
||||
bool was = touch_plugged();
|
||||
Logger::log("touch: %s at start\n", was ? "plugged in" : "unplugged");
|
||||
for (;;) {
|
||||
struct timespec interval = {0, 50 * 1000 * 1000};
|
||||
nanosleep(&interval, nullptr);
|
||||
bool now = touch_plugged();
|
||||
if (now == was) continue;
|
||||
was = now;
|
||||
touch_uevent(now ? "add" : "remove");
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
__attribute__((constructor)) static void start_touch_controller() {
|
||||
config();
|
||||
if (!process_is_steam_client() || !touch_ring()) return;
|
||||
pthread_t thread;
|
||||
if (pthread_create(&thread, nullptr, touch_plug_thread, nullptr) == 0) pthread_detach(thread);
|
||||
}
|
||||
|
||||
// libudev (systemd's sd-device) accepts a device only where its directory is on sysfs, which it
|
||||
// checks with fstatfs on the directory it resolved. The Deck's sysfs is the app's own files bound
|
||||
// into /sys, so those paths are said to be sysfs; nothing else is touched.
|
||||
@@ -1823,6 +2142,7 @@ EXPORT int open(const char *pathname, int flags, ...) {
|
||||
|
||||
if (fake_uinput_enabled() && is_uinput_path(pathname)) return open_uinput(flags);
|
||||
if (is_deck_hidraw_path(pathname)) return open_deck_hidraw(flags);
|
||||
if (is_touch_hidraw_path(pathname)) return open_touch_hidraw(flags);
|
||||
if (is_withdrawn_pad_path(pathname)) {
|
||||
errno = ENOENT;
|
||||
return -1;
|
||||
@@ -1897,6 +2217,7 @@ EXPORT int openat(int dirfd, const char *pathname, int flags, ...) {
|
||||
|
||||
if (fake_uinput_enabled() && is_uinput_path(pathname)) return open_uinput(flags);
|
||||
if (is_deck_hidraw_path(pathname)) return open_deck_hidraw(flags);
|
||||
if (is_touch_hidraw_path(pathname)) return open_touch_hidraw(flags);
|
||||
if (is_withdrawn_pad_path(pathname)) {
|
||||
errno = ENOENT;
|
||||
return -1;
|
||||
@@ -1957,6 +2278,12 @@ static int fake_stat_path(const char *pathname, S *statbuf, Real real) {
|
||||
errno = ENOENT;
|
||||
return -1;
|
||||
}
|
||||
if (is_touch_hidraw_path(pathname)) {
|
||||
memset(statbuf, 0, sizeof(*statbuf));
|
||||
statbuf->st_mode = S_IFCHR | 0666;
|
||||
statbuf->st_rdev = makedev(DECK_HIDRAW_MAJOR, TOUCH_HIDRAW_MINOR);
|
||||
return 0;
|
||||
}
|
||||
if (is_deck_hidraw_path(pathname)) {
|
||||
memset(statbuf, 0, sizeof(*statbuf));
|
||||
statbuf->st_mode = S_IFCHR | 0666;
|
||||
@@ -2008,7 +2335,7 @@ static int fake_stat_fd(int fd, S *buf, Real real) {
|
||||
std::lock_guard<std::recursive_mutex> guard(controller_mutex());
|
||||
if (ret == 0 && deck_map().count(fd)) {
|
||||
buf->st_mode = (buf->st_mode & ~S_IFMT) | S_IFCHR;
|
||||
buf->st_rdev = makedev(DECK_HIDRAW_MAJOR, DECK_HIDRAW_MINOR);
|
||||
buf->st_rdev = makedev(DECK_HIDRAW_MAJOR, deck_map()[fd]->touch ? TOUCH_HIDRAW_MINOR : DECK_HIDRAW_MINOR);
|
||||
return ret;
|
||||
}
|
||||
auto controller = controller_map().find(fd);
|
||||
@@ -2054,6 +2381,7 @@ EXPORT int access(const char *pathname, int mode) {
|
||||
static auto my_access = reinterpret_cast<decltype(&::access)>(dlsym(RTLD_NEXT, "access"));
|
||||
if (fake_uinput_enabled() && is_uinput_path(pathname)) return 0;
|
||||
if (is_deck_hidraw_path(pathname) && process_is_steam_client()) return 0;
|
||||
if (is_touch_hidraw_path(pathname)) return 0;
|
||||
if (is_withdrawn_pad_path(pathname) || is_deck_hidraw_path(pathname)) {
|
||||
errno = ENOENT;
|
||||
return -1;
|
||||
@@ -2090,6 +2418,7 @@ EXPORT int faccessat(int dirfd, const char *pathname, int mode, int flags) {
|
||||
static auto my_faccessat = reinterpret_cast<decltype(&::faccessat)>(dlsym(RTLD_NEXT, "faccessat"));
|
||||
if (fake_uinput_enabled() && is_uinput_path(pathname)) return 0;
|
||||
if (is_deck_hidraw_path(pathname) && process_is_steam_client()) return 0;
|
||||
if (is_touch_hidraw_path(pathname)) return 0;
|
||||
if (is_withdrawn_pad_path(pathname) || is_deck_hidraw_path(pathname)) {
|
||||
errno = ENOENT;
|
||||
return -1;
|
||||
@@ -2211,7 +2540,8 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
|
||||
auto maker = uinput_map().find(fd);
|
||||
if (maker != uinput_map().end()) return ioctl_uinput(*maker->second, op, argp);
|
||||
auto deck = deck_map().find(fd);
|
||||
if (deck != deck_map().end()) return ioctl_deck(*deck->second, op, argp);
|
||||
if (deck != deck_map().end())
|
||||
return deck->second->touch ? ioctl_touch(*deck->second, op, argp) : ioctl_deck(*deck->second, op, argp);
|
||||
auto controller = controller_map().find(fd);
|
||||
if (controller == controller_map().end()) {
|
||||
guard.unlock();
|
||||
@@ -2537,7 +2867,10 @@ EXPORT ssize_t write(int fd, const void *buf, size_t count) {
|
||||
std::unique_lock<std::recursive_mutex> guard(controller_mutex());
|
||||
auto made = uinput_map().find(fd);
|
||||
if (made != uinput_map().end()) return write_uinput(*made->second, buf, count);
|
||||
if (deck_map().count(fd)) return static_cast<ssize_t>(count);
|
||||
if (deck_map().count(fd)) {
|
||||
if (deck_map()[fd]->touch) touch_output(static_cast<const uint8_t *>(buf), count);
|
||||
return static_cast<ssize_t>(count);
|
||||
}
|
||||
auto controller = controller_map().find(fd);
|
||||
if (controller != controller_map().end()) {
|
||||
if (fake_fd_is_stale(fd)) {
|
||||
@@ -2562,7 +2895,11 @@ EXPORT ssize_t writev(int fd, const struct iovec *iov, int iovcnt) {
|
||||
std::unique_lock<std::recursive_mutex> guard(controller_mutex());
|
||||
if (deck_map().count(fd)) {
|
||||
ssize_t total = 0;
|
||||
for (int i = 0; i < iovcnt; i++) total += static_cast<ssize_t>(iov[i].iov_len);
|
||||
bool touch = deck_map()[fd]->touch;
|
||||
for (int i = 0; i < iovcnt; i++) {
|
||||
if (touch) touch_output(static_cast<const uint8_t *>(iov[i].iov_base), iov[i].iov_len);
|
||||
total += static_cast<ssize_t>(iov[i].iov_len);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
auto made = uinput_map().find(fd);
|
||||
|
||||
@@ -145,6 +145,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (resumed && !pipUi && SessionState.deckPad) padMotion?.start() else padMotion?.stop()
|
||||
}
|
||||
private var onScreenControls: OnScreenControls? = null
|
||||
/** Steam's touch controls (SteamTouchControls), made once the session offers the device. */
|
||||
private var steamTouchControls: com.droiddeck.launcher.input.SteamTouchControls? = null
|
||||
private var steamTouchAvailable by mutableStateOf(false)
|
||||
private val steamTouchRetry = Runnable { updateOnScreenControls() }
|
||||
/** While Steam's touch controls are up: how the game's touch config says touches outside them
|
||||
* act (Steam Link's input and mouse modes), in place of the drawer's touch mode. */
|
||||
private var steamTouchOptions: com.droiddeck.launcher.input.SteamTouchConfig.Options? = null
|
||||
/** The touch mode touches are routed by: the drawer's, or the game's while Steam's controls are up. */
|
||||
private var routedTouchMode = SessionPrefs.TOUCH_AUTO
|
||||
private var keyboard: KeyboardHost? = null
|
||||
private var controllerSettings by mutableStateOf<ControllerPrefs.Settings?>(null)
|
||||
private val sessionClipboard by lazy {
|
||||
@@ -295,6 +304,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
override fun onInputDeviceRemoved(deviceId: Int) {
|
||||
Log.i(TAG, "input device $deviceId disconnected")
|
||||
padBridge?.onDeviceRemoved(deviceId)
|
||||
updateOnScreenControls()
|
||||
updatePointerCapture()
|
||||
}
|
||||
@@ -560,7 +570,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
SessionPrefs.setBackActionsInverted(this@SessionActivity, inverted)
|
||||
backActionsInverted = inverted
|
||||
},
|
||||
onOsc = { v -> SessionPrefs.setOscMode(this@SessionActivity, v); readPrefs(); updateOnScreenControls() },
|
||||
onOsc = { v ->
|
||||
SessionPrefs.setOscMode(this@SessionActivity, v)
|
||||
readPrefs()
|
||||
updateOnScreenControls()
|
||||
if (v == SessionPrefs.OSC_STEAM_TOUCH && com.droiddeck.launcher.input.SteamTouchDevice.current == null)
|
||||
Toast.makeText(this@SessionActivity, R.string.osc_style_next_session, Toast.LENGTH_LONG).show()
|
||||
},
|
||||
onEditSteamTouch = if (steamTouchAvailable && oscMode == SessionPrefs.OSC_STEAM_TOUCH && onScreenButtonsVisible) ({
|
||||
drawerOpen = false
|
||||
steamTouchControls?.startEditing()
|
||||
}) else null,
|
||||
onSuspendPolicy = { policy ->
|
||||
SessionPrefs.setSuspendPolicy(this@SessionActivity, SessionState.mode, policy)
|
||||
suspendPolicy = policy
|
||||
@@ -795,6 +815,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (nextTouchMode == SessionPrefs.TOUCH_OFF) cursorVisible = false
|
||||
}
|
||||
touchMode = nextTouchMode
|
||||
routedTouchMode = effectiveTouchMode()
|
||||
frameGen = FrameGen.mode(this)
|
||||
fexPreset = SessionPrefs.fexPreset(this)
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
|
||||
@@ -1654,8 +1675,32 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
}
|
||||
|
||||
/** The touch mode in force: the game's own while Steam's touch controls are shown. */
|
||||
private fun effectiveTouchMode(): String {
|
||||
val options = steamTouchOptions
|
||||
if (options == null || steamTouchControls?.visibility != View.VISIBLE) return SessionPrefs.touchMode(this)
|
||||
return when {
|
||||
options.inputMode == com.droiddeck.launcher.input.SteamTouchConfig.INPUT_CONTROLLER -> SessionPrefs.TOUCH_OFF
|
||||
options.mouseMode == com.droiddeck.launcher.input.SteamTouchConfig.MOUSE_RELATIVE -> SessionPrefs.TOUCH_PAD
|
||||
else -> SessionPrefs.TOUCH_DIRECT
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyTouchRouting() {
|
||||
val next = effectiveTouchMode()
|
||||
if (::touchpad.isInitialized) {
|
||||
val speed = steamTouchOptions?.trackpadSensitivity?.takeIf { steamTouchControls?.visibility == View.VISIBLE } ?: 1f
|
||||
touchpad.sensitivity = 1.15f * speed
|
||||
}
|
||||
if (next == routedTouchMode) return
|
||||
if (::touchpad.isInitialized) touchpad.cancel()
|
||||
if (CompositorHost.isStarted) WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
|
||||
if (next == SessionPrefs.TOUCH_OFF) cursorVisible = false
|
||||
routedTouchMode = next
|
||||
}
|
||||
|
||||
/** Touchpad on the desktop, direct in Steam, unless the drawer says otherwise. */
|
||||
private fun usingTouchpad(): Boolean = when (SessionPrefs.touchMode(this)) {
|
||||
private fun usingTouchpad(): Boolean = when (routedTouchMode) {
|
||||
SessionPrefs.TOUCH_PAD -> true
|
||||
SessionPrefs.TOUCH_DIRECT -> false
|
||||
else -> SessionState.mode != SessionService.MODE_STEAM || SessionState.steamUi == "desktop"
|
||||
@@ -1677,7 +1722,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE)) return onMouse(event)
|
||||
// Child views get the event first, so menus, keyboards and the on-screen pad still work.
|
||||
if (touchMode == SessionPrefs.TOUCH_OFF) return true
|
||||
if (routedTouchMode == SessionPrefs.TOUCH_OFF) return true
|
||||
if (usingTouchpad()) {
|
||||
val rect = drawnRect() ?: return false
|
||||
if (touchpad.bounds != rect) {
|
||||
@@ -1796,6 +1841,41 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
what, d.name, d.vendorId, d.productId, d.id, d.sources))
|
||||
}
|
||||
|
||||
/** Steam's touch controls, once the session has made the device (SessionService). */
|
||||
private fun steamTouch(): com.droiddeck.launcher.input.SteamTouchControls? {
|
||||
val device = com.droiddeck.launcher.input.SteamTouchDevice.current ?: return null
|
||||
steamTouchControls?.let { view ->
|
||||
if (view.device === device) return view
|
||||
// A new session made a new device: the view of the old one goes.
|
||||
view.releaseAll()
|
||||
(view.parent as? android.view.ViewGroup)?.removeView(view)
|
||||
steamTouchControls = null
|
||||
}
|
||||
val root = onScreenControls?.parent as? android.view.ViewGroup ?: return null
|
||||
val view = com.droiddeck.launcher.input.SteamTouchControls(this, device,
|
||||
onMenu = { drawerOpen = true }, onKeyboard = ::togglePcKeyboard)
|
||||
view.visibility = View.GONE
|
||||
view.onOptions = { options ->
|
||||
steamTouchOptions = options
|
||||
applyTouchRouting()
|
||||
}
|
||||
// Paste: the clipboard is shared with the session's X servers; Ctrl+V types it.
|
||||
view.onPaste = {
|
||||
sendGuestKey(29, true); sendGuestKey(47, true) // KEY_LEFTCTRL, KEY_V
|
||||
sendGuestKey(47, false); sendGuestKey(29, false)
|
||||
}
|
||||
// The client reads a controller's config when it connects: after a save, plug the device
|
||||
// out and back in so it takes the new one.
|
||||
view.onSaved = {
|
||||
device.plugged = false
|
||||
uiHandler.postDelayed({ updateOnScreenControls() }, 700)
|
||||
}
|
||||
root.addView(view, root.indexOfChild(onScreenControls) + 1)
|
||||
steamTouchControls = view
|
||||
steamTouchAvailable = true
|
||||
return view
|
||||
}
|
||||
|
||||
private fun updateOnScreenControls() {
|
||||
val forced = File(Environment.getExternalStorageDirectory(), "Download/droiddeck-osc")
|
||||
.takeIf { it.isFile }
|
||||
@@ -1806,20 +1886,49 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
onScreenButtonsVisible = false
|
||||
controls.releaseAll()
|
||||
controls.visibility = View.GONE
|
||||
steamTouchControls?.let { it.releaseAll(); it.visibility = View.GONE }
|
||||
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false
|
||||
return
|
||||
}
|
||||
val buttonsOnly = forced == SessionPrefs.OSC_STEAM_QAM
|
||||
// Steam's touch controller stands in for the app's pad, and is plugged into the client only
|
||||
// while it is shown - as Steam Link withdraws its controls when a physical pad is in use.
|
||||
val wantsSteam = forced == SessionPrefs.OSC_STEAM_TOUCH && SessionState.mode == SessionService.MODE_STEAM
|
||||
val steam = if (wantsSteam) steamTouch() else null
|
||||
// The service makes the device while the session starts, after this activity: the app's
|
||||
// own pad until then, and another look in a moment.
|
||||
if (wantsSteam && steam == null && !isFinishing && !isDestroyed) {
|
||||
uiHandler.removeCallbacks(steamTouchRetry)
|
||||
uiHandler.postDelayed(steamTouchRetry, 1000)
|
||||
}
|
||||
if (steam == null) {
|
||||
steamTouchControls?.let { it.releaseAll(); it.visibility = View.GONE }
|
||||
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false
|
||||
}
|
||||
controls.setButtonsOnly(buttonsOnly)
|
||||
val show = when (forced) {
|
||||
SessionPrefs.OSC_ALWAYS -> true
|
||||
SessionPrefs.OSC_STEAM_QAM -> true
|
||||
SessionPrefs.OSC_NEVER -> false
|
||||
// Shown throughout a Steam session: a handheld's built-in pad would keep Auto off for good.
|
||||
// Outside Steam it is Auto.
|
||||
SessionPrefs.OSC_STEAM_TOUCH -> SessionState.mode == SessionService.MODE_STEAM ||
|
||||
(!PadBridge.anyControllerConnected() && SessionState.mode != SessionService.MODE_DESKTOP)
|
||||
// Auto: the touch pad when there is no controller - except on the desktop, where the
|
||||
// screen is a touchpad for the pointer and a pad over it would be in the way. A game
|
||||
// started from the rail, or Steam, gets it; the drawer turns it on anywhere.
|
||||
else -> !PadBridge.anyControllerConnected() && SessionState.mode != SessionService.MODE_DESKTOP
|
||||
}
|
||||
onScreenButtonsVisible = show
|
||||
if (steam != null) {
|
||||
if (controls.visibility == View.VISIBLE) { controls.releaseAll(); controls.visibility = View.GONE }
|
||||
if (!show) steam.releaseAll()
|
||||
steam.visibility = if (show) View.VISIBLE else View.GONE
|
||||
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = show
|
||||
applyTouchRouting()
|
||||
Log.i(TAG, "on-screen controls: Steam's touch controller " + if (show) "shown" else "hidden")
|
||||
return
|
||||
}
|
||||
if (show == (controls.visibility == View.VISIBLE)) return
|
||||
if (!show) controls.releaseAll()
|
||||
controls.visibility = if (show) View.VISIBLE else View.GONE
|
||||
@@ -2038,6 +2147,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
uiHandler.removeCallbacks(steamTouchRetry)
|
||||
if (runtimeRemovalBlocked) { super.onDestroy(); return }
|
||||
// Deliberately does NOT end the session: this activity can be destroyed while the user is
|
||||
// in another app, and the whole point of the service is that Steam survives that.
|
||||
|
||||
@@ -10,6 +10,8 @@ import android.view.MotionEvent;
|
||||
import com.droiddeck.launcher.session.SessionState;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.Arrays;
|
||||
import java.util.concurrent.atomic.AtomicIntegerArray;
|
||||
|
||||
/**
|
||||
* A physical controller, republished as a synthetic evdev device the Steam client can see.
|
||||
@@ -25,11 +27,14 @@ import java.io.File;
|
||||
* to the client instead ({@link com.droiddeck.launcher.session.SteamDeckPad}), which has a Quick
|
||||
* Access button of its own; an Xbox pad has none, so there the client's menu is reached with the
|
||||
* Guide-then-A chord the client itself answers.
|
||||
*
|
||||
* <p>Each further controller is another player: slots 1 to 3, an Xbox 360 pad each, which in a
|
||||
* Steam session only the client reads (libfakeinput) and hands games through Steam Input.
|
||||
*/
|
||||
public final class PadBridge {
|
||||
private static final String TAG = "PadBridge";
|
||||
/** Slot 0 is the one the session exports; extra slots would each show as another pad. */
|
||||
private static final int SLOT = 0;
|
||||
/** Player slots: the session prepares a ring for each (SessionService). */
|
||||
public static final int SLOTS = 4;
|
||||
private static final float DEAD_ZONE = 0.12f;
|
||||
// The client takes A as part of the chord only once it has had Guide held for a while, and a
|
||||
// client starved of CPU needs longer. Too short, and it acts on A as well, selecting whatever it
|
||||
@@ -50,8 +55,13 @@ public final class PadBridge {
|
||||
chordHandler = new Handler(thread.getLooper());
|
||||
}
|
||||
|
||||
private final File fakeInputDir;
|
||||
private final FakeInputWriter writer;
|
||||
private final PadState state = new PadState();
|
||||
/** The input device id holding each slot, or {@link #NO_CONTROLLER}. */
|
||||
private final int[] owners = new int[SLOTS];
|
||||
private final FakeInputWriter[] playerWriters = new FakeInputWriter[SLOTS];
|
||||
private final PadState[] playerStates = new PadState[SLOTS];
|
||||
private final PadState effectiveState = new PadState();
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
private boolean open;
|
||||
@@ -79,29 +89,50 @@ public final class PadBridge {
|
||||
/** No physical controller is driving the pad: nothing yet, or the on-screen controls were last. */
|
||||
public static final int NO_CONTROLLER = -1;
|
||||
/**
|
||||
* The Android input device id of the physical controller that last drove the pad, or
|
||||
* {@link #NO_CONTROLLER}. Every pad feeds the one exported slot, so this is how a game's rumble
|
||||
* on that slot finds the motors in the player's hands (RumbleComponent).
|
||||
* The Android input device id of the physical controller that last drove any slot, or
|
||||
* {@link #NO_CONTROLLER}: where rumble goes when it names no slot of ours (RumbleComponent).
|
||||
*/
|
||||
private static volatile int activeControllerId = NO_CONTROLLER;
|
||||
/** Per slot, the controller its rumble belongs to; slot 0's is none while the on-screen controls lead. */
|
||||
private static final AtomicIntegerArray slotControllers = new AtomicIntegerArray(SLOTS);
|
||||
static {
|
||||
for (int slot = 0; slot < SLOTS; slot++) slotControllers.set(slot, NO_CONTROLLER);
|
||||
}
|
||||
|
||||
public static int activeControllerId() { return activeControllerId; }
|
||||
|
||||
/** The controller playing in this ring slot, or the last one used for any other slot. */
|
||||
public static int controllerForSlot(int slot) {
|
||||
return slot >= 0 && slot < SLOTS ? slotControllers.get(slot) : activeControllerId;
|
||||
}
|
||||
|
||||
public PadBridge(File fakeInputDir) {
|
||||
writer = new FakeInputWriter(fakeInputDir.getAbsolutePath(), SLOT);
|
||||
this.fakeInputDir = fakeInputDir;
|
||||
writer = new FakeInputWriter(fakeInputDir.getAbsolutePath(), 0);
|
||||
Arrays.fill(owners, NO_CONTROLLER);
|
||||
for (int slot = 1; slot < SLOTS; slot++) playerStates[slot] = new PadState();
|
||||
}
|
||||
|
||||
/** Opens the ring; safe to call more than once. */
|
||||
public synchronized boolean start() {
|
||||
if (!open) {
|
||||
open = writer.open();
|
||||
Log.i(TAG, "ring slot " + SLOT + (open ? " open" : " NOT open"));
|
||||
Log.i(TAG, "ring slot 0" + (open ? " open" : " NOT open"));
|
||||
}
|
||||
return open;
|
||||
}
|
||||
|
||||
public synchronized void stop() {
|
||||
activeControllerId = NO_CONTROLLER;
|
||||
for (int slot = 0; slot < SLOTS; slot++) {
|
||||
owners[slot] = NO_CONTROLLER;
|
||||
slotControllers.set(slot, NO_CONTROLLER);
|
||||
if (playerWriters[slot] != null) {
|
||||
playerWriters[slot].destroy();
|
||||
playerWriters[slot] = null;
|
||||
}
|
||||
if (playerStates[slot] != null) playerStates[slot].clear();
|
||||
}
|
||||
systemGuidePressed = false;
|
||||
systemQamPressed = false;
|
||||
qamChordActive = false;
|
||||
@@ -155,38 +186,40 @@ public final class PadBridge {
|
||||
|
||||
/** @return true when the event was a pad button and has been consumed. */
|
||||
public synchronized boolean onKeyEvent(KeyEvent event) {
|
||||
if (!isFromController(event.getDevice())) return false;
|
||||
noteDevice(event.getDevice());
|
||||
if (!isFromController(event.getDevice()) || !isPadKey(event.getKeyCode())) return false;
|
||||
int slot = slotFor(event.getDevice());
|
||||
PadState pad = stateFor(slot);
|
||||
noteDevice(event.getDevice(), slot);
|
||||
boolean pressed = event.getAction() == KeyEvent.ACTION_DOWN;
|
||||
switch (event.getKeyCode()) {
|
||||
case KeyEvent.KEYCODE_BUTTON_A: state.press(0, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_B: state.press(1, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_X: state.press(2, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_Y: state.press(3, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_L1: state.press(4, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_R1: state.press(5, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_A: pad.press(0, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_B: pad.press(1, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_X: pad.press(2, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_Y: pad.press(3, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_L1: pad.press(4, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_R1: pad.press(5, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_SELECT:
|
||||
case KeyEvent.KEYCODE_BACK: state.press(6, pressed); break;
|
||||
case KeyEvent.KEYCODE_BACK: pad.press(6, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_START:
|
||||
case KeyEvent.KEYCODE_MENU: state.press(7, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_THUMBL: state.press(8, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_THUMBR: state.press(9, pressed); break;
|
||||
case KeyEvent.KEYCODE_MENU: pad.press(7, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_THUMBL: pad.press(8, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_THUMBR: pad.press(9, pressed); break;
|
||||
// The client's own in-game menu is opened by this one; the interposer publishes it as
|
||||
// BTN_MODE, which SDL reports as the "guide" button.
|
||||
case KeyEvent.KEYCODE_BUTTON_MODE:
|
||||
case KeyEvent.KEYCODE_HOME: state.press(PadState.GUIDE, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_L2: state.leftTrigger = pressed ? 1f : 0f; break;
|
||||
case KeyEvent.KEYCODE_BUTTON_R2: state.rightTrigger = pressed ? 1f : 0f; break;
|
||||
case KeyEvent.KEYCODE_DPAD_UP: state.up = pressed; break;
|
||||
case KeyEvent.KEYCODE_DPAD_RIGHT: state.right = pressed; break;
|
||||
case KeyEvent.KEYCODE_DPAD_DOWN: state.down = pressed; break;
|
||||
case KeyEvent.KEYCODE_DPAD_LEFT: state.left = pressed; break;
|
||||
case KeyEvent.KEYCODE_HOME: pad.press(PadState.GUIDE, pressed); break;
|
||||
case KeyEvent.KEYCODE_BUTTON_L2: pad.leftTrigger = pressed ? 1f : 0f; break;
|
||||
case KeyEvent.KEYCODE_BUTTON_R2: pad.rightTrigger = pressed ? 1f : 0f; break;
|
||||
case KeyEvent.KEYCODE_DPAD_UP: pad.up = pressed; break;
|
||||
case KeyEvent.KEYCODE_DPAD_RIGHT: pad.right = pressed; break;
|
||||
case KeyEvent.KEYCODE_DPAD_DOWN: pad.down = pressed; break;
|
||||
case KeyEvent.KEYCODE_DPAD_LEFT: pad.left = pressed; break;
|
||||
default: return false;
|
||||
}
|
||||
if (pressed) notePlayerInput();
|
||||
statButtons++;
|
||||
scheduleStats();
|
||||
publish();
|
||||
publish(slot);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -194,33 +227,35 @@ public final class PadBridge {
|
||||
public synchronized boolean onMotionEvent(MotionEvent event) {
|
||||
if (!isFromController(event.getDevice())) return false;
|
||||
if (event.getAction() != MotionEvent.ACTION_MOVE) return false;
|
||||
noteDevice(event.getDevice());
|
||||
int slot = slotFor(event.getDevice());
|
||||
PadState pad = stateFor(slot);
|
||||
noteDevice(event.getDevice(), slot);
|
||||
statAxes++;
|
||||
scheduleStats();
|
||||
state.leftX = axis(event, MotionEvent.AXIS_X);
|
||||
pad.leftX = axis(event, MotionEvent.AXIS_X);
|
||||
// Android's Y axis grows downwards and evdev's ABS_Y does too, so no flip here: what the
|
||||
// pad reports as "down" is what the client is told.
|
||||
state.leftY = axis(event, MotionEvent.AXIS_Y);
|
||||
state.rightX = axis(event, MotionEvent.AXIS_Z);
|
||||
state.rightY = axis(event, MotionEvent.AXIS_RZ);
|
||||
pad.leftY = axis(event, MotionEvent.AXIS_Y);
|
||||
pad.rightX = axis(event, MotionEvent.AXIS_Z);
|
||||
pad.rightY = axis(event, MotionEvent.AXIS_RZ);
|
||||
float lt = event.getAxisValue(MotionEvent.AXIS_LTRIGGER);
|
||||
float rt = event.getAxisValue(MotionEvent.AXIS_RTRIGGER);
|
||||
// Some pads only report the triggers on BRAKE/GAS.
|
||||
if (lt == 0f) lt = event.getAxisValue(MotionEvent.AXIS_BRAKE);
|
||||
if (rt == 0f) rt = event.getAxisValue(MotionEvent.AXIS_GAS);
|
||||
state.leftTrigger = lt;
|
||||
state.rightTrigger = rt;
|
||||
pad.leftTrigger = lt;
|
||||
pad.rightTrigger = rt;
|
||||
float hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X);
|
||||
float hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y);
|
||||
state.up = hatY < -0.5f;
|
||||
state.right = hatX > 0.5f;
|
||||
state.down = hatY > 0.5f;
|
||||
state.left = hatX < -0.5f;
|
||||
if (Math.max(Math.max(Math.abs(state.leftX), Math.abs(state.leftY)), Math.max(Math.abs(state.rightX), Math.abs(state.rightY))) > 0.5f
|
||||
|| lt > 0.5f || rt > 0.5f || state.up || state.right || state.down || state.left) {
|
||||
pad.up = hatY < -0.5f;
|
||||
pad.right = hatX > 0.5f;
|
||||
pad.down = hatY > 0.5f;
|
||||
pad.left = hatX < -0.5f;
|
||||
if (Math.max(Math.max(Math.abs(pad.leftX), Math.abs(pad.leftY)), Math.max(Math.abs(pad.rightX), Math.abs(pad.rightY))) > 0.5f
|
||||
|| lt > 0.5f || rt > 0.5f || pad.up || pad.right || pad.down || pad.left) {
|
||||
notePlayerInput();
|
||||
}
|
||||
publish();
|
||||
publish(slot);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -231,6 +266,7 @@ public final class PadBridge {
|
||||
public synchronized void applyTouch(java.util.function.Consumer<PadState> mutation) {
|
||||
mutation.accept(state);
|
||||
activeControllerId = NO_CONTROLLER;
|
||||
slotControllers.set(0, NO_CONTROLLER);
|
||||
notePlayerInput();
|
||||
statOnScreen++;
|
||||
scheduleStats();
|
||||
@@ -255,6 +291,18 @@ public final class PadBridge {
|
||||
public synchronized void releaseAll() {
|
||||
state.clear();
|
||||
publish();
|
||||
for (int slot = 1; slot < SLOTS; slot++) {
|
||||
if (playerWriters[slot] == null) continue;
|
||||
playerStates[slot].clear();
|
||||
publish(slot);
|
||||
}
|
||||
}
|
||||
|
||||
/** A controller went away: its player's buttons are let go, and a further player's pad is unplugged. */
|
||||
public synchronized void onDeviceRemoved(int deviceId) {
|
||||
for (int slot = 0; slot < SLOTS; slot++) {
|
||||
if (owners[slot] == deviceId) free(slot);
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens the client's Quick Access menu: a tap of the Deck's button, or the Guide-then-A chord. */
|
||||
@@ -310,16 +358,71 @@ public final class PadBridge {
|
||||
publish();
|
||||
}
|
||||
|
||||
private static boolean isPadKey(int keyCode) {
|
||||
switch (keyCode) {
|
||||
case KeyEvent.KEYCODE_BACK:
|
||||
case KeyEvent.KEYCODE_MENU:
|
||||
case KeyEvent.KEYCODE_HOME:
|
||||
case KeyEvent.KEYCODE_DPAD_UP:
|
||||
case KeyEvent.KEYCODE_DPAD_RIGHT:
|
||||
case KeyEvent.KEYCODE_DPAD_DOWN:
|
||||
case KeyEvent.KEYCODE_DPAD_LEFT:
|
||||
return true;
|
||||
default:
|
||||
return KeyEvent.isGamepadButton(keyCode);
|
||||
}
|
||||
}
|
||||
|
||||
/** The slot this controller plays in: the one it has, else the first free one, else player 1's. */
|
||||
private int slotFor(InputDevice device) {
|
||||
int id = device.getId();
|
||||
if (owners[0] == id) return 0;
|
||||
for (int slot = 1; slot < SLOTS; slot++) {
|
||||
if (owners[slot] == id) return slot;
|
||||
}
|
||||
for (int slot = 0; slot < SLOTS; slot++) {
|
||||
if (owners[slot] != NO_CONTROLLER && InputDevice.getDevice(owners[slot]) == null) free(slot);
|
||||
}
|
||||
for (int slot = 0; slot < SLOTS; slot++) {
|
||||
if (owners[slot] != NO_CONTROLLER) continue;
|
||||
owners[slot] = id;
|
||||
Log.i(TAG, "\"" + device.getName() + "\" (id " + id + ") is player " + (slot + 1));
|
||||
return slot;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private PadState stateFor(int slot) {
|
||||
return slot == 0 ? state : playerStates[slot];
|
||||
}
|
||||
|
||||
private void free(int slot) {
|
||||
Log.i(TAG, "player " + (slot + 1) + " (id " + owners[slot] + ") left");
|
||||
owners[slot] = NO_CONTROLLER;
|
||||
slotControllers.set(slot, NO_CONTROLLER);
|
||||
if (slot == 0) {
|
||||
state.clear();
|
||||
publish();
|
||||
return;
|
||||
}
|
||||
playerStates[slot].clear();
|
||||
if (playerWriters[slot] != null) {
|
||||
playerWriters[slot].destroy();
|
||||
playerWriters[slot] = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Per event, so the common case - the same pad as last time - is a single compare. */
|
||||
private void noteDevice(InputDevice device) {
|
||||
private void noteDevice(InputDevice device, int slot) {
|
||||
activeControllerId = device.getId();
|
||||
slotControllers.set(slot, device.getId());
|
||||
if (device.getId() == lastDeviceId) return;
|
||||
lastDeviceId = device.getId();
|
||||
statDevice = device.getName();
|
||||
if (seenDevices.add(device.getId())) {
|
||||
Log.i(TAG, String.format(java.util.Locale.ROOT, "first input from \"%s\" (%04x:%04x, id %d, sources 0x%x); ring %s, deck pad %b",
|
||||
Log.i(TAG, String.format(java.util.Locale.ROOT, "first input from \"%s\" (%04x:%04x, id %d, sources 0x%x); slot %d, ring %s, deck pad %b",
|
||||
device.getName(), device.getVendorId(), device.getProductId(), device.getId(), device.getSources(),
|
||||
open ? "open" : "not open yet", SessionState.getDeckPad()));
|
||||
slot, open ? "open" : "not open yet", SessionState.getDeckPad()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,6 +442,23 @@ public final class PadBridge {
|
||||
statButtons = statAxes = statOnScreen = statDropped = 0;
|
||||
}
|
||||
|
||||
private void publish(int slot) {
|
||||
if (slot == 0) {
|
||||
publish();
|
||||
return;
|
||||
}
|
||||
FakeInputWriter player = playerWriters[slot];
|
||||
if (player == null) {
|
||||
player = new FakeInputWriter(fakeInputDir.getAbsolutePath(), slot);
|
||||
if (!player.open()) {
|
||||
statDropped++;
|
||||
return;
|
||||
}
|
||||
playerWriters[slot] = player;
|
||||
}
|
||||
player.writePad(playerStates[slot]);
|
||||
}
|
||||
|
||||
private void publish() {
|
||||
if (!open && !start()) {
|
||||
statDropped++;
|
||||
|
||||
@@ -60,6 +60,7 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
|
||||
Handler(worker.looper).post {
|
||||
gyro.fill(0)
|
||||
FakeInputWriter.writeMotion(SLOT, accel, gyro)
|
||||
SteamTouchDevice.current?.setMotion(accel, gyro)
|
||||
}
|
||||
worker.quitSafely()
|
||||
}
|
||||
@@ -85,6 +86,7 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
|
||||
target[1] = counts(-z * scale)
|
||||
target[2] = counts(y * scale)
|
||||
FakeInputWriter.writeMotion(SLOT, accel, gyro)
|
||||
SteamTouchDevice.current?.setMotion(accel, gyro)
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.LruCache
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* What each touch control is bound to, and the icon it shows - Steam's, as its configurator keeps
|
||||
* them.
|
||||
*
|
||||
* A Steam Input binding is one string: `<action>, <label>, <icon>, <#foreground #background>`, e.g.
|
||||
* `xinput_button A, Jump, ghost_045_move_0400.png, #232323 #E4E4E4`. The icon is a file name looked
|
||||
* up as Steam does (steamui.so): the game's own `TouchMenuIcons` folder, then the binding icon
|
||||
* library `tenfoot/resource/images/library/controller/binding_icons`. Steam draws it filled with
|
||||
* the foreground colour through its own alpha (mask-image) on a button of the background colour.
|
||||
* Steam Link is sent these per control by the client it streams from; here they are read from the
|
||||
* game's touch config, and editing one writes the binding string back, as the configurator does.
|
||||
*/
|
||||
object SteamTouchBindings {
|
||||
|
||||
/** Steam's binding icon colours (the configurator's palette, steamui sp.js), and its defaults. */
|
||||
val PALETTE = listOf(
|
||||
"#FFFFFF", "#E4E4E4", "#AAAAAA", "#787878", "#434343", "#222222", "#0000AD", "#0045AD", "#0074AD",
|
||||
"#00ADAD", "#33AD69", "#00AD3D", "#00AD00", "#48B119", "#74AD00", "#96AD00", "#ADA200", "#AD5D00",
|
||||
"#AD3A00", "#AD0000", "#AD0051", "#AD007F", "#AD00AD", "#6800AD", "#4800AD",
|
||||
)
|
||||
const val DEFAULT_FOREGROUND = "#232323"
|
||||
const val DEFAULT_BACKGROUND = "#E4E4E4"
|
||||
|
||||
/** A parsed binding string. Empty strings for absent fields. */
|
||||
data class Binding(val action: String, val label: String, val icon: String, val foreground: String, val background: String) {
|
||||
fun compose(): String =
|
||||
if (icon.isEmpty() && foreground.isEmpty() && background.isEmpty()) "$action, $label, "
|
||||
else "$action, $label, $icon, ${listOf(foreground, background).filter { it.isNotEmpty() }.joinToString(" ")}"
|
||||
|
||||
val hasIcon get() = icon.isNotEmpty()
|
||||
}
|
||||
|
||||
fun parse(binding: String): Binding {
|
||||
val parts = binding.split(',').map { it.trim() }
|
||||
val colors = parts.getOrNull(3).orEmpty().split(' ').filter { it.startsWith("#") }
|
||||
return Binding(parts.getOrNull(0).orEmpty(), parts.getOrNull(1).orEmpty(), parts.getOrNull(2).orEmpty(),
|
||||
colors.getOrNull(0).orEmpty(), colors.getOrNull(1).orEmpty())
|
||||
}
|
||||
|
||||
/** Where a control's binding lives: its input in the group the preset puts on its source. */
|
||||
data class Ref(val groupId: String, val input: String)
|
||||
|
||||
/** The source and input names a control reads (D-pad: its four directions). */
|
||||
private fun inputsOf(type: Int): List<Pair<String, String>> = when (type) {
|
||||
SteamTouchConfig.A -> listOf("button_diamond" to "button_a")
|
||||
SteamTouchConfig.B -> listOf("button_diamond" to "button_b")
|
||||
SteamTouchConfig.X -> listOf("button_diamond" to "button_x")
|
||||
SteamTouchConfig.Y -> listOf("button_diamond" to "button_y")
|
||||
SteamTouchConfig.SELECT -> listOf("switch" to "button_menu")
|
||||
SteamTouchConfig.START -> listOf("switch" to "button_escape")
|
||||
SteamTouchConfig.BUMPER_LEFT -> listOf("switch" to "left_bumper")
|
||||
SteamTouchConfig.BUMPER_RIGHT -> listOf("switch" to "right_bumper")
|
||||
SteamTouchConfig.TRIGGER_LEFT -> listOf("left_trigger" to "click")
|
||||
SteamTouchConfig.TRIGGER_RIGHT -> listOf("right_trigger" to "click")
|
||||
SteamTouchConfig.JOYSTICK_LEFT_BUTTON -> listOf("joystick" to "click")
|
||||
SteamTouchConfig.JOYSTICK_RIGHT_BUTTON -> listOf("right_joystick" to "click")
|
||||
SteamTouchConfig.MACRO_1_FINGER -> listOf("switch" to "button_macro_1finger")
|
||||
SteamTouchConfig.MACRO_2_FINGER -> listOf("switch" to "button_macro_2finger")
|
||||
SteamTouchConfig.DPAD -> DPAD_INPUTS.map { "dpad" to it }
|
||||
in SteamTouchConfig.MACRO_0..SteamTouchConfig.MACRO_0 + 7 -> listOf("switch" to "button_macro${type - SteamTouchConfig.MACRO_0}")
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
/** D-pad directions, in the order [refsFor] returns them: up, down, left, right. */
|
||||
val DPAD_INPUTS = listOf("dpad_north", "dpad_south", "dpad_west", "dpad_east")
|
||||
|
||||
/**
|
||||
* Where control [type]'s bindings are for preset [preset] (the action set less one) and its
|
||||
* active [layers]: a layer's group wins over the base's for the same source.
|
||||
*/
|
||||
fun refsFor(mappings: KeyValues.Node, type: Int, preset: Int, layers: List<Int> = emptyList()): List<Ref?> {
|
||||
val presets = mappings.children("preset").associateBy { it.string("id")?.toIntOrNull() ?: -1 }
|
||||
val stack = layers.reversed().mapNotNull { presets[it - 1] } + listOfNotNull(presets[preset] ?: presets.values.firstOrNull())
|
||||
return inputsOf(type).map { (source, input) ->
|
||||
stack.firstNotNullOfOrNull { p -> groupFor(p, source)?.let { Ref(it, input) } }
|
||||
}
|
||||
}
|
||||
|
||||
private fun groupFor(preset: KeyValues.Node, source: String): String? =
|
||||
preset.child("group_source_bindings")?.entries?.firstOrNull { (_, v) ->
|
||||
val parts = (v as? String)?.split(' ') ?: return@firstOrNull false
|
||||
parts.getOrNull(0) == source && parts.getOrNull(1) == "active" && !parts.contains("modeshift")
|
||||
}?.first
|
||||
|
||||
/** The binding at [ref]: its first activator's first binding (Full_Press when there is one). */
|
||||
fun bindingAt(mappings: KeyValues.Node, ref: Ref): Binding? {
|
||||
val group = mappings.children("group").firstOrNull { it.string("id") == ref.groupId } ?: return null
|
||||
val activators = group.child("inputs")?.child(ref.input)?.child("activators") ?: return null
|
||||
val activator = activators.child("Full_Press") ?: activators.entries.firstNotNullOfOrNull { it.second as? KeyValues.Node }
|
||||
return activator?.child("bindings")?.string("binding")?.let(::parse)
|
||||
}
|
||||
|
||||
/** A short glyph for a binding with no icon or label: what it presses. */
|
||||
fun glyph(binding: Binding?): String? {
|
||||
val words = binding?.action?.split(' ') ?: return null
|
||||
val arg = words.getOrNull(1)?.uppercase() ?: return null
|
||||
return when (words[0]) {
|
||||
"xinput_button" -> XINPUT_GLYPHS[arg] ?: arg
|
||||
"key_press" -> KEY_GLYPHS[arg] ?: arg.take(4)
|
||||
"mouse_button" -> when (arg) { "LEFT" -> "LMB"; "RIGHT" -> "RMB"; "MIDDLE" -> "MMB"; else -> "M" + arg.take(2) }
|
||||
"mouse_wheel" -> if (arg.contains("UP")) "▲" else "▼"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private val XINPUT_GLYPHS = mapOf(
|
||||
"SHOULDER_LEFT" to "LB", "SHOULDER_RIGHT" to "RB", "TRIGGER_LEFT" to "LT", "TRIGGER_RIGHT" to "RT",
|
||||
"JOYSTICK_LEFT" to "L3", "JOYSTICK_RIGHT" to "R3", "START" to "☰", "SELECT" to "⧉", "BACK" to "⧉",
|
||||
"DPAD_UP" to "▲", "DPAD_DOWN" to "▼", "DPAD_LEFT" to "◀", "DPAD_RIGHT" to "▶",
|
||||
)
|
||||
private val KEY_GLYPHS = mapOf(
|
||||
"RETURN" to "⏎", "SPACE" to "␣", "ESCAPE" to "Esc", "TAB" to "Tab", "BACKSPACE" to "⌫",
|
||||
"LEFT_SHIFT" to "⇧", "RIGHT_SHIFT" to "⇧", "LEFT_CONTROL" to "Ctrl", "RIGHT_CONTROL" to "Ctrl",
|
||||
"LEFT_ALT" to "Alt", "RIGHT_ALT" to "Alt", "UP_ARROW" to "↑", "DOWN_ARROW" to "↓",
|
||||
"LEFT_ARROW" to "←", "RIGHT_ARROW" to "→",
|
||||
)
|
||||
|
||||
// ---- Writing a binding back ----
|
||||
|
||||
/**
|
||||
* Rewrites every binding of [ref]'s input in config [text] (VDF) with [change], keeping the rest
|
||||
* of the file byte for byte. Returns the new text, or null when the input is not there.
|
||||
*/
|
||||
fun rewrite(text: String, ref: Ref, change: (Binding) -> Binding): String? {
|
||||
val tokens = Vdf.tokens(text)
|
||||
val group = Vdf.blocks(tokens, 0, tokens.size, "group").firstOrNull { (s, e) ->
|
||||
Vdf.value(tokens, s, e, "id") == ref.groupId
|
||||
} ?: return null
|
||||
val inputs = Vdf.blocks(tokens, group.first, group.second, "inputs").firstOrNull() ?: return null
|
||||
val input = Vdf.blocks(tokens, inputs.first, inputs.second, ref.input).firstOrNull() ?: return null
|
||||
val targets = (input.first until input.second).filter { i ->
|
||||
tokens[i].text.equals("binding", true) && tokens[i].quoted && i + 1 < input.second && tokens[i + 1].quoted
|
||||
}.map { tokens[it + 1] }
|
||||
if (targets.isEmpty()) return null
|
||||
val out = StringBuilder(text)
|
||||
for (t in targets.sortedByDescending { it.start }) out.replace(t.start, t.end, change(parse(t.text)).compose())
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
/** A VDF tokenizer that keeps where each token sits, for edits that leave the rest untouched. */
|
||||
private object Vdf {
|
||||
class Token(val text: String, val quoted: Boolean, val start: Int, val end: Int)
|
||||
|
||||
fun tokens(text: String): List<Token> {
|
||||
val out = mutableListOf<Token>()
|
||||
var i = 0
|
||||
while (i < text.length) {
|
||||
val c = text[i]
|
||||
when {
|
||||
c.isWhitespace() -> i++
|
||||
c == '/' && i + 1 < text.length && text[i + 1] == '/' -> while (i < text.length && text[i] != '\n') i++
|
||||
c == '{' || c == '}' -> { out += Token(c.toString(), false, i, i + 1); i++ }
|
||||
c == '"' -> {
|
||||
val start = i + 1
|
||||
i++
|
||||
while (i < text.length && text[i] != '"') { if (text[i] == '\\') i++; i++ }
|
||||
out += Token(text.substring(start, minOf(i, text.length)), true, start, minOf(i, text.length))
|
||||
i++
|
||||
}
|
||||
else -> {
|
||||
val start = i
|
||||
while (i < text.length && !text[i].isWhitespace() && text[i] != '{' && text[i] != '}' && text[i] != '"') i++
|
||||
out += Token(text.substring(start, i), false, start, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The bodies (token index ranges inside the braces) of direct children named [key] of the
|
||||
* block spanning [from, to). */
|
||||
fun blocks(tokens: List<Token>, from: Int, to: Int, key: String): List<Pair<Int, Int>> {
|
||||
val found = mutableListOf<Pair<Int, Int>>()
|
||||
var i = from
|
||||
var depth = 0
|
||||
while (i < to) {
|
||||
val t = tokens[i]
|
||||
if (t.text == "{" && !t.quoted) depth++
|
||||
else if (t.text == "}" && !t.quoted) depth--
|
||||
else if (depth == 0 && t.quoted && t.text.equals(key, true) && i + 1 < to && tokens[i + 1].text == "{" && !tokens[i + 1].quoted) {
|
||||
val open = i + 1
|
||||
var d = 0
|
||||
var j = open
|
||||
while (j < to) {
|
||||
if (tokens[j].text == "{" && !tokens[j].quoted) d++
|
||||
if (tokens[j].text == "}" && !tokens[j].quoted) { d--; if (d == 0) break }
|
||||
j++
|
||||
}
|
||||
found += (open + 1) to j
|
||||
i = j + 1
|
||||
continue
|
||||
}
|
||||
i++
|
||||
}
|
||||
// The whole file is one top-level block ("controller_mappings"): look inside it too.
|
||||
if (found.isEmpty() && from == 0) {
|
||||
val top = tokens.indexOfFirst { it.text == "{" && !it.quoted }
|
||||
if (top > 0) {
|
||||
var d = 0
|
||||
var j = top
|
||||
while (j < to) {
|
||||
if (tokens[j].text == "{" && !tokens[j].quoted) d++
|
||||
if (tokens[j].text == "}" && !tokens[j].quoted) { d--; if (d == 0) break }
|
||||
j++
|
||||
}
|
||||
return blocks(tokens, top + 1, j, key)
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/** A direct key-value of the block spanning [from, to). */
|
||||
fun value(tokens: List<Token>, from: Int, to: Int, key: String): String? {
|
||||
var depth = 0
|
||||
var i = from
|
||||
while (i < to) {
|
||||
val t = tokens[i]
|
||||
if (t.text == "{" && !t.quoted) depth++
|
||||
else if (t.text == "}" && !t.quoted) depth--
|
||||
else if (depth == 0 && t.quoted && t.text.equals(key, true) && i + 1 < to && tokens[i + 1].quoted) return tokens[i + 1].text
|
||||
i++
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Icons ----
|
||||
|
||||
private val cache = object : LruCache<String, Bitmap>(24 * 1024 * 1024) {
|
||||
override fun sizeOf(key: String, value: Bitmap) = value.byteCount
|
||||
}
|
||||
private val missing = HashSet<String>()
|
||||
|
||||
fun libraryDir(context: Context) = File(SteamTouchConfig.steamRoot(context), "tenfoot/resource/images/library/controller/binding_icons")
|
||||
|
||||
/** The game's own icons folder (`TouchMenuIcons` in its install directory), when it has one. */
|
||||
fun gameIconsDir(context: Context, appId: Int): File? = try {
|
||||
val manifest = File(SteamTouchConfig.steamRoot(context), "steamapps/appmanifest_$appId.acf")
|
||||
val installDir = KeyValues.parse(manifest.readText()).let { it.child("AppState") ?: it }.string("installdir")
|
||||
installDir?.let { File(SteamTouchConfig.steamRoot(context), "steamapps/common/$it/TouchMenuIcons") }?.takeIf { it.isDirectory }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
fun iconFile(context: Context, appId: Int, name: String): File? {
|
||||
if (name.isEmpty() || name.contains('/')) return null
|
||||
gameIconsDir(context, appId)?.let { File(it, name) }?.takeIf { it.isFile }?.let { return it }
|
||||
File(libraryDir(context), name).takeIf { it.isFile }?.let { return it }
|
||||
return File(SteamTouchConfig.steamRoot(context), "steamui/images/controller/$name").takeIf { it.isFile }
|
||||
}
|
||||
|
||||
/** An icon, decoded at about [size] px. Blocking (reads a file); cached. */
|
||||
fun icon(context: Context, appId: Int, name: String, size: Int = 128): Bitmap? {
|
||||
val key = "$appId/$name/$size"
|
||||
cache.get(key)?.let { return it }
|
||||
if (key in missing) return null
|
||||
val file = iconFile(context, appId, name)
|
||||
val bitmap = file?.let {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(it.path, bounds)
|
||||
var sample = 1
|
||||
while (bounds.outWidth / (sample * 2) >= size) sample *= 2
|
||||
BitmapFactory.decodeFile(it.path, BitmapFactory.Options().apply { inSampleSize = sample })
|
||||
}
|
||||
if (bitmap == null) missing += key else cache.put(key, bitmap)
|
||||
return bitmap
|
||||
}
|
||||
|
||||
fun cachedIcon(appId: Int, name: String, size: Int = 128): Bitmap? = cache.get("$appId/$name/$size")
|
||||
|
||||
/** Icon names for the picker: the game's own first, then Steam's library by category. */
|
||||
fun iconNames(context: Context, appId: Int): List<String> {
|
||||
val own = gameIconsDir(context, appId)?.list()?.filter { it.endsWith(".png", true) }?.sorted().orEmpty()
|
||||
val library = libraryDir(context).list()?.filter { it.endsWith(".png", true) && it != "special_blank.png" }?.sorted().orEmpty()
|
||||
return own + library.filter { it !in own }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
|
||||
/**
|
||||
* Steam's touch configs, read and written where Steam keeps them.
|
||||
*
|
||||
* A touch config is a Steam Input config (`controller_mobile_touch`) like any other - bindings per
|
||||
* action set, synced to the account, shared as community configs - with one extra key,
|
||||
* `touch_layout`: where each on-screen control sits, per action set, as a hex-encoded
|
||||
* `CVirtualControllerLayouts` protobuf (steammessages_virtualcontroller.proto). Steam Link is
|
||||
* handed both by the client it streams from; here the client tells the device only the app and
|
||||
* action set it is on (SteamTouchDevice), so the config is found on disk: the file the client's
|
||||
* console log says it loaded for the touch controller, else the app's entry in
|
||||
* `configset_controller_mobile_touch.vdf`. With no layout in it, controls go where Steam Link puts
|
||||
* them by default. Saving a layout writes `touch_layout` into the app's autosave config, as the
|
||||
* client does when Steam Link saves one; Steam Cloud picks that file up like any autosave.
|
||||
* (docs/development/steam-touch-controller.md.)
|
||||
*/
|
||||
object SteamTouchConfig {
|
||||
private const val TAG = "SteamTouch"
|
||||
|
||||
// EControllerElementType.
|
||||
const val THUMB = 0
|
||||
const val STEAM = 1
|
||||
const val JOYSTICK_LEFT = 2
|
||||
const val JOYSTICK_LEFT_BUTTON = 3
|
||||
const val JOYSTICK_RIGHT = 4
|
||||
const val JOYSTICK_RIGHT_BUTTON = 5
|
||||
const val DPAD = 6
|
||||
const val A = 7
|
||||
const val B = 8
|
||||
const val X = 9
|
||||
const val Y = 10
|
||||
const val SELECT = 11
|
||||
const val START = 12
|
||||
const val TRIGGER_LEFT = 13
|
||||
const val TRIGGER_RIGHT = 14
|
||||
const val BUMPER_LEFT = 15
|
||||
const val BUMPER_RIGHT = 16
|
||||
const val MACRO_0 = 17 // 17..24 = macros 0-7
|
||||
const val TRACKPAD_CENTER = 25
|
||||
const val TRACKPAD_LEFT = 26
|
||||
const val TRACKPAD_RIGHT = 27
|
||||
const val KEYBOARD = 28
|
||||
const val MACRO_1_FINGER = 30
|
||||
const val MACRO_2_FINGER = 31
|
||||
const val MAGNIFYING_GLASS = 29
|
||||
const val PASTE = 34
|
||||
|
||||
/** Controls a layout may place though no binding asks for them (Steam Link's tray). */
|
||||
val OPTIONAL = setOf(PASTE)
|
||||
|
||||
/** A control's place: centre as a fraction of the screen, and its scale. */
|
||||
data class Element(val type: Int, val visible: Boolean, val x: Float, val y: Float, val xScale: Float = 1f, val yScale: Float = 1f)
|
||||
|
||||
data class Layout(val actionSet: Int, val elements: List<Element>, val color: FloatArray?, val version: Int?)
|
||||
|
||||
/** A decoded `touch_layout`; [rest] is every other top-level field, written back untouched. */
|
||||
class Layouts(val layouts: List<Layout>, val rest: ByteArray) {
|
||||
fun forActionSet(id: Int): Layout? = layouts.firstOrNull { it.actionSet == id }
|
||||
|
||||
/** The per-game options Steam Link keeps beside the layouts (CVirtualControllerLayouts
|
||||
* fields 2-4): input mode, mouse mode, trackpad sensitivity. */
|
||||
val options: Options get() = Options.decode(rest)
|
||||
}
|
||||
|
||||
/** EInputMode: 1 mouse, 2 controller, 3 both. EMouseMode: 2 absolute cursor (direct touch),
|
||||
* 3 touch, 4 relative (trackpad); 0 = not set. */
|
||||
data class Options(val inputMode: Int = INPUT_CONTROLLER, val mouseMode: Int = 0, val trackpadSensitivity: Float = 1f) {
|
||||
/** [rest] with these options in place of its own fields 2-4, the rest kept as it was. */
|
||||
fun encodeInto(rest: ByteArray): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
val p = Proto(rest)
|
||||
while (p.more()) {
|
||||
val start = p.pos
|
||||
val (field, wire) = p.key()
|
||||
p.skip(wire)
|
||||
if (field !in 2..4) out.write(rest, start, p.pos - start)
|
||||
}
|
||||
tag(out, 2, 0); varint(out, inputMode.toLong())
|
||||
if (mouseMode != 0) { tag(out, 3, 0); varint(out, mouseMode.toLong()) }
|
||||
tag(out, 4, 5); fixed(out, trackpadSensitivity)
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun decode(rest: ByteArray): Options {
|
||||
var input = INPUT_CONTROLLER
|
||||
var mouse = 0
|
||||
var sensitivity = 1f
|
||||
val p = Proto(rest)
|
||||
while (p.more()) {
|
||||
val (field, wire) = p.key()
|
||||
when {
|
||||
field == 2 && wire == 0 -> input = p.varint().toInt()
|
||||
field == 3 && wire == 0 -> mouse = p.varint().toInt()
|
||||
field == 4 && wire == 5 -> sensitivity = p.float()
|
||||
else -> p.skip(wire)
|
||||
}
|
||||
}
|
||||
return Options(input, mouse, sensitivity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const val INPUT_MOUSE = 1
|
||||
const val INPUT_CONTROLLER = 2
|
||||
const val INPUT_BOTH = 3
|
||||
const val MOUSE_ABSOLUTE = 2
|
||||
const val MOUSE_TOUCH = 3
|
||||
const val MOUSE_RELATIVE = 4
|
||||
|
||||
/** The config's action sets in preset order: id as the device is told (preset id + 1) and title. */
|
||||
fun actionSets(config: Config): List<Pair<Int, String>> {
|
||||
val mappings = config.mappings ?: return listOf(1 to "Default")
|
||||
val titles = mappings.child("actions")?.entries?.mapNotNull { (key, v) ->
|
||||
(v as? KeyValues.Node)?.let { key to (it.string("title") ?: key) }
|
||||
}?.toMap().orEmpty()
|
||||
val sets = mappings.children("preset").mapNotNull { preset ->
|
||||
val id = preset.string("id")?.toIntOrNull() ?: return@mapNotNull null
|
||||
val name = preset.string("name") ?: ""
|
||||
(id + 1) to (titles[name]?.takeUnless { it.startsWith("#") } ?: name.ifEmpty { "Set ${id + 1}" })
|
||||
}.sortedBy { it.first }
|
||||
return sets.ifEmpty { listOf(1 to "Default") }
|
||||
}
|
||||
|
||||
/** A config as the overlay needs it. */
|
||||
class Config(
|
||||
val file: File?,
|
||||
val text: String?,
|
||||
val layouts: Layouts?,
|
||||
/** Bound controls per preset (action set id - 1). */
|
||||
val available: Map<Int, Set<Int>>,
|
||||
/** The parsed config, for its bindings (SteamTouchBindings). */
|
||||
val mappings: KeyValues.Node? = null,
|
||||
) {
|
||||
/** The config's own title, as Steam Link's tray shows it ("Default", "Gamepad"…). */
|
||||
val title: String? get() = mappings?.string("title")?.let { t ->
|
||||
if (t.startsWith("#")) mappings.child("localization")?.child("english")?.string("title") ?: "Default" else t
|
||||
}
|
||||
|
||||
/** The controls to show for [actionSet] (as the device is told it) and [layers]. */
|
||||
fun availableFor(actionSet: Int, layers: List<Int>): Set<Int> {
|
||||
val base = available[presetOf(actionSet)] ?: available.values.firstOrNull() ?: DEFAULT_SET
|
||||
return layers.fold(base) { set, layer -> set + (available[presetOf(layer)] ?: emptySet()) } + ALWAYS
|
||||
}
|
||||
}
|
||||
|
||||
/** Big Picture's own config uses Steam's basic UI bindings: the default set. */
|
||||
private val DEFAULT_SET = setOf(DPAD, A, B, X, Y, SELECT, START)
|
||||
private val ALWAYS = setOf(STEAM, THUMB, KEYBOARD)
|
||||
|
||||
/** The layout entry for the action set the device was told about (the client counts from 1,
|
||||
* 0 for an app with none). */
|
||||
fun layoutIdOf(actionSet: Int) = if (actionSet <= 0) 1 else actionSet
|
||||
private fun presetOf(actionSet: Int) = layoutIdOf(actionSet) - 1
|
||||
|
||||
// ---- Where Steam keeps them ----
|
||||
|
||||
/** A game's name from its app manifest, when it is installed. */
|
||||
fun gameName(context: Context, appId: Int): String? = try {
|
||||
val kv = KeyValues.parse(File(steamRoot(context), "steamapps/appmanifest_$appId.acf").readText())
|
||||
(kv.child("AppState") ?: kv).string("name")
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
fun steamRoot(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
|
||||
|
||||
fun configDir(context: Context): File? {
|
||||
val base = File(steamRoot(context), "steamapps/common/Steam Controller Configs")
|
||||
val account = accountId(context)
|
||||
if (account != null) File(base, "$account/config").takeIf { it.isDirectory }?.let { return it }
|
||||
return base.listFiles()?.filter { it.name.all(Char::isDigit) && File(it, "config").isDirectory }
|
||||
?.maxByOrNull { it.lastModified() }?.let { File(it, "config") }
|
||||
}
|
||||
|
||||
/** The signed-in account (loginusers.vdf's MostRecent) as a 32-bit account id. */
|
||||
private fun accountId(context: Context): Long? = try {
|
||||
val users = KeyValues.parse(File(steamRoot(context), "config/loginusers.vdf").readText())
|
||||
val node = users.child("users")
|
||||
val entries = node?.entries?.mapNotNull { (k, v) -> (v as? KeyValues.Node)?.let { k to it } }.orEmpty()
|
||||
val chosen = entries.firstOrNull { it.second.string("MostRecent") == "1" } ?: entries.firstOrNull()
|
||||
chosen?.first?.toLongOrNull()?.minus(76561197960265728L)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/** The config the client has for app [appId] on the touch controller. Blocking: reads files. */
|
||||
fun load(context: Context, appId: Int): Config {
|
||||
val dir = configDir(context)
|
||||
// The app's own saved config is what the client loads when there is one (its configset
|
||||
// entry); otherwise what it logged loading - a template, an official or a workshop config.
|
||||
val file = dir?.let { autosaveConfig(it, appId) }
|
||||
?: loggedConfig(context, appId)?.takeIf { isTouchConfig(it) }
|
||||
?: dir?.let { configsetConfig(context, it, appId) }
|
||||
if (file == null) {
|
||||
Log.i(TAG, "steam touch: app $appId has no touch config on disk; default layout")
|
||||
return Config(null, null, null, emptyMap())
|
||||
}
|
||||
return try {
|
||||
val text = file.readText()
|
||||
val kv = KeyValues.parse(text.removePrefix(""))
|
||||
val mappings = kv.child("controller_mappings") ?: kv
|
||||
val layouts = mappings.string("touch_layout")?.let { hex -> decodeLayouts(hexToBytes(hex)) }
|
||||
Log.i(TAG, "steam touch: app $appId uses ${file.path} (${layouts?.layouts?.size ?: 0} layouts)")
|
||||
Config(file, text, layouts, availability(mappings), mappings)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "steam touch: ${file.path} unreadable: $e")
|
||||
Config(file, null, null, emptyMap())
|
||||
}
|
||||
}
|
||||
|
||||
private fun isTouchConfig(file: File) = file.isFile &&
|
||||
file.bufferedReader().use { r -> generateSequence { r.readLine() }.take(40).any { it.contains("controller_mobile_touch") } }
|
||||
|
||||
/** From the client's console log: the last config it loaded for [appId] on the touch controller
|
||||
* (`Loaded Config for ... App ID <n>, Controller <i>: <path>`, where controller i reported
|
||||
* type 43). Guest paths come back as the app's own. */
|
||||
private fun loggedConfig(context: Context, appId: Int): File? = try {
|
||||
val log = File(steamRoot(context), "logs/console_log.txt")
|
||||
val tail = RandomAccessFile(log, "r").use { raf ->
|
||||
val start = maxOf(0L, raf.length() - 2_000_000L)
|
||||
raf.seek(start)
|
||||
val bytes = ByteArray((raf.length() - start).toInt())
|
||||
raf.readFully(bytes)
|
||||
String(bytes)
|
||||
}
|
||||
val touchIndex = Regex("""!! Controller (\d+) attributes:\s*\n\s*Type: 43""").findAll(tail).lastOrNull()?.groupValues?.get(1)
|
||||
if (touchIndex == null) null else {
|
||||
val loaded = Regex("""Loaded Config for [^\n]*? Path for App ID $appId, Controller $touchIndex: ([^\n]+)""")
|
||||
.findAll(tail).lastOrNull()?.groupValues?.get(1)?.trim()
|
||||
loaded?.let { guestPath -> File(LinuxRuntime.rootDir(context), guestPath.trimStart('/').replace("//", "/")) }
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun autosaveConfig(dir: File, appId: Int): File? = try {
|
||||
val set = KeyValues.parse(File(dir, CONFIGSET).readText()).child("controller_config")?.child(appId.toString())
|
||||
if (set?.string("autosave") == "1") File(dir, "$appId/controller_mobile_touch.vdf").takeIf { it.isFile } else null
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/** The app's entry in configset_controller_mobile_touch.vdf. */
|
||||
private fun configsetConfig(context: Context, dir: File, appId: Int): File? = try {
|
||||
val set = KeyValues.parse(File(dir, CONFIGSET).readText()).child("controller_config")?.child(appId.toString())
|
||||
when {
|
||||
set == null -> null
|
||||
set.string("autosave") == "1" -> File(dir, "$appId/controller_mobile_touch.vdf")
|
||||
set.string("template") != null -> File(steamRoot(context), "controller_base/templates/" + set.string("template"))
|
||||
else -> null
|
||||
}?.takeIf { it.isFile }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private const val CONFIGSET = "configset_controller_mobile_touch.vdf"
|
||||
|
||||
// ---- What a config binds ----
|
||||
|
||||
/** Per preset, the controls its active sources bind - what Steam Link shows (UpdateElementAvailability). */
|
||||
private fun availability(mappings: KeyValues.Node): Map<Int, Set<Int>> {
|
||||
val groups = mappings.children("group").associateBy({ it.string("id") ?: "" }) { group ->
|
||||
group.child("inputs")?.entries?.filter { (_, v) -> (v as? KeyValues.Node)?.let(::hasBinding) == true }
|
||||
?.map { it.first }?.toSet().orEmpty()
|
||||
}
|
||||
return mappings.children("preset").associate { preset ->
|
||||
val id = preset.string("id")?.toIntOrNull() ?: 0
|
||||
val set = mutableSetOf<Int>()
|
||||
preset.child("group_source_bindings")?.entries?.forEach { (groupId, value) ->
|
||||
val parts = (value as? String)?.split(' ') ?: return@forEach
|
||||
if (parts.getOrNull(1) != "active" || parts.contains("modeshift")) return@forEach
|
||||
val bound = groups[groupId].orEmpty()
|
||||
when (parts[0]) {
|
||||
"button_diamond" -> mapOf("button_a" to A, "button_b" to B, "button_x" to X, "button_y" to Y)
|
||||
.forEach { (input, element) -> if (input in bound) set += element }
|
||||
"dpad" -> set += DPAD
|
||||
"joystick" -> { set += JOYSTICK_LEFT; if ("click" in bound) set += JOYSTICK_LEFT_BUTTON }
|
||||
"right_joystick" -> { set += JOYSTICK_RIGHT; if ("click" in bound) set += JOYSTICK_RIGHT_BUTTON }
|
||||
"left_trigger" -> set += TRIGGER_LEFT
|
||||
"right_trigger" -> set += TRIGGER_RIGHT
|
||||
"left_trackpad" -> set += TRACKPAD_LEFT
|
||||
"right_trackpad" -> set += TRACKPAD_RIGHT
|
||||
"center_trackpad" -> set += TRACKPAD_CENTER
|
||||
"switch" -> bound.forEach { input ->
|
||||
when (input) {
|
||||
"button_escape" -> set += START
|
||||
"button_menu" -> set += SELECT
|
||||
"left_bumper" -> set += BUMPER_LEFT
|
||||
"right_bumper" -> set += BUMPER_RIGHT
|
||||
"button_macro_1finger" -> set += MACRO_1_FINGER
|
||||
"button_macro_2finger" -> set += MACRO_2_FINGER
|
||||
else -> Regex("button_macro(\\d)").matchEntire(input)?.let { set += MACRO_0 + it.groupValues[1].toInt() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
id to set.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasBinding(input: KeyValues.Node): Boolean =
|
||||
input.child("activators")?.entries?.any { (_, activator) ->
|
||||
(activator as? KeyValues.Node)?.child("bindings")?.entries?.any { (k, v) -> k == "binding" && (v as? String)?.isNotBlank() == true } == true
|
||||
} == true
|
||||
|
||||
// ---- Steam Link's defaults ----
|
||||
|
||||
/** Where Steam Link puts a control with no layout (CVirtualController::BInitializeDefaultElement,
|
||||
* on a 1280x720 screen), and the same scheme for the controls its table leaves out, made room
|
||||
* for when a config binds sticks. */
|
||||
fun defaultElement(type: Int, available: Set<Int>): Element? {
|
||||
val leftStick = JOYSTICK_LEFT in available
|
||||
val rightStick = JOYSTICK_RIGHT in available
|
||||
val faceY = if (rightStick) -0.30f else 0f
|
||||
val p: Pair<Float, Float> = when (type) {
|
||||
THUMB -> 75f / 1280 to 75f / 720
|
||||
STEAM -> 636f / 1280 to 75f / 720
|
||||
SELECT -> 516f / 1280 to 75f / 720
|
||||
START -> 756f / 1280 to 75f / 720
|
||||
KEYBOARD -> 1205f / 1280 to 75f / 720
|
||||
PASTE -> 1115f / 1280 to 75f / 720
|
||||
DPAD -> if (leftStick) 0.147f to 0.47f else 200f / 1280 to 525f / 720
|
||||
A -> 1083f / 1280 to 607f / 720 + faceY
|
||||
B -> 1163f / 1280 to 527f / 720 + faceY
|
||||
X -> 1003f / 1280 to 527f / 720 + faceY
|
||||
Y -> 1083f / 1280 to 447f / 720 + faceY
|
||||
JOYSTICK_LEFT -> 0.137f to 0.76f
|
||||
JOYSTICK_RIGHT -> 0.85f to 0.76f
|
||||
JOYSTICK_LEFT_BUTTON -> 0.29f to 0.88f
|
||||
JOYSTICK_RIGHT_BUTTON -> 0.71f to 0.88f
|
||||
BUMPER_LEFT -> 0.06f to 0.205f
|
||||
TRIGGER_LEFT -> 0.16f to 0.205f
|
||||
BUMPER_RIGHT -> 0.94f to 0.205f
|
||||
TRIGGER_RIGHT -> 0.84f to 0.205f
|
||||
TRACKPAD_LEFT -> 0.3f to 0.55f
|
||||
TRACKPAD_CENTER -> 0.5f to 0.55f
|
||||
TRACKPAD_RIGHT -> 0.7f to 0.55f
|
||||
MACRO_1_FINGER -> 0.42f to 0.92f
|
||||
MACRO_2_FINGER -> 0.58f to 0.92f
|
||||
in MACRO_0..MACRO_0 + 7 -> (0.3f + 0.057f * (type - MACRO_0)) to 0.22f
|
||||
else -> return null
|
||||
}
|
||||
return Element(type, true, p.first, p.second)
|
||||
}
|
||||
|
||||
/** The controls to draw: the layout's own where it has one, Steam Link's defaults otherwise,
|
||||
* only those the config binds. */
|
||||
fun elementsFor(config: Config, actionSet: Int, layers: List<Int>): List<Element> {
|
||||
val available = config.availableFor(actionSet, layers)
|
||||
val layout = config.layouts?.forActionSet(layoutIdOf(actionSet))
|
||||
val placed = layout?.elements?.associateBy { it.type }.orEmpty()
|
||||
val optional = OPTIONAL.filter { placed[it]?.visible == true && it !in available }
|
||||
return (available + optional).mapNotNull { type ->
|
||||
val own = placed[type]
|
||||
when {
|
||||
own != null && !own.visible -> null
|
||||
own != null -> own
|
||||
else -> defaultElement(type, available)
|
||||
}
|
||||
}.sortedBy { it.type }
|
||||
}
|
||||
|
||||
fun layoutColor(config: Config, actionSet: Int): FloatArray? = config.layouts?.forActionSet(layoutIdOf(actionSet))?.color
|
||||
|
||||
// ---- Saving ----
|
||||
|
||||
/**
|
||||
* Saves [elements] as the layout of [actionSet] in app [appId]'s touch config: into the app's
|
||||
* autosave config (`<appid>/controller_mobile_touch.vdf`, made from the config in use when the
|
||||
* app has none) and selected for the app in the configset, as the client saves a layout Steam
|
||||
* Link sends it. Returns the file written, or null.
|
||||
*/
|
||||
fun saveLayout(
|
||||
context: Context,
|
||||
appId: Int,
|
||||
config: Config,
|
||||
actionSet: Int,
|
||||
elements: List<Element>,
|
||||
color: FloatArray? = null,
|
||||
bindings: Map<SteamTouchBindings.Ref, (SteamTouchBindings.Binding) -> SteamTouchBindings.Binding> = emptyMap(),
|
||||
): File? = saveLayouts(context, appId, config, mapOf(layoutIdOf(actionSet) to elements), color?.let { c -> setOf(layoutIdOf(actionSet)).associateWith { c } } ?: emptyMap(), bindings)
|
||||
|
||||
/**
|
||||
* Saves the layouts of several action sets (by layout id) at once, with their colours, any
|
||||
* binding edits and the per-game [options], into the game's autosave touch config.
|
||||
*/
|
||||
fun saveLayouts(
|
||||
context: Context,
|
||||
appId: Int,
|
||||
config: Config,
|
||||
sets: Map<Int, List<Element>>,
|
||||
colors: Map<Int, FloatArray> = emptyMap(),
|
||||
bindings: Map<SteamTouchBindings.Ref, (SteamTouchBindings.Binding) -> SteamTouchBindings.Binding> = emptyMap(),
|
||||
options: Options? = null,
|
||||
): File? {
|
||||
val dir = configDir(context) ?: return null
|
||||
// Icons, labels and colours live in the binding strings (SteamTouchBindings), as the
|
||||
// configurator writes them.
|
||||
val source = bindings.entries.fold(config.text ?: return null) { text, (ref, change) ->
|
||||
SteamTouchBindings.rewrite(text, ref, change) ?: text
|
||||
}
|
||||
val target = File(dir, "$appId/controller_mobile_touch.vdf")
|
||||
val old = config.layouts
|
||||
val keep = old?.layouts?.filter { it.actionSet !in sets.keys }.orEmpty()
|
||||
val written = sets.map { (id, elements) ->
|
||||
Layout(id, elements, colors[id] ?: old?.forActionSet(id)?.color ?: floatArrayOf(1f, 1f, 1f, 0.4f), old?.forActionSet(id)?.version)
|
||||
}
|
||||
val rest = old?.rest ?: byteArrayOf(0x10, 0x02) // input_mode = controller
|
||||
val layouts = Layouts((keep + written).sortedBy { it.actionSet }, options?.encodeInto(rest) ?: rest)
|
||||
val hex = bytesToHex(encodeLayouts(layouts))
|
||||
val line = "\t\"touch_layout\"\t\t\"$hex\""
|
||||
val existing = Regex("""\n\s*"touch_layout"\s+"[0-9a-fA-F]*"""")
|
||||
val text = if (existing.containsMatchIn(source)) source.replace(existing, "\n$line")
|
||||
else source.replaceFirst(Regex("""("controller_type"\s+"controller_mobile_touch"[^\n]*)"""), "$1\n$line")
|
||||
if (!text.contains("touch_layout")) return null
|
||||
target.parentFile?.mkdirs()
|
||||
val tmp = File(target.path + ".tmp")
|
||||
tmp.writeText(text)
|
||||
if (!tmp.renameTo(target)) return null
|
||||
selectAutosave(dir, appId)
|
||||
Log.i(TAG, "steam touch: saved layouts ${sets.keys} for app $appId to ${target.path}")
|
||||
return target
|
||||
}
|
||||
|
||||
/** Points the app's configset entry at its autosave config. */
|
||||
private fun selectAutosave(dir: File, appId: Int) {
|
||||
val file = File(dir, CONFIGSET)
|
||||
val text = if (file.isFile) file.readText() else "\"controller_config\"\n{\n}\n"
|
||||
val entry = "\t\"$appId\"\n\t{\n\t\t\"autosave\"\t\t\"1\"\n\t}"
|
||||
val block = Regex("""\n\s*"$appId"\s*\{[^}]*\}""")
|
||||
val next = if (block.containsMatchIn(text)) text.replace(block, "\n$entry")
|
||||
else text.substring(0, text.lastIndexOf('}')).trimEnd() + "\n$entry\n}\n"
|
||||
File(file.path + ".tmp").apply { writeText(next) }.renameTo(file)
|
||||
}
|
||||
|
||||
// ---- touch_layout: CVirtualControllerLayouts ----
|
||||
|
||||
fun decodeLayouts(bytes: ByteArray): Layouts {
|
||||
val layouts = mutableListOf<Layout>()
|
||||
val rest = ByteArrayOutputStream()
|
||||
val p = Proto(bytes)
|
||||
while (p.more()) {
|
||||
val start = p.pos
|
||||
val (field, wire) = p.key()
|
||||
if (field == 1 && wire == 2) layouts += decodeLayout(p.bytes())
|
||||
else { p.skip(wire); rest.write(bytes, start, p.pos - start) }
|
||||
}
|
||||
return Layouts(layouts, rest.toByteArray())
|
||||
}
|
||||
|
||||
private fun decodeLayout(bytes: ByteArray): Layout {
|
||||
var actionSet = 0
|
||||
var version: Int? = null
|
||||
var color: FloatArray? = null
|
||||
val elements = mutableListOf<Element>()
|
||||
val p = Proto(bytes)
|
||||
while (p.more()) {
|
||||
val (field, wire) = p.key()
|
||||
when {
|
||||
field == 1 && wire == 0 -> version = p.varint().toInt()
|
||||
field == 2 && wire == 0 -> actionSet = p.varint().toInt()
|
||||
field == 4 && wire == 2 -> elements += decodeElement(p.bytes())
|
||||
field == 5 && wire == 2 -> color = decodeColor(p.bytes())
|
||||
else -> p.skip(wire)
|
||||
}
|
||||
}
|
||||
return Layout(actionSet, elements, color, version)
|
||||
}
|
||||
|
||||
private fun decodeElement(bytes: ByteArray): Element {
|
||||
var type = -1
|
||||
var visible = false
|
||||
var x = 0f
|
||||
var y = 0f
|
||||
var sx = 1f
|
||||
var sy = 1f
|
||||
val p = Proto(bytes)
|
||||
while (p.more()) {
|
||||
val (field, wire) = p.key()
|
||||
when {
|
||||
field == 1 && wire == 0 -> type = p.varint().toInt()
|
||||
field == 2 && wire == 0 -> visible = p.varint() != 0L
|
||||
field == 3 && wire == 5 -> x = p.float()
|
||||
field == 4 && wire == 5 -> y = p.float()
|
||||
field == 5 && wire == 5 -> sx = p.float()
|
||||
field == 6 && wire == 5 -> sy = p.float()
|
||||
else -> p.skip(wire)
|
||||
}
|
||||
}
|
||||
return Element(type, visible, x, y, sx, sy)
|
||||
}
|
||||
|
||||
private fun decodeColor(bytes: ByteArray): FloatArray {
|
||||
val c = floatArrayOf(1f, 1f, 1f, 1f)
|
||||
val p = Proto(bytes)
|
||||
while (p.more()) {
|
||||
val (field, wire) = p.key()
|
||||
if (field in 1..4 && wire == 5) c[field - 1] = p.float() else p.skip(wire)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
fun encodeLayouts(layouts: Layouts): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
layouts.layouts.forEach { layout ->
|
||||
val l = ByteArrayOutputStream()
|
||||
layout.version?.let { tag(l, 1, 0); varint(l, it.toLong()) }
|
||||
tag(l, 2, 0); varint(l, layout.actionSet.toLong())
|
||||
layout.elements.forEach { e ->
|
||||
val m = ByteArrayOutputStream()
|
||||
tag(m, 1, 0); varint(m, e.type.toLong())
|
||||
tag(m, 2, 0); varint(m, if (e.visible) 1 else 0)
|
||||
tag(m, 3, 5); fixed(m, e.x)
|
||||
tag(m, 4, 5); fixed(m, e.y)
|
||||
tag(m, 5, 5); fixed(m, e.xScale)
|
||||
tag(m, 6, 5); fixed(m, e.yScale)
|
||||
tag(l, 4, 2); bytes(l, m.toByteArray())
|
||||
}
|
||||
layout.color?.let { c ->
|
||||
val m = ByteArrayOutputStream()
|
||||
for (i in 0 until 4) { tag(m, i + 1, 5); fixed(m, c[i]) }
|
||||
tag(l, 5, 2); bytes(l, m.toByteArray())
|
||||
}
|
||||
tag(out, 1, 2); bytes(out, l.toByteArray())
|
||||
}
|
||||
out.write(layouts.rest)
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
private fun tag(out: ByteArrayOutputStream, field: Int, wire: Int) = varint(out, (field shl 3 or wire).toLong())
|
||||
private fun varint(out: ByteArrayOutputStream, value: Long) {
|
||||
var v = value
|
||||
while (true) {
|
||||
if (v and 0x7fL.inv() == 0L) { out.write(v.toInt()); return }
|
||||
out.write((v and 0x7f or 0x80).toInt())
|
||||
v = v ushr 7
|
||||
}
|
||||
}
|
||||
private fun fixed(out: ByteArrayOutputStream, f: Float) {
|
||||
val bits = java.lang.Float.floatToIntBits(f)
|
||||
for (i in 0 until 4) out.write(bits ushr (8 * i) and 0xff)
|
||||
}
|
||||
private fun bytes(out: ByteArrayOutputStream, b: ByteArray) { varint(out, b.size.toLong()); out.write(b) }
|
||||
|
||||
private class Proto(val data: ByteArray) {
|
||||
var pos = 0
|
||||
fun varint(): Long {
|
||||
var shift = 0
|
||||
var result = 0L
|
||||
while (pos < data.size) {
|
||||
val b = data[pos++].toInt() and 0xff
|
||||
result = result or ((b and 0x7f).toLong() shl shift)
|
||||
if (b and 0x80 == 0) break
|
||||
shift += 7
|
||||
}
|
||||
return result
|
||||
}
|
||||
fun float(): Float {
|
||||
var bits = 0
|
||||
for (i in 0 until 4) bits = bits or ((data[pos + i].toInt() and 0xff) shl (8 * i))
|
||||
pos += 4
|
||||
return java.lang.Float.intBitsToFloat(bits)
|
||||
}
|
||||
fun bytes(): ByteArray {
|
||||
val n = varint().toInt()
|
||||
return data.copyOfRange(pos, pos + n).also { pos += n }
|
||||
}
|
||||
fun skip(wire: Int) {
|
||||
when (wire) {
|
||||
0 -> varint()
|
||||
1 -> pos += 8
|
||||
2 -> pos += varint().toInt()
|
||||
5 -> pos += 4
|
||||
else -> pos = data.size
|
||||
}
|
||||
}
|
||||
fun more() = pos < data.size
|
||||
fun key(): Pair<Int, Int> = varint().toInt().let { (it ushr 3) to (it and 7) }
|
||||
}
|
||||
|
||||
fun hexToBytes(hex: String) = ByteArray(hex.length / 2) { hex.substring(2 * it, 2 * it + 2).toInt(16).toByte() }
|
||||
fun bytesToHex(b: ByteArray) = b.joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||
}
|
||||
|
||||
/** Valve's KeyValues text format, as far as controller configs use it. */
|
||||
class KeyValues private constructor() {
|
||||
class Node(val entries: MutableList<Pair<String, Any>> = mutableListOf()) {
|
||||
fun child(key: String): Node? = entries.firstOrNull { it.first.equals(key, true) && it.second is Node }?.second as Node?
|
||||
fun children(key: String): List<Node> = entries.filter { it.first.equals(key, true) && it.second is Node }.map { it.second as Node }
|
||||
fun string(key: String): String? = entries.firstOrNull { it.first.equals(key, true) && it.second is String }?.second as String?
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun parse(text: String): Node {
|
||||
val tokens = tokenize(text)
|
||||
var i = 0
|
||||
fun node(): Node {
|
||||
val n = Node()
|
||||
while (i < tokens.size) {
|
||||
val t = tokens[i++]
|
||||
if (t == "}" ) return n
|
||||
if (t == "{") continue
|
||||
if (i >= tokens.size) break
|
||||
val next = tokens[i]
|
||||
if (next == "{") { i++; n.entries += t to node() } else { i++; n.entries += t to next }
|
||||
}
|
||||
return n
|
||||
}
|
||||
return node()
|
||||
}
|
||||
|
||||
private fun tokenize(text: String): List<String> {
|
||||
val out = mutableListOf<String>()
|
||||
var i = 0
|
||||
while (i < text.length) {
|
||||
val c = text[i]
|
||||
when {
|
||||
c.isWhitespace() -> i++
|
||||
c == '/' && i + 1 < text.length && text[i + 1] == '/' -> while (i < text.length && text[i] != '\n') i++
|
||||
c == '{' || c == '}' -> { out += c.toString(); i++ }
|
||||
c == '"' -> {
|
||||
val sb = StringBuilder()
|
||||
i++
|
||||
while (i < text.length && text[i] != '"') {
|
||||
if (text[i] == '\\' && i + 1 < text.length) {
|
||||
i++
|
||||
sb.append(when (text[i]) { 'n' -> '\n'; 't' -> '\t'; else -> text[i] })
|
||||
} else sb.append(text[i])
|
||||
i++
|
||||
}
|
||||
i++
|
||||
out += sb.toString()
|
||||
}
|
||||
c == '[' -> { while (i < text.length && text[i] != ']') i++; i++ }
|
||||
else -> {
|
||||
val start = i
|
||||
while (i < text.length && !text[i].isWhitespace() && text[i] != '{' && text[i] != '}' && text[i] != '"') i++
|
||||
out += text.substring(start, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,171 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.ByteOrder
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.channels.FileChannel
|
||||
|
||||
/**
|
||||
* Steam's touch controller, as the app's side of it.
|
||||
*
|
||||
* libfakeinput offers the Steam client a HID device with the ids of Steam Link's touch controls
|
||||
* (0000:11fb), and the client builds its own touch controller for it: Steam Input's Mobile Touch
|
||||
* type, with the game's touch config, action sets, community configs and cloud sync all Steam's
|
||||
* (docs/development/steam-touch-controller.md). This class is the file the two share
|
||||
* (fakeinput_steam.cpp: TouchRingFile): the app writes the device's 40-byte input report and
|
||||
* whether it is plugged in; libfakeinput writes back what the client told the device - the app and
|
||||
* action set it is on, its layers, rumble.
|
||||
*/
|
||||
class SteamTouchDevice private constructor(private val buffer: ByteBuffer) {
|
||||
|
||||
/** What the client last told the device. */
|
||||
data class State(
|
||||
val opened: Int,
|
||||
val appId: Int,
|
||||
val actionSet: Int,
|
||||
val layers: List<Int>,
|
||||
val actionSeq: Int,
|
||||
)
|
||||
|
||||
// The input report (Steam Link's CVirtualController, docs/development/steam-touch-controller.md):
|
||||
// bytes 0-7 buttons, 8-15 sticks, 16-27 trackpads, 28-33 accelerometer, 34-39 gyro.
|
||||
private val report = ByteArray(REPORT_BYTES)
|
||||
private var reportSeq = 0L
|
||||
|
||||
/** The controls part of the report: button bits, sticks (LX, LY, RX, RY, already in the report's
|
||||
* units) and trackpads (centre, left, right: x, y each). */
|
||||
@Synchronized
|
||||
fun setControls(buttons: Long, sticks: ShortArray, pads: ShortArray) {
|
||||
val next = report.copyOf()
|
||||
for (i in 0 until 8) next[i] = (buttons ushr (8 * i)).toByte()
|
||||
for (i in 0 until 4) putShort(next, 8 + 2 * i, sticks[i])
|
||||
for (i in 0 until 6) putShort(next, 16 + 2 * i, pads[i])
|
||||
publish(next)
|
||||
}
|
||||
|
||||
/** Motion in the Deck's axes and units (PadMotion), which the touch report shares. */
|
||||
@Synchronized
|
||||
fun setMotion(accel: ShortArray, gyro: ShortArray) {
|
||||
// Sent only while the client asks for it (setting report 0x30, which Steam Link reads as
|
||||
// "gyro on") and the player has not turned motion off - as Steam Link does.
|
||||
if (!plugged || !gyroRequested || !motionEnabled) return
|
||||
val next = report.copyOf()
|
||||
for (i in 0 until 3) {
|
||||
// Accelerometer: the Deck's 1 g = 16384 is the touch report's ±2 g = ±32767 already.
|
||||
putShort(next, 28 + 2 * i, accel[i])
|
||||
// Gyro: the Deck's 2000 °/s = 32768; the touch report's full scale is 1000 °/s
|
||||
// (Steam Link: rad/s × 180/π / 1000 × 32767).
|
||||
putShort(next, 34 + 2 * i, (gyro[i] * 2).coerceIn(-32767, 32767).toShort())
|
||||
}
|
||||
publish(next)
|
||||
}
|
||||
|
||||
/** Whether the client has asked the device for motion (setting report 0x30 non-zero). */
|
||||
val gyroRequested get() = buffer.getInt(OFF_SETTING30) != 0
|
||||
|
||||
/** The player's motion switch (Steam Link's gyroscope option). */
|
||||
@Volatile var motionEnabled = true
|
||||
set(value) {
|
||||
field = value
|
||||
if (!value) synchronized(this) {
|
||||
val next = report.copyOf()
|
||||
for (i in 28 until 40) next[i] = 0
|
||||
publish(next)
|
||||
}
|
||||
}
|
||||
|
||||
private fun putShort(into: ByteArray, at: Int, value: Short) {
|
||||
into[at] = value.toInt().toByte()
|
||||
into[at + 1] = (value.toInt() shr 8).toByte()
|
||||
}
|
||||
|
||||
/** Writes the report under the seqlock libfakeinput reads it with. */
|
||||
private fun publish(next: ByteArray) {
|
||||
if (next.contentEquals(report)) return
|
||||
next.copyInto(report)
|
||||
buffer.putLong(OFF_REPORT_SEQ, ++reportSeq)
|
||||
for (i in 0 until REPORT_BYTES) buffer.put(OFF_REPORT + i, report[i])
|
||||
buffer.putLong(OFF_REPORT_SEQ, ++reportSeq)
|
||||
}
|
||||
|
||||
var plugged: Boolean
|
||||
get() = buffer.getInt(OFF_PLUGGED) != 0
|
||||
set(value) {
|
||||
if (value == plugged) return
|
||||
if (!value) synchronized(this) { publish(ByteArray(REPORT_BYTES)) }
|
||||
buffer.putInt(OFF_PLUGGED, if (value) 1 else 0)
|
||||
Log.i(TAG, "steam touch: " + if (value) "plugged in" else "unplugged")
|
||||
}
|
||||
|
||||
fun setBattery(percent: Int) = buffer.putInt(OFF_BATTERY, percent.coerceIn(0, 100))
|
||||
|
||||
fun state(): State {
|
||||
repeat(8) {
|
||||
val seq = buffer.getLong(OFF_STATE_SEQ)
|
||||
if (seq and 1L != 0L) return@repeat
|
||||
val count = buffer.getInt(OFF_LAYER_COUNT).coerceIn(0, 8)
|
||||
val read = State(
|
||||
opened = buffer.getInt(OFF_OPENED),
|
||||
appId = buffer.getInt(OFF_APPID),
|
||||
actionSet = buffer.getInt(OFF_ACTION_SET),
|
||||
layers = (0 until count).map { buffer.getInt(OFF_LAYERS + 4 * it) },
|
||||
actionSeq = buffer.getInt(OFF_ACTION_SEQ),
|
||||
)
|
||||
if (seq == buffer.getLong(OFF_STATE_SEQ)) return read
|
||||
}
|
||||
return State(0, 0, 0, emptyList(), -1)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SteamTouch"
|
||||
const val REPORT_BYTES = 40
|
||||
private const val SIZE = 256
|
||||
private const val MAGIC = 0x31484354 // TCH1
|
||||
private const val OFF_REPORT_SEQ = 8
|
||||
private const val OFF_REPORT = 16
|
||||
private const val OFF_PLUGGED = 56
|
||||
private const val OFF_BATTERY = 60
|
||||
private const val OFF_STATE_SEQ = 64
|
||||
private const val OFF_OPENED = 72
|
||||
private const val OFF_APPID = 76
|
||||
private const val OFF_ACTION_SET = 80
|
||||
private const val OFF_LAYER_COUNT = 84
|
||||
private const val OFF_LAYERS = 88
|
||||
private const val OFF_SETTING30 = 132
|
||||
private const val OFF_ACTION_SEQ = 136
|
||||
|
||||
@Volatile
|
||||
var current: SteamTouchDevice? = null
|
||||
private set
|
||||
|
||||
/** The shared file in the session's pad directory, made fresh each session. */
|
||||
fun ringFile(fakeInputDir: File) = File(fakeInputDir, "touchctl")
|
||||
|
||||
/** Creates the file for a new session (unplugged) and maps it. */
|
||||
fun prepare(fakeInputDir: File): File? = try {
|
||||
val file = ringFile(fakeInputDir)
|
||||
file.delete()
|
||||
RandomAccessFile(file, "rw").use { raf ->
|
||||
raf.setLength(SIZE.toLong())
|
||||
val map = raf.channel.map(FileChannel.MapMode.READ_WRITE, 0, SIZE.toLong()).order(ByteOrder.LITTLE_ENDIAN)
|
||||
map.putInt(4, 1)
|
||||
map.putInt(0, MAGIC)
|
||||
current = SteamTouchDevice(map)
|
||||
}
|
||||
file.setReadable(true, false)
|
||||
file.setWritable(true, false)
|
||||
file
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "steam touch: no shared file: $e")
|
||||
current = null
|
||||
null
|
||||
}
|
||||
|
||||
fun release() {
|
||||
current?.plugged = false
|
||||
current = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.text.InputType
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
import android.widget.EditText
|
||||
import android.widget.GridView
|
||||
import android.widget.HorizontalScrollView
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Picks a touch control's icon, label and colours the way Steam's configurator does: an icon from
|
||||
* the game's own `TouchMenuIcons` and Steam's binding icon library, a foreground and a background
|
||||
* from Steam's palette. [onPicked] gets the binding with the new fields (the action untouched).
|
||||
*/
|
||||
class SteamTouchIconPicker(
|
||||
private val context: Context,
|
||||
private val host: ViewGroup,
|
||||
private val appId: Int,
|
||||
private val title: String,
|
||||
private val current: SteamTouchBindings.Binding,
|
||||
private val onPicked: (SteamTouchBindings.Binding) -> Unit,
|
||||
) {
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private val loader = Executors.newFixedThreadPool(2)
|
||||
private var icon = current.icon
|
||||
private var foreground = current.foreground.ifEmpty { SteamTouchBindings.DEFAULT_FOREGROUND }
|
||||
private var background = current.background.ifEmpty { SteamTouchBindings.DEFAULT_BACKGROUND }
|
||||
private val density = context.resources.displayMetrics.density
|
||||
|
||||
private fun dp(v: Int) = (v * density).toInt()
|
||||
|
||||
fun show() {
|
||||
// Side by side, for a landscape screen: what is picked on the left, the icons on the right.
|
||||
val root = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL; setPadding(dp(16), dp(8), dp(16), 0) }
|
||||
val left = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL }
|
||||
val label = EditText(context).apply {
|
||||
hint = "Label (optional)"
|
||||
setText(current.label)
|
||||
inputType = InputType.TYPE_CLASS_TEXT
|
||||
setSingleLine()
|
||||
}
|
||||
left.addView(label)
|
||||
val preview = ImageView(context)
|
||||
val names = SteamTouchBindings.iconNames(context, appId)
|
||||
val grid = GridView(context).apply {
|
||||
numColumns = GridView.AUTO_FIT
|
||||
columnWidth = dp(56)
|
||||
stretchMode = GridView.STRETCH_COLUMN_WIDTH
|
||||
verticalSpacing = dp(6)
|
||||
horizontalSpacing = dp(6)
|
||||
}
|
||||
val adapter = IconAdapter(names)
|
||||
grid.adapter = adapter
|
||||
grid.setOnItemClickListener { _, _, position, _ ->
|
||||
icon = names[position]
|
||||
adapter.notifyDataSetChanged()
|
||||
updatePreview(preview)
|
||||
}
|
||||
left.addView(LinearLayout(context).apply {
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding(0, dp(8), 0, 0)
|
||||
addView(preview, LinearLayout.LayoutParams(dp(56), dp(56)))
|
||||
addView(TextView(context).apply {
|
||||
text = if (names.isEmpty()) "Steam's icons were not found" else "${names.size} icons from Steam"
|
||||
setPadding(dp(12), 0, 0, 0)
|
||||
})
|
||||
})
|
||||
left.addView(paletteRow("Icon colour", { foreground }) { foreground = it; adapter.notifyDataSetChanged(); updatePreview(preview) })
|
||||
left.addView(paletteRow("Button colour", { background }) { background = it; adapter.notifyDataSetChanged(); updatePreview(preview) })
|
||||
root.addView(left, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
||||
root.addView(grid, LinearLayout.LayoutParams(0, dp(250), 1.2f).apply { marginStart = dp(16) })
|
||||
updatePreview(preview)
|
||||
showPanel(host, title, root, listOf(
|
||||
"No icon" to {
|
||||
onPicked(current.copy(label = label.text.toString().replace(",", " ").trim(), icon = "", foreground = "", background = ""))
|
||||
},
|
||||
"Cancel" to {},
|
||||
"Use" to {
|
||||
onPicked(current.copy(label = label.text.toString().replace(",", " ").trim(), icon = icon,
|
||||
foreground = if (icon.isEmpty()) "" else foreground, background = if (icon.isEmpty()) "" else background))
|
||||
},
|
||||
)) { loader.shutdown() }
|
||||
}
|
||||
|
||||
private fun paletteRow(name: String, value: () -> String, onPick: (String) -> Unit): View {
|
||||
val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER_VERTICAL }
|
||||
val swatches = mutableListOf<View>()
|
||||
fun refresh() = swatches.forEachIndexed { i, v ->
|
||||
(v.background as GradientDrawable).setStroke(dp(if (SteamTouchBindings.PALETTE[i].equals(value(), true)) 3 else 1),
|
||||
if (SteamTouchBindings.PALETTE[i].equals(value(), true)) Color.rgb(102, 192, 244) else Color.GRAY)
|
||||
}
|
||||
SteamTouchBindings.PALETTE.forEach { hex ->
|
||||
val swatch = View(context).apply {
|
||||
background = GradientDrawable().apply { shape = GradientDrawable.OVAL; setColor(Color.parseColor(hex)) }
|
||||
setOnClickListener { onPick(hex); refresh() }
|
||||
}
|
||||
swatches += swatch
|
||||
row.addView(swatch, LinearLayout.LayoutParams(dp(30), dp(30)).apply { marginEnd = dp(6) })
|
||||
}
|
||||
refresh()
|
||||
return LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(0, dp(8), 0, 0)
|
||||
addView(TextView(context).apply { text = name })
|
||||
addView(HorizontalScrollView(context).apply { addView(row) })
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePreview(view: ImageView) {
|
||||
if (icon.isEmpty()) { view.setImageDrawable(null); view.background = null; return }
|
||||
loader.execute {
|
||||
val bitmap = SteamTouchBindings.icon(context, appId, icon, 128)?.let { render(it, dp(56)) }
|
||||
main.post { view.setImageBitmap(bitmap) }
|
||||
}
|
||||
}
|
||||
|
||||
/** The icon as Steam draws it: on a disc of the background colour, filled with the foreground
|
||||
* through its own alpha. */
|
||||
private fun render(icon: Bitmap, size: Int): Bitmap {
|
||||
val out = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(out)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
|
||||
paint.color = Color.parseColor(background)
|
||||
canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint)
|
||||
drawSteamIcon(canvas, icon, android.graphics.RectF(size * 0.18f, size * 0.18f, size * 0.82f, size * 0.82f), Color.parseColor(foreground), paint)
|
||||
return out
|
||||
}
|
||||
|
||||
private inner class IconAdapter(val names: List<String>) : BaseAdapter() {
|
||||
private val loading = HashSet<String>()
|
||||
private var refreshQueued = false
|
||||
|
||||
/** Loads an icon in the background; the grid redraws from the cache once some have come in. */
|
||||
private fun load(name: String) {
|
||||
if (!loading.add(name)) return
|
||||
loader.execute {
|
||||
SteamTouchBindings.icon(context, appId, name, 96)
|
||||
main.post {
|
||||
loading.remove(name)
|
||||
if (refreshQueued) return@post
|
||||
refreshQueued = true
|
||||
main.postDelayed({ refreshQueued = false; notifyDataSetChanged() }, 120)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCount() = names.size
|
||||
override fun getItem(position: Int) = names[position]
|
||||
override fun getItemId(position: Int) = position.toLong()
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
|
||||
val view = (convertView as? ImageView) ?: ImageView(context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(56))
|
||||
setPadding(dp(8), dp(8), dp(8), dp(8))
|
||||
}
|
||||
val name = names[position]
|
||||
view.tag = name
|
||||
view.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(8).toFloat()
|
||||
setColor(Color.parseColor(background))
|
||||
if (name == icon) setStroke(dp(3), Color.rgb(102, 192, 244))
|
||||
}
|
||||
view.colorFilter = null
|
||||
val cached = SteamTouchBindings.cachedIcon(appId, name, 96)
|
||||
if (cached != null) {
|
||||
val t = tinted(name, cached)
|
||||
if ((view.drawable as? android.graphics.drawable.BitmapDrawable)?.bitmap !== t) view.setImageBitmap(t)
|
||||
} else {
|
||||
view.setImageDrawable(null)
|
||||
load(name)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
// One thumbnail per icon and colour, reused when the grid rebinds.
|
||||
private val thumbs = android.util.LruCache<String, Bitmap>(64)
|
||||
|
||||
private fun tinted(name: String, icon: Bitmap): Bitmap {
|
||||
val key = "$name|$foreground"
|
||||
thumbs.get(key)?.let { return it }
|
||||
val out = Bitmap.createBitmap(icon.width, icon.height, Bitmap.Config.ARGB_8888)
|
||||
drawSteamIcon(Canvas(out), icon, android.graphics.RectF(0f, 0f, icon.width.toFloat(), icon.height.toFloat()),
|
||||
Color.parseColor(foreground), Paint(Paint.FILTER_BITMAP_FLAG))
|
||||
thumbs.put(key, out)
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* A panel over the session, in the session's own window: a dialog's window of its own left
|
||||
* new bitmaps and layers undrawn in the session window afterwards on the Thor.
|
||||
*/
|
||||
fun showPanel(host: ViewGroup, title: String, content: View, buttons: List<Pair<String, () -> Unit>>, onClose: () -> Unit = {}): () -> Unit {
|
||||
val context = host.context
|
||||
val density = context.resources.displayMetrics.density
|
||||
fun dp(v: Int) = (v * density).toInt()
|
||||
val scrim = android.widget.FrameLayout(context).apply {
|
||||
setBackgroundColor(Color.argb(140, 0, 0, 0))
|
||||
isClickable = true
|
||||
isFocusable = true
|
||||
}
|
||||
// DroidDeck's panel: dark surface, rounded, a hairline border.
|
||||
val card = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
background = GradientDrawable().apply {
|
||||
cornerRadius = dp(18).toFloat(); setColor(Color.rgb(18, 20, 23)); setStroke(dp(1), Color.rgb(38, 42, 49))
|
||||
}
|
||||
setPadding(dp(12), dp(14), dp(12), dp(10))
|
||||
isClickable = true
|
||||
}
|
||||
card.addView(TextView(context).apply {
|
||||
text = title
|
||||
textSize = 15f
|
||||
setTypeface(android.graphics.Typeface.DEFAULT_BOLD)
|
||||
gravity = Gravity.CENTER
|
||||
setTextColor(Color.WHITE)
|
||||
setPadding(dp(16), 0, dp(16), dp(4))
|
||||
})
|
||||
card.addView(content)
|
||||
val row = LinearLayout(context).apply { gravity = Gravity.CENTER; setPadding(dp(8), dp(10), dp(8), 0) }
|
||||
fun close() {
|
||||
(scrim.parent as? ViewGroup)?.removeView(scrim)
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager
|
||||
imm.hideSoftInputFromWindow(host.windowToken, 0)
|
||||
onClose()
|
||||
}
|
||||
buttons.forEachIndexed { i, (name, action) ->
|
||||
row.addView(TextView(context).apply {
|
||||
text = name
|
||||
gravity = Gravity.CENTER
|
||||
textSize = 14f
|
||||
setTextColor(Color.WHITE)
|
||||
val primary = i == buttons.size - 1
|
||||
setTextColor(if (primary) Color.rgb(3, 17, 31) else Color.rgb(242, 244, 247))
|
||||
background = GradientDrawable().apply {
|
||||
cornerRadius = dp(12).toFloat()
|
||||
if (primary) setColor(Color.rgb(26, 159, 255)) else { setColor(Color.rgb(26, 29, 34)); setStroke(dp(1), Color.rgb(52, 58, 67)) }
|
||||
}
|
||||
setPadding(dp(20), dp(10), dp(20), dp(10))
|
||||
setOnClickListener { close(); action() }
|
||||
}, LinearLayout.LayoutParams(0, dp(44), 1f).apply { marginStart = dp(6); marginEnd = dp(6) })
|
||||
}
|
||||
card.addView(row)
|
||||
val width = minOf(dp(640), (context.resources.displayMetrics.widthPixels * 0.9f).toInt())
|
||||
scrim.addView(card, android.widget.FrameLayout.LayoutParams(width, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER))
|
||||
scrim.setOnClickListener { close() }
|
||||
host.addView(scrim, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||||
return { if (scrim.parent != null) close() }
|
||||
}
|
||||
|
||||
/** Steam's binding icon look (steamui: the icon multiplied with its foreground colour,
|
||||
* background-blend-mode: multiply, keeping the icon's own alpha). */
|
||||
fun drawSteamIcon(canvas: Canvas, icon: Bitmap, into: android.graphics.RectF, foreground: Int, paint: Paint) {
|
||||
val tint = Paint(paint).apply { colorFilter = PorterDuffColorFilter(foreground, PorterDuff.Mode.MULTIPLY) }
|
||||
canvas.drawBitmap(icon, null, into, tint)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -227,7 +227,8 @@ public final class LinuxRuntimeInstaller {
|
||||
// Its quarantined home is not user data to carry into an update.
|
||||
File pendingRemoval = removalDirectory(LinuxRuntime.rootDir(context));
|
||||
if (pendingRemoval.exists() && listener != null) listener.onProgress(context.getString(R.string.rtinst_removing_leftovers), -1);
|
||||
RuntimeFileTree.delete(pendingRemoval, null);
|
||||
if (!discard(pendingRemoval, null)) throw new IOException("Cannot clear the previous runtime: " + pendingRemoval);
|
||||
emptyTrash(LinuxRuntime.rootDir(context).getParentFile());
|
||||
String downloading = context.getString(R.string.rtinst_downloading);
|
||||
if (listener != null) listener.onProgress(Step.DOWNLOADING, downloading, 0);
|
||||
// Downloader reports a 0..1 fraction, or -1 while the total size is unknown.
|
||||
@@ -255,8 +256,7 @@ public final class LinuxRuntimeInstaller {
|
||||
File staging = new File(root.getParentFile(), LinuxRuntime.DIR + ".new");
|
||||
File old = new File(root.getParentFile(), LinuxRuntime.DIR + ".old");
|
||||
recoverInterruptedSwap(root, staging, old);
|
||||
RuntimeFileTree.delete(staging, null);
|
||||
if (!staging.mkdirs()) return false;
|
||||
if (!discard(staging, null) || !staging.mkdirs()) return false;
|
||||
if (listener != null) listener.onProgress(context.getString(R.string.rtinst_extracting), -1);
|
||||
if (!extract(context, archive, staging, listener)) {
|
||||
RuntimeFileTree.delete(staging, null);
|
||||
@@ -264,7 +264,10 @@ public final class LinuxRuntimeInstaller {
|
||||
}
|
||||
FileUtils.writeString(new File(staging, VERSION_FILE), release.version);
|
||||
|
||||
RuntimeFileTree.delete(old, null);
|
||||
if (!discard(old, null)) {
|
||||
discard(staging, null);
|
||||
return false;
|
||||
}
|
||||
if (root.isDirectory() && !root.renameTo(old)) {
|
||||
RuntimeFileTree.delete(staging, null);
|
||||
return false;
|
||||
@@ -292,7 +295,7 @@ public final class LinuxRuntimeInstaller {
|
||||
if (old.isDirectory()) old.renameTo(root);
|
||||
return false;
|
||||
}
|
||||
RuntimeFileTree.delete(old, null);
|
||||
discard(old, null);
|
||||
return LinuxRuntime.isInstalled(context);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "install", e);
|
||||
@@ -315,6 +318,35 @@ public final class LinuxRuntimeInstaller {
|
||||
}
|
||||
}
|
||||
|
||||
/** Where entries the app cannot unlink are set aside, out of the way of the runtime's names. */
|
||||
static final String TRASH = ".runtime-trash";
|
||||
|
||||
/**
|
||||
* Gets {@code tree} out from under its name. What can be deleted is; if something inside
|
||||
* cannot be (a file made by root or {@code su} carries another SELinux category, and the app
|
||||
* may not unlink it), the remainder is moved aside into {@link #TRASH} instead of blocking
|
||||
* every later install and removal on it. False only when even that move is refused.
|
||||
*/
|
||||
static boolean discard(File tree, java.util.function.LongConsumer progress) {
|
||||
if (RuntimeFileTree.deleteWhatCan(tree, progress)) return true;
|
||||
File trash = new File(tree.getParentFile(), TRASH);
|
||||
trash.mkdirs();
|
||||
File aside = new File(trash, tree.getName() + "-" + System.currentTimeMillis());
|
||||
if (tree.renameTo(aside)) {
|
||||
Log.w(TAG, "could not delete all of " + tree + "; set the rest aside in " + aside);
|
||||
return true;
|
||||
}
|
||||
Log.w(TAG, "could not delete or set aside " + tree);
|
||||
return !tree.exists();
|
||||
}
|
||||
|
||||
/** Another try at what {@link #discard} set aside. Whatever still cannot go stays there. */
|
||||
static void emptyTrash(File parent) {
|
||||
File trash = new File(parent, TRASH);
|
||||
if (trash.exists() && !RuntimeFileTree.deleteWhatCan(trash, null))
|
||||
Log.i(TAG, "some runtime leftovers still cannot be deleted: " + trash);
|
||||
}
|
||||
|
||||
private static boolean isEmptyDir(File dir) {
|
||||
String[] names = dir.list();
|
||||
return dir.isDirectory() && names != null && names.length == 0;
|
||||
@@ -369,15 +401,21 @@ public final class LinuxRuntimeInstaller {
|
||||
// Invalidate the installation before traversing it. A process killed halfway
|
||||
// through leaves a named removal to resume, never a launchable partial runtime.
|
||||
if (root.exists()) {
|
||||
RuntimeFileTree.delete(pending, progress);
|
||||
if (!root.renameTo(pending)) throw new IOException("Cannot prepare the runtime for removal");
|
||||
if (!discard(pending, progress) || !root.renameTo(pending))
|
||||
throw new IOException("Cannot prepare the runtime for removal");
|
||||
} else if (!pending.isDirectory() && !pending.mkdirs()) {
|
||||
throw new IOException("Cannot prepare the runtime for removal");
|
||||
}
|
||||
// Keep the quarantine until all leftovers are gone, so a failure stays retryable.
|
||||
RuntimeFileTree.delete(new File(root.getParentFile(), root.getName() + ".new"), progress);
|
||||
RuntimeFileTree.delete(new File(root.getParentFile(), root.getName() + ".old"), progress);
|
||||
RuntimeFileTree.delete(pending, progress);
|
||||
// An entry the app may not delete is set aside rather than failing the removal:
|
||||
// a quarantine that can never clear would block reinstalling for good.
|
||||
for (File leftover : new File[]{
|
||||
new File(root.getParentFile(), root.getName() + ".new"),
|
||||
new File(root.getParentFile(), root.getName() + ".old"),
|
||||
pending}) {
|
||||
if (!discard(leftover, progress)) throw new IOException("Cannot remove " + leftover);
|
||||
}
|
||||
emptyTrash(root.getParentFile());
|
||||
job.ok = true;
|
||||
report(text(app, R.string.rtinst_removed), 100);
|
||||
return true;
|
||||
|
||||
@@ -24,8 +24,15 @@ object ProotFastPath {
|
||||
/** MAX_BINDS in fastpath.c: past it the library turns itself off, so the session keeps it off. */
|
||||
const val MAX_BINDS = 256
|
||||
|
||||
/**
|
||||
* Only the app's own proot (libproot.so, built with patches/0014) lets the trampoline through.
|
||||
* The runtime's fallback copy (LinuxRuntime.prootBinary) is Termux's, which traps those calls
|
||||
* too and translates their host paths a second time: every lookup then fails inside the
|
||||
* process and goes to proot anyway, paying for both.
|
||||
*/
|
||||
fun enabled(context: Context): Boolean =
|
||||
SessionPrefs.prootFastPath(context) && !SessionPrefs.prootNoSeccomp(context)
|
||||
SessionPrefs.prootFastPath(context) && !SessionPrefs.prootNoSeccomp(context) &&
|
||||
LinuxRuntime.prootBinary(context).parent == context.applicationInfo.nativeLibraryDir
|
||||
|
||||
/** The key for this rootfs and these binds, or null when the library cannot be told them. */
|
||||
fun key(root: File, binds: List<String>): String? {
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.runtime;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.AccessDeniedException;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
@@ -9,11 +10,15 @@ import java.nio.file.NoSuchFileException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.function.LongConsumer;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
/** Checked removal of an app-owned runtime tree, including read-only directories. */
|
||||
final class RuntimeFileTree {
|
||||
private RuntimeFileTree() {}
|
||||
|
||||
/** Unit tests stand in for an entry the app cannot unlink (a file another SELinux user made). */
|
||||
static volatile Predicate<Path> refuseForTest;
|
||||
|
||||
static void delete(File root, LongConsumer progress) throws IOException {
|
||||
delete(root.toPath(), progress, new long[]{0});
|
||||
}
|
||||
@@ -30,10 +35,48 @@ final class RuntimeFileTree {
|
||||
for (Path child : children) delete(child, progress, removed);
|
||||
}
|
||||
}
|
||||
Files.delete(path);
|
||||
unlink(path);
|
||||
if (progress != null) progress.accept(++removed[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes what it can of {@code root} and carries on past entries it cannot, rather than
|
||||
* stopping at the first. Returns true when nothing is left.
|
||||
*/
|
||||
static boolean deleteWhatCan(File root, LongConsumer progress) {
|
||||
return deleteWhatCan(root.toPath(), progress, new long[]{0});
|
||||
}
|
||||
|
||||
private static boolean deleteWhatCan(Path path, LongConsumer progress, long[] removed) {
|
||||
BasicFileAttributes attrs;
|
||||
try { attrs = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); }
|
||||
catch (NoSuchFileException e) { return true; }
|
||||
catch (IOException e) { return false; }
|
||||
boolean emptied = true;
|
||||
if (attrs.isDirectory()) {
|
||||
try {
|
||||
makeWritable(path.toFile());
|
||||
try (DirectoryStream<Path> children = Files.newDirectoryStream(path)) {
|
||||
for (Path child : children) emptied &= deleteWhatCan(child, progress, removed);
|
||||
}
|
||||
} catch (IOException | RuntimeException e) {
|
||||
emptied = false;
|
||||
}
|
||||
}
|
||||
if (!emptied) return false;
|
||||
try { unlink(path); }
|
||||
catch (NoSuchFileException e) { return true; }
|
||||
catch (IOException e) { return false; }
|
||||
if (progress != null) progress.accept(++removed[0]);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void unlink(Path path) throws IOException {
|
||||
Predicate<Path> refuse = refuseForTest;
|
||||
if (refuse != null && refuse.test(path)) throw new AccessDeniedException(path.toString());
|
||||
Files.delete(path);
|
||||
}
|
||||
|
||||
private static void makeWritable(File dir) throws IOException {
|
||||
if (!dir.setReadable(true, true) || !dir.setWritable(true, true) || !dir.setExecutable(true, true))
|
||||
throw new IOException("Cannot make directory removable: " + dir);
|
||||
|
||||
@@ -22,19 +22,19 @@ import kotlin.math.max
|
||||
* When a game plays a force-feedback effect on the fake pad (or Steam Input sends the Deck's
|
||||
* rumble report), the fake evdev layer inside the guest connects to the abstract socket [NAME]
|
||||
* and sends one packet: strong, weak, duration in ms and the pad slot, four little-endian 16-bit
|
||||
* values. This listens for those and plays the effect on the physical controller that last drove
|
||||
* the pad (PadBridge.activeControllerId) when Android exposes its motors - strong and weak
|
||||
* separately where it has two - and otherwise on the device's own vibrator, with the stronger
|
||||
* motor's strength: the on-screen pad, or a controller without rumble. Best effort both ways: a
|
||||
* missing listener costs the guest nothing, and a bad packet is dropped.
|
||||
* values. This listens for those and plays the effect on the physical controller playing in that
|
||||
* slot (PadBridge.controllerForSlot) when Android exposes its motors - strong and weak separately
|
||||
* where it has two - and otherwise on the device's own vibrator, with the stronger motor's
|
||||
* strength: the on-screen pad, or a controller without rumble. Best effort both ways: a missing
|
||||
* listener costs the guest nothing, and a bad packet is dropped.
|
||||
*/
|
||||
class RumbleComponent : SessionPart() {
|
||||
@Volatile private var server: LocalServerSocket? = null
|
||||
private var phone: Motors? = null
|
||||
/** Where the last effect went, so the next one (or a stop) can end it there. */
|
||||
private var playing: Motors? = null
|
||||
private var controllerId = PadBridge.NO_CONTROLLER
|
||||
private var controller: Motors? = null
|
||||
/** Per slot, where its last effect went, so the next one (or a stop) can end it there. */
|
||||
private val playing = HashMap<Int, Motors>()
|
||||
/** Each controller's motors, looked up once; null when it has none. */
|
||||
private val controllers = HashMap<Int, Motors?>()
|
||||
private var preferences: SharedPreferences? = null
|
||||
private var enabled = false
|
||||
private val preferenceListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
@@ -46,7 +46,7 @@ class RumbleComponent : SessionPart() {
|
||||
|
||||
@Synchronized private fun refreshEnabled() {
|
||||
enabled = app()?.let { ControllerPrefs.rumbleEnabled(it) } == true
|
||||
if (!enabled) { playing?.cancel(); playing = null }
|
||||
if (!enabled) cancelAll()
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
@@ -64,7 +64,7 @@ class RumbleComponent : SessionPart() {
|
||||
try {
|
||||
val bytes = ByteArray(8)
|
||||
DataInputStream(client.inputStream).readFully(bytes)
|
||||
buzz(u16(bytes, 0), u16(bytes, 2), u16(bytes, 4))
|
||||
buzz(u16(bytes, 0), u16(bytes, 2), u16(bytes, 4), u16(bytes, 6))
|
||||
} catch (e: Exception) {
|
||||
// A partial packet or a closed peer: nothing to play.
|
||||
} finally {
|
||||
@@ -79,11 +79,9 @@ class RumbleComponent : SessionPart() {
|
||||
preferences?.unregisterOnSharedPreferenceChangeListener(preferenceListener)
|
||||
preferences = null
|
||||
enabled = false
|
||||
playing?.cancel()
|
||||
playing = null
|
||||
cancelAll()
|
||||
phone = null
|
||||
controller = null
|
||||
controllerId = PadBridge.NO_CONTROLLER
|
||||
controllers.clear()
|
||||
val s = server
|
||||
server = null
|
||||
try { s?.close() } catch (e: Exception) { /* the accept loop ends on the next wake */ }
|
||||
@@ -91,13 +89,14 @@ class RumbleComponent : SessionPart() {
|
||||
|
||||
private fun u16(b: ByteArray, at: Int): Int = (b[at].toInt() and 0xFF) or ((b[at + 1].toInt() and 0xFF) shl 8)
|
||||
|
||||
@Synchronized private fun buzz(strong: Int, weak: Int, ms: Int) {
|
||||
@Synchronized private fun buzz(strong: Int, weak: Int, ms: Int, slot: Int) {
|
||||
if (!enabled) return
|
||||
val target = target()
|
||||
if (target !== playing) {
|
||||
playing?.cancel()
|
||||
playing = target
|
||||
target?.let { Log.i(TAG, "rumble: playing on ${it.name}") }
|
||||
val target = target(slot)
|
||||
val previous = playing[slot]
|
||||
if (target !== previous) {
|
||||
previous?.cancel()
|
||||
if (target == null) playing.remove(slot) else playing[slot] = target
|
||||
target?.let { Log.i(TAG, "rumble: slot $slot playing on ${it.name}") }
|
||||
}
|
||||
if (target == null) return
|
||||
if ((strong == 0 && weak == 0) || ms == 0) { target.cancel(); return }
|
||||
@@ -108,14 +107,17 @@ class RumbleComponent : SessionPart() {
|
||||
}
|
||||
}
|
||||
|
||||
/** The active controller's motors when it has any, else the phone's. */
|
||||
private fun target(): Motors? {
|
||||
val id = PadBridge.activeControllerId()
|
||||
if (id != controllerId) {
|
||||
controllerId = id
|
||||
controller = if (id == PadBridge.NO_CONTROLLER) null else try { controllerMotors(id) } catch (e: Exception) { null }
|
||||
}
|
||||
return controller ?: phone
|
||||
/** The motors of the controller playing in this slot when it has any, else the phone's. */
|
||||
private fun target(slot: Int): Motors? {
|
||||
val id = PadBridge.controllerForSlot(slot)
|
||||
if (id == PadBridge.NO_CONTROLLER) return phone
|
||||
if (!controllers.containsKey(id)) controllers[id] = try { controllerMotors(id) } catch (e: Exception) { null }
|
||||
return controllers[id] ?: phone
|
||||
}
|
||||
|
||||
private fun cancelAll() {
|
||||
playing.values.toSet().forEach { it.cancel() }
|
||||
playing.clear()
|
||||
}
|
||||
|
||||
/** One place an effect can play. Strengths are the evdev 0..65535 magnitudes. */
|
||||
|
||||
@@ -22,6 +22,8 @@ object SessionPrefs {
|
||||
const val OSC_ALWAYS = "always"
|
||||
const val OSC_STEAM_QAM = "steam-qam"
|
||||
const val OSC_NEVER = "never"
|
||||
/** Steam's touch controller (SteamTouchControls) in place of the app's pad; Steam sessions only. */
|
||||
const val OSC_STEAM_TOUCH = "steam-touch"
|
||||
|
||||
/** What Back does in a Steam session, first press then second: the labels of the two orders. */
|
||||
val BACK_MENU_THEN_QAM = R.string.back_menu_then_qam
|
||||
|
||||
@@ -744,7 +744,9 @@ class SessionService : Service() {
|
||||
val wantsDeck = uinput && SessionState.mode == MODE_STEAM &&
|
||||
SessionPrefs.steamController(this) == SessionPrefs.CONTROLLER_DECK &&
|
||||
!File(Environment.getExternalStorageDirectory(), NO_DECK_PAD_SWITCH).exists()
|
||||
deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!) else emptyList()
|
||||
// Steam's touch controller rides on the Deck's sysfs and is offered only beside it.
|
||||
val wantsTouch = wantsDeck && !File(Environment.getExternalStorageDirectory(), NO_STEAM_TOUCH_SWITCH).exists()
|
||||
deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!, wantsTouch) else emptyList()
|
||||
SessionState.deckPad = deckBinds.isNotEmpty()
|
||||
if (wantsDeck && !SessionState.deckPad) Log.w(TAG, "deck pad: not available this session; the pad stays an Xbox 360 controller")
|
||||
logControllersAtStart()
|
||||
@@ -754,6 +756,10 @@ class SessionService : Service() {
|
||||
guest.add("FAKE_EVDEV_UINPUT=1")
|
||||
if (SessionState.deckPad) {
|
||||
guest.add("FAKE_EVDEV_DECK=1")
|
||||
// Steam's touch controller (SteamTouchDevice): the file the app and libfakeinput share.
|
||||
if (wantsTouch) com.droiddeck.launcher.input.SteamTouchDevice.prepare(fakeInputDir)?.let {
|
||||
guest.add("FAKE_TOUCHCTL_RING=" + it.path)
|
||||
}
|
||||
guest.add("FAKE_DECK_SYSFS_LISTING=" + SteamDeckPad.listingDir(fakeInputDir.parentFile!!.parentFile!!).path)
|
||||
}
|
||||
} else if (SessionState.mode == MODE_STEAM) {
|
||||
@@ -1496,6 +1502,7 @@ class SessionService : Service() {
|
||||
}
|
||||
components.clear()
|
||||
FakeInputWriter.releaseAllRingSlots()
|
||||
com.droiddeck.launcher.input.SteamTouchDevice.release()
|
||||
val steamClientMayRun = SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP
|
||||
val finishAfterTeardown: () -> Unit = {
|
||||
if (prootPid > 1 || auxiliary.isNotEmpty()) {
|
||||
@@ -1756,6 +1763,7 @@ class SessionService : Service() {
|
||||
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
|
||||
private const val NO_UINPUT_SWITCH = "Download/droiddeck-no-uinput"
|
||||
private const val NO_DECK_PAD_SWITCH = "Download/droiddeck-no-deck-pad"
|
||||
private const val NO_STEAM_TOUCH_SWITCH = "Download/droiddeck-no-steam-touch"
|
||||
/** libfakeinput numbers the pads made through its /dev/uinput stand-in from here. */
|
||||
private const val FIRST_VIRTUAL_PAD = 16
|
||||
|
||||
|
||||
@@ -44,6 +44,24 @@ object SteamDeckPad {
|
||||
private const val HIDRAW = "$HID/hidraw/$NODE"
|
||||
private const val SERIAL = "DROIDDECK0001"
|
||||
|
||||
// Steam's touch controller (SteamTouchDevice), a second device beside the Deck's: 0000:11fb,
|
||||
// "Mobile Touch Control", on a USB device of its own. Must match libfakeinput (TOUCH_*).
|
||||
private const val TOUCH_MINOR = 17
|
||||
private const val TOUCH_NODE = "hidraw$TOUCH_MINOR"
|
||||
private const val TOUCH_USB = "$GUEST_DEVICES/usb2"
|
||||
private const val TOUCH_INTERFACE = "$TOUCH_USB/2-1:1.0"
|
||||
private const val TOUCH_HID = "$TOUCH_INTERFACE/0003:0000:11FB.0002"
|
||||
private const val TOUCH_HIDRAW = "$TOUCH_HID/hidraw/$TOUCH_NODE"
|
||||
private const val TOUCH_SERIAL = "DROIDDECK0001"
|
||||
|
||||
/** libfakeinput's kTouchReportDescriptor: Generic Desktop / Game Pad, 40-byte input report. */
|
||||
private val TOUCH_REPORT_DESCRIPTOR = intArrayOf(
|
||||
0x05, 0x01, 0x09, 0x05, 0xa1, 0x01,
|
||||
0x09, 0x02, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x28, 0x81, 0x02,
|
||||
0x09, 0x03, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0x91, 0x02,
|
||||
0x09, 0x04, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0,
|
||||
).map { it.toByte() }.toByteArray()
|
||||
|
||||
/** InputPlumber's CONTROLLER_DESCRIPTOR, as libfakeinput answers HIDIOCGRDESC. */
|
||||
private val REPORT_DESCRIPTOR = intArrayOf(
|
||||
0x06, 0xff, 0xff, 0x09, 0x01, 0xa1, 0x01, 0x09, 0x02, 0x09, 0x03, 0x15, 0x00,
|
||||
@@ -55,7 +73,7 @@ object SteamDeckPad {
|
||||
fun listingDir(sessionRoot: File) = File(sessionRoot, "sys/deck/listing")
|
||||
|
||||
/** Writes the tree and returns the `host:guest` binds for it, or none if it could not be made. */
|
||||
fun prepare(context: Context, sessionRoot: File): List<String> {
|
||||
fun prepare(context: Context, sessionRoot: File, touch: Boolean = false): List<String> {
|
||||
val base = File(sessionRoot, "sys/deck")
|
||||
val devices = File(base, "devices")
|
||||
val hidrawClass = File(base, "class-hidraw")
|
||||
@@ -107,6 +125,34 @@ object SteamDeckPad {
|
||||
val udevData = File(LinuxRuntime.rootDir(context), "run/udev/data").apply { mkdirs() }
|
||||
File(udevData, "c$MAJOR:$MINOR").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
|
||||
|
||||
if (touch) {
|
||||
write(TOUCH_USB, "uevent", "DEVTYPE=usb_device\nPRODUCT=0/11fb/0\nTYPE=0/0/0\nBUSNUM=002\nDEVNUM=002\n")
|
||||
write(TOUCH_USB, "idVendor", "0000\n")
|
||||
write(TOUCH_USB, "idProduct", "11fb\n")
|
||||
write(TOUCH_USB, "bcdDevice", "0000\n")
|
||||
write(TOUCH_USB, "manufacturer", "Valve Software\n")
|
||||
write(TOUCH_USB, "product", "Mobile Touch Control\n")
|
||||
write(TOUCH_USB, "serial", "$TOUCH_SERIAL\n")
|
||||
link(File(dir(TOUCH_USB), "subsystem"), "/sys/bus/usb")
|
||||
write(TOUCH_INTERFACE, "uevent", "DEVTYPE=usb_interface\nPRODUCT=0/11fb/0\nINTERFACE=3/0/0\n")
|
||||
write(TOUCH_INTERFACE, "bInterfaceNumber", "00\n")
|
||||
write(TOUCH_INTERFACE, "bInterfaceClass", "03\n")
|
||||
link(File(dir(TOUCH_INTERFACE), "subsystem"), "/sys/bus/usb")
|
||||
write(TOUCH_HID, "uevent", "DRIVER=hid-generic\nHID_ID=0003:00000000:000011FB\n" +
|
||||
"HID_NAME=Mobile Touch Control\nHID_PHYS=usb-droiddeck-2/input0\n" +
|
||||
"HID_UNIQ=$TOUCH_SERIAL\nMODALIAS=hid:b0003g0001v00000000p000011FB\n")
|
||||
File(dir(TOUCH_HID), "report_descriptor").writeBytes(TOUCH_REPORT_DESCRIPTOR)
|
||||
link(File(dir(TOUCH_HID), "subsystem"), "/sys/bus/hid")
|
||||
write(TOUCH_HIDRAW, "uevent", "MAJOR=$MAJOR\nMINOR=$TOUCH_MINOR\nDEVNAME=$TOUCH_NODE\n")
|
||||
write(TOUCH_HIDRAW, "dev", "$MAJOR:$TOUCH_MINOR\n")
|
||||
link(File(dir(TOUCH_HIDRAW), "subsystem"), "/sys/class/hidraw")
|
||||
link(File(dir(TOUCH_HIDRAW), "device"), TOUCH_HID)
|
||||
link(File(hidrawClass, TOUCH_NODE), TOUCH_HIDRAW)
|
||||
link(File(context.cacheDir, "drm/sys/$MAJOR:$TOUCH_MINOR"), TOUCH_HIDRAW)
|
||||
File(udevData, "c$MAJOR:$TOUCH_MINOR").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
|
||||
Log.i(TAG, "deck pad: /dev/$TOUCH_NODE described as Steam's touch controller (0000:11fb)")
|
||||
}
|
||||
|
||||
Log.i(TAG, "deck pad: /dev/$NODE described as a Steam Deck controller (28de:1205)")
|
||||
listOf(devices.path + ":" + GUEST_DEVICES, hidrawClass.path + ":/sys/class/hidraw")
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -43,7 +43,7 @@ private fun Swatch(color: Int) {
|
||||
fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPrefs.Settings, a: ControllerActions) {
|
||||
ChoiceRow(
|
||||
host, "controller-osc", stringResource(R.string.mode_osc), stringResource(R.string.ctrl_osc_hint),
|
||||
listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never)),
|
||||
listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_STEAM_TOUCH to stringResource(R.string.osc_steam_touch), SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never)),
|
||||
oscMode, note = stringResource(R.string.mode_osc_note), onPick = a.onOsc,
|
||||
)
|
||||
val tintOpen = host.open == "controller-tint"
|
||||
|
||||
@@ -270,6 +270,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
listOf(
|
||||
SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto),
|
||||
SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always),
|
||||
SessionPrefs.OSC_STEAM_TOUCH to stringResource(R.string.osc_steam_touch),
|
||||
SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam),
|
||||
SessionPrefs.OSC_NEVER to stringResource(R.string.common_never),
|
||||
), s.oscMode,
|
||||
|
||||
@@ -217,6 +217,8 @@ class DrawerActions(
|
||||
val onSteamMenu: (() -> Unit)?,
|
||||
val onQam: (() -> Unit)?,
|
||||
val onOsc: (String) -> Unit,
|
||||
/** Edits the Steam touch layout of the game in front, when Steam's controls are up. */
|
||||
val onEditSteamTouch: (() -> Unit)? = null,
|
||||
val controller: com.droiddeck.launcher.input.ControllerPrefs.Settings? = null,
|
||||
val onRumble: (Boolean) -> Unit = {},
|
||||
val onSteamButton: (Boolean) -> Unit = {},
|
||||
@@ -418,9 +420,14 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
|
||||
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.drawer_touch_auto, a.touchAuto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)),
|
||||
a.touchMode, chipModifier = focus.track(page, "touch"), onPick = a.onTouch)
|
||||
ChoiceRow(host, "osc", stringResource(R.string.mode_osc), null,
|
||||
if (a.steam) listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never))
|
||||
if (a.steam) listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_STEAM_TOUCH to stringResource(R.string.osc_steam_touch), SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never))
|
||||
else listOf(SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), SessionPrefs.OSC_NEVER to stringResource(R.string.common_never)),
|
||||
a.oscMode, chipModifier = focus.track(page, "osc"), onPick = a.onOsc)
|
||||
// Steam's touch controller is Steam's alone; elsewhere that choice behaves as Auto.
|
||||
if (!a.steam && a.oscMode == SessionPrefs.OSC_STEAM_TOUCH) SessionPrefs.OSC_AUTO else a.oscMode,
|
||||
chipModifier = focus.track(page, "osc"), onPick = a.onOsc)
|
||||
a.onEditSteamTouch?.let { edit ->
|
||||
ActionRow(stringResource(R.string.osc_steam_layout), stringResource(R.string.osc_steam_layout_hint), stringResource(R.string.ctrl_edit), edit)
|
||||
}
|
||||
a.controller?.let { c ->
|
||||
ToggleRow(host, "rumble", stringResource(R.string.ctrl_rumble), null, c.rumble,
|
||||
chipModifier = focus.track(page, "rumble"), onChange = a.onRumble)
|
||||
|
||||
@@ -257,6 +257,10 @@
|
||||
<string name="mode_osc">On-screen controls</string>
|
||||
<string name="mode_osc_qam">Steam + QAM</string>
|
||||
<string name="mode_osc_note">Auto shows the controls when no controller is connected.</string>
|
||||
<string name="osc_steam_touch">Steam touch (beta)</string>
|
||||
<string name="osc_style_next_session">Steam\'s touch controls start with the next Steam session</string>
|
||||
<string name="osc_steam_layout">Steam touch layout</string>
|
||||
<string name="osc_steam_layout_hint">Move, resize or hide this game\'s touch controls; saved to Steam</string>
|
||||
<string name="mode_controller">Controller</string>
|
||||
<string name="mode_controller_hint">How Steam sees your controller. Applies next session.</string>
|
||||
<string name="mode_controller_deck">Steam Deck controller</string>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class SteamTouchBindingsTest {
|
||||
private val config = """
|
||||
"controller_mappings"
|
||||
{
|
||||
"version" "3"
|
||||
"controller_type" "controller_mobile_touch"
|
||||
"group"
|
||||
{
|
||||
"id" "0"
|
||||
"mode" "four_buttons"
|
||||
"inputs"
|
||||
{
|
||||
"button_a"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button A, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"button_b"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "key_press ESCAPE, Pause, ghost_050_menu_0020.png, #FFFFFF #AD0000"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"group"
|
||||
{
|
||||
"id" "9"
|
||||
"mode" "dpad"
|
||||
"inputs"
|
||||
{
|
||||
"dpad_north"
|
||||
{
|
||||
"activators"
|
||||
{
|
||||
"Full_Press"
|
||||
{
|
||||
"bindings"
|
||||
{
|
||||
"binding" "xinput_button DPAD_UP, , "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"preset"
|
||||
{
|
||||
"id" "0"
|
||||
"name" "Default"
|
||||
"group_source_bindings"
|
||||
{
|
||||
"0" "button_diamond active"
|
||||
"9" "dpad active"
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
private val mappings = KeyValues.parse(config).child("controller_mappings")!!
|
||||
|
||||
@Test
|
||||
fun parsesAndComposesSteamsFormat() {
|
||||
val b = SteamTouchBindings.parse("key_press ESCAPE, Pause, ghost_050_menu_0020.png, #FFFFFF #AD0000")
|
||||
assertEquals("key_press ESCAPE", b.action)
|
||||
assertEquals("Pause", b.label)
|
||||
assertEquals("ghost_050_menu_0020.png", b.icon)
|
||||
assertEquals("#FFFFFF", b.foreground)
|
||||
assertEquals("#AD0000", b.background)
|
||||
assertEquals("key_press ESCAPE, Pause, ghost_050_menu_0020.png, #FFFFFF #AD0000", b.compose())
|
||||
assertEquals("xinput_button A, , ", SteamTouchBindings.parse("xinput_button A, , ").compose())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun findsEachControlsBinding() {
|
||||
val b = SteamTouchBindings.refsFor(mappings, SteamTouchConfig.B, 0).single()!!
|
||||
assertEquals(SteamTouchBindings.Ref("0", "button_b"), b)
|
||||
assertEquals("ghost_050_menu_0020.png", SteamTouchBindings.bindingAt(mappings, b)!!.icon)
|
||||
val dpad = SteamTouchBindings.refsFor(mappings, SteamTouchConfig.DPAD, 0)
|
||||
assertEquals(4, dpad.size)
|
||||
assertEquals("xinput_button DPAD_UP", SteamTouchBindings.bindingAt(mappings, dpad[0]!!)!!.action)
|
||||
assertNull(SteamTouchBindings.refsFor(mappings, SteamTouchConfig.BUMPER_LEFT, 0).single())
|
||||
assertEquals("Esc", SteamTouchBindings.glyph(SteamTouchBindings.parse("key_press ESCAPE, , ")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rewritesOnlyThatBinding() {
|
||||
val ref = SteamTouchBindings.Ref("0", "button_a")
|
||||
val out = SteamTouchBindings.rewrite(config, ref) { it.copy(label = "Jump", icon = "ghost_045_move_0400.png", foreground = "#232323", background = "#E4E4E4") }
|
||||
assertNotNull(out)
|
||||
assertEquals(config.replace("\"xinput_button A, , \"", "\"xinput_button A, Jump, ghost_045_move_0400.png, #232323 #E4E4E4\""), out)
|
||||
val reparsed = KeyValues.parse(out!!).child("controller_mappings")!!
|
||||
assertEquals("Jump", SteamTouchBindings.bindingAt(reparsed, ref)!!.label)
|
||||
assertNull(SteamTouchBindings.rewrite(config, SteamTouchBindings.Ref("5", "button_a")) { it })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SteamTouchConfigTest {
|
||||
// A layout DroidDeck saved for Geometry Wars on the Thor, as Steam keeps it.
|
||||
private val saved = "0aec0310012218080010011d0000703d255555d53d2d0000803f350000803f2218080110011d6666fe3e255555d53d2d0000803f350000803f2218080210011dba490c3e255c8f423f2d0000803f350000803f2218080410011d9a99593f255c8f423f2d0000803f350000803f2218080510011d8fc2353f25ae47613f2d0000803f350000803f2218080610011d2b87163e25d7a3f03e2d0000803f350000803f2218080710011dba49003f25f958053f2d293ab23f35293ab23f2218080810011d9a99683f25d227dd3e2d0000803f350000803f2218080910011d9a99483f25d227dd3e2d0000803f350000803f2218080a10011d9a99583f254444a43e2d0000803f350000803f2218080b10011d6666ce3e255555d53d2d0000803f350000803f2218080c10011d3333173f255555d53d2d0000803f350000803f2218080d10011d0ad7233e2585eb513e2d0000803f350000803f2218080e10011d3d0a573f2585eb513e2d0000803f350000803f2218080f10011d8fc2753d2585eb513e2d0000803f350000803f2218081010011dd7a3703f2585eb513e2d0000803f350000803f2218081c10011d0000713f255555d53d2d0000803f350000803f2218080310001de17a943e25ae47613f2d0000803f350000803f2a140d0000803f150000803f1d0000803f25cdcccc3e1002"
|
||||
|
||||
@Test
|
||||
fun decodesSavedLayout() {
|
||||
val layouts = SteamTouchConfig.decodeLayouts(SteamTouchConfig.hexToBytes(saved))
|
||||
assertEquals(1, layouts.layouts.size)
|
||||
val layout = layouts.forActionSet(1)!!
|
||||
val a = layout.elements.first { it.type == SteamTouchConfig.A }
|
||||
assertEquals(0.501f, a.x, 0.002f)
|
||||
assertEquals(1.39f, a.xScale, 0.01f)
|
||||
assertFalse(layout.elements.first { it.type == SteamTouchConfig.JOYSTICK_LEFT_BUTTON }.visible)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun roundTrips() {
|
||||
val bytes = SteamTouchConfig.hexToBytes(saved)
|
||||
assertEquals(saved, SteamTouchConfig.bytesToHex(SteamTouchConfig.encodeLayouts(SteamTouchConfig.decodeLayouts(bytes))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appliesLayoutToElements() {
|
||||
val layouts = SteamTouchConfig.decodeLayouts(SteamTouchConfig.hexToBytes(saved))
|
||||
val all = (0..31).toSet()
|
||||
val config = SteamTouchConfig.Config(null, null, layouts, mapOf(0 to all))
|
||||
val shown = SteamTouchConfig.elementsFor(config, 0, emptyList())
|
||||
val a = shown.first { it.type == SteamTouchConfig.A }
|
||||
assertEquals(0.501f, a.x, 0.002f)
|
||||
assertTrue(shown.none { it.type == SteamTouchConfig.JOYSTICK_LEFT_BUTTON })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun optionsRoundTripAndKeepTheRest() {
|
||||
val layouts = SteamTouchConfig.decodeLayouts(SteamTouchConfig.hexToBytes(saved))
|
||||
assertEquals(SteamTouchConfig.INPUT_CONTROLLER, layouts.options.inputMode)
|
||||
val next = SteamTouchConfig.Options(SteamTouchConfig.INPUT_BOTH, SteamTouchConfig.MOUSE_RELATIVE, 1.75f)
|
||||
val rest = next.encodeInto(layouts.rest)
|
||||
assertEquals(next, SteamTouchConfig.Options.decode(rest))
|
||||
val again = SteamTouchConfig.decodeLayouts(SteamTouchConfig.encodeLayouts(SteamTouchConfig.Layouts(layouts.layouts, rest)))
|
||||
assertEquals(next, again.options)
|
||||
assertEquals(layouts.layouts.size, again.layouts.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listsActionSets() {
|
||||
val kv = KeyValues.parse("""
|
||||
"controller_mappings"
|
||||
{
|
||||
"actions"
|
||||
{
|
||||
"Default" { "title" "Desktop" }
|
||||
"Preset_1000001" { "title" "Gamepad" }
|
||||
}
|
||||
"preset" { "id" "0" "name" "Default" }
|
||||
"preset" { "id" "1" "name" "Preset_1000001" }
|
||||
}
|
||||
""").child("controller_mappings")
|
||||
val config = SteamTouchConfig.Config(null, null, null, emptyMap(), kv)
|
||||
assertEquals(listOf(1 to "Desktop", 2 to "Gamepad"), SteamTouchConfig.actionSets(config))
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ class RuntimeRemovalTest {
|
||||
private lateinit var dir: File
|
||||
@Before fun setUp() { dir = Files.createTempDirectory("runtime-removal").toFile() }
|
||||
@After fun tearDown() {
|
||||
RuntimeFileTree.refuseForTest = null
|
||||
dir.setWritable(true)
|
||||
RuntimeFileTree.delete(dir, null)
|
||||
}
|
||||
@@ -160,4 +161,40 @@ class RuntimeRemovalTest {
|
||||
assertTrue(LinuxRuntimeInstaller.beginUninstall(null, root)!!.run(null))
|
||||
assertNull(LinuxRuntimeInstaller.removalError())
|
||||
}
|
||||
|
||||
@Test fun anEntryTheAppCannotDeleteIsSetAsideInsteadOfStrandingTheRemoval() {
|
||||
val root = runtime()
|
||||
File(root, "root/.steampath").writeText("made by su")
|
||||
RuntimeFileTree.refuseForTest = java.util.function.Predicate { it.fileName.toString() == ".steampath" }
|
||||
assertTrue(LinuxRuntimeInstaller.beginUninstall(null, root)!!.run(null))
|
||||
assertNull(LinuxRuntimeInstaller.removalError())
|
||||
assertFalse(root.exists())
|
||||
assertFalse(File(dir, "linuxfs.removing").exists())
|
||||
// Only the undeletable entry and its parents remain, out of the way.
|
||||
val trash = File(dir, LinuxRuntimeInstaller.TRASH)
|
||||
assertEquals(listOf(".steampath"), trash.walk().filter { it.isFile }.map { it.name }.toList())
|
||||
assertEquals(setOf(LinuxRuntimeInstaller.TRASH), dir.list()!!.toSet())
|
||||
|
||||
// Once it can be deleted, the next removal clears the trash too.
|
||||
RuntimeFileTree.refuseForTest = null
|
||||
assertTrue(LinuxRuntimeInstaller.beginUninstall(null, root)!!.run(null))
|
||||
assertFalse(trash.exists())
|
||||
}
|
||||
|
||||
@Test fun aStuckQuarantineFromAnEarlierBuildClearsOnTheNextRemoval() {
|
||||
val pending = File(dir, "linuxfs.removing").apply { File(this, "root").mkdirs() }
|
||||
File(pending, "root/.steampid").writeText("1")
|
||||
File(pending, "root/game").writeText("game")
|
||||
RuntimeFileTree.refuseForTest = java.util.function.Predicate { it.fileName.toString() == ".steampid" }
|
||||
assertTrue(LinuxRuntimeInstaller.beginUninstall(null, File(dir, "linuxfs"))!!.run(null))
|
||||
assertFalse(pending.exists())
|
||||
}
|
||||
|
||||
@Test fun deleteWhatCanKeepsGoingPastAnEntryItCannotDelete() {
|
||||
val root = runtime()
|
||||
RuntimeFileTree.refuseForTest = java.util.function.Predicate { it.fileName.toString() == "gamescope" }
|
||||
assertFalse(RuntimeFileTree.deleteWhatCan(root, null))
|
||||
assertEquals(listOf("gamescope"), root.walk().filter { it.isFile }.map { it.name }.toList())
|
||||
assertFalse(File(root, "root").exists())
|
||||
}
|
||||
}
|
||||
@@ -115,16 +115,41 @@ class RumbleComponentTest {
|
||||
assertFalse(pad.vibrating)
|
||||
}
|
||||
|
||||
@After fun clearActiveController() { activeController(PadBridge.NO_CONTROLLER) }
|
||||
|
||||
private fun activeController(id: Int) {
|
||||
PadBridge::class.java.getDeclaredField("activeControllerId").apply { isAccessible = true }.setInt(null, id)
|
||||
@Test fun eachPlayerSlotRumblesItsOwnControllerAndVirtualPadsUseTheLastActive() {
|
||||
val first = FakeMotors()
|
||||
val second = FakeMotors()
|
||||
rumble.controllerMotors = { id -> if (id == PAD_ID) first else if (id == SECOND_PAD_ID) second else null }
|
||||
activeController(PAD_ID)
|
||||
activeController(SECOND_PAD_ID, slot = 1)
|
||||
effect(strong = 100, weak = 0, slot = 1)
|
||||
assertEquals(listOf(Triple(100, 0, 5000L)), second.played)
|
||||
assertTrue(first.played.isEmpty())
|
||||
effect(strong = 200, weak = 0)
|
||||
assertEquals(listOf(Triple(200, 0, 5000L)), first.played)
|
||||
assertTrue("one player's effect ended another's", second.vibrating)
|
||||
effect(strong = 0, weak = 0, slot = 1)
|
||||
assertFalse(second.vibrating)
|
||||
assertTrue(first.vibrating)
|
||||
effect(strong = 300, weak = 0, slot = 16)
|
||||
assertEquals(Triple(300, 0, 5000L), second.played.last())
|
||||
assertFalse(shadowOf(vibrator).isVibrating)
|
||||
}
|
||||
|
||||
private fun effect(strong: Int = 65535, weak: Int = 65535) {
|
||||
@After fun clearActiveController() {
|
||||
for (slot in 0 until PadBridge.SLOTS) activeController(PadBridge.NO_CONTROLLER, slot)
|
||||
}
|
||||
|
||||
private fun activeController(id: Int, slot: Int = 0) {
|
||||
PadBridge::class.java.getDeclaredField("activeControllerId").apply { isAccessible = true }.setInt(null, id)
|
||||
val slots = PadBridge::class.java.getDeclaredField("slotControllers").apply { isAccessible = true }.get(null)
|
||||
(slots as java.util.concurrent.atomic.AtomicIntegerArray).set(slot, id)
|
||||
}
|
||||
|
||||
private fun effect(strong: Int = 65535, weak: Int = 65535, slot: Int = 0) {
|
||||
// Inject a decoded force-feedback packet; transport is outside these tests.
|
||||
RumbleComponent::class.java.getDeclaredMethod("buzz", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType)
|
||||
.apply { isAccessible = true }.invoke(rumble, strong, weak, 5000)
|
||||
val int = Int::class.javaPrimitiveType
|
||||
RumbleComponent::class.java.getDeclaredMethod("buzz", int, int, int, int)
|
||||
.apply { isAccessible = true }.invoke(rumble, strong, weak, 5000, slot)
|
||||
}
|
||||
|
||||
private class FakeMotors : RumbleComponent.Motors {
|
||||
@@ -135,7 +160,10 @@ class RumbleComponentTest {
|
||||
override fun cancel() { vibrating = false }
|
||||
}
|
||||
|
||||
private companion object { const val PAD_ID = 7 }
|
||||
private companion object {
|
||||
const val PAD_ID = 7
|
||||
const val SECOND_PAD_ID = 8
|
||||
}
|
||||
|
||||
@Implements(LocalServerSocket::class)
|
||||
class NoPacketsSocket {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# Steam's touch controls
|
||||
|
||||
Drawer → Controller → **On-screen controls: Steam touch (beta)** replaces DroidDeck's own on-screen pad, in a
|
||||
Steam session, with Steam's touch controller: the one the Steam Link apps use. Bindings, action
|
||||
sets, per-game configs, Valve's touch templates, official and community touch configs and Steam
|
||||
Cloud sync are all Steam's; DroidDeck draws the controls and turns touches into the controller's
|
||||
input. The research behind this (how Steam Link's touch controller works, the experiments on the
|
||||
Thor) is in `steam-touch-controller.md` on the `research/steam-touch-controller` branch.
|
||||
|
||||
## How it fits together
|
||||
|
||||
```
|
||||
SteamTouchControls (overlay) → SteamTouchDevice (shared file) → libfakeinput /dev/hidraw17
|
||||
↑ layout ↑ app / action set ↓ 0000:11fb
|
||||
SteamTouchConfig (Steam's configs) ←─── output reports ←── Steam client: Mobile Touch controller
|
||||
↓ Steam Input
|
||||
virtual pad → game
|
||||
```
|
||||
|
||||
- **The device.** Steam builds its touch controller (Steam Input's Mobile Touch type) for any HID
|
||||
device with the ids 0000:11fb. libfakeinput offers one to the client as `/dev/hidraw17` beside
|
||||
the Deck pad, with its sysfs written by `SteamDeckPad.prepare(touch = true)`. It needs a Generic
|
||||
Desktop / Game Pad report descriptor: for a non-Valve vendor id the client ignores a vendor-page
|
||||
device. Offered only with the Deck pad (Steam controller set to Deck);
|
||||
`Download/droiddeck-no-steam-touch` turns it off.
|
||||
- **The shared file** (`touchctl` in the session's pad directory, `FAKE_TOUCHCTL_RING`;
|
||||
`SteamTouchDevice.kt` / `TouchRingFile` in `fakeinput_steam.cpp`). The app writes the 40-byte input
|
||||
report under a seqlock, whether the device is plugged in, and the battery; libfakeinput writes
|
||||
back what the client tells the device.
|
||||
- **The report** (Steam Link's `CVirtualController`): bytes 0-7 button bits (the Deck's low word for
|
||||
face buttons, bumpers, triggers, d-pad, Steam, menu buttons; 22/26 stick clicks; 46/47 sticks
|
||||
touched; 27/19/20 trackpads touched; 32-39 macros; 48/49 one- and two-finger macros), 8-15
|
||||
sticks (int16, Y up), 16-27 three trackpads, 28-33 accelerometer, 34-39 gyro (from PadMotion,
|
||||
in the Deck's units).
|
||||
- **Output reports from the client:** 4 = app id and action set (decides which layout is shown),
|
||||
5/6 = action set layer added/removed, 1 = rumble (also sent to the app's vibration like any
|
||||
pad's), 3 = a setting. Feature report 2 is the battery.
|
||||
- **Plugging.** The device is plugged in only while Steam's controls are shown: always in a Steam
|
||||
session with that choice, since a handheld's built-in pad would keep Auto off for good. Unplugging
|
||||
ends the device's report stream (the client lets the controller go); either way libfakeinput
|
||||
sends a udev event through `udevmon.c`'s stand-in monitor (`bl_udevmon_inject`), since the
|
||||
sandbox gives the client no kernel uevents and its HID discovery would never look again.
|
||||
|
||||
## Layouts
|
||||
|
||||
`SteamTouchConfig.load` finds the game's touch config: the app's own saved config when the
|
||||
configset (`configset_controller_mobile_touch.vdf`) points at one, otherwise the file the client's
|
||||
console log says it loaded for the touch controller (a template, an official or a workshop config).
|
||||
`touch_layout` in it is a hex `CVirtualControllerLayouts` protobuf: per action set, each control's
|
||||
type, visibility, centre (fractions of the screen) and scale. Controls with no layout go where
|
||||
Steam Link puts them by default; only controls the config's active sources bind are shown.
|
||||
|
||||
**Editing** (drawer → Steam touch layout → Edit): drag, pinch or −/+ to resize, Hide, Reset,
|
||||
Cancel, Save. Save writes `touch_layout` into the game's autosave config
|
||||
(`<account>/config/<appid>/controller_mobile_touch.vdf`, made from the config in use) and points the
|
||||
configset at it, as the client does when Steam Link saves a layout; the device is then replugged so
|
||||
the client reads it again. Steam Cloud uploads the file like any autosaved config (seen in
|
||||
`userdata/<account>/241100/remotecache.vdf`).
|
||||
|
||||
## Verified on the Thor
|
||||
|
||||
- Big Picture: Steam shows "Controller Connected - Remote Play Touch Controller"; the Steam button
|
||||
opens the menu, the d-pad moves focus.
|
||||
- A game (Geometry Wars: Retro Evolved): Valve's Gamepad touch template loaded, the stick flies
|
||||
the ship.
|
||||
- A saved layout survives a session restart and was uploaded to Steam Cloud.
|
||||
- Touch menus: a touch menu bound to a trackpad is drawn by Steam's own overlay when the trackpad
|
||||
is held.
|
||||
- Unplug and replug mid-session; two touch devices at once (research branch).
|
||||
|
||||
## Open
|
||||
|
||||
- Gyro: motion reaches the device's report, but a `gyro_to_joystick` binding did not move the stick
|
||||
in the one test made (injected rates, sensors paused). Needs a look at what the client wants
|
||||
(calibration, an enable setting).
|
||||
- Custom touch icons (`SetTouchIconData` on Steam Link) are not drawn; controls use text labels.
|
||||
- Not tried on a retail phone with SELinux enforcing (the Deck pad's sysfs works there).
|
||||
|
||||
## Icons, labels and colours
|
||||
|
||||
Each control shows what its binding carries, as Steam Link does: a binding is
|
||||
`<action>, <label>, <icon>, <#foreground #background>` (e.g.
|
||||
`xinput_button A, Attack, ghost_010_wpn_0070.png, #FFFFFF #AD0000`). Icons are looked up as Steam does:
|
||||
the game's own `TouchMenuIcons` folder, then `tenfoot/resource/images/library/controller/binding_icons`
|
||||
(459 icons). They are drawn as Steam draws binding icons: the icon multiplied by the foreground colour
|
||||
on a disc of the background colour. A binding with no icon shows its label, else a glyph for what it
|
||||
presses (LB, Esc, ⏎…), else the button's own name.
|
||||
|
||||
In the layout editor, **Icon** opens a picker for the selected control (a D-pad direction: the one
|
||||
tapped): label, an icon from the game's own and Steam's library, foreground and background from Steam's
|
||||
binding-icon palette. **Colour** sets the layout's colour and opacity. Save rewrites only those binding
|
||||
strings in the game's touch config (byte for byte otherwise) along with `touch_layout`. Verified with
|
||||
Castle Crashers (its config ships icons on the shoulders and stick click): icons drawn; A set to a knife,
|
||||
white on red, "Attack" and B to another icon; both written in Steam's format and loaded back after a
|
||||
session restart.
|
||||
|
||||
The pickers are panels in the session's window, not dialogs.
|
||||
|
||||
## Steam Link's menu and editor
|
||||
|
||||
- **Menu button (…)**: touch input Controller / Mouse / Controller + mouse; mouse as direct touch or
|
||||
trackpad; trackpad speed. Like Steam Link these are per game, saved in the game's `touch_layout`
|
||||
(`CVirtualControllerLayouts` fields 2-4), so they sync with the config. In Controller mode touches
|
||||
outside the controls do nothing; in Mouse mode only the menu button is drawn and the screen is the
|
||||
mouse. Also: vibrate on touch, fade when not touched, gyroscope (Steam Link's global options, kept
|
||||
per device), the layout editor and DroidDeck's own menu.
|
||||
- **Editor**: ◀ ▶ steps through every action set of the game's config (each saved to its own layout),
|
||||
Add (the tray: bound controls not on screen, and the optional Paste button), More (colour and
|
||||
opacity, copy a layout, paste it into another game, reset), W−/W+/H−/H+ and axis-wise pinch for a
|
||||
control's width and height (ovals).
|
||||
- **Paste** control: Ctrl+V into the session (the Android clipboard is shared with it).
|
||||
|
||||
Not done: Steam Link's magnifying glass / pinch zoom, record and playback of input, shake to fade, and
|
||||
its custom tray drag-in animation. Gyro: the report now carries motion only while the client asks
|
||||
(setting report 0x30) at Steam Link's scale (1000 °/s full scale); a `gyro_to_joystick` binding still
|
||||
did not move a game's stick with injected rates, so gyro remains unconfirmed.
|
||||
|
||||
## DroidDeck's look
|
||||
|
||||
With feature parity reached, the overlay keeps Steam Link's controls (outlined, white, Steam's face
|
||||
colours) but is DroidDeck's own: the guide button carries the split-D mark (artwork/droiddeck-mark.svg)
|
||||
instead of Steam's logo, and the menu, tray and pickers use the app's palette (ui/Theme.kt: surfaces
|
||||
#121417 / #1A1D22, hairlines #262A31, text #F2F4F7 / #9AA3AF, accent #1A9FFF), rounded corners and
|
||||
switches rather than Steam Link's navy panels and blue gradient tiles.
|
||||
|
||||
**Shake to hide**: a toggle in the menu (Steam Link's shake_fade). Three jolts above 2.3 g within
|
||||
about a second hide the controls (touches then reach the game as with the controls off); shaking
|
||||
again brings them back.
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user