Add Steam Controller support over Bluetooth LE (#404)

This commit is contained in:
SG3xHERO authored and GitHub committed 2026-10-10 20:53:07 +00:00
1 parent 90690d1c5c
commit 96d7c66535
8 files changed
+872 -16

No files matched your search

+3
View File
@@ -30,6 +30,9 @@
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Installs DroidDeck's own updates (Updates page) over itself, after the user allows it. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- A paired Steam Controller, read over Bluetooth LE out of lizard mode (SteamControllerBle).
Granted at install below targetSdk 31; no scan, so no location. -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-feature android:name="android.hardware.vulkan.version" android:version="0x401000" android:required="true" />
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
+320 -8
View File
@@ -1238,6 +1238,7 @@ struct DeckHidraw {
size_t mapping_size = 0;
uint8_t pending_feature = 0;
bool touch = false; // Steam's touch controller (below), not the Deck
bool triton = false; // The paired 2025 Steam Controller, with native BLE report IDs
};
// What the Deck has and an Xbox pad does not, after the ring's events: motion (PadMotion) and the
@@ -1271,6 +1272,61 @@ 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");
// Shared native Steam Controller reports. Field offsets match FakeInputWriter.java. Android writes
// the original BLE input packet; this process writes Steam's HID output report for Android to send.
// A feature read is a round trip: this process bumps get_request, Android reads the controller's
// report characteristic and answers with get_reply set to the same number.
struct TritonSharedData {
uint32_t magic;
uint32_t reserved;
uint64_t input_seq;
uint32_t report_id;
uint32_t input_length;
uint8_t input[64];
uint64_t output_seq;
uint32_t output_feature;
uint32_t output_length;
uint8_t output[64];
uint64_t get_request;
uint64_t get_reply;
uint32_t get_report_id;
int32_t get_length;
uint8_t get[64];
};
static constexpr uint32_t TRITON_SHARED_MAGIC = 0x4e525454;
static constexpr size_t TRITON_SHARED_BLOCK_SIZE = 256;
static constexpr size_t TRITON_SHARED_OFFSET = FAKE_INPUT_RING_SIZE + DECK_IMU_BLOCK_SIZE;
static_assert(offsetof(TritonSharedData, input_seq) == 8, "Triton input sequence offset");
static_assert(offsetof(TritonSharedData, report_id) == 16, "Triton report ID offset");
static_assert(offsetof(TritonSharedData, input) == 24, "Triton input offset");
static_assert(offsetof(TritonSharedData, output_seq) == 88, "Triton output sequence offset");
static_assert(offsetof(TritonSharedData, output_feature) == 96, "Triton output feature offset");
static_assert(offsetof(TritonSharedData, output) == 104, "Triton output offset");
static_assert(offsetof(TritonSharedData, get_request) == 168, "Triton feature request offset");
static_assert(offsetof(TritonSharedData, get_reply) == 176, "Triton feature reply offset");
static_assert(offsetof(TritonSharedData, get_report_id) == 184, "Triton feature report ID offset");
static_assert(offsetof(TritonSharedData, get_length) == 188, "Triton feature length offset");
static_assert(offsetof(TritonSharedData, get) == 192, "Triton feature data offset");
static_assert(sizeof(TritonSharedData) <= TRITON_SHARED_BLOCK_SIZE, "Triton ring block size");
static constexpr const char *TRITON_HIDRAW_PATH = "/dev/hidraw18";
static constexpr unsigned int TRITON_HIDRAW_MAJOR = 240;
static constexpr unsigned int TRITON_HIDRAW_MINOR = 18;
static constexpr int TRITON_REPORT_ID_BYTES = 1;
static constexpr int TRITON_STATE_BYTES = 45;
static constexpr int TRITON_REPORT_INTERVAL_US = 4000;
// A BLE read takes a few connection intervals; past this the controller is taken to be gone.
static constexpr int TRITON_FEATURE_TIMEOUT_MS = 500;
static constexpr const char *TRITON_NAME = "Steam Controller";
static constexpr const char *TRITON_SERIAL = "DROIDDECKSC01";
static const uint8_t kTritonReportDescriptor[] = {
0x06, 0x00, 0xff, 0x09, 0x01, 0xa1, 0x01, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08,
0x85, 0x45, 0x09, 0x02, 0x95, 0x2d, 0x81, 0x02, 0x85, 0x47, 0x09, 0x03, 0x95, 0x2d, 0x81, 0x02,
0x85, 0x80, 0x09, 0x04, 0x95, 0x09, 0x91, 0x02, 0x85, 0x81, 0x09, 0x05, 0x95, 0x07, 0x91, 0x02,
0x85, 0x82, 0x09, 0x06, 0x95, 0x03, 0x91, 0x02, 0x85, 0x83, 0x09, 0x07, 0x95, 0x09, 0x91, 0x02,
0x85, 0x84, 0x09, 0x08, 0x95, 0x08, 0x91, 0x02, 0x85, 0x85, 0x09, 0x09, 0x95, 0x03, 0x91, 0x02,
0x85, 0x01, 0x09, 0x0a, 0x95, 0x3f, 0xb1, 0x02, 0xc0};
__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<const DeckImu *>(reinterpret_cast<const uint8_t *>(deck.ring) +
@@ -1307,6 +1363,13 @@ static bool fake_deck_enabled() {
return enabled == 1;
}
static bool fake_triton_enabled() {
static int enabled = -1;
if (enabled < 0)
enabled = fake_deck_enabled() && getenv("FAKE_EVDEV_TRITON") && atoi(getenv("FAKE_EVDEV_TRITON")) ? 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() {
@@ -1337,6 +1400,10 @@ __attribute__((visibility("hidden"))) static bool is_deck_hidraw_path(const char
return pathname && fake_deck_enabled() && !strcmp(pathname, DECK_HIDRAW_PATH);
}
__attribute__((visibility("hidden"))) static bool is_triton_hidraw_path(const char *pathname) {
return pathname && fake_triton_enabled() && !strcmp(pathname, TRITON_HIDRAW_PATH);
}
// The app's first pad is the Deck now: its evdev node is withdrawn from everyone. Further players'
// pads are left to the client alone, which hands them to games through Steam Input.
__attribute__((visibility("hidden"))) static bool is_withdrawn_pad_path(const char *pathname) {
@@ -1507,6 +1574,116 @@ __attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags) {
return pair[0];
}
__attribute__((visibility("hidden"))) static bool read_triton_state(const DeckHidraw &triton, uint8_t &report_id,
uint8_t *report, size_t &length) {
if (triton.mapping_size < TRITON_SHARED_OFFSET + TRITON_SHARED_BLOCK_SIZE) return false;
const auto *shared = reinterpret_cast<const TritonSharedData *>(reinterpret_cast<const uint8_t *>(triton.ring) +
TRITON_SHARED_OFFSET);
for (int attempt = 0; attempt < 8; attempt++) {
uint64_t seq = __atomic_load_n(&shared->input_seq, __ATOMIC_ACQUIRE);
if (seq & 1) continue;
if (__atomic_load_n(&shared->magic, __ATOMIC_RELAXED) != TRITON_SHARED_MAGIC) return false;
uint32_t id = __atomic_load_n(&shared->report_id, __ATOMIC_RELAXED);
uint32_t size = __atomic_load_n(&shared->input_length, __ATOMIC_RELAXED);
if (id > 0xff || size > sizeof(shared->input)) return false;
if (size) memcpy(report, shared->input, size);
__atomic_thread_fence(__ATOMIC_ACQUIRE);
if (seq == __atomic_load_n(&shared->input_seq, __ATOMIC_RELAXED)) {
report_id = size ? static_cast<uint8_t>(id) : 0x45;
length = size ? size : TRITON_STATE_BYTES;
if (!size) memset(report, 0, length);
return true;
}
}
return false;
}
__attribute__((visibility("hidden"))) static void *triton_report_thread(void *arg) {
auto *holder = static_cast<std::shared_ptr<DeckHidraw> *>(arg);
std::shared_ptr<DeckHidraw> self = *holder;
delete holder;
uint8_t report[TRITON_REPORT_ID_BYTES + 64] = {};
uint8_t report_id = 0x45;
size_t length = TRITON_STATE_BYTES;
for (;;) {
if (read_triton_state(*self, report_id, report + 1, length)) {
report[0] = report_id;
if (send(self->peer, report, length + 1, MSG_DONTWAIT | MSG_NOSIGNAL) < 0 &&
errno != EAGAIN && errno != EWOULDBLOCK)
break;
}
struct timespec interval = {0, TRITON_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_triton_hidraw(int flags) {
static auto my_open = reinterpret_cast<int (*)(const char *, int, ...)>(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_RDWR | O_CLOEXEC);
if (ring_fd < 0) {
Logger::log("triton: %s refused: ring %s not openable (%s)\n", TRITON_HIDRAW_PATH,
ring_path.empty() ? "(none configured)" : ring_path.c_str(), strerror(errno));
errno = ENODEV;
return -1;
}
struct stat ring_stat;
const size_t mapping_size = TRITON_SHARED_OFFSET + TRITON_SHARED_BLOCK_SIZE;
if (fstat(ring_fd, &ring_stat) < 0 || static_cast<size_t>(ring_stat.st_size) < mapping_size) {
syscall(SYS_close, ring_fd);
errno = ENODEV;
return -1;
}
void *mapping = mmap(nullptr, mapping_size, PROT_READ | PROT_WRITE, MAP_SHARED, ring_fd, 0);
syscall(SYS_close, ring_fd);
if (mapping == MAP_FAILED || !ring_header_is_valid(static_cast<FakeInputRingHeader *>(mapping)) ||
reinterpret_cast<TritonSharedData *>(static_cast<uint8_t *>(mapping) + TRITON_SHARED_OFFSET)->magic != TRITON_SHARED_MAGIC) {
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;
}
int buffer = (TRITON_STATE_BYTES + TRITON_REPORT_ID_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 triton = std::make_shared<DeckHidraw>();
triton->peer = pair[1];
triton->ring = static_cast<FakeInputRingHeader *>(mapping);
triton->mapping_size = mapping_size;
triton->triton = true;
{
std::lock_guard<std::recursive_mutex> guard(controller_mutex());
deck_map()[pair[0]] = triton;
}
pthread_t thread;
auto *thread_arg = new std::shared_ptr<DeckHidraw>(triton);
if (pthread_create(&thread, nullptr, triton_report_thread, thread_arg) != 0) {
delete thread_arg;
std::lock_guard<std::recursive_mutex> guard(controller_mutex());
deck_map().erase(pair[0]);
syscall(SYS_close, pair[0]);
syscall(SYS_close, pair[1]);
munmap(mapping, mapping_size);
errno = ENOMEM;
return -1;
}
pthread_detach(thread);
Logger::log("triton: %s opened as fd %d (28de:1303)\n", TRITON_HIDRAW_PATH, pair[0]);
return pair[0];
}
// ---- Steam's touch controller ----
//
@@ -2032,6 +2209,119 @@ ioctl_deck(DeckHidraw &deck, ioctl_request_t op, void *argp) {
}
}
__attribute__((visibility("hidden"))) static void triton_queue_output(DeckHidraw &triton, const uint8_t *data,
size_t size, bool feature) {
if (!data || !size || size > 64 || triton.mapping_size < TRITON_SHARED_OFFSET + TRITON_SHARED_BLOCK_SIZE) return;
auto *shared = reinterpret_cast<TritonSharedData *>(reinterpret_cast<uint8_t *>(triton.ring) + TRITON_SHARED_OFFSET);
if (__atomic_load_n(&shared->magic, __ATOMIC_ACQUIRE) != TRITON_SHARED_MAGIC) return;
uint64_t seq = __atomic_load_n(&shared->output_seq, __ATOMIC_RELAXED);
if (seq & 1) seq++;
__atomic_store_n(&shared->output_seq, seq + 1, __ATOMIC_RELEASE);
shared->output_feature = feature ? 1 : 0;
shared->output_length = static_cast<uint32_t>(size);
memcpy(shared->output, data, size);
if (size < sizeof(shared->output)) memset(shared->output + size, 0, sizeof(shared->output) - size);
__atomic_thread_fence(__ATOMIC_RELEASE);
__atomic_store_n(&shared->output_seq, seq + 2, __ATOMIC_RELEASE);
static std::atomic<unsigned> counts[2][256];
uint8_t report_id = feature ? (size > 1 ? data[1] : 0) : data[0];
unsigned n = ++counts[feature ? 1 : 0][report_id];
if ((n & (n - 1)) == 0)
Logger::log("triton: %s report 0x%02x (x%u, %zu bytes)\n", feature ? "feature" : "output", report_id, n, size);
}
// HIDIOCGFEATURE: the client reads the controller's info (report 1) this way, and a device that
// answers with nothing is dropped as a zombie. The read goes to the controller itself, as SDL's
// BLE backend does it (hid.m, get_feature_report): its reply is the report, with the report ID
// put in front when the controller left it out. Called without controller_mutex: it waits.
__attribute__((visibility("hidden"))) static int triton_get_feature(DeckHidraw &triton, uint8_t *report, size_t size) {
if (!report || size < 2 || triton.mapping_size < TRITON_SHARED_OFFSET + TRITON_SHARED_BLOCK_SIZE) {
errno = EINVAL;
return -1;
}
auto *shared = reinterpret_cast<TritonSharedData *>(reinterpret_cast<uint8_t *>(triton.ring) + TRITON_SHARED_OFFSET);
static std::mutex one_at_a_time;
std::lock_guard<std::mutex> guard(one_at_a_time);
uint8_t report_id = report[0];
__atomic_store_n(&shared->get_report_id, report_id, __ATOMIC_RELAXED);
uint64_t request = __atomic_add_fetch(&shared->get_request, 1, __ATOMIC_RELEASE);
for (int waited = 0; waited < TRITON_FEATURE_TIMEOUT_MS * 4; waited++) {
if (__atomic_load_n(&shared->get_reply, __ATOMIC_ACQUIRE) == request) {
int32_t length = __atomic_load_n(&shared->get_length, __ATOMIC_RELAXED);
if (length <= 0 || length > static_cast<int32_t>(sizeof(shared->get))) break;
size_t copied;
memset(report, 0, size);
if (shared->get[0] == report_id) {
copied = std::min(size, static_cast<size_t>(length));
memcpy(report, shared->get, copied);
} else {
report[0] = report_id;
copied = 1 + std::min(size - 1, static_cast<size_t>(length));
memcpy(report + 1, shared->get, copied - 1);
}
static std::atomic<unsigned> reads[256];
unsigned n = ++reads[report_id];
if ((n & (n - 1)) == 0)
Logger::log("triton: feature 0x%02x read, %d bytes from the controller [%02x %02x %02x %02x] (x%u)\n", report_id,
length, shared->get[0], shared->get[1], shared->get[2], shared->get[3], n);
return static_cast<int>(copied);
}
struct timespec interval = {0, 250 * 1000L};
nanosleep(&interval, nullptr);
}
static std::atomic<unsigned> failures;
unsigned n = ++failures;
if ((n & (n - 1)) == 0) Logger::log("triton: feature 0x%02x read got no answer from the controller (x%u)\n", report_id, n);
errno = EIO;
return -1;
}
__attribute__((visibility("hidden"))) static int ioctl_triton(DeckHidraw &triton, ioctl_request_t op, void *argp) {
if (_IOC_TYPE(op) != 'H') {
errno = ENOTTY;
return -1;
}
switch (_IOC_NR(op)) {
case 0x01:
*static_cast<int *>(argp) = sizeof(kTritonReportDescriptor);
return 0;
case 0x02: {
auto *descriptor = static_cast<uint8_t *>(argp);
uint32_t size;
memcpy(&size, descriptor, sizeof(size));
memcpy(descriptor + 4, kTritonReportDescriptor, std::min<size_t>(size, sizeof(kTritonReportDescriptor)));
return 0;
}
case 0x03: {
struct {
uint32_t bustype;
int16_t vendor;
int16_t product;
} info = {BUS_BLUETOOTH, static_cast<int16_t>(0x28de), static_cast<int16_t>(0x1303)};
memcpy(argp, &info, sizeof(info));
return 0;
}
case 0x04: return copy_ioctl_string(op, argp, TRITON_NAME);
case 0x05: return copy_ioctl_string(op, argp, "bluetooth-droiddeck-1");
case 0x08: return copy_ioctl_string(op, argp, TRITON_SERIAL);
case 0x06: {
// The client's feature reports are the report ID and 64 bytes; the last is padding the BLE
// characteristic does not take (SteamControllerBle.sendOutputReport drops it), so 64 are kept.
size_t size = _IOC_SIZE(op);
if (!argp || size < 2 || size > 65) {
errno = EINVAL;
return -1;
}
triton_queue_output(triton, static_cast<const uint8_t *>(argp), std::min<size_t>(size, 64), true);
return static_cast<int>(size);
}
case 0x07: return triton_get_feature(triton, static_cast<uint8_t *>(argp), _IOC_SIZE(op));
default:
errno = EINVAL;
return -1;
}
}
// A device scan that fails on a phone and not on the Thor: libudev gives up its whole scan on any
// sysfs directory it cannot read for a reason other than its absence, and a phone's SELinux policy
// denies directories a permissive device lets through. The client's /sys failures that are not
@@ -2201,6 +2491,7 @@ EXPORT int open(const char *pathname, int flags, ...) {
if (fake_uinput_enabled() && is_uinput_path(pathname)) return open_uinput(flags);
if (is_deck_hidraw_path(pathname)) return open_deck_hidraw(flags);
if (is_triton_hidraw_path(pathname)) return open_triton_hidraw(flags);
if (is_touch_hidraw_path(pathname)) return open_touch_hidraw(flags);
if (is_withdrawn_pad_path(pathname)) {
errno = ENOENT;
@@ -2276,6 +2567,7 @@ EXPORT int openat(int dirfd, const char *pathname, int flags, ...) {
if (fake_uinput_enabled() && is_uinput_path(pathname)) return open_uinput(flags);
if (is_deck_hidraw_path(pathname)) return open_deck_hidraw(flags);
if (is_triton_hidraw_path(pathname)) return open_triton_hidraw(flags);
if (is_touch_hidraw_path(pathname)) return open_touch_hidraw(flags);
if (is_withdrawn_pad_path(pathname)) {
errno = ENOENT;
@@ -2333,7 +2625,8 @@ EXPORT int openat(int dirfd, const char *pathname, int flags, ...) {
template <typename S, typename Real>
static int fake_stat_path(const char *pathname, S *statbuf, Real real) {
if (is_withdrawn_pad_path(pathname) || (is_deck_hidraw_path(pathname) && !process_is_steam_client())) {
if (is_withdrawn_pad_path(pathname) ||
((is_deck_hidraw_path(pathname) || is_triton_hidraw_path(pathname)) && !process_is_steam_client())) {
errno = ENOENT;
return -1;
}
@@ -2349,6 +2642,12 @@ static int fake_stat_path(const char *pathname, S *statbuf, Real real) {
statbuf->st_rdev = makedev(DECK_HIDRAW_MAJOR, DECK_HIDRAW_MINOR);
return 0;
}
if (is_triton_hidraw_path(pathname)) {
memset(statbuf, 0, sizeof(*statbuf));
statbuf->st_mode = S_IFCHR | 0666;
statbuf->st_rdev = makedev(TRITON_HIDRAW_MAJOR, TRITON_HIDRAW_MINOR);
return 0;
}
const char *event = nullptr;
char *fake_path = nullptr;
@@ -2394,7 +2693,9 @@ static int fake_stat_fd(int fd, S *buf, Real real) {
std::lock_guard<std::recursive_mutex> guard(controller_mutex());
if (ret == 0 && deck_map().count(fd)) {
buf->st_mode = (buf->st_mode & ~S_IFMT) | S_IFCHR;
buf->st_rdev = makedev(DECK_HIDRAW_MAJOR, deck_map()[fd]->touch ? TOUCH_HIDRAW_MINOR : DECK_HIDRAW_MINOR);
const auto &device = *deck_map()[fd];
unsigned int minor = device.touch ? TOUCH_HIDRAW_MINOR : (device.triton ? TRITON_HIDRAW_MINOR : DECK_HIDRAW_MINOR);
buf->st_rdev = makedev(DECK_HIDRAW_MAJOR, minor);
return ret;
}
auto controller = controller_map().find(fd);
@@ -2440,8 +2741,9 @@ EXPORT int access(const char *pathname, int mode) {
static auto my_access = reinterpret_cast<decltype(&::access)>(dlsym(RTLD_NEXT, "access"));
if (fake_uinput_enabled() && is_uinput_path(pathname)) return 0;
if (is_deck_hidraw_path(pathname) && process_is_steam_client()) return 0;
if (is_triton_hidraw_path(pathname) && process_is_steam_client()) return 0;
if (is_touch_hidraw_path(pathname)) return 0;
if (is_withdrawn_pad_path(pathname) || is_deck_hidraw_path(pathname)) {
if (is_withdrawn_pad_path(pathname) || is_deck_hidraw_path(pathname) || is_triton_hidraw_path(pathname)) {
errno = ENOENT;
return -1;
}
@@ -2477,8 +2779,9 @@ EXPORT int faccessat(int dirfd, const char *pathname, int mode, int flags) {
static auto my_faccessat = reinterpret_cast<decltype(&::faccessat)>(dlsym(RTLD_NEXT, "faccessat"));
if (fake_uinput_enabled() && is_uinput_path(pathname)) return 0;
if (is_deck_hidraw_path(pathname) && process_is_steam_client()) return 0;
if (is_triton_hidraw_path(pathname) && process_is_steam_client()) return 0;
if (is_touch_hidraw_path(pathname)) return 0;
if (is_withdrawn_pad_path(pathname) || is_deck_hidraw_path(pathname)) {
if (is_withdrawn_pad_path(pathname) || is_deck_hidraw_path(pathname) || is_triton_hidraw_path(pathname)) {
errno = ENOENT;
return -1;
}
@@ -2599,8 +2902,15 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
auto maker = uinput_map().find(fd);
if (maker != uinput_map().end()) return ioctl_uinput(*maker->second, op, argp);
auto deck = deck_map().find(fd);
if (deck != deck_map().end())
return deck->second->touch ? ioctl_touch(*deck->second, op, argp) : ioctl_deck(*deck->second, op, argp);
if (deck != deck_map().end()) {
if (deck->second->touch) return ioctl_touch(*deck->second, op, argp);
if (deck->second->triton) {
std::shared_ptr<DeckHidraw> triton = deck->second;
guard.unlock();
return ioctl_triton(*triton, op, argp);
}
return ioctl_deck(*deck->second, op, argp);
}
auto controller = controller_map().find(fd);
if (controller == controller_map().end()) {
guard.unlock();
@@ -2928,6 +3238,7 @@ EXPORT ssize_t write(int fd, const void *buf, size_t count) {
if (made != uinput_map().end()) return write_uinput(*made->second, buf, count);
if (deck_map().count(fd)) {
if (deck_map()[fd]->touch) touch_output(static_cast<const uint8_t *>(buf), count);
else if (deck_map()[fd]->triton) triton_queue_output(*deck_map()[fd], static_cast<const uint8_t *>(buf), count, false);
return static_cast<ssize_t>(count);
}
auto controller = controller_map().find(fd);
@@ -2954,9 +3265,10 @@ EXPORT ssize_t writev(int fd, const struct iovec *iov, int iovcnt) {
std::unique_lock<std::recursive_mutex> guard(controller_mutex());
if (deck_map().count(fd)) {
ssize_t total = 0;
bool touch = deck_map()[fd]->touch;
auto device = deck_map()[fd];
for (int i = 0; i < iovcnt; i++) {
if (touch) touch_output(static_cast<const uint8_t *>(iov[i].iov_base), iov[i].iov_len);
if (device->touch) touch_output(static_cast<const uint8_t *>(iov[i].iov_base), iov[i].iov_len);
else if (device->triton) triton_queue_output(*device, static_cast<const uint8_t *>(iov[i].iov_base), iov[i].iov_len, false);
total += static_cast<ssize_t>(iov[i].iov_len);
}
return total;
@@ -52,6 +52,7 @@ import com.droiddeck.launcher.input.ControllerPrefs
import com.droiddeck.launcher.input.SessionClipboard
import com.droiddeck.launcher.input.OnScreenControls
import com.droiddeck.launcher.input.PadBridge
import com.droiddeck.launcher.input.SteamControllerBle
import com.droiddeck.launcher.input.PointerGestures
import com.droiddeck.launcher.input.SecondScreenDisplay
import com.droiddeck.launcher.input.SecondScreenDisplays
@@ -147,8 +148,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
/** 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 && !pipUi && SessionState.deckPad) padMotion?.start() else padMotion?.stop()
// A Steam Controller brings its own gyro (SteamControllerBle); the phone's would fight it.
if (resumed && !pipUi && SessionState.deckPad && !SteamControllerBle.connected) padMotion?.start()
else padMotion?.stop()
}
private var steamController: SteamControllerBle? = null
private var onScreenControls: OnScreenControls? = null
/** Steam's touch controls (SteamTouchControls), made once the session offers the device. */
private var steamTouchControls: com.droiddeck.launcher.input.SteamTouchControls? = null
@@ -389,6 +393,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
bridge.setSelectSteam(ControllerPrefs.read(this).selectSteam)
padBridge = bridge
SessionState.padBridge = bridge
// A paired Steam Controller is read over Bluetooth LE, out of lizard mode, as Steam Link reads it.
SteamControllerBle.listener = { uiHandler.post { updatePadMotion(); updateOnScreenControls() } }
steamController = SteamControllerBle(this).also { it.start() }
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
@@ -2037,7 +2044,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// Auto: the touch pad when there is no controller - except on the desktop, where the
// screen is a touchpad for the pointer and a pad over it would be in the way. A game
// started from the rail, or Steam, gets it; the drawer turns it on anywhere.
else -> !PadBridge.anyControllerConnected() && SessionState.mode != SessionService.MODE_DESKTOP
else -> !PadBridge.anyControllerConnected() && !SteamControllerBle.connected &&
SessionState.mode != SessionService.MODE_DESKTOP
}
onScreenButtonsVisible = show
if (steam != null) {
@@ -2286,6 +2294,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
closeSecondScreen(reset = false)
if (::hud.isInitialized) hud.stop()
padBridge?.stop()
steamController?.stop()
if (SessionState.padBridge === padBridge) SessionState.padBridge = null
padMotion?.stop()
if (SessionState.deckPadListener === deckPadListener) SessionState.deckPadListener = null
@@ -94,9 +94,9 @@ public class FakeInputWriter {
private static final int RING_SNAPSHOT_SEQ_OFFSET = 32;
private static final int RING_SNAPSHOT_BUTTONS_OFFSET = 40;
private static final int RING_SNAPSHOT_AXES_OFFSET = 44; // short[8]
// 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.
// After the events - MUST match the blocks in fakeinput_steam.cpp. DeckImu carries the
// Deck-only motion, grips and trackpads. TritonShared carries the paired Steam Controller's
// native BLE reports and output reports, so the Steam client can see it as its own device.
private static final int IMU_OFFSET = RING_SIZE;
private static final int IMU_MAGIC = 0x31554D49; // IMU1
// Written by libfakeinput, not here: the client's SETTING_IMU_MODE | 0x80000000 (readImuMode).
@@ -107,7 +107,25 @@ public class FakeInputWriter {
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 int TRITON_OFFSET = RING_SIZE + 64;
private static final int TRITON_MAGIC = 0x4e525454; // TTRN
private static final int TRITON_INPUT_SEQ_OFFSET = TRITON_OFFSET + 8;
private static final int TRITON_REPORT_ID_OFFSET = TRITON_OFFSET + 16;
private static final int TRITON_REPORT_LENGTH_OFFSET = TRITON_OFFSET + 20;
private static final int TRITON_REPORT_OFFSET = TRITON_OFFSET + 24;
private static final int TRITON_OUTPUT_SEQ_OFFSET = TRITON_OFFSET + 88;
private static final int TRITON_OUTPUT_FEATURE_OFFSET = TRITON_OFFSET + 96;
private static final int TRITON_OUTPUT_LENGTH_OFFSET = TRITON_OFFSET + 100;
private static final int TRITON_OUTPUT_OFFSET = TRITON_OFFSET + 104;
// A feature read: libfakeinput bumps the request, the reply names the request it answers.
private static final int TRITON_GET_REQUEST_OFFSET = TRITON_OFFSET + 168;
private static final int TRITON_GET_REPLY_OFFSET = TRITON_OFFSET + 176;
private static final int TRITON_GET_REPORT_ID_OFFSET = TRITON_OFFSET + 184;
private static final int TRITON_GET_LENGTH_OFFSET = TRITON_OFFSET + 188;
private static final int TRITON_GET_OFFSET = TRITON_OFFSET + 192;
private static final int TRITON_REPORT_MAX = 64;
private static final int TRITON_BLOCK_SIZE = 256;
private static final int RING_FILE_SIZE = TRITON_OFFSET + TRITON_BLOCK_SIZE;
private static final Object RING_LOCK = new Object();
private static final RingSlot[] RING_SLOTS = new RingSlot[MAX_FAKE_INPUT_SLOTS];
@@ -223,6 +241,15 @@ public class FakeInputWriter {
data.putShort(IMU_ACCEL_OFFSET + (i * 2), (short) 0);
}
data.putInt(DECK_CONTROLS_OFFSET, 0);
data.putInt(TRITON_OFFSET, TRITON_MAGIC);
data.putLong(TRITON_INPUT_SEQ_OFFSET, 0L);
data.putInt(TRITON_REPORT_ID_OFFSET, 0);
data.putInt(TRITON_REPORT_LENGTH_OFFSET, 0);
data.putLong(TRITON_OUTPUT_SEQ_OFFSET, 0L);
data.putInt(TRITON_OUTPUT_FEATURE_OFFSET, 0);
data.putInt(TRITON_OUTPUT_LENGTH_OFFSET, 0);
data.putLong(TRITON_GET_REQUEST_OFFSET, 0L);
data.putLong(TRITON_GET_REPLY_OFFSET, 0L);
}
// Lock order: RING_LOCK, then the slot. Every writer holds the slot while it touches data, and
@@ -522,6 +549,89 @@ public class FakeInputWriter {
}
}
/** Publishes a native Triton input report (without the HID report ID byte). */
public static void writeTritonState(int slot, int reportId, byte[] report) {
if (report == null || report.length == 0 || report.length > TRITON_REPORT_MAX) return;
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 || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return;
long seq = ring.getLong(TRITON_INPUT_SEQ_OFFSET);
if ((seq & 1) != 0) seq++;
ring.putLong(TRITON_INPUT_SEQ_OFFSET, seq + 1);
nativeStoreFence();
ring.putInt(TRITON_REPORT_ID_OFFSET, reportId);
ring.putInt(TRITON_REPORT_LENGTH_OFFSET, report.length);
for (int i = 0; i < TRITON_REPORT_MAX; i++) {
ring.put(TRITON_REPORT_OFFSET + i, i < report.length ? report[i] : 0);
}
nativeStoreFence();
ring.putLong(TRITON_INPUT_SEQ_OFFSET, seq + 2);
}
}
/** Returns the newest Steam-client output report after {@code lastSequence}, or null. */
public static byte[] readTritonOutput(int slot, long lastSequence) {
RingSlot ringSlot;
synchronized (RING_LOCK) {
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
}
if (ringSlot == null) return null;
synchronized (ringSlot) {
ByteBuffer ring = ringSlot.data;
if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return null;
nativeStoreFence();
long seq = ring.getLong(TRITON_OUTPUT_SEQ_OFFSET);
if ((seq & 1) != 0 || seq <= lastSequence) return null;
int length = ring.getInt(TRITON_OUTPUT_LENGTH_OFFSET);
if (length <= 0 || length > TRITON_REPORT_MAX) return null;
byte[] output = new byte[9 + length];
ByteBuffer.wrap(output).order(ByteOrder.LITTLE_ENDIAN).putLong(seq);
output[8] = (byte) (ring.getInt(TRITON_OUTPUT_FEATURE_OFFSET) != 0 ? 1 : 0);
for (int i = 0; i < length; i++) output[9 + i] = ring.get(TRITON_OUTPUT_OFFSET + i);
nativeStoreFence();
return seq == ring.getLong(TRITON_OUTPUT_SEQ_OFFSET) ? output : null;
}
}
/** The Steam client's newest unanswered feature read, or 0: its request number. */
public static long readTritonFeatureRequest(int slot) {
RingSlot ringSlot = tritonSlot(slot);
if (ringSlot == null) return 0L;
synchronized (ringSlot) {
ByteBuffer ring = ringSlot.data;
if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return 0L;
nativeStoreFence();
long request = ring.getLong(TRITON_GET_REQUEST_OFFSET);
return request != ring.getLong(TRITON_GET_REPLY_OFFSET) ? request : 0L;
}
}
/** Answers feature read {@code request} with the controller's report, or a failure when null. */
public static void writeTritonFeatureReply(int slot, long request, byte[] report) {
RingSlot ringSlot = tritonSlot(slot);
if (ringSlot == null) return;
synchronized (ringSlot) {
ByteBuffer ring = ringSlot.data;
if (ring == null || ring.getInt(TRITON_OFFSET) != TRITON_MAGIC) return;
int length = report == null ? 0 : Math.min(report.length, TRITON_REPORT_MAX);
for (int i = 0; i < TRITON_REPORT_MAX; i++) ring.put(TRITON_GET_OFFSET + i, i < length ? report[i] : 0);
ring.putInt(TRITON_GET_LENGTH_OFFSET, length);
nativeStoreFence();
ring.putLong(TRITON_GET_REPLY_OFFSET, request);
}
}
private static RingSlot tritonSlot(int slot) {
synchronized (RING_LOCK) {
return slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
}
}
private static short clampShort(int value) {
if (value > Short.MAX_VALUE) {
return Short.MAX_VALUE;
@@ -369,6 +369,23 @@ public final class PadBridge {
publish();
}
/**
* A controller the app reads itself rather than through Android's input (the Steam Controller
* over Bluetooth, SteamControllerBle): its whole state, merged into the one exported slot as a
* physical pad's events are. Android has no device for it, so a game's rumble cannot reach it.
* It streams its state whether or not anything moved; {@code changed} is whether this one did.
*/
public synchronized void applyExternal(java.util.function.Consumer<PadState> mutation, boolean changed) {
mutation.accept(state);
if (changed) {
activeControllerId = NO_CONTROLLER;
notePlayerInput();
statButtons++;
scheduleStats();
}
publish();
}
/** Touch-only Steam and QAM buttons, merged with physical input without changing its state. */
public synchronized void setSystemButtons(boolean guidePressed, boolean qamPressed) {
if (systemGuidePressed == guidePressed && systemQamPressed == qamPressed) return;
@@ -0,0 +1,360 @@
package com.droiddeck.launcher.input
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.ArrayDeque
import java.util.UUID
/**
* Reads the Steam Controller (2025, Triton) through its Valve BLE service. State reports remain
* native Triton packets and are published to the session's separate 28DE:1303 HID device. Steam's
* output reports travel back to their matching BLE characteristics, preserving grip capacitance,
* trackpads, sensors, and controller haptics without folding the controller into the Deck target.
*
* Lizard mode is refreshed while connected; when a session ends, the controller restores it itself.
*/
@SuppressLint("MissingPermission") // BLUETOOTH is granted at install below targetSdk 31
class SteamControllerBle(private val context: Context) {
private val thread = HandlerThread("steam-controller-ble").apply { start() }
private val handler = Handler(thread.looper)
private val outputReports = mutableMapOf<Int, UUID>()
/** GATT allows one operation at a time: writes carry a value, a feature read none. */
private val queuedWrites = ArrayDeque<Pair<UUID, ByteArray?>>()
private var gatt: BluetoothGatt? = null
private var running = false
private var input: UUID? = null
private var reportId = 0
private var lizardOffSent = false
private var reportedNone = false
private var discoveries = 0
private var outputSequence = 0L
private var outputWritePending = false
/** The Steam client's feature read being answered, so the poll queues each one once. */
private var featureRead = 0L
/** Looks for a paired Steam Controller and keeps a connection to it until [stop]. */
fun start() = handler.post {
if (running) return@post
running = true
Log.i(TAG, "looking for a paired Steam Controller")
connect()
}
/** Lets the controller go (it returns to lizard mode by itself); this object is done after. */
fun stop() = handler.post {
running = false
handler.removeCallbacksAndMessages(null)
disconnect("session ended")
thread.quitSafely()
}
private fun connect() {
if (!running || gatt != null) return
val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
val bonded = runCatching { adapter?.takeIf(BluetoothAdapter::isEnabled)?.bondedDevices }
.onFailure { Log.w(TAG, "paired devices unreadable", it) }
.getOrNull()
val device = bonded?.firstOrNull(::isSteamController)
if (device == null) {
if (!reportedNone) {
reportedNone = true
Log.i(TAG, "no paired Steam Controller (Bluetooth ${if (adapter?.isEnabled == true) "on" else "off"}; paired: " +
(bonded?.joinToString { "\"${it.name}\" type ${it.type}" } ?: "unreadable") + "); looking again every ${RETRY_MS / 1000} s")
}
handler.postDelayed(::connect, RETRY_MS)
return
}
Log.i(TAG, "connecting to \"${device.name}\"")
gatt = device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE, BluetoothDevice.PHY_LE_1M_MASK, handler)
}
private fun disconnect(why: String) {
val g = gatt ?: return
gatt = null
discoveries = 0
input = null
lizardOffSent = false
outputWritePending = false
featureRead = 0L
queuedWrites.clear()
outputReports.clear()
connected = false
g.disconnect()
g.close()
release()
Log.i(TAG, "disconnected: $why")
listener?.invoke()
}
private val callback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
if (g !== gatt) return
if (newState == BluetoothProfile.STATE_CONNECTED) {
g.discoverServices()
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
disconnect("link lost (status $status)")
if (running) handler.postDelayed(::connect, RETRY_MS)
}
}
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
if (g !== gatt) return
if (status != BluetoothGatt.GATT_SUCCESS) {
disconnect("service discovery failed (status $status)")
if (running) handler.postDelayed(::connect, RETRY_MS)
return
}
// The Triton needs data length extensions, which Android enables for a large MTU.
if (!g.requestMtu(517)) subscribe(g)
}
override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) {
if (g === gatt) subscribe(g)
}
override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
if (g !== gatt || descriptor.characteristic.uuid != input) return
Log.i(TAG, "subscribed to report 0x%02x (status %d)".format(reportId, status))
connected = status == BluetoothGatt.GATT_SUCCESS
listener?.invoke()
if (connected) {
refreshLizardMode()
pollOutput()
}
}
override fun onCharacteristicWrite(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
if (g !== gatt) return
if (characteristic.uuid == REPORT && !lizardOffSent) {
lizardOffSent = true
Log.i(TAG, "lizard mode off (status $status)")
} else if (status != BluetoothGatt.GATT_SUCCESS) {
Log.w(TAG, "output report write failed for ${characteristic.uuid} (status $status)")
}
outputWritePending = false
writeNextOutput()
}
override fun onCharacteristicRead(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray, status: Int) {
if (g === gatt) onFeatureRead(value.takeIf { status == BluetoothGatt.GATT_SUCCESS })
}
@Deprecated("Below Android 13 only", ReplaceWith(""))
override fun onCharacteristicRead(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
@Suppress("DEPRECATION")
if (g === gatt) onFeatureRead(characteristic.value.takeIf { status == BluetoothGatt.GATT_SUCCESS })
}
// Android 13 hands the value over with the call; older ones only through the characteristic.
override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
if (g === gatt && characteristic.uuid == input) onState(value)
}
@Deprecated("Below Android 13 only", ReplaceWith(""))
override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
@Suppress("DEPRECATION")
if (g === gatt && characteristic.uuid == input) onState(characteristic.value ?: return)
}
}
private fun subscribe(g: BluetoothGatt) {
val service = g.getService(SERVICE)
if (service == null) {
Log.w(TAG, "no Valve service on \"${g.device.name}\" (attempt ${discoveries + 1}); services: " +
g.services.joinToString { it.uuid.toString().take(8) })
// Android may answer from the service list its HID host cached, which can lack the
// vendor service: ask the controller again before giving up on it.
if (++discoveries < 3) {
refreshCache(g)
handler.postDelayed({ if (g === gatt) g.discoverServices() }, 500)
} else {
disconnect("no Valve service")
if (running) handler.postDelayed(::connect, RETRY_MS)
}
return
}
val chr = service.getCharacteristic(INPUT_0X47)?.also { reportId = 0x47 }
?: service.getCharacteristic(INPUT_0X45)?.also { reportId = 0x45 }
if (chr == null) {
disconnect("no state report characteristic")
return
}
outputReports.clear()
service.characteristics.forEach { characteristic ->
val uuid = characteristic.uuid.toString().lowercase()
if (uuid.startsWith(OUTPUT_UUID_PREFIX)) {
val characteristicId = uuid.substring(6, 8).toIntOrNull(16) ?: return@forEach
val outputId = characteristicId - OUTPUT_UUID_OFFSET
if (outputId in 0x80..0x85) outputReports[outputId] = characteristic.uuid
}
}
Log.i(TAG, "found Triton haptic reports: ${outputReports.keys.sorted().joinToString { "0x%02x".format(it) }}")
input = chr.uuid
g.setCharacteristicNotification(chr, true)
val cccd = chr.getDescriptor(CCCD) ?: return disconnect("state report cannot notify")
@Suppress("DEPRECATION")
cccd.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
@Suppress("DEPRECATION")
g.writeDescriptor(cccd)
}
/** BluetoothGatt.refresh(): hidden, but used to drop stale service caches. */
private fun refreshCache(g: BluetoothGatt) {
runCatching { g.javaClass.getMethod("refresh").invoke(g) }
.onFailure { Log.w(TAG, "could not refresh the service cache", it) }
}
/** SDL's DisableSteamTritonLizardMode: ID_SET_SETTINGS_VALUES (0x87), setting 9 = off. */
private fun refreshLizardMode() {
if (!connected) return
val msg = ByteArray(FEATURE_BYTES)
msg[0] = 0x87.toByte(); msg[1] = 3; msg[2] = 9
enqueueOutput(REPORT, msg)
handler.postDelayed(::refreshLizardMode, LIZARD_REFRESH_MS)
}
/** Preserve the full 0x45 or 0x47 packet so Triton-only bits reach Steam's native driver. */
private fun onState(data: ByteArray) {
if (data.size < STATE_BYTES) return
FakeInputWriter.writeTritonState(SLOT, reportId, data)
}
/** A neutral report prevents a link lost mid-press from leaving controls held. */
private fun release() {
FakeInputWriter.writeTritonState(SLOT, if (reportId == 0x47) 0x47 else 0x45, ByteArray(STATE_BYTES))
}
/** Drain Steam HID output reports and write each to Triton's matching BLE characteristic. */
private fun pollOutput() {
if (!running || !connected || gatt == null) return
FakeInputWriter.readTritonOutput(SLOT, outputSequence)?.let { output ->
outputSequence = ByteBuffer.wrap(output, 0, 8).order(ByteOrder.LITTLE_ENDIAN).long
sendOutputReport(output[8] != 0.toByte(), output.copyOfRange(9, output.size))
}
val request = FakeInputWriter.readTritonFeatureRequest(SLOT)
if (request != 0L && request != featureRead) {
featureRead = request
// Behind any queued write, so a read answers the feature report written before it.
queuedWrites.addLast(REPORT to null)
writeNextOutput()
}
handler.postDelayed(::pollOutput, OUTPUT_POLL_MS)
}
/** The controller's answer to a feature read (its info, for one), or null when the read failed. */
private fun onFeatureRead(value: ByteArray?) {
outputWritePending = false
if (featureRead != 0L) {
if (value == null) Log.w(TAG, "feature read failed")
FakeInputWriter.writeTritonFeatureReply(SLOT, featureRead, value)
}
writeNextOutput()
}
private fun sendOutputReport(feature: Boolean, report: ByteArray) {
if (report.size < 2) return
val characteristic = if (feature) {
REPORT
} else {
val id = report[0].toInt() and 0xff
outputReports[id] ?: run {
Log.w(TAG, "no BLE output characteristic for report 0x%02x".format(id))
return
}
}
// SDL's BLE driver removes the HID report ID and trailing padding byte before transmission.
enqueueOutput(characteristic, report.copyOfRange(1, report.size - 1))
}
private fun enqueueOutput(characteristic: UUID, value: ByteArray?) {
queuedWrites.addLast(characteristic to value)
writeNextOutput()
}
private fun writeNextOutput() {
if (outputWritePending) return
val g = gatt ?: return
val (uuid, value) = queuedWrites.pollFirst() ?: return
val characteristic = g.getService(SERVICE)?.getCharacteristic(uuid)
if (characteristic == null) {
Log.w(TAG, "BLE output characteristic $uuid disappeared")
return
}
if (value == null) {
if (g.readCharacteristic(characteristic)) {
outputWritePending = true
} else {
queuedWrites.addFirst(uuid to null)
handler.postDelayed(::writeNextOutput, OUTPUT_RETRY_MS)
}
return
}
@Suppress("DEPRECATION")
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
@Suppress("DEPRECATION")
characteristic.value = value
@Suppress("DEPRECATION")
if (!g.writeCharacteristic(characteristic)) {
queuedWrites.addFirst(uuid to value)
handler.postDelayed(::writeNextOutput, OUTPUT_RETRY_MS)
} else {
outputWritePending = true
}
}
companion object {
private const val TAG = "SteamControllerBle"
/** The paired controller's native Triton reports live in slot 0's extended ring. */
private const val SLOT = 0
private const val RETRY_MS = 5_000L
private const val LIZARD_REFRESH_MS = 3_000L
private const val OUTPUT_POLL_MS = 4L
private const val OUTPUT_RETRY_MS = 10L
/** SDL's 64-byte feature report less the report ID and the trailing byte. */
private const val FEATURE_BYTES = 62
private const val STATE_BYTES = 45
private const val OUTPUT_UUID_PREFIX = "100f6c"
private const val OUTPUT_UUID_OFFSET = 0x35
private val SERVICE = UUID.fromString("100F6C32-1735-4313-B402-38567131E5F3")
private val INPUT_0X45 = UUID.fromString("100F6C7A-1735-4313-B402-38567131E5F3")
private val INPUT_0X47 = UUID.fromString("100F6C7C-1735-4313-B402-38567131E5F3")
private val REPORT = UUID.fromString("100F6C34-1735-4313-B402-38567131E5F3")
private val CCCD = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
private fun isSteamController(device: BluetoothDevice): Boolean =
device.type and BluetoothDevice.DEVICE_TYPE_LE != 0 &&
(device.name ?: "").let { it.startsWith("Steam Ctrl") || it.startsWith("SteamController") }
/** Whether a session should expose a Triton HID node for a paired controller. */
@JvmStatic
fun hasPairedController(context: Context): Boolean {
val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter ?: return false
return runCatching { adapter.isEnabled && adapter.bondedDevices.any(::isSteamController) }
.onFailure { Log.w(TAG, "paired devices unreadable", it) }
.getOrDefault(false)
}
/** A Steam Controller is connected and feeding its native HID reports. */
@Volatile @JvmStatic
var connected = false
private set
/** Notifies the activity when [connected] changes. */
@Volatile @JvmStatic
var listener: (() -> Unit)? = null
}
}
@@ -37,6 +37,7 @@ import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.core.HostProcess
import com.droiddeck.launcher.frontend.GameLaunchLink
import com.droiddeck.launcher.input.FakeInputWriter
import com.droiddeck.launcher.input.SteamControllerBle
import com.droiddeck.launcher.runtime.LinuxNetworkLinkComponent
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.runtime.ProotFastPath
@@ -744,7 +745,10 @@ class SessionService : Service() {
!File(Environment.getExternalStorageDirectory(), NO_DECK_PAD_SWITCH).exists()
// Steam's touch controller rides on the Deck's sysfs and is offered only beside it.
val wantsTouch = wantsDeck && !File(Environment.getExternalStorageDirectory(), NO_STEAM_TOUCH_SWITCH).exists()
deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!, wantsTouch) else emptyList()
// A paired Triton is exposed as a second native Steam Controller HID, with its original
// report IDs and output path. The handheld's own pad remains the Deck target.
val wantsTriton = wantsDeck && SteamControllerBle.hasPairedController(this)
deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!, wantsTouch, wantsTriton) else emptyList()
SessionState.deckPad = deckBinds.isNotEmpty()
if (wantsDeck && !SessionState.deckPad) Log.w(TAG, "deck pad: not available this session; the pad stays an Xbox 360 controller")
logControllersAtStart()
@@ -754,6 +758,7 @@ class SessionService : Service() {
guest.add("FAKE_EVDEV_UINPUT=1")
if (SessionState.deckPad) {
guest.add("FAKE_EVDEV_DECK=1")
if (wantsTriton) guest.add("FAKE_EVDEV_TRITON=1")
// Steam's touch controller (SteamTouchDevice): the file the app and libfakeinput share.
if (wantsTouch) com.droiddeck.launcher.input.SteamTouchDevice.prepare(fakeInputDir)?.let {
guest.add("FAKE_TOUCHCTL_RING=" + it.path)
@@ -44,6 +44,14 @@ object SteamDeckPad {
private const val HIDRAW = "$HID/hidraw/$NODE"
private const val SERIAL = "DROIDDECK0001"
// The paired Bluetooth Steam Controller is a separate Triton HID device. It must not inherit
// the Deck's identity or report format. Must match fakeinput_steam.cpp (TRITON_HIDRAW_*).
private const val TRITON_MINOR = 18
private const val TRITON_NODE = "hidraw$TRITON_MINOR"
private const val TRITON_HID = "$GUEST_DEVICES/bluetooth/0005:28DE:1303.0003"
private const val TRITON_HIDRAW = "$TRITON_HID/hidraw/$TRITON_NODE"
private const val TRITON_SERIAL = "DROIDDECKSC01"
// Steam's touch controller (SteamTouchDevice), a second device beside the Deck's: 0000:11fb,
// "Mobile Touch Control", on a USB device of its own. Must match libfakeinput (TOUCH_*).
private const val TOUCH_MINOR = 17
@@ -69,11 +77,27 @@ object SteamDeckPad {
0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0,
).map { it.toByte() }.toByteArray()
/** Triton's native 0x45/0x47 input and 0x80-0x85 haptic output report IDs. */
private val TRITON_REPORT_DESCRIPTOR = intArrayOf(
0x06, 0x00, 0xff, 0x09, 0x01, 0xa1, 0x01,
0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08,
0x85, 0x45, 0x09, 0x02, 0x95, 0x2d, 0x81, 0x02,
0x85, 0x47, 0x09, 0x03, 0x95, 0x2d, 0x81, 0x02,
0x85, 0x80, 0x09, 0x04, 0x95, 0x09, 0x91, 0x02,
0x85, 0x81, 0x09, 0x05, 0x95, 0x07, 0x91, 0x02,
0x85, 0x82, 0x09, 0x06, 0x95, 0x03, 0x91, 0x02,
0x85, 0x83, 0x09, 0x07, 0x95, 0x09, 0x91, 0x02,
0x85, 0x84, 0x09, 0x08, 0x95, 0x08, 0x91, 0x02,
0x85, 0x85, 0x09, 0x09, 0x95, 0x03, 0x91, 0x02,
0x85, 0x01, 0x09, 0x0a, 0x95, 0x3f, 0xb1, 0x02,
0xc0,
).map { it.toByte() }.toByteArray()
/** Where [prepare] puts the stand-in listings; bound into the guest at the same path. */
fun listingDir(sessionRoot: File) = File(sessionRoot, "sys/deck/listing")
/** 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, touch: Boolean = false): List<String> {
fun prepare(context: Context, sessionRoot: File, touch: Boolean = false, triton: Boolean = false): List<String> {
val base = File(sessionRoot, "sys/deck")
val devices = File(base, "devices")
val hidrawClass = File(base, "class-hidraw")
@@ -153,6 +177,22 @@ object SteamDeckPad {
Log.i(TAG, "deck pad: /dev/$TOUCH_NODE described as Steam's touch controller (0000:11fb)")
}
if (triton) {
write(TRITON_HID, "uevent", "DRIVER=hid-generic\nHID_ID=0005:000028DE:00001303\n" +
"HID_NAME=Valve Software Steam Controller\nHID_PHYS=bluetooth-droiddeck-1\n" +
"HID_UNIQ=$TRITON_SERIAL\nMODALIAS=hid:b0005g0001v000028DEp00001303\n")
File(dir(TRITON_HID), "report_descriptor").writeBytes(TRITON_REPORT_DESCRIPTOR)
link(File(dir(TRITON_HID), "subsystem"), "/sys/bus/hid")
write(TRITON_HIDRAW, "uevent", "MAJOR=$MAJOR\nMINOR=$TRITON_MINOR\nDEVNAME=$TRITON_NODE\n")
write(TRITON_HIDRAW, "dev", "$MAJOR:$TRITON_MINOR\n")
link(File(dir(TRITON_HIDRAW), "subsystem"), "/sys/class/hidraw")
link(File(dir(TRITON_HIDRAW), "device"), TRITON_HID)
link(File(hidrawClass, TRITON_NODE), TRITON_HIDRAW)
link(File(context.cacheDir, "drm/sys/$MAJOR:$TRITON_MINOR"), TRITON_HIDRAW)
File(udevData, "c$MAJOR:$TRITON_MINOR").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
Log.i(TAG, "steam controller: /dev/$TRITON_NODE described as Triton (28de:1303)")
}
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) {