diff --git a/app/src/main/cpp/fakeinput_steam.cpp b/app/src/main/cpp/fakeinput_steam.cpp index 4d71ce186..41d6cfe75 100644 --- a/app/src/main/cpp/fakeinput_steam.cpp +++ b/app/src/main/cpp/fakeinput_steam.cpp @@ -3,7 +3,9 @@ * through f1f34cd7, 2026-09-14..19), GPL-3.0: the locking, the fork handlers, the shared device * table, the Steam virtual-gamepad identity and the Z/RZ trigger placement are that author's. * Added here: an opt-in Xbox 360 identity (FAKE_EVDEV_IDENTITY=xbox360), which is what gets SDL's - * built-in mapping applied with no configuration when the pad is read by the Steam client itself. + * built-in mapping applied with no configuration when the pad is read by the Steam client itself; + * and a stand-in for /dev/uinput (FAKE_EVDEV_UINPUT=1), so the virtual pads Steam Input makes for + * a game become more of these nodes instead of vanishing. */ #include #include @@ -24,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -31,12 +34,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -80,6 +85,11 @@ static constexpr uint32_t FAKE_INPUT_RING_CAPACITY = 4096; static constexpr unsigned int FAKE_INPUT_MAJOR = 13; static constexpr unsigned int FAKE_INPUT_EVENT_MINOR_BASE = 64; static constexpr unsigned int FAKE_INPUT_JS_MINOR_BASE = 0; +// Nodes from event16 up are the virtual pads programs make through the /dev/uinput stand-in; +// below that are the app's own rings, one per slot. +static constexpr int UINPUT_EVENT_BASE = 16; +static constexpr int UINPUT_EVENT_LIMIT = 64; +static constexpr uint32_t UINPUT_DESCRIPTION_MAGIC = 0x46495544; struct FakeInputRingHeader { uint32_t magic; // 0 @@ -107,8 +117,28 @@ static constexpr size_t FAKE_INPUT_RING_SIZE = FAKE_INPUT_RING_HEADER_SIZE + (FAKE_INPUT_RING_CAPACITY * FAKE_INPUT_EVENT_SIZE); +// What a program told the /dev/uinput stand-in about the device it made, stored after the ring +// in that device's file so every process that opens the node answers the same identity. +struct UinputDescription { + uint32_t magic; + int32_t owner_pid; + struct input_id id; + uint32_t ff_effects_max; + char name[UINPUT_MAX_NAME_SIZE]; + char phys[64]; + uint8_t evbits[(EV_MAX + 8) / 8]; + uint8_t keybits[(KEY_MAX + 8) / 8]; + uint8_t absbits[(ABS_MAX + 8) / 8]; + uint8_t ffbits[(FF_MAX + 8) / 8]; + struct input_absinfo absinfo[ABS_CNT]; +}; + +static constexpr size_t UINPUT_FILE_SIZE = FAKE_INPUT_RING_SIZE + sizeof(UinputDescription); + struct FakeController { char *event = nullptr; + // Set for a node made through the /dev/uinput stand-in; null for the app's own pads. + const UinputDescription *uinput = nullptr; int slot = -1; FakeInputRingHeader *ring = nullptr; uint64_t read_seq = 0; @@ -189,6 +219,7 @@ struct FakeConfig { const char *udev_data_dir; bool vibration_enabled; bool xbox360_identity; + bool uinput_enabled; }; static FakeConfig &config() { @@ -201,6 +232,7 @@ static FakeConfig &config() { getenv("FAKE_EVDEV_VIBRATION") && atoi(getenv("FAKE_EVDEV_VIBRATION")); const char *identity = getenv("FAKE_EVDEV_IDENTITY"); c->xbox360_identity = identity && !strcmp(identity, "xbox360"); + c->uinput_enabled = getenv("FAKE_EVDEV_UINPUT") && atoi(getenv("FAKE_EVDEV_UINPUT")); pthread_atfork(controller_fork_prepare, controller_fork_parent, controller_fork_child); Logger::init(); @@ -233,6 +265,7 @@ static const char *fake_hook_dir() { return config().hook_dir; } static const char *fake_udev_data_dir() { return config().udev_data_dir; } static bool fake_vibration_enabled() { return config().vibration_enabled; } static bool fake_xbox360_identity() { return config().xbox360_identity; } +static bool fake_uinput_enabled() { return config().uinput_enabled; } static bool ring_paths_loaded = false; static int next_ff_id = 0; @@ -535,10 +568,22 @@ keyframe_value(const FakeController &fake, uint16_t type, uint16_t code) { return 0; // SYN / unknown } +// A virtual pad's ring and description live beside its node, hidden from anything that lists +// event* names: /dev/input/.uinput-event. +__attribute__((visibility("hidden"))) static std::string uinput_ring_path(int node) { + return std::string(fake_hook_dir()) + "/.uinput-event" + std::to_string(node); +} + +__attribute__((visibility("hidden"))) static bool process_alive(pid_t pid) { + return pid > 0 && (kill(pid, 0) == 0 || errno == EPERM); +} + __attribute__((visibility("hidden"))) static int open_fake_input_ring(const char *event, int flags) { int slot = get_event_number(event); - std::string ring_path = get_ring_path_for_slot(slot); + bool virtual_pad = slot >= UINPUT_EVENT_BASE; + std::string ring_path = virtual_pad ? uinput_ring_path(slot) : get_ring_path_for_slot(slot); + size_t mapping_size = virtual_pad ? UINPUT_FILE_SIZE : FAKE_INPUT_RING_SIZE; if (ring_path.empty()) { Logger::log("No input ring is configured for %s (slot %d)\n", event, slot); errno = ENODEV; @@ -551,8 +596,7 @@ open_fake_input_ring(const char *event, int flags) { if (fd < 0) return -1; - void *mapping = - mmap(nullptr, FAKE_INPUT_RING_SIZE, PROT_READ, MAP_SHARED, fd, 0); + void *mapping = mmap(nullptr, mapping_size, PROT_READ, MAP_SHARED, fd, 0); if (mapping == MAP_FAILED) { int saved_errno = errno; syscall(SYS_close, fd); @@ -562,9 +606,16 @@ open_fake_input_ring(const char *event, int flags) { FakeInputRingHeader *ring = reinterpret_cast(mapping); - if (!ring_header_is_valid(ring)) { + const UinputDescription *description = + virtual_pad ? reinterpret_cast( + static_cast(mapping) + FAKE_INPUT_RING_SIZE) + : nullptr; + // A virtual pad whose maker has gone (a crashed client leaves its files) is not there. + if (!ring_header_is_valid(ring) || + (description && (description->magic != UINPUT_DESCRIPTION_MAGIC || + !process_alive(description->owner_pid)))) { Logger::log("Input ring %s has no valid header\n", ring_path.c_str()); - munmap(mapping, FAKE_INPUT_RING_SIZE); + munmap(mapping, mapping_size); syscall(SYS_close, fd); errno = ENODEV; return -1; @@ -574,7 +625,8 @@ open_fake_input_ring(const char *event, int flags) { controller->event = strdup(event); controller->slot = slot; controller->ring = ring; - controller->mapping_size = FAKE_INPUT_RING_SIZE; + controller->uinput = description; + controller->mapping_size = mapping_size; controller->generation = ring_generation(ring); // Establish the cursor at open so taps arriving before the first read stay // queued. If publication is busy, read() will finish establishing it later. @@ -618,20 +670,32 @@ __attribute__((visibility("hidden"))) static bool presents_steam_virtual() { // gamepad's axes by their position among the ones it advertises, so the triggers have to sit // where that pad has them: as ABS_GAS and ABS_BRAKE they follow the sticks, the right stick // lands on the left trigger, and a released right trigger reads as the right stick held up. -__attribute__((visibility("hidden"))) static uint16_t presented_abs_code(uint16_t code) { - if (!presents_steam_virtual()) return code; +// A pad made through /dev/uinput is that pad for real, whoever reads it. +__attribute__((visibility("hidden"))) static bool +presents_xbox_triggers(const FakeController &fake) { + return fake.uinput || presents_steam_virtual(); +} + +__attribute__((visibility("hidden"))) static uint16_t +presented_abs_code(const FakeController &fake, uint16_t code) { + if (!presents_xbox_triggers(fake)) return code; if (code == ABS_BRAKE) return ABS_Z; if (code == ABS_GAS) return ABS_RZ; return code; } -__attribute__((visibility("hidden"))) static uint16_t ring_abs_code(uint16_t code) { - if (!presents_steam_virtual()) return code; +// The snapshot keeps the triggers in the ABS_GAS/ABS_BRAKE places either way. +__attribute__((visibility("hidden"))) static uint16_t snapshot_abs_code(uint16_t code) { if (code == ABS_Z) return ABS_BRAKE; if (code == ABS_RZ) return ABS_GAS; return code; } +__attribute__((visibility("hidden"))) static uint16_t +ring_abs_code(const FakeController &fake, uint16_t code) { + return presents_xbox_triggers(fake) ? snapshot_abs_code(code) : code; +} + __attribute__((visibility("hidden"))) static void copy_slot_ioctl_string(int op, void *argp, const char *format, int event_number) { size_t size = _IOC_SIZE(op); @@ -707,6 +771,793 @@ __attribute__((visibility("hidden"))) static long long monotonic_ms() { return static_cast(now.tv_sec) * 1000LL + now.tv_nsec / 1000000LL; } +// ---- /dev/uinput, stood in for ---- +// +// Under Steam, a game is meant to read Steam Input's output, not the pad: the client hides the +// physical pad from the game (SDL_GAMECONTROLLER_IGNORE_DEVICES, and its overlay refuses the +// open) and makes it a virtual X-Box 360 pad through /dev/uinput, which it then drives with the +// layout the player chose. The sandbox has no uinput the client may use, so the virtual pad never +// appeared and every game was left reading the physical pad as-is. +// +// So /dev/uinput is answered here. A gamepad the client makes becomes another of this library's +// nodes - /dev/input/event16 and up - backed by a ring file of the same layout the app writes, +// and the client's writes are that ring's events. Any process reading the node sees the identity +// the client gave it and gets exactly what the client wrote. A device that is not a gamepad (the +// client's virtual keyboard and mouse) is accepted and its events dropped, which is what happened +// to them before. +struct UinputWriter { + UinputDescription description{}; + bool created = false; + bool published = false; + int node = -1; + pid_t owner = 0; + FakeInputRingHeader *ring = nullptr; + UinputDescription *shared = nullptr; + std::vector pending; + uint32_t buttons = 0; + int16_t axes[8] = {}; +}; + +static std::unordered_map> &uinput_map() { + static auto *map = new std::unordered_map>(); + return *map; +} + +__attribute__((visibility("hidden"))) static bool is_uinput_path(const char *pathname) { + return pathname && (!strcmp(pathname, "/dev/uinput") || !strcmp(pathname, "/dev/input/uinput")); +} + +__attribute__((visibility("hidden"))) static void set_bit(uint8_t *bits, size_t size, int bit) { + if (bit >= 0 && static_cast(bit) / 8 < size) bits[bit / 8] |= 1u << (bit % 8); +} + +__attribute__((visibility("hidden"))) static bool test_bit(const uint8_t *bits, size_t size, int bit) { + return bit >= 0 && static_cast(bit) / 8 < size && (bits[bit / 8] >> (bit % 8)) & 1u; +} + +// A real uinput fd reads EV_UINPUT requests and polls as writable. An eventfd that is never +// signalled does both: nothing to read, always writable, blocking or not as the caller asked. +__attribute__((visibility("hidden"))) static int open_uinput(int flags) { + int fd = eventfd(0, ((flags & O_NONBLOCK) ? EFD_NONBLOCK : 0) | ((flags & O_CLOEXEC) ? EFD_CLOEXEC : 0)); + if (fd < 0) return -1; + auto writer = std::make_shared(); + writer->owner = getpid(); + std::lock_guard guard(controller_mutex()); + uinput_map()[fd] = writer; + Logger::log("uinput: opened as fd %d\n", fd); + return fd; +} + +// The layout the client asks for only matters to readers; what makes it a pad is a south button +// and a left stick, which is also what every pad the ring format carries has. +__attribute__((visibility("hidden"))) static bool is_gamepad(const UinputDescription &d) { + return test_bit(d.evbits, sizeof(d.evbits), EV_KEY) && test_bit(d.keybits, sizeof(d.keybits), BTN_SOUTH) && + test_bit(d.evbits, sizeof(d.evbits), EV_ABS) && test_bit(d.absbits, sizeof(d.absbits), ABS_X); +} + +__attribute__((visibility("hidden"))) static void unpublish_uinput(UinputWriter &w) { + if (!w.published) return; + // Readers holding the mapping see the generation move and report the device gone; the node's + // removal is what tells SDL and Wine through inotify. + __atomic_add_fetch(&w.ring->generation, 1, __ATOMIC_RELEASE); + std::string node = std::string(fake_hook_dir()) + "/event" + std::to_string(w.node); + unlink(node.c_str()); + unlink((uinput_ring_path(w.node) + ".uevent").c_str()); + unlink(uinput_ring_path(w.node).c_str()); + munmap(w.ring, UINPUT_FILE_SIZE); + Logger::log("uinput: removed event%d\n", w.node); + w.ring = nullptr; + w.shared = nullptr; + w.published = false; +} + +// A node number is claimed by creating its ring file exclusively, so two processes making pads +// at once cannot take the same one. A file left by a process that has gone is reclaimed. +__attribute__((visibility("hidden"))) static int claim_uinput_node(int *fd_out) { + static auto my_open = reinterpret_cast(dlsym(RTLD_NEXT, "open")); + for (int node = UINPUT_EVENT_BASE; node < UINPUT_EVENT_LIMIT; node++) { + std::string path = uinput_ring_path(node); + int fd = my_open(path.c_str(), O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC, 0644); + if (fd >= 0) { + *fd_out = fd; + return node; + } + if (errno != EEXIST) return -1; + int existing = my_open(path.c_str(), O_RDONLY | O_CLOEXEC); + if (existing < 0) continue; + UinputDescription old{}; + bool stale = pread(existing, &old, sizeof(old), FAKE_INPUT_RING_SIZE) == sizeof(old) && + old.magic == UINPUT_DESCRIPTION_MAGIC && !process_alive(old.owner_pid); + syscall(SYS_close, existing); + if (stale) { + unlink((std::string(fake_hook_dir()) + "/event" + std::to_string(node)).c_str()); + unlink((path + ".uevent").c_str()); + unlink(path.c_str()); + node--; // try the same number again + } + } + errno = ENOSPC; + return -1; +} + +__attribute__((visibility("hidden"))) static int publish_uinput(UinputWriter &w) { + static auto my_open = reinterpret_cast(dlsym(RTLD_NEXT, "open")); + int fd = -1; + int node = claim_uinput_node(&fd); + if (node < 0) return -1; + void *mapping = MAP_FAILED; + if (ftruncate(fd, UINPUT_FILE_SIZE) == 0) + mapping = mmap(nullptr, UINPUT_FILE_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + syscall(SYS_close, fd); + std::string ring_path = uinput_ring_path(node); + if (mapping == MAP_FAILED) { + unlink(ring_path.c_str()); + return -1; + } + auto *ring = static_cast(mapping); + ring->magic = FAKE_INPUT_RING_MAGIC; + ring->version = FAKE_INPUT_RING_VERSION; + ring->event_size = FAKE_INPUT_EVENT_SIZE; + ring->capacity = FAKE_INPUT_RING_CAPACITY; + ring->generation = 1; + auto *shared = reinterpret_cast(static_cast(mapping) + FAKE_INPUT_RING_SIZE); + *shared = w.description; + shared->magic = UINPUT_DESCRIPTION_MAGIC; + shared->owner_pid = w.owner; + __atomic_thread_fence(__ATOMIC_RELEASE); + + // The identity Wine's HID bus reads through libudev (inputudev.c in libblsession), written + // before the node so the first lookup finds it. + char uevent[256]; + int length = snprintf(uevent, sizeof(uevent), "PRODUCT=%x/%x/%x/%x\nNAME=\"%s\"\nPHYS=\"%s\"\n", + shared->id.bustype, shared->id.vendor, shared->id.product, shared->id.version, + shared->name, shared->phys); + int uevent_fd = my_open((ring_path + ".uevent").c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644); + if (uevent_fd >= 0) { + if (write(uevent_fd, uevent, std::min(length, sizeof(uevent) - 1)) < 0) {} + syscall(SYS_close, uevent_fd); + } + std::string node_path = std::string(fake_hook_dir()) + "/event" + std::to_string(node); + int node_fd = my_open(node_path.c_str(), O_WRONLY | O_CREAT | O_CLOEXEC, 0644); + if (node_fd < 0) { + munmap(mapping, UINPUT_FILE_SIZE); + unlink((ring_path + ".uevent").c_str()); + unlink(ring_path.c_str()); + return -1; + } + syscall(SYS_close, node_fd); + w.node = node; + w.ring = ring; + w.shared = shared; + w.published = true; + Logger::log("uinput: \"%s\" %04x:%04x is /dev/input/event%d\n", shared->name, shared->id.vendor, + shared->id.product, node); + return 0; +} + +// Same publication as the app's writer (FakeInputWriter.flushBufferToRing): the events, then the +// absolute state under its seqlock, then the cursor that makes both visible. +__attribute__((visibility("hidden"))) static void flush_uinput(UinputWriter &w) { + if (!w.published || w.pending.empty()) { + w.pending.clear(); + return; + } + FakeInputRingHeader *ring = w.ring; + auto *events = reinterpret_cast(reinterpret_cast(ring) + + FAKE_INPUT_RING_HEADER_SIZE); + uint64_t write_seq = __atomic_load_n(&ring->write_seq, __ATOMIC_RELAXED); + struct timeval now = {}; + gettimeofday(&now, nullptr); + for (struct input_event ev : w.pending) { + ev.time = now; + events[write_seq % FAKE_INPUT_RING_CAPACITY] = ev; + write_seq++; + if (ev.type == EV_KEY) { + for (size_t i = 0; i < kSnapshotButtonCount; i++) + if (kSnapshotButtons[i] == ev.code) + w.buttons = ev.value ? (w.buttons | (1u << i)) : (w.buttons & ~(1u << i)); + } else if (ev.type == EV_ABS) { + uint16_t code = snapshot_abs_code(ev.code); + for (int i = 0; i < 8; i++) + if (kSnapshotAxisCodes[i] == code) + w.axes[i] = static_cast(std::max(-32768, std::min(32767, ev.value))); + } + } + w.pending.clear(); + uint64_t sequence = __atomic_load_n(&ring->snapshot_seq, __ATOMIC_RELAXED); + __atomic_store_n(&ring->snapshot_seq, sequence + 1, __ATOMIC_RELAXED); + __atomic_thread_fence(__ATOMIC_RELEASE); + __atomic_store_n(&ring->snapshot_buttons, w.buttons, __ATOMIC_RELAXED); + for (int i = 0; i < 8; i++) __atomic_store_n(&ring->snapshot_axes[i], w.axes[i], __ATOMIC_RELAXED); + __atomic_thread_fence(__ATOMIC_RELEASE); + __atomic_store_n(&ring->snapshot_seq, sequence + 2, __ATOMIC_RELEASE); + __atomic_store_n(&ring->write_seq, write_seq, __ATOMIC_RELEASE); +} + +__attribute__((visibility("hidden"))) static ssize_t +write_uinput(UinputWriter &w, const void *buf, size_t count) { + if (!w.created) { + // The legacy description: written once, before UI_DEV_CREATE. + if (count != sizeof(struct uinput_user_dev)) { + errno = EINVAL; + return -1; + } + const auto *legacy = static_cast(buf); + memcpy(w.description.name, legacy->name, sizeof(w.description.name)); + w.description.name[sizeof(w.description.name) - 1] = '\0'; + w.description.id = legacy->id; + w.description.ff_effects_max = legacy->ff_effects_max; + for (int i = 0; i < ABS_CNT; i++) { + w.description.absinfo[i].minimum = legacy->absmin[i]; + w.description.absinfo[i].maximum = legacy->absmax[i]; + w.description.absinfo[i].fuzz = legacy->absfuzz[i]; + w.description.absinfo[i].flat = legacy->absflat[i]; + } + return static_cast(count); + } + if (count % sizeof(struct input_event)) { + errno = EINVAL; + return -1; + } + const auto *events = static_cast(buf); + for (size_t i = 0; i < count / sizeof(struct input_event); i++) { + w.pending.push_back(events[i]); + // A frame ends at SYN_REPORT; a writer that never sends one still gets its events out. + if ((events[i].type == EV_SYN && events[i].code == SYN_REPORT) || w.pending.size() >= 64) + flush_uinput(w); + } + return static_cast(count); +} + +__attribute__((visibility("hidden"))) static int +ioctl_uinput(UinputWriter &w, ioctl_request_t op, void *argp) { + if (_IOC_TYPE(op) != UINPUT_IOCTL_BASE) { + errno = ENOTTY; + return -1; + } + int number = _IOC_NR(op); + int value = static_cast(reinterpret_cast(argp)); + UinputDescription &d = w.description; + if (number == _IOC_NR(UI_GET_VERSION)) { + *static_cast(argp) = 5; + return 0; + } + if (number == _IOC_NR(UI_GET_SYSNAME(0))) { + if (!w.created) { + errno = ENOENT; + return -1; + } + snprintf(static_cast(argp), _IOC_SIZE(op), "input%d", w.published ? w.node : 0); + return 0; + } + if (number == _IOC_NR(UI_DEV_DESTROY)) { + if (getpid() == w.owner) unpublish_uinput(w); + w.created = false; + return 0; + } + if (w.created && number != _IOC_NR(UI_BEGIN_FF_UPLOAD) && number != _IOC_NR(UI_END_FF_UPLOAD) && + number != _IOC_NR(UI_BEGIN_FF_ERASE) && number != _IOC_NR(UI_END_FF_ERASE)) { + errno = EINVAL; // the kernel refuses configuration once the device exists + return -1; + } + switch (number) { + case _IOC_NR(UI_SET_EVBIT): set_bit(d.evbits, sizeof(d.evbits), value); return 0; + case _IOC_NR(UI_SET_KEYBIT): set_bit(d.keybits, sizeof(d.keybits), value); return 0; + case _IOC_NR(UI_SET_ABSBIT): set_bit(d.absbits, sizeof(d.absbits), value); return 0; + case _IOC_NR(UI_SET_FFBIT): set_bit(d.ffbits, sizeof(d.ffbits), value); return 0; + case _IOC_NR(UI_SET_RELBIT): + case _IOC_NR(UI_SET_MSCBIT): + case _IOC_NR(UI_SET_LEDBIT): + case _IOC_NR(UI_SET_SNDBIT): + case _IOC_NR(UI_SET_SWBIT): + case _IOC_NR(UI_SET_PROPBIT): + return 0; + case _IOC_NR(UI_SET_PHYS): + snprintf(d.phys, sizeof(d.phys), "%s", static_cast(argp)); + return 0; + case _IOC_NR(UI_DEV_SETUP): { + const auto *setup = static_cast(argp); + d.id = setup->id; + memcpy(d.name, setup->name, sizeof(d.name)); + d.name[sizeof(d.name) - 1] = '\0'; + d.ff_effects_max = setup->ff_effects_max; + return 0; + } + case _IOC_NR(UI_ABS_SETUP): { + const auto *setup = static_cast(argp); + if (setup->code >= ABS_CNT) { + errno = EINVAL; + return -1; + } + d.absinfo[setup->code] = setup->absinfo; + return 0; + } + case _IOC_NR(UI_DEV_CREATE): + w.created = true; + if (!is_gamepad(d)) { + Logger::log("uinput: \"%s\" %04x:%04x is not a gamepad; its events are dropped\n", d.name, + d.id.vendor, d.id.product); + return 0; + } + if (publish_uinput(w) < 0) { + Logger::log("uinput: could not publish \"%s\": %s\n", d.name, strerror(errno)); + w.created = false; + errno = ENOMEM; + return -1; + } + return 0; + // Force feedback reaches the client only as EV_UINPUT requests on this fd, and none are ever + // sent: a game's rumble on a virtual pad goes straight to the app (check_ff_event). + case _IOC_NR(UI_BEGIN_FF_UPLOAD): + case _IOC_NR(UI_END_FF_UPLOAD): + case _IOC_NR(UI_BEGIN_FF_ERASE): + case _IOC_NR(UI_END_FF_ERASE): + errno = EINVAL; + return -1; + default: + Logger::log("uinput: unhandled ioctl %d\n", number); + errno = EINVAL; + return -1; + } +} + +// Only the process that made the device takes it down: a forked child closing its inherited +// copy of the fd (as a spawn does before exec) must not remove the parent's pad. +__attribute__((visibility("hidden"))) static void close_uinput(int fd) { + std::lock_guard guard(controller_mutex()); + auto it = uinput_map().find(fd); + if (it == uinput_map().end()) return; + if (getpid() == it->second->owner) unpublish_uinput(*it->second); + uinput_map().erase(it); +} + +// ---- The pad as a Steam Deck controller ---- +// +// On a Deck, and on the handhelds InputPlumber serves, the Steam client reads the built-in +// controller itself as a Steam Deck controller: a vendor HID device it talks to over hidraw, which +// is what gives it the Quick Access button, the back grips, gyro and trackpads, and Steam Input's +// full treatment of the pad. With FAKE_EVDEV_DECK set the app's pad in ring slot 0 is presented to +// the client that way: /dev/hidraw16, found through the sysfs tree the app binds into the session +// (SteamDeckPad.kt), streams the Deck's 64-byte state report every 4 ms, as the real one does, built +// from the ring's absolute snapshot; its feature reports are answered as InputPlumber answers them +// for a virtual Deck. The evdev nodes of the app's pads are then withdrawn - the client would +// otherwise count the pad twice - and games get Steam Input's virtual pad (the /dev/uinput stand-in). +// +// A read returns one report and never blocks past the next one, so the fd is one end of a +// SOCK_SEQPACKET socket pair with a thread writing reports into the other end: poll, select and +// epoll then work on it with no help from the hooks here. +static constexpr const char *DECK_HIDRAW_PATH = "/dev/hidraw16"; +static constexpr unsigned int DECK_HIDRAW_MAJOR = 240; +static constexpr unsigned int DECK_HIDRAW_MINOR = 16; +static constexpr int DECK_REPORT_BYTES = 64; +static constexpr int DECK_REPORT_INTERVAL_US = 4000; +static constexpr const char *DECK_NAME = "Valve Software Steam Deck Controller"; +static constexpr const char *DECK_SERIAL = "DROIDDECK0001"; + +// InputPlumber, src/drivers/steam_deck/report_descriptor.rs (CONTROLLER_DESCRIPTOR): one 64-byte +// vendor input report and one 64-byte feature report, no report ids. +static const uint8_t kDeckReportDescriptor[] = { + 0x06, 0xff, 0xff, 0x09, 0x01, 0xa1, 0x01, 0x09, 0x02, 0x09, 0x03, 0x15, 0x00, + 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0x81, 0x02, 0x09, 0x06, 0x09, 0x07, + 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0}; + +// Feature report ids (SDL, src/joystick/hidapi/steam/controller_constants.h). +static constexpr uint8_t DECK_GET_ATTRIBUTES_VALUES = 0x83; +static constexpr uint8_t DECK_GET_STRING_ATTRIBUTE = 0xAE; +static constexpr uint8_t DECK_GET_CHIP_ID = 0xBA; +static constexpr uint8_t DECK_TRIGGER_RUMBLE_CMD = 0xEB; + +// Button bits of the state report (SDL, SDL_hidapi_steamdeck.c): the low word, then the high. +static constexpr uint32_t DECK_L_R2 = 0x00000001, DECK_L_L2 = 0x00000002, DECK_L_R1 = 0x00000004, + DECK_L_L1 = 0x00000008, DECK_L_Y = 0x00000010, DECK_L_B = 0x00000020, + DECK_L_X = 0x00000040, DECK_L_A = 0x00000080, DECK_L_UP = 0x00000100, + DECK_L_RIGHT = 0x00000200, DECK_L_LEFT = 0x00000400, + DECK_L_DOWN = 0x00000800, DECK_L_VIEW = 0x00001000, + DECK_L_STEAM = 0x00002000, DECK_L_MENU = 0x00004000, + DECK_L_L3 = 0x00400000, DECK_L_R3 = 0x04000000; +static constexpr uint32_t DECK_L_L5 = 0x00008000, DECK_L_R5 = 0x00010000, + DECK_L_LEFT_PAD = 0x00020000, DECK_L_RIGHT_PAD = 0x00040000, + DECK_L_LEFT_TOUCH = 0x00080000, DECK_L_RIGHT_TOUCH = 0x00100000; +static constexpr uint32_t DECK_H_L4 = 0x00000200, DECK_H_R4 = 0x00000400; +static constexpr uint32_t DECK_H_QAM = 0x00040000; +// Bit 11 of the ring snapshot's button word: the Quick Access button, which an Xbox pad does not +// have and the evdev nodes therefore never report (FakeInputWriter.SNAPSHOT_IDX_QAM). +static constexpr uint32_t SNAPSHOT_QAM_BIT = 1u << 11; + +struct DeckHidraw { + int peer = -1; + FakeInputRingHeader *ring = nullptr; + size_t mapping_size = 0; + uint8_t pending_feature = 0; +}; + +// What the Deck has and an Xbox pad does not, after the ring's events: motion (PadMotion) and the +// back grips and trackpads (the second screen's Deck controls, DeckControls), already in the +// Deck's axes and units, under a seqlock (FakeInputWriter.writeMotion / writeDeckControls). +// Absent from a ring file written without one. +struct DeckImu { + uint32_t magic; // IMU1 once written + uint32_t reserved; + uint64_t seq; // odd while being written + int16_t accel[3]; + int16_t gyro[3]; + int16_t pads[4]; // left X, Y, right X, Y: -32768..32767, Y up + uint16_t pressure[2]; // left, right: 0..32767 + uint32_t controls; // DECK_EXTRA_* bits +}; +// DeckImu::controls (DeckControls.kt). +static constexpr uint32_t DECK_EXTRA_L4 = 1, DECK_EXTRA_R4 = 2, DECK_EXTRA_L5 = 4, + DECK_EXTRA_R5 = 8, DECK_EXTRA_LPAD_TOUCH = 16, + DECK_EXTRA_RPAD_TOUCH = 32, DECK_EXTRA_LPAD_CLICK = 64, + DECK_EXTRA_RPAD_CLICK = 128; + +struct DeckExtras { + int16_t accel[3] = {}; + int16_t gyro[3] = {}; + int16_t pads[4] = {}; + uint16_t pressure[2] = {}; + uint32_t controls = 0; +}; +static constexpr uint32_t DECK_IMU_MAGIC = 0x31554D49; +static constexpr size_t DECK_IMU_BLOCK_SIZE = 64; +static_assert(sizeof(DeckImu) <= DECK_IMU_BLOCK_SIZE, "the IMU block is 64 bytes in the ring file"); + +__attribute__((visibility("hidden"))) static bool read_deck_extras(const DeckHidraw &deck, DeckExtras &out) { + if (deck.mapping_size < FAKE_INPUT_RING_SIZE + DECK_IMU_BLOCK_SIZE) return false; + const auto *imu = reinterpret_cast(reinterpret_cast(deck.ring) + + FAKE_INPUT_RING_SIZE); + for (int attempt = 0; attempt < 4; attempt++) { + uint64_t seq = __atomic_load_n(&imu->seq, __ATOMIC_ACQUIRE); + if (seq & 1) continue; + if (__atomic_load_n(&imu->magic, __ATOMIC_RELAXED) != DECK_IMU_MAGIC) return false; + DeckExtras read; + for (int i = 0; i < 3; i++) { + read.accel[i] = __atomic_load_n(&imu->accel[i], __ATOMIC_RELAXED); + read.gyro[i] = __atomic_load_n(&imu->gyro[i], __ATOMIC_RELAXED); + } + for (int i = 0; i < 4; i++) read.pads[i] = __atomic_load_n(&imu->pads[i], __ATOMIC_RELAXED); + for (int i = 0; i < 2; i++) read.pressure[i] = __atomic_load_n(&imu->pressure[i], __ATOMIC_RELAXED); + read.controls = __atomic_load_n(&imu->controls, __ATOMIC_RELAXED); + __atomic_thread_fence(__ATOMIC_ACQUIRE); + if (seq == __atomic_load_n(&imu->seq, __ATOMIC_RELAXED)) { + out = read; + return true; + } + } + return false; +} + +static std::unordered_map> &deck_map() { + static auto *map = new std::unordered_map>(); + return *map; +} + +static bool fake_deck_enabled() { + static int enabled = -1; + if (enabled < 0) enabled = getenv("FAKE_EVDEV_DECK") && atoi(getenv("FAKE_EVDEV_DECK")) ? 1 : 0; + return enabled == 1; +} + +// The Deck is the client's alone: a game that found it would have the pad twice, once through it +// and once through Steam Input's virtual pad. +__attribute__((visibility("hidden"))) static bool process_is_steam_client() { + static int known; + if (known == 0) { + char exe[PATH_MAX]; + ssize_t length = readlink("/proc/self/exe", exe, sizeof(exe) - 1); + bool client = false; + if (length > 0) { + exe[length] = '\0'; + const char *name = strrchr(exe, '/'); + client = !strcmp(name ? name + 1 : exe, "steam"); + } + known = client ? 1 : -1; + } + return known == 1; +} + +__attribute__((visibility("hidden"))) static bool is_deck_hidraw_path(const char *pathname) { + 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. +__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; +} + +static inline void put16(uint8_t *at, int value) { + at[0] = static_cast(value & 0xff); + at[1] = static_cast((value >> 8) & 0xff); +} + +static inline void put32(uint8_t *at, uint32_t value) { + for (int i = 0; i < 4; i++) at[i] = static_cast(value >> (8 * i)); +} + +// The Deck's state report (SDL, controller_structs.h: ValveInReportHeader_t then +// SteamDeckStatePacket_t) from the ring's snapshot: kSnapshotButtons order, kSnapshotAxisCodes +// order, sticks as evdev has them (Y down), triggers 0..255. +__attribute__((visibility("hidden"))) static void +build_deck_report(uint8_t *report, uint32_t packet, uint32_t buttons, const int32_t *axes, + const DeckExtras &extras) { + memset(report, 0, DECK_REPORT_BYTES); + report[0] = 0x01; // report version + report[2] = 0x09; // ID_CONTROLLER_DECK_STATE + report[3] = DECK_REPORT_BYTES; + put32(report + 4, packet); + static const uint32_t kButtonBits[11] = {DECK_L_A, DECK_L_B, DECK_L_X, DECK_L_Y, + DECK_L_L1, DECK_L_R1, DECK_L_VIEW, DECK_L_MENU, + DECK_L_L3, DECK_L_R3, DECK_L_STEAM}; + uint32_t low = 0, high = 0; + for (int i = 0; i < 11; i++) + if (buttons & (1u << i)) low |= kButtonBits[i]; + if (buttons & SNAPSHOT_QAM_BIT) high |= DECK_H_QAM; + int32_t hat_x = axes[6], hat_y = axes[7]; + if (hat_y < 0) low |= DECK_L_UP; + if (hat_y > 0) low |= DECK_L_DOWN; + if (hat_x < 0) low |= DECK_L_LEFT; + if (hat_x > 0) low |= DECK_L_RIGHT; + int32_t left_trigger = axes[5], right_trigger = axes[4]; // ABS_BRAKE, ABS_GAS + if (left_trigger > 127) low |= DECK_L_L2; + if (right_trigger > 127) low |= DECK_L_R2; + uint32_t controls = extras.controls; + if (controls & DECK_EXTRA_L4) high |= DECK_H_L4; + if (controls & DECK_EXTRA_R4) high |= DECK_H_R4; + if (controls & DECK_EXTRA_L5) low |= DECK_L_L5; + if (controls & DECK_EXTRA_R5) low |= DECK_L_R5; + if (controls & DECK_EXTRA_LPAD_TOUCH) low |= DECK_L_LEFT_TOUCH; + if (controls & DECK_EXTRA_RPAD_TOUCH) low |= DECK_L_RIGHT_TOUCH; + if (controls & DECK_EXTRA_LPAD_CLICK) low |= DECK_L_LEFT_PAD; + if (controls & DECK_EXTRA_RPAD_CLICK) low |= DECK_L_RIGHT_PAD; + put32(report + 8, low); + put32(report + 12, high); + for (int i = 0; i < 4; i++) put16(report + 16 + i * 2, extras.pads[i]); // s{Left,Right}PadX/Y + for (int i = 0; i < 3; i++) { + put16(report + 24 + i * 2, extras.accel[i]); // sAccelX/Y/Z + put16(report + 30 + i * 2, extras.gyro[i]); // sGyroX/Y/Z + } + put16(report + 56, extras.pressure[0]); // sPressurePadLeft + put16(report + 58, extras.pressure[1]); // sPressurePadRight + put16(report + 44, std::min(32767, left_trigger * 32767 / 255)); + put16(report + 46, std::min(32767, right_trigger * 32767 / 255)); + auto stick = [](int32_t value) { return std::max(-32767, std::min(32767, value)); }; + put16(report + 48, stick(axes[0])); + put16(report + 50, stick(-axes[1])); + put16(report + 52, stick(axes[2])); + put16(report + 54, stick(-axes[3])); +} + +__attribute__((visibility("hidden"))) static void *deck_report_thread(void *arg) { + auto *deck = static_cast *>(arg); + std::shared_ptr self = *deck; + delete deck; + uint32_t packet = 0; + SnapshotState snap; + uint32_t buttons = 0; + int32_t axes[8] = {}; + DeckExtras extras; + for (;;) { + if (read_snapshot(self->ring, snap) && snap.generation == ring_generation(self->ring)) { + buttons = snap.buttons; + memcpy(axes, snap.axes, sizeof(axes)); + } + read_deck_extras(*self, extras); + uint8_t report[DECK_REPORT_BYTES]; + build_deck_report(report, ++packet, buttons, axes, extras); + // A reader that fell behind loses reports rather than getting stale ones later; a reader that + // has closed ends the stream. + if (send(self->peer, report, sizeof(report), MSG_DONTWAIT | MSG_NOSIGNAL) < 0 && + errno != EAGAIN && errno != EWOULDBLOCK) + break; + struct timespec interval = {0, DECK_REPORT_INTERVAL_US * 1000L}; + nanosleep(&interval, nullptr); + } + syscall(SYS_close, self->peer); + munmap(self->ring, self->mapping_size); + return nullptr; +} + +__attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags) { + static auto my_open = reinterpret_cast(dlsym(RTLD_NEXT, "open")); + if (!process_is_steam_client()) { + errno = ENOENT; + return -1; + } + std::string ring_path = get_ring_path_for_slot(0); + int ring_fd = ring_path.empty() ? -1 : my_open(ring_path.c_str(), O_RDONLY | O_CLOEXEC); + if (ring_fd < 0) { + errno = ENODEV; + return -1; + } + // The IMU block is mapped only where the file has one: past its end a mapping faults. + struct stat ring_stat; + size_t mapping_size = FAKE_INPUT_RING_SIZE; + if (fstat(ring_fd, &ring_stat) == 0 && + static_cast(ring_stat.st_size) >= FAKE_INPUT_RING_SIZE + DECK_IMU_BLOCK_SIZE) + mapping_size = FAKE_INPUT_RING_SIZE + DECK_IMU_BLOCK_SIZE; + void *mapping = mmap(nullptr, mapping_size, PROT_READ, MAP_SHARED, ring_fd, 0); + syscall(SYS_close, ring_fd); + if (mapping == MAP_FAILED || !ring_header_is_valid(static_cast(mapping))) { + if (mapping != MAP_FAILED) munmap(mapping, mapping_size); + errno = ENODEV; + return -1; + } + int pair[2]; + if (socketpair(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0, pair) < 0) { + munmap(mapping, mapping_size); + return -1; + } + // Room for a few reports only, so what the client reads is never more than a few ms old. + int buffer = DECK_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 deck = std::make_shared(); + deck->peer = pair[1]; + deck->ring = static_cast(mapping); + deck->mapping_size = mapping_size; + { + std::lock_guard guard(controller_mutex()); + deck_map()[pair[0]] = deck; + } + pthread_t thread; + auto *arg = new std::shared_ptr(deck); + if (pthread_create(&thread, nullptr, deck_report_thread, arg) != 0) { + delete arg; + std::lock_guard guard(controller_mutex()); + deck_map().erase(pair[0]); + syscall(SYS_close, pair[0]); + syscall(SYS_close, pair[1]); + munmap(mapping, mapping_size); + errno = ENOMEM; + return -1; + } + pthread_detach(thread); + Logger::log("deck: %s opened as fd %d\n", DECK_HIDRAW_PATH, pair[0]); + return pair[0]; +} + +// 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. +static constexpr long SYSFS_FS_MAGIC = 0x62656572; + +__attribute__((visibility("hidden"))) static bool is_deck_sysfs_path(const char *path) { + return path && (!strncmp(path, "/sys/devices/droiddeck", 22) || !strncmp(path, "/sys/class/hidraw", 17)); +} + +__attribute__((visibility("hidden"))) static bool is_deck_sysfs_fd(int fd) { + char link[64], path[PATH_MAX]; + snprintf(link, sizeof(link), "/proc/self/fd/%d", fd); + ssize_t length = readlink(link, path, sizeof(path) - 1); + if (length <= 0) return false; + path[length] = '\0'; + return is_deck_sysfs_path(path); +} + +EXPORT int fstatfs(int fd, struct statfs *buf) { + static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "fstatfs")); + int result = real(fd, buf); + if (result == 0 && fake_deck_enabled() && is_deck_sysfs_fd(fd)) buf->f_type = SYSFS_FS_MAGIC; + return result; +} + +EXPORT int statfs(const char *path, struct statfs *buf) { + static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "statfs")); + int result = real(path, buf); + if (result == 0 && fake_deck_enabled() && is_deck_sysfs_path(path)) buf->f_type = SYSFS_FS_MAGIC; + return result; +} + +#ifdef __GLIBC__ +EXPORT int fstatfs64(int fd, struct statfs64 *buf) { + return fstatfs(fd, reinterpret_cast(buf)); +} + +EXPORT int statfs64(const char *path, struct statfs64 *buf) { + return statfs(path, reinterpret_cast(buf)); +} +#endif + +__attribute__((visibility("hidden"))) static int copy_ioctl_string(ioctl_request_t op, void *argp, const char *value) { + size_t size = _IOC_SIZE(op); + if (!argp || !size) return 0; + snprintf(static_cast(argp), size, "%s", value); + return static_cast(std::min(size, strlen(value) + 1)); +} + +// Feature reports carry the report id (0) first, then the Valve message: type, length, payload. +__attribute__((visibility("hidden"))) static int +deck_feature(DeckHidraw &deck, ioctl_request_t op, uint8_t *buf, bool set) { + size_t size = _IOC_SIZE(op); + if (!buf || size < 2) { + errno = EINVAL; + return -1; + } + if (set) { + deck.pending_feature = buf[1]; + if (buf[1] == DECK_TRIGGER_RUMBLE_CMD && size >= 10) { + int left = buf[6] | buf[7] << 8, right = buf[8] | buf[9] << 8; + send_vibration(left, right, left || right ? 1000 : 0, 0); + } + Logger::log("deck: feature 0x%02x set\n", buf[1]); + return static_cast(size); + } + uint8_t reply[DECK_REPORT_BYTES + 1] = {}; + switch (deck.pending_feature) { + case DECK_GET_ATTRIBUTES_VALUES: { + // InputPlumber's capture from a real Deck (steam_deck_uhid.rs, handle_get_report). + static const uint8_t kAttributes[] = {0x00, 0x83, 0x2d, 0x01, 0x05, 0x12, 0x00, 0x00, 0x02, + 0x00, 0x00, 0x00, 0x00, 0x0a, 0x2b, 0x12, 0xa9, 0x62, + 0x04, 0xad, 0xf1, 0xe4, 0x65, 0x09, 0x2e, 0x00, 0x00, + 0x00, 0x0b, 0xa0, 0x0f, 0x00, 0x00, 0x0d, 0x00, 0x00, + 0x00, 0x00, 0x0c, 0x00, 0x00, 0x00, 0x00, 0x0e}; + memcpy(reply, kAttributes, sizeof(kAttributes)); + break; + } + case DECK_GET_STRING_ATTRIBUTE: + reply[1] = DECK_GET_STRING_ATTRIBUTE; + reply[2] = 0x14; + reply[3] = 0x01; + memcpy(reply + 4, DECK_SERIAL, strlen(DECK_SERIAL)); + break; + case DECK_GET_CHIP_ID: + reply[1] = DECK_GET_CHIP_ID; + reply[2] = 0x11; + memcpy(reply + 4, "DROIDDECKCHIP01", 15); + break; + default: + reply[1] = deck.pending_feature; + break; + } + Logger::log("deck: feature 0x%02x read\n", deck.pending_feature); + size_t length = std::min(size, sizeof(reply)); + memcpy(buf, reply, length); + return static_cast(length); +} + +__attribute__((visibility("hidden"))) static int +ioctl_deck(DeckHidraw &deck, ioctl_request_t op, void *argp) { + if (_IOC_TYPE(op) != 'H') { + errno = ENOTTY; + return -1; + } + switch (_IOC_NR(op)) { + case 0x01: // HIDIOCGRDESCSIZE + *static_cast(argp) = sizeof(kDeckReportDescriptor); + return 0; + case 0x02: { // HIDIOCGRDESC: struct hidraw_report_descriptor { __u32 size; __u8 value[4096]; } + auto *descriptor = static_cast(argp); + uint32_t size; + memcpy(&size, descriptor, sizeof(size)); + memcpy(descriptor + 4, kDeckReportDescriptor, std::min(size, sizeof(kDeckReportDescriptor))); + return 0; + } + case 0x03: { // HIDIOCGRAWINFO: struct hidraw_devinfo { __u32 bustype; __s16 vendor, product; } + struct { + uint32_t bustype; + int16_t vendor; + int16_t product; + } info = {BUS_USB, static_cast(0x28de), static_cast(0x1205)}; + memcpy(argp, &info, sizeof(info)); + return 0; + } + case 0x04: return copy_ioctl_string(op, argp, DECK_NAME); // HIDIOCGRAWNAME + case 0x05: return copy_ioctl_string(op, argp, "usb-droiddeck-1/input2"); // HIDIOCGRAWPHYS + case 0x08: return copy_ioctl_string(op, argp, DECK_SERIAL); // HIDIOCGRAWUNIQ + case 0x06: return deck_feature(deck, op, static_cast(argp), true); // HIDIOCSFEATURE + case 0x07: return deck_feature(deck, op, static_cast(argp), false); // HIDIOCGFEATURE + default: + Logger::log("deck: unhandled hidraw ioctl 0x%02x\n", _IOC_NR(op)); + errno = EINVAL; + return -1; + } +} + EXPORT int open(const char *pathname, int flags, ...) { va_list va; mode_t mode; @@ -725,6 +1576,13 @@ EXPORT int open(const char *pathname, int flags, ...) { static auto my_open = reinterpret_cast(dlsym(RTLD_NEXT, "open")); + 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_withdrawn_pad_path(pathname)) { + errno = ENOENT; + return -1; + } + char *fake_path = nullptr; const char *event = nullptr; if (pathname) { @@ -788,6 +1646,13 @@ EXPORT int openat(int dirfd, const char *pathname, int flags, ...) { static auto my_openat = reinterpret_cast(dlsym(RTLD_NEXT, "openat")); + 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_withdrawn_pad_path(pathname)) { + errno = ENOENT; + return -1; + } + char *fake_path = nullptr; const char *event = nullptr; if (pathname) { @@ -836,6 +1701,17 @@ EXPORT int openat(int dirfd, const char *pathname, int flags, ...) { EXPORT int stat(const char *pathname, struct stat *statbuf) { static auto my_stat = reinterpret_cast(dlsym(RTLD_NEXT, "stat")); + if (is_withdrawn_pad_path(pathname) || (is_deck_hidraw_path(pathname) && !process_is_steam_client())) { + errno = ENOENT; + return -1; + } + if (is_deck_hidraw_path(pathname)) { + memset(statbuf, 0, sizeof(*statbuf)); + statbuf->st_mode = S_IFCHR | 0666; + statbuf->st_rdev = makedev(DECK_HIDRAW_MAJOR, DECK_HIDRAW_MINOR); + return 0; + } + const char *event = nullptr; char *fake_path = nullptr; @@ -879,6 +1755,11 @@ EXPORT int fstat(int fd, struct stat *buf) { int ret = my_fstat(fd, buf); std::lock_guard 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); + return ret; + } auto controller = controller_map().find(fd); if (ret == 0 && controller != controller_map().end()) { buf->st_mode = (buf->st_mode & ~S_IFMT) | S_IFCHR; @@ -890,6 +1771,12 @@ EXPORT int fstat(int fd, struct stat *buf) { EXPORT int access(const char *pathname, int mode) { static auto my_access = reinterpret_cast(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_withdrawn_pad_path(pathname) || is_deck_hidraw_path(pathname)) { + errno = ENOENT; + return -1; + } char *fake_path = nullptr; if (pathname) { @@ -920,6 +1807,12 @@ EXPORT int access(const char *pathname, int mode) { EXPORT int faccessat(int dirfd, const char *pathname, int mode, int flags) { static auto my_faccessat = reinterpret_cast(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_withdrawn_pad_path(pathname) || is_deck_hidraw_path(pathname)) { + errno = ENOENT; + return -1; + } char *fake_path = nullptr; if (pathname) { @@ -1034,6 +1927,10 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) { // blocking ioctl(BINDER_WRITE_READ), so a parked binder pool thread would own // controller_mutex() for as long as it waits and deadlock every other ioctl. std::unique_lock guard(controller_mutex()); + 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); auto controller = controller_map().find(fd); if (controller == controller_map().end()) { guard.unlock(); @@ -1044,6 +1941,8 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) { int number = (op >> 0 & 0xFF); const char *event = controller->second->event ? controller->second->event : "event0"; int event_number = controller->second->slot; + // A virtual pad answers with what its maker described, not with the app's pad identity. + const UinputDescription *made = controller->second->uinput; if (type == 0x45 && number == 0x1) { Logger::log("Hooking ioctl EVIOCGVERSION for event %s\n", event); @@ -1055,7 +1954,9 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) { struct input_id id; memset(&id, 0, sizeof(id)); id.bustype = 0x03; - if (presents_steam_virtual()) { + if (made) { + id = made->id; + } else if (presents_steam_virtual()) { id.vendor = STEAM_VIRTUAL_VENDOR_ID; id.product = STEAM_VIRTUAL_PRODUCT_ID; } else if (fake_xbox360_identity()) { @@ -1065,11 +1966,15 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) { id.vendor = static_cast(GAMEPAD_VENDOR_ID_BASE + event_number); id.product = static_cast(GAMEPAD_PRODUCT_ID_BASE + event_number); } - id.version = GAMEPAD_VERSION; + if (!made) id.version = GAMEPAD_VERSION; memcpy(argp, (void *)&id, sizeof(id)); return 0; } else if (type == 0x45 && number == 0x6) { Logger::log("Hooking ioctl EVIOCGNAME for event %s\n", event); + if (made) { + if (argp && _IOC_SIZE(op)) snprintf(static_cast(argp), _IOC_SIZE(op), "%s", made->name); + return 0; + } copy_slot_ioctl_string(op, argp, presents_steam_virtual() ? STEAM_VIRTUAL_NAME_TEMPLATE : (fake_xbox360_identity() ? X360_NAME_TEMPLATE : GAMEPAD_NAME_TEMPLATE), @@ -1077,10 +1982,18 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) { return 0; } else if (type == 0x45 && number == 0x7) { Logger::log("Hooking ioctl EVIOCGPHYS for event %s\n", event); + if (made) { + if (argp && _IOC_SIZE(op)) snprintf(static_cast(argp), _IOC_SIZE(op), "%s", made->phys); + return 0; + } copy_slot_ioctl_string(op, argp, GAMEPAD_PHYS_TEMPLATE, event_number); return 0; } else if (type == 0x45 && number == 0x8) { Logger::log("Hooking ioctl EVIOCGUNIQ for event %s\n", event); + if (made) { + if (argp && _IOC_SIZE(op)) static_cast(argp)[0] = '\0'; + return 0; + } copy_slot_ioctl_string(op, argp, GAMEPAD_UNIQ_TEMPLATE, event_number); return 0; } else if (type == 0x45 && number == 0x9) { @@ -1097,6 +2010,7 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) { return copy_ioctl_bits(op, argp, bitmask); } else if (type == 0x45 && number == 0x20) { Logger::log("Hooking ioctl EVIOCGBIT(0, len) for event %s\n", event); + if (made) return copy_ioctl_bits(op, argp, made->evbits); unsigned char bitmask[(EV_MAX + 8) / 8] = {}; bitmask[EV_SYN / 8] |= (1 << (EV_SYN % 8)); bitmask[EV_KEY / 8] |= (1 << (EV_KEY % 8)); @@ -1104,6 +2018,7 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) { return copy_ioctl_bits(op, argp, bitmask); } else if (type == 0x45 && number == 0x21) { Logger::log("Hooking ioctl EVIOCGBIT(EV_KEY, len) for event %s\n", event); + if (made) return copy_ioctl_bits(op, argp, made->keybits); unsigned char bitmask[(KEY_MAX + 8) / 8] = {}; const int xbox_buttons[] = {BTN_A, BTN_B, BTN_X, BTN_Y, BTN_TL, BTN_TR, BTN_SELECT, BTN_START, @@ -1117,13 +2032,14 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) { return copy_ioctl_bits(op, argp, bitmask); } else if (type == 0x45 && number == 0x23) { Logger::log("Hooking ioctl EVIOCGBIT(EV_ABS, len) for event %s\n", event); + if (made) return copy_ioctl_bits(op, argp, made->absbits); unsigned char bitmask[(ABS_MAX + 8) / 8] = {}; bitmask[ABS_X / 8] |= (1 << (ABS_X % 8)); bitmask[ABS_Y / 8] |= (1 << (ABS_Y % 8)); bitmask[ABS_RX / 8] |= (1 << (ABS_RX % 8)); bitmask[ABS_RY / 8] |= (1 << (ABS_RY % 8)); for (uint16_t trigger : {ABS_GAS, ABS_BRAKE}) { - uint16_t code = presented_abs_code(trigger); + uint16_t code = presented_abs_code(*controller->second, trigger); bitmask[code / 8] |= (1 << (code % 8)); } bitmask[ABS_HAT0X / 8] |= (1 << (ABS_HAT0X % 8)); @@ -1131,6 +2047,7 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) { return copy_ioctl_bits(op, argp, bitmask); } else if (type == 0x45 && number == 0x35) { Logger::log("Hooking ioctl EVIOCGBIT(EV_FF, len) for event %s\n", event); + if (made) return copy_ioctl_bits(op, argp, made->ffbits); unsigned char bitmask[(FF_MAX + 8) / 8] = {}; bitmask[FF_RUMBLE / 8] |= (1 << (FF_RUMBLE % 8)); bitmask[FF_PERIODIC / 8] |= (1 << (FF_PERIODIC % 8)); @@ -1163,8 +2080,10 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) { Logger::log("Hooking ioctl EVIOCGABS(ABS) for event %s\n", event); struct input_absinfo abs_info; memset(&abs_info, 0, sizeof(abs_info)); - uint16_t code = ring_abs_code(number - 0x40); - if (code == ABS_GAS || code == ABS_BRAKE) { + uint16_t code = ring_abs_code(*controller->second, number - 0x40); + if (made) { + abs_info = made->absinfo[number - 0x40]; + } else if (code == ABS_GAS || code == ABS_BRAKE) { abs_info.value = 0; abs_info.minimum = 0; abs_info.maximum = 255; @@ -1218,7 +2137,9 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) { EXPORT int close(int fd) { static auto my_close = reinterpret_cast(dlsym(RTLD_NEXT, "close")); + close_uinput(fd); std::unique_lock guard(controller_mutex()); + deck_map().erase(fd); auto controller = controller_map().find(fd); if (controller != controller_map().end()) { Logger::log("Removing controller, fd %d event %s\n", controller->first, @@ -1283,7 +2204,7 @@ EXPORT ssize_t read(int fd, void *buf, size_t count) { size_t index = (fake.read_seq + i) % FAKE_INPUT_RING_CAPACITY; struct input_event ev; memcpy(&ev, ring_events + index * FAKE_INPUT_EVENT_SIZE, FAKE_INPUT_EVENT_SIZE); - if (ev.type == EV_ABS) ev.code = presented_abs_code(ev.code); + if (ev.type == EV_ABS) ev.code = presented_abs_code(fake, ev.code); memcpy(static_cast(buf) + i * FAKE_INPUT_EVENT_SIZE, &ev, FAKE_INPUT_EVENT_SIZE); } @@ -1309,7 +2230,7 @@ EXPORT ssize_t read(int fd, void *buf, size_t count) { ev.type = kNeutralEvents[index].type; ev.code = kNeutralEvents[index].code; ev.value = keyframe_value(fake, ev.type, ev.code); - if (ev.type == EV_ABS) ev.code = presented_abs_code(ev.code); + if (ev.type == EV_ABS) ev.code = presented_abs_code(fake, ev.code); memcpy(static_cast(buf) + i * FAKE_INPUT_EVENT_SIZE, &ev, FAKE_INPUT_EVENT_SIZE); --fake.keyframe_remaining; @@ -1333,6 +2254,9 @@ EXPORT ssize_t write(int fd, const void *buf, size_t count) { static auto my_write = reinterpret_cast(dlsym(RTLD_NEXT, "write")); std::unique_lock 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(count); auto controller = controller_map().find(fd); if (controller != controller_map().end()) { if (fake_fd_is_stale(fd)) { @@ -1355,6 +2279,22 @@ EXPORT ssize_t write(int fd, const void *buf, size_t count) { EXPORT ssize_t writev(int fd, const struct iovec *iov, int iovcnt) { std::unique_lock guard(controller_mutex()); + if (deck_map().count(fd)) { + ssize_t total = 0; + for (int i = 0; i < iovcnt; i++) total += static_cast(iov[i].iov_len); + return total; + } + auto made = uinput_map().find(fd); + if (made != uinput_map().end()) { + // One description or whole events per vector, which is how uinput writers write. + ssize_t total = 0; + for (int i = 0; i < iovcnt; i++) { + ssize_t written = write_uinput(*made->second, iov[i].iov_base, iov[i].iov_len); + if (written < 0) return total ? total : -1; + total += written; + } + return total; + } auto controller = controller_map().find(fd); if (controller != controller_map().end()) { if (fake_fd_is_stale(fd)) { diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index c35d2c20b..bd606a0d9 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -104,6 +104,7 @@ class MainActivity : ComponentActivity() { private var glThread by mutableStateOf(true) private var noGlError by mutableStateOf(true) private var steamDeckMode by mutableStateOf(false) + private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK) private var showCredits by mutableStateOf(false) private var showProtons by mutableStateOf(false) // Components page: FEX / DXVK / VKD3D-Proton per Proton (ComponentsManager). @@ -808,6 +809,7 @@ class MainActivity : ComponentActivity() { fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null, steamChannel = if (mode == SessionService.MODE_STEAM) steamChannel else null, steamDeckMode = mode == SessionService.MODE_STEAM && steamDeckMode, + steamController = if (mode == SessionService.MODE_STEAM) steamController else null, runSteamAtStartup = mode == SessionService.MODE_STEAM && runSteamAtStartup, addedGamesDirs = if (mode == SessionService.MODE_STEAM) addedGamesDirs else null, addedGames = if (mode == SessionService.MODE_STEAM) addedGames else emptyList(), @@ -864,6 +866,7 @@ class MainActivity : ComponentActivity() { steamDeckMode = on steamChannel = SessionPrefs.steamChannel(this) }, + onSteamController = { id -> SessionPrefs.setSteamController(this, id); steamController = id }, onRunSteamAtStartup = { on -> SessionPrefs.setRunSteamAtStartup(this, on) runSteamAtStartup = on @@ -949,6 +952,7 @@ class MainActivity : ComponentActivity() { fexPreset = SessionPrefs.fexPreset(this) steamChannel = SessionPrefs.steamChannel(this) steamDeckMode = SessionPrefs.steamDeckMode(this) + steamController = SessionPrefs.steamController(this) runSteamAtStartup = SessionPrefs.runSteamAtStartup(this) addedGamesDirs = SessionPrefs.addedGamesDirs(this) refreshAddedGames() diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 71f52be82..141c0a3c4 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -91,6 +91,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private lateinit var loading: LoadingState private lateinit var hud: PerfHud private var padBridge: PadBridge? = null + /** The handheld's gyro and accelerometer, fed to the Deck controller while the session shows. */ + private var padMotion: com.droiddeck.launcher.input.PadMotion? = null + /** Between onResume and onPause. */ + private var resumed = false + private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion() } } + + /** Motion is read while the session shows and only if the pad is a Deck controller - which + * the service can decide after this activity has resumed (SessionState.deckPadListener). */ + private fun updatePadMotion() { + if (resumed && SessionState.deckPad) padMotion?.start() else padMotion?.stop() + } private var onScreenControls: OnScreenControls? = null private var keyboard: KeyboardHost? = null private var watching = true @@ -165,9 +176,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL) private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO) private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO) - private var secondScreenMode by mutableStateOf(SecondScreenMode.NONE) + private var secondScreenMode by mutableStateOf(SessionState.secondScreenMode) private var secondScreenDisplays by mutableStateOf>(emptyList()) - private var selectedSecondScreenDisplay by mutableStateOf(-1) + private var selectedSecondScreenDisplay by mutableStateOf(SessionState.secondScreenDisplay) + /** Between onStart and onStop: while stopped the second screen stays closed, its choice kept. */ + private var started = false private lateinit var displayManager: DisplayManager private var secondScreenPresentation: SecondScreenPresentation? = null private val displayListener = object : DisplayManager.DisplayListener { @@ -223,6 +236,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { val bridge = PadBridge(File(LinuxRuntime.sessionRoot(this), "dev/input")) padBridge = bridge + padMotion = com.droiddeck.launcher.input.PadMotion(this) { + @Suppress("DEPRECATION") + (if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0 + } + SessionState.deckPadListener = deckPadListener // A player on the pad or the on-screen controls has no use for the mouse arrow; the next // touchpad or mouse move brings it back (showCursor), once the pad has been quiet a moment. bridge.setOnPlayerInput { @@ -1274,11 +1292,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } if (selectedSecondScreenDisplay !in newIds) selectedSecondScreenDisplay = candidates.firstOrNull()?.id ?: -1 if (secondScreenMode != SecondScreenMode.NONE && candidates.isEmpty()) closeSecondScreen(reset = true) + // A choice kept while its display was away (asleep, waking): back as soon as it is. + else if (started && secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode) } private fun selectSecondScreenDisplay(displayId: Int) { if (secondScreenDisplays.none { it.id == displayId }) return selectedSecondScreenDisplay = displayId + SessionState.secondScreenDisplay = displayId if (secondScreenMode != SecondScreenMode.NONE) showSecondScreen(secondScreenMode) } @@ -1300,8 +1321,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private fun showSecondScreen(mode: SecondScreenMode) { val target = displayManager.getDisplay(selectedSecondScreenDisplay) if (target == null || !target.isValid || (target.flags and Display.FLAG_PRESENTATION) == 0) { - closeSecondScreen(reset = true) - refreshSecondScreenDisplays() + // Not there right now - a panel still waking, say. The choice is kept; the display + // listener shows it again when the display comes back (refreshSecondScreenDisplays). + closeSecondScreen(reset = false) return } var presentation = secondScreenPresentation @@ -1333,6 +1355,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (!presentation.isShowing) presentation.show() presentation.showMode(mode) secondScreenMode = mode + SessionState.secondScreenMode = mode + SessionState.secondScreenDisplay = selectedSecondScreenDisplay } catch (e: Exception) { Log.w(TAG, "could not show second-screen controls on ${target.name}", e) closeSecondScreen(reset = true) @@ -1352,7 +1376,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private fun closeSecondScreen(reset: Boolean) { secondScreenPresentation?.closeControls() secondScreenPresentation = null - if (reset) secondScreenMode = SecondScreenMode.NONE + if (reset) { + secondScreenMode = SecondScreenMode.NONE + SessionState.secondScreenMode = SecondScreenMode.NONE + } } /** Touchpad on the desktop, direct in Steam, unless the drawer says otherwise. */ @@ -1465,15 +1492,19 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun onStart() { super.onStart() + started = true displayManager.registerDisplayListener(displayListener, Handler(Looper.getMainLooper())) refreshSecondScreenDisplays() - if (secondScreenMode != SecondScreenMode.NONE) showSecondScreen(secondScreenMode) + if (secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode) SessionService.setActivityVisible(this, true) } override fun onStop() { + started = false displayManager.unregisterDisplayListener(displayListener) - closeSecondScreen(reset = true) + // Closed while the session is out of sight (sleep, a closed lid, another app), and kept: + // onStart puts it back. + closeSecondScreen(reset = false) SessionService.setActivityVisible(this, false) super.onStop() } @@ -1510,6 +1541,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { ?.registerInputDeviceListener(deviceListener, Handler(Looper.getMainLooper())) onScreenControls?.reload() updateOnScreenControls() + resumed = true + updatePadMotion() readPrefs() if (CompositorHost.isStarted) applyFrameGen() } @@ -1523,6 +1556,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { releaseDrawerDirection() // A button held when the app goes away would stay held in the ring for the whole session. onScreenControls?.releaseAll() + resumed = false + updatePadMotion() keyboard?.takeIf { it.shown }?.hide() super.onPause() } @@ -1576,6 +1611,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { closeSecondScreen(reset = true) if (::hud.isInitialized) hud.stop() padBridge?.stop() + padMotion?.stop() + if (SessionState.deckPadListener === deckPadListener) SessionState.deckPadListener = null if (SessionState.endListener === endListener) SessionState.endListener = null WaylandCompositor.clearFirstFrameListener(firstFrameListener) super.onDestroy() diff --git a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt index 944b752a6..84c1d038b 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt @@ -144,6 +144,7 @@ object DeviceReport { k("Threaded GL (glthread)", SessionPrefs.glThread(context)) k("No GL error checks", SessionPrefs.noGlError(context)) k("Steam Deck mode", SessionPrefs.steamDeckMode(context)) + k("Steam controller", SessionPrefs.steamController(context)) k("FEX preset", SessionPrefs.fexPreset(context).ifEmpty { "FEX defaults" }) k("Skip xalia", SessionPrefs.noXalia(context)) k("gamescope realtime", SessionPrefs.gamescopeRealtime(context)) diff --git a/app/src/main/java/com/droiddeck/launcher/input/DeckControls.kt b/app/src/main/java/com/droiddeck/launcher/input/DeckControls.kt new file mode 100644 index 000000000..23146be37 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/DeckControls.kt @@ -0,0 +1,69 @@ +package com.droiddeck.launcher.input + +/** + * The Steam Deck controller's back grips and trackpads, which a handheld's own pad does not have: + * the second screen's Deck controls (DeckControlsPanel) drive them, and each change goes into the + * pad's ring ([FakeInputWriter.writeDeckControls]) for libfakeinput's Deck report. + * + * Trackpad positions are -1..1 each way with y up, as on a Deck; a pad touched reports its + * position, a pad clicked also reports full pressure. + */ +object DeckControls { + /** Bits of the ring's controls word - MUST match DECK_EXTRA_* in fakeinput_steam.cpp. */ + const val L4 = 1 + const val R4 = 2 + const val L5 = 4 + const val R5 = 8 + private const val LEFT_TOUCH = 16 + private const val RIGHT_TOUCH = 32 + private const val LEFT_CLICK = 64 + private const val RIGHT_CLICK = 128 + + /** The pad the Deck controller is made from (PadBridge's slot). */ + private const val SLOT = 0 + + private var controls = 0 + private val pads = ShortArray(4) + private val pressure = ShortArray(2) + + @Synchronized + fun setGrip(bit: Int, down: Boolean) { + set(bit, down) + publish() + } + + /** A finger on (or off) one trackpad at x, y in -1..1, y up. */ + @Synchronized + fun setPad(right: Boolean, touching: Boolean, x: Float = 0f, y: Float = 0f) { + val at = if (right) 2 else 0 + set(if (right) RIGHT_TOUCH else LEFT_TOUCH, touching) + pads[at] = if (touching) axis(x) else 0 + pads[at + 1] = if (touching) axis(y) else 0 + publish() + } + + @Synchronized + fun setClick(right: Boolean, down: Boolean) { + set(if (right) RIGHT_CLICK else LEFT_CLICK, down) + pressure[if (right) 1 else 0] = if (down) Short.MAX_VALUE else 0 + publish() + } + + /** Everything let go - for when the panel goes away with a finger still on it. */ + @Synchronized + fun releaseAll() { + if (controls == 0 && pads.all { it.toInt() == 0 } && pressure.all { it.toInt() == 0 }) return + controls = 0 + pads.fill(0) + pressure.fill(0) + publish() + } + + private fun set(bit: Int, on: Boolean) { + controls = if (on) controls or bit else controls and bit.inv() + } + + private fun axis(value: Float): Short = (value.coerceIn(-1f, 1f) * Short.MAX_VALUE).toInt().toShort() + + private fun publish() = FakeInputWriter.writeDeckControls(SLOT, controls, pads, pressure) +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java b/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java index 30b802166..0e4ea2de3 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java +++ b/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java @@ -67,6 +67,9 @@ public class FakeInputWriter { /** Bit 10 of the snapshot word. The first ten are PadState's own bit order; the Steam * button is not, because PadState keeps 10 and 11 for the triggers. */ private static final int SNAPSHOT_IDX_MODE = 10; + /** Bit 11: the Quick Access button. Snapshot only - an Xbox pad's evdev node has no such key; + * libfakeinput's Deck controller reads it from here. */ + private static final int SNAPSHOT_IDX_QAM = 11; private static final int EVENT_SIZE = 24; static final int MAX_EVENTS_PER_UPDATE = 32; @@ -92,6 +95,18 @@ public class FakeInputWriter { private static final int RING_SNAPSHOT_BUTTONS_OFFSET = 40; private static final int RING_SNAPSHOT_AXES_OFFSET = 44; // short[8] private static final String RING_DIR_NAME = "fakeinput-rings"; + // After the events - MUST match the block in fakeinput_steam.cpp (DeckImu): what a Deck has and + // an Xbox pad does not - motion, back grips, trackpads - in the Steam Deck controller's axes and + // units, under a seqlock like the snapshot's. Only libfakeinput's Deck controller reads it. + private static final int IMU_OFFSET = RING_SIZE; + private static final int IMU_MAGIC = 0x31554D49; // IMU1 + private static final int IMU_SEQ_OFFSET = IMU_OFFSET + 8; + private static final int IMU_ACCEL_OFFSET = IMU_OFFSET + 16; // short[3] + private static final int IMU_GYRO_OFFSET = IMU_OFFSET + 22; // short[3] + private static final int DECK_PADS_OFFSET = IMU_OFFSET + 28; // short[4]: left X, Y, right X, Y + private static final int DECK_PRESSURE_OFFSET = IMU_OFFSET + 36; // short[2]: left, right + private static final int DECK_CONTROLS_OFFSET = IMU_OFFSET + 40; // int: DeckControls bits + private static final int RING_FILE_SIZE = RING_SIZE + 64; private static final Object RING_LOCK = new Object(); private static final RingSlot[] RING_SLOTS = new RingSlot[MAX_FAKE_INPUT_SLOTS]; @@ -110,6 +125,7 @@ public class FakeInputWriter { private volatile boolean destroyed = false; private final boolean[] prevButtonStates = new boolean[12]; + private boolean prevQam; private int prevThumbLX, prevThumbLY, prevThumbRX, prevThumbRY; private int prevTriggerL, prevTriggerR; private int prevHatX, prevHatY; @@ -217,30 +233,40 @@ public class FakeInputWriter { for (int i = 0; i < 8; i++) { data.putShort(RING_SNAPSHOT_AXES_OFFSET + (i * 2), (short) 0); } + data.putInt(IMU_OFFSET, 0); + data.putLong(IMU_SEQ_OFFSET, 0L); + for (int i = 0; i < 12; i++) { + data.putShort(IMU_ACCEL_OFFSET + (i * 2), (short) 0); + } + data.putInt(DECK_CONTROLS_OFFSET, 0); } + // Lock order: RING_LOCK, then the slot. Every writer holds the slot while it touches data, and + // re-checks it there: a writer that looked the slot up just before this ran finds it gone. private static void releaseRingSlotLocked(int slot) { RingSlot ringSlot = RING_SLOTS[slot]; if (ringSlot == null) { return; } - ringSlot.data = null; - if (ringSlot.ringChannel != null) { - try { - ringSlot.ringChannel.close(); - } catch (IOException ignored) { + synchronized (ringSlot) { + ringSlot.data = null; + if (ringSlot.ringChannel != null) { + try { + ringSlot.ringChannel.close(); + } catch (IOException ignored) { + } + ringSlot.ringChannel = null; } - ringSlot.ringChannel = null; - } - if (ringSlot.ringRaf != null) { - try { - ringSlot.ringRaf.close(); - } catch (IOException ignored) { + if (ringSlot.ringRaf != null) { + try { + ringSlot.ringRaf.close(); + } catch (IOException ignored) { + } + ringSlot.ringRaf = null; + } + if (ringSlot.ringFile != null && ringSlot.ringFile.exists()) { + ringSlot.ringFile.delete(); } - ringSlot.ringRaf = null; - } - if (ringSlot.ringFile != null && ringSlot.ringFile.exists()) { - ringSlot.ringFile.delete(); } RING_SLOTS[slot] = null; } @@ -289,9 +315,9 @@ public class FakeInputWriter { FileChannel channel = null; try { raf = new RandomAccessFile(ringFile, "rw"); - raf.setLength(RING_SIZE); + raf.setLength(RING_FILE_SIZE); channel = raf.getChannel(); - ByteBuffer data = channel.map(FileChannel.MapMode.READ_WRITE, 0, RING_SIZE); + ByteBuffer data = channel.map(FileChannel.MapMode.READ_WRITE, 0, RING_FILE_SIZE); initializeRingHeader(data); RingSlot ringSlot = new RingSlot(); @@ -332,6 +358,9 @@ public class FakeInputWriter { return false; } synchronized (ringSlot) { + if (ringSlot.data == null) { + return false; + } if (!ringSlot.active) { if (ringSlot.everActivated) { ringSlot.generation++; @@ -381,6 +410,9 @@ public class FakeInputWriter { ByteBuffer source = this.buffer.duplicate(); synchronized (ringSlot) { ByteBuffer ring = ringSlot.data; + if (ring == null) { + return false; + } long writeSeq = ring.getLong(RING_WRITE_SEQ_OFFSET); int sourceLimit = source.limit(); while (source.remaining() >= EVENT_SIZE) { @@ -416,6 +448,9 @@ public class FakeInputWriter { buttons |= (1 << i); } } + if (this.prevQam) { + buttons |= (1 << SNAPSHOT_IDX_QAM); + } long seq = ring.getLong(RING_SNAPSHOT_SEQ_OFFSET); ring.putLong(RING_SNAPSHOT_SEQ_OFFSET, seq + 1); // odd: write in progress nativeStoreFence(); @@ -434,6 +469,58 @@ public class FakeInputWriter { ring.putLong(RING_SNAPSHOT_SEQ_OFFSET, seq + 2); // even: write complete } + /** + * Publishes the pad's motion for slot {@code slot}: accelerometer and gyro, already in the Deck + * controller's axes and units (see PadMotion). Safe from any thread. + */ + public static void writeMotion(int slot, short[] accel, short[] gyro) { + RingSlot ringSlot; + synchronized (RING_LOCK) { + ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null; + } + if (ringSlot == null) return; + synchronized (ringSlot) { + ByteBuffer ring = ringSlot.data; + if (ring == null) return; + long seq = ring.getLong(IMU_SEQ_OFFSET); + ring.putLong(IMU_SEQ_OFFSET, seq + 1); // odd: write in progress + nativeStoreFence(); + for (int i = 0; i < 3; i++) { + ring.putShort(IMU_ACCEL_OFFSET + i * 2, accel[i]); + ring.putShort(IMU_GYRO_OFFSET + i * 2, gyro[i]); + } + ring.putInt(IMU_OFFSET, IMU_MAGIC); + nativeStoreFence(); + ring.putLong(IMU_SEQ_OFFSET, seq + 2); + } + } + + /** + * Publishes the Deck's back grips and trackpads for slot {@code slot} (see DeckControls): + * {@code controls} is the DeckControls bit set, {@code pads} left X, Y, right X, Y and + * {@code pressure} left, right, already in the Deck's units. Safe from any thread. + */ + public static void writeDeckControls(int slot, int controls, short[] pads, short[] pressure) { + RingSlot ringSlot; + synchronized (RING_LOCK) { + ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null; + } + if (ringSlot == null) return; + synchronized (ringSlot) { + ByteBuffer ring = ringSlot.data; + if (ring == null) return; + long seq = ring.getLong(IMU_SEQ_OFFSET); + ring.putLong(IMU_SEQ_OFFSET, seq + 1); + nativeStoreFence(); + for (int i = 0; i < 4; i++) ring.putShort(DECK_PADS_OFFSET + i * 2, pads[i]); + for (int i = 0; i < 2; i++) ring.putShort(DECK_PRESSURE_OFFSET + i * 2, pressure[i]); + ring.putInt(DECK_CONTROLS_OFFSET, controls); + ring.putInt(IMU_OFFSET, IMU_MAGIC); + nativeStoreFence(); + ring.putLong(IMU_SEQ_OFFSET, seq + 2); + } + } + private static short clampShort(int value) { if (value > Short.MAX_VALUE) { return Short.MAX_VALUE; @@ -512,6 +599,11 @@ public class FakeInputWriter { } } + if (prevQam) { + prevQam = false; + hasChanges = true; + } + // Zero all axes if (prevThumbLX != 0) { prevThumbLX = 0; @@ -620,6 +712,11 @@ public class FakeInputWriter { // reports as button 8 - exactly the "guide:b8" in the mapping Steam writes for this pad. writeButton(SNAPSHOT_IDX_MODE, state.isDown(PadState.GUIDE)); + boolean qam = state.isDown(PadState.QAM); + if (forceResend || qam != prevQam) { + prevQam = qam; + hasChanges = true; + } // Sticks int lx = (int) (state.leftX * 32767); diff --git a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java index 890fde729..9fe6f6ecd 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java +++ b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java @@ -7,6 +7,8 @@ import android.view.InputDevice; import android.view.KeyEvent; import android.view.MotionEvent; +import com.droiddeck.launcher.session.SessionState; + import java.io.File; /** @@ -19,7 +21,10 @@ import java.io.File; * SDL → Big Picture. * *

