diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e0f103841..d833ca2ae 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -30,6 +30,9 @@
+
+
diff --git a/app/src/main/cpp/fakeinput_steam.cpp b/app/src/main/cpp/fakeinput_steam.cpp
index c315a96b6..913d11914 100644
--- a/app/src/main/cpp/fakeinput_steam.cpp
+++ b/app/src/main/cpp/fakeinput_steam.cpp
@@ -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(reinterpret_cast(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(reinterpret_cast(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(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 *>(arg);
+ std::shared_ptr 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(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(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(mapping)) ||
+ reinterpret_cast(static_cast(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();
+ triton->peer = pair[1];
+ triton->ring = static_cast(mapping);
+ triton->mapping_size = mapping_size;
+ triton->triton = true;
+ {
+ std::lock_guard guard(controller_mutex());
+ deck_map()[pair[0]] = triton;
+ }
+ pthread_t thread;
+ auto *thread_arg = new std::shared_ptr(triton);
+ if (pthread_create(&thread, nullptr, triton_report_thread, thread_arg) != 0) {
+ delete thread_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("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(reinterpret_cast(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(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 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(reinterpret_cast(triton.ring) + TRITON_SHARED_OFFSET);
+ static std::mutex one_at_a_time;
+ std::lock_guard 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(sizeof(shared->get))) break;
+ size_t copied;
+ memset(report, 0, size);
+ if (shared->get[0] == report_id) {
+ copied = std::min(size, static_cast(length));
+ memcpy(report, shared->get, copied);
+ } else {
+ report[0] = report_id;
+ copied = 1 + std::min(size - 1, static_cast(length));
+ memcpy(report + 1, shared->get, copied - 1);
+ }
+ static std::atomic 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(copied);
+ }
+ struct timespec interval = {0, 250 * 1000L};
+ nanosleep(&interval, nullptr);
+ }
+ static std::atomic 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(argp) = sizeof(kTritonReportDescriptor);
+ return 0;
+ case 0x02: {
+ auto *descriptor = static_cast(argp);
+ uint32_t size;
+ memcpy(&size, descriptor, sizeof(size));
+ memcpy(descriptor + 4, kTritonReportDescriptor, std::min(size, sizeof(kTritonReportDescriptor)));
+ return 0;
+ }
+ case 0x03: {
+ struct {
+ uint32_t bustype;
+ int16_t vendor;
+ int16_t product;
+ } info = {BUS_BLUETOOTH, static_cast(0x28de), static_cast(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(argp), std::min(size, 64), true);
+ return static_cast(size);
+ }
+ case 0x07: return triton_get_feature(triton, static_cast(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
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 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(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(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 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(buf), count);
+ else if (deck_map()[fd]->triton) triton_queue_output(*deck_map()[fd], static_cast(buf), count, false);
return static_cast(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 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(iov[i].iov_base), iov[i].iov_len);
+ if (device->touch) touch_output(static_cast(iov[i].iov_base), iov[i].iov_len);
+ else if (device->triton) triton_queue_output(*device, static_cast(iov[i].iov_base), iov[i].iov_len, false);
total += static_cast(iov[i].iov_len);
}
return total;
diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
index 19f9f7eac..78d18e1be 100644
--- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
+++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
@@ -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
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 a5f18a062..f80c592e3 100644
--- a/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java
+++ b/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java
@@ -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;
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 7a543a363..9eff5a89b 100644
--- a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java
+++ b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java
@@ -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 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;
diff --git a/app/src/main/java/com/droiddeck/launcher/input/SteamControllerBle.kt b/app/src/main/java/com/droiddeck/launcher/input/SteamControllerBle.kt
new file mode 100644
index 000000000..a7bea1a1c
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/input/SteamControllerBle.kt
@@ -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()
+ /** GATT allows one operation at a time: writes carry a value, a feature read none. */
+ private val queuedWrites = ArrayDeque>()
+ 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
+ }
+}
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 1d2637723..2009b3e24 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt
@@ -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)
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt b/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt
index 928a397f3..a4b8ea2d1 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt
@@ -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 {
+ fun prepare(context: Context, sessionRoot: File, touch: Boolean = false, triton: Boolean = false): List {
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) {