The identity the interposer reports is an Xbox 360 pad, which is what makes SDL apply a known - * mapping without the user configuring anything. + * mapping without the user configuring anything. In a Steam session it is a Steam Deck controller + * 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. */ public final class PadBridge { private static final String TAG = "PadBridge"; @@ -54,6 +59,8 @@ public final class PadBridge { private boolean systemQamPressed; private boolean qamChordActive; private boolean qamSyntheticAPressed; + /** The Deck controller's Quick Access button, held for one tap. */ + private boolean qamTapPressed; private int qamChordGeneration; /** Told on the main thread when a player uses the pad or the on-screen controls; see [setOnPlayerInput]. */ private volatile Runnable onPlayerInput; @@ -77,6 +84,7 @@ public final class PadBridge { systemQamPressed = false; qamChordActive = false; qamSyntheticAPressed = false; + qamTapPressed = false; qamChordGeneration++; if (open) { state.clear(); @@ -204,7 +212,8 @@ public final class PadBridge { boolean qamStarted = qamPressed && !systemQamPressed; systemGuidePressed = guidePressed; systemQamPressed = qamPressed; - if (qamStarted) startQamChord(); + // A Deck controller's QAM button is held for as long as the touch one is. + if (qamStarted && !SessionState.getDeckPad()) startQamChord(); else publish(); } @@ -217,9 +226,23 @@ public final class PadBridge { publish(); } - /** Starts the same Guide-then-A sequence used by the touch QAM button. */ + /** Opens the client's Quick Access menu: a tap of the Deck's button, or the Guide-then-A chord. */ public synchronized void triggerQam() { - startQamChord(); + if (!SessionState.getDeckPad()) { + startQamChord(); + return; + } + if (qamTapPressed) return; + qamTapPressed = true; + int generation = qamChordGeneration; + publish(); + chordHandler.postDelayed(() -> releaseQamTap(generation), QAM_A_HOLD_MS); + } + + private synchronized void releaseQamTap(int generation) { + if (generation != qamChordGeneration || !qamTapPressed) return; + qamTapPressed = false; + publish(); } private void startQamChord() { @@ -258,16 +281,17 @@ public final class PadBridge { private void publish() { if (!open && !start()) return; - if (systemGuidePressed || qamChordActive) { - effectiveState.copyFrom(state); - effectiveState.press(PadState.GUIDE, true); - if (qamSyntheticAPressed) { - effectiveState.press(PadState.A, state.isDown(PadState.A) || qamSyntheticAPressed); - } - writer.writePad(effectiveState); - } else { + boolean guide = systemGuidePressed || qamChordActive; + boolean qam = SessionState.getDeckPad() && (systemQamPressed || qamTapPressed); + if (!guide && !qam) { writer.writePad(state); + return; } + effectiveState.copyFrom(state); + if (guide) effectiveState.press(PadState.GUIDE, true); + if (qam) effectiveState.press(PadState.QAM, true); + if (qamSyntheticAPressed) effectiveState.press(PadState.A, true); + writer.writePad(effectiveState); } private static float axis(MotionEvent event, int axis) { diff --git a/app/src/main/java/com/droiddeck/launcher/input/PadMotion.kt b/app/src/main/java/com/droiddeck/launcher/input/PadMotion.kt new file mode 100644 index 000000000..31707a9a0 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/PadMotion.kt @@ -0,0 +1,103 @@ +package com.droiddeck.launcher.input + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.os.Handler +import android.os.HandlerThread +import android.util.Log +import android.view.Surface +import kotlin.math.roundToInt + +/** + * The handheld's own gyro and accelerometer, as the Steam Deck controller's (SteamDeckPad). + * + * The phone is the controller's body, so its motion sensors are the ones a Deck's would be. Android + * reports them in the device's natural orientation; they are turned to the screen as it is shown + * (x right, y up, z towards the player - SDL's frame for a gamepad), then to the Deck's own axes + * and units as SDL's Deck driver undoes them (SDL_hidapi_steamdeck.c): X right, Y away from the + * player, Z up; 1 g = 16384, 2000 °/s = 32768. The Steam client calibrates the gyro itself. + * + * Sensors run only while the session is on screen with the pad a Deck controller, sampled as often + * as the Deck reports (4 ms); each reading goes straight into the pad's ring + * ([FakeInputWriter.writeMotion]), where libfakeinput's 4 ms report picks up the latest. + */ +class PadMotion(private val context: Context, private val rotation: () -> Int) : SensorEventListener { + private val sensors = context.getSystemService(SensorManager::class.java) + private var thread: HandlerThread? = null + /** Readings are taken only while this is set; a callback already queued when [stop] ran drops. */ + @Volatile private var active = false + // Touched only on the worker thread. + private val accel = ShortArray(3) + private val gyro = ShortArray(3) + + fun start() { + if (thread != null || sensors == null) return + val gyroscope = sensors.getDefaultSensor(Sensor.TYPE_GYROSCOPE) + val accelerometer = sensors.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) + if (gyroscope == null && accelerometer == null) { + Log.i(TAG, "pad motion: this device has no gyro or accelerometer") + return + } + val worker = HandlerThread("pad-motion", android.os.Process.THREAD_PRIORITY_DISPLAY).apply { start() } + thread = worker + val handler = Handler(worker.looper) + active = true + gyroscope?.let { sensors.registerListener(this, it, SAMPLING_US, handler) } + accelerometer?.let { sensors.registerListener(this, it, SAMPLING_US, handler) } + Log.i(TAG, "pad motion: gyro ${gyroscope?.name ?: "none"}, accelerometer ${accelerometer?.name ?: "none"}") + } + + fun stop() { + val worker = thread ?: return + thread = null + active = false + sensors?.unregisterListener(this) + // A still controller, not one frozen mid-turn - published on the worker, after any reading + // already queued there, so nothing can overwrite it. + Handler(worker.looper).post { + gyro.fill(0) + FakeInputWriter.writeMotion(SLOT, accel, gyro) + } + worker.quitSafely() + } + + override fun onSensorChanged(event: SensorEvent) { + if (!active) return + val values = event.values + // Natural orientation to the screen's: Android's display-rotation transform. + val (x, y) = when (rotation()) { + Surface.ROTATION_90 -> -values[1] to values[0] + Surface.ROTATION_180 -> -values[0] to -values[1] + Surface.ROTATION_270 -> values[1] to -values[0] + else -> values[0] to values[1] + } + val z = values[2] + val (target, scale) = when (event.sensor.type) { + Sensor.TYPE_GYROSCOPE -> gyro to GYRO_COUNTS_PER_RAD_S + Sensor.TYPE_ACCELEROMETER -> accel to ACCEL_COUNTS_PER_M_S2 + else -> return + } + // Screen frame (right, up, towards the player) to the Deck's (right, away, up). + target[0] = counts(x * scale) + target[1] = counts(-z * scale) + target[2] = counts(y * scale) + FakeInputWriter.writeMotion(SLOT, accel, gyro) + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit + + private fun counts(value: Float): Short = value.roundToInt().coerceIn(-32768, 32767).toShort() + + companion object { + private const val TAG = "PadMotion" + /** The pad the Deck controller is made from (PadBridge's slot). */ + private const val SLOT = 0 + /** The Deck controller's report interval (libfakeinput: DECK_REPORT_INTERVAL_US). */ + private const val SAMPLING_US = 4000 + private const val ACCEL_COUNTS_PER_M_S2 = 16384f / SensorManager.GRAVITY_EARTH + private const val GYRO_COUNTS_PER_RAD_S = (32768f / 2000f) * (180f / Math.PI.toFloat()) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/PadState.kt b/app/src/main/java/com/droiddeck/launcher/input/PadState.kt index 7f4c9efbd..c798b682f 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/PadState.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/PadState.kt @@ -57,5 +57,7 @@ class PadState { const val R3 = 9 /** The Steam/Guide button, which the fake evdev node reports as BTN_MODE. */ const val GUIDE = 12 + /** The Quick Access button: a Deck controller's (SteamDeckPad) only, since an Xbox pad has none. */ + const val QAM = 13 } } diff --git a/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt b/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt index 85fc94ca8..4b08a6d21 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt @@ -8,6 +8,8 @@ enum class SecondScreenMode(val id: String, val label: String) { NONE("none", "None"), KEYBOARD_TRACKPAD("keyboard-trackpad", "Keyboard + trackpad"), TERMINAL("terminal", "Terminal"), + /** The Steam Deck controller's back grips and trackpads; offered while the pad is one. */ + DECK_CONTROLS("deck-controls", "Deck grips + trackpads"), } data class SecondScreenDisplay(val id: Int, val label: String) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 07b2e3fba..0b89121c7 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -8,6 +8,8 @@ object SessionPrefs { const val SUSPEND_MANUAL = "manual" const val SUSPEND_NEVER = "never" + const val CONTROLLER_DECK = "deck" + const val CONTROLLER_XBOX360 = "xbox360" const val OSC_AUTO = "auto" const val OSC_ALWAYS = "always" const val OSC_STEAM_QAM = "steam-qam" @@ -267,6 +269,15 @@ object SessionPrefs { fun noGlError(context: Context): Boolean = prefs(context).getBoolean("noGlError", true) fun setNoGlError(context: Context, on: Boolean) { prefs(context).edit().putBoolean("noGlError", on).apply() } + /** + * What the pad is to the Steam client: [CONTROLLER_DECK], a Steam Deck controller (Quick Access + * button, gyro, Steam Input's full treatment - SteamDeckPad), or [CONTROLLER_XBOX360], the plain + * Xbox 360 pad of earlier versions (QAM by the Guide+A chord). + */ + fun steamController(context: Context): String = + prefs(context).getString("steamController", CONTROLLER_DECK) ?: CONTROLLER_DECK + fun setSteamController(context: Context, id: String) { prefs(context).edit().putString("steamController", id).apply() } + /** Runs the SteamOS gamepad client with its Quick Access performance controls. */ fun steamDeckMode(context: Context): Boolean = prefs(context).getBoolean("steamDeckMode", false) fun setSteamDeckMode(context: Context, on: Boolean) { prefs(context).edit().putBoolean("steamDeckMode", on).apply() } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 13400e511..3e4f76399 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -57,6 +57,8 @@ import java.util.Locale */ class SessionService : Service() { private val components = java.util.concurrent.CopyOnWriteArrayList() + /** The Steam Deck controller's sysfs binds (SteamDeckPad), when this session has one. */ + private var deckBinds: List = emptyList() private val stopLock = Any() private var wakeLock: PowerManager.WakeLock? = null private var wifiLock: WifiManager.WifiLock? = null @@ -292,6 +294,8 @@ class SessionService : Service() { val fakeInputDir = File(sessionRoot, "dev/input").apply { mkdirs() } val controllersOn = !File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists() + SessionState.deckPad = false + deckBinds = emptyList() if (controllersOn) addControllerEnvironment(guest, fakeInputDir) // The desktop is wlroots (labwc). Stock wlroots allocates through gbm on a real DRM render // node, and ours is a KGSL stand-in - labwc died at "unable to create allocator" - so the @@ -605,6 +609,12 @@ class SessionService : Service() { /** The fake evdev pads: the ring files the app writes and the identity SDL and Steam see. */ private fun addControllerEnvironment(guest: MutableList, fakeInputDir: File) { FakeInputWriter.prepareRingSlots(fakeInputDir, 4) + // Virtual pads a client made last session (event16 and up, and their hidden rings) are + // not there any more; a client that crashed never took its own down. + fakeInputDir.listFiles()?.forEach { file -> + val node = Regex("event(\\d+)").matchEntire(file.name)?.groupValues?.get(1)?.toIntOrNull() + if (file.name.startsWith(".uinput-") || (node != null && node >= FIRST_VIRTUAL_PAD)) file.delete() + } guest.add("FAKE_EVDEV_DIR=" + fakeInputDir.path) val rings = FakeInputWriter.getRingEnv(fakeInputDir) if (!rings.isNullOrEmpty()) guest.add("FAKE_EVDEV_MEMFD_PATHS=$rings") @@ -612,14 +622,34 @@ class SessionService : Service() { // identity gets the standard layout without the user configuring the pad by hand. guest.add("FAKE_EVDEV_IDENTITY=xbox360") guest.add("FAKE_EVDEV_VIBRATION=1") - // Steam Input's virtual-gamepad identity is for games the client starts, which are - // meant to see that pad. Everywhere else (the desktop, a program from the rail) it - // hides the pad: SDL ignores a Steam virtual gamepad unless it runs under Steam, so - // every SDL emulator came up with no controller. There it is a plain Xbox 360 pad. - if (SessionState.mode == MODE_STEAM) guest.add("FAKE_EVDEV_STEAM_VIRTUAL=1") + val uinput = !File(Environment.getExternalStorageDirectory(), NO_UINPUT_SWITCH).exists() + // In Steam, the pad is what a Deck's is: a Steam Deck controller the client reads over + // hidraw (SteamDeckPad), which only works with Steam Input's virtual pad for games to read. + // Decided only once its sysfs is in place: without it the client would find no Deck, and + // with the Deck asked for the pad's own nodes are withdrawn - no controller at all. + 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() + SessionState.deckPad = deckBinds.isNotEmpty() + if (wantsDeck && !SessionState.deckPad) Log.w(TAG, "deck pad: not available this session; the pad stays an Xbox 360 controller") + if (uinput) { + // /dev/uinput, stood in for by libfakeinput: the virtual pad Steam Input makes for a + // game becomes a node the game reads, carrying the player's layout, as on a Deck. + guest.add("FAKE_EVDEV_UINPUT=1") + if (SessionState.deckPad) guest.add("FAKE_EVDEV_DECK=1") + } else if (SessionState.mode == MODE_STEAM) { + // Without it, games see the pad itself wearing Steam Input's virtual-gamepad identity - + // for games the client starts only. Everywhere else (the desktop, a program from the + // rail) that identity hides the pad: SDL ignores a Steam virtual gamepad unless it + // runs under Steam, so every SDL emulator came up with no controller. + guest.add("FAKE_EVDEV_STEAM_VIRTUAL=1") + } guest.add("SDL_JOYSTICK_DISABLE_UDEV=1") guest.add("SDL_HIDAPI_JOYSTICK_DISABLE_UDEV=1") - guest.add("SDL_JOYSTICK_HIDAPI=0") + // The client reads a Deck controller through SDL's HIDAPI; a hint in the environment + // outranks the client's own. Nothing else is shown the Deck (libfakeinput). + if (!SessionState.deckPad) guest.add("SDL_JOYSTICK_HIDAPI=0") if (File(Environment.getExternalStorageDirectory(), PAD_LOG_SWITCH).exists()) { guest.add("FAKE_EVDEV_LOG=1") } @@ -630,6 +660,7 @@ class SessionService : Service() { private fun sessionBinds(controllersOn: Boolean, fakeInputDir: File): ArrayList { val binds = ArrayList() if (controllersOn) binds.add(fakeInputDir.path + ":/dev/input") + if (controllersOn) binds.addAll(deckBinds) // The client's battery readout (the Quick Access Menu, the top bar) reads // /sys/class/power_supply/BAT/..., a laptop's or a Deck's naming; Android's supply is // called "battery" and its files differ, so the client sees no battery at all. A directory @@ -1145,6 +1176,10 @@ class SessionService : Service() { private const val STEAM_EXIT_MS = 10_000L private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad" private const val PAD_LOG_SWITCH = "Download/droiddeck-pad-log" + private const val NO_UINPUT_SWITCH = "Download/droiddeck-no-uinput" + private const val NO_DECK_PAD_SWITCH = "Download/droiddeck-no-deck-pad" + /** libfakeinput numbers the pads made through its /dev/uinput stand-in from here. */ + private const val FIRST_VIRTUAL_PAD = 16 const val EXTRA_MODE = "mode" const val MODE_STEAM = "steam" diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt index 21227ba51..71245d3fc 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt @@ -63,6 +63,27 @@ object SessionState { * gets DXVK_HDR=1 and gamescope --hdr-enabled. Decided by the activity before the compositor starts. */ @JvmStatic var hdr = false + /** The pad is presented to the Steam client as a Steam Deck controller this session + * (SteamDeckPad): its Quick Access button is a real button, not the Guide+A chord. Decided by + * the service as the session starts, which can be after the activity has resumed - hence + * [deckPadListener]. */ + @Volatile private var deckPadValue = false + @JvmStatic var deckPad: Boolean + get() = deckPadValue + set(value) { + if (deckPadValue == value) return + deckPadValue = value + deckPadListener?.invoke() + } + + /** Told (on the thread that changed it) whenever [deckPad] changes. */ + @Volatile var deckPadListener: (() -> Unit)? = null + + /** What the second screen shows, and on which display: kept for the session, so a screen + * turned off (sleep, a closed lid) or an activity recreated brings it back as it was. */ + @Volatile var secondScreenMode = com.droiddeck.launcher.input.SecondScreenMode.NONE + @Volatile var secondScreenDisplay = -1 + /** Which session this is: SessionService.MODE_STEAM or MODE_DESKTOP. */ @Volatile var mode = "steam" diff --git a/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt b/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt new file mode 100644 index 000000000..39747df8f --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt @@ -0,0 +1,105 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import android.system.Os +import android.util.Log +import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.runtime.LinuxRuntime +import java.io.File + +/** + * The session's pad, described to the Steam client as a Steam Deck controller. + * + * On a Deck - and on the handhelds InputPlumber serves as one - the client finds the controller + * through libudev as a `hidraw` device under a `hid` device under a USB interface and reads it + * itself. libfakeinput serves the device node (`/dev/hidraw16`, built from the pad's input ring); + * what the client also needs is the sysfs it walks to find and identify that node, which a sandbox + * shows nothing of. This writes that sysfs as the kernel lays out a Deck's controller interface and + * returns the binds that put it in place: + * + * - `/sys/devices/droiddeck/usb1` - the USB device (Valve, 28de:1205), its interface 2, the HID + * device on it (`HID_ID`, `report_descriptor`) and that device's `hidraw/hidraw16`; + * - `/sys/class/hidraw/hidraw16` - what an enumeration of the `hidraw` subsystem lists; + * - `/sys/dev/char/240:16` - how the node's device number is looked up (in the directory the + * runtime already binds there for the GPU); + * - `/run/udev/data/c240:16` - the udev database entry that marks the device initialised, without + * which libudev's enumeration leaves a device node out. + * + * Symlinks are absolute guest paths; proot resolves them inside the session. + */ +object SteamDeckPad { + private const val TAG = "SessionService" + /** Must match libfakeinput (fakeinput_steam.cpp: DECK_HIDRAW_*). */ + private const val MAJOR = 240 + private const val MINOR = 16 + private const val NODE = "hidraw$MINOR" + private const val GUEST_DEVICES = "/sys/devices/droiddeck" + private const val USB = "$GUEST_DEVICES/usb1" + private const val INTERFACE = "$USB/1-1:1.2" + private const val HID = "$INTERFACE/0003:28DE:1205.0001" + private const val HIDRAW = "$HID/hidraw/$NODE" + private const val SERIAL = "DROIDDECK0001" + + /** 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, + 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0x81, 0x02, 0x09, 0x06, 0x09, 0x07, + 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0, + ).map { it.toByte() }.toByteArray() + + /** 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 { + val base = File(sessionRoot, "sys/deck") + val devices = File(base, "devices") + val hidrawClass = File(base, "class-hidraw") + return try { + FileUtils.clear(base) + fun dir(guest: String) = File(devices, guest.removePrefix("$GUEST_DEVICES/")).apply { mkdirs() } + fun write(guest: String, name: String, text: String) = File(dir(guest), name).writeText(text) + fun link(file: File, target: String) { + file.parentFile?.mkdirs() + file.delete() + Os.symlink(target, file.path) + } + + write(USB, "uevent", "DEVTYPE=usb_device\nPRODUCT=28de/1205/100\nTYPE=0/0/0\nBUSNUM=001\nDEVNUM=002\n") + write(USB, "idVendor", "28de\n") + write(USB, "idProduct", "1205\n") + write(USB, "bcdDevice", "0100\n") + write(USB, "manufacturer", "Valve Software\n") + write(USB, "product", "Steam Deck Controller\n") + write(USB, "serial", "$SERIAL\n") + link(File(dir(USB), "subsystem"), "/sys/bus/usb") + + write(INTERFACE, "uevent", "DEVTYPE=usb_interface\nPRODUCT=28de/1205/100\nINTERFACE=3/0/0\n") + write(INTERFACE, "bInterfaceNumber", "02\n") + write(INTERFACE, "bInterfaceClass", "03\n") + link(File(dir(INTERFACE), "subsystem"), "/sys/bus/usb") + + write(HID, "uevent", "DRIVER=hid-steam\nHID_ID=0003:000028DE:00001205\n" + + "HID_NAME=Valve Software Steam Deck Controller\nHID_PHYS=usb-droiddeck-1/input2\n" + + "HID_UNIQ=$SERIAL\nMODALIAS=hid:b0003g0001v000028DEp00001205\n") + File(dir(HID), "report_descriptor").writeBytes(REPORT_DESCRIPTOR) + link(File(dir(HID), "subsystem"), "/sys/bus/hid") + + write(HIDRAW, "uevent", "MAJOR=$MAJOR\nMINOR=$MINOR\nDEVNAME=$NODE\n") + write(HIDRAW, "dev", "$MAJOR:$MINOR\n") + link(File(dir(HIDRAW), "subsystem"), "/sys/class/hidraw") + link(File(dir(HIDRAW), "device"), HID) + + hidrawClass.mkdirs() + link(File(hidrawClass, NODE), HIDRAW) + // The runtime binds this directory over /sys/dev/char for the GPU (LinuxRuntime.bindGpuNode). + link(File(context.cacheDir, "drm/sys/$MAJOR:$MINOR"), HIDRAW) + + 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") + + 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) { + Log.w(TAG, "deck pad: could not describe the pad as a Deck controller: $e") + emptyList() + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/DeckControlsPanel.kt b/app/src/main/java/com/droiddeck/launcher/ui/DeckControlsPanel.kt new file mode 100644 index 000000000..e1bce8375 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/DeckControlsPanel.kt @@ -0,0 +1,351 @@ +package com.droiddeck.launcher.ui + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RadialGradient +import android.graphics.RectF +import android.graphics.Shader +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.view.Gravity +import android.view.HapticFeedbackConstants +import android.view.MotionEvent +import android.view.View +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import com.droiddeck.launcher.input.DeckControls +import kotlin.math.min + +/** + * The Steam Deck controller's back grips and trackpads, on the second screen. + * + * Four tabs: the grips as a 2x2 grid (left grips on the left, upper above lower, as they sit on a + * Deck); either trackpad on its own with a bar to click it; and both trackpads with the grips in a + * row beneath. A trackpad clicks from its bar or from a second finger while the first is down. + * Drawn for OLED: true black, with outlines rather than filled surfaces, and the Steam blue only + * where something is being touched. + */ +@SuppressLint("ViewConstructor") +class DeckControlsPanel( + context: Context, + private val onClose: () -> Unit, + private val onSteamMenu: () -> Unit, + private val onQam: () -> Unit, +) : LinearLayout(context) { + private enum class Tab(val label: String) { GRIPS("Grips"), LEFT("Left pad"), RIGHT("Right pad"), BOTH("Both") } + + private val content = FrameLayout(context) + private val tabViews = HashMap() + + init { + orientation = VERTICAL + setBackgroundColor(Color.BLACK) + addView(topBar(), LayoutParams(LayoutParams.MATCH_PARENT, dp(64))) + addView(content, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f)) + select(selected) + } + + override fun onDetachedFromWindow() { + DeckControls.releaseAll() + super.onDetachedFromWindow() + } + + private fun topBar(): View = LinearLayout(context).apply { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(12), dp(10), dp(12), dp(6)) + addView(roundButton("×", "Close second-screen controls", onClose)) + val tabs = LinearLayout(context).apply { + orientation = HORIZONTAL + background = outline(CORNER_PILL, SURFACE) + setPadding(dp(4), dp(4), dp(4), dp(4)) + for (tab in Tab.values()) { + val view = TextView(context).apply { + text = tab.label + textSize = 14f + typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + gravity = Gravity.CENTER + isSingleLine = true + setPadding(dp(14), 0, dp(14), 0) + setOnClickListener { select(tab) } + } + tabViews[tab] = view + addView(view, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT)) + } + } + val centre = FrameLayout(context).apply { + addView(tabs, FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, dp(44), Gravity.CENTER)) + } + addView(centre, LayoutParams(0, LayoutParams.MATCH_PARENT, 1f)) + addView(roundButton("STEAM", "Open Steam menu", onSteamMenu, wide = true)) + addView(space(dp(8))) + addView(roundButton("•••", "Open Quick Access Menu", onQam, wide = true)) + } + + private fun select(tab: Tab) { + selected = tab + DeckControls.releaseAll() + for ((each, view) in tabViews) { + val on = each == tab + view.background = if (on) filled(CORNER_PILL, ACCENT) else null + view.setTextColor(if (on) ON_ACCENT else TEXT_DIM) + } + content.removeAllViews() + content.addView( + when (tab) { + Tab.GRIPS -> gripsGrid() + Tab.LEFT -> singlePad(right = false) + Tab.RIGHT -> singlePad(right = true) + Tab.BOTH -> bothPads() + }, + FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT), + ) + } + + private fun gripsGrid(): View = LinearLayout(context).apply { + orientation = HORIZONTAL + setPadding(dp(12), dp(6), dp(12), dp(12)) + fun column(upper: View, lower: View) = LinearLayout(context).apply { + orientation = VERTICAL + addView(upper, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { bottomMargin = dp(6) }) + addView(lower, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { topMargin = dp(6) }) + } + addView(column(GripButton(context, "L4", "Left · upper", DeckControls.L4), GripButton(context, "L5", "Left · lower", DeckControls.L5)), + LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { rightMargin = dp(6) }) + addView(column(GripButton(context, "R4", "Right · upper", DeckControls.R4), GripButton(context, "R5", "Right · lower", DeckControls.R5)), + LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { leftMargin = dp(6) }) + } + + private fun singlePad(right: Boolean): View = LinearLayout(context).apply { + orientation = VERTICAL + setPadding(dp(12), dp(6), dp(12), dp(12)) + addView(TrackpadView(context, right), LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { bottomMargin = dp(12) }) + addView(ClickBar(context, right), LayoutParams(LayoutParams.MATCH_PARENT, dp(76))) + } + + private fun bothPads(): View = LinearLayout(context).apply { + orientation = VERTICAL + setPadding(dp(12), dp(6), dp(12), dp(12)) + val pads = LinearLayout(context).apply { + orientation = HORIZONTAL + addView(TrackpadView(context, right = false), LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { rightMargin = dp(6) }) + addView(TrackpadView(context, right = true), LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { leftMargin = dp(6) }) + } + addView(pads, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f).apply { bottomMargin = dp(12) }) + // Mirrored, as hands hold them: the upper grips outside, the lower ones in the middle. + val grips = LinearLayout(context).apply { + orientation = HORIZONTAL + val row = listOf("L4" to DeckControls.L4, "L5" to DeckControls.L5, "R5" to DeckControls.R5, "R4" to DeckControls.R4) + row.forEachIndexed { index, (label, bit) -> + addView(GripButton(context, label, null, bit), LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { + if (index > 0) leftMargin = dp(6) + if (index < row.size - 1) rightMargin = dp(6) + }) + } + } + addView(grips, LayoutParams(LayoutParams.MATCH_PARENT, dp(84))) + } + + private fun roundButton(label: String, description: String, onClick: () -> Unit, wide: Boolean = false) = + TextView(context).apply { + text = label + contentDescription = description + textSize = if (wide) 13f else 20f + typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + letterSpacing = if (wide) 0.08f else 0f + gravity = Gravity.CENTER + setTextColor(TEXT) + background = outline(CORNER_PILL, SURFACE) + setPadding(if (wide) dp(16) else 0, 0, if (wide) dp(16) else 0, 0) + minWidth = dp(44) + layoutParams = LayoutParams(if (wide) LayoutParams.WRAP_CONTENT else dp(44), dp(44)) + setOnClickListener { + performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + onClick() + } + } + + private fun space(width: Int) = View(context).apply { layoutParams = LayoutParams(width, 1) } + + private fun outline(radius: Float, fill: Int) = GradientDrawable().apply { + cornerRadius = radius * resources.displayMetrics.density + setColor(fill) + setStroke(dp(1), LINE) + } + + private fun filled(radius: Float, fill: Int) = GradientDrawable().apply { + cornerRadius = radius * resources.displayMetrics.density + setColor(fill) + } + + private fun dp(value: Int): Int = (value * resources.displayMetrics.density + 0.5f).toInt() + + /** A surface drawn the same way everywhere on the panel; pressed, it lights up in the accent. */ + private abstract class Surface(context: Context) : View(context) { + protected val density = resources.displayMetrics.density + protected val rect = RectF() + private val fill = Paint(Paint.ANTI_ALIAS_FLAG) + private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE } + protected val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + textAlign = Paint.Align.CENTER + typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + } + protected val captionPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + textAlign = Paint.Align.CENTER + color = TEXT_DIM + } + + protected fun drawSurface(canvas: Canvas, lit: Boolean) { + val inset = density + rect.set(inset, inset, width - inset, height - inset) + val radius = CORNER * density + fill.color = if (lit) ACCENT_WASH else SURFACE + canvas.drawRoundRect(rect, radius, radius, fill) + stroke.color = if (lit) ACCENT else LINE + stroke.strokeWidth = (if (lit) 2f else 1f) * density + canvas.drawRoundRect(rect, radius, radius, stroke) + } + } + + /** A held button: down while a finger is on it. */ + private open class HoldButton( + context: Context, + private val label: String, + private val caption: String?, + private val onChange: (Boolean) -> Unit, + ) : Surface(context) { + private var down = false + + override fun onDraw(canvas: Canvas) { + drawSurface(canvas, down) + val cx = width / 2f + labelPaint.textSize = min(height * 0.3f, 30 * density) + labelPaint.color = if (down) ACCENT else TEXT + val labelY = height / 2f + labelPaint.textSize * (if (caption == null) 0.35f else 0.1f) + canvas.drawText(label, cx, labelY, labelPaint) + if (caption != null) { + captionPaint.textSize = 12 * density + canvas.drawText(caption, cx, labelY + 22 * density, captionPaint) + } + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> set(true) + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> set(false) + } + return true + } + + private fun set(pressed: Boolean) { + if (down == pressed) return + down = pressed + if (pressed) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + onChange(pressed) + invalidate() + } + } + + private class GripButton(context: Context, label: String, caption: String?, bit: Int) : + HoldButton(context, label, caption, { DeckControls.setGrip(bit, it) }) + + private class ClickBar(context: Context, right: Boolean) : + HoldButton(context, "Click", if (right) "Right trackpad" else "Left trackpad", { DeckControls.setClick(right, it) }) + + /** + * One trackpad: where the first finger is, as the Deck reports it (-1..1, y up), and a click + * while a second finger is down. + */ + private class TrackpadView(context: Context, private val right: Boolean) : Surface(context) { + private var touching = false + private var clicked = false + private var fingerX = 0f + private var fingerY = 0f + private val guide = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE; color = GUIDE } + private val dot = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = ACCENT } + private val glow = Paint(Paint.ANTI_ALIAS_FLAG) + + override fun onDraw(canvas: Canvas) { + drawSurface(canvas, clicked) + val cx = width / 2f + val cy = height / 2f + guide.strokeWidth = density + val reach = min(width, height) / 2f - 24 * density + canvas.drawCircle(cx, cy, reach, guide) + canvas.drawCircle(cx, cy, reach / 2f, guide) + canvas.drawLine(cx - reach, cy, cx + reach, cy, guide) + canvas.drawLine(cx, cy - reach, cx, cy + reach, guide) + captionPaint.textSize = 12 * density + captionPaint.color = if (touching) ACCENT else TEXT_DIM + canvas.drawText(if (right) "RIGHT TRACKPAD" else "LEFT TRACKPAD", cx, 28 * density, captionPaint) + if (touching) { + val radius = 56 * density + glow.shader = RadialGradient(fingerX, fingerY, radius, ACCENT_GLOW, Color.TRANSPARENT, Shader.TileMode.CLAMP) + canvas.drawCircle(fingerX, fingerY, radius, glow) + canvas.drawCircle(fingerX, fingerY, (if (clicked) 16 else 12) * density, dot) + } + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> { + touch(event.getX(0), event.getY(0)) + click(event.pointerCount > 1) + } + MotionEvent.ACTION_POINTER_DOWN -> click(true) + MotionEvent.ACTION_POINTER_UP -> { + // The finger that lifted may be the first; the pad follows whichever stays. + val stays = if (event.actionIndex == 0) 1 else 0 + touch(event.getX(stays), event.getY(stays)) + click(event.pointerCount - 1 > 1) + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + click(false) + touching = false + DeckControls.setPad(right, touching = false) + invalidate() + } + } + return true + } + + private fun touch(x: Float, y: Float) { + fingerX = x.coerceIn(0f, width.toFloat()) + fingerY = y.coerceIn(0f, height.toFloat()) + touching = true + DeckControls.setPad(right, true, (fingerX - width / 2f) / (width / 2f), -(fingerY - height / 2f) / (height / 2f)) + invalidate() + } + + private fun click(down: Boolean) { + if (clicked == down) return + clicked = down + if (down) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + DeckControls.setClick(right, down) + invalidate() + } + } + + companion object { + /** The tab last shown, kept for the session so reopening the panel lands where it was. */ + private var selected = Tab.BOTH + + private const val CORNER = 20f + private const val CORNER_PILL = 22f + private val ACCENT = Color.rgb(0x1A, 0x9F, 0xFF) + private val ACCENT_WASH = Color.argb(0x2E, 0x1A, 0x9F, 0xFF) + private val ACCENT_GLOW = Color.argb(0x66, 0x1A, 0x9F, 0xFF) + private val ON_ACCENT = Color.rgb(0x03, 0x11, 0x1F) + private val SURFACE = Color.rgb(0x0A, 0x0B, 0x0D) + private val LINE = Color.rgb(0x26, 0x29, 0x2E) + private val GUIDE = Color.rgb(0x16, 0x18, 0x1B) + private val TEXT = Color.rgb(0xF2, 0xF4, 0xF7) + private val TEXT_DIM = Color.rgb(0x7A, 0x80, 0x8A) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index 00bb92839..37b4dac97 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -70,6 +70,8 @@ class ModeSettings( val steamChannel: String? = null, /** Steam only: enable the SteamOS client interface and its performance controls. */ val steamDeckMode: Boolean = false, + /** Steam only: what the pad is to the client (SessionPrefs.CONTROLLER_*); null outside Steam. */ + val steamController: String? = null, /** Steam only: start a Steam session when DroidDeck opens. */ val runSteamAtStartup: Boolean = false, /** Steam only: the user's chosen Games folders; null outside Steam. */ @@ -126,6 +128,7 @@ class ModeSettingsActions( val onForceFullscreen: (Boolean) -> Unit = {}, val onSteamChannel: (String) -> Unit = {}, val onSteamDeckMode: (Boolean) -> Unit = {}, + val onSteamController: (String) -> Unit = {}, val onRunSteamAtStartup: (Boolean) -> Unit = {}, val onPickAddedGamesDir: () -> Unit = {}, val onForgetAddedGamesDir: (path: String) -> Unit = {}, @@ -259,6 +262,16 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { ), s.oscMode, note = "Auto shows all controls without a controller. Steam + QAM shows only those buttons.", onPick = a.onOsc, ) + if (steam && s.steamController != null) ChoiceRow( + host, "controller", "Controller", "What your controller is to Steam. Applies next session.", + listOf( + SessionPrefs.CONTROLLER_DECK to "Steam Deck controller", + SessionPrefs.CONTROLLER_XBOX360 to "Xbox 360 controller", + ), s.steamController, + note = "Steam Deck controller: Steam reads it as a Deck's own, with its Quick Access button and the device's gyro. " + + "Xbox 360 controller: the plain pad of earlier versions; Quick Access opens with Guide+A.", + onPick = a.onSteamController, + ) if (steam) ChoiceRow( host, "back-actions", "Back", SessionPrefs.backActionsOrder(s.backActionsInverted), listOf( diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt b/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt index 3a4bdb2ef..4ad50ed2e 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt @@ -112,6 +112,7 @@ class SecondScreenPresentation( when (currentMode) { SecondScreenMode.KEYBOARD_TRACKPAD -> renderKeyboardAndTrackpad() SecondScreenMode.TERMINAL -> renderTerminal() + SecondScreenMode.DECK_CONTROLS -> root.addView(DeckControlsPanel(context, onClose, onSteamMenu, onQam), weightParams()) SecondScreenMode.NONE -> Unit } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index d853906a8..f02b05134 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -357,7 +357,9 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha } if (a.steam && a.secondScreenDisplays.isNotEmpty()) SettingsGroup("Second screen") { ChoiceRow(host, "second-screen-mode", "Shows", null, - listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL).map { it to it.label }, + (listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL) + + (if (com.droiddeck.launcher.session.SessionState.deckPad) listOf(SecondScreenMode.DECK_CONTROLS) else emptyList())) + .map { it to it.label }, a.secondScreenMode, chipModifier = focus.track(page, "second-screen-mode"), onPick = a.onSecondScreenMode) if (a.secondScreenDisplays.size > 1) ChoiceRow(host, "second-screen-display", "Display", null, a.secondScreenDisplays.map { it.id to it.label }, a.selectedSecondScreenDisplay, diff --git a/docs/progress-log.md b/docs/progress-log.md index 25db70de0..1b00fee7c 100644 --- a/docs/progress-log.md +++ b/docs/progress-log.md @@ -7,6 +7,47 @@ the timeline, then lessons and backlog. Companion to the README (what the app *d --- +## 2026-09-29 - `feat/controller-input`: the pad the way SteamOS has it + +Device-tested on the AYN Thor (Katamari under Proton Experimental ARM64). + +- **Why**: the client read the pad as an Xbox 360 controller, and games read the *same* node + wearing Steam Input's virtual identity (28de:11ff), because the client's own virtual pad needs + `/dev/uinput` and never appeared. Steam Input's layouts therefore never reached a game, and the + QAM was a timed Guide+A chord. InputPlumber itself cannot run in the sandbox (root, uinput, uhid, + udev, D-Bus), so what it provides is emulated in the interposer instead. +- **`/dev/uinput` stand-in** (`FAKE_EVDEV_UINPUT=1`, libfakeinput): a gamepad the client makes + becomes `/dev/input/event16+`, backed by a ring of the app's own format, with the client's name, + ids and bits; its writes are that ring's events. Other devices (virtual keyboard and mouse) are + accepted and dropped. `inputudev.c` describes these nodes to Wine's HID bus from a `.uevent` file + beside each one, and while one exists describes the app's pads as the Xbox 360 pad they are, so + Wine reads only Steam Input's output. Verified: the client makes "Microsoft X-Box 360 pad 0", + re-makes it at game launch, and the game binds it (`Controller 0 uses xinput : true`). +- **Steam Deck controller** (`FAKE_EVDEV_DECK=1`, Steam sessions): `/dev/hidraw16`, found through a + sysfs tree `SteamDeckPad.kt` binds in (usb_device → interface 2 → hid 28de:1205 → hidraw), streams + the Deck's 64-byte state report every 4 ms from ring 0 and answers feature reports as InputPlumber + does. systemd 261's libudev refuses a syspath not on sysfs, so `fstatfs`/`statfs` report sysfs + for that tree. The app's evdev nodes are withdrawn while it is on, and only the `steam` process + sees the Deck. QAM is a real button (snapshot bit 11). Verified: the client lists a Steam Deck + Controller (V1 HID protocol) and runs its handshake (0x83, 0xAE, 0x81/0x87 lizard off, 0x8F). +- **Setting**: Steam page → Touch & controls → **Controller**: *Steam Deck controller* (default) or + *Xbox 360 controller* (the pad of earlier versions, QAM by Guide+A; games still get Steam Input's + virtual pad). Debug switches: `droiddeck-no-uinput` (back to the 28de:11ff disguise), + `droiddeck-no-deck-pad` (forces the Xbox 360 pad). +- **Gyro**: `PadMotion.kt` feeds the handheld's own gyro and accelerometer (4 ms sampling, while + the session is on screen) into an IMU block after ring 0's events, turned to the screen and then + to the Deck's axes and units; the Deck report carries them. Verified: Steam's Gyro Calibration + page moves with the Thor (sh5001 IMU); at rest the accelerometer reads 1 g. +- **Deck grips + trackpads on the second screen** (`DeckControlsPanel.kt`, offered while the pad + is a Deck controller): tabs for the four grips (2x2), either trackpad with a click bar, and both + trackpads over the grips in a row; a second finger on a trackpad clicks it. True black for OLED. + Grips, pad positions, touch, click and pressure go through the same block after ring 0's events + as the gyro (`DeckControls.kt`) into the Deck report. +- **Not yet**: a Bluetooth pad's own IMU (DualSense); the + game's rumble on the virtual pad goes straight to the vibrator rather than back through the client. +- **Prior art checked**: WinNative and Bannerlator never emulated uinput or hidraw; Bannerlator's + `-steamdeck` mode stopped at "the virtual pad never arrives", which is this missing uinput. + ## 2026-09-25 - main `8cdedbe`: melonDS out of the box, touch in Big Picture, log privacy, Decky State: **main = `8cdedbe`** (PR #22 merge). Device-tested on the AYANEO Pocket FIT (repacked test builds) diff --git a/tools/linuxfs/preload/inputudev.c b/tools/linuxfs/preload/inputudev.c index 5600538ea..c1d04f029 100644 --- a/tools/linuxfs/preload/inputudev.c +++ b/tools/linuxfs/preload/inputudev.c @@ -20,6 +20,14 @@ * Only Wine's device host is answered, and only for the session's own nodes: the Steam client * enumerates the same nodes for itself and would take a pad wearing Steam Input's identity for one * of its own and ignore it. Every other caller, path and device object goes straight through. + * + * With libfakeinput's /dev/uinput stand-in (FAKE_EVDEV_UINPUT=1) Steam Input's own virtual pads + * exist too, as event16 and up, and those are what Wine is meant to read: each is described with + * the identity the client gave it, from the .uevent file libfakeinput writes beside the node. + * While one exists the app's pads are described as what they are, an Xbox 360 controller, which + * Wine's HID bus leaves alone - the game reads Steam Input's output, not the pad under it, as on + * a Steam Deck. With none (Steam Input off for the game) they keep the virtual identity, so the + * game still has a controller. */ #define _GNU_SOURCE #include @@ -31,8 +39,11 @@ #include #include -/* The app opens one ring per configured controller; four is what its launcher offers. */ -#define PAD_MAX 4 +/* The app opens one ring per configured controller; four is what its launcher offers. Nodes from + * UINPUT_BASE up are the virtual pads made through libfakeinput's /dev/uinput stand-in. */ +#define RING_MAX 4 +#define UINPUT_BASE 16 +#define PAD_MAX 64 #define EVENT_MINOR_BASE 64 /* BUS_VIRTUAL, and the identity Steam Input gives the gamepad it presents to a game. */ @@ -41,16 +52,23 @@ "NAME=\"Generic HID Gamepad %d\"\n" \ "PHYS=\"usb-fakeinput/input%d\"\n" +/* The identity libfakeinput gives the pad itself (FAKE_EVDEV_IDENTITY=xbox360). */ +#define PAD_PHYSICAL_UEVENT_FORMAT \ + "PRODUCT=3/45e/28e/110\n" \ + "NAME=\"Xbox 360 Controller (%d)\"\n" \ + "PHYS=\"usb-fakeinput/input%d\"\n" + struct pad { char syspath[64]; char devnode[32]; char sysname[16]; - char uevent[128]; + char uevent[256]; dev_t devnum; }; static struct pad pads[PAD_MAX]; static int pad_count; +static pthread_mutex_t virtual_lock = PTHREAD_MUTEX_INITIALIZER; /* * The slots the app published rings for, named in FAKE_EVDEV_MEMFD_PATHS as "=" @@ -66,7 +84,7 @@ static void discover_pads(void) { char *end; long slot = strtol(entry, &end, 10); - if (end != entry && *end == '=' && slot >= 0 && slot < PAD_MAX) { + if (end != entry && *end == '=' && slot >= 0 && slot < RING_MAX) { struct pad *pad = &pads[slot]; if (pad->devnode[0] == '\0') { @@ -90,6 +108,66 @@ static void ensure_pads(void) { pthread_once(&once, discover_pads); } +static int uinput_enabled(void) { + const char *enabled = getenv("FAKE_EVDEV_UINPUT"); + return enabled != NULL && atoi(enabled) != 0; +} + +/* Reads the .uevent libfakeinput wrote for virtual pad into buf; 0 when there is none. */ +static int read_virtual_uevent(int node, char *buf, size_t size) { + const char *dir = getenv("FAKE_EVDEV_DIR"); + char path[PATH_MAX]; + FILE *file; + size_t length; + + if (dir == NULL) return 0; + snprintf(path, sizeof(path), "%s/.uinput-event%d.uevent", dir, node); + if ((file = fopen(path, "re")) == NULL) return 0; + length = fread(buf, 1, size - 1, file); + fclose(file); + buf[length] = '\0'; + return length > 0; +} + +static int any_virtual_pad(void) { + char uevent[256]; + int node; + + for (node = UINPUT_BASE; node < PAD_MAX; node++) + if (read_virtual_uevent(node, uevent, sizeof(uevent))) return 1; + return 0; +} + +/* + * Brings the entry for event up to date before Wine resolves it: a virtual pad comes and + * goes with the client, and the app's pads change identity with it. + */ +static void refresh_pad(int node) { + struct pad *pad; + + if (node < 0 || node >= PAD_MAX) return; + pad = &pads[node]; + pthread_mutex_lock(&virtual_lock); + if (node >= UINPUT_BASE) { + char uevent[sizeof(pad->uevent)]; + + if (read_virtual_uevent(node, uevent, sizeof(uevent))) { + snprintf(pad->sysname, sizeof(pad->sysname), "event%d", node); + snprintf(pad->syspath, sizeof(pad->syspath), "/sys/devices/virtual/input/bannerlator%d", node); + memcpy(pad->uevent, uevent, sizeof(uevent)); + pad->devnum = makedev(13, EVENT_MINOR_BASE + (unsigned int)node); + snprintf(pad->devnode, sizeof(pad->devnode), "/dev/input/event%d", node); + if (node >= pad_count) pad_count = node + 1; + } else { + pad->devnode[0] = '\0'; + } + } else if (pad->devnode[0] != '\0' && uinput_enabled()) { + snprintf(pad->uevent, sizeof(pad->uevent), + any_virtual_pad() ? PAD_PHYSICAL_UEVENT_FORMAT : PAD_UEVENT_FORMAT, node, node); + } + pthread_mutex_unlock(&virtual_lock); +} + /* The pad a synthetic syspath names, or NULL when the path is not one of ours. */ static struct pad *pad_for_syspath(const char *syspath) { int i; @@ -123,6 +201,7 @@ static struct pad *pad_for_syslink(const char *path) { if (path == NULL || strncmp(path, prefix, sizeof(prefix) - 1) != 0) return NULL; name = path + sizeof(prefix) - 1; ensure_pads(); + if (strncmp(name, "event", 5) == 0 && name[5] >= '0' && name[5] <= '9') refresh_pad(atoi(name + 5)); for (i = 0; i < pad_count; i++) { if (pads[i].devnode[0] != '\0' && strcmp(pads[i].sysname, name) == 0) return &pads[i]; }