diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 29718e087..bca1b149d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -29,6 +29,10 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/assets/licenses/sdl3/LICENSE.txt b/app/src/main/assets/licenses/sdl3/LICENSE.txt
new file mode 100644
index 000000000..e9adee448
--- /dev/null
+++ b/app/src/main/assets/licenses/sdl3/LICENSE.txt
@@ -0,0 +1,18 @@
+Copyright (C) 1997-2026 Sam Lantinga
+
+This software is provided 'as-is', without any express or implied
+warranty. In no event will the authors be held liable for any damages
+arising from the use of this software.
+
+Permission is granted to anyone to use this software for any purpose,
+including commercial applications, and to alter it and redistribute it
+freely, subject to the following restrictions:
+
+1. The origin of this software must not be misrepresented; you must not
+ claim that you wrote the original software. If you use this software
+ in a product, an acknowledgment in the product documentation would be
+ appreciated but is not required.
+2. Altered source versions must be plainly marked as such, and must not be
+ misrepresented as being the original software.
+3. This notice may not be removed or altered from any source distribution.
+
diff --git a/app/src/main/assets/sdl3bridge-build.txt b/app/src/main/assets/sdl3bridge-build.txt
new file mode 100644
index 000000000..170b501e9
--- /dev/null
+++ b/app/src/main/assets/sdl3bridge-build.txt
@@ -0,0 +1,3 @@
+repo=embedded
+tag=SdlBridge 63feeac
+sdl=3.4.18
diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt
index ab06f6a56..d06b353b9 100644
--- a/app/src/main/cpp/CMakeLists.txt
+++ b/app/src/main/cpp/CMakeLists.txt
@@ -63,3 +63,6 @@ if(DROIDDECK_UVC_DIR)
target_link_libraries(droiddeck_uvc PRIVATE rgb_uvc rgb_usb log)
target_link_options(droiddeck_uvc PRIVATE -Wl,-z,max-page-size=16384)
endif()
+
+# sdl3droiddeck: SDL3 and the SDL3 bridge (patch-droiddeck.sh step 30)
+add_subdirectory(sdl3bridge)
diff --git a/app/src/main/cpp/fakeinput_steam.cpp b/app/src/main/cpp/fakeinput_steam.cpp
index c446170fe..4f853326e 100644
--- a/app/src/main/cpp/fakeinput_steam.cpp
+++ b/app/src/main/cpp/fakeinput_steam.cpp
@@ -18,6 +18,9 @@
#include
#include
#include
+#include
+#include
+#include
#include
#include
@@ -354,8 +357,11 @@ __attribute__((constructor)) static void library_init() {
controller_map();
}
+static void hookup_ff_from_game(uint16_t slot, int strong, int weak, uint16_t length);
+
__attribute__((visibility("hidden"))) static void
send_vibration(int strong, int weak, uint16_t duration_ms, uint16_t slot) {
+ hookup_ff_from_game(slot, strong, weak, duration_ms);
if (!fake_vibration_enabled())
return;
@@ -804,6 +810,12 @@ struct UinputWriter {
std::vector pending;
uint32_t buttons = 0;
int16_t axes[8] = {};
+ std::deque ff_queue; // sdl3bridge: game rumble handed to Steam
+ uint64_t ff_seen = 0;
+ uint32_t ff_request = 0;
+ struct ff_effect ff_effect = {};
+ uint64_t paced_us = 0; // sdl3bridge: when the game last got a frame (virtual pad pacing)
+ bool held = false, flush_now = false;
};
static std::unordered_map> &uinput_map() {
@@ -934,7 +946,12 @@ __attribute__((visibility("hidden"))) static int publish_uinput(UinputWriter &w)
return 0;
}
+static void hookup_meter_virtual_pad(const std::vector &events);
+static bool virtual_pad_hold(UinputWriter &w);
+
__attribute__((visibility("hidden"))) static void flush_uinput(UinputWriter &w) {
+ if (w.published && !w.flush_now) hookup_meter_virtual_pad(w.pending);
+ if (w.published && virtual_pad_hold(w)) return; // sdl3bridge: stick / trigger moves paced for the game
if (!w.published || w.pending.empty()) {
w.pending.clear();
return;
@@ -1080,12 +1097,25 @@ ioctl_uinput(UinputWriter &w, ioctl_request_t op, void *argp) {
return -1;
}
return 0;
- case _IOC_NR(UI_BEGIN_FF_UPLOAD):
+ case _IOC_NR(UI_BEGIN_FF_UPLOAD): {
+ auto *upload = static_cast(argp);
+ if (!upload || upload->request_id != w.ff_request) {
+ errno = EINVAL;
+ return -1;
+ }
+ upload->retval = 0;
+ upload->effect = w.ff_effect;
+ memset(&upload->old, 0, sizeof(upload->old));
+ return 0;
+ }
case _IOC_NR(UI_END_FF_UPLOAD):
- case _IOC_NR(UI_BEGIN_FF_ERASE):
case _IOC_NR(UI_END_FF_ERASE):
- errno = EINVAL;
- return -1;
+ return 0;
+ case _IOC_NR(UI_BEGIN_FF_ERASE): {
+ auto *erase = static_cast(argp);
+ if (erase) erase->retval = 0;
+ return 0;
+ }
default:
Logger::log("uinput: unhandled ioctl %d\n", number);
errno = EINVAL;
@@ -1142,6 +1172,10 @@ struct DeckHidraw {
bool triton = false;
bool emulated = false;
uint8_t sony = 0;
+ bool sc1 = false; // sdl3bridge: Steam Controller (2015)
+ int8_t hookup = -1; // sdl3bridge: Steam Native Hook-up slot
+ uint32_t hookup_generation = 0;
+ std::shared_ptr hookup_in; // sdl3bridge: reports waiting for Steam (mailbox)
};
struct DeckImu {
@@ -1271,7 +1305,9 @@ static bool fake_deck_enabled() {
}
static constexpr uint32_t PAD_TYPE_DECK = 0, PAD_TYPE_STEAM = 1, PAD_TYPE_XBOX = 2, PAD_TYPE_DS4 = 3,
- PAD_TYPE_DUALSENSE = 4, PAD_TYPE_LAST = PAD_TYPE_DUALSENSE;
+ PAD_TYPE_DUALSENSE = 4, PAD_TYPE_DUALSENSE_EDGE = 5,
+ PAD_TYPE_STEAM_2015 = 6,
+ PAD_TYPE_NONE = 7, PAD_TYPE_LAST = PAD_TYPE_NONE;
static constexpr uint32_t PAD_TYPE_SET = 0x80000000u;
__attribute__((visibility("hidden"))) static uint32_t session_pad_type() {
@@ -1383,12 +1419,23 @@ __attribute__((visibility("hidden"))) static bool is_deck_hidraw_path(const char
static constexpr const char *DS4_HIDRAW_PATH = "/dev/hidraw20";
static constexpr const char *DUALSENSE_HIDRAW_PATH = "/dev/hidraw21";
-static constexpr unsigned int DS4_HIDRAW_MINOR = 20, DUALSENSE_HIDRAW_MINOR = 21;
+static constexpr unsigned int DS4_HIDRAW_MINOR = 20, DUALSENSE_HIDRAW_MINOR = 21, DUALSENSE_EDGE_HIDRAW_MINOR = 22;
+static constexpr const char *DUALSENSE_EDGE_HIDRAW_PATH = "/dev/hidraw22";
+static constexpr const char *SC1_HIDRAW_PATH = "/dev/hidraw23";
+static constexpr unsigned int SC1_HIDRAW_MINOR = 23;
+static constexpr const char *SC1_NAME = "Valve Software Steam Controller";
+static constexpr const char *SC1_SERIAL = "DROIDDECKSC15";
+
+__attribute__((visibility("hidden"))) static bool is_sc1_hidraw_path(const char *pathname) {
+ return pathname && fake_deck_enabled() && !strcmp(pathname, SC1_HIDRAW_PATH) &&
+ live_pad_type() == PAD_TYPE_STEAM_2015;
+}
__attribute__((visibility("hidden"))) static int sony_pad_path(const char *pathname) {
if (!pathname || !fake_deck_enabled() || strncmp(pathname, "/dev/hidraw2", 12)) return 0;
if (!strcmp(pathname, DS4_HIDRAW_PATH)) return live_pad_type() == PAD_TYPE_DS4 ? 4 : 0;
if (!strcmp(pathname, DUALSENSE_HIDRAW_PATH)) return live_pad_type() == PAD_TYPE_DUALSENSE ? 5 : 0;
+ if (!strcmp(pathname, DUALSENSE_EDGE_HIDRAW_PATH)) return live_pad_type() == PAD_TYPE_DUALSENSE_EDGE ? 6 : 0;
return 0;
}
@@ -1401,6 +1448,88 @@ __attribute__((visibility("hidden"))) static bool is_triton_hidraw_path(const ch
return pathname && fake_triton_enabled() && !strcmp(pathname, TRITON_HIDRAW_PATH) && triton_connected();
}
+// ===================== sdl3bridge: DroidDeck's desktop session =====================
+// On the desktop (FAKE_EVDEV_DESKTOP=1: the deck pad is on there for the SDL3 bridge) Steam is one program among
+// others, like on a PC:
+// - the Steam client gets the bridge's controller as its own node (the "present as" type, or the real controller
+// with Steam Native Hook-up), so DroidDeck's first pad (the same controller) is not shown to it; the other pads
+// are, as in the Steam session;
+// - games Steam starts get Steam's virtual pads, as in the Steam session (none of DroidDeck's own pads);
+// - every other program keeps DroidDeck's pads (Xbox 360 controllers) and does not see the hidraw nodes meant for
+// Steam, so no controller shows up twice.
+__attribute__((visibility("hidden"))) static bool desktop_session() {
+ static int on = -1;
+ if (on < 0) on = getenv("FAKE_EVDEV_DESKTOP") && atoi(getenv("FAKE_EVDEV_DESKTOP")) ? 1 : 0;
+ return on == 1;
+}
+
+__attribute__((visibility("hidden"))) static bool desktop_word_is_steam(const char *word, size_t length) {
+ const char *name = word;
+ for (size_t i = 0; i < length && word[i]; i++)
+ if (word[i] == '/') name = word + i + 1;
+ return name + 5 == word + length && !strncmp(name, "steam", 5);
+}
+
+/** Is process [pid] the Steam client? Its name, or -- FEX running the x86-64 client -- the program it runs. */
+__attribute__((visibility("hidden"))) static bool desktop_pid_is_steam(pid_t pid, pid_t *parent) {
+ char path[64], buf[1024];
+ *parent = 0;
+ snprintf(path, sizeof(path), "/proc/%d/stat", static_cast(pid));
+ int fd = static_cast(syscall(SYS_openat, AT_FDCWD, path, O_RDONLY | O_CLOEXEC));
+ if (fd < 0) return false;
+ ssize_t n = syscall(SYS_read, fd, buf, sizeof(buf) - 1);
+ syscall(SYS_close, fd);
+ if (n <= 0) return false;
+ buf[n] = 0;
+ char *open_paren = strchr(buf, '('), *close_paren = strrchr(buf, ')');
+ if (!open_paren || !close_paren || close_paren < open_paren) return false;
+ int ppid = 0;
+ if (sscanf(close_paren + 1, " %*c %d", &ppid) == 1) *parent = static_cast(ppid);
+ if (desktop_word_is_steam(open_paren + 1, static_cast(close_paren - open_paren - 1))) return true;
+ snprintf(path, sizeof(path), "/proc/%d/cmdline", static_cast(pid));
+ fd = static_cast(syscall(SYS_openat, AT_FDCWD, path, O_RDONLY | O_CLOEXEC));
+ if (fd < 0) return false;
+ n = syscall(SYS_read, fd, buf, sizeof(buf) - 1);
+ syscall(SYS_close, fd);
+ if (n <= 0) return false;
+ buf[n] = 0;
+ const char *word = buf, *end = buf + n;
+ for (int i = 0; i < 2 && word < end; i++) { // argv[0], and argv[1] for FEX ("FEX /.../steamrt64/steam")
+ size_t length = strnlen(word, static_cast(end - word));
+ if (desktop_word_is_steam(word, length)) return true;
+ word += length + 1;
+ }
+ return false;
+}
+
+/** Started by the Steam client (a game, or anything it runs): looked up once, through the parent processes. */
+__attribute__((visibility("hidden"))) static bool started_by_steam() {
+ static int known = 0;
+ if (known) return known > 0;
+ bool found = false;
+ pid_t pid = getppid();
+ for (int depth = 0; pid > 1 && depth < 64 && !found; depth++) {
+ pid_t parent = 0;
+ found = desktop_pid_is_steam(pid, &parent);
+ pid = parent;
+ }
+ known = found ? 1 : -1;
+ return found;
+}
+
+/** A program started on the desktop: neither the Steam client nor something Steam started. */
+__attribute__((visibility("hidden"))) static bool desktop_program() {
+ return desktop_session() && !process_is_steam_client() && !started_by_steam();
+}
+
+/** /dev/hidraw16..27 (DroidDeck's nodes for Steam) are not there for a desktop program. */
+__attribute__((visibility("hidden"))) static bool desktop_hidden_hidraw(const char *pathname) {
+ if (!pathname || strncmp(pathname, "/dev/hidraw", 11) || !desktop_session()) return false;
+ char *end = nullptr;
+ long minor = strtol(pathname + 11, &end, 10);
+ return end != pathname + 11 && !*end && minor >= 16 && minor <= 27 && desktop_program();
+}
+
__attribute__((visibility("hidden"))) static bool is_withdrawn_pad_path(const char *pathname) {
if (!fake_deck_enabled() || !pathname || strncmp(pathname, "/dev/input/", 11)) return false;
if (live_pad_type() == PAD_TYPE_XBOX) return false;
@@ -1408,6 +1537,7 @@ __attribute__((visibility("hidden"))) static bool is_withdrawn_pad_path(const ch
if (strncmp(event, "event", 5) && strncmp(event, "js", 2)) return false;
int number = get_event_number(event);
if (number < 0 || number >= UINPUT_EVENT_BASE) return false;
+ if (desktop_session()) return process_is_steam_client() ? number == 0 : started_by_steam(); // sdl3bridge
return number == 0 || !process_is_steam_client();
}
@@ -1477,6 +1607,10 @@ build_deck_report(uint8_t *report, uint32_t packet, uint32_t buttons, const int3
put16(report + 54, stick(-axes[3]));
}
+// sdl3bridge: Steam reads the newest reports of every controller (mailbox, see HookupQueue)
+static void pad_mailbox_attach(DeckHidraw &pad);
+static int pad_mailbox_offer(DeckHidraw &pad, const uint8_t *data, uint32_t length);
+
__attribute__((visibility("hidden"))) static void *deck_report_thread(void *arg) {
auto *deck = static_cast *>(arg);
std::shared_ptr self = *deck;
@@ -1494,9 +1628,7 @@ __attribute__((visibility("hidden"))) static void *deck_report_thread(void *arg)
read_deck_extras(*self, extras);
uint8_t report[DECK_REPORT_BYTES];
build_deck_report(report, ++packet, buttons, axes, extras);
- if (send(self->peer, report, sizeof(report), MSG_DONTWAIT | MSG_NOSIGNAL) < 0 &&
- errno != EAGAIN && errno != EWOULDBLOCK)
- break;
+ if (pad_mailbox_offer(*self, report, sizeof(report)) < 0) break;
sleep_us(controller_report_interval_us(DECK_REPORT_INTERVAL_US));
}
syscall(SYS_close, self->peer);
@@ -1519,8 +1651,7 @@ __attribute__((visibility("hidden"))) static void put_sony_touch(uint8_t *at, So
bool down, int16_t x, int16_t y, int height) {
if (down && !touch.down[side]) touch.id[side] = touch.next_id++ & 0x7f;
touch.down[side] = down;
- int half = SONY_TOUCH_WIDTH / 2;
- int px = side * half + (static_cast(x) + 32768) * (half - 1) / 65535;
+ int px = (static_cast(x) + 32768) * (SONY_TOUCH_WIDTH - 1) / 65535;
int py = (32767 - static_cast(y)) * (height - 1) / 65535;
at[0] = static_cast(touch.id[side] | (down ? 0 : 0x80));
at[1] = static_cast(px & 0xff);
@@ -1548,6 +1679,19 @@ build_sony_report(uint8_t *report, uint8_t model, uint32_t packet, uint64_t elap
(buttons & 256 ? 0x40 : 0) | (buttons & 512 ? 0x80 : 0);
bool click = extras.controls & (DECK_EXTRA_LPAD_CLICK | DECK_EXTRA_RPAD_CLICK);
uint8_t system = (buttons & 1024 ? 0x01 : 0) | (click ? 0x02 : 0);
+ // DualSense / Edge mute (microphone) button -> bit 0x04 of this byte. The bridge maps the mute
+ // button to the Deck QAM bit; DualShock 4 (model 4) has no mute and reuses 0x04 for its counter.
+ if (model != 4 && (buttons & SNAPSHOT_QAM_BIT)) system |= 0x04;
+ // DualSense Edge extra buttons: SDL/Steam read all four from this byte on PID 0x0df2 --
+ // 0x10 left Fn, 0x20 right Fn, 0x40 left back paddle, 0x80 right back paddle.
+ // model 6 == the Edge node; plain DualSense (5) and DS4 (4) are left untouched. Deck grips map 1:1:
+ // L4 -> left paddle, R4 -> right paddle, L5 -> left Fn, R5 -> right Fn.
+ if (model == 6) {
+ if (extras.controls & DECK_EXTRA_L4) system |= 0x40;
+ if (extras.controls & DECK_EXTRA_R4) system |= 0x80;
+ if (extras.controls & DECK_EXTRA_L5) system |= 0x10;
+ if (extras.controls & DECK_EXTRA_R5) system |= 0x20;
+ }
uint8_t left_trigger = static_cast(std::max(0, std::min(255, axes[5])));
uint8_t right_trigger = static_cast(std::max(0, std::min(255, axes[4])));
int16_t gyro[3] = {extras.gyro[0], extras.gyro[2], static_cast(-extras.gyro[1])};
@@ -1594,7 +1738,7 @@ __attribute__((visibility("hidden"))) static void *sony_report_thread(void *arg)
auto *holder = static_cast *>(arg);
std::shared_ptr self = *holder;
delete holder;
- uint32_t type = self->sony == 4 ? PAD_TYPE_DS4 : PAD_TYPE_DUALSENSE;
+ uint32_t type = self->sony == 4 ? PAD_TYPE_DS4 : self->sony == 6 ? PAD_TYPE_DUALSENSE_EDGE : PAD_TYPE_DUALSENSE;
uint32_t packet = 0;
SnapshotState snap;
uint32_t buttons = 0;
@@ -1610,9 +1754,7 @@ __attribute__((visibility("hidden"))) static void *sony_report_thread(void *arg)
read_deck_extras(*self, extras);
uint8_t report[SONY_REPORT_BYTES];
build_sony_report(report, self->sony, ++packet, monotonic_us() - start_us, buttons, axes, extras, touch);
- if (send(self->peer, report, sizeof(report), MSG_DONTWAIT | MSG_NOSIGNAL) < 0 &&
- errno != EAGAIN && errno != EWOULDBLOCK)
- break;
+ if (pad_mailbox_offer(*self, report, sizeof(report)) < 0) break;
sleep_us(controller_report_interval_us(DECK_REPORT_INTERVAL_US));
}
syscall(SYS_close, self->peer);
@@ -1620,8 +1762,119 @@ __attribute__((visibility("hidden"))) static void *sony_report_thread(void *arg)
return nullptr;
}
-__attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags, uint8_t sony = 0) {
- const char *path = sony == 4 ? DS4_HIDRAW_PATH : sony == 5 ? DUALSENSE_HIDRAW_PATH : DECK_HIDRAW_PATH;
+// sdl3bridge: Steam Controller (2015) state packet -- ValveInReport_t type 1 (ID_CONTROLLER_STATE),
+// laid out as SDL's ValveControllerStatePacket_t: buttons in bytes 8..10, analog triggers in bytes 11/12,
+// left axes 16/18 (stick OR left pad, see SC1_FINGER_LEFT), right pad 20/22, triggers 24/26, accel 28..,
+// gyro 34.., orientation quaternion 40...
+static constexpr uint32_t SC1_RT = 0x000001, SC1_LT = 0x000002, SC1_RB = 0x000004, SC1_LB = 0x000008,
+ SC1_Y = 0x000010, SC1_B = 0x000020, SC1_X = 0x000040, SC1_A = 0x000080,
+ SC1_UP = 0x000100, SC1_RIGHT = 0x000200, SC1_LEFT = 0x000400, SC1_DOWN = 0x000800,
+ SC1_BACK = 0x001000, SC1_STEAM = 0x002000, SC1_START = 0x004000,
+ SC1_LGRIP = 0x008000, SC1_RGRIP = 0x010000, SC1_LPAD_CLICK = 0x020000,
+ SC1_RPAD_CLICK = 0x040000, SC1_FINGER_LEFT = 0x080000, SC1_FINGER_RIGHT = 0x100000,
+ SC1_STICK_CLICK = 0x400000, SC1_PAD_AND_STICK = 0x800000;
+static constexpr int32_t SC1_STICK_ACTIVE = 2000;
+static constexpr int16_t SC1_DPAD_EDGE = 24000;
+
+__attribute__((visibility("hidden"))) static void
+build_sc1_report(uint8_t *report, uint32_t packet, uint32_t buttons, const int32_t *axes, const DeckExtras &extras) {
+ memset(report, 0, DECK_REPORT_BYTES);
+ report[0] = 0x01; // report version 1
+ report[2] = 0x01; // ID_CONTROLLER_STATE
+ report[3] = 0x3c;
+ put32(report + 4, packet);
+ // Snapshot order: A B X Y L1 R1 View Menu L3 R3 Steam. No right stick on this controller, so no R3.
+ static const uint32_t kButtonBits[11] = {SC1_A, SC1_B, SC1_X, SC1_Y, SC1_LB, SC1_RB,
+ SC1_BACK, SC1_START, SC1_STICK_CLICK, 0, SC1_STEAM};
+ uint32_t bits = 0;
+ for (int i = 0; i < 11; i++)
+ if (buttons & (1u << i)) bits |= kButtonBits[i];
+ int32_t left_trigger = std::max(0, std::min(255, axes[5])), right_trigger = std::max(0, std::min(255, axes[4]));
+ if (left_trigger >= 250) bits |= SC1_LT;
+ if (right_trigger >= 250) bits |= SC1_RT;
+ uint32_t controls = extras.controls;
+ if (controls & (DECK_EXTRA_L4 | DECK_EXTRA_L5)) bits |= SC1_LGRIP;
+ if (controls & (DECK_EXTRA_R4 | DECK_EXTRA_R5)) bits |= SC1_RGRIP;
+ bool left_pad = controls & DECK_EXTRA_LPAD_TOUCH, right_pad = controls & DECK_EXTRA_RPAD_TOUCH;
+ if (controls & DECK_EXTRA_LPAD_CLICK) bits |= SC1_LPAD_CLICK;
+ if (controls & DECK_EXTRA_RPAD_CLICK) bits |= SC1_RPAD_CLICK;
+ int32_t left_x = extras.pads[0], left_y = extras.pads[1];
+ // The real controller has no D-pad: its D-pad is a click on the left trackpad's edge.
+ int32_t hat_x = axes[6], hat_y = axes[7];
+ if (hat_x || hat_y) {
+ if (hat_y < 0) bits |= SC1_UP;
+ if (hat_y > 0) bits |= SC1_DOWN;
+ if (hat_x < 0) bits |= SC1_LEFT;
+ if (hat_x > 0) bits |= SC1_RIGHT;
+ left_pad = true;
+ left_x = hat_x < 0 ? -SC1_DPAD_EDGE : hat_x > 0 ? SC1_DPAD_EDGE : 0;
+ left_y = hat_y < 0 ? SC1_DPAD_EDGE : hat_y > 0 ? -SC1_DPAD_EDGE : 0;
+ bits |= SC1_LPAD_CLICK;
+ }
+ auto clamp16 = [](int32_t value) { return std::max(-32767, std::min(32767, value)); };
+ int32_t stick_x = clamp16(axes[0]), stick_y = clamp16(-axes[1]);
+ bool stick = abs(stick_x) > SC1_STICK_ACTIVE || abs(stick_y) > SC1_STICK_ACTIVE;
+ // The stick and the left pad share one axis pair; SC1_FINGER_LEFT says which one this packet carries,
+ // and SC1_PAD_AND_STICK says both are in use (the firmware then alternates them packet by packet).
+ int32_t axis_x = stick_x, axis_y = stick_y;
+ if (left_pad && stick) {
+ bits |= SC1_PAD_AND_STICK;
+ if (packet & 1) {
+ bits |= SC1_FINGER_LEFT;
+ axis_x = left_x;
+ axis_y = left_y;
+ }
+ } else if (left_pad) {
+ bits |= SC1_FINGER_LEFT;
+ axis_x = left_x;
+ axis_y = left_y;
+ }
+ if (right_pad) bits |= SC1_FINGER_RIGHT;
+ report[8] = static_cast(bits);
+ report[9] = static_cast(bits >> 8);
+ report[10] = static_cast(bits >> 16);
+ report[11] = static_cast(left_trigger);
+ report[12] = static_cast(right_trigger);
+ put16(report + 16, clamp16(axis_x));
+ put16(report + 18, clamp16(axis_y));
+ put16(report + 20, right_pad ? extras.pads[2] : 0);
+ put16(report + 22, right_pad ? extras.pads[3] : 0);
+ put16(report + 24, left_trigger * 32767 / 255);
+ put16(report + 26, right_trigger * 32767 / 255);
+ for (int i = 0; i < 3; i++) {
+ put16(report + 28 + i * 2, extras.accel[i]);
+ put16(report + 34 + i * 2, extras.gyro[i]);
+ }
+ put16(report + 40, 32767); // orientation quaternion: identity (Steam uses the raw gyro)
+}
+
+__attribute__((visibility("hidden"))) static void *sc1_report_thread(void *arg) {
+ auto *holder = static_cast *>(arg);
+ std::shared_ptr self = *holder;
+ delete holder;
+ uint32_t packet = 0;
+ SnapshotState snap;
+ uint32_t buttons = 0;
+ int32_t axes[8] = {};
+ DeckExtras extras;
+ while (live_pad_type() == PAD_TYPE_STEAM_2015) {
+ if (read_snapshot(self->ring, snap) && snap.generation == ring_generation(self->ring)) {
+ buttons = snap.buttons;
+ memcpy(axes, snap.axes, sizeof(axes));
+ }
+ read_deck_extras(*self, extras);
+ uint8_t report[DECK_REPORT_BYTES];
+ build_sc1_report(report, ++packet, buttons, axes, extras);
+ if (pad_mailbox_offer(*self, report, sizeof(report)) < 0) break;
+ sleep_us(controller_report_interval_us(DECK_REPORT_INTERVAL_US));
+ }
+ syscall(SYS_close, self->peer);
+ munmap(self->ring, self->mapping_size);
+ return nullptr;
+}
+
+__attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags, uint8_t sony = 0, bool sc1 = false) {
+ const char *path = sc1 ? SC1_HIDRAW_PATH : sony == 4 ? DS4_HIDRAW_PATH : sony == 5 ? DUALSENSE_HIDRAW_PATH : sony == 6 ? DUALSENSE_EDGE_HIDRAW_PATH : DECK_HIDRAW_PATH;
static auto my_open = reinterpret_cast(dlsym(RTLD_NEXT, "open"));
if (!process_is_steam_client()) {
Logger::log("deck: %s refused: not the Steam client\n", path);
@@ -1665,6 +1918,8 @@ __attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags, uin
deck->ring = static_cast(mapping);
deck->mapping_size = mapping_size;
deck->sony = sony;
+ deck->sc1 = sc1;
+ pad_mailbox_attach(*deck);
{
std::lock_guard guard(controller_mutex());
deck_map()[pair[0]] = deck;
@@ -1672,7 +1927,7 @@ __attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags, uin
}
pthread_t thread;
auto *arg = new std::shared_ptr(deck);
- if (pthread_create(&thread, nullptr, sony ? sony_report_thread : deck_report_thread, arg) != 0) {
+ if (pthread_create(&thread, nullptr, sony ? sony_report_thread : sc1 ? sc1_report_thread : deck_report_thread, arg) != 0) {
delete arg;
std::lock_guard guard(controller_mutex());
deck_map().erase(pair[0]);
@@ -1685,7 +1940,7 @@ __attribute__((visibility("hidden"))) static int open_deck_hidraw(int flags, uin
}
pthread_detach(thread);
Logger::log("deck: %s opened as fd %d%s\n", path, pair[0],
- sony == 4 ? " (a DualShock 4)" : sony == 5 ? " (a DualSense)" : "");
+ sony == 4 ? " (a DualShock 4)" : sony == 5 ? " (a DualSense)" : sony == 6 ? " (a DualSense Edge)" : sc1 ? " (a Steam Controller 2015)" : "");
return pair[0];
}
@@ -1723,9 +1978,7 @@ __attribute__((visibility("hidden"))) static void *triton_report_thread(void *ar
while (triton_connected()) {
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;
+ if (pad_mailbox_offer(*self, report, static_cast(length + 1)) < 0) break;
}
sleep_us(controller_report_interval_us(TRITON_REPORT_INTERVAL_US));
}
@@ -1820,9 +2073,7 @@ __attribute__((visibility("hidden"))) static void *triton_pad_thread(void *arg)
uint8_t report[TRITON_REPORT_ID_BYTES + TRITON_STATE_BYTES];
report[0] = TRITON_STATE_WIRED;
build_triton_report(report + 1, ++packet, monotonic_us() - start_us, buttons, axes, extras);
- if (send(self->peer, report, sizeof(report), MSG_DONTWAIT | MSG_NOSIGNAL) < 0 &&
- errno != EAGAIN && errno != EWOULDBLOCK)
- break;
+ if (pad_mailbox_offer(*self, report, sizeof(report)) < 0) break;
sleep_us(controller_report_interval_us(TRITON_REPORT_INTERVAL_US));
}
syscall(SYS_close, self->peer);
@@ -1874,6 +2125,7 @@ __attribute__((visibility("hidden"))) static int open_triton_hidraw(int flags, b
triton->mapping_size = mapping_size;
triton->triton = true;
triton->emulated = emulated;
+ pad_mailbox_attach(*triton);
{
std::lock_guard guard(controller_mutex());
deck_map()[pair[0]] = triton;
@@ -2173,12 +2425,15 @@ __attribute__((visibility("hidden"))) static void touch_uevent(const char *actio
static constexpr const char *DECK_DEVPATH = "/devices/droiddeck/usb1/1-1:1.2/0003:28DE:1205.0001/hidraw/hidraw16";
static constexpr const char *TRITON_PAD_DEVPATH = "/devices/droiddeck/usb3/3-1:1.0/0003:28DE:1302.0004/hidraw/hidraw19";
+static constexpr const char *SC1_DEVPATH = "/devices/droiddeck/usb7/7-1:1.2/0003:28DE:1102.0008/hidraw/hidraw23";
static constexpr const char *PAD_EVDEV_DEVPATH = "/devices/virtual/input/droiddeck0/event0";
static constexpr const char *DS4_DEVPATH = "/devices/droiddeck/usb4/4-1:1.3/0003:054C:09CC.0005/hidraw/hidraw20";
static constexpr const char *DUALSENSE_DEVPATH = "/devices/droiddeck/usb5/5-1:1.3/0003:054C:0CE6.0006/hidraw/hidraw21";
+static constexpr const char *DUALSENSE_EDGE_DEVPATH = "/devices/droiddeck/usb6/6-1:1.3/0003:054C:0DF2.0007/hidraw/hidraw22";
static constexpr const char *TRITON_DEVPATH = "/devices/droiddeck/bluetooth/0005:28DE:1303.0003/hidraw/hidraw18";
__attribute__((visibility("hidden"))) static int pad_type_uevent(uint32_t type, const char *action) {
+ if (type == PAD_TYPE_NONE) return 0; // sdl3bridge: no pad (real controllers handed to Steam)
if (type == PAD_TYPE_DECK)
return inject_uevent(action, DECK_DEVPATH, "hidraw", DECK_HIDRAW_PATH, DECK_HIDRAW_MAJOR, DECK_HIDRAW_MINOR);
if (type == PAD_TYPE_DS4)
@@ -2186,6 +2441,11 @@ __attribute__((visibility("hidden"))) static int pad_type_uevent(uint32_t type,
if (type == PAD_TYPE_DUALSENSE)
return inject_uevent(action, DUALSENSE_DEVPATH, "hidraw", DUALSENSE_HIDRAW_PATH, DECK_HIDRAW_MAJOR,
DUALSENSE_HIDRAW_MINOR);
+ if (type == PAD_TYPE_DUALSENSE_EDGE)
+ return inject_uevent(action, DUALSENSE_EDGE_DEVPATH, "hidraw", DUALSENSE_EDGE_HIDRAW_PATH, DECK_HIDRAW_MAJOR,
+ DUALSENSE_EDGE_HIDRAW_MINOR);
+ if (type == PAD_TYPE_STEAM_2015)
+ return inject_uevent(action, SC1_DEVPATH, "hidraw", SC1_HIDRAW_PATH, DECK_HIDRAW_MAJOR, SC1_HIDRAW_MINOR);
if (type == PAD_TYPE_STEAM)
return inject_uevent(action, TRITON_PAD_DEVPATH, "hidraw", TRITON_PAD_HIDRAW_PATH, TRITON_HIDRAW_MAJOR,
TRITON_PAD_HIDRAW_MINOR);
@@ -2207,15 +2467,18 @@ __attribute__((visibility("hidden"))) static void poke_hidraw_watchers(unsigned
__attribute__((visibility("hidden"))) static unsigned pad_type_minor(uint32_t type) {
return type == PAD_TYPE_DS4 ? DS4_HIDRAW_MINOR
: type == PAD_TYPE_DUALSENSE ? DUALSENSE_HIDRAW_MINOR
- : type == PAD_TYPE_STEAM ? TRITON_PAD_HIDRAW_MINOR : DECK_HIDRAW_MINOR;
+ : type == PAD_TYPE_DUALSENSE_EDGE ? DUALSENSE_EDGE_HIDRAW_MINOR
+ : type == PAD_TYPE_STEAM ? TRITON_PAD_HIDRAW_MINOR
+ : type == PAD_TYPE_STEAM_2015 ? SC1_HIDRAW_MINOR : DECK_HIDRAW_MINOR;
}
__attribute__((visibility("hidden"))) static void *pad_type_thread(void *) {
static const char *kNames[] = {"Steam Deck controller", "Steam Controller", "Xbox 360 controller",
- "DualShock 4", "DualSense"};
+ "DualShock 4", "DualSense", "DualSense Edge",
+ "Steam Controller (2015)", "no pad"};
uint32_t was = live_pad_type();
bool paired = fake_triton_enabled() && triton_connected();
- Logger::log("pad: a %s at start%s\n", kNames[was % 5], paired ? ", the paired Steam Controller connected" : "");
+ Logger::log("pad: a %s at start%s\n", kNames[was % 8], paired ? ", the paired Steam Controller connected" : "");
for (;;) {
struct timespec interval = {0, 50 * 1000 * 1000};
nanosleep(&interval, nullptr);
@@ -2393,6 +2656,13 @@ deck_feature(DeckHidraw &deck, ioctl_request_t op, uint8_t *buf, bool set) {
int left = buf[6] | buf[7] << 8, right = buf[8] | buf[9] << 8;
send_vibration(left, right, left || right ? 1000 : 0, 0);
}
+ // Steam rumbles / identifies a 2015 Steam Controller with trackpad haptic pulses (0x8f: side,
+ // on-us, off-us, repeat). Forward the long ones as rumble; short ones are trackpad ticks.
+ if (deck.sc1 && buf[1] == 0x8f && size >= 10) {
+ int on_us = buf[4] | buf[5] << 8, off_us = buf[6] | buf[7] << 8, repeat = buf[8] | buf[9] << 8;
+ long total_ms = static_cast(on_us + off_us) * std::max(1, repeat) / 1000;
+ if (total_ms >= 20) send_vibration(0, 40000, static_cast(std::min(1000L, total_ms)), 0);
+ }
if (buf[1] == DECK_SET_SETTINGS_VALUES && size >= 3) {
int imu_mode = deck_imu_mode_change(buf, size);
if (imu_mode >= 0) publish_imu_mode(deck, imu_mode);
@@ -2409,13 +2679,17 @@ deck_feature(DeckHidraw &deck, ioctl_request_t op, uint8_t *buf, bool set) {
0x00, 0x0b, 0xa0, 0x0f, 0x00, 0x00, 0x0d, 0x00, 0x00,
0x00, 0x00, 0x0c, 0x00, 0x00, 0x00, 0x00, 0x0e};
memcpy(reply, kAttributes, sizeof(kAttributes));
+ if (deck.sc1) { // ATTRIB_PRODUCT_ID = 0x1102
+ reply[4] = 0x02;
+ reply[5] = 0x11;
+ }
break;
}
case DECK_GET_STRING_ATTRIBUTE:
reply[1] = DECK_GET_STRING_ATTRIBUTE;
reply[2] = 0x14;
reply[3] = 0x01;
- memcpy(reply + 4, DECK_SERIAL, strlen(DECK_SERIAL));
+ memcpy(reply + 4, deck.sc1 ? SC1_SERIAL : DECK_SERIAL, strlen(deck.sc1 ? SC1_SERIAL : DECK_SERIAL));
break;
case DECK_GET_CHIP_ID:
reply[1] = DECK_GET_CHIP_ID;
@@ -2464,11 +2738,11 @@ __attribute__((visibility("hidden"))) static int sony_feature(const DeckHidraw &
uint8_t reply[64] = {};
reply[0] = id;
size_t length = size;
- if ((pad.sony == 4 && id == 0x02) || (pad.sony == 5 && id == 0x05)) {
+ if ((pad.sony == 4 && id == 0x02) || ((pad.sony == 5 || pad.sony == 6) && id == 0x05)) {
put_sony_calibration(reply);
- } else if ((pad.sony == 4 && (id == 0x12 || id == 0x81)) || (pad.sony == 5 && id == 0x09)) {
+ } else if ((pad.sony == 4 && (id == 0x12 || id == 0x81)) || ((pad.sony == 5 || pad.sony == 6) && id == 0x09)) {
for (int i = 0; i < 6; i++) reply[1 + i] = SONY_MAC[5 - i];
- } else if (pad.sony == 5 && id == 0x20) {
+ } else if ((pad.sony == 5 || pad.sony == 6) && id == 0x20) {
reply[44] = 0x30;
reply[45] = 0x04;
}
@@ -2480,19 +2754,35 @@ __attribute__((visibility("hidden"))) static int sony_feature(const DeckHidraw &
__attribute__((visibility("hidden"))) static void sony_output(const DeckHidraw &pad, const uint8_t *data, size_t size) {
if (!data || size < 6) return;
- int weak, strong;
+ // sdl3bridge: sony rumble v2
+ static int last_strong = -1, last_weak = -1;
+ static unsigned logged = 0;
+ int weak = 0, strong = 0;
if (pad.sony == 4 && data[0] == 0x05) {
- if (!(data[1] & 0x01)) return;
+ if (!(data[1] & 0x01)) return; // DS4: motors untouched by this report
weak = data[4];
strong = data[5];
- } else if (pad.sony == 5 && data[0] == 0x02) {
- if (!(data[1] & 0x03)) return;
- weak = data[3];
- strong = data[4];
+ } else if ((pad.sony == 5 || pad.sony == 6) && data[0] == 0x02) {
+ // [1] enable bits1 (0x01 rumble emulation, 0x02 haptics off), [3] right/weak, [4] left/strong,
+ // [39] enable bits3 (0x04 = improved rumble, fw >= 2.24 / Edge). Full state is re-sent every
+ // report, and the bits are cleared once the motors stop: no bits = motors off.
+ bool rumble = (data[1] & 0x03) || (size > 39 && (data[39] & 0x04));
+ if (rumble) {
+ weak = data[3];
+ strong = data[4];
+ }
} else {
return;
}
- send_vibration(strong * 257, weak * 257, strong || weak ? 2000 : 0, 0);
+ if (strong == last_strong && weak == last_weak) return; // LED / repeat report: nothing changed
+ if (logged < 40) {
+ logged++;
+ Logger::log("sony: out 0x%02x bits %02x/%02x motors %d/%d\n", data[0], data[1],
+ size > 39 ? data[39] : 0, strong, weak);
+ }
+ last_strong = strong;
+ last_weak = weak;
+ send_vibration(strong * 257, weak * 257, strong || weak ? RUMBLE_HOLD_MS : 0, 0);
}
__attribute__((visibility("hidden"))) static int ioctl_sony(DeckHidraw &pad, ioctl_request_t op, void *argp) {
@@ -2518,14 +2808,14 @@ __attribute__((visibility("hidden"))) static int ioctl_sony(DeckHidraw &pad, ioc
uint32_t bustype;
int16_t vendor;
int16_t product;
- } info = {BUS_USB, static_cast(0x054c), static_cast(pad.sony == 4 ? 0x09cc : 0x0ce6)};
+ } info = {BUS_USB, static_cast(0x054c), static_cast(pad.sony == 4 ? 0x09cc : pad.sony == 6 ? 0x0df2 : 0x0ce6)};
memcpy(argp, &info, sizeof(info));
return 0;
}
case 0x04:
return copy_ioctl_string(op, argp, pad.sony == 4 ? "Sony Interactive Entertainment Wireless Controller"
- : "Sony Interactive Entertainment DualSense Wireless Controller");
- case 0x05: return copy_ioctl_string(op, argp, pad.sony == 4 ? "usb-droiddeck-4/input3" : "usb-droiddeck-5/input3");
+ : pad.sony == 6 ? "Sony Interactive Entertainment DualSense Edge Wireless Controller" : "Sony Interactive Entertainment DualSense Wireless Controller");
+ case 0x05: return copy_ioctl_string(op, argp, pad.sony == 4 ? "usb-droiddeck-4/input3" : pad.sony == 6 ? "usb-droiddeck-6/input3" : "usb-droiddeck-5/input3");
case 0x08: return copy_ioctl_string(op, argp, "5e:43:4b:44:44:02");
case 0x06:
return static_cast(_IOC_SIZE(op));
@@ -2558,13 +2848,13 @@ ioctl_deck(DeckHidraw &deck, ioctl_request_t op, void *argp) {
uint32_t bustype;
int16_t vendor;
int16_t product;
- } info = {BUS_USB, static_cast(0x28de), static_cast(0x1205)};
+ } info = {BUS_USB, static_cast(0x28de), static_cast(deck.sc1 ? 0x1102 : 0x1205)};
memcpy(argp, &info, sizeof(info));
return 0;
}
- case 0x04: return copy_ioctl_string(op, argp, DECK_NAME);
- case 0x05: return copy_ioctl_string(op, argp, "usb-droiddeck-1/input2");
- case 0x08: return copy_ioctl_string(op, argp, DECK_SERIAL);
+ case 0x04: return copy_ioctl_string(op, argp, deck.sc1 ? SC1_NAME : DECK_NAME);
+ case 0x05: return copy_ioctl_string(op, argp, deck.sc1 ? "usb-droiddeck-7/input2" : "usb-droiddeck-1/input2");
+ case 0x08: return copy_ioctl_string(op, argp, deck.sc1 ? SC1_SERIAL : DECK_SERIAL);
case 0x06: return deck_feature(deck, op, static_cast(argp), true);
case 0x07: return deck_feature(deck, op, static_cast(argp), false);
default:
@@ -2800,6 +3090,1547 @@ __attribute__((visibility("hidden"))) static int ioctl_triton(DeckHidraw &triton
}
}
+// ===================== sdl3bridge: Steam Native Hook-up =====================
+// The SDL3 bridge app passes a USB controller through raw: its real USB identity (vendor/product id,
+// name, serial, interface, HID report descriptor) and every HID report, both ways. Here each such
+// controller becomes a real-looking /dev/hidraw24..27 node, so Steam's own drivers recognise it exactly
+// as on a PC (DualSense / Edge, DualShock 4, Switch / Switch 2, Xbox, Flydigi, 8BitDo, Steam Controller,
+// ...) and use all of its features. DroidDeck's app side (HookupBridge.kt) fills the shared file named by
+// FAKE_HOOKUP_SHM; this side serves Steam's open/read/write/ioctl calls and announces hot-plug to udev.
+//
+// Game rumble: games rumble Steam's virtual pad; DroidDeck normally handles that itself. While a hooked-up
+// controller is present (ff_to_steam), the effect is handed to Steam instead -- through the virtual pad's
+// uinput force-feedback protocol -- so Steam rumbles the real controller with its own driver, like on a PC.
+static constexpr uint32_t HOOKUP_MAGIC = 0x4b4f4f48; // "HOOK"
+static constexpr int HOOKUP_SLOTS = 4;
+static constexpr int HOOKUP_IN_FIFO = 256;
+static constexpr int HOOKUP_OUT_FIFO = 64;
+static constexpr int HOOKUP_REPORT_MAX = 128;
+static constexpr int HOOKUP_DESC_MAX = 4096;
+static constexpr int HOOKUP_FF_NODES = UINPUT_EVENT_LIMIT - UINPUT_EVENT_BASE;
+static constexpr unsigned HOOKUP_MINOR_BASE = 24;
+static constexpr size_t HOOKUP_HEADER_SIZE = 4096;
+static constexpr size_t HOOKUP_SLOT_SIZE = 53248;
+static constexpr size_t HOOKUP_SIZE = HOOKUP_HEADER_SIZE + HOOKUP_SLOTS * HOOKUP_SLOT_SIZE;
+static constexpr int HOOKUP_FEATURE_TIMEOUT_MS = 1000;
+static constexpr int HOOKUP_OUTPUT_WAIT_MS = 50; // feature writes (settings): wait this long for room
+static constexpr int HOOKUP_REPORT_WAIT_MS = 4; // output reports (rumble, lights): newer ones follow, so drop
+static constexpr int HOOKUP_FUTEX_WAIT = 0; // FUTEX_WAIT, shared: the writer is the SDL3 bridge, another process
+
+struct HookupReport {
+ uint64_t seq;
+ uint32_t length;
+ uint32_t kind; // output fifo: 0 output report, 1 feature write; input fifo: SDL3Bridge's read time (us, 0 = none)
+ uint8_t data[HOOKUP_REPORT_MAX];
+};
+struct HookupSlot {
+ uint32_t present;
+ uint32_t generation;
+ uint16_t vendor, product, version, bus;
+ int32_t iface;
+ uint32_t desc_len;
+ char name[128];
+ char serial[64];
+ char manufacturer[64];
+ char product_name[64];
+ uint64_t in_write;
+ uint64_t out_write;
+ uint64_t out_read;
+ uint64_t get_request;
+ uint64_t get_reply;
+ uint32_t get_report_id; // low byte: report id; bit 8: input report (HIDIOCGINPUT) instead of feature
+ int32_t get_length;
+ uint8_t get[HOOKUP_REPORT_MAX];
+ uint64_t direct_ms; // SDL3 bridge heartbeat (CLOCK_MONOTONIC ms) while it writes this slot itself (fast path)
+ uint32_t rate_hz; // the bridge's Poll rate: hand Steam at most this many reports a second (0 = every one)
+ uint8_t reserved[1024 - 532];
+ uint8_t desc[HOOKUP_DESC_MAX];
+ HookupReport in[HOOKUP_IN_FIFO];
+ HookupReport out[HOOKUP_OUT_FIFO];
+};
+struct HookupFF {
+ uint64_t seq;
+ int32_t strong;
+ int32_t weak;
+ uint32_t length;
+ uint32_t play;
+};
+struct HookupHeader {
+ uint32_t magic;
+ uint32_t version;
+ uint32_t ff_to_steam;
+ uint32_t reserved0;
+ uint64_t steam_ms; // the Steam client's heartbeat (CLOCK_MONOTONIC ms): DroidDeck's desktop knows Steam runs
+ uint32_t reserved[10];
+ HookupFF ff[HOOKUP_FF_NODES];
+};
+static_assert(sizeof(HookupReport) == 144, "HookupReport layout");
+static_assert(offsetof(HookupSlot, name) == 24, "hookup name offset");
+static_assert(offsetof(HookupSlot, serial) == 152, "hookup serial offset");
+static_assert(offsetof(HookupSlot, manufacturer) == 216, "hookup manufacturer offset");
+static_assert(offsetof(HookupSlot, product_name) == 280, "hookup product offset");
+static_assert(offsetof(HookupSlot, in_write) == 344, "hookup in_write offset");
+static_assert(offsetof(HookupSlot, get_report_id) == 384, "hookup get_report_id offset");
+static_assert(offsetof(HookupSlot, get) == 392, "hookup get offset");
+static_assert(offsetof(HookupSlot, direct_ms) == 520, "hookup direct_ms offset");
+static_assert(offsetof(HookupSlot, rate_hz) == 528, "hookup rate_hz offset");
+static_assert(offsetof(HookupSlot, desc) == 1024, "hookup desc offset");
+static_assert(offsetof(HookupSlot, in) == 5120, "hookup in offset");
+static_assert(offsetof(HookupSlot, out) == 41984, "hookup out offset");
+static_assert(sizeof(HookupSlot) <= HOOKUP_SLOT_SIZE, "hookup slot size");
+static_assert(offsetof(HookupHeader, steam_ms) == 16, "hookup steam_ms offset");
+static_assert(offsetof(HookupHeader, ff) == 64, "hookup ff offset");
+static_assert(sizeof(HookupFF) == 24, "hookup ff entry");
+static_assert(sizeof(HookupHeader) <= HOOKUP_HEADER_SIZE, "hookup header size");
+
+__attribute__((visibility("hidden"))) static uint8_t *hookup_shm() {
+ static uint8_t *mapped = nullptr;
+ static std::once_flag once;
+ std::call_once(once, [] {
+ const char *path = getenv("FAKE_HOOKUP_SHM");
+ if (!path || !*path) return;
+ static auto my_open = reinterpret_cast(dlsym(RTLD_NEXT, "open"));
+ int fd = my_open(path, O_RDWR | O_CLOEXEC);
+ if (fd < 0) {
+ Logger::log("hookup: %s not openable (%s)\n", path, strerror(errno));
+ return;
+ }
+ struct stat st;
+ void *mapping = MAP_FAILED;
+ if (fstat(fd, &st) == 0 && static_cast(st.st_size) >= HOOKUP_SIZE)
+ mapping = mmap(nullptr, HOOKUP_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
+ syscall(SYS_close, fd);
+ if (mapping == MAP_FAILED || static_cast(mapping)->magic != HOOKUP_MAGIC) {
+ Logger::log("hookup: %s %s\n", path, mapping == MAP_FAILED ? "could not be mapped" : "has no valid header");
+ if (mapping != MAP_FAILED) munmap(mapping, HOOKUP_SIZE);
+ return;
+ }
+ mapped = static_cast(mapping);
+ });
+ return mapped;
+}
+
+__attribute__((visibility("hidden"))) static HookupHeader *hookup_header() {
+ return reinterpret_cast(hookup_shm());
+}
+
+__attribute__((visibility("hidden"))) static HookupSlot *hookup_slot(int i) {
+ uint8_t *base = hookup_shm();
+ if (!base || i < 0 || i >= HOOKUP_SLOTS) return nullptr;
+ return reinterpret_cast(base + HOOKUP_HEADER_SIZE + static_cast(i) * HOOKUP_SLOT_SIZE);
+}
+
+__attribute__((visibility("hidden"))) static bool hookup_present(int i) {
+ HookupSlot *s = hookup_slot(i);
+ return s && __atomic_load_n(&s->present, __ATOMIC_ACQUIRE) != 0;
+}
+
+/** /dev/hidraw24..27 -> slot 0..3 when that slot holds a controller, else -1. */
+__attribute__((visibility("hidden"))) static int hookup_path_slot(const char *pathname) {
+ if (!pathname || !fake_deck_enabled() || strncmp(pathname, "/dev/hidraw2", 12)) return -1;
+ for (int i = 0; i < HOOKUP_SLOTS; i++) {
+ char path[32];
+ snprintf(path, sizeof(path), "/dev/hidraw%u", HOOKUP_MINOR_BASE + i);
+ if (!strcmp(pathname, path)) return hookup_present(i) ? i : -1;
+ }
+ return -1;
+}
+
+/** Must match HookupBridge.kt's sysfs layout. */
+__attribute__((visibility("hidden"))) static void hookup_devpath(int i, const HookupSlot &s, char *out, size_t size) {
+ int iface = s.iface < 0 ? 0 : s.iface;
+ snprintf(out, size, "/devices/droiddeck/usb%d/%d-1:1.%d/0003:%04X:%04X.%04X/hidraw/hidraw%u", 10 + i, 10 + i, iface,
+ s.vendor, s.product, 0x10 + i, HOOKUP_MINOR_BASE + i);
+}
+
+// ---- latency meter (logged to pad.log every 10 s while it has samples) ----
+// Where a button press of a hooked-up controller spends its time, in three steps:
+// 1. waiting in Steam's queue: from the report being handed to Steam until Steam reads it,
+// 2. Steam's own processing: from Steam reading the press until Steam writes the mapped button on its virtual pad,
+// 3. the game: from Steam's virtual pad until the game reads it.
+// Mouse / keyboard output from Steam does not go through the virtual pad, so it is in step 1 only.
+struct HookupLatency {
+ std::mutex lock;
+ uint32_t n = 0, missed = 0;
+ uint64_t sum_us = 0, max_us = 0, since_us = 0;
+ void add(const char *what, uint64_t us, uint64_t now_us, bool hit = true) {
+ std::lock_guard guard(lock);
+ if (!since_us) since_us = now_us;
+ if (hit) {
+ n++;
+ sum_us += us;
+ if (us > max_us) max_us = us;
+ } else {
+ missed++;
+ }
+ if (now_us - since_us < 10000000ULL) return;
+ if (n)
+ Logger::log("latency: %s: %u presses, avg %.1f ms, max %.1f ms%s\n", what, n, sum_us / 1000.0 / n, max_us / 1000.0,
+ missed ? " (plus presses that never reached the pad: mapped to mouse / keyboard or nothing)" : "");
+ n = missed = 0;
+ sum_us = max_us = 0;
+ since_us = now_us;
+ }
+};
+
+static std::atomic g_hookup_press_us{0}; // Steam process: a press Steam has read, not yet on its pad
+
+/**
+ * Steam's controller work runs at a higher priority, like Android's own input threads (nice -8): the threads that
+ * read controllers (hooked-up ones and DroidDeck's own pads -- every controller), the one that writes Steam's
+ * virtual pad, and ours that hand reports over. On a phone the game and the emulation keep the CPU busy, and
+ * Steam's controller loop otherwise waits its turn behind them -- that is when it reads late. Once per thread; if
+ * Android doesn't allow it, nothing changes (logged once).
+ */
+__attribute__((visibility("hidden"))) static void hookup_boost_this_thread(const char *what) {
+ static thread_local bool done;
+ if (done) return;
+ done = true;
+ if (!process_is_steam_client()) return;
+ pid_t tid = static_cast(syscall(SYS_gettid));
+ errno = 0;
+ int was = getpriority(PRIO_PROCESS, static_cast(tid));
+ if (errno == 0 && was <= -8) return;
+ if (setpriority(PRIO_PROCESS, static_cast(tid), -8) == 0) {
+ Logger::log("thread %d (%s) now runs at higher priority (nice %d -> -8)\n", tid, what, was);
+ } else {
+ static std::atomic told{false};
+ if (!told.exchange(true))
+ Logger::log("could not raise the priority of thread %d (%s): %s\n", tid, what, strerror(errno));
+ }
+}
+static std::atomic g_hookup_samples{0};
+static std::atomic g_vpad_passed{0}; // frames of Steam's virtual pad handed to the game (pacing below)
+static uint64_t virtual_pad_interval_us();
+
+/** Steam process, as Steam writes its virtual pad: the end of step 2. */
+__attribute__((visibility("hidden"))) static void hookup_meter_virtual_pad(const std::vector &events) {
+ hookup_boost_this_thread("writes Steam's virtual controller");
+ { // how often Steam updates its virtual pad: every update is passed on to the game, one by one
+ static std::atomic updates{0}, logged{0};
+ static std::atomic since{0};
+ uint64_t t = monotonic_us(), was = since.load(std::memory_order_relaxed);
+ if (!was) since.compare_exchange_strong(was, t);
+ if (!events.empty()) updates.fetch_add(1, std::memory_order_relaxed);
+ if (was && t - was >= 10000000ULL && since.compare_exchange_strong(was, t)) {
+ uint32_t n = updates.exchange(0), passed = g_vpad_passed.exchange(0);
+ double secs = (t - was) / 1e6;
+ if (n && (logged.fetch_add(1) < 3 || n > 4000))
+ Logger::log("latency: Steam updates its virtual pad %.0f times a second; the game gets %.0f%s\n", n / secs,
+ passed / secs, virtual_pad_interval_us() ? " (stick / trigger moves paced, buttons at once)" : "");
+ }
+ }
+ uint64_t press = g_hookup_press_us.load(std::memory_order_acquire);
+ if (!press) return;
+ static HookupLatency stat;
+ uint64_t now = monotonic_us();
+ if (now - press > 250000ULL) { // no gamepad button followed: mapped to mouse / keyboard, or nothing
+ if (g_hookup_press_us.compare_exchange_strong(press, 0)) stat.add("button press -> Steam's virtual pad", 0, now, false);
+ return;
+ }
+ for (const struct input_event &ev : events) {
+ if (ev.type != EV_KEY || ev.value != 1) continue;
+ if (!g_hookup_press_us.compare_exchange_strong(press, 0)) return;
+ stat.add("Steam read a button press -> on Steam's virtual pad (Steam's own processing)", now - press, now);
+ int sample = g_hookup_samples.fetch_add(1, std::memory_order_relaxed);
+ if (sample < 12) Logger::log("latency: press %d: on Steam's virtual pad %.1f ms after Steam read it\n", sample + 1, (now - press) / 1000.0);
+ return;
+ }
+}
+
+// ---- Steam's virtual pad, paced for the game (every controller, every mode) ----
+// Steam writes its virtual pad each time a controller report changes its state: with a controller that sends 1000
+// reports a second (a DualSense over USB in Steam Native Hook-up) about that often. A Windows game gets each update
+// passed along one by one inside Wine, and under x86 emulation that falls behind: the game lags -- unplayable at
+// 1000 Hz, fine at 250 Hz, the rate DroidDeck's own pads send. So stick / trigger movement reaches the game at most
+// 250 times a second, merged to the newest values; buttons are never held: a frame with a button change goes out
+// at once (after the newest axis values). Steam itself still reads the controller at full rate, so gyro-to-mouse
+// and mouse / keyboard bindings (not through the virtual pad) keep it. FAKE_VIRTUAL_PAD_HZ sets another rate
+// (0 = every update, as before).
+__attribute__((visibility("hidden"))) static uint64_t virtual_pad_interval_us() {
+ static const uint64_t interval = [] {
+ const char *v = getenv("FAKE_VIRTUAL_PAD_HZ");
+ long hz = v && *v ? strtol(v, nullptr, 10) : 250;
+ return hz > 0 && hz < 1000000 ? static_cast(1000000 / hz) : 0;
+ }();
+ return interval;
+}
+
+/** Only the newest value of each axis, then one SYN_REPORT. */
+__attribute__((visibility("hidden"))) static void virtual_pad_merge(std::vector &events) {
+ std::vector merged;
+ merged.reserve(events.size());
+ struct input_event syn = {};
+ bool has_syn = false;
+ for (const struct input_event &ev : events) {
+ if (ev.type == EV_SYN) {
+ if (ev.code == SYN_REPORT) {
+ syn = ev;
+ has_syn = true;
+ }
+ continue;
+ }
+ bool replaced = false;
+ for (struct input_event &m : merged)
+ if (m.type == ev.type && m.code == ev.code) {
+ m = ev;
+ replaced = true;
+ break;
+ }
+ if (!replaced) merged.push_back(ev);
+ }
+ if (has_syn) merged.push_back(syn);
+ events.swap(merged);
+}
+
+// Never destroyed (allocated once): at exit the pacer thread is still waiting on them, and destroying a condition
+// variable with a waiter blocks forever -- the process would hang on quit.
+static std::mutex &g_vpad_lock = *new std::mutex();
+static std::condition_variable &g_vpad_cv = *new std::condition_variable();
+static uint64_t g_vpad_due = 0;
+static pid_t g_vpad_thread_pid = 0;
+
+__attribute__((visibility("hidden"))) static void *virtual_pad_pacer(void *);
+
+/** Frames are held: hand them to the game at `due` (the pacer thread), unless Steam's next frame comes first. */
+__attribute__((visibility("hidden"))) static void virtual_pad_wake(uint64_t due) {
+ std::lock_guard guard(g_vpad_lock);
+ if (!g_vpad_due || due < g_vpad_due) g_vpad_due = due;
+ if (g_vpad_thread_pid != getpid()) { // once per process (not inherited by a fork)
+ pthread_t thread;
+ if (pthread_create(&thread, nullptr, virtual_pad_pacer, nullptr) == 0) {
+ pthread_detach(thread);
+ g_vpad_thread_pid = getpid();
+ }
+ }
+ g_vpad_cv.notify_one();
+}
+
+__attribute__((visibility("hidden"))) static void *virtual_pad_pacer(void *) {
+ hookup_boost_this_thread("passes Steam's virtual controller on to games");
+ std::unique_lock lock(g_vpad_lock);
+ for (;;) {
+ if (!g_vpad_due) {
+ g_vpad_cv.wait(lock);
+ continue;
+ }
+ uint64_t now = monotonic_us();
+ if (now < g_vpad_due) {
+ g_vpad_cv.wait_for(lock, std::chrono::microseconds(g_vpad_due - now));
+ continue;
+ }
+ g_vpad_due = 0;
+ lock.unlock();
+ {
+ std::lock_guard guard(controller_mutex());
+ uint64_t interval = virtual_pad_interval_us();
+ for (auto &entry : uinput_map()) {
+ UinputWriter &w = *entry.second;
+ if (!w.held) continue;
+ if (monotonic_us() - w.paced_us + 200 >= interval) { // due (within 0.2 ms)
+ w.flush_now = true;
+ flush_uinput(w);
+ w.flush_now = false;
+ } else {
+ virtual_pad_wake(w.paced_us + interval);
+ }
+ }
+ }
+ lock.lock();
+ }
+ return nullptr;
+}
+
+/** flush_uinput: true = hold these events for now (they stay in w.pending, merged); false = write them. */
+__attribute__((visibility("hidden"))) static bool virtual_pad_hold(UinputWriter &w) {
+ uint64_t interval = virtual_pad_interval_us();
+ if (!interval || w.pending.empty()) {
+ w.held = false;
+ return false;
+ }
+ const struct input_event &last = w.pending.back();
+ if (last.type != EV_SYN || last.code != SYN_REPORT) return false; // a partial frame (64 events): as before
+ bool only_axes = true;
+ for (const struct input_event &ev : w.pending)
+ if (ev.type != EV_ABS && ev.type != EV_SYN) {
+ only_axes = false;
+ break;
+ }
+ uint64_t now = monotonic_us();
+ if (!only_axes || w.flush_now || now - w.paced_us >= interval) {
+ if (only_axes) virtual_pad_merge(w.pending); // held frames and this one: one frame, the newest values
+ w.paced_us = now;
+ w.held = false;
+ g_vpad_passed.fetch_add(1, std::memory_order_relaxed);
+ return false;
+ }
+ virtual_pad_merge(w.pending);
+ w.held = true;
+ virtual_pad_wake(w.paced_us + interval);
+ return true;
+}
+
+/** Game process, as it reads Steam's virtual pad: step 3 (stamped when Steam wrote it). */
+__attribute__((visibility("hidden"))) static void hookup_meter_game_read(const struct input_event *events, size_t count) {
+ if (process_is_steam_client()) return;
+ for (size_t i = 0; i < count; i++) {
+ if (events[i].type != EV_KEY || events[i].value != 1) continue;
+ struct timeval now = {};
+ gettimeofday(&now, nullptr);
+ int64_t us = (static_cast(now.tv_sec) - events[i].time.tv_sec) * 1000000LL + (now.tv_usec - events[i].time.tv_usec);
+ if (us < 0 || us > 1000000LL) continue;
+ static HookupLatency stat;
+ stat.add("Steam's virtual pad -> game read", static_cast(us), monotonic_us());
+ }
+}
+
+/** Steam's output reports / feature writes to a hooked-up controller, logged every 10 s when Steam had to wait. */
+struct HookupOutputStat {
+ uint32_t reports = 0, features = 0, dropped = 0;
+ uint64_t waited_us = 0, since_us = 0;
+};
+
+/**
+ * Buttons and hat switches the controller's own HID report descriptor declares for input report `id` (0 = the
+ * controller uses no report IDs), as a bit mask over the report bytes (byte 0 = the report ID when IDs are used).
+ * Controllers with a standard descriptor get their buttons protected exactly; vendor-defined reports (Sony's extra
+ * area, Switch, Steam Controller, Xbox) declare none, and rely on what is learned from the reports.
+ */
+__attribute__((visibility("hidden"))) static void hookup_descriptor_buttons(const HookupSlot &s, uint8_t id, uint8_t *mask,
+ uint32_t length) {
+ const uint8_t *d = s.desc;
+ uint32_t n = std::min(s.desc_len, HOOKUP_DESC_MAX);
+ struct Globals { uint32_t page = 0, size = 0, count = 0, id = 0; } g, stack[4];
+ int depth = 0;
+ uint32_t usages[64];
+ int nusages = 0;
+ uint32_t umin = 0, umax = 0;
+ bool range = false, ids = false;
+ uint32_t bitpos[256] = {};
+ for (uint32_t pos = 0; pos < n;) {
+ uint8_t prefix = d[pos++];
+ if (prefix == 0xfe) { // long item
+ if (pos + 1 >= n) break;
+ pos += 2 + d[pos];
+ continue;
+ }
+ uint32_t size = prefix & 3;
+ if (size == 3) size = 4;
+ if (pos + size > n) break;
+ uint32_t v = 0;
+ for (uint32_t k = 0; k < size; k++) v |= static_cast(d[pos + k]) << (8 * k);
+ pos += size;
+ uint32_t type = (prefix >> 2) & 3, tag = prefix >> 4;
+ if (type == 1) { // global
+ if (tag == 0) g.page = v;
+ else if (tag == 7) g.size = v;
+ else if (tag == 8) { g.id = v & 0xff; ids = true; }
+ else if (tag == 9) g.count = v;
+ else if (tag == 10 && depth < 4) stack[depth++] = g;
+ else if (tag == 11 && depth > 0) g = stack[--depth];
+ } else if (type == 2) { // local
+ if (tag == 0 && nusages < 64) usages[nusages++] = size == 4 ? v : (g.page << 16) | v;
+ else if (tag == 1) { umin = size == 4 ? v : (g.page << 16) | v; range = true; }
+ else if (tag == 2) umax = size == 4 ? v : (g.page << 16) | v;
+ } else if (type == 0) { // main
+ if (tag == 8) { // Input
+ uint32_t start = bitpos[g.id];
+ uint32_t bits = g.size * g.count;
+ bitpos[g.id] += bits;
+ if (!(v & 1) && g.id == id && g.size && g.size <= 32) {
+ for (uint32_t j = 0; j < g.count; j++) {
+ uint32_t usage = j < static_cast(nusages) ? usages[j]
+ : range ? std::min(umin + j, umax ? umax : umin + j)
+ : nusages ? usages[nusages - 1] : (g.page << 16);
+ uint32_t page = usage >> 16, u = usage & 0xffff;
+ if (page != 0x09 && !(page == 0x01 && u == 0x39)) continue;
+ for (uint32_t b = start + j * g.size; b < start + (j + 1) * g.size; b++) {
+ uint32_t byte = (ids ? 1 : 0) + b / 8;
+ if (byte < length) mask[byte] |= 1u << (b % 8);
+ }
+ }
+ }
+ }
+ nusages = 0;
+ range = false;
+ umin = umax = 0;
+ }
+ }
+ (void)ids;
+}
+
+/**
+ * Which bits of a controller's reports are "moving", learned from the reports themselves for ANY controller (no
+ * report layout needed): bytes that change in more than a quarter of the last 256 reports of that kind (sticks in
+ * use, gyro, accelerometer, touch position, noise) and single bits that do; and counters / timestamps (a byte that
+ * goes up in nearly every report, and the bytes above it that only change when it wraps around). A button is never
+ * pressed that fast. Buttons the HID descriptor declares are never "moving". Until the first 256 reports are seen
+ * (and for kinds that don't stream) nothing counts as moving.
+ */
+struct HookupReportKind {
+ uint32_t length = 0;
+ uint8_t id = 0; // first byte (the report ID on controllers that use them)
+ bool learned = false;
+ uint32_t count = 0;
+ uint8_t prev[HOOKUP_REPORT_MAX] = {};
+ bool has_prev = false;
+ uint16_t changes[HOOKUP_REPORT_MAX * 8] = {};
+ uint16_t byte_changes[HOOKUP_REPORT_MAX] = {};
+ uint16_t ups[HOOKUP_REPORT_MAX] = {}; // byte went up by 1..127 (mod 256)
+ uint16_t carried[HOOKUP_REPORT_MAX] = {}; // byte changed while the byte below it wrapped around
+ uint16_t uncarried[HOOKUP_REPORT_MAX] = {}; // byte changed while the byte below it did not wrap
+ uint8_t moving[HOOKUP_REPORT_MAX] = {}; // 1 bits = moving (or part of a counter)
+ bool analog[HOOKUP_REPORT_MAX] = {}; // the whole byte is moving, and it is not a counter
+ uint8_t buttons[HOOKUP_REPORT_MAX] = {}; // declared buttons / hat switches (HID descriptor)
+ uint8_t noisy[HOOKUP_REPORT_MAX] = {}; // bits that change in over a quarter of reports, declared or not
+ uint8_t given[HOOKUP_REPORT_MAX] = {}; // the last report of this kind Steam got
+ bool has_given = false;
+ uint64_t window_us = 0;
+ void learn(const uint8_t *d, uint64_t now_us) {
+ if (!window_us) window_us = now_us;
+ if (has_prev) {
+ for (uint32_t i = 0; i < length; i++) {
+ uint8_t x = d[i] ^ prev[i];
+ if (!x) continue;
+ byte_changes[i]++;
+ for (uint32_t b = x; b; b &= b - 1) changes[i * 8 + __builtin_ctz(b)]++;
+ if (static_cast(d[i] - prev[i]) > 0) ups[i]++;
+ if (i > 0) {
+ if (d[i - 1] < prev[i - 1]) carried[i]++; else uncarried[i]++;
+ }
+ }
+ }
+ memcpy(prev, d, length);
+ has_prev = true;
+ if (++count < 256) return;
+ bool counter[HOOKUP_REPORT_MAX] = {};
+ for (uint32_t i = 0; i < length; i++) {
+ if (ups[i] >= 200) counter[i] = true; // goes up in ~80 % of reports
+ else if (i > 0 && counter[i - 1] && carried[i] >= 2 && !uncarried[i]) counter[i] = true; // its upper part
+ analog[i] = !counter[i] && byte_changes[i] > 64 && !buttons[i];
+ uint8_t m = counter[i] || byte_changes[i] > 64 ? 0xff : 0;
+ for (int b = 0; b < 8; b++)
+ if (changes[i * 8 + b] > 64) m |= 1u << b;
+ moving[i] = m & ~buttons[i];
+ noisy[i] = m;
+ }
+ memset(changes, 0, sizeof(changes));
+ memset(byte_changes, 0, sizeof(byte_changes));
+ memset(ups, 0, sizeof(ups));
+ memset(carried, 0, sizeof(carried));
+ memset(uncarried, 0, sizeof(uncarried));
+ count = 0;
+ // Only a kind that streams (256 reports within 5 s) is ever replaced: rarer kinds -- replies to Steam's
+ // commands, status reports -- always reach Steam one by one.
+ learned = now_us - window_us < 5000000ULL;
+ window_us = now_us;
+ }
+ /**
+ * Would replacing `older` by `newer` lose a change Steam has not seen -- a button-like bit that changed (compared
+ * with `base`, what Steam has or will get) and changed back? That is a short press-and-release (or the reverse)
+ * that must reach Steam. Not counted: moving bits (their next value carries them), and the carry of an analog
+ * value crossing a boundary: a bit that changed together with lower bits of its byte, or a byte that moved by
+ * 1..3 right above an analog byte (the upper half of a 16-bit value). A declared button always counts.
+ */
+ bool loses_change(const uint8_t *older, const uint8_t *newer, const uint8_t *base) const {
+ for (uint32_t i = 0; i < length; i++) {
+ uint8_t changed = older[i] ^ base[i];
+ uint8_t reverted = changed & ~(newer[i] ^ base[i]) & ~noisy[i]; // noisy: moving bits, and declared ones that act so
+ for (uint32_t r = reverted; r; r &= r - 1) {
+ int b = __builtin_ctz(r);
+ if (buttons[i] & (1u << b)) return true;
+ if (changed & ((1u << b) - 1)) continue;
+ int delta = static_cast(older[i] - base[i]);
+ if (i > 0 && analog[i - 1] && delta >= -3 && delta <= 3) continue;
+ return true;
+ }
+ }
+ return false;
+ }
+ /**
+ * For the latency meter, any controller: a button press since `ref` -- a declared button (HID descriptor) that
+ * turned on, or, for controllers that declare none, a learned non-moving bit that turned on (not the carry of an
+ * analog value crossing a boundary, as in loses_change). Bits that change in over a quarter of reports are never
+ * a press, even when the descriptor calls them buttons (some controllers, e.g. a DualSense Edge, declare bits
+ * that change constantly): nobody presses a button that fast, and counting them buried the real presses.
+ */
+ bool press(const uint8_t *d, const uint8_t *ref) const {
+ bool declared = false;
+ for (uint32_t i = 0; i < length; i++) {
+ if (!buttons[i]) continue;
+ declared = true;
+ if (d[i] & ~ref[i] & buttons[i] & ~noisy[i]) return true;
+ }
+ if (declared || !learned) return false;
+ for (uint32_t i = 0; i < length; i++) {
+ uint8_t changed = d[i] ^ ref[i];
+ uint8_t rises = d[i] & changed & ~moving[i];
+ if (!rises) continue;
+ int delta = static_cast(d[i] - ref[i]);
+ if (i > 0 && analog[i - 1] && delta >= -3 && delta <= 3) continue;
+ for (uint32_t r = rises; r; r &= r - 1)
+ if (!(changed & ((1u << __builtin_ctz(r)) - 1))) return true;
+ }
+ return false;
+ }
+};
+
+/**
+ * Controller reports -> Steam, for every controller: the hooked-up ones (/dev/hidraw24-27) and DroidDeck's own pads
+ * (Steam Deck, DualSense / Edge, DualShock 4, Steam Controller 2015 / 2026). A mailbox read at the moment Steam
+ * reads: the report thread puts reports here, Steam's read() of the node is answered from here (hookup_read_steam),
+ * and the node's socket only carries a 1-byte "something to read" token, so poll() works as on a real node.
+ *
+ * How the reports are handed over follows how Steam reads, learned while it runs:
+ * - Steam takes everything waiting each time (like SDL's update loop): it gets every report, in order, the newest
+ * last -- the newest state is in that same read, and gyro / touch keep all their samples.
+ * - Steam takes one report and goes away (busy): it gets the newest one. Older ones are skipped, unless skipping
+ * one would lose a change: a button-like bit that changed and changed back before Steam read it, like a press
+ * shorter than Steam's read cycle (HookupReportKind::loses_change). Those reach Steam, in order.
+ * At most 8 reports wait; beyond that the oldest skippable one goes. Reports of kinds that don't stream (replies to
+ * Steam's commands, status) are never skipped.
+ * (Before, reports went into a small socket queue: when Steam read slowly it filled up, the NEWEST reports were the
+ * ones dropped and Steam read old ones -- a press could reach it one or more of its read cycles late.)
+ */
+struct HookupQueue {
+ struct Entry {
+ int kind; // index in kinds, -1 = beyond the 4 kinds tracked (never skipped)
+ uint32_t length;
+ uint64_t arrived_us;
+ uint8_t data[HOOKUP_REPORT_MAX];
+ };
+ static constexpr int MAX = 8;
+ std::mutex lock;
+ Entry entries[MAX + 1];
+ int n = 0;
+ HookupReportKind kinds[4];
+ int nkinds = 0;
+ bool token = false; // the "something to read" byte is in Steam's socket
+ bool closed = false; // the report thread ended (controller gone)
+ bool drains = false; // learned: Steam takes everything waiting each time
+ uint64_t last_read_us = 0; // Steam's last read that got a report
+ // Paced (Hook-up, the bridge's Poll rate): Steam is told "something to read" at most once per interval and then
+ // gets the newest report (short presses still reach it in order) -- like DroidDeck's own pads, which make
+ // reports at their own rate.
+ uint32_t interval_us = 0;
+ uint64_t last_token_us = 0;
+ uint64_t peer_check_us = 0;
+ uint32_t mode_logs = 0;
+ char name[64] = {};
+ // 10 s summary
+ uint64_t meter_us = 0;
+ uint32_t arrived = 0, handed = 0, skipped = 0, lost = 0;
+ uint64_t handed_ever = 0; // a node Steam keeps open only to send it settings is never read
+ bool told_unread = false;
+ uint64_t wait_sum_us = 0, wait_max_us = 0;
+};
+
+/** Reads this close together are one read cycle of Steam's. */
+static constexpr uint64_t HOOKUP_CYCLE_GAP_US = 1000;
+
+/** What Steam has, or will have, before entry i of its kind: the previous one waiting, or the last one it got. */
+__attribute__((visibility("hidden"))) static const uint8_t *hookup_base(const HookupQueue &q, int i) {
+ int kind = q.entries[i].kind;
+ for (int j = i - 1; j >= 0; j--)
+ if (q.entries[j].kind == kind) return q.entries[j].data;
+ return q.kinds[kind].has_given ? q.kinds[kind].given : nullptr;
+}
+
+/** Entry i can be skipped: a newer report of its kind waits behind it, and skipping i loses no change. */
+__attribute__((visibility("hidden"))) static bool hookup_skippable(const HookupQueue &q, int i) {
+ const HookupQueue::Entry &e = q.entries[i];
+ if (e.kind < 0 || !q.kinds[e.kind].learned) return false;
+ for (int j = i + 1; j < q.n; j++) {
+ if (q.entries[j].kind != e.kind) continue;
+ const uint8_t *base = hookup_base(q, i);
+ return !base || !q.kinds[e.kind].loses_change(e.data, q.entries[j].data, base);
+ }
+ return false;
+}
+
+__attribute__((visibility("hidden"))) static void hookup_remove(HookupQueue &q, int i) {
+ for (int j = i; j < q.n - 1; j++) q.entries[j] = q.entries[j + 1];
+ q.n--;
+}
+
+__attribute__((visibility("hidden"))) static void hookup_set_mode(HookupQueue &q, bool drains) {
+ q.drains = drains;
+ if (q.mode_logs++ < 4)
+ Logger::log("%s: %s\n", q.name,
+ drains ? "Steam takes every report waiting each time: it gets them all, in order, the newest last"
+ : "Steam reads one report at a time: it gets the newest (older ones are skipped unless that "
+ "would lose a press)");
+}
+
+/**
+ * A new report for Steam, from a report thread. `slot` = the hooked-up controller (its HID report descriptor marks
+ * the buttons), or null for DroidDeck's own pads. Returns -1 once Steam has closed the node (the thread ends).
+ */
+__attribute__((visibility("hidden"))) static int hookup_offer(HookupQueue &q, int peer, const HookupSlot *slot,
+ const uint8_t *data, uint32_t length) {
+ if (length > HOOKUP_REPORT_MAX) length = HOOKUP_REPORT_MAX;
+ if (!length) return 0;
+ uint64_t now = monotonic_us();
+ std::lock_guard guard(q.lock);
+ q.arrived++;
+ int kind = -1;
+ for (int i = 0; i < q.nkinds && kind < 0; i++)
+ if (q.kinds[i].length == length && q.kinds[i].id == data[0]) kind = i;
+ if (kind < 0 && q.nkinds < 4) {
+ HookupReportKind &k = q.kinds[q.nkinds];
+ k.length = length;
+ k.id = data[0];
+ if (slot) {
+ hookup_descriptor_buttons(*slot, data[0], k.buttons, length); // report IDs: byte 0 is the ID
+ uint8_t no_ids[HOOKUP_REPORT_MAX] = {};
+ hookup_descriptor_buttons(*slot, 0, no_ids, length); // no IDs: the report starts at once
+ for (uint32_t i = 0; i < length; i++) k.buttons[i] |= no_ids[i];
+ }
+ kind = q.nkinds++;
+ }
+ if (kind >= 0) q.kinds[kind].learn(data, now);
+ HookupQueue::Entry &e = q.entries[q.n++];
+ e.kind = kind;
+ e.length = length;
+ e.arrived_us = now;
+ memcpy(e.data, data, length);
+ if (q.n > HookupQueue::MAX) { // Steam hasn't read for a while: make room, the oldest skippable report first
+ int drop = -1;
+ for (int i = 0; i < q.n - 1 && drop < 0; i++)
+ if (hookup_skippable(q, i)) drop = i;
+ if (drop >= 0) {
+ q.skipped++;
+ } else {
+ drop = 0;
+ if (q.entries[0].kind >= 0 && q.kinds[q.entries[0].kind].learned) q.lost++;
+ }
+ hookup_remove(q, drop);
+ }
+ if (!q.token && (!q.interval_us || now - q.last_token_us >= q.interval_us)) { // tell Steam's poll() there is something
+ uint8_t token = 1;
+ if (send(peer, &token, 1, MSG_DONTWAIT | MSG_NOSIGNAL) == 1) {
+ q.token = true;
+ q.last_token_us = now;
+ } else if (errno != EAGAIN && errno != EWOULDBLOCK) {
+ return -1; // Steam closed the node
+ }
+ } else if (q.token && now - q.peer_check_us > 100000) { // the token is waiting: did Steam close the node meanwhile?
+ q.peer_check_us = now;
+ struct pollfd p = {peer, 0, 0};
+ struct timespec zero = {0, 0};
+ if (syscall(SYS_ppoll, &p, 1, &zero, nullptr, 0) > 0 && (p.revents & (POLLHUP | POLLERR))) return -1;
+ }
+ // 10 s summary, logged when Steam was slow to read (skipping reports, or reading them late)
+ if (!q.meter_us) q.meter_us = now;
+ if (now - q.meter_us >= 10000000ULL) {
+ double secs = (now - q.meter_us) / 1e6;
+ if (!q.handed_ever) { // opened only to send it settings (Steam reads another copy): not slow reading
+ if (!q.told_unread)
+ Logger::log("%s: Steam keeps this copy of the node open without reading it (it only sends it settings)\n", q.name);
+ q.told_unread = true;
+ } else if ((q.skipped && !q.interval_us) || q.lost ||
+ (q.handed && q.wait_sum_us / q.handed > 2000 + q.interval_us))
+ Logger::log("%s: %.0f reports/s, Steam read %.0f/s (%s), avg %.1f ms after they arrived (max %.1f ms); %u older "
+ "ones skipped%s%s\n",
+ q.name, q.arrived / secs, q.handed / secs,
+ q.interval_us ? "paced: the newest" : q.drains ? "all waiting each time" : "the newest each time",
+ q.handed ? q.wait_sum_us / 1000.0 / q.handed : 0.0, q.wait_max_us / 1000.0, q.skipped,
+ q.interval_us ? "" : " while Steam was busy",
+ q.lost ? " -- Steam stopped reading for a while, some short presses were dropped" : "");
+ q.arrived = q.handed = q.skipped = q.lost = 0;
+ q.wait_sum_us = q.wait_max_us = 0;
+ q.meter_us = now;
+ }
+ return 0;
+}
+
+/**
+ * Paced mailbox: the "something to read" token held back by hookup_offer (interval not over yet) goes out once the
+ * interval is over. Returns how many microseconds until then (0 = nothing waiting). -1 = Steam closed the node.
+ */
+__attribute__((visibility("hidden"))) static int64_t hookup_token_due(HookupQueue &q, int peer, uint64_t now) {
+ std::lock_guard guard(q.lock);
+ if (q.token || !q.n || !q.interval_us) return 0;
+ uint64_t since = now - q.last_token_us;
+ if (since < q.interval_us) return static_cast(q.interval_us - since);
+ uint8_t token = 1;
+ if (send(peer, &token, 1, MSG_DONTWAIT | MSG_NOSIGNAL) == 1) {
+ q.token = true;
+ q.last_token_us = now;
+ } else if (errno != EAGAIN && errno != EWOULDBLOCK) {
+ return -1;
+ }
+ return 0;
+}
+
+/** Steam process, as Steam reads a report from the mailbox: step 1 of the latency meter, and the start of step 2. */
+__attribute__((visibility("hidden"))) static void hookup_meter_handed(HookupQueue &q, const HookupQueue::Entry &e,
+ uint64_t now) {
+ uint64_t wait = now - e.arrived_us;
+ q.handed++;
+ q.handed_ever++;
+ q.wait_sum_us += wait;
+ if (wait > q.wait_max_us) q.wait_max_us = wait;
+ HookupReportKind *k = e.kind >= 0 ? &q.kinds[e.kind] : nullptr;
+ if (!k || !k->has_given || !k->press(e.data, k->given)) return;
+ static HookupLatency stat;
+ stat.add("button press waited for Steam to read it", wait, now);
+ g_hookup_press_us.store(now, std::memory_order_release);
+ static std::atomic samples{0};
+ int sample = samples.fetch_add(1, std::memory_order_relaxed);
+ if (sample < 12) Logger::log("latency: press %d: Steam read it %.1f ms after it arrived\n", sample + 1, wait / 1000.0);
+}
+
+/** Steam's read() of a controller node with a mailbox: the next report (see HookupQueue). */
+__attribute__((visibility("hidden"))) static ssize_t hookup_read_steam(int fd, HookupQueue &q, void *buf, size_t count) {
+ hookup_boost_this_thread("reads a controller");
+ for (;;) {
+ {
+ std::lock_guard guard(q.lock);
+ uint64_t now = monotonic_us();
+ bool new_cycle = !q.last_read_us || now - q.last_read_us >= HOOKUP_CYCLE_GAP_US;
+ if (q.n) {
+ // back after a while, and a report it left waiting last time is still here: it reads one at a time
+ if (new_cycle && q.drains && q.entries[0].arrived_us < q.last_read_us) hookup_set_mode(q, false);
+ if (!q.drains || q.interval_us) // the newest, minus what can't be skipped (always when paced)
+ for (int i = 0; i < q.n - 1;) {
+ if (hookup_skippable(q, i)) {
+ hookup_remove(q, i);
+ q.skipped++;
+ } else {
+ i++;
+ }
+ }
+ HookupQueue::Entry e = q.entries[0];
+ hookup_remove(q, 0);
+ if (!q.n && q.token) { // nothing left: take the token back so poll() reports "nothing to read"
+ uint8_t token;
+ if (recv(fd, &token, 1, MSG_DONTWAIT) < 0) {}
+ q.token = false;
+ }
+ size_t copied = std::min(count, e.length);
+ memcpy(buf, e.data, copied);
+ hookup_meter_handed(q, e, now);
+ if (e.kind >= 0) {
+ memcpy(q.kinds[e.kind].given, e.data, e.length);
+ q.kinds[e.kind].has_given = true;
+ }
+ q.last_read_us = now;
+ return static_cast(copied);
+ }
+ if (!new_cycle && !q.drains) hookup_set_mode(q, true); // came straight back for more: it takes all waiting
+ if (q.closed) return 0; // the controller is gone: end of file, as on a real node
+ uint8_t peek;
+ if (recv(fd, &peek, 1, MSG_PEEK | MSG_DONTWAIT) == 0) return 0; // the report thread ended: end of file
+ }
+ int flags = fcntl(fd, F_GETFL);
+ if (flags >= 0 && (flags & O_NONBLOCK)) {
+ errno = EAGAIN;
+ return -1;
+ }
+ struct pollfd p = {fd, POLLIN, 0};
+ if (syscall(SYS_ppoll, &p, 1, nullptr, nullptr, 0) < 0 && errno != EINTR) return -1; // until there is something
+ if (p.revents & (POLLHUP | POLLERR)) {
+ std::lock_guard guard(q.lock);
+ if (!q.n) return 0;
+ }
+ }
+}
+
+/** readv() of a node with a mailbox: one report, as read() gives it, spread over the buffers. -2 = not one. */
+__attribute__((visibility("hidden"))) static ssize_t hookup_readv_mailbox(int fd, const struct iovec *iov, int iovcnt) {
+ if (!watched(fd)) return -2;
+ std::shared_ptr q;
+ {
+ std::lock_guard guard(controller_mutex());
+ auto deck = deck_map().find(fd);
+ if (deck == deck_map().end() || !deck->second->hookup_in) return -2;
+ q = deck->second->hookup_in;
+ }
+ if (iovcnt < 0 || iovcnt > IOV_MAX || (iovcnt && !iov)) {
+ errno = EINVAL;
+ return -1;
+ }
+ size_t total = 0;
+ for (int i = 0; i < iovcnt; i++) total += iov[i].iov_len;
+ uint8_t report[HOOKUP_REPORT_MAX];
+ ssize_t got = hookup_read_steam(fd, *q, report, std::min(total, sizeof(report)));
+ size_t done = 0;
+ for (int i = 0; i < iovcnt && got > 0 && done < static_cast(got); i++) {
+ size_t part = std::min(iov[i].iov_len, static_cast(got) - done);
+ memcpy(iov[i].iov_base, report + done, part);
+ done += part;
+ }
+ return got;
+}
+
+// ---- DroidDeck's own controller nodes (Steam Deck pad, DualSense / Edge, DualShock 4, Steam Controller 2015 and
+// 2026): the same mailbox, so Steam reads the newest state of every controller, not only hooked-up ones ----
+__attribute__((visibility("hidden"))) static void pad_mailbox_attach(DeckHidraw &pad) {
+ pad.hookup_in = std::make_shared();
+ const char *what = pad.triton ? (pad.emulated ? "Steam Controller (2026) pad" : "Steam Controller (2026)")
+ : pad.sc1 ? "Steam Controller (2015) pad"
+ : pad.sony == 4 ? "DualShock 4 pad"
+ : pad.sony == 6 ? "DualSense Edge pad"
+ : pad.sony ? "DualSense pad"
+ : "Steam Deck pad";
+ snprintf(pad.hookup_in->name, sizeof(pad.hookup_in->name), "deck: %s", what);
+}
+
+/** A report of one of DroidDeck's own pads for Steam. -1 = Steam closed the node (the report thread ends). */
+__attribute__((visibility("hidden"))) static int pad_mailbox_offer(DeckHidraw &pad, const uint8_t *data,
+ uint32_t length) {
+ hookup_boost_this_thread("hands DroidDeck's controller reports to Steam");
+ if (!pad.hookup_in) { // no mailbox: straight into the node's socket, as DroidDeck does
+ if (send(pad.peer, data, length, MSG_DONTWAIT | MSG_NOSIGNAL) < 0 && errno != EAGAIN && errno != EWOULDBLOCK)
+ return -1;
+ return 0;
+ }
+ return hookup_offer(*pad.hookup_in, pad.peer, nullptr, data, length);
+}
+
+__attribute__((visibility("hidden"))) static void *hookup_report_thread(void *arg) {
+ auto *holder = static_cast *>(arg);
+ std::shared_ptr self = *holder;
+ delete holder;
+ HookupSlot *s = hookup_slot(self->hookup);
+ std::shared_ptr qp = self->hookup_in;
+ HookupQueue &q = *qp;
+ hookup_boost_this_thread("hands controller reports to Steam");
+ uint64_t next = s ? __atomic_load_n(&s->in_write, __ATOMIC_ACQUIRE) : 0;
+ bool peer_gone = false;
+ // the hop before every other meter: SDL3Bridge reading the report from USB -> picked up here (Hook-up only)
+ uint32_t hop_n = 0, hop_logs = 0;
+ uint64_t hop_sum = 0, hop_max = 0, hop_since = monotonic_us();
+ while (s && __atomic_load_n(&s->present, __ATOMIC_ACQUIRE) &&
+ __atomic_load_n(&s->generation, __ATOMIC_ACQUIRE) == self->hookup_generation) {
+ uint64_t written = __atomic_load_n(&s->in_write, __ATOMIC_ACQUIRE);
+ if (written - next > HOOKUP_IN_FIFO) next = written - HOOKUP_IN_FIFO; // fell behind: skip the oldest
+ while (next < written && !peer_gone) {
+ const HookupReport &r = s->in[next % HOOKUP_IN_FIFO];
+ uint8_t data[HOOKUP_REPORT_MAX];
+ uint32_t length = __atomic_load_n(&r.length, __ATOMIC_RELAXED);
+ if (length > HOOKUP_REPORT_MAX) length = HOOKUP_REPORT_MAX;
+ memcpy(data, r.data, length);
+ uint32_t stamp = __atomic_load_n(&r.kind, __ATOMIC_RELAXED);
+ __atomic_thread_fence(__ATOMIC_ACQUIRE);
+ bool intact = __atomic_load_n(&r.seq, __ATOMIC_ACQUIRE) == next + 1;
+ next++;
+ if (!intact || !length) continue;
+ if (stamp) { // same clock in both processes (CLOCK_MONOTONIC); 32-bit microseconds, wraps every 71 min
+ uint32_t hop = static_cast(monotonic_us()) - stamp;
+ if (hop < 1000000u) {
+ hop_n++;
+ hop_sum += hop;
+ if (hop > hop_max) hop_max = hop;
+ }
+ }
+ if (hookup_offer(q, self->peer, s, data, length) < 0) peer_gone = true;
+ }
+ if (peer_gone) break; // Steam closed the node
+ // the bridge's Poll rate paces what Steam is handed (fast path only; 0 / relayed by DroidDeck's app = every report)
+ {
+ uint64_t beat_ms = __atomic_load_n(&s->direct_ms, __ATOMIC_ACQUIRE);
+ uint32_t hz = beat_ms && monotonic_us() / 1000 - beat_ms < 1500 ? __atomic_load_n(&s->rate_hz, __ATOMIC_ACQUIRE) : 0;
+ uint32_t interval = hz >= 20 && hz < 1000 ? 1000000u / hz : 0; // 1000 Hz and up: every report, as before
+ std::lock_guard guard(q.lock);
+ if (interval != q.interval_us) {
+ if (interval)
+ Logger::log("hookup: /dev/hidraw%u: Steam gets the newest report %u times a second (the bridge's Poll rate)\n",
+ HOOKUP_MINOR_BASE + self->hookup, hz);
+ else
+ Logger::log("hookup: /dev/hidraw%u: every report goes to Steam (Poll rate 1000 Hz)\n",
+ HOOKUP_MINOR_BASE + self->hookup);
+ q.interval_us = interval;
+ }
+ }
+ int64_t due = hookup_token_due(q, self->peer, monotonic_us());
+ if (due < 0) break; // Steam closed the node
+ uint64_t hop_now = monotonic_us();
+ if (hop_now - hop_since >= 10000000ULL) { // first one always, then when the hop is slow
+ if (hop_n && (hop_logs++ == 0 || hop_sum / hop_n > 1000 || hop_max > 8000))
+ Logger::log("latency: SDL3Bridge read a report -> it reached Steam's side (/dev/hidraw%u): %u reports, avg %.2f "
+ "ms, max %.1f ms\n", HOOKUP_MINOR_BASE + self->hookup, hop_n, hop_sum / 1000.0 / hop_n,
+ hop_max / 1000.0);
+ hop_n = 0;
+ hop_sum = hop_max = 0;
+ hop_since = hop_now;
+ }
+ // Sleep until the next report. The SDL3 bridge's fast path wakes this futex the moment it writes one (the
+ // low half of in_write changes); reports relayed by DroidDeck's app are picked up by the timeout.
+ uint64_t beat = __atomic_load_n(&s->direct_ms, __ATOMIC_ACQUIRE);
+ bool fast = beat && monotonic_us() / 1000 - beat < 1500;
+ long wait_us = fast ? 4000L : 500L;
+ if (due > 0 && due < wait_us) wait_us = static_cast(due); // a held-back token is due sooner
+ struct timespec wait = {0, wait_us * 1000L};
+ syscall(SYS_futex, reinterpret_cast(&s->in_write), HOOKUP_FUTEX_WAIT, static_cast(written),
+ &wait, nullptr, 0);
+ }
+ {
+ std::lock_guard guard(q.lock);
+ q.closed = true;
+ }
+ syscall(SYS_close, self->peer);
+ return nullptr;
+}
+
+__attribute__((visibility("hidden"))) static int open_hookup_hidraw(int flags, int slot) {
+ HookupSlot *s = hookup_slot(slot);
+ if (!s || !process_is_steam_client()) {
+ errno = ENOENT;
+ return -1;
+ }
+ int pair[2];
+ if (socketpair(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0, pair) < 0) return -1;
+ // The socket only carries the mailbox's 1-byte "something to read" token (see HookupQueue): small is plenty.
+ int buffer = HOOKUP_REPORT_MAX * 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 pad = std::make_shared();
+ pad->peer = pair[1];
+ pad->hookup = static_cast(slot);
+ pad->hookup_generation = __atomic_load_n(&s->generation, __ATOMIC_ACQUIRE);
+ pad->hookup_in = std::make_shared();
+ snprintf(pad->hookup_in->name, sizeof(pad->hookup_in->name), "hookup: /dev/hidraw%u", HOOKUP_MINOR_BASE + slot);
+ {
+ std::lock_guard guard(controller_mutex());
+ deck_map()[pair[0]] = pad;
+ watch_fd(pair[0]);
+ }
+ pthread_t thread;
+ auto *arg = new std::shared_ptr(pad);
+ if (pthread_create(&thread, nullptr, hookup_report_thread, arg) != 0) {
+ delete arg;
+ std::lock_guard guard(controller_mutex());
+ deck_map().erase(pair[0]);
+ unwatch_fd(pair[0]);
+ syscall(SYS_close, pair[0]);
+ syscall(SYS_close, pair[1]);
+ errno = ENOMEM;
+ return -1;
+ }
+ pthread_detach(thread);
+ Logger::log("hookup: /dev/hidraw%u opened as fd %d (%04x:%04x \"%s\")\n", HOOKUP_MINOR_BASE + slot, pair[0], s->vendor,
+ s->product, s->name);
+ return pair[0];
+}
+
+/**
+ * Steam -> controller: an output report (kind 0) or a feature-report write (kind 1). Called without the hook's
+ * controller lock (see the write() patch), so a wait here never holds up Steam's other controller calls -- its
+ * virtual pad above all. Output reports (rumble, lights) wait at most a few ms for room: when the controller can't
+ * take them that fast the newest is dropped, as the next one carries the current state again.
+ */
+__attribute__((visibility("hidden"))) static void hookup_queue(const DeckHidraw &pad, const uint8_t *data, size_t size,
+ uint32_t kind) {
+ HookupSlot *s = hookup_slot(pad.hookup);
+ if (!s || !data || !size || !__atomic_load_n(&s->present, __ATOMIC_ACQUIRE)) return;
+ if (size > HOOKUP_REPORT_MAX) size = HOOKUP_REPORT_MAX;
+ static std::mutex one_at_a_time;
+ std::lock_guard guard(one_at_a_time);
+ static HookupOutputStat stats[HOOKUP_SLOTS];
+ HookupOutputStat &st = stats[pad.hookup];
+ uint64_t t0 = monotonic_us();
+ uint64_t idx = __atomic_load_n(&s->out_write, __ATOMIC_RELAXED);
+ int limit = (kind ? HOOKUP_OUTPUT_WAIT_MS : HOOKUP_REPORT_WAIT_MS) * 4;
+ int waited = 0;
+ for (; idx - __atomic_load_n(&s->out_read, __ATOMIC_ACQUIRE) >= HOOKUP_OUT_FIFO && waited < limit; waited++)
+ sleep_us(250);
+ uint64_t t1 = monotonic_us();
+ if (waited) st.waited_us += t1 - t0;
+ if (!st.since_us) st.since_us = t1;
+ if (kind) st.features++; else st.reports++;
+ bool full = idx - __atomic_load_n(&s->out_read, __ATOMIC_ACQUIRE) >= HOOKUP_OUT_FIFO;
+ if (full && !kind) st.dropped++;
+ if (t1 - st.since_us >= 10000000ULL) {
+ if (st.waited_us || st.dropped)
+ Logger::log("hookup: /dev/hidraw%u: Steam sent %u output reports + %u feature writes in 10 s; the controller "
+ "fell behind: waited %.1f ms for room, dropped %u output reports\n",
+ HOOKUP_MINOR_BASE + pad.hookup, st.reports, st.features, st.waited_us / 1000.0, st.dropped);
+ st = HookupOutputStat{};
+ st.since_us = t1;
+ }
+ if (full && !kind) return;
+ HookupReport &r = s->out[idx % HOOKUP_OUT_FIFO];
+ __atomic_store_n(&r.seq, 0, __ATOMIC_RELAXED);
+ __atomic_thread_fence(__ATOMIC_RELEASE);
+ r.length = static_cast(size);
+ r.kind = kind;
+ memcpy(r.data, data, size);
+ __atomic_thread_fence(__ATOMIC_RELEASE);
+ __atomic_store_n(&r.seq, idx + 1, __ATOMIC_RELEASE);
+ __atomic_store_n(&s->out_write, idx + 1, __ATOMIC_RELEASE);
+}
+
+/** Feature (or input) report read: asks the controller through the app and waits for the answer. */
+__attribute__((visibility("hidden"))) static int hookup_get(const DeckHidraw &pad, uint8_t *buf, size_t size, bool input) {
+ HookupSlot *s = hookup_slot(pad.hookup);
+ if (!s || !buf || size < 1) {
+ errno = EINVAL;
+ return -1;
+ }
+ static std::mutex one_at_a_time[HOOKUP_SLOTS];
+ std::lock_guard guard(one_at_a_time[pad.hookup]);
+ uint8_t report_id = buf[0];
+ __atomic_store_n(&s->get_report_id, report_id | (input ? 0x100u : 0u), __ATOMIC_RELAXED);
+ __atomic_store_n(&s->get_length, static_cast(std::min(size, HOOKUP_REPORT_MAX)), __ATOMIC_RELAXED);
+ uint64_t request = __atomic_add_fetch(&s->get_request, 1, __ATOMIC_RELEASE);
+ uint64_t t0 = monotonic_us();
+ for (int waited = 0; waited < HOOKUP_FEATURE_TIMEOUT_MS * 4; waited++) {
+ if (!__atomic_load_n(&s->present, __ATOMIC_ACQUIRE)) break;
+ if (__atomic_load_n(&s->get_reply, __ATOMIC_ACQUIRE) == request) {
+ int32_t length = __atomic_load_n(&s->get_length, __ATOMIC_RELAXED);
+ if (length <= 0 || length > HOOKUP_REPORT_MAX) break;
+ size_t copied = std::min(size, static_cast(length));
+ memcpy(buf, s->get, copied);
+ uint64_t took = monotonic_us() - t0;
+ static std::atomic slow;
+ if (took > 20000) { // Steam waits for these: a slow one holds up that Steam thread
+ unsigned n = ++slow;
+ if ((n & (n - 1)) == 0)
+ Logger::log("hookup: report 0x%02x read took %.1f ms (slow reads so far: %u)\n", report_id, took / 1000.0, n);
+ }
+ return static_cast(copied);
+ }
+ sleep_us(250);
+ }
+ static std::atomic failures;
+ unsigned n = ++failures;
+ if ((n & (n - 1)) == 0) Logger::log("hookup: report 0x%02x read got no answer (x%u)\n", report_id, n);
+ errno = EIO;
+ return -1;
+}
+
+__attribute__((visibility("hidden"))) static int ioctl_hookup(DeckHidraw &pad, ioctl_request_t op, void *argp) {
+ HookupSlot *s = hookup_slot(pad.hookup);
+ if (_IOC_TYPE(op) != 'H' || !s) {
+ errno = ENOTTY;
+ return -1;
+ }
+ switch (_IOC_NR(op)) {
+ case 0x01:
+ *static_cast(argp) = static_cast(std::min(s->desc_len, HOOKUP_DESC_MAX));
+ return 0;
+ case 0x02: {
+ auto *out = static_cast(argp);
+ uint32_t size;
+ memcpy(&size, out, sizeof(size));
+ memcpy(out + 4, s->desc, std::min(size, std::min(s->desc_len, HOOKUP_DESC_MAX)));
+ return 0;
+ }
+ case 0x03: {
+ struct {
+ uint32_t bustype;
+ int16_t vendor;
+ int16_t product;
+ } info = {s->bus ? s->bus : static_cast(BUS_USB), static_cast(s->vendor), static_cast(s->product)};
+ memcpy(argp, &info, sizeof(info));
+ return 0;
+ }
+ case 0x04: return copy_ioctl_string(op, argp, s->name);
+ case 0x05: {
+ char phys[48];
+ snprintf(phys, sizeof(phys), "usb-droiddeck-%d/input%d", 10 + pad.hookup, s->iface < 0 ? 0 : s->iface);
+ return copy_ioctl_string(op, argp, phys);
+ }
+ case 0x08: return copy_ioctl_string(op, argp, s->serial);
+ case 0x06: // HIDIOCSFEATURE
+ hookup_queue(pad, static_cast(argp), _IOC_SIZE(op), 1);
+ return static_cast(_IOC_SIZE(op));
+ case 0x07: // HIDIOCGFEATURE
+ return hookup_get(pad, static_cast(argp), _IOC_SIZE(op), false);
+ case 0x0A: // HIDIOCGINPUT
+ return hookup_get(pad, static_cast(argp), _IOC_SIZE(op), true);
+ case 0x0B: // HIDIOCSOUTPUT
+ hookup_queue(pad, static_cast(argp), _IOC_SIZE(op), 0);
+ return static_cast(_IOC_SIZE(op));
+ default:
+ errno = EINVAL;
+ return -1;
+ }
+}
+
+// ---- game rumble -> Steam (uinput force feedback) ----
+__attribute__((visibility("hidden"))) static void hookup_ff_from_game(uint16_t slot, int strong, int weak, uint16_t length) {
+ HookupHeader *h = hookup_header();
+ if (!h || slot < UINPUT_EVENT_BASE || slot >= UINPUT_EVENT_LIMIT) return;
+ if (!__atomic_load_n(&h->ff_to_steam, __ATOMIC_ACQUIRE)) return;
+ HookupFF &f = h->ff[slot - UINPUT_EVENT_BASE];
+ f.strong = strong;
+ f.weak = weak;
+ f.length = length;
+ f.play = (strong || weak) && length ? 1 : 0;
+ __atomic_add_fetch(&f.seq, 1, __ATOMIC_RELEASE);
+}
+
+/** Runs in the Steam client: turns game rumble into uinput force-feedback requests for Steam's virtual pad. */
+__attribute__((visibility("hidden"))) static void hookup_ff_to_steam() {
+ HookupHeader *h = hookup_header();
+ if (!h) return;
+ std::lock_guard guard(controller_mutex());
+ for (auto &entry : uinput_map()) {
+ UinputWriter &w = *entry.second;
+ if (!w.published || w.owner != getpid() || w.node < UINPUT_EVENT_BASE || w.node >= UINPUT_EVENT_LIMIT) continue;
+ if (!test_bit(w.description.ffbits, sizeof(w.description.ffbits), FF_RUMBLE)) continue;
+ HookupFF &f = h->ff[w.node - UINPUT_EVENT_BASE];
+ uint64_t seq = __atomic_load_n(&f.seq, __ATOMIC_ACQUIRE);
+ if (seq == w.ff_seen) continue;
+ w.ff_seen = seq;
+ struct timeval now = {};
+ gettimeofday(&now, nullptr);
+ auto event = [&](uint16_t type, uint16_t code, int32_t value) {
+ struct input_event ev = {};
+ ev.time = now;
+ ev.type = type;
+ ev.code = code;
+ ev.value = value;
+ w.ff_queue.push_back(ev);
+ };
+ if (f.play) {
+ w.ff_request++;
+ memset(&w.ff_effect, 0, sizeof(w.ff_effect));
+ w.ff_effect.type = FF_RUMBLE;
+ w.ff_effect.id = 0;
+ w.ff_effect.replay.length = f.length == 0xffff ? 0 : static_cast(f.length);
+ w.ff_effect.u.rumble.strong_magnitude = static_cast(std::max(0, std::min(65535, f.strong)));
+ w.ff_effect.u.rumble.weak_magnitude = static_cast(std::max(0, std::min(65535, f.weak)));
+ event(EV_UINPUT, UI_FF_UPLOAD, static_cast(w.ff_request));
+ event(EV_FF, 0, 1);
+ } else {
+ event(EV_FF, 0, 0);
+ }
+ uint64_t one = 1;
+ if (syscall(SYS_write, entry.first, &one, sizeof(one)) < 0) {}
+ }
+}
+
+/** Serves Steam's read() of its uinput fd: the queued force-feedback requests. -2 = not handled. */
+__attribute__((visibility("hidden"))) static ssize_t hookup_read_uinput(int fd, void *buf, size_t count) {
+ std::unique_lock guard(controller_mutex());
+ auto made = uinput_map().find(fd);
+ if (made == uinput_map().end()) {
+ // Steam reading a controller node with a mailbox (HookupQueue): answered from it, unlocked
+ auto deck = deck_map().find(fd);
+ if (deck == deck_map().end()) return -2;
+ if (!deck->second->hookup_in) { // a node without one (touch screen, or the pad mailbox not patched in)
+ if (!deck->second->touch) hookup_boost_this_thread("reads DroidDeck's controller");
+ return -2;
+ }
+ std::shared_ptr q = deck->second->hookup_in;
+ guard.unlock();
+ return hookup_read_steam(fd, *q, buf, count);
+ }
+ std::shared_ptr w = made->second;
+ if (count < sizeof(struct input_event)) {
+ errno = EINVAL;
+ return -1;
+ }
+ for (;;) {
+ if (!w->ff_queue.empty()) {
+ size_t n = std::min(count / sizeof(struct input_event), w->ff_queue.size());
+ for (size_t i = 0; i < n; i++) {
+ memcpy(static_cast(buf) + i * sizeof(struct input_event), &w->ff_queue.front(), sizeof(struct input_event));
+ w->ff_queue.pop_front();
+ }
+ // The eventfd counter mirrors "events pending": drained when the queue empties, kept >0 otherwise,
+ // so Steam's poll() on the fd stays accurate.
+ int flags = fcntl(fd, F_GETFL);
+ if (w->ff_queue.empty()) {
+ uint64_t counter;
+ if (flags >= 0 && !(flags & O_NONBLOCK)) fcntl(fd, F_SETFL, flags | O_NONBLOCK);
+ if (syscall(SYS_read, fd, &counter, sizeof(counter)) < 0) {}
+ if (flags >= 0 && !(flags & O_NONBLOCK)) fcntl(fd, F_SETFL, flags);
+ } else {
+ uint64_t one = 1;
+ if (syscall(SYS_write, fd, &one, sizeof(one)) < 0) {}
+ }
+ return static_cast(n * sizeof(struct input_event));
+ }
+ int flags = fcntl(fd, F_GETFL);
+ if (flags >= 0 && (flags & O_NONBLOCK)) {
+ errno = EAGAIN;
+ return -1;
+ }
+ guard.unlock();
+ uint64_t counter;
+ if (syscall(SYS_read, fd, &counter, sizeof(counter)) < 0 && errno != EINTR) return -1; // blocks until queued
+ guard.lock();
+ }
+}
+
+// ---- hot-plug: udev add/remove for the hooked-up nodes, plus the force-feedback pump ----
+__attribute__((visibility("hidden"))) static void *hookup_watch_thread(void *) {
+ uint32_t seen_gen[HOOKUP_SLOTS] = {};
+ bool seen[HOOKUP_SLOTS] = {};
+ char devpath[HOOKUP_SLOTS][160] = {};
+ for (unsigned tick = 0;; tick++) {
+ // Steam runs: on DroidDeck's desktop (where Steam is one program among others) the SDL3 bridge hands
+ // controllers to Steam only while this heartbeat is fresh, and they are ordinary gamepads otherwise
+ if (tick % 50 == 0)
+ if (HookupHeader *h = hookup_header()) __atomic_store_n(&h->steam_ms, monotonic_us() / 1000, __ATOMIC_RELEASE);
+ sleep_us(2000);
+ hookup_ff_to_steam();
+ if (tick % 10) continue;
+ for (int i = 0; i < HOOKUP_SLOTS; i++) {
+ HookupSlot *s = hookup_slot(i);
+ if (!s) continue;
+ bool now = __atomic_load_n(&s->present, __ATOMIC_ACQUIRE) != 0;
+ uint32_t gen = __atomic_load_n(&s->generation, __ATOMIC_ACQUIRE);
+ if (now == seen[i] && (!now || gen == seen_gen[i])) continue;
+ char path[32];
+ snprintf(path, sizeof(path), "/dev/hidraw%u", HOOKUP_MINOR_BASE + i);
+ if (seen[i]) {
+ int sent = inject_uevent("remove", devpath[i], "hidraw", path, DECK_HIDRAW_MAJOR, HOOKUP_MINOR_BASE + i);
+ Logger::log("hookup: %s removed (udev event to %d monitors)\n", path, sent);
+ }
+ seen[i] = now;
+ seen_gen[i] = gen;
+ if (now) {
+ hookup_devpath(i, *s, devpath[i], sizeof(devpath[i]));
+ if (seen_gen[i] != 0) {
+ struct timespec settle = {0, 200 * 1000 * 1000};
+ nanosleep(&settle, nullptr);
+ }
+ int sent = inject_uevent("add", devpath[i], "hidraw", path, DECK_HIDRAW_MAJOR, HOOKUP_MINOR_BASE + i);
+ Logger::log("hookup: %s is %04x:%04x \"%s\" (udev event to %d monitors)\n", path, s->vendor, s->product, s->name, sent);
+ }
+ poke_hidraw_watchers(HOOKUP_MINOR_BASE + i);
+ }
+ }
+ return nullptr;
+}
+
+__attribute__((constructor)) static void start_hookup_watch() {
+ config();
+ if (!process_is_steam_client() || !fake_deck_enabled()) return;
+ const char *path = getenv("FAKE_HOOKUP_SHM");
+ if (!path || !*path) {
+ Logger::log("hookup: off (no FAKE_HOOKUP_SHM: the SDL3 bridge app is not installed, or an older DroidDeck build)\n");
+ return;
+ }
+ if (!hookup_shm()) return;
+ Logger::log("hookup: watching %s for controllers handed over by the SDL3 bridge\n", path);
+ pthread_t thread;
+ if (pthread_create(&thread, nullptr, hookup_watch_thread, nullptr) == 0) pthread_detach(thread);
+}
+// ---- Steam Input's mouse / keyboard output, traced (pad.log "mouse: ...") ----
+// A Steam binding to a key, mouse movement or a mouse button reaches a game in three steps: Steam sends an XTest
+// event to the game's X server (Xwayland; Steam does send clicks there), Xwayland hands it to gamescope over libei,
+// and gamescope (libeis) delivers it to the game's window. Steps 2 and 3 are logged here, in the Xwayland and
+// gamescope processes (this library is loaded in all of them), plus the input device each side sets up, and
+// Steam's own XTest calls (step 1) -- so a
+// click that goes missing shows where. Buttons are logged one by one at first (then every 100th), keys a few times
+// to compare. Every call is passed on unchanged. The real function is looked up in the library itself if it is not
+// next in line (a program that loaded it privately), so a call is never lost here.
+namespace input_trace {
+static std::atomic buttons{0}, keys{0}, setup{0};
+
+static bool first(std::atomic &n, uint32_t count) {
+ uint32_t i = n.fetch_add(1, std::memory_order_relaxed);
+ return i < count || i % 100 == 0;
+}
+
+/** Which X server this is (Xwayland ":0" / ":1" from its command line), or "" in other processes. */
+static const char *display() {
+ static char name[16] = {0};
+ static std::once_flag once;
+ std::call_once(once, [] {
+ char args[4096];
+ int fd = static_cast(syscall(SYS_openat, AT_FDCWD, "/proc/self/cmdline", O_RDONLY | O_CLOEXEC));
+ if (fd < 0) return;
+ ssize_t n = syscall(SYS_read, fd, args, sizeof(args) - 1);
+ syscall(SYS_close, fd);
+ if (n <= 0) return;
+ args[n] = 0;
+ for (ssize_t i = 0; i < n; i += static_cast(strlen(args + i)) + 1)
+ if (args[i] == ':' && args[i + 1] >= '0' && args[i + 1] <= '9') {
+ snprintf(name, sizeof(name), " %s", args + i);
+ break;
+ }
+ });
+ return name;
+}
+
+static void log(const char *fmt, ...) __attribute__((format(printf, 1, 2)));
+static void log(const char *fmt, ...) {
+ config();
+ char text[256];
+ va_list args;
+ va_start(args, fmt);
+ vsnprintf(text, sizeof(text), fmt, args);
+ va_end(args);
+ Logger::log("mouse:%s %s\n", display(), text);
+}
+
+/** Each libei / libeis event type once per process (the connection's life: added, resumed, paused, ...). */
+static void event_type_once(const char *side, uint32_t type) {
+ static std::atomic seen[4];
+ uint32_t slot = type % 256;
+ uint64_t bit = 1ULL << (slot % 64);
+ if (seen[slot / 64].fetch_or(bit) & bit) return;
+ log("%s event type %u", side, type);
+}
+
+static const char *x_display(void *display) {
+ using Fn = char *(*)(void *);
+ static Fn get = [] {
+ void *fn = dlsym(RTLD_DEFAULT, "XDisplayString");
+ if (!fn)
+ if (void *x11 = dlopen("libX11.so.6", RTLD_LAZY | RTLD_NOLOAD)) fn = dlsym(x11, "XDisplayString");
+ return reinterpret_cast(fn);
+ }();
+ const char *name = display && get ? get(display) : nullptr;
+ return name ? name : "?";
+}
+
+template static Fn real(const char *name, const char *library) {
+ void *fn = dlsym(RTLD_NEXT, name);
+ if (!fn) {
+ void *handle = dlopen(library, RTLD_LAZY | RTLD_NOLOAD);
+ if (!handle) handle = dlopen(library, RTLD_LAZY);
+ if (handle) fn = dlsym(handle, name); // the library's own definition, not this one
+ }
+ if (!fn) log("could not find the real %s in %s: this call is dropped", name, library);
+ return reinterpret_cast(fn);
+}
+
+static const char *caps(uint32_t c) {
+ static thread_local char text[64];
+ // libei / libeis capability bits: pointer 1, pointer absolute 2, keyboard 4, touch 8, scroll 16, button 32
+ snprintf(text, sizeof(text), "%s%s%s%s%s%s", c & 1 ? " pointer" : "", c & 2 ? " absolute" : "",
+ c & 4 ? " keyboard" : "", c & 8 ? " touch" : "", c & 16 ? " scroll" : "", c & 32 ? " button" : "");
+ return text;
+}
+} // namespace input_trace
+
+// step 1 (Steam): XTest sent to the game's X server. Steam's client library links libXtst privately, so the real
+// function is not "next" from here: it is looked up in libXtst itself (input_trace::real).
+EXPORT int XTestFakeButtonEvent(void *display, unsigned int button, int is_press, unsigned long delay) {
+ static auto real = input_trace::real("XTestFakeButtonEvent", "libXtst.so.6");
+ int result = real ? real(display, button, is_press, delay) : 0;
+ if (input_trace::first(input_trace::buttons, 40))
+ input_trace::log("Steam sent XTest mouse button %u %s to X display %s (%s)", button, is_press ? "press" : "release",
+ input_trace::x_display(display), result ? "sent" : "NOT sent: no XTest on that display");
+ return result;
+}
+
+EXPORT int XTestFakeKeyEvent(void *display, unsigned int keycode, int is_press, unsigned long delay) {
+ static auto real = input_trace::real("XTestFakeKeyEvent", "libXtst.so.6");
+ int result = real ? real(display, keycode, is_press, delay) : 0;
+ if (input_trace::first(input_trace::keys, 40))
+ input_trace::log("Steam sent XTest key %u %s to X display %s (%s)", keycode, is_press ? "press" : "release",
+ input_trace::x_display(display), result ? "sent" : "NOT sent: no XTest on that display");
+ return result;
+}
+
+EXPORT int XTestFakeRelativeMotionEvent(void *display, int dx, int dy, unsigned long delay) {
+ static auto real = input_trace::real("XTestFakeRelativeMotionEvent", "libXtst.so.6");
+ return real ? real(display, dx, dy, delay) : 0;
+}
+
+EXPORT int XTestFakeMotionEvent(void *display, int screen, int x, int y, unsigned long delay) {
+ static auto real = input_trace::real("XTestFakeMotionEvent", "libXtst.so.6");
+ static std::atomic n{0};
+ if (n.fetch_add(1) < 3) input_trace::log("Steam moved the X pointer to %d,%d on X display %s", x, y, input_trace::x_display(display));
+ return real ? real(display, screen, x, y, delay) : 0;
+}
+
+// step 2 (Xwayland): the X server hands XTest input to gamescope over libei
+EXPORT void ei_device_button_button(void *device, uint32_t button, bool is_press) {
+ static auto real = input_trace::real("ei_device_button_button", "libei.so.1");
+ if (input_trace::first(input_trace::buttons, 24))
+ input_trace::log("X server handed mouse button 0x%x %s to gamescope (libei)", button, is_press ? "press" : "release");
+ if (real) real(device, button, is_press);
+}
+
+EXPORT void ei_device_keyboard_key(void *device, uint32_t keycode, bool is_press) {
+ static auto real = input_trace::real("ei_device_keyboard_key", "libei.so.1");
+ if (input_trace::first(input_trace::keys, 40))
+ input_trace::log("X server handed key %u %s to gamescope (libei)", keycode, is_press ? "press" : "release");
+ if (real) real(device, keycode, is_press);
+}
+
+EXPORT bool ei_device_has_capability(void *device, uint32_t capability) {
+ static auto real = input_trace::real("ei_device_has_capability", "libei.so.1");
+ bool has = real && real(device, capability);
+ if (input_trace::first(input_trace::setup, 24))
+ input_trace::log("X server: gamescope's input device %p %s%s", device, has ? "can send" : "can NOT send",
+ input_trace::caps(capability));
+ return has;
+}
+
+EXPORT void ei_device_start_emulating(void *device, uint32_t sequence) {
+ static auto real = input_trace::real("ei_device_start_emulating", "libei.so.1");
+ if (input_trace::first(input_trace::setup, 24)) input_trace::log("X server: input device %p starts sending", device);
+ if (real) real(device, sequence);
+}
+
+EXPORT int ei_setup_backend_socket(void *ei, const char *socketpath) {
+ static auto real = input_trace::real("ei_setup_backend_socket", "libei.so.1");
+ int result = real ? real(ei, socketpath) : -ENOSYS;
+ input_trace::log("X server connects to gamescope's input socket %s: %s", socketpath ? socketpath : "(LIBEI_SOCKET)",
+ result == 0 ? "ok" : strerror(result < 0 ? -result : result));
+ return result;
+}
+
+EXPORT void ei_device_pointer_motion(void *device, double x, double y) {
+ static auto real = input_trace::real("ei_device_pointer_motion", "libei.so.1");
+ static std::atomic n{0};
+ uint32_t i = n.fetch_add(1, std::memory_order_relaxed);
+ if (i < 3 || i % 500 == 0) input_trace::log("X server handed mouse motion #%u to gamescope (libei)", i + 1);
+ if (real) real(device, x, y);
+}
+
+EXPORT uint32_t ei_event_get_type(void *event) {
+ static auto real = input_trace::real("ei_event_get_type", "libei.so.1");
+ uint32_t type = real ? real(event) : 0;
+ input_trace::event_type_once("X server: libei", type);
+ return type;
+}
+
+// step 3 (gamescope): received over libeis, then delivered to the window that has the pointer
+EXPORT uint32_t eis_event_button_get_button(void *event) {
+ static auto real = input_trace::real("eis_event_button_get_button", "libeis.so.1");
+ static auto pressed = input_trace::real("eis_event_button_get_is_press", "libeis.so.1");
+ uint32_t button = real ? real(event) : 0;
+ if (input_trace::first(input_trace::buttons, 24))
+ input_trace::log("gamescope received mouse button 0x%x %s (libeis)", button,
+ pressed && pressed(event) ? "press" : "release");
+ return button;
+}
+
+EXPORT uint32_t eis_event_keyboard_get_key(void *event) {
+ static auto real = input_trace::real("eis_event_keyboard_get_key", "libeis.so.1");
+ static auto pressed = input_trace::real("eis_event_keyboard_get_key_is_press", "libeis.so.1");
+ uint32_t key = real ? real(event) : 0;
+ if (input_trace::first(input_trace::keys, 40))
+ input_trace::log("gamescope received key %u %s (libeis)", key, pressed && pressed(event) ? "press" : "release");
+ return key;
+}
+
+EXPORT void eis_client_connect(void *client) {
+ static auto real = input_trace::real("eis_client_connect", "libeis.so.1");
+ static std::atomic n{0};
+ input_trace::log("gamescope: X server #%u connected for input (libeis client %p)", n.fetch_add(1) + 1, client);
+ if (real) real(client);
+}
+
+EXPORT uint32_t eis_event_get_type(void *event) {
+ static auto real = input_trace::real("eis_event_get_type", "libeis.so.1");
+ uint32_t type = real ? real(event) : 0;
+ input_trace::event_type_once("gamescope: libeis", type);
+ return type;
+}
+
+EXPORT void eis_device_configure_capability(void *device, uint32_t capability) {
+ static auto real = input_trace::real("eis_device_configure_capability", "libeis.so.1");
+ if (input_trace::first(input_trace::setup, 24))
+ input_trace::log("gamescope: its input device %p for the X server can send%s", device, input_trace::caps(capability));
+ if (real) real(device, capability);
+}
+// =================== end sdl3bridge: Steam Native Hook-up ===================
+
__attribute__((visibility("hidden"))) static std::string note_sys_failure(int dirfd, const char *pathname) {
int saved_errno = errno;
std::string path;
@@ -2955,7 +4786,13 @@ EXPORT int open(const char *pathname, int flags, ...) {
static auto my_open = reinterpret_cast(dlsym(RTLD_NEXT, "open"));
if (fake_uinput_enabled() && is_uinput_path(pathname)) return open_uinput(flags);
+ if (desktop_hidden_hidraw(pathname)) { // sdl3bridge: meant for Steam, not for desktop programs
+ errno = ENOENT;
+ return -1;
+ }
if (is_deck_hidraw_path(pathname)) return open_deck_hidraw(flags);
+ if (int hk = hookup_path_slot(pathname); hk >= 0) return open_hookup_hidraw(flags, hk);
+ if (is_sc1_hidraw_path(pathname)) return open_deck_hidraw(flags, 0, true);
if (is_triton_hidraw_path(pathname)) return open_triton_hidraw(flags, false);
if (is_triton_pad_path(pathname)) return open_triton_hidraw(flags, true);
if (int sony = sony_pad_path(pathname)) return open_deck_hidraw(flags, static_cast(sony));
@@ -3033,7 +4870,13 @@ EXPORT int openat(int dirfd, const char *pathname, int flags, ...) {
static auto my_openat = reinterpret_cast(dlsym(RTLD_NEXT, "openat"));
if (fake_uinput_enabled() && is_uinput_path(pathname)) return open_uinput(flags);
+ if (desktop_hidden_hidraw(pathname)) { // sdl3bridge: meant for Steam, not for desktop programs
+ errno = ENOENT;
+ return -1;
+ }
if (is_deck_hidraw_path(pathname)) return open_deck_hidraw(flags);
+ if (int hk = hookup_path_slot(pathname); hk >= 0) return open_hookup_hidraw(flags, hk);
+ if (is_sc1_hidraw_path(pathname)) return open_deck_hidraw(flags, 0, true);
if (is_triton_hidraw_path(pathname)) return open_triton_hidraw(flags, false);
if (is_triton_pad_path(pathname)) return open_triton_hidraw(flags, true);
if (int sony = sony_pad_path(pathname)) return open_deck_hidraw(flags, static_cast(sony));
@@ -3096,7 +4939,11 @@ template
static int fake_stat_path(const char *pathname, S *statbuf, Real real) {
if (is_withdrawn_pad_path(pathname) ||
((is_deck_hidraw_path(pathname) || is_triton_hidraw_path(pathname) || is_triton_pad_path(pathname) ||
- sony_pad_path(pathname)) && !process_is_steam_client())) {
+ sony_pad_path(pathname) || is_sc1_hidraw_path(pathname)) && !process_is_steam_client())) {
+ errno = ENOENT;
+ return -1;
+ }
+ if (hookup_path_slot(pathname) >= 0 && !process_is_steam_client()) {
errno = ENOENT;
return -1;
}
@@ -3112,10 +4959,22 @@ 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 (int hk = hookup_path_slot(pathname); hk >= 0) {
+ memset(statbuf, 0, sizeof(*statbuf));
+ statbuf->st_mode = S_IFCHR | 0666;
+ statbuf->st_rdev = makedev(DECK_HIDRAW_MAJOR, HOOKUP_MINOR_BASE + hk);
+ return 0;
+ }
+ if (is_sc1_hidraw_path(pathname)) {
+ memset(statbuf, 0, sizeof(*statbuf));
+ statbuf->st_mode = S_IFCHR | 0666;
+ statbuf->st_rdev = makedev(DECK_HIDRAW_MAJOR, SC1_HIDRAW_MINOR);
+ return 0;
+ }
if (int sony = sony_pad_path(pathname)) {
memset(statbuf, 0, sizeof(*statbuf));
statbuf->st_mode = S_IFCHR | 0666;
- statbuf->st_rdev = makedev(DECK_HIDRAW_MAJOR, sony == 4 ? DS4_HIDRAW_MINOR : DUALSENSE_HIDRAW_MINOR);
+ statbuf->st_rdev = makedev(DECK_HIDRAW_MAJOR, sony == 4 ? DS4_HIDRAW_MINOR : sony == 6 ? DUALSENSE_EDGE_HIDRAW_MINOR : DUALSENSE_HIDRAW_MINOR);
return 0;
}
if (is_triton_hidraw_path(pathname) || is_triton_pad_path(pathname)) {
@@ -3171,11 +5030,14 @@ static int fake_stat_fd(int fd, S *buf, Real real) {
if (ret == 0 && deck_map().count(fd)) {
buf->st_mode = (buf->st_mode & ~S_IFMT) | S_IFCHR;
const auto &device = *deck_map()[fd];
- unsigned int minor = device.touch ? TOUCH_HIDRAW_MINOR
+ unsigned int minor = device.hookup >= 0 ? HOOKUP_MINOR_BASE + device.hookup
+ : device.touch ? TOUCH_HIDRAW_MINOR
: device.sony == 4 ? DS4_HIDRAW_MINOR
+ : device.sony == 6 ? DUALSENSE_EDGE_HIDRAW_MINOR
: device.sony == 5 ? DUALSENSE_HIDRAW_MINOR
: device.emulated ? TRITON_PAD_HIDRAW_MINOR
- : device.triton ? TRITON_HIDRAW_MINOR : DECK_HIDRAW_MINOR;
+ : device.triton ? TRITON_HIDRAW_MINOR
+ : device.sc1 ? SC1_HIDRAW_MINOR : DECK_HIDRAW_MINOR;
buf->st_rdev = makedev(DECK_HIDRAW_MAJOR, minor);
return ret;
}
@@ -3222,11 +5084,15 @@ 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_sc1_hidraw_path(pathname) && process_is_steam_client()) return 0;
+ if (hookup_path_slot(pathname) >= 0 && process_is_steam_client()) return 0;
if ((is_triton_hidraw_path(pathname) || is_triton_pad_path(pathname) || sony_pad_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) || is_triton_hidraw_path(pathname) ||
+ if (is_withdrawn_pad_path(pathname) || is_deck_hidraw_path(pathname) || is_sc1_hidraw_path(pathname) ||
+ hookup_path_slot(pathname) >= 0 ||
+ is_triton_hidraw_path(pathname) ||
is_triton_pad_path(pathname) || sony_pad_path(pathname)) {
errno = ENOENT;
return -1;
@@ -3263,11 +5129,15 @@ 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_sc1_hidraw_path(pathname) && process_is_steam_client()) return 0;
+ if (hookup_path_slot(pathname) >= 0 && process_is_steam_client()) return 0;
if ((is_triton_hidraw_path(pathname) || is_triton_pad_path(pathname) || sony_pad_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) || is_triton_hidraw_path(pathname) ||
+ if (is_withdrawn_pad_path(pathname) || is_deck_hidraw_path(pathname) || is_sc1_hidraw_path(pathname) ||
+ hookup_path_slot(pathname) >= 0 ||
+ is_triton_hidraw_path(pathname) ||
is_triton_pad_path(pathname) || sony_pad_path(pathname)) {
errno = ENOENT;
return -1;
@@ -3396,6 +5266,11 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
if (maker != uinput_map().end()) return ioctl_uinput(*maker->second, op, argp);
auto deck = deck_map().find(fd);
if (deck != deck_map().end()) {
+ if (deck->second->hookup >= 0) {
+ std::shared_ptr pad = deck->second;
+ guard.unlock();
+ return ioctl_hookup(*pad, op, argp);
+ }
if (deck->second->touch) return ioctl_touch(*deck->second, op, argp);
if (deck->second->sony) return ioctl_sony(*deck->second, op, argp);
if (deck->second->triton) {
@@ -3643,6 +5518,7 @@ static ssize_t copy_input_events(void *buf, const struct input_event *events, si
static ssize_t read_input(int fd, void *buf, size_t count) {
if (!watched(fd)) return syscall(SYS_read, fd, buf, count);
+ if (ssize_t ff = hookup_read_uinput(fd, buf, count); ff != -2) return ff;
std::unique_lock guard(controller_mutex());
auto controller = controller_map().find(fd);
if (controller == controller_map().end()) {
@@ -3704,6 +5580,7 @@ static ssize_t read_input(int fd, void *buf, size_t count) {
continue;
ssize_t copied = copy_input_events(buf, input_events.data(), events);
if (copied > 0) fake.read_seq += copied / sizeof(struct input_event);
+ if (copied > 0 && fake.uinput) hookup_meter_game_read(input_events.data(), copied / sizeof(struct input_event));
return copied;
}
}
@@ -3743,7 +5620,18 @@ EXPORT ssize_t read(int fd, void *buf, size_t count) {
return read_input(fd, buf, count);
}
+// sdl3bridge: read() as programs built with _FORTIFY_SOURCE call it
+EXPORT ssize_t __read_chk(int fd, void *buf, size_t count, size_t buflen) {
+ if (count > buflen) { // a buffer overflow: the C library reports it, as it would without the hook
+ static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "__read_chk"));
+ if (real) return real(fd, buf, count, buflen);
+ abort();
+ }
+ return read_input(fd, buf, count);
+}
+
EXPORT ssize_t readv(int fd, const struct iovec *iov, int iovcnt) {
+ if (ssize_t mailbox = hookup_readv_mailbox(fd, iov, iovcnt); mailbox != -2) return mailbox;
if (!is_fake_input_fd(fd)) return syscall(SYS_readv, fd, iov, iovcnt);
if (iovcnt < 0 || iovcnt > IOV_MAX) {
errno = EINVAL;
@@ -3793,6 +5681,12 @@ EXPORT ssize_t write(int fd, const void *buf, size_t count) {
auto made = uinput_map().find(fd);
if (made != uinput_map().end()) return write_uinput(*made->second, buf, count);
if (deck_map().count(fd)) {
+ if (deck_map()[fd]->hookup >= 0) {
+ std::shared_ptr pad = deck_map()[fd];
+ guard.unlock();
+ hookup_queue(*pad, static_cast(buf), count, 0);
+ return static_cast(count);
+ }
if (deck_map()[fd]->touch) touch_output(static_cast(buf), count);
else if (deck_map()[fd]->sony) sony_output(*deck_map()[fd], static_cast(buf), count);
else if (deck_map()[fd]->emulated) triton_pad_output(static_cast(buf), count);
@@ -3825,6 +5719,14 @@ EXPORT ssize_t writev(int fd, const struct iovec *iov, int iovcnt) {
if (deck_map().count(fd)) {
ssize_t total = 0;
auto device = deck_map()[fd];
+ if (device->hookup >= 0) {
+ guard.unlock();
+ for (int i = 0; i < iovcnt; i++) {
+ hookup_queue(*device, static_cast(iov[i].iov_base), iov[i].iov_len, 0);
+ total += static_cast(iov[i].iov_len);
+ }
+ return total;
+ }
for (int i = 0; i < iovcnt; i++) {
if (device->touch) touch_output(static_cast(iov[i].iov_base), iov[i].iov_len);
else if (device->sony) sony_output(*device, static_cast(iov[i].iov_base), iov[i].iov_len);
diff --git a/app/src/main/cpp/sdl3bridge/CMakeLists.txt b/app/src/main/cpp/sdl3bridge/CMakeLists.txt
new file mode 100644
index 000000000..a9f2757fd
--- /dev/null
+++ b/app/src/main/cpp/sdl3bridge/CMakeLists.txt
@@ -0,0 +1,15 @@
+# sdl3droiddeck: SDL3 and the SDL3 bridge's controller loop, built into the app (libSDL3.so + libmain.so, loaded by
+# the bridge in its own ":sdl3" process). Written by patch-droiddeck.sh (step 30); the sources are SdlBridge's.
+set(SDL_SHARED ON CACHE BOOL "" FORCE)
+set(SDL_STATIC OFF CACHE BOOL "" FORCE)
+set(SDL_TEST_LIBRARY OFF CACHE BOOL "" FORCE)
+set(SDL_TESTS OFF CACHE BOOL "" FORCE)
+set(SDL_EXAMPLES OFF CACHE BOOL "" FORCE)
+set(SDL_ANDROID_JAR OFF CACHE BOOL "" FORCE)
+set(SDL_INSTALL OFF CACHE BOOL "" FORCE)
+# The bridge draws nothing (SDL3 runs headless for controllers): no OpenGL ES, so libSDL3.so needs no libGLESv1_CM.so,
+# which DroidDeck's APK check (tools/release/check_android_libraries.py) does not count as a system library.
+set(SDL_OPENGLES OFF CACHE BOOL "" FORCE)
+add_subdirectory(SDL)
+add_library(main SHARED src/main.c)
+target_link_libraries(main PRIVATE SDL3::SDL3 log)
diff --git a/app/src/main/cpp/sdl3bridge/src/hkshm.h b/app/src/main/cpp/sdl3bridge/src/hkshm.h
new file mode 100644
index 000000000..037d50b4d
--- /dev/null
+++ b/app/src/main/cpp/sdl3bridge/src/hkshm.h
@@ -0,0 +1,213 @@
+/*
+ * Steam Native Hook-up fast path: direct access to DroidDeck's shared hook-up file (".hookup").
+ *
+ * DroidDeck passes the file to the bridge as a file descriptor (Android lets an app use a descriptor another
+ * app hands it, only opening the other app's file by path is forbidden). The bridge then writes every HID
+ * report of a hooked-up controller straight into the slot's input FIFO and wakes Steam's reader with a futex,
+ * and takes Steam's output reports / feature reads from the slot itself -- no Java, no Binder message, no
+ * polling on either side. DroidDeck still owns the slot (identity, sysfs, present flag); while the bridge
+ * serves a slot it keeps the slot's direct_ms heartbeat fresh and DroidDeck leaves that slot's FIFOs alone.
+ *
+ * Plain C (no SDL / JNI) so it can be tested on Linux against the real fakeinput hook.
+ * Layout must match HookupSlot in DroidDeck's hookup_native.inc and HookupBridge.kt.
+ */
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+enum {
+ HKS_MAGIC = 0x4b4f4f48,
+ HKS_HEADER = 4096,
+ HKS_SLOT = 53248,
+ HKS_SLOTS = 4,
+ HKS_SIZE = HKS_HEADER + HKS_SLOTS * HKS_SLOT,
+ /* slot fields */
+ HKS_PRESENT = 0,
+ HKS_VENDOR = 8,
+ HKS_PRODUCT = 10,
+ HKS_IN_WRITE = 344,
+ HKS_OUT_WRITE = 352,
+ HKS_OUT_READ = 360,
+ HKS_GET_REQUEST = 368,
+ HKS_GET_REPLY = 376,
+ HKS_GET_REPORT_ID = 384,
+ HKS_GET_LENGTH = 388,
+ HKS_GET = 392,
+ HKS_DIRECT_MS = 520,
+ HKS_RATE_HZ = 528, /* the bridge's poll rate: Steam's side hands Steam at most this many reports a second */
+ HKS_IN = 5120,
+ HKS_OUT = 41984,
+ HKS_IN_FIFO = 256,
+ HKS_OUT_FIFO = 64,
+ HKS_ENTRY = 144,
+ HKS_REPORT_MAX = 128
+};
+
+#define HKS_FUTEX_WAKE 1 /* shared (not FUTEX_PRIVATE_FLAG): the waiter is Steam, another process */
+
+typedef struct {
+ uint8_t *base;
+ int fd;
+ uint64_t ino;
+} HkShm;
+
+typedef struct {
+ uint32_t kind; /* 0 output report, 1 feature report */
+ int len;
+ uint8_t data[HKS_REPORT_MAX];
+} HksOut;
+
+static inline uint64_t hks_now_ms(void)
+{
+ struct timespec t;
+ clock_gettime(CLOCK_MONOTONIC, &t); /* = Android's SystemClock.uptimeMillis() clock */
+ return (uint64_t)t.tv_sec * 1000u + (uint64_t)t.tv_nsec / 1000000u;
+}
+
+/* Read time of an input report for DroidDeck's latency meter: CLOCK_MONOTONIC in us, low 32 bits, never 0. */
+static inline uint32_t hks_stamp_us(void)
+{
+ struct timespec t;
+ clock_gettime(CLOCK_MONOTONIC, &t);
+ uint32_t us = (uint32_t)((uint64_t)t.tv_sec * 1000000u + (uint64_t)t.tv_nsec / 1000u);
+ return us ? us : 1;
+}
+
+static inline uint8_t *hks_slot(const HkShm *m, int i) { return m->base + HKS_HEADER + (size_t)i * HKS_SLOT; }
+static inline uint64_t *hks_u64(uint8_t *s, int off) { return (uint64_t *)(void *)(s + off); }
+static inline uint32_t *hks_u32(uint8_t *s, int off) { return (uint32_t *)(void *)(s + off); }
+
+/* Maps the file behind fd (takes ownership of fd). 0 = ok, <0 = why not. */
+static inline int hks_map(HkShm *m, int fd)
+{
+ struct stat st;
+ if (fd < 0) return -1;
+ if (fstat(fd, &st) != 0 || st.st_size < HKS_SIZE) { close(fd); return -2; }
+ void *p = mmap(NULL, HKS_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
+ if (p == MAP_FAILED) { close(fd); return -3; }
+ if (__atomic_load_n((uint32_t *)p, __ATOMIC_ACQUIRE) != HKS_MAGIC) { munmap(p, HKS_SIZE); close(fd); return -4; }
+ m->base = (uint8_t *)p;
+ m->fd = fd;
+ m->ino = (uint64_t)st.st_ino;
+ return 0;
+}
+
+static inline void hks_unmap(HkShm *m)
+{
+ if (m->base) munmap(m->base, HKS_SIZE);
+ if (m->fd >= 0) close(m->fd);
+ m->base = NULL;
+ m->fd = -1;
+ m->ino = 0;
+}
+
+/* Still DroidDeck's live file? (A new DroidDeck session deletes it and makes a new one.) */
+static inline int hks_alive(const HkShm *m)
+{
+ struct stat st;
+ return m->base && fstat(m->fd, &st) == 0 && st.st_nlink > 0;
+}
+
+/* Does DroidDeck show this controller in slot i right now? Only then may the bridge serve the slot. */
+static inline int hks_owns(const HkShm *m, int i, uint16_t vid, uint16_t pid)
+{
+ if (!m->base || i < 0 || i >= HKS_SLOTS) return 0;
+ uint8_t *s = hks_slot(m, i);
+ if (!__atomic_load_n(hks_u32(s, HKS_PRESENT), __ATOMIC_ACQUIRE)) return 0;
+ uint16_t v, p;
+ memcpy(&v, s + HKS_VENDOR, 2);
+ memcpy(&p, s + HKS_PRODUCT, 2);
+ return v == vid && p == pid;
+}
+
+/* "The bridge serves this slot": DroidDeck and Steam's side read it (stale after ~1.5 s). */
+static inline void hks_heartbeat(const HkShm *m, int i, uint64_t now_ms)
+{
+ __atomic_store_n(hks_u64(hks_slot(m, i), HKS_DIRECT_MS), now_ms, __ATOMIC_RELEASE);
+}
+
+/* The rate Steam's side hands this slot's reports to Steam (the bridge's Poll rate setting; 0 = every report). */
+static inline void hks_set_rate(const HkShm *m, int i, uint32_t hz)
+{
+ __atomic_store_n(hks_u32(hks_slot(m, i), HKS_RATE_HZ), hz, __ATOMIC_RELEASE);
+}
+
+/* One input report (controller -> Steam): same protocol as HookupBridge.input(), then wake Steam's reader. */
+static inline void hks_write_in(const HkShm *m, int i, const uint8_t *data, int n)
+{
+ uint8_t *s = hks_slot(m, i);
+ uint64_t *write = hks_u64(s, HKS_IN_WRITE);
+ uint64_t idx = __atomic_load_n(write, __ATOMIC_RELAXED);
+ uint8_t *e = s + HKS_IN + (size_t)(idx % HKS_IN_FIFO) * HKS_ENTRY;
+ if (n < 0) n = 0;
+ if (n > HKS_REPORT_MAX) n = HKS_REPORT_MAX;
+ __atomic_store_n(hks_u64(e, 0), 0, __ATOMIC_RELAXED);
+ __atomic_thread_fence(__ATOMIC_RELEASE);
+ __atomic_store_n(hks_u32(e, 8), (uint32_t)n, __ATOMIC_RELAXED);
+ __atomic_store_n(hks_u32(e, 12), hks_stamp_us(), __ATOMIC_RELAXED); /* when the bridge read it: latency meter */
+ memcpy(e + 16, data, (size_t)n);
+ __atomic_thread_fence(__ATOMIC_RELEASE);
+ __atomic_store_n(hks_u64(e, 0), idx + 1, __ATOMIC_RELEASE);
+ __atomic_store_n(write, idx + 1, __ATOMIC_RELEASE);
+ /* Steam's reader sleeps on the low half of in_write (little endian). */
+ syscall(SYS_futex, (uint32_t *)(void *)write, HKS_FUTEX_WAKE, INT_MAX, NULL, NULL, 0);
+}
+
+/* Takes up to max queued reports Steam wrote (rumble, lights, settings). */
+static inline int hks_take_out(const HkShm *m, int i, HksOut *out, int max)
+{
+ uint8_t *s = hks_slot(m, i);
+ uint64_t read = __atomic_load_n(hks_u64(s, HKS_OUT_READ), __ATOMIC_ACQUIRE);
+ uint64_t written = __atomic_load_n(hks_u64(s, HKS_OUT_WRITE), __ATOMIC_ACQUIRE);
+ int n = 0;
+ while (read < written && n < max) {
+ uint8_t *e = s + HKS_OUT + (size_t)(read % HKS_OUT_FIFO) * HKS_ENTRY;
+ if (__atomic_load_n(hks_u64(e, 0), __ATOMIC_ACQUIRE) != read + 1) break; /* still being written */
+ uint32_t len = __atomic_load_n(hks_u32(e, 8), __ATOMIC_RELAXED);
+ if (len > HKS_REPORT_MAX) len = HKS_REPORT_MAX;
+ out[n].len = (int)len;
+ out[n].kind = __atomic_load_n(hks_u32(e, 12), __ATOMIC_RELAXED);
+ memcpy(out[n].data, e + 16, len);
+ n++;
+ read++;
+ }
+ __atomic_store_n(hks_u64(s, HKS_OUT_READ), read, __ATOMIC_RELEASE);
+ return n;
+}
+
+/* A feature / input report read Steam is waiting for? (1 = yes; *last remembers what was taken.) */
+static inline int hks_get_pending(const HkShm *m, int i, uint64_t *last, uint64_t *request, int *report_id,
+ int *input, int *length)
+{
+ uint8_t *s = hks_slot(m, i);
+ uint64_t r = __atomic_load_n(hks_u64(s, HKS_GET_REQUEST), __ATOMIC_ACQUIRE);
+ if (r == *last || r == __atomic_load_n(hks_u64(s, HKS_GET_REPLY), __ATOMIC_ACQUIRE)) return 0;
+ *last = r;
+ uint32_t word = __atomic_load_n(hks_u32(s, HKS_GET_REPORT_ID), __ATOMIC_RELAXED);
+ int32_t len;
+ memcpy(&len, s + HKS_GET_LENGTH, 4);
+ *request = r;
+ *report_id = (int)(word & 0xff);
+ *input = (int)((word >> 8) & 1);
+ *length = len < 1 ? 1 : len > HKS_REPORT_MAX ? HKS_REPORT_MAX : len;
+ return 1;
+}
+
+/* Answer to that read (n <= 0: it failed). */
+static inline void hks_get_reply(const HkShm *m, int i, uint64_t request, const uint8_t *data, int n)
+{
+ uint8_t *s = hks_slot(m, i);
+ if (n < 0 || !data) n = 0;
+ if (n > HKS_REPORT_MAX) n = HKS_REPORT_MAX;
+ if (n) memcpy(s + HKS_GET, data, (size_t)n);
+ int32_t len = n;
+ __atomic_store_n((int32_t *)(void *)(s + HKS_GET_LENGTH), len, __ATOMIC_RELAXED);
+ __atomic_store_n(hks_u64(s, HKS_GET_REPLY), request, __ATOMIC_RELEASE);
+}
diff --git a/app/src/main/cpp/sdl3bridge/src/hookup.h b/app/src/main/cpp/sdl3bridge/src/hookup.h
new file mode 100644
index 000000000..54f407f32
--- /dev/null
+++ b/app/src/main/cpp/sdl3bridge/src/hookup.h
@@ -0,0 +1,714 @@
+/*
+ * "Steam Native Hook-up": instead of reading a controller as a gamepad and re-presenting it, hand Steam the
+ * REAL controller. SDL3 is still used to find out which USB HID interface is a game controller (any
+ * controller SDL3's USB driver supports); that interface is then released by SDL's gamepad layer and opened
+ * raw, and every HID report travels unchanged between the controller and DroidDeck's hidraw node -- with the
+ * controller's real vendor/product id, name, serial and report descriptor -- so Steam's own driver for that
+ * controller runs, exactly like on a PC: a Vader 4 Pro with its back buttons, C/Z and gyro, a DualSense Edge
+ * with its touchpad, mute, paddles and Fn buttons, an Xbox controller, a Switch Pro controller, ...
+ *
+ * Controllers SDL3 can only reach through Android's input (Bluetooth) cannot be handed over raw; they keep
+ * working as before (DroidDeck's Steam Deck pad). Included once from main.c; SDL_hid calls run on the SDL thread.
+ */
+#pragma once
+
+#include
+#include
+#include "hkshm.h"
+
+#define HOOKUP_SLOTS 4
+#define HOOKUP_REPORT_MAX 128
+
+typedef struct {
+ SDL_hid_device *dev;
+ char path[256];
+ Uint16 vid, pid;
+ int iface;
+} HookupDev;
+
+static HookupDev g_hk[HOOKUP_SLOTS];
+static _Atomic int g_hookup_on;
+static _Atomic int g_hookup_held; /* on, but waiting for Steam (DroidDeck's desktop without Steam running) */
+static int g_hookup_applied = -1; /* last mode the SDL thread acted on */
+static char g_hk_types[1024]; /* "0xVVVV/0xPPPP,..." controller types handed to Steam */
+static int g_hk_iface_of_type[32]; /* interface number SDL used, per entry in g_hk_types (index order) */
+static Uint16 g_hk_type_vid[32], g_hk_type_pid[32];
+static int g_hk_type_count;
+static Uint64 g_hk_scan_ms;
+static Uint32 g_hk_changes = 0xffffffffu;
+static jmethodID g_hk_added, g_hk_input, g_hk_removed, g_hk_reply, g_hk_prefetch, g_hk_log;
+
+/* ---- fast path (hkshm.h): reports go straight into DroidDeck's shared file, read by a dedicated thread ----
+ * g_hk_io is held by the reader thread for each pass over the controllers, and by anyone who remaps the file
+ * or releases a controller, so neither disappears under the reader. */
+static HkShm g_hks = { NULL, -1, 0 };
+static pthread_mutex_t g_hk_io = PTHREAD_MUTEX_INITIALIZER;
+/* Held by the writer thread while it uses the controllers (its USB writes can take a few ms, so not under g_hk_io);
+ * releasing a controller takes it first. Order: g_hk_wr, then g_hk_io. */
+static pthread_mutex_t g_hk_wr = PTHREAD_MUTEX_INITIALIZER;
+static _Atomic int g_hk_writer_started;
+static _Atomic int g_hk_gone[HOOKUP_SLOTS]; /* reader saw the controller go away; SDL thread closes it */
+static _Atomic int g_hk_reader_started;
+static _Atomic int g_hk_fast_now[HOOKUP_SLOTS]; /* slot currently served through the shared file */
+static int g_hk_fast_logged[HOOKUP_SLOTS]; /* SDL thread: last fast/slow state logged per slot */
+static uint64_t g_hk_get_last[HOOKUP_SLOTS]; /* writer thread: last feature read taken from the file */
+static Uint64 g_hk_alive_ms;
+
+/* Logged to logcat AND sent to DroidDeck, which writes it into the session's pad.log ("sdl3bridge: ..."). */
+static void hk_log(JNIEnv *env, const char *fmt, ...)
+{
+ char line[400];
+ va_list ap;
+ va_start(ap, fmt);
+ vsnprintf(line, sizeof(line), fmt, ap);
+ va_end(ap);
+ SDL_Log("%s", line);
+ if (env && g_hk_log) {
+ jstring s = (*env)->NewStringUTF(env, line);
+ if (s) {
+ (*env)->CallStaticVoidMethod(env, g_cls, g_hk_log, s);
+ (*env)->DeleteLocalRef(env, s);
+ }
+ if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
+ }
+}
+
+/* ---- requests from Java (any thread) -> SDL thread ---- */
+enum { HK_OUT = 0, HK_FEATURE_SET = 1, HK_GET_FEATURE = 2, HK_GET_INPUT = 3 };
+typedef struct {
+ int slot, type, len;
+ jlong request;
+ Uint8 data[HOOKUP_REPORT_MAX];
+} HookupReq;
+#define HK_QUEUE 128
+static HookupReq g_hk_queue[HK_QUEUE];
+static int g_hk_head, g_hk_tail;
+static SDL_SpinLock g_hk_lock;
+
+static void hk_push(int slot, int type, const Uint8 *data, int len, jlong request)
+{
+ if (slot < 0 || slot >= HOOKUP_SLOTS) return;
+ if (len < 0) len = 0;
+ if (len > HOOKUP_REPORT_MAX) len = HOOKUP_REPORT_MAX;
+ SDL_LockSpinlock(&g_hk_lock);
+ int next = (g_hk_tail + 1) % HK_QUEUE;
+ if (next != g_hk_head) {
+ HookupReq *r = &g_hk_queue[g_hk_tail];
+ r->slot = slot; r->type = type; r->len = len; r->request = request;
+ if (len) memcpy(r->data, data, (size_t)len);
+ g_hk_tail = next;
+ }
+ SDL_UnlockSpinlock(&g_hk_lock);
+}
+
+JNIEXPORT void JNICALL Java_com_example_dsedge_NativeBridge_nativeSetHookup(JNIEnv *env, jclass c, jint on)
+{
+ (void)env; (void)c;
+ atomic_store(&g_hookup_held, on == 2); /* DroidDeck's desktop without Steam: wait (controllers stay gamepads) */
+ atomic_store(&g_hookup_on, on == 1);
+}
+
+/* DroidDeck handed over its shared hook-up file (any thread). Returns 0 when the fast path is on. */
+JNIEXPORT jint JNICALL Java_com_example_dsedge_NativeBridge_nativeHookupMapShm(JNIEnv *env, jclass c, jint fd)
+{
+ (void)c;
+ HkShm m = { NULL, -1, 0 };
+ int r = hks_map(&m, fd);
+ pthread_mutex_lock(&g_hk_io);
+ int same = r == 0 && g_hks.base && g_hks.ino == m.ino && hks_alive(&g_hks);
+ if (r == 0 && !same) {
+ hks_unmap(&g_hks);
+ g_hks = m;
+ }
+ pthread_mutex_unlock(&g_hk_io);
+ if (same) hks_unmap(&m); /* already mapped: keep the old mapping */
+ if (r == 0 && !same)
+ hk_log(env, "hookup: fast path on: reports go straight into DroidDeck's shared memory (no messages, Steam woken at once)");
+ else if (r != 0)
+ hk_log(env, "hookup: fast path unavailable (shared file could not be mapped, %d); using messages", r);
+ return r;
+}
+
+/* Inode of the mapped live file (0: none) -- DroidDeck compares it with its own file to know if it must resend. */
+JNIEXPORT jlong JNICALL Java_com_example_dsedge_NativeBridge_nativeHookupShmInode(JNIEnv *env, jclass c)
+{
+ (void)env; (void)c;
+ pthread_mutex_lock(&g_hk_io);
+ jlong ino = hks_alive(&g_hks) ? (jlong)g_hks.ino : 0;
+ pthread_mutex_unlock(&g_hk_io);
+ return ino;
+}
+
+JNIEXPORT void JNICALL Java_com_example_dsedge_NativeBridge_nativeHookupOutput(JNIEnv *env, jclass c, jint slot, jint kind, jbyteArray data)
+{
+ (void)c;
+ if (!data) return;
+ jsize n = (*env)->GetArrayLength(env, data);
+ if (n < 1) return;
+ Uint8 buf[HOOKUP_REPORT_MAX];
+ if (n > HOOKUP_REPORT_MAX) n = HOOKUP_REPORT_MAX;
+ (*env)->GetByteArrayRegion(env, data, 0, n, (jbyte *)buf);
+ hk_push(slot, kind == 1 ? HK_FEATURE_SET : HK_OUT, buf, (int)n, 0);
+}
+
+JNIEXPORT void JNICALL Java_com_example_dsedge_NativeBridge_nativeHookupGet(JNIEnv *env, jclass c, jint slot, jlong request,
+ jint reportId, jboolean input, jint length)
+{
+ (void)env; (void)c;
+ Uint8 buf[2] = { (Uint8)reportId, (Uint8)(length > HOOKUP_REPORT_MAX ? HOOKUP_REPORT_MAX : length < 1 ? 1 : length) };
+ hk_push(slot, input ? HK_GET_INPUT : HK_GET_FEATURE, buf, 2, request);
+}
+
+/* ---- SDL thread -> Java ---- */
+static void hk_java_init(JNIEnv *env)
+{
+ if (g_hk_added) return;
+ g_hk_added = (*env)->GetStaticMethodID(env, g_cls, "onHookupAdded", "(ILjava/lang/String;III)V");
+ g_hk_input = (*env)->GetStaticMethodID(env, g_cls, "onHookupInput", "(I[B)V");
+ g_hk_removed = (*env)->GetStaticMethodID(env, g_cls, "onHookupRemoved", "(I)V");
+ g_hk_reply = (*env)->GetStaticMethodID(env, g_cls, "onHookupReply", "(IJ[B)V");
+ g_hk_prefetch = (*env)->GetStaticMethodID(env, g_cls, "prefetchHookupDescriptor", "(Ljava/lang/String;)V");
+ g_hk_log = (*env)->GetStaticMethodID(env, g_cls, "onBridgeLog", "(Ljava/lang/String;)V");
+ if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
+}
+
+static jbyteArray hk_bytes(JNIEnv *env, const Uint8 *data, int len)
+{
+ if (!data || len <= 0) return NULL;
+ jbyteArray arr = (*env)->NewByteArray(env, len);
+ if (arr) (*env)->SetByteArrayRegion(env, arr, 0, len, (const jbyte *)data);
+ return arr;
+}
+
+static bool hookup_active(void)
+{
+ for (int i = 0; i < HOOKUP_SLOTS; i++) if (g_hk[i].dev) return true;
+ return false;
+}
+
+static int hookup_count(void)
+{
+ int n = 0;
+ for (int i = 0; i < HOOKUP_SLOTS; i++) if (g_hk[i].dev) n++;
+ return n;
+}
+
+/* Hooked-up controllers whose reports currently go straight into DroidDeck's shared memory. */
+static int hookup_fast_count(void)
+{
+ int n = 0;
+ for (int i = 0; i < HOOKUP_SLOTS; i++) if (g_hk[i].dev && atomic_load(&g_hk_fast_now[i])) n++;
+ return n;
+}
+
+static void hk_close(JNIEnv *env, int i, const char *why)
+{
+ if (!g_hk[i].dev) return;
+ hk_log(env, "hookup: slot %d %04x:%04x released (%s)", i, g_hk[i].vid, g_hk[i].pid, why);
+ pthread_mutex_lock(&g_hk_wr); /* the writer thread is not using it now, and won't see it again */
+ pthread_mutex_lock(&g_hk_io); /* nor is the reader thread */
+ SDL_hid_device *dev = g_hk[i].dev;
+ memset(&g_hk[i], 0, sizeof(g_hk[i]));
+ atomic_store(&g_hk_gone[i], 0);
+ atomic_store(&g_hk_fast_now[i], 0);
+ pthread_mutex_unlock(&g_hk_io);
+ pthread_mutex_unlock(&g_hk_wr);
+ g_hk_fast_logged[i] = 0;
+ SDL_hid_close(dev);
+ if (g_hk_removed) (*env)->CallStaticVoidMethod(env, g_cls, g_hk_removed, (jint)i);
+ if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
+}
+
+static bool hk_path_open(const char *path)
+{
+ for (int i = 0; i < HOOKUP_SLOTS; i++) if (g_hk[i].dev && !strcmp(g_hk[i].path, path)) return true;
+ return false;
+}
+
+/*
+ * Reader thread: takes each report the moment SDL's USB thread has queued it (checked every 250 us instead of
+ * once per main-loop pass) and writes it straight into Steam's FIFO, waking Steam's reader. When the fast path
+ * is not available for a slot, the report goes the old way (Java -> message to DroidDeck).
+ */
+static int SDLCALL hk_reader(void *unused)
+{
+ (void)unused;
+ JNIEnv *env = (JNIEnv *)SDL_GetAndroidJNIEnv();
+ setpriority(PRIO_PROCESS, (id_t)syscall(SYS_gettid), -10); /* like Android's URGENT_DISPLAY; ignored if refused */
+ /* Latency meter, every controller: do reports come from Android's USB layer one by one, or in bursts (several
+ * waiting at once = they were held up before the bridge got them)? Logged every 10 s: the first time, then
+ * when bursts or long gaps show up. */
+ uint32_t m_reports[HOOKUP_SLOTS] = {0}, m_passes[HOOKUP_SLOTS] = {0}, m_burst[HOOKUP_SLOTS] = {0};
+ uint64_t m_last_us[HOOKUP_SLOTS] = {0}, m_gap_us[HOOKUP_SLOTS] = {0}, m_since_us = 0;
+ int m_logged[HOOKUP_SLOTS] = {0};
+ for (;;) {
+ int got = 0, any = 0;
+ struct timespec ts;
+ clock_gettime(CLOCK_MONOTONIC, &ts);
+ uint64_t now_us = (uint64_t)ts.tv_sec * 1000000u + (uint64_t)ts.tv_nsec / 1000u;
+ pthread_mutex_lock(&g_hk_io);
+ for (int i = 0; i < HOOKUP_SLOTS; i++) {
+ SDL_hid_device *dev = g_hk[i].dev;
+ if (!dev || atomic_load(&g_hk_gone[i])) continue;
+ any = 1;
+ int fast = hks_owns(&g_hks, i, g_hk[i].vid, g_hk[i].pid);
+ atomic_store(&g_hk_fast_now[i], fast);
+ uint32_t pass = 0;
+ for (int reads = 0; reads < 64; reads++) {
+ Uint8 buf[HOOKUP_REPORT_MAX];
+ int n = SDL_hid_read(dev, buf, sizeof(buf));
+ if (n == 0) break;
+ if (n < 0) { atomic_store(&g_hk_gone[i], 1); break; }
+ got++;
+ pass++;
+ if (fast) {
+ hks_write_in(&g_hks, i, buf, n);
+ } else if (env && g_hk_input) {
+ jbyteArray arr = hk_bytes(env, buf, n);
+ if (arr) (*env)->CallStaticVoidMethod(env, g_cls, g_hk_input, (jint)i, arr);
+ if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
+ if (arr) (*env)->DeleteLocalRef(env, arr);
+ }
+ }
+ if (pass) {
+ m_reports[i] += pass;
+ m_passes[i]++;
+ if (pass > m_burst[i]) m_burst[i] = pass;
+ if (m_last_us[i] && now_us - m_last_us[i] > m_gap_us[i]) m_gap_us[i] = now_us - m_last_us[i];
+ m_last_us[i] = now_us;
+ }
+ }
+ pthread_mutex_unlock(&g_hk_io);
+ if (!m_since_us) m_since_us = now_us;
+ if (now_us - m_since_us >= 10000000u) {
+ double secs = (now_us - m_since_us) / 1e6;
+ for (int i = 0; i < HOOKUP_SLOTS; i++) {
+ if (!m_passes[i]) continue;
+ if (env && (!m_logged[i]++ || m_burst[i] > 2 || m_gap_us[i] > 8000))
+ hk_log(env, "latency: slot %d: %.0f reports/s from USB, up to %u waiting at once (avg %.2f), "
+ "longest gap %.1f ms", i, m_reports[i] / secs, m_burst[i],
+ (double)m_reports[i] / m_passes[i], m_gap_us[i] / 1000.0);
+ m_reports[i] = m_passes[i] = m_burst[i] = 0;
+ m_gap_us[i] = 0;
+ }
+ m_since_us = now_us;
+ }
+ if (!got) {
+ struct timespec t = { 0, any ? 250000L : 20000000L }; /* 250 us while controllers are handed over */
+ nanosleep(&t, NULL);
+ }
+ }
+ return 0;
+}
+
+/*
+ * Writer thread: Steam's output reports (rumble, lights, settings) and feature reads for the slots the bridge
+ * serves through the shared file, sent to the controller as soon as Steam queues them (checked every 500 us).
+ * Each USB write takes a few ms; done in the SDL main loop they waited for its next pass and for each other,
+ * so under heavy rumble Steam's queue filled up and Steam had to wait for room.
+ */
+static int SDLCALL hk_writer(void *unused)
+{
+ (void)unused;
+ setpriority(PRIO_PROCESS, (id_t)syscall(SYS_gettid), -10);
+ for (;;) {
+ int did = 0, any = 0;
+ pthread_mutex_lock(&g_hk_wr);
+ for (int i = 0; i < HOOKUP_SLOTS; i++) {
+ HksOut outs[16];
+ int nout = 0, want_get = 0, report_id = 0, input = 0, length = 0;
+ uint64_t request = 0;
+ pthread_mutex_lock(&g_hk_io);
+ SDL_hid_device *dev = g_hk[i].dev;
+ if (dev && !atomic_load(&g_hk_gone[i]) && hks_owns(&g_hks, i, g_hk[i].vid, g_hk[i].pid)) {
+ nout = hks_take_out(&g_hks, i, outs, (int)SDL_arraysize(outs));
+ want_get = hks_get_pending(&g_hks, i, &g_hk_get_last[i], &request, &report_id, &input, &length);
+ }
+ pthread_mutex_unlock(&g_hk_io);
+ if (!dev) continue;
+ any = 1;
+ for (int k = 0; k < nout; k++) {
+ if (outs[k].kind == 1) SDL_hid_send_feature_report(dev, outs[k].data, (size_t)outs[k].len);
+ else SDL_hid_write(dev, outs[k].data, (size_t)outs[k].len);
+ }
+ if (want_get) {
+ Uint8 buf[HOOKUP_REPORT_MAX + 1] = { 0 };
+ buf[0] = (Uint8)report_id;
+ int n = input ? SDL_hid_get_input_report(dev, buf, (size_t)length)
+ : SDL_hid_get_feature_report(dev, buf, (size_t)length);
+ pthread_mutex_lock(&g_hk_io);
+ if (hks_owns(&g_hks, i, g_hk[i].vid, g_hk[i].pid)) hks_get_reply(&g_hks, i, request, buf, n);
+ pthread_mutex_unlock(&g_hk_io);
+ }
+ did += nout + want_get;
+ }
+ pthread_mutex_unlock(&g_hk_wr);
+ if (!did) {
+ struct timespec t = { 0, any ? 500000L : 20000000L };
+ nanosleep(&t, NULL);
+ }
+ }
+ return 0;
+}
+
+static void hk_start_reader(void)
+{
+ if (!atomic_exchange(&g_hk_writer_started, 1)) {
+ SDL_Thread *w = SDL_CreateThread(hk_writer, "hookup-writer", NULL);
+ if (w) SDL_DetachThread(w);
+ else atomic_store(&g_hk_writer_started, 0);
+ }
+ if (atomic_exchange(&g_hk_reader_started, 1)) return;
+ SDL_Thread *t = SDL_CreateThread(hk_reader, "hookup-reader", NULL);
+ if (t) SDL_DetachThread(t);
+ else atomic_store(&g_hk_reader_started, 0);
+}
+
+/* SDL thread, every loop: the heartbeat that tells DroidDeck and Steam's side the bridge serves the slot itself. */
+static void hk_serve_fast(JNIEnv *env)
+{
+ uint64_t now = hks_now_ms();
+ for (int i = 0; i < HOOKUP_SLOTS; i++) {
+ if (!g_hk[i].dev) continue;
+ pthread_mutex_lock(&g_hk_io);
+ int fast = hks_owns(&g_hks, i, g_hk[i].vid, g_hk[i].pid);
+ if (fast) {
+ hks_heartbeat(&g_hks, i, now);
+ int ms = atomic_load(&g_poll_ms);
+ hks_set_rate(&g_hks, i, ms > 0 ? (uint32_t)(1000 / ms) : 0u); /* Poll rate also paces Hook-up */
+ }
+ pthread_mutex_unlock(&g_hk_io);
+ if (fast != g_hk_fast_logged[i]) {
+ g_hk_fast_logged[i] = fast;
+ if (fast) hk_log(env, "hookup: slot %d served through shared memory (fast path)", i);
+ }
+ }
+}
+
+static int hk_free_slot(void)
+{
+ for (int i = 0; i < HOOKUP_SLOTS; i++) if (!g_hk[i].dev) return i;
+ return -1;
+}
+
+/* Read the HID report descriptor while nobody holds the interface (Android refuses an interface request on an
+ * interface another connection has claimed), so Steam gets the controller's real one. */
+static void hk_prefetch(JNIEnv *env, const char *path)
+{
+ if (!g_hk_prefetch) return;
+ jstring p = (*env)->NewStringUTF(env, path);
+ (*env)->CallStaticVoidMethod(env, g_cls, g_hk_prefetch, p);
+ (*env)->DeleteLocalRef(env, p);
+ if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
+}
+
+static bool hk_adopt(JNIEnv *env, int slot, SDL_hid_device *dev, const char *path, Uint16 vid, Uint16 pid, int iface);
+
+static bool hk_open(JNIEnv *env, const char *path, Uint16 vid, Uint16 pid, int iface)
+{
+ if (hk_path_open(path)) return true;
+ int slot = hk_free_slot();
+ if (slot < 0) return false;
+ hk_prefetch(env, path);
+ SDL_hid_device *dev = SDL_hid_open_path(path);
+ if (!dev) {
+ hk_log(env, "hookup: could not open %s: %s", path, SDL_GetError());
+ return false;
+ }
+ SDL_hid_set_nonblocking(dev, 1);
+ return hk_adopt(env, slot, dev, path, vid, pid, iface);
+}
+
+/* A raw-opened controller interface becomes hook-up slot `slot` (reader thread + DroidDeck's hidraw node). */
+static bool hk_adopt(JNIEnv *env, int slot, SDL_hid_device *dev, const char *path, Uint16 vid, Uint16 pid, int iface)
+{
+ pthread_mutex_lock(&g_hk_io);
+ SDL_strlcpy(g_hk[slot].path, path, sizeof(g_hk[slot].path));
+ g_hk[slot].vid = vid;
+ g_hk[slot].pid = pid;
+ g_hk[slot].iface = iface;
+ atomic_store(&g_hk_gone[slot], 0);
+ g_hk_get_last[slot] = 0;
+ g_hk[slot].dev = dev;
+ pthread_mutex_unlock(&g_hk_io);
+ hk_start_reader();
+ hk_log(env, "hookup: slot %d is %04x:%04x interface %d (%s), handing it to DroidDeck", slot, vid, pid, iface, path);
+ if (g_hk_added) {
+ jstring p = (*env)->NewStringUTF(env, path);
+ (*env)->CallStaticVoidMethod(env, g_cls, g_hk_added, (jint)slot, p, (jint)vid, (jint)pid, (jint)iface);
+ (*env)->DeleteLocalRef(env, p);
+ if ((*env)->ExceptionCheck(env)) {
+ (*env)->ExceptionDescribe(env);
+ (*env)->ExceptionClear(env);
+ hk_log(env, "hookup: slot %d: describing the controller to DroidDeck failed (Java exception, see logcat)", slot);
+ }
+ }
+ return true;
+}
+
+/* SDL's gamepad layer must let go of the hooked-up controller types: the ignore hint (which also keeps the
+ * Steam Controller 2026, passed through by triton.h, away) plus a restart of the gamepad subsystem. */
+static void hk_apply_ignore_list(JNIEnv *env)
+{
+ char hint[1200] = "0x28de/0x1302,0x28de/0x1304,0x28de/0x1305";
+ if (atomic_load(&g_hookup_on) && g_hk_types[0]) {
+ SDL_strlcat(hint, ",", sizeof(hint));
+ SDL_strlcat(hint, g_hk_types, sizeof(hint));
+ }
+ SDL_SetHint(SDL_HINT_GAMECONTROLLER_IGNORE_DEVICES, hint);
+}
+
+static bool hk_type_known(Uint16 vid, Uint16 pid)
+{
+ for (int i = 0; i < g_hk_type_count; i++) if (g_hk_type_vid[i] == vid && g_hk_type_pid[i] == pid) return true;
+ return false;
+}
+
+static void hk_add_type(Uint16 vid, Uint16 pid, int iface)
+{
+ if (hk_type_known(vid, pid) || g_hk_type_count >= (int)SDL_arraysize(g_hk_type_vid)) return;
+ char one[24];
+ snprintf(one, sizeof(one), "%s0x%04x/0x%04x", g_hk_types[0] ? "," : "", vid, pid);
+ SDL_strlcat(g_hk_types, one, sizeof(g_hk_types));
+ g_hk_type_vid[g_hk_type_count] = vid;
+ g_hk_type_pid[g_hk_type_count] = pid;
+ g_hk_iface_of_type[g_hk_type_count] = iface;
+ g_hk_type_count++;
+}
+
+static int hk_type_iface(Uint16 vid, Uint16 pid)
+{
+ for (int i = 0; i < g_hk_type_count; i++) if (g_hk_type_vid[i] == vid && g_hk_type_pid[i] == pid) return g_hk_iface_of_type[i];
+ return -1;
+}
+
+static void restart_gamepads(JNIEnv *env); /* main.c */
+
+/* Interface number of an SDL hid path ("///" on Android). */
+static int hk_path_iface(const char *path)
+{
+ const char *slash = strrchr(path, '/');
+ return slash ? SDL_atoi(slash + 1) : 0;
+}
+
+/* Hand every SDL3 USB-driver gamepad (and later copies of those controller types) over to Steam. */
+static void hookup_take_gamepads(JNIEnv *env)
+{
+ bool handed = false;
+ int cnt = 0;
+ SDL_JoystickID *ids = SDL_GetGamepads(&cnt);
+ for (int i = 0; ids && i < cnt; i++) {
+ const char *path = SDL_GetGamepadPathForID(ids[i]);
+ if (!path) continue; /* Android input (Bluetooth): cannot be handed over raw */
+ Uint16 vid = SDL_GetGamepadVendorForID(ids[i]), pid = SDL_GetGamepadProductForID(ids[i]);
+ if (hk_type_known(vid, pid)) continue;
+ hk_log(env, "hookup: taking %04x:%04x \"%s\" from SDL's gamepad layer", vid, pid,
+ SDL_GetGamepadNameForID(ids[i]) ? SDL_GetGamepadNameForID(ids[i]) : "?");
+ hk_add_type(vid, pid, hk_path_iface(path));
+ handed = true;
+ }
+ SDL_free(ids);
+ if (handed) {
+ hk_log(env, "hookup: restarting SDL's gamepad layer so it lets go (ignored: %s)", g_hk_types);
+ hk_apply_ignore_list(env);
+ restart_gamepads(env); /* SDL closes them; they stay ignored; the scan below opens them raw */
+ g_hk_changes = 0xffffffffu;
+ }
+}
+
+static void hookup_scan(JNIEnv *env)
+{
+ if (!g_hk_type_count) return;
+ SDL_hid_device_info *list = SDL_hid_enumerate(0, 0);
+ for (SDL_hid_device_info *d = list; d; d = d->next) {
+ if (!d->path || !hk_type_known(d->vendor_id, d->product_id)) continue;
+ int want = hk_type_iface(d->vendor_id, d->product_id);
+ int iface = hk_path_iface(d->path);
+ if (want >= 0 && iface != want) continue;
+ if (d->bus_type == SDL_HID_API_BUS_BLUETOOTH) continue;
+ if (!hk_path_open(d->path)) hk_log(env, "hookup: found %04x:%04x at %s, opening it raw", d->vendor_id, d->product_id, d->path);
+ hk_open(env, d->path, d->vendor_id, d->product_id, iface);
+ }
+ SDL_hid_free_enumeration(list);
+}
+
+static void hookup_run_queue(JNIEnv *env)
+{
+ for (;;) {
+ HookupReq r;
+ SDL_LockSpinlock(&g_hk_lock);
+ bool have = g_hk_head != g_hk_tail;
+ if (have) { r = g_hk_queue[g_hk_head]; g_hk_head = (g_hk_head + 1) % HK_QUEUE; }
+ SDL_UnlockSpinlock(&g_hk_lock);
+ if (!have) return;
+ SDL_hid_device *dev = g_hk[r.slot].dev;
+ if (r.type == HK_GET_FEATURE || r.type == HK_GET_INPUT) {
+ Uint8 buf[HOOKUP_REPORT_MAX + 1] = { 0 };
+ int want = r.data[1] ? r.data[1] : HOOKUP_REPORT_MAX;
+ buf[0] = r.data[0];
+ int n = -1;
+ if (dev) n = r.type == HK_GET_INPUT ? SDL_hid_get_input_report(dev, buf, (size_t)want)
+ : SDL_hid_get_feature_report(dev, buf, (size_t)want);
+ jbyteArray arr = n > 0 ? hk_bytes(env, buf, n) : NULL;
+ if (g_hk_reply) (*env)->CallStaticVoidMethod(env, g_cls, g_hk_reply, (jint)r.slot, r.request, arr);
+ if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
+ if (arr) (*env)->DeleteLocalRef(env, arr);
+ } else if (dev && r.type == HK_FEATURE_SET) {
+ SDL_hid_send_feature_report(dev, r.data, (size_t)r.len);
+ } else if (dev && r.type == HK_OUT) {
+ SDL_hid_write(dev, r.data, (size_t)r.len);
+ }
+ }
+}
+
+/*
+ * Steam Controller (2026): cable 28de:1302, wireless puck 28de:1304/1305 (one controller per interface 2..5).
+ * SDL's gamepad layer never opens it (it is always on the ignore list; outside hook-up, triton.h passes it
+ * through to DroidDeck's own Steam Controller node), so hook-up finds it itself. Android doesn't say which of
+ * its HID interfaces is the controller, so the candidates are opened and watched, and the one that carries a
+ * controller -- state reports, or the puck announcing a connected controller -- is handed to Steam as itself,
+ * exactly like on a PC (Steam drives lizard mode, haptics, trackpads, grip sense, IMU). Only interfaces with a
+ * controller get a hook-up slot, so a puck doesn't use up all four.
+ */
+#define HK_WATCH_MAX 6
+typedef struct {
+ SDL_hid_device *dev;
+ char path[256];
+ Uint16 pid;
+ int iface;
+} HkWatch;
+static HkWatch g_hkw[HK_WATCH_MAX];
+static int g_hkw_full_logged;
+
+static bool hk_triton_puck(Uint16 pid) { return pid == 0x1304 || pid == 0x1305; }
+
+static bool hk_watching(const char *path)
+{
+ for (int w = 0; w < HK_WATCH_MAX; w++) if (g_hkw[w].dev && !strcmp(g_hkw[w].path, path)) return true;
+ return false;
+}
+
+static void hk_watch_close_all(void)
+{
+ for (int w = 0; w < HK_WATCH_MAX; w++) {
+ if (g_hkw[w].dev) SDL_hid_close(g_hkw[w].dev);
+ memset(&g_hkw[w], 0, sizeof(g_hkw[w]));
+ }
+}
+
+static void hk_valve_scan(JNIEnv *env)
+{
+ SDL_hid_device_info *list = SDL_hid_enumerate(VALVE_VID, 0);
+ for (SDL_hid_device_info *d = list; d; d = d->next) {
+ if (!d->path || !triton_pid(d->product_id)) continue;
+ if (hk_triton_puck(d->product_id) && (d->interface_number < 2 || d->interface_number > 5)) continue;
+ if (d->interface_class == 3 && d->interface_subclass == 1) continue; /* boot keyboard / mouse (lizard mode) */
+ if (hk_path_open(d->path) || hk_watching(d->path)) continue;
+ int w = -1;
+ for (int k = 0; k < HK_WATCH_MAX; k++) if (!g_hkw[k].dev) { w = k; break; }
+ if (w < 0) break;
+ hk_prefetch(env, d->path); /* before it is opened: the descriptor can only be read while nobody holds it */
+ SDL_hid_device *dev = SDL_hid_open_path(d->path);
+ if (!dev) {
+ hk_log(env, "hookup: could not open Steam Controller (2026) interface %d: %s", d->interface_number, SDL_GetError());
+ continue;
+ }
+ SDL_hid_set_nonblocking(dev, 1);
+ g_hkw[w].dev = dev;
+ SDL_strlcpy(g_hkw[w].path, d->path, sizeof(g_hkw[w].path));
+ g_hkw[w].pid = d->product_id;
+ g_hkw[w].iface = d->interface_number;
+ if (!hk_triton_puck(d->product_id)) tri_lizard_off(dev); /* no stray mouse input until Steam owns it */
+ hk_log(env, "hookup: Steam Controller (2026) %s, interface %d: waiting for controller data",
+ hk_triton_puck(d->product_id) ? "puck" : "cable", d->interface_number);
+ }
+ SDL_hid_free_enumeration(list);
+}
+
+static void hk_valve_poll(JNIEnv *env)
+{
+ for (int w = 0; w < HK_WATCH_MAX; w++) {
+ for (int reads = 0; g_hkw[w].dev && reads < 16; reads++) {
+ Uint8 buf[HOOKUP_REPORT_MAX];
+ int n = SDL_hid_read(g_hkw[w].dev, buf, sizeof(buf));
+ if (n == 0) break;
+ if (n < 0) {
+ hk_log(env, "hookup: Steam Controller (2026) interface %d gone", g_hkw[w].iface);
+ SDL_hid_close(g_hkw[w].dev);
+ memset(&g_hkw[w], 0, sizeof(g_hkw[w]));
+ break;
+ }
+ Uint8 id = buf[0];
+ bool controller = id == 0x42 || id == 0x45 || id == 0x47 || ((id == 0x79 || id == 0x46) && n >= 2 && buf[1] == 2);
+ if (!controller) continue;
+ int slot = hk_free_slot();
+ if (slot < 0) {
+ if (!g_hkw_full_logged) hk_log(env, "hookup: a Steam Controller (2026) is ready but all %d hook-up slots are in use", HOOKUP_SLOTS);
+ g_hkw_full_logged = 1;
+ break;
+ }
+ g_hkw_full_logged = 0;
+ HkWatch h = g_hkw[w];
+ memset(&g_hkw[w], 0, sizeof(g_hkw[w])); /* the handle moves to the hook-up slot */
+ hk_log(env, "hookup: Steam Controller (2026) on %s interface %d goes to Steam natively",
+ hk_triton_puck(h.pid) ? "the puck," : "the cable,", h.iface);
+ hk_adopt(env, slot, h.dev, h.path, VALVE_VID, h.pid, h.iface);
+ }
+ }
+}
+
+/* Why triton.h must not pass a Steam Controller (2026) through right now (NULL: it may). Hook-up hands it to
+ * Steam itself while on -- and until it has actually released it after being switched off, so the two never
+ * hold the same interface. */
+static const char *hookup_triton_off_why(void)
+{
+ return atomic_load(&g_hookup_on) || g_hookup_applied == 1 ? "handed to Steam natively (Steam Native Hook-up)" : NULL;
+}
+
+static void hookup_poll(JNIEnv *env, Uint64 now, bool driver_off)
+{
+ hk_java_init(env);
+ int on = atomic_load(&g_hookup_on) && !driver_off;
+ if (on != g_hookup_applied) {
+ int was = g_hookup_applied;
+ g_hookup_applied = on;
+ hk_log(env, "hookup: Steam Native Hook-up %s", on ? "ON" : "OFF");
+ if (on) g_hk_changes = 0xffffffffu; /* look for controllers (incl. a Steam Controller 2026) right away */
+ if (!on && was == 1) {
+ for (int i = 0; i < HOOKUP_SLOTS; i++) hk_close(env, i, "Steam Native Hook-up off");
+ hk_watch_close_all();
+ g_tri_changes = 0xffffffffu; /* triton.h takes a Steam Controller (2026) back at once */
+ g_hk_types[0] = 0;
+ g_hk_type_count = 0;
+ hk_apply_ignore_list(env);
+ restart_gamepads(env); /* SDL takes the controllers back as gamepads */
+ }
+ }
+ if (!on) {
+ hookup_run_queue(env); /* answer stale reads */
+ return;
+ }
+ static Uint64 last_take;
+ if (now - last_take > 250) { last_take = now; hookup_take_gamepads(env); }
+ Uint32 changes = SDL_hid_device_change_count();
+ if (changes != g_hk_changes || now - g_hk_scan_ms > 2000) {
+ g_hk_changes = changes;
+ g_hk_scan_ms = now;
+ hookup_scan(env);
+ hk_valve_scan(env);
+ }
+ hk_valve_poll(env);
+ /* reports are read by hk_reader(); here: controllers it saw go away, and Steam's side of the fast path */
+ for (int i = 0; i < HOOKUP_SLOTS; i++)
+ if (g_hk[i].dev && atomic_load(&g_hk_gone[i])) hk_close(env, i, "unplugged");
+ if (now - g_hk_alive_ms > 500) {
+ g_hk_alive_ms = now;
+ int dropped = 0;
+ pthread_mutex_lock(&g_hk_io);
+ if (g_hks.base && !hks_alive(&g_hks)) { hks_unmap(&g_hks); dropped = 1; }
+ pthread_mutex_unlock(&g_hk_io);
+ if (dropped) hk_log(env, "hookup: DroidDeck's session ended; fast path off until it hands over its new shared file");
+ }
+ hk_serve_fast(env);
+ hookup_run_queue(env);
+}
diff --git a/app/src/main/cpp/sdl3bridge/src/main.c b/app/src/main/cpp/sdl3bridge/src/main.c
new file mode 100644
index 000000000..773cdbd1f
--- /dev/null
+++ b/app/src/main/cpp/sdl3bridge/src/main.c
@@ -0,0 +1,637 @@
+/*
+ * SDL3 controller loop. Reads the first gamepad (DualSense, DualSense Edge, Xbox, Switch Pro, ... anything SDL3
+ * supports) and pushes frames to Java (NativeBridge.onFrame). Rumble/lightbar requests come back through
+ * NativeBridge.nativeSetOutput.
+ */
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define FRAME_LEN 22
+#define MAX_PADS 4
+
+static JavaVM *g_vm;
+static jclass g_cls;
+static jmethodID g_frame, g_status, g_gone, g_info, g_caps;
+
+/* Per-slot output state, written from Java threads, read by the SDL thread. */
+static _Atomic int g_rl[MAX_PADS], g_rr[MAX_PADS];
+static _Atomic int g_rumble_dirty[MAX_PADS];
+static _Atomic int g_led_dirty[MAX_PADS];
+static _Atomic int g_led_r[MAX_PADS], g_led_g[MAX_PADS], g_led_b[MAX_PADS]; /* per-slot lightbar colour */
+static _Atomic int g_player_idx[MAX_PADS]; /* per-slot player LEDs: -1 = off, 0..3 = player 1..4 */
+static _Atomic int g_player_dirty[MAX_PADS];
+
+JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *reserved)
+{
+ (void)reserved;
+ JNIEnv *env;
+ if ((*vm)->GetEnv(vm, (void **)&env, JNI_VERSION_1_6) != JNI_OK) return JNI_ERR;
+ jclass c = (*env)->FindClass(env, "com/example/dsedge/NativeBridge");
+ if (!c) return JNI_ERR;
+ g_cls = (jclass)(*env)->NewGlobalRef(env, c);
+ g_frame = (*env)->GetStaticMethodID(env, g_cls, "onFrame", "([FI)V");
+ g_status = (*env)->GetStaticMethodID(env, g_cls, "onStatus", "(Ljava/lang/String;)V");
+ g_gone = (*env)->GetStaticMethodID(env, g_cls, "onPadGone", "(I)V");
+ g_info = (*env)->GetStaticMethodID(env, g_cls, "onPadInfo", "(IIILjava/lang/String;)V");
+ g_caps = (*env)->GetStaticMethodID(env, g_cls, "onPadCaps", "(IIIILjava/lang/String;)V");
+ g_vm = vm;
+ return JNI_VERSION_1_6;
+}
+
+/* Slot 0: rumble + lightbar colour (the user's light settings). */
+JNIEXPORT void JNICALL Java_com_example_dsedge_NativeBridge_nativeSetLed(
+ JNIEnv *env, jclass cls, jint slot, jint r, jint g, jint b)
+{
+ (void)env; (void)cls;
+ if (slot < 0 || slot >= MAX_PADS) return;
+ atomic_store(&g_led_r[slot], r); atomic_store(&g_led_g[slot], g); atomic_store(&g_led_b[slot], b);
+ atomic_store(&g_led_dirty[slot], 1);
+}
+
+/* Rumble for any slot (0..3). */
+JNIEXPORT void JNICALL Java_com_example_dsedge_NativeBridge_nativeSetRumble(
+ JNIEnv *env, jclass cls, jint slot, jint rl, jint rr)
+{
+ (void)env; (void)cls;
+ if (slot < 0 || slot >= MAX_PADS) return;
+ atomic_store(&g_rl[slot], rl); atomic_store(&g_rr[slot], rr);
+ atomic_store(&g_rumble_dirty[slot], 1);
+}
+
+JNIEXPORT void JNICALL Java_com_example_dsedge_NativeBridge_nativeSetPlayerLed(JNIEnv *env, jclass cls, jint slot, jint index)
+{
+ (void)env; (void)cls;
+ if (slot < 0 || slot >= MAX_PADS) return;
+ atomic_store(&g_player_idx[slot], index);
+ atomic_store(&g_player_dirty[slot], 1);
+}
+
+static atomic_int g_force = 1; /* 1 = only accept pads served by SDL's own USB (HIDAPI) driver */
+static atomic_int g_rescan = 0;
+
+JNIEXPORT void JNICALL Java_com_example_dsedge_NativeBridge_nativeSetForce(JNIEnv *env, jclass c, jint on)
+{
+ (void)env; (void)c;
+ atomic_store(&g_force, on ? 1 : 0);
+}
+
+JNIEXPORT void JNICALL Java_com_example_dsedge_NativeBridge_nativeRescan(JNIEnv *env, jclass c)
+{
+ (void)env; (void)c;
+ atomic_store(&g_rescan, 1);
+}
+
+static atomic_int g_driver_off = 0; /* 1 = SDL3 (HIDAPI) driver disabled: release the controller to Android */
+static atomic_int g_poll_ms = 2; /* loop period: 2ms = 500Hz (lower = lower latency, more CPU) */
+
+JNIEXPORT void JNICALL Java_com_example_dsedge_NativeBridge_nativeSetPollMs(JNIEnv *env, jclass c, jint ms)
+{
+ (void)env; (void)c;
+ if (ms < 1) ms = 1;
+ if (ms > 16) ms = 16;
+ atomic_store(&g_poll_ms, ms);
+}
+
+/* Hard on/off for the SDL3 driver. Off disables HIDAPI and releases the controller; on re-enables it. */
+JNIEXPORT void JNICALL Java_com_example_dsedge_NativeBridge_nativeSetDriver(JNIEnv *env, jclass c, jint on)
+{
+ (void)env; (void)c;
+ atomic_store(&g_driver_off, on ? 0 : 1);
+ SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI, on ? "1" : "0");
+ atomic_store(&g_rescan, 1);
+}
+
+/* A line in DroidDeck's session log ("sdl3bridge: ...") through NativeBridge.onBridgeLog. */
+static void engine_log(JNIEnv *env, const char *msg)
+{
+ SDL_Log("%s", msg);
+ jmethodID m = (*env)->GetStaticMethodID(env, g_cls, "onBridgeLog", "(Ljava/lang/String;)V");
+ if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionClear(env); return; }
+ jstring s = (*env)->NewStringUTF(env, msg);
+ if (m && s) (*env)->CallStaticVoidMethod(env, g_cls, m, s);
+ if (s) (*env)->DeleteLocalRef(env, s);
+ if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
+}
+
+static void send_status(JNIEnv *env, const char *msg)
+{
+ jstring s = (*env)->NewStringUTF(env, msg);
+ (*env)->CallStaticVoidMethod(env, g_cls, g_status, s);
+ (*env)->DeleteLocalRef(env, s);
+}
+
+static void send_gone(JNIEnv *env, int slot)
+{
+ (*env)->CallStaticVoidMethod(env, g_cls, g_gone, (jint)slot);
+}
+
+static void send_info(JNIEnv *env, int slot, int vid, int pid, const char *name)
+{
+ jstring s = (*env)->NewStringUTF(env, name ? name : "gamepad");
+ (*env)->CallStaticVoidMethod(env, g_cls, g_info, (jint)slot, (jint)vid, (jint)pid, s);
+ (*env)->DeleteLocalRef(env, s);
+}
+
+#include "triton.h"
+#include "hookup.h"
+
+/* SDL button -> bit in the frame mask. Bit layout is shared with the first bridge and DroidDeck's client. */
+static const struct { SDL_GamepadButton button; int bit; } BUTTONS[] = {
+ { SDL_GAMEPAD_BUTTON_SOUTH, 0 }, /* Cross */
+ { SDL_GAMEPAD_BUTTON_EAST, 1 }, /* Circle */
+ { SDL_GAMEPAD_BUTTON_WEST, 2 }, /* Square */
+ { SDL_GAMEPAD_BUTTON_NORTH, 3 }, /* Triangle */
+ { SDL_GAMEPAD_BUTTON_LEFT_SHOULDER, 4 },
+ { SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER, 5 },
+ { SDL_GAMEPAD_BUTTON_BACK, 8 }, /* Create */
+ { SDL_GAMEPAD_BUTTON_START, 9 }, /* Options */
+ { SDL_GAMEPAD_BUTTON_LEFT_STICK, 10 },
+ { SDL_GAMEPAD_BUTTON_RIGHT_STICK, 11 },
+ { SDL_GAMEPAD_BUTTON_GUIDE, 12 }, /* PS */
+ { SDL_GAMEPAD_BUTTON_TOUCHPAD, 13 },
+ { SDL_GAMEPAD_BUTTON_MISC1, 14 }, /* Mute */
+ { SDL_GAMEPAD_BUTTON_DPAD_UP, 15 },
+ { SDL_GAMEPAD_BUTTON_DPAD_DOWN, 16 },
+ { SDL_GAMEPAD_BUTTON_DPAD_LEFT, 17 },
+ { SDL_GAMEPAD_BUTTON_DPAD_RIGHT, 18 },
+ { SDL_GAMEPAD_BUTTON_LEFT_PADDLE1, 19 }, /* Edge left back button */
+ { SDL_GAMEPAD_BUTTON_RIGHT_PADDLE1, 20 }, /* Edge right back button */
+ { SDL_GAMEPAD_BUTTON_LEFT_PADDLE2, 21 }, /* Edge left Fn */
+ { SDL_GAMEPAD_BUTTON_RIGHT_PADDLE2, 22 }, /* Edge right Fn */
+};
+
+/* Extra buttons that don't fit the 0..22 mask safely in a float -> a second mask in f[21]. */
+static const struct { SDL_GamepadButton button; int bit; } BUTTONS2[] = {
+ { SDL_GAMEPAD_BUTTON_MISC2, 0 }, /* e.g. Flydigi C */
+ { SDL_GAMEPAD_BUTTON_MISC3, 1 }, /* e.g. Flydigi Z */
+ { SDL_GAMEPAD_BUTTON_MISC4, 2 },
+ { SDL_GAMEPAD_BUTTON_MISC5, 3 },
+ { SDL_GAMEPAD_BUTTON_MISC6, 4 },
+};
+
+static float stick(Sint16 v) { return (float)(((int)v + 32768) >> 8); } /* 0..255, 128 = centre */
+static float trigger(Sint16 v) { return (float)((int)v * 255 / 32767); } /* 0..255 */
+
+static void send_frame(JNIEnv *env, int slot, SDL_Gamepad *pad, jfloatArray arr)
+{
+ float f[FRAME_LEN];
+ memset(f, 0, sizeof(f));
+
+ int mask = 0;
+ for (size_t i = 0; i < SDL_arraysize(BUTTONS); i++) {
+ if (SDL_GetGamepadButton(pad, BUTTONS[i].button)) mask |= 1 << BUTTONS[i].bit;
+ }
+ f[1] = stick(SDL_GetGamepadAxis(pad, SDL_GAMEPAD_AXIS_LEFTX));
+ f[2] = stick(SDL_GetGamepadAxis(pad, SDL_GAMEPAD_AXIS_LEFTY));
+ f[3] = stick(SDL_GetGamepadAxis(pad, SDL_GAMEPAD_AXIS_RIGHTX));
+ f[4] = stick(SDL_GetGamepadAxis(pad, SDL_GAMEPAD_AXIS_RIGHTY));
+ f[5] = trigger(SDL_GetGamepadAxis(pad, SDL_GAMEPAD_AXIS_LEFT_TRIGGER));
+ f[6] = trigger(SDL_GetGamepadAxis(pad, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER));
+ if (f[5] > 30.0f) mask |= 1 << 6;
+ if (f[6] > 30.0f) mask |= 1 << 7;
+ f[0] = (float)mask; /* < 2^24, exact in a float */
+ int mask2 = 0;
+ for (size_t i = 0; i < SDL_arraysize(BUTTONS2); i++) {
+ if (SDL_GetGamepadButton(pad, BUTTONS2[i].button)) mask2 |= 1 << BUTTONS2[i].bit;
+ }
+ f[21] = (float)mask2;
+
+ float v[3];
+ if (SDL_GetGamepadSensorData(pad, SDL_SENSOR_GYRO, v, 3)) { f[7] = v[0]; f[8] = v[1]; f[9] = v[2]; }
+ if (SDL_GetGamepadSensorData(pad, SDL_SENSOR_ACCEL, v, 3)) { f[10] = v[0]; f[11] = v[1]; f[12] = v[2]; }
+
+ int ntp = SDL_GetNumGamepadTouchpads(pad);
+ if (ntp >= 2) {
+ /* two trackpads (e.g. Steam Controller): finger slot 0 = pad 0, slot 1 = pad 1 */
+ for (int tp = 0; tp < 2; tp++) {
+ bool down = false; float x = 0, y = 0, p = 0;
+ if (SDL_GetGamepadTouchpadFinger(pad, tp, 0, &down, &x, &y, &p) && down) {
+ f[13 + 3 * tp] = 1.0f;
+ f[14 + 3 * tp] = x * 1919.0f;
+ f[15 + 3 * tp] = y * 1079.0f;
+ }
+ }
+ } else if (ntp == 1) {
+ for (int i = 0; i < 2; i++) {
+ bool down = false;
+ float x = 0, y = 0, p = 0;
+ if (SDL_GetGamepadTouchpadFinger(pad, 0, i, &down, &x, &y, &p) && down) {
+ f[13 + 3 * i] = 1.0f;
+ f[14 + 3 * i] = x * 1919.0f; /* normalized; the view divides by the same */
+ f[15 + 3 * i] = y * 1079.0f;
+ }
+ }
+ }
+
+ int pct = -1;
+ SDL_PowerState ps = SDL_GetGamepadPowerInfo(pad, &pct);
+ f[19] = (float)pct;
+ f[20] = (float)ps;
+
+ (*env)->SetFloatArrayRegion(env, arr, 0, FRAME_LEN, f);
+ (*env)->CallStaticVoidMethod(env, g_cls, g_frame, arr, (jint)slot);
+}
+
+/* SDL's own USB driver gives the gamepad a device path; Android's basic input path does not.
+ * Only the USB driver provides gyro, touchpad and the Edge back buttons. */
+static bool pad_is_full_driver(SDL_Gamepad *pad) { return SDL_GetGamepadPath(pad) != NULL; }
+
+static void enable_sensors(SDL_Gamepad *pad)
+{
+ static const SDL_SensorType types[] = { SDL_SENSOR_GYRO, SDL_SENSOR_ACCEL };
+ for (size_t i = 0; i < SDL_arraysize(types); i++) {
+ if (SDL_GamepadHasSensor(pad, types[i]) && !SDL_GamepadSensorEnabled(pad, types[i])) {
+ SDL_SetGamepadSensorEnabled(pad, types[i], true);
+ }
+ }
+}
+
+static void describe(SDL_Gamepad *pad, char *buf, size_t n)
+{
+ const char *name = SDL_GetGamepadName(pad);
+ SDL_JoystickConnectionState cs = SDL_GetGamepadConnectionState(pad);
+ snprintf(buf, n, "Connected: %s [%s] vid=%04x pid=%04x %s | gyro:%s accel:%s touchpads:%d",
+ name ? name : "gamepad",
+ pad_is_full_driver(pad) ? "SDL3 USB driver: full features"
+ : "Android input (e.g. Bluetooth): SDL3's own driver can't reach it, some extras may be missing",
+ (unsigned)SDL_GetGamepadVendor(pad), (unsigned)SDL_GetGamepadProduct(pad),
+ cs == SDL_JOYSTICK_CONNECTION_WIRED ? "wired" : (cs == SDL_JOYSTICK_CONNECTION_WIRELESS ? "wireless" : "link unknown"),
+ SDL_GamepadHasSensor(pad, SDL_SENSOR_GYRO) ? "yes" : "no",
+ SDL_GamepadHasSensor(pad, SDL_SENSOR_ACCEL) ? "yes" : "no",
+ SDL_GetNumGamepadTouchpads(pad));
+
+ /* Every gamepad SDL knows about, so we can tell if the USB-driver version of the controller exists. */
+ int cnt = 0;
+ SDL_JoystickID *ids = SDL_GetGamepads(&cnt);
+ SDL_strlcat(buf, " | SDL sees:", n);
+ for (int i = 0; ids && i < cnt; i++) {
+ const char *nm = SDL_GetGamepadNameForID(ids[i]);
+ char one[120];
+ snprintf(one, sizeof(one), " [%s: %s]", SDL_GetGamepadPathForID(ids[i]) ? "usb driver" : "android", nm ? nm : "?");
+ SDL_strlcat(buf, one, n);
+ }
+ if (cnt == 0) SDL_strlcat(buf, " nothing", n);
+ SDL_free(ids);
+}
+
+static char g_last_diag[512];
+
+/* In forced mode an Android-path gamepad (no SDL USB path) is never used. */
+static bool usable_id(SDL_JoystickID id)
+{
+ /* A Steam Controller (2026) is never a bridge player: it is passed through raw (see triton.h). */
+ if (SDL_GetGamepadVendorForID(id) == VALVE_VID && triton_pid(SDL_GetGamepadProductForID(id))) return false;
+ return !atomic_load(&g_force) || SDL_GetGamepadPathForID(id) != NULL;
+}
+
+/* The same physical controller can show up twice: once through SDL's USB driver and once through Android's basic
+ * input. The Android copy is a duplicate and is never used as its own player. */
+static bool android_duplicate(SDL_JoystickID id)
+{
+ if (SDL_GetGamepadPathForID(id) != NULL) return false;
+ Uint16 vid = SDL_GetGamepadVendorForID(id), pid = SDL_GetGamepadProductForID(id);
+ int cnt = 0, full = 0, rank = 0;
+ bool seen = false;
+ SDL_JoystickID *ids = SDL_GetGamepads(&cnt);
+ for (int i = 0; ids && i < cnt; i++) {
+ if (SDL_GetGamepadVendorForID(ids[i]) != vid || SDL_GetGamepadProductForID(ids[i]) != pid) continue;
+ if (SDL_GetGamepadPathForID(ids[i]) != NULL) { full++; continue; }
+ if (ids[i] == id) seen = true;
+ else if (!seen) rank++;
+ }
+ SDL_free(ids);
+ return rank < full;
+}
+
+static SDL_Gamepad *g_pads[MAX_PADS];
+static SDL_JoystickID g_ids[MAX_PADS];
+
+static int rumble_on[MAX_PADS];
+
+static int pad_count(void)
+{
+ int n = 0;
+ for (int i = 0; i < MAX_PADS; i++) if (g_pads[i]) n++;
+ return n;
+}
+
+static int slot_of(SDL_JoystickID id)
+{
+ for (int i = 0; i < MAX_PADS; i++) if (g_pads[i] && g_ids[i] == id) return i;
+ return -1;
+}
+
+static void refresh_status(JNIEnv *env)
+{
+ char diag[560];
+ if (atomic_load(&g_driver_off)) {
+ snprintf(diag, sizeof(diag), "SDL3 driver OFF \u2014 controller released to Android (tap the bubble to turn it back on)");
+ } else if (g_pads[0]) {
+ describe(g_pads[0], diag, sizeof(diag));
+ char tail[80];
+ snprintf(tail, sizeof(tail), " | players:%d%s", pad_count(),
+ triton_active() ? " | + Steam Controller (2026) raw passthrough" : "");
+ SDL_strlcat(diag, tail, sizeof(diag));
+ if (hookup_active()) {
+ snprintf(tail, sizeof(tail), " | + %d controller(s) handed to Steam natively%s", hookup_count(),
+ hookup_fast_count() ? " (fast path)" : "");
+ SDL_strlcat(diag, tail, sizeof(diag));
+ } else if (atomic_load(&g_hookup_held)) {
+ SDL_strlcat(diag, " | Steam Native Hook-up waits for Steam on DroidDeck's desktop", sizeof(diag));
+ }
+ } else if (hookup_active()) {
+ snprintf(diag, sizeof(diag), "Connected: Steam Native Hook-up | %d controller(s) handed to Steam as the real "
+ "device (Steam's own driver: all buttons, touchpads, gyro, rumble, lights)%s%s", hookup_count(),
+ hookup_fast_count() ? " | fast path: shared memory" : "",
+ triton_active() ? " | + Steam Controller (2026) raw passthrough" : "");
+ } else if (triton_active()) {
+ snprintf(diag, sizeof(diag), "Connected: %s | raw passthrough to DroidDeck (all sensors, capacitive "
+ "touch, grip sense, trackpad pressure and haptics handled by Steam)", tri_kind(&g_tri[g_tri_active]));
+ } else {
+ int cnt = 0, ignored = 0;
+ SDL_JoystickID *ids = SDL_GetGamepads(&cnt);
+ for (int i = 0; ids && i < cnt; i++) if (!usable_id(ids[i])) ignored++;
+ SDL_free(ids);
+ snprintf(diag, sizeof(diag),
+ "Forced SDL3 USB mode: waiting for the USB driver. Plug the controller in with a USB-C cable, "
+ "then tap 'Force SDL3 USB driver' and allow USB access (or switch Controller source to Auto to "
+ "also use Bluetooth / Android-only controllers). Ignored Android-only controllers: %d", ignored);
+ if (!atomic_load(&g_force))
+ snprintf(diag, sizeof(diag), "Waiting for a controller: plug any SDL3-supported controller in via USB-C "
+ "(full features) or connect it over Bluetooth");
+ }
+ if (strcmp(diag, g_last_diag) != 0) {
+ SDL_strlcpy(g_last_diag, diag, sizeof(g_last_diag));
+ send_status(env, diag);
+ }
+}
+
+/* Which buttons/axes/touchpads this controller actually has, so the UI can draw a universal layout. */
+static void send_caps(JNIEnv *env, int slot, SDL_Gamepad *pad)
+{
+ int present = 0;
+ for (size_t i = 0; i < SDL_arraysize(BUTTONS); i++) {
+ if (SDL_GamepadHasButton(pad, BUTTONS[i].button)) present |= 1 << BUTTONS[i].bit;
+ }
+ int axes = 0;
+ if (SDL_GamepadHasAxis(pad, SDL_GAMEPAD_AXIS_LEFTX)) axes |= 1;
+ if (SDL_GamepadHasAxis(pad, SDL_GAMEPAD_AXIS_RIGHTX)) axes |= 2;
+ if (SDL_GamepadHasAxis(pad, SDL_GAMEPAD_AXIS_LEFT_TRIGGER)) axes |= 4;
+ if (SDL_GamepadHasAxis(pad, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER)) axes |= 8;
+ int touchpads = SDL_GetNumGamepadTouchpads(pad);
+
+ const char *kind;
+ switch (SDL_GetGamepadType(pad)) {
+ case SDL_GAMEPAD_TYPE_PS3:
+ case SDL_GAMEPAD_TYPE_PS4:
+ case SDL_GAMEPAD_TYPE_PS5: kind = "ps"; break;
+ case SDL_GAMEPAD_TYPE_XBOX360:
+ case SDL_GAMEPAD_TYPE_XBOXONE: kind = "xbox"; break;
+ case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO:
+ case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_LEFT:
+ case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_RIGHT:
+ case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_PAIR: kind = "nintendo"; break;
+ case SDL_GAMEPAD_TYPE_STEAM: kind = "steam"; break;
+ default: {
+ const char *nm = SDL_GetGamepadName(pad);
+ kind = (nm && SDL_strcasestr(nm, "steam")) ? "steam" : "generic";
+ }
+ }
+ jstring k = (*env)->NewStringUTF(env, kind);
+ (*env)->CallStaticVoidMethod(env, g_cls, g_caps, (jint)slot, (jint)present, (jint)axes, (jint)touchpads, k);
+ (*env)->DeleteLocalRef(env, k);
+}
+
+static void setup_slot(JNIEnv *env, int slot)
+{
+ SDL_Gamepad *pad = g_pads[slot];
+ enable_sensors(pad);
+ atomic_store(&g_led_dirty[slot], 1);
+ atomic_store(&g_rumble_dirty[slot], 1);
+ atomic_store(&g_player_dirty[slot], 1);
+ send_info(env, slot, SDL_GetGamepadVendor(pad), SDL_GetGamepadProduct(pad), SDL_GetGamepadName(pad));
+ send_caps(env, slot, pad);
+ g_last_diag[0] = 0;
+}
+
+static void open_unopened(JNIEnv *env)
+{
+ if (atomic_load(&g_driver_off)) return; /* driver off: don't claim anything */
+ int n = 0;
+ SDL_JoystickID *ids = SDL_GetGamepads(&n);
+ for (int i = 0; ids && i < n; i++) {
+ if (slot_of(ids[i]) >= 0 || !usable_id(ids[i]) || android_duplicate(ids[i])) continue;
+ int free_slot = -1;
+ for (int s = 0; s < MAX_PADS; s++) if (!g_pads[s]) { free_slot = s; break; }
+ if (free_slot < 0) break;
+ SDL_Gamepad *p = SDL_OpenGamepad(ids[i]);
+ if (!p) continue;
+ g_pads[free_slot] = p;
+ g_ids[free_slot] = ids[i];
+ setup_slot(env, free_slot);
+ }
+ SDL_free(ids);
+}
+
+/* Closes a slot. If it was player 1, the next controller moves up so DroidDeck's primary pad is never empty. */
+static void close_slot(JNIEnv *env, int slot)
+{
+ if (!g_pads[slot]) return;
+ SDL_RumbleGamepad(g_pads[slot], 0, 0, 0);
+ SDL_CloseGamepad(g_pads[slot]);
+ g_pads[slot] = NULL;
+ atomic_store(&g_rl[slot], 0);
+ atomic_store(&g_rr[slot], 0);
+ send_gone(env, slot);
+ if (slot == 0) {
+ for (int k = 1; k < MAX_PADS; k++) {
+ if (!g_pads[k]) continue;
+ g_pads[0] = g_pads[k];
+ g_ids[0] = g_ids[k];
+ g_pads[k] = NULL;
+ send_gone(env, k);
+ setup_slot(env, 0);
+ break;
+ }
+ }
+ g_last_diag[0] = 0;
+}
+
+/* Drops controllers that are no longer usable (forced mode switched on, or an Android duplicate of a USB-driver pad). */
+static void drop_unusable(JNIEnv *env)
+{
+ for (int s = 0; s < MAX_PADS; s++) {
+ if (!g_pads[s]) continue;
+ if (!usable_id(g_ids[s]) || android_duplicate(g_ids[s])) { rumble_on[s] = 0; close_slot(env, s); }
+ }
+}
+
+/* Close every bridge player and restart SDL's gamepad layer (re-enumerates; ignore hints take effect).
+ * Raw handles (Steam Controller passthrough, Steam Native Hook-up) survive: main() holds an SDL_hid_init
+ * reference so the HID layer itself stays up. */
+static void restart_gamepads(JNIEnv *env)
+{
+ for (int s = 0; s < MAX_PADS; s++) {
+ if (g_pads[s]) { SDL_CloseGamepad(g_pads[s]); g_pads[s] = NULL; send_gone(env, s); }
+ rumble_on[s] = 0;
+ }
+ SDL_QuitSubSystem(SDL_INIT_GAMEPAD);
+ SDL_InitSubSystem(SDL_INIT_GAMEPAD);
+ g_last_diag[0] = 0;
+}
+
+/* SDL only reports a joystick as a gamepad when it has a mapping. SDL ships mappings for thousands of
+ * controllers and Android generates one for every Android gamepad, but a rare generic one (arcade stick,
+ * cheap USB pad) may have none -- give it a sensible default layout so it still works as a player. */
+static void map_unmapped_joystick(SDL_JoystickID id)
+{
+ if (SDL_IsGamepad(id)) return;
+ SDL_Joystick *joy = SDL_OpenJoystick(id);
+ if (!joy) return;
+ int nb = SDL_GetNumJoystickButtons(joy), na = SDL_GetNumJoystickAxes(joy), nh = SDL_GetNumJoystickHats(joy);
+ char guid[64], map[1024];
+ SDL_GUIDToString(SDL_GetJoystickGUID(joy), guid, sizeof(guid));
+ const char *name = SDL_GetJoystickName(joy);
+ char clean[128];
+ SDL_strlcpy(clean, name ? name : "Generic controller", sizeof(clean));
+ for (char *c = clean; *c; c++) if (*c == ',') *c = ' ';
+ SDL_CloseJoystick(joy);
+ if (nb == 0 && na == 0) return;
+ snprintf(map, sizeof(map), "%s,%s,", guid, clean);
+ static const char *btn[] = { "a", "b", "x", "y", "leftshoulder", "rightshoulder", "back", "start",
+ "leftstick", "rightstick", "guide", "lefttrigger", "righttrigger" };
+ char one[48];
+ int first_trigger_button = na >= 6 ? 11 : 13; /* analog triggers when there are 6+ axes */
+ for (int b = 0; b < nb && b < (int)SDL_arraysize(btn); b++) {
+ if (b >= first_trigger_button) break;
+ snprintf(one, sizeof(one), "%s:b%d,", btn[b], b);
+ SDL_strlcat(map, one, sizeof(map));
+ }
+ if (na < 6) {
+ for (int b = 11; b < nb && b < 13; b++) {
+ snprintf(one, sizeof(one), "%s:b%d,", btn[b], b);
+ SDL_strlcat(map, one, sizeof(map));
+ }
+ }
+ static const char *ax[] = { "leftx", "lefty", "rightx", "righty", "lefttrigger", "righttrigger" };
+ for (int a = 0; a < na && a < 6; a++) {
+ snprintf(one, sizeof(one), "%s:a%d,", ax[a], a);
+ SDL_strlcat(map, one, sizeof(map));
+ }
+ if (nh > 0) SDL_strlcat(map, "dpup:h0.1,dpright:h0.2,dpdown:h0.4,dpleft:h0.8,", sizeof(map));
+ if (SDL_AddGamepadMapping(map) >= 0) SDL_Log("bridge: default mapping for unmapped \"%s\": %s", clean, map);
+}
+
+int main(int argc, char *argv[])
+{
+ /* argv[1]: SDL3's gamepad mappings file (absolute path, made by the app). Without it SDL3 looks the file up
+ * through the app screen's assets -- headless there is no screen, and that lookup crashes the app. */
+ if (argc > 1 && argv[1] && argv[1][0] == '/') SDL_SetHint(SDL_HINT_GAMECONTROLLERCONFIG_FILE, argv[1]);
+ SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1");
+ SDL_SetHint(SDL_HINT_JOYSTICK_ENHANCED_REPORTS, "1");
+ SDL_SetHint(SDL_HINT_ANDROID_BLOCK_ON_PAUSE, "0");
+ SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI, "1");
+ SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS5, "1");
+ SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS4, "1");
+ SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_PS5_PLAYER_LED, "1");
+ /* Every SDL3 USB (HIDAPI) driver on, including Valve's (Steam Controller 2015 cable/dongle; it is off by
+ * default in SDL). Only the Steam Controller (2026) is kept away from SDL's gamepad layer: it is passed
+ * through raw instead (triton.h). That hint hides it from gamepads but not from SDL_hid_enumerate. */
+ SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_STEAM, "1");
+ SDL_SetHint(SDL_HINT_GAMECONTROLLER_IGNORE_DEVICES, "0x28de/0x1302,0x28de/0x1304,0x28de/0x1305");
+
+ JNIEnv *env = (JNIEnv *)SDL_GetAndroidJNIEnv();
+ if (!env || !g_cls) {
+ SDL_Log("SDL3Bridge: no JNI environment (%p) or bridge class (%p); SDL3's loop cannot run", (void *)env, (void *)g_cls);
+ return 1;
+ }
+ engine_log(env, "engine: SDL3's loop started; initialising SDL3's gamepad support");
+
+ if (!SDL_Init(SDL_INIT_GAMEPAD)) {
+ char msg[200];
+ snprintf(msg, sizeof(msg), "SDL init failed: %s", SDL_GetError());
+ send_status(env, msg);
+ return 1;
+ }
+ engine_log(env, "engine: SDL3's gamepad support is up");
+ SDL_hid_init(); /* keeps the HID layer alive across gamepad-layer restarts (raw passthrough handles) */
+ send_status(env, "Waiting for a controller (plug it in via USB-C)");
+
+ jfloatArray arr = (*env)->NewFloatArray(env, FRAME_LEN);
+ Uint64 last_scan = 0, last_tick = 0, last_rumble[MAX_PADS] = {0};
+
+ for (;;) {
+ SDL_Event e;
+ while (SDL_PollEvent(&e)) {
+ if (e.type == SDL_EVENT_JOYSTICK_ADDED) {
+ map_unmapped_joystick(e.jdevice.which);
+ } else if (e.type == SDL_EVENT_GAMEPAD_ADDED) {
+ drop_unusable(env);
+ open_unopened(env);
+ } else if (e.type == SDL_EVENT_GAMEPAD_REMOVED) {
+ int s = slot_of(e.gdevice.which);
+ if (s >= 0) {
+ rumble_on[s] = 0;
+ close_slot(env, s);
+ if (pad_count() == 0) send_status(env, "Controller disconnected");
+ }
+ }
+ }
+
+ Uint64 now = SDL_GetTicks();
+
+ if (atomic_exchange(&g_rescan, 0)) { /* restart SDL's gamepad/HIDAPI enumeration */
+ tri_close_all(env, "rescan");
+ restart_gamepads(env);
+ send_status(env, "Rescanning controllers...");
+ }
+
+ if (now - last_scan > 1000) { /* fallback in case an ADDED event was missed, and housekeeping */
+ last_scan = now;
+ drop_unusable(env);
+ open_unopened(env);
+ }
+
+ if (now - last_tick > 1000) {
+ last_tick = now;
+ for (int s = 0; s < MAX_PADS; s++) if (g_pads[s]) enable_sensors(g_pads[s]);
+ refresh_status(env);
+ }
+
+ triton_poll(env, now, atomic_load(&g_driver_off) ? "SDL3 driver off" : hookup_triton_off_why());
+ hookup_poll(env, now, atomic_load(&g_driver_off) != 0);
+
+ if (pad_count() > 0) SDL_UpdateGamepads();
+
+ for (int s = 0; s < MAX_PADS; s++) {
+ SDL_Gamepad *pad = g_pads[s];
+ if (!pad) continue;
+
+ if (atomic_exchange(&g_led_dirty[s], 0))
+ SDL_SetGamepadLED(pad, (Uint8)atomic_load(&g_led_r[s]), (Uint8)atomic_load(&g_led_g[s]), (Uint8)atomic_load(&g_led_b[s]));
+ if (atomic_exchange(&g_player_dirty[s], 0)) SDL_SetGamepadPlayerIndex(pad, atomic_load(&g_player_idx[s]));
+
+ int rl = atomic_load(&g_rl[s]), rr = atomic_load(&g_rr[s]);
+ int rdirty = atomic_exchange(&g_rumble_dirty[s], 0);
+ if (rl || rr) {
+ if (rdirty || now - last_rumble[s] > 400) { /* SDL rumble has a duration, so refresh it */
+ SDL_RumbleGamepad(pad, (Uint16)(rl * 257), (Uint16)(rr * 257), 1000);
+ last_rumble[s] = now;
+ }
+ rumble_on[s] = 1;
+ } else if (rumble_on[s]) {
+ SDL_RumbleGamepad(pad, 0, 0, 0);
+ rumble_on[s] = 0;
+ }
+
+ send_frame(env, s, pad, arr);
+ }
+ SDL_Delay((Uint32)atomic_load(&g_poll_ms));
+ }
+ return 0;
+}
diff --git a/app/src/main/cpp/sdl3bridge/src/triton.h b/app/src/main/cpp/sdl3bridge/src/triton.h
new file mode 100644
index 000000000..d2375ad0e
--- /dev/null
+++ b/app/src/main/cpp/sdl3bridge/src/triton.h
@@ -0,0 +1,296 @@
+/*
+ * Steam Controller (2026, "Triton") raw passthrough over USB: the wired controller (28de:1302) or its
+ * wireless "puck" dongle (28de:1304 / 28de:1305, one controller per HID interface 2..5).
+ *
+ * Nothing is decoded here. Every state report (buttons, sticks, trackpads with pressure, capacitive stick
+ * touch, grip sense, IMU) goes to DroidDeck byte for byte, and everything Steam sends back (haptics,
+ * settings, feature reads) goes to the controller unchanged, so Steam drives it exactly like a real one --
+ * just over a cable/2.4 GHz link instead of Bluetooth LE.
+ *
+ * Included once, from main.c (uses its JNI globals). All SDL_hid calls run on the SDL thread.
+ */
+#pragma once
+
+#define VALVE_VID 0x28de
+#define TRITON_MAX_IFACES 4
+#define TRITON_REPORT_BYTES 64
+#define TRITON_STATE_PAYLOAD 45 /* bytes after the report id, as the Bluetooth descriptor declares */
+#define TRITON_LIZARD_REFRESH_MS 3000
+#define TRITON_RUMBLE_RESEND_MS 40
+
+static bool triton_pid(Uint16 pid) { return pid == 0x1302 || pid == 0x1304 || pid == 0x1305; }
+static bool triton_dongle(Uint16 pid) { return pid == 0x1304 || pid == 0x1305; }
+
+typedef struct {
+ SDL_hid_device *dev;
+ char path[256];
+ int iface;
+ Uint16 pid;
+} TritonIface;
+
+static TritonIface g_tri[TRITON_MAX_IFACES];
+static int g_tri_active = -1; /* index in g_tri of the controller being passed through */
+static Uint32 g_tri_changes = 0xffffffffu;
+static Uint64 g_tri_scan_ms, g_tri_lizard_ms, g_tri_rumble_ms;
+static jmethodID g_tri_state, g_tri_conn, g_tri_feature;
+
+/* ---- requests from Java (any thread) -> SDL thread ---- */
+enum { TRI_REQ_OUTPUT = 1, TRI_REQ_FEATURE_SET = 2, TRI_REQ_FEATURE_GET = 3 };
+typedef struct {
+ int type;
+ int len;
+ jlong request;
+ Uint8 data[TRITON_REPORT_BYTES + 1];
+} TritonReq;
+#define TRI_QUEUE 64
+static TritonReq g_tri_queue[TRI_QUEUE];
+static int g_tri_head, g_tri_tail;
+static SDL_SpinLock g_tri_lock; /* no init needed: Java may queue before the SDL thread starts */
+static _Atomic int g_tri_rl, g_tri_rr, g_tri_rumble_dirty;
+
+static void tri_lock(void) { SDL_LockSpinlock(&g_tri_lock); }
+static void tri_unlock(void) { SDL_UnlockSpinlock(&g_tri_lock); }
+
+static void tri_push(int type, const Uint8 *data, int len, jlong request)
+{
+ if (len < 0) len = 0;
+ if (len > TRITON_REPORT_BYTES + 1) len = TRITON_REPORT_BYTES + 1;
+ tri_lock();
+ int next = (g_tri_tail + 1) % TRI_QUEUE;
+ if (next != g_tri_head) { /* full: drop (Steam re-sends settings and haptics constantly) */
+ TritonReq *r = &g_tri_queue[g_tri_tail];
+ r->type = type;
+ r->len = len;
+ r->request = request;
+ if (len) memcpy(r->data, data, (size_t)len);
+ g_tri_tail = next;
+ }
+ tri_unlock();
+}
+
+JNIEXPORT void JNICALL Java_com_example_dsedge_NativeBridge_nativeTritonOutput(JNIEnv *env, jclass c, jbyteArray data, jboolean feature)
+{
+ (void)c;
+ if (!data) return;
+ jsize n = (*env)->GetArrayLength(env, data);
+ if (n < 1) return;
+ Uint8 buf[TRITON_REPORT_BYTES + 1];
+ if (n > (jsize)sizeof(buf)) n = (jsize)sizeof(buf);
+ (*env)->GetByteArrayRegion(env, data, 0, n, (jbyte *)buf);
+ tri_push(feature ? TRI_REQ_FEATURE_SET : TRI_REQ_OUTPUT, buf, (int)n, 0);
+}
+
+JNIEXPORT void JNICALL Java_com_example_dsedge_NativeBridge_nativeTritonGetFeature(JNIEnv *env, jclass c, jlong request, jint reportId)
+{
+ (void)env; (void)c;
+ Uint8 id = (Uint8)reportId;
+ tri_push(TRI_REQ_FEATURE_GET, &id, 1, request);
+}
+
+JNIEXPORT void JNICALL Java_com_example_dsedge_NativeBridge_nativeTritonRumble(JNIEnv *env, jclass c, jint left, jint right)
+{
+ (void)env; (void)c;
+ atomic_store(&g_tri_rl, left < 0 ? 0 : left > 65535 ? 65535 : left);
+ atomic_store(&g_tri_rr, right < 0 ? 0 : right > 65535 ? 65535 : right);
+ atomic_store(&g_tri_rumble_dirty, 1);
+}
+
+/* ---- SDL thread -> Java ---- */
+static void tri_java_init(JNIEnv *env)
+{
+ if (g_tri_state) return;
+ g_tri_state = (*env)->GetStaticMethodID(env, g_cls, "onTritonState", "(I[B)V");
+ g_tri_conn = (*env)->GetStaticMethodID(env, g_cls, "onTritonConnected", "(ZLjava/lang/String;)V");
+ g_tri_feature = (*env)->GetStaticMethodID(env, g_cls, "onTritonFeature", "(J[B)V");
+ if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
+}
+
+static void tri_send_conn(JNIEnv *env, bool on, const char *what)
+{
+ if (!g_tri_conn) return;
+ jstring s = (*env)->NewStringUTF(env, what ? what : "");
+ (*env)->CallStaticVoidMethod(env, g_cls, g_tri_conn, (jboolean)on, s);
+ (*env)->DeleteLocalRef(env, s);
+}
+
+static void tri_send_bytes(JNIEnv *env, jmethodID m, jlong request, int id, const Uint8 *data, int len, bool with_request)
+{
+ if (!m) return;
+ jbyteArray arr = NULL;
+ if (data && len > 0) {
+ arr = (*env)->NewByteArray(env, len);
+ if (!arr) return;
+ (*env)->SetByteArrayRegion(env, arr, 0, len, (const jbyte *)data);
+ }
+ if (with_request) (*env)->CallStaticVoidMethod(env, g_cls, m, request, arr);
+ else (*env)->CallStaticVoidMethod(env, g_cls, m, (jint)id, arr);
+ if (arr) (*env)->DeleteLocalRef(env, arr);
+}
+
+static const char *tri_kind(const TritonIface *t)
+{
+ return triton_dongle(t->pid) ? "Steam Controller (2026) via wireless puck" : "Steam Controller (2026) via USB cable";
+}
+
+static void tri_deactivate(JNIEnv *env, const char *why)
+{
+ if (g_tri_active < 0) return;
+ SDL_Log("triton: passthrough stopped (%s)", why);
+ g_tri_active = -1;
+ atomic_store(&g_tri_rl, 0);
+ atomic_store(&g_tri_rr, 0);
+ tri_send_conn(env, false, why);
+}
+
+static void tri_close(JNIEnv *env, int i, const char *why)
+{
+ if (!g_tri[i].dev) return;
+ if (g_tri_active == i) tri_deactivate(env, why);
+ SDL_hid_close(g_tri[i].dev);
+ memset(&g_tri[i], 0, sizeof(g_tri[i]));
+}
+
+static void tri_close_all(JNIEnv *env, const char *why)
+{
+ for (int i = 0; i < TRITON_MAX_IFACES; i++) tri_close(env, i, why);
+}
+
+static bool tri_is_open(const char *path)
+{
+ for (int i = 0; i < TRITON_MAX_IFACES; i++) if (g_tri[i].dev && !strcmp(g_tri[i].path, path)) return true;
+ return false;
+}
+
+/* Lizard mode (the controller acting as a mouse + keyboard) off: SET_SETTINGS_VALUES, SETTING_LIZARD_MODE = 0.
+ * Steam sends this itself once it owns the controller; doing it here as well keeps the phone free of stray
+ * mouse/keyboard input before Steam has started. */
+static void tri_lizard_off(SDL_hid_device *dev)
+{
+ Uint8 buf[TRITON_REPORT_BYTES] = { 0x01, 0x87, 0x03, 0x09, 0x00, 0x00 };
+ SDL_hid_send_feature_report(dev, buf, sizeof(buf));
+}
+
+static void tri_scan(JNIEnv *env)
+{
+ SDL_hid_device_info *list = SDL_hid_enumerate(VALVE_VID, 0);
+ for (SDL_hid_device_info *d = list; d; d = d->next) {
+ if (!triton_pid(d->product_id) || !d->path) continue;
+ /* The puck carries one controller per interface 2..5; the cable has a single controller interface. */
+ if (triton_dongle(d->product_id) && (d->interface_number < 2 || d->interface_number > 5)) continue;
+ if (!triton_dongle(d->product_id) && d->usage_page != 0 && d->usage_page < 0xff00) continue;
+ if (d->interface_class == 3 && d->interface_subclass == 1) continue; /* boot keyboard / mouse (lizard mode) */
+ if (tri_is_open(d->path)) continue;
+ int slot = -1;
+ for (int i = 0; i < TRITON_MAX_IFACES; i++) if (!g_tri[i].dev) { slot = i; break; }
+ if (slot < 0) break;
+ SDL_hid_device *dev = SDL_hid_open_path(d->path);
+ if (!dev) {
+ SDL_Log("triton: could not open %04x:%04x interface %d: %s", d->vendor_id, d->product_id, d->interface_number, SDL_GetError());
+ continue;
+ }
+ SDL_hid_set_nonblocking(dev, 1);
+ g_tri[slot].dev = dev;
+ SDL_strlcpy(g_tri[slot].path, d->path, sizeof(g_tri[slot].path));
+ g_tri[slot].iface = d->interface_number;
+ g_tri[slot].pid = d->product_id;
+ SDL_Log("triton: opened %04x:%04x interface %d", d->vendor_id, d->product_id, d->interface_number);
+ if (!triton_dongle(d->product_id)) tri_lizard_off(dev);
+ }
+ SDL_hid_free_enumeration(list);
+}
+
+static void tri_run_queue(JNIEnv *env)
+{
+ for (;;) {
+ TritonReq r;
+ tri_lock();
+ bool have = g_tri_head != g_tri_tail;
+ if (have) { r = g_tri_queue[g_tri_head]; g_tri_head = (g_tri_head + 1) % TRI_QUEUE; }
+ tri_unlock();
+ if (!have) return;
+ SDL_hid_device *dev = g_tri_active >= 0 ? g_tri[g_tri_active].dev : NULL;
+ if (r.type == TRI_REQ_FEATURE_GET) {
+ Uint8 buf[TRITON_REPORT_BYTES + 1] = { 0 };
+ buf[0] = r.data[0];
+ int n = dev ? SDL_hid_get_feature_report(dev, buf, TRITON_REPORT_BYTES) : -1;
+ tri_send_bytes(env, g_tri_feature, r.request, 0, n > 0 ? buf : NULL, n > 0 ? n : 0, true);
+ } else if (dev && r.type == TRI_REQ_FEATURE_SET) {
+ Uint8 buf[TRITON_REPORT_BYTES] = { 0 };
+ int n = r.len > TRITON_REPORT_BYTES ? TRITON_REPORT_BYTES : r.len;
+ memcpy(buf, r.data, (size_t)n);
+ SDL_hid_send_feature_report(dev, buf, TRITON_REPORT_BYTES);
+ } else if (dev && r.type == TRI_REQ_OUTPUT) {
+ SDL_hid_write(dev, r.data, (size_t)r.len);
+ }
+ }
+}
+
+/* Game rumble for the passed-through controller (DroidDeck's virtual-pad rumble): haptic rumble report 0x80,
+ * re-sent every 40 ms while on, like SDL's own driver does. */
+static void tri_rumble(Uint64 now)
+{
+ if (g_tri_active < 0) return;
+ int dirty = atomic_exchange(&g_tri_rumble_dirty, 0);
+ int l = atomic_load(&g_tri_rl), r = atomic_load(&g_tri_rr);
+ if (!dirty && !(l || r)) return;
+ if (!dirty && now - g_tri_rumble_ms < TRITON_RUMBLE_RESEND_MS) return;
+ Uint8 msg[10] = { 0x80, 0, 0, 0, (Uint8)l, (Uint8)(l >> 8), 0, (Uint8)r, (Uint8)(r >> 8), 0 };
+ SDL_hid_write(g_tri[g_tri_active].dev, msg, sizeof(msg));
+ g_tri_rumble_ms = now;
+}
+
+/* off_why: NULL = pass the controller through; otherwise why not (SDL3 driver off, or Steam Native Hook-up,
+ * which hands the Steam Controller (2026) to Steam as itself instead -- see hookup.h). */
+static void triton_poll(JNIEnv *env, Uint64 now, const char *off_why)
+{
+ tri_java_init(env);
+ if (off_why) {
+ tri_close_all(env, off_why);
+ return;
+ }
+ Uint32 changes = SDL_hid_device_change_count();
+ if (changes != g_tri_changes || now - g_tri_scan_ms > 3000) {
+ g_tri_changes = changes;
+ g_tri_scan_ms = now;
+ tri_scan(env);
+ }
+ for (int i = 0; i < TRITON_MAX_IFACES; i++) {
+ TritonIface *t = &g_tri[i];
+ for (int reads = 0; t->dev && reads < 32; reads++) {
+ Uint8 buf[TRITON_REPORT_BYTES];
+ int n = SDL_hid_read(t->dev, buf, sizeof(buf));
+ if (n == 0) break;
+ if (n < 0) { tri_close(env, i, "controller unplugged"); break; }
+ Uint8 id = buf[0];
+ if (id == 0x42 || id == 0x45 || id == 0x47) {
+ if (g_tri_active < 0) {
+ g_tri_active = i;
+ SDL_Log("triton: passing through interface %d", t->iface);
+ tri_send_conn(env, true, tri_kind(t));
+ tri_lizard_off(t->dev);
+ g_tri_lizard_ms = now;
+ }
+ if (g_tri_active != i) continue;
+ /* The wired state report (0x42) has the same layout as the Bluetooth one (0x45) that
+ * DroidDeck's Steam Controller node declares, so it is relabelled; 0x47 keeps its id. */
+ int len = n - 1 < TRITON_STATE_PAYLOAD ? n - 1 : TRITON_STATE_PAYLOAD;
+ tri_send_bytes(env, g_tri_state, 0, id == 0x42 ? 0x45 : id, buf + 1, len, false);
+ } else if ((id == 0x79 || id == 0x46) && n >= 2) {
+ /* Puck wireless status: 1 = controller went away, 2 = connected. */
+ if (buf[1] == 1 && g_tri_active == i) tri_deactivate(env, "controller turned off");
+ }
+ }
+ }
+ if (g_tri_active >= 0) {
+ tri_run_queue(env);
+ tri_rumble(now);
+ if (now - g_tri_lizard_ms > TRITON_LIZARD_REFRESH_MS) {
+ g_tri_lizard_ms = now;
+ tri_lizard_off(g_tri[g_tri_active].dev);
+ }
+ } else {
+ tri_run_queue(env); /* answers pending feature reads with "no data" */
+ }
+}
+
+static bool triton_active(void) { return g_tri_active >= 0; }
diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt
index 928d7caa8..5023e703d 100644
--- a/app/src/main/java/com/droiddeck/launcher/App.kt
+++ b/app/src/main/java/com/droiddeck/launcher/App.kt
@@ -34,6 +34,7 @@ class App : Application() {
com.droiddeck.launcher.stores.StoresState.init(this)
return
}
+ if (process == "$packageName:sdl3") return // sdl3droiddeck: the SDL3 bridge's process
if (process == "$packageName:vr_recovery" || process == "$packageName:rgb_diagnostic") return
CrashHandler.install(this)
com.droiddeck.launcher.session.QuestExperimental.initialize(this)
diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
index 29953e6c3..c6a1dbc13 100644
--- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
+++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
@@ -510,6 +510,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
bridge.setBuiltinPad(SessionState.mode != SessionService.MODE_STEAM || SessionPrefs.builtinPad(this))
padBridge = bridge
SessionState.padBridge = bridge
+ com.droiddeck.launcher.input.DualSenseBridge.attach(this, bridge)
if (Build.VERSION.SDK_INT >= 31) {
val vr = com.droiddeck.launcher.input.JoyConVr(this, File(filesDir, ".wayland-rt/${com.droiddeck.launcher.input.JoyConVr.FILE}")) { taken, released ->
if (joyConVr == null) return@JoyConVr
@@ -2041,6 +2042,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return super.dispatchKeyEvent(event)
}
if (pipUi) return true
+ if (com.droiddeck.launcher.input.DualSenseBridge.isBridged(event)) return true
val pad = com.droiddeck.launcher.input.JoyCon.normalize(event)
if (pad !== event) return dispatchKeyEvent(pad)
val fromController = PadBridge.isControllerKeyEvent(event)
@@ -3525,6 +3527,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
handCamera?.stop()
handCamera = null
padBridge?.stop()
+ com.droiddeck.launcher.input.DualSenseBridge.screenClosed(this)
steamController?.stop()
if (SessionState.padBridge === padBridge) SessionState.padBridge = null
padMotion?.stop()
diff --git a/app/src/main/java/com/droiddeck/launcher/input/DualSenseBridge.kt b/app/src/main/java/com/droiddeck/launcher/input/DualSenseBridge.kt
new file mode 100644
index 000000000..0e77c1984
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/input/DualSenseBridge.kt
@@ -0,0 +1,1058 @@
+package com.droiddeck.launcher.input
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.ServiceConnection
+import android.os.Bundle
+import android.os.Handler
+import android.os.IBinder
+import android.os.Looper
+import android.os.Message
+import android.os.Messenger
+import android.os.RemoteException
+import android.os.SystemClock
+import android.view.InputDevice
+import android.view.KeyEvent
+import android.view.MotionEvent
+
+/**
+ * Receives controller input (DualSense / Edge with full features, any other SDL3-supported pad) from the companion SDL3 bridge app
+ * and feeds it into DroidDeck's pad pipeline.
+ *
+ * sticks / triggers / D-pad / face + shoulder buttons -> PadBridge.applyExternal
+ * Edge paddles (L/R) -> Deck grips L4 / R4; Edge Fn (L/R) -> L5 / R5
+ * touchpad -> decoupled by the bridge: left half = left Deck trackpad, right half = right trackpad (+ click)
+ * gyro / accel -> FakeInputWriter.writeMotion (slot 0)
+ *
+ * Usage: DualSenseBridge.attach(context, padBridge) when the session starts,
+ * DualSenseBridge.detach() when it stops.
+ */
+object DualSenseBridge {
+ private const val BRIDGE_PKG = "com.example.dsedge"
+ private const val BRIDGE_ACTION = "com.example.dsedge.BRIDGE"
+ private const val MSG_REGISTER = 1
+ private const val MSG_UNREGISTER = 2
+ private const val MSG_OUTPUT = 3
+ private const val MSG_FRAME = 10
+ private const val MSG_STATUS = 11
+ private const val MSG_PAD_GONE = 12
+ // Steam Controller (2026) raw passthrough over USB (cable or wireless puck), see the bridge's triton.h
+ private const val MSG_TRITON_OUTPUT = 4
+ private const val MSG_TRITON_GET = 5
+ private const val MSG_TRITON_RUMBLE = 6
+ private const val MSG_TRITON_STATE = 13
+ private const val MSG_TRITON_CONN = 14
+ private const val MSG_TRITON_FEATURE = 15
+ private const val TRITON_SLOT = 0 // the ring slot DroidDeck's Steam Controller node reads
+ private const val TRITON_STATE_BYTES = 45
+ private const val TRITON_POLL_MS = 2L
+ // Steam Native Hook-up (see HookupBridge.kt and the bridge's hookup.h)
+ private const val MSG_HOOKUP_OUT = 7
+ private const val MSG_HOOKUP_GET = 8
+ private const val MSG_HOOKUP_ADD = 20
+ private const val MSG_HOOKUP_INPUT = 21
+ private const val MSG_HOOKUP_REMOVE = 22
+ private const val MSG_HOOKUP_REPLY = 23
+ private const val HOOKUP_POLL_MS = 1L
+ private const val MSG_BRIDGE_LOG = 30
+ // Background watchdog (like Steam on a PC: controllers are picked up whenever they appear)
+ private const val MSG_HOOKUP_SYNC = 9 // -> bridge: "send me everything you hand over right now"
+ private const val MSG_HOOKUP_SHM = 25 // -> bridge: "fd" = the shared hook-up file (fast path)
+ private const val MSG_HOOKUP_STATE = 24 // <- bridge: "slots": the hook-up slots that are live
+ private const val WATCHDOG_MS = 1500L
+ private const val SLOTS = 4
+
+ // ---- Tunables (verify on device) ----
+ private const val DEAD_ZONE = 0.12f // same as PadBridge
+ private const val MIN_AXIS_INTERVAL_MS = 4L // don't flood the input ring with stick jitter
+ private const val GYRO_LSB_PER_DPS = 16.384f // Steam Deck IMU: 2000 dps full scale (assumed)
+ private const val ACCEL_LSB_PER_G = 16384f // Steam Deck IMU: 2 g full scale (assumed)
+ private val GYRO_SIGN = floatArrayOf(1f, 1f, 1f) // flip an entry to -1f if an axis feels inverted
+ private val ACCEL_SIGN = floatArrayOf(1f, 1f, 1f)
+ private const val GYRO_GAIN = 1f // multiplies gyro only; raise if motion is too slow
+ // Touchpad orientation on the DroidDeck side (the bridge app has the same settings; use one or the other, not both).
+ private const val TOUCH_INV_X = false
+ private const val TOUCH_INV_Y = false
+ private const val TOUCH_SWAP = false
+ private const val MAP_TOUCHPAD = true // left half = left trackpad, right half = right trackpad
+ private const val TOUCH_HALF = 960 // DualSense touchpad is 1920 x 1080
+ private const val TOUCH_H = 1079f
+
+ // bridge mask bit -> PadState button
+ private val BUTTONS = mapOf(
+ 0 to PadState.A, 1 to PadState.B, 2 to PadState.X, 3 to PadState.Y,
+ 4 to PadState.LB, 5 to PadState.RB,
+ 8 to PadState.SELECT, 9 to PadState.START,
+ 10 to PadState.L3, 11 to PadState.R3,
+ 12 to PadState.GUIDE, 14 to PadState.QAM,
+ )
+ // bridge mask bit -> Deck grip bit
+ private val GRIPS = mapOf(
+ 19 to DeckControls.L4, 20 to DeckControls.R4,
+ 21 to DeckControls.L5, 22 to DeckControls.R5,
+ )
+
+ private var appContext: Context? = null
+ @Volatile private var owner: Any? = null
+ private var pad: PadBridge? = null
+ private var server: Messenger? = null
+ private var bound = false
+
+ private var lastMask = 0
+ private val lastAxes = FloatArray(6)
+ private var lastApplyMs = 0L
+ // Per bridge slot (0 = player 1 with all features, 1..3 = extra controllers: buttons, sticks, triggers, gyro, rumble).
+ private val vidS = IntArray(SLOTS)
+ private val pidS = IntArray(SLOTS)
+ private val frameMsS = LongArray(SLOTS)
+ private val ddSlot = intArrayOf(0, -1, -1, -1) // bridge slot -> DroidDeck player slot (-1 = not claimed)
+ private val maskS = IntArray(SLOTS)
+ private val axesS = Array(SLOTS) { FloatArray(6) }
+ private val applyMsS = LongArray(SLOTS)
+ private val lastTouchKey = intArrayOf(-1, -1) // [left, right]
+ private var clickSide = -1 // -1 none, 0 left, 1 right
+
+ // DeckControls bit layout (those constants are private over there)
+ private const val DC_LEFT_TOUCH = 16
+ private const val DC_RIGHT_TOUCH = 32
+ private const val DC_LEFT_CLICK = 64
+ private const val DC_RIGHT_CLICK = 128
+
+ // Separate-controller mode: the DualSense becomes its OWN DroidDeck controller slot (its own entry in Steam),
+ // with full back buttons, both trackpads and gyro, instead of driving the Steam Deck pad.
+ private var sepSlot = -1
+ private var sepMs = 0L
+ private var sepVid = 0
+ private var sepPid = 0
+ private var sepMask = 0
+ private val sepAxes = FloatArray(6)
+ private var sepApplyMs = 0L
+ private var lastPadType = 0
+ private var lastSepControls = -1
+ private val lastSepPads = ShortArray(4)
+ private val lastSepPressure = ShortArray(2)
+
+ private val worker = android.os.HandlerThread("ds-bridge", android.os.Process.THREAD_PRIORITY_URGENT_DISPLAY).apply { start() }
+ private val workerHandler = Handler(worker.looper)
+ private val inbox = Messenger(object : Handler(worker.looper) {
+ /*
+ * Called on the Binder thread that received the bridge's message, before it is queued. Input must never
+ * wait in a queue: with a game running the worker thread can get less CPU than 500-1000 messages a second
+ * need, and an ever-growing queue of old input is lag that builds up over time. So:
+ * - controller reports for Steam (hook-up) and Steam Controller states go straight into shared memory;
+ * - frames are "latest wins" per controller (see offerFrame), so only fresh state waits for the worker.
+ */
+ override fun sendMessageAtTime(msg: Message, uptimeMillis: Long): Boolean {
+ if (msg.callback == null) when (msg.what) {
+ MSG_FRAME -> { offerFrame(msg.data); return true }
+ MSG_HOOKUP_INPUT -> {
+ msg.data.getByteArray("data")?.let { HookupBridge.input(msg.data.getInt("slot"), it) }
+ return true
+ }
+ MSG_TRITON_STATE -> {
+ val d = msg.data.getByteArray("data")
+ if (d != null && tritonOn) FakeInputWriter.writeTritonState(TRITON_SLOT, msg.data.getInt("id"), d)
+ return true
+ }
+ }
+ return super.sendMessageAtTime(msg, uptimeMillis)
+ }
+
+ override fun handleMessage(m: Message) {
+ when (m.what) {
+ MSG_FRAME -> onFrame(m.data)
+ MSG_STATUS -> if (m.data.getString("text")?.startsWith("Connected") != true) release()
+ MSG_PAD_GONE -> padGone(m.data.getInt("slot"))
+ MSG_TRITON_STATE -> m.data.getByteArray("data")?.let {
+ if (tritonOn) FakeInputWriter.writeTritonState(TRITON_SLOT, m.data.getInt("id"), it)
+ }
+ MSG_TRITON_CONN -> setTriton(m.data.getBoolean("on"), m.data.getString("what") ?: "")
+ MSG_HOOKUP_ADD -> { HookupBridge.add(m.data); startHookupPoll(); updateHookupPad() }
+ MSG_HOOKUP_INPUT -> m.data.getByteArray("data")?.let { HookupBridge.input(m.data.getInt("slot"), it) }
+ MSG_HOOKUP_STATE -> {
+ val live = m.data.getIntArray("slots") ?: IntArray(0)
+ // the bridge maps a different (or no) shared file: hand it the current one (at most every 10 s)
+ if (m.data.getLong("fast_ino", 0L) != HookupBridge.shmInode() &&
+ android.os.SystemClock.uptimeMillis() - shmSentMs > 10_000) sendShm()
+ for (s in 0 until HookupBridge.SLOTS) if (HookupBridge.isPresent(s) && s !in live) {
+ HookupBridge.note("hookup: the bridge no longer hands over slot $s; removing it")
+ HookupBridge.remove(s)
+ }
+ if (live.isNotEmpty()) startHookupPoll()
+ updateHookupPad()
+ }
+ MSG_BRIDGE_LOG -> m.data.getString("text")?.split('\n')?.forEach { HookupBridge.note("sdl3bridge: $it") }
+ MSG_HOOKUP_REMOVE -> { HookupBridge.remove(m.data.getInt("slot")); updateHookupPad() }
+ MSG_HOOKUP_REPLY -> HookupBridge.reply(m.data.getInt("slot"), m.data.getLong("request"), m.data.getByteArray("data"))
+ MSG_TRITON_FEATURE -> {
+ val request = m.data.getLong("request")
+ if (request == tritonFeatureAsked) FakeInputWriter.writeTritonFeatureReply(TRITON_SLOT, request, m.data.getByteArray("data"))
+ }
+ }
+ }
+ })
+
+ private val conn = object : ServiceConnection {
+ override fun onServiceConnected(n: ComponentName, b: IBinder) {
+ server = Messenger(b)
+ send(Message.obtain(null, MSG_REGISTER).apply { replyTo = inbox })
+ sendShm()
+ workerHandler.post { if (onDesktop()) sendSync() } // the desktop: say at once whether Steam runs
+ }
+ override fun onServiceDisconnected(n: ComponentName) {
+ server = null; release()
+ workerHandler.post { setTriton(false, "bridge stopped"); HookupBridge.removeAll() }
+ }
+ }
+
+ /** @return false if the bridge app is not installed / not bindable. */
+ @JvmStatic
+ @Synchronized
+ fun attach(context: Context, padBridge: PadBridge): Boolean {
+ pad = padBridge
+ owner = context
+ if (!sessionActive) {
+ sessionActive = true
+ workerHandler.removeCallbacks(watchdog)
+ workerHandler.postDelayed(watchdog, WATCHDOG_MS)
+ }
+ if (bound) {
+ // A new session screen while the old one is still being torn down (Android recreates it when you
+ // come back from another app): keep the connection, and ask the bridge to resend its state.
+ send(Message.obtain(null, MSG_REGISTER).apply { replyTo = inbox })
+ sendShm()
+ HookupBridge.note("bridge: new session screen, connection kept, state re-requested")
+ return true
+ }
+ val app = context.applicationContext
+ appContext = app
+ bound = try {
+ app.bindService(Intent(BRIDGE_ACTION).setPackage(bridgePackage(app)), conn, Context.BIND_AUTO_CREATE)
+ } catch (e: SecurityException) { false }
+ return bound
+ }
+
+ @JvmStatic
+ @Synchronized
+ fun detach() = detach(null)
+
+ /**
+ * The session screen is closing (Android also does this when you switch apps; a new screen attaches when you
+ * come back). Only its PadBridge is dropped -- the bridge stays connected, so Steam Native Hook-up and the
+ * Steam Controller passthrough keep running until the session ends or DroidDeck is closed.
+ */
+ @JvmStatic
+ @Synchronized
+ fun screenClosed(who: Any?) {
+ if (who !== owner) return
+ HookupBridge.note("bridge: session screen closed; the bridge stays connected until the session ends")
+ owner = null
+ pad = null
+ }
+
+ /** Only the screen that attached last may detach (an old screen's late onDestroy must not cut the new one off). */
+ @JvmStatic
+ @Synchronized
+ fun detach(who: Any?) {
+ if (who != null && who !== owner) {
+ HookupBridge.note("bridge: an old session screen closed; the bridge stays connected")
+ return
+ }
+ HookupBridge.note("bridge: disconnected (session ended)")
+ sessionActive = false
+ steamAwayNoted = null
+ workerHandler.removeCallbacks(watchdog)
+ owner = null
+ if (bound) {
+ send(Message.obtain(null, MSG_UNREGISTER).apply { replyTo = inbox })
+ try { appContext?.unbindService(conn) } catch (_: IllegalArgumentException) {}
+ bound = false
+ }
+ release()
+ workerHandler.post { setTriton(false, "session ended"); HookupBridge.removeAll() }
+ pad = null
+ server = null
+ }
+
+ // ---------------- Steam Controller (2026) raw passthrough ----------------
+ // The bridge streams the controller's own HID reports; they go into the same shared block DroidDeck's
+ // Bluetooth LE Steam Controller support uses, so Steam sees DroidDeck's Steam Controller node and drives
+ // it natively (capacitive stick touch, grip sense, trackpad pressure, IMU, haptics, settings). Steam's
+ // output reports and feature reads/writes travel back to the controller the same way.
+ @Volatile private var tritonOn = false
+ private var tritonOutputSeq = 0L
+ private var tritonFeatureAsked = 0L
+ private val tritonPoll = object : Runnable {
+ override fun run() {
+ if (!tritonOn) return
+ while (true) {
+ val out = FakeInputWriter.readTritonOutput(TRITON_SLOT, tritonOutputSeq) ?: break
+ tritonOutputSeq = java.nio.ByteBuffer.wrap(out, 0, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN).long
+ send(Message.obtain(null, MSG_TRITON_OUTPUT).apply {
+ data = Bundle().apply {
+ putByteArray("data", out.copyOfRange(9, out.size))
+ putBoolean("feature", out[8] != 0.toByte())
+ }
+ })
+ }
+ val request = FakeInputWriter.readTritonFeatureRequest(TRITON_SLOT)
+ if (request != 0L && request != tritonFeatureAsked) {
+ tritonFeatureAsked = request
+ send(Message.obtain(null, MSG_TRITON_GET).apply {
+ data = Bundle().apply { putLong("request", request); putInt("id", 1) }
+ })
+ }
+ workerHandler.postDelayed(this, TRITON_POLL_MS)
+ }
+ }
+
+ // ---------------- Steam Native Hook-up fast path ----------------
+ // The bridge gets the shared hook-up file itself (as a descriptor: Android lets an app use a descriptor it is
+ // handed, only opening another app's file by path is forbidden). It then writes each report straight into
+ // the file and wakes Steam's reader -- no message per report, no polling -- and DroidDeck only keeps the
+ // controller list and its sysfs description. An older bridge simply ignores the message.
+ @Volatile private var shmSentMs = 0L
+
+ private fun sendShm() {
+ val file = HookupBridge.shmFile ?: return
+ if (server == null) return
+ shmSentMs = android.os.SystemClock.uptimeMillis()
+ try {
+ android.os.ParcelFileDescriptor.open(file, android.os.ParcelFileDescriptor.MODE_READ_WRITE).use { pfd ->
+ send(Message.obtain(null, MSG_HOOKUP_SHM).apply { data = Bundle().apply { putParcelable("fd", pfd) } })
+ }
+ } catch (e: Exception) {
+ HookupBridge.note("hookup: could not hand the shared file to the bridge: $e")
+ }
+ }
+
+ // ---------------- background watchdog ----------------
+ // For the whole session (not just while the session screen is open), like Steam on a PC: reconnect to the
+ // bridge if that connection is gone (bridge restarted, killed, installed late), and ask it every 1.5 s what it
+ // is handing over -- controllers that appeared are added, ones it no longer has are removed, and nothing
+ // changes for ones that are already there. Also keeps DroidDeck's own pad withdrawn while Steam has a real one.
+ @Volatile private var sessionActive = false
+ private var serverLostTicks = 0
+ private val watchdog = object : Runnable {
+ override fun run() {
+ if (!sessionActive) return
+ val ctx = appContext
+ if (!bound && ctx != null) {
+ bound = try {
+ ctx.bindService(Intent(BRIDGE_ACTION).setPackage(bridgePackage(ctx)), conn, Context.BIND_AUTO_CREATE)
+ } catch (_: SecurityException) { false }
+ if (bound) HookupBridge.note("bridge: watchdog (re)connecting to the SDL3 bridge")
+ } else if (server != null) {
+ serverLostTicks = 0
+ sendSync()
+ } else if (bound && ++serverLostTicks >= 4) {
+ // bound but no connection for ~6 s (the bridge app was force-stopped): start over
+ serverLostTicks = 0
+ try { ctx?.unbindService(conn) } catch (_: IllegalArgumentException) {}
+ bound = false
+ HookupBridge.note("bridge: the SDL3 bridge went away; the watchdog will reconnect")
+ }
+ updateHookupPad()
+ workerHandler.postDelayed(this, WATCHDOG_MS)
+ }
+ }
+
+ // ---------------- DroidDeck's desktop: Steam Native Hook-up waits for Steam ----------------
+ // On the desktop Steam is one program among others, and it may not be running. The bridge then hands controllers
+ // to Steam only while the Steam client runs (its heartbeat in the hook-up file); until it starts -- and after it
+ // quits -- they stay ordinary gamepads every program can use. In the Steam session Steam always runs.
+ @Volatile private var steamAwayNoted: Boolean? = null
+
+ private fun onDesktop(): Boolean = try {
+ com.droiddeck.launcher.session.SessionState.mode == com.droiddeck.launcher.session.SessionService.MODE_DESKTOP
+ } catch (_: Throwable) { false }
+
+ private fun desktopWithoutSteam(): Boolean {
+ val desktop = onDesktop()
+ val away = desktop && !HookupBridge.steamRunning()
+ if (desktop && away != steamAwayNoted) {
+ steamAwayNoted = away
+ HookupBridge.note(if (away) "hookup: Steam is not running on the desktop: with Steam Native Hook-up on, the SDL3 " +
+ "bridge keeps controllers as ordinary gamepads until Steam starts"
+ else "hookup: Steam runs on the desktop: with Steam Native Hook-up on, the SDL3 bridge hands controllers to Steam")
+ }
+ return away
+ }
+
+ /** "Send me everything you hand over right now" -- and, on the desktop, whether Steam is there to take it. */
+ private fun sendSync() {
+ send(Message.obtain(null, MSG_HOOKUP_SYNC).apply {
+ replyTo = inbox
+ data = Bundle().apply { putBoolean("steamAway", desktopWithoutSteam()) }
+ })
+ }
+
+ // ---------------- Steam Native Hook-up: Steam -> controller ----------------
+ private var hookupPolling = false
+ private var hookupPollTicks = 0
+ private val hookupPoll = object : Runnable {
+ override fun run() {
+ if (!HookupBridge.anyPresent()) { hookupPolling = false; return }
+ // every ~250 ms: keep DroidDeck's own pad withdrawn (also once a bridged gamepad's last frame is
+ // more than a second old -- e.g. right after hook-up was switched back on)
+ if (++hookupPollTicks % 250 == 0) updateHookupPad()
+ HookupBridge.poll(
+ { slot, kind, report ->
+ send(Message.obtain(null, MSG_HOOKUP_OUT).apply {
+ data = Bundle().apply { putInt("slot", slot); putInt("kind", kind); putByteArray("data", report) }
+ })
+ },
+ { slot, request, id, input, length ->
+ send(Message.obtain(null, MSG_HOOKUP_GET).apply {
+ data = Bundle().apply {
+ putInt("slot", slot); putLong("request", request); putInt("id", id)
+ putBoolean("input", input); putInt("length", length)
+ }
+ })
+ })
+ workerHandler.postDelayed(this, HOOKUP_POLL_MS)
+ }
+ }
+
+ // While real controllers are handed to Steam and no bridged gamepad is streaming, DroidDeck's own pad
+ // is withdrawn so the real controller is player 1 in Steam (pad type 7 = none), like on a PC.
+ private var hookupPadHidden = false
+
+ private val padTypeField = runCatching {
+ FakeInputWriter::class.java.getDeclaredField("padType").apply { isAccessible = true }
+ }.getOrNull()
+ private fun currentPadType(): Int = runCatching { padTypeField?.getInt(null) ?: -2 }.getOrDefault(-2)
+
+ private fun updateHookupPad() {
+ val hide = HookupBridge.anyPresent() && !isActive()
+ if (hide && hookupPadHidden && currentPadType() != FakeInputWriter.PAD_TYPE_NONE) {
+ // something switched DroidDeck's pad back on (e.g. the pad-type menu): withdraw it again
+ HookupBridge.note("hookup: DroidDeck's pad came back while a controller is handed to Steam; withdrawing it again")
+ applyPadType(FakeInputWriter.PAD_TYPE_NONE)
+ }
+ if (hide && !hookupPadHidden) {
+ hookupPadHidden = true
+ lastPadType = -1
+ applyPadType(FakeInputWriter.PAD_TYPE_NONE)
+ } else if (!hide && hookupPadHidden) {
+ hookupPadHidden = false
+ val ctx = appContext ?: return
+ val pt = com.droiddeck.launcher.session.SessionPrefs.padType(com.droiddeck.launcher.session.SessionPrefs.steamController(ctx))
+ lastPadType = pt
+ applyPadType(pt)
+ }
+ }
+
+ private fun startHookupPoll() {
+ if (hookupPolling) return
+ hookupPolling = true
+ workerHandler.post(hookupPoll)
+ }
+
+ /** Runs on the worker thread. */
+ private fun setTriton(on: Boolean, what: String) {
+ if (on == tritonOn) return
+ tritonOn = on
+ HookupBridge.tritonPassthrough = on
+ workerHandler.removeCallbacks(tritonPoll)
+ if (on) {
+ tritonFeatureAsked = 0L
+ FakeInputWriter.setTritonConnected(TRITON_SLOT, true)
+ workerHandler.post(tritonPoll)
+ } else {
+ tritonRumbleStop?.let { rumbleTimer.removeCallbacks(it) }
+ tritonRumbleStop = null
+ FakeInputWriter.setTritonConnected(TRITON_SLOT, false)
+ FakeInputWriter.writeTritonState(TRITON_SLOT, 0x45, ByteArray(TRITON_STATE_BYTES))
+ }
+ android.util.Log.i("SessionService", "steam controller: SDL3 bridge passthrough ${if (on) "on ($what)" else "off ($what)"}")
+ }
+
+ /** True while a Steam Controller (2026) is passed through by the bridge (cable or puck). */
+ @JvmStatic fun isTritonPassthrough(): Boolean = tritonOn
+
+ /**
+ * True when the SDL3 bridge app is installed. DroidDeck then prepares its Steam Controller node at session
+ * start even without a Bluetooth-paired one, so a cable/puck controller can appear at any time (hot-plug).
+ */
+ @JvmStatic
+ fun isBridgeInstalled(context: Context): Boolean = try {
+ context.packageManager.getPackageInfo(bridgePackage(context), 0); true
+ } catch (_: Exception) { false }
+
+ @Volatile private var bridgePkg: String? = null
+
+ /**
+ * Where the SDL3 bridge is: built into this app (SDL3DroidDeck: the same bridge, in its own ":sdl3" process), or
+ * the separate SDL3Bridge app (SDL3Deck). The built-in one is used whenever this app has it.
+ */
+ @JvmStatic
+ fun bridgePackage(context: Context): String {
+ bridgePkg?.let { return it }
+ val own = context.packageName
+ val builtIn = try {
+ context.packageManager.resolveService(Intent(BRIDGE_ACTION).setPackage(own), 0) != null
+ } catch (_: Exception) { false }
+ return (if (builtIn) own else BRIDGE_PKG).also { bridgePkg = it }
+ }
+
+ /** True when the bridge is built into this app (SDL3DroidDeck). */
+ @JvmStatic
+ fun bridgeBuiltIn(context: Context): Boolean = bridgePackage(context) == context.packageName
+
+ private var tritonRumbleStop: Runnable? = null
+
+ private fun tritonRumble(strong: Int, weak: Int, ms: Int) {
+ rumbleTimer.post {
+ tritonRumbleStop?.let { rumbleTimer.removeCallbacks(it) }
+ tritonRumbleStop = null
+ sendTritonRumble(strong, weak)
+ if (strong == 0 && weak == 0) return@post
+ val hold = if (ms == RUMBLE_HOLD_MS || ms <= 0) HOLD_SAFETY_MS else ms.toLong().coerceIn(1L, 5000L)
+ val stop = Runnable { sendTritonRumble(0, 0); tritonRumbleStop = null }
+ tritonRumbleStop = stop
+ rumbleTimer.postDelayed(stop, hold)
+ }
+ }
+
+ private fun sendTritonRumble(strong: Int, weak: Int) {
+ send(Message.obtain(null, MSG_TRITON_RUMBLE).apply {
+ data = Bundle().apply { putInt("l", strong.coerceIn(0, 65535)); putInt("r", weak.coerceIn(0, 65535)) }
+ })
+ }
+
+ /** Forward game rumble (0..255 each) to the physical controller of player 1. */
+ // Per-bridge-slot pending auto-stop. The bridge's rumble is level-based (stays on until set to 0),
+ // so we must stop it ourselves after the effect's duration — exactly as RumbleComponent does with its
+ // one-shots. Without this a single "ping" (one 2000 ms buzz) would rumble the controller forever.
+ private val rumbleTimer = Handler(worker.looper)
+ private val rumbleStop = arrayOfNulls(SLOTS)
+ private const val RUMBLE_HOLD_MS = 0xFFFF // matches RumbleComponent.HOLD_MS
+ private const val HOLD_SAFETY_MS = 30_000L
+
+ @JvmStatic
+ fun rumble(strong: Int, weak: Int) = rumble(0, strong, weak, RUMBLE_HOLD_MS)
+
+ @JvmStatic
+ fun rumble(deckSlot: Int, strong: Int, weak: Int) = rumble(deckSlot, strong, weak, RUMBLE_HOLD_MS)
+
+ /**
+ * Rumble for a DroidDeck player slot (0 = player 1), honoring the effect duration [ms].
+ * HOLD effects (0xFFFF) play until the explicit stop that follows them (30 s safety cap); a timed
+ * one-shot (e.g. the controller "ping") plays for its own duration and then stops.
+ */
+ @JvmStatic
+ fun rumble(deckSlot: Int, strong: Int, weak: Int, ms: Int) {
+ rumbleTimer.post {
+ val bridgeSlot = (0 until SLOTS).firstOrNull { ddSlot[it] == deckSlot } ?: return@post
+ rumbleStop[bridgeSlot]?.let { rumbleTimer.removeCallbacks(it); rumbleStop[bridgeSlot] = null }
+ sendRumble(bridgeSlot, strong, weak)
+ if (strong == 0 && weak == 0) return@post
+ // HOLD = play until the game's/Steam's explicit stop (they always send one); the long cap is only a
+ // safety net in case that stop is lost (e.g. the game crashes mid-rumble).
+ val hold = if (ms == RUMBLE_HOLD_MS || ms <= 0) HOLD_SAFETY_MS else ms.toLong().coerceIn(1L, 5000L)
+ val stop = Runnable { sendRumble(bridgeSlot, 0, 0); rumbleStop[bridgeSlot] = null }
+ rumbleStop[bridgeSlot] = stop
+ rumbleTimer.postDelayed(stop, hold)
+ }
+ }
+
+ private fun sendRumble(bridgeSlot: Int, strong: Int, weak: Int) {
+ send(Message.obtain(null, MSG_OUTPUT).apply {
+ data = Bundle().apply {
+ putInt("slot", bridgeSlot)
+ putInt("rl", strong.coerceIn(0, 255)); putInt("rr", weak.coerceIn(0, 255))
+ }
+ })
+ }
+
+ private fun send(m: Message) { try { server?.send(m) } catch (_: RemoteException) {} }
+
+ // ---------------- frame handling ----------------
+
+ private fun stick(v: Int): Float {
+ val f = ((v - 128) / 127f).coerceIn(-1f, 1f)
+ return if (Math.abs(f) < DEAD_ZONE) 0f else f
+ }
+
+ /** True while player 1's controller is streaming; PadMotion uses this to stop writing the phone's gyro. */
+ @JvmStatic
+ fun isActive(): Boolean = frameMsS[0] != 0L && SystemClock.elapsedRealtime() - frameMsS[0] < 1000L
+
+ // ---------------- frames: latest wins ----------------
+ // Each bridge frame is a complete controller state, so when several wait for the worker only the newest matters
+ // -- except frames where a button, trackpad touch or click changed, which are all kept so no tap is lost. A
+ // frame that only moves sticks / gyro and is already older than FRAME_STALE_NS (a backlog inside Binder) is
+ // dropped: its state is out of date and a newer frame follows.
+ private const val FRAME_STALE_NS = 50_000_000L
+ private const val FRAME_PENDING_MAX = 16
+ private val frames = FrameMailbox(SLOTS, FRAME_STALE_NS, FRAME_PENDING_MAX,
+ sigOf = ::frameSig, sentNsOf = { it.getLong("t", 0L) }, nowNs = SystemClock::elapsedRealtimeNanos)
+ private val drainFrames = Array(SLOTS) { slot -> Runnable { drainFrames(slot) } }
+
+ /** The digital part of a frame: buttons, trackpad click side, which trackpads are touched. */
+ private fun frameSig(b: Bundle): Long {
+ val padL = b.getFloatArray("padL")
+ val padR = b.getFloatArray("padR")
+ val touch = b.getIntArray("touch")
+ return (b.getInt("mask").toLong() and 0xffffffffL) or
+ ((b.getInt("padClick", -1) + 1).toLong() shl 32) or
+ ((if (padL != null && padL.isNotEmpty() && padL[0] > 0f) 1L else 0L) shl 34) or
+ ((if (padR != null && padR.isNotEmpty() && padR[0] > 0f) 1L else 0L) shl 35) or
+ (((touch?.size ?: 0) / 3).toLong() shl 36)
+ }
+
+ private fun offerFrame(b: Bundle) {
+ val slot = b.getInt("slot", 0).coerceIn(0, SLOTS - 1)
+ if (frames.offer(slot, b)) workerHandler.post(drainFrames[slot])
+ }
+
+ private fun drainFrames(slot: Int) {
+ val now = SystemClock.elapsedRealtimeNanos()
+ for (f in frames.take(slot)) {
+ val sent = f.getLong("t", 0L)
+ if (sent != 0L) lagMaxNs = maxOf(lagMaxNs, now - sent)
+ lagHandled++
+ onFrame(f)
+ }
+ lagReport(now)
+ }
+
+ // Lag meter, written to pad.log every 10 s when input was late: how old frames were when handled (bridge ->
+ // Binder -> this thread) and how many stale ones were dropped. Lag that keeps growing shows up here.
+ private var lagHandled = 0
+ private var lagMaxNs = 0L
+ private var lagSinceNs = 0L
+ private fun lagReport(now: Long) {
+ if (lagSinceNs == 0L) lagSinceNs = now
+ if (now - lagSinceNs < 10_000_000_000L) return
+ val dropped = frames.takeDropped()
+ if (lagMaxNs > 20_000_000L || dropped > 0)
+ HookupBridge.note("bridge: input lag, last 10 s: %d frames handled, oldest %.1f ms; %d stale frames dropped"
+ .format(lagHandled, lagMaxNs / 1e6, dropped))
+ lagSinceNs = now; lagHandled = 0; lagMaxNs = 0L
+ }
+
+ /**
+ * True when [dev] is the same physical controller the SDL3 bridge is currently streaming (any player slot).
+ * Android reports that controller too (basic input), so DroidDeck's own gamepad handling must
+ * ignore it while the bridge is active, otherwise every press arrives twice.
+ */
+ @JvmStatic
+ fun isBridged(dev: InputDevice?): Boolean {
+ if (dev == null) return false
+ val now = SystemClock.elapsedRealtime()
+ for (i in 0 until SLOTS) {
+ if (vidS[i] != 0 && frameMsS[i] != 0L && now - frameMsS[i] < 1000L &&
+ dev.vendorId == vidS[i] && dev.productId == pidS[i]) return true
+ }
+ if (sepVid != 0 && sepMs != 0L && now - sepMs < 1000L &&
+ dev.vendorId == sepVid && dev.productId == sepPid) return true
+ return false
+ }
+
+ /** True when DroidDeck player [deckSlot] is currently driven by a bridge controller (for rumble routing). */
+ @JvmStatic
+ fun isBridgedSlot(deckSlot: Int): Boolean {
+ val now = SystemClock.elapsedRealtime()
+ for (i in 0 until SLOTS) {
+ if (ddSlot[i] == deckSlot && frameMsS[i] != 0L && now - frameMsS[i] < 1000L) return true
+ }
+ return false
+ }
+
+ /**
+ * Called first thing in RumbleComponent.buzz(). Returns true when the effect was sent to a bridged
+ * controller (and must NOT also go to the phone / an Android pad).
+ *
+ * - slot 0..3: a DroidDeck player slot -> its bridged controller, if one drives it.
+ * - slot >= PadBridge.SLOTS (e.g. 16): a game rumbling Steam's VIRTUAL pad. Upstream sends that to the
+ * "last active Android controller", which is nobody while the bridge holds the pad, so it fell back to
+ * the phone's vibrator. Route it to player 1's bridged controller instead (unless a real Android pad is
+ * the active one, or it is a SteamVR Joy-Con hand slot).
+ */
+ @JvmStatic
+ fun routeRumble(slot: Int, strong: Int, weak: Int, ms: Int): Boolean {
+ // A real controller Steam drives itself is connected (Steam Native Hook-up / Steam Controller
+ // passthrough): game rumble on Steam's virtual pad goes to Steam (fakeinput_steam.cpp hands it the
+ // uinput force-feedback request) and Steam rumbles that controller with its own driver.
+ if (slot >= PadBridge.SLOTS && JoyCon.vrRumbleDevice(slot) == null && HookupBridge.ffToSteam()) return true
+ // Only a passed-through Steam Controller (2026), no bridged gamepad: player 1 / virtual-pad rumble -> it.
+ if (tritonOn && !isBridgedSlot(0) && (slot == 0 || slot >= PadBridge.SLOTS) && JoyCon.vrRumbleDevice(slot) == null &&
+ (slot == 0 || !realAndroidPadActive())) {
+ val on = (strong != 0 || weak != 0) && ms != 0
+ if (on) tritonRumble(strong, weak, ms) else tritonRumble(0, 0, 0)
+ return true
+ }
+ val deckSlot = when {
+ slot in 0 until PadBridge.SLOTS -> if (isBridgedSlot(slot)) slot else return false
+ JoyCon.vrRumbleDevice(slot) != null -> return false
+ !isBridgedSlot(0) -> return false
+ else -> {
+ if (realAndroidPadActive()) return false // a real Android pad with motors is in use: leave it to upstream
+ 0
+ }
+ }
+ val on = (strong != 0 || weak != 0) && ms != 0 // ms == 0 is a stop, as in RumbleComponent
+ if (on != rumbleLogged) {
+ rumbleLogged = on
+ android.util.Log.i("SessionService", "rumble: slot $slot -> SDL3 bridge (player ${deckSlot + 1}) ${if (on) "on ${strong shr 8}/${weak shr 8} ${ms}ms" else "off"}")
+ }
+ if (on) rumble(deckSlot, strong shr 8, weak shr 8, ms) else rumble(deckSlot, 0, 0, 0)
+ return true
+ }
+ @Volatile private var rumbleLogged = false
+
+ private fun realAndroidPadActive(): Boolean {
+ val id = PadBridge.activeControllerId()
+ if (id == PadBridge.NO_CONTROLLER) return false
+ val dev = InputDevice.getDevice(id) ?: return false
+ return !isBridged(dev) && hasMotors(dev)
+ }
+
+ private fun hasMotors(dev: InputDevice): Boolean = try {
+ if (android.os.Build.VERSION.SDK_INT >= 31) dev.vibratorManager.vibratorIds.isNotEmpty()
+ else @Suppress("DEPRECATION") dev.vibrator.hasVibrator()
+ } catch (_: Exception) { false }
+
+ @JvmStatic fun isBridged(e: KeyEvent): Boolean = isBridged(e.device)
+ @JvmStatic fun isBridged(e: MotionEvent): Boolean = isBridged(e.device)
+
+ /** A controller in bridge slot [slot] was unplugged. */
+ private fun padGone(slot: Int) {
+ if (slot !in 0 until SLOTS) return
+ if (slot == 0) { release(); return }
+ val p = pad
+ val dd = ddSlot[slot]
+ if (p != null && dd > 0) {
+ p.applyExternalSlot(dd, { s: PadState -> fill(s, 0, FloatArray(6)) }, false)
+ p.releaseBridgeSlot(dd)
+ }
+ ddSlot[slot] = -1
+ frameMsS[slot] = 0L
+ vidS[slot] = 0; pidS[slot] = 0
+ maskS[slot] = 0; axesS[slot].fill(0f)
+ }
+
+ /** Extra controllers (bridge slots 1..3) become DroidDeck players 2..4: plain gamepad plus gyro. */
+ private fun onExtraFrame(slot: Int, b: Bundle) {
+ val p = pad ?: return
+ val now = SystemClock.elapsedRealtime()
+ frameMsS[slot] = now
+ vidS[slot] = b.getInt("vid"); pidS[slot] = b.getInt("pid")
+ var dd = ddSlot[slot]
+ if (dd <= 0) {
+ dd = p.claimBridgeSlot()
+ if (dd <= 0) return // all DroidDeck player slots are taken
+ ddSlot[slot] = dd
+ }
+ val mask = b.getInt("mask")
+ val ax = floatArrayOf(
+ stick(b.getInt("lx")), stick(b.getInt("ly")), stick(b.getInt("rx")), stick(b.getInt("ry")),
+ b.getInt("l2") / 255f, b.getInt("r2") / 255f,
+ )
+ val buttonsChanged = mask != maskS[slot]
+ val axesChanged = !ax.contentEquals(axesS[slot])
+ if (buttonsChanged || (axesChanged && now - applyMsS[slot] >= MIN_AXIS_INTERVAL_MS)) {
+ maskS[slot] = mask
+ ax.copyInto(axesS[slot])
+ applyMsS[slot] = now
+ p.applyExternalSlot(dd, { s: PadState -> fill(s, mask, ax) }, buttonsChanged)
+ }
+ val g = b.getFloatArray("gyro")
+ val a = b.getFloatArray("accel")
+ if (g != null && a != null) {
+ FakeInputWriter.writeMotion(dd, shorts(a, ACCEL_LSB_PER_G, ACCEL_SIGN), shorts(g, GYRO_LSB_PER_DPS * GYRO_GAIN, GYRO_SIGN))
+ }
+ }
+
+ private fun axisS(v: Float): Short = (v.coerceIn(-1f, 1f) * 32767f).toInt().toShort()
+
+ /** The DualSense as its own full-featured DroidDeck controller (separate Steam entry). */
+ private fun onSeparateFrame(b: Bundle) {
+ val p = pad ?: return
+ val now = SystemClock.elapsedRealtime()
+ sepMs = now
+ sepVid = b.getInt("vid"); sepPid = b.getInt("pid")
+ if (sepSlot <= 0) {
+ sepSlot = p.claimBridgeSlot()
+ if (sepSlot <= 0) return // no free DroidDeck slot
+ lastSepControls = -1
+ }
+ val mask = b.getInt("mask")
+ val ax = floatArrayOf(
+ stick(b.getInt("lx")), stick(b.getInt("ly")), stick(b.getInt("rx")), stick(b.getInt("ry")),
+ b.getInt("l2") / 255f, b.getInt("r2") / 255f,
+ )
+ val buttonsChanged = mask != sepMask
+ val axesChanged = !ax.contentEquals(sepAxes)
+ if (buttonsChanged || (axesChanged && now - sepApplyMs >= MIN_AXIS_INTERVAL_MS)) {
+ sepMask = mask
+ ax.copyInto(sepAxes)
+ sepApplyMs = now
+ p.applyExternalSlot(sepSlot, { s: PadState -> fill(s, mask, ax) }, buttonsChanged)
+ }
+
+ // back buttons + trackpads + clicks -> this slot's Deck controls
+ var controls = 0
+ if (((mask shr 19) and 1) != 0) controls = controls or DeckControls.L4
+ if (((mask shr 20) and 1) != 0) controls = controls or DeckControls.R4
+ if (((mask shr 21) and 1) != 0) controls = controls or DeckControls.L5
+ if (((mask shr 22) and 1) != 0) controls = controls or DeckControls.R5
+ val pads = ShortArray(4)
+ val padL = b.getFloatArray("padL")
+ val padR = b.getFloatArray("padR")
+ if (padL != null && padL.size >= 3 && padL[0] > 0.5f) {
+ val xy = orientPad(padL[1], padL[2]); controls = controls or DC_LEFT_TOUCH
+ pads[0] = axisS(xy[0]); pads[1] = axisS(xy[1])
+ }
+ if (padR != null && padR.size >= 3 && padR[0] > 0.5f) {
+ val xy = orientPad(padR[1], padR[2]); controls = controls or DC_RIGHT_TOUCH
+ pads[2] = axisS(xy[0]); pads[3] = axisS(xy[1])
+ }
+ val pressure = ShortArray(2)
+ when (b.getInt("padClick", -1)) {
+ 0 -> { controls = controls or DC_LEFT_CLICK; pressure[0] = Short.MAX_VALUE }
+ 1 -> { controls = controls or DC_RIGHT_CLICK; pressure[1] = Short.MAX_VALUE }
+ }
+ if (controls != lastSepControls || !pads.contentEquals(lastSepPads) || !pressure.contentEquals(lastSepPressure)) {
+ FakeInputWriter.writeDeckControls(sepSlot, controls, pads, pressure)
+ lastSepControls = controls
+ pads.copyInto(lastSepPads)
+ pressure.copyInto(lastSepPressure)
+ }
+
+ val g = b.getFloatArray("gyro")
+ val a = b.getFloatArray("accel")
+ if (g != null && a != null) {
+ FakeInputWriter.writeMotion(sepSlot, shorts(a, ACCEL_LSB_PER_G, ACCEL_SIGN), shorts(g, GYRO_LSB_PER_DPS * GYRO_GAIN, GYRO_SIGN))
+ }
+ }
+
+ private fun orientPad(x0: Float, y0: Float): FloatArray {
+ var x = x0; var y = y0
+ if (TOUCH_SWAP) { val t = x; x = y; y = t }
+ if (TOUCH_INV_X) x = -x
+ if (TOUCH_INV_Y) y = -y
+ return floatArrayOf(x, y)
+ }
+
+ private fun releaseSep() {
+ val p = pad
+ if (p != null && sepSlot > 0) {
+ FakeInputWriter.writeDeckControls(sepSlot, 0, ShortArray(4), ShortArray(2))
+ p.applyExternalSlot(sepSlot, { s: PadState -> fill(s, 0, FloatArray(6)) }, false)
+ p.releaseBridgeSlot(sepSlot)
+ }
+ sepSlot = -1; sepMs = 0L; sepVid = 0; sepPid = 0; sepMask = 0; sepAxes.fill(0f)
+ lastSepControls = -1; lastSepPads.fill(0); lastSepPressure.fill(0)
+ }
+
+ /**
+ * Switch the emulated controller type exactly the way DroidDeck's own QAM does: first show the
+ * right hidraw node (creates /sys/class/hidraw symlink, removes the others) so Steam can enumerate
+ * it, THEN flip the shared pad_type. Without showPad() setPadType alone only changes the report
+ * format, so Steam never sees the new device and keeps the Steam Deck pad.
+ */
+ private fun applyPadType(pt: Int) {
+ try { com.droiddeck.launcher.session.SteamDeckPad.showPad(pt) } catch (t: Throwable) { }
+ FakeInputWriter.setPadType(pt)
+ }
+
+ private fun onFrame(b: Bundle) {
+ val slot = b.getInt("slot", 0).coerceIn(0, SLOTS - 1)
+ if (slot != 0) { onExtraFrame(slot, b); return }
+ val pt = b.getInt("ptype", 0)
+ if (hookupPadHidden) hookupPadHidden = false // a bridged gamepad needs DroidDeck's pad again
+ if (pt != lastPadType) { lastPadType = pt; applyPadType(pt) } // Deck / DualSense / DS4
+ val p = pad ?: return
+ frameMsS[0] = SystemClock.elapsedRealtime()
+ vidS[0] = b.getInt("vid"); pidS[0] = b.getInt("pid")
+ val mask = b.getInt("mask")
+ val ax = floatArrayOf(
+ stick(b.getInt("lx")), stick(b.getInt("ly")), stick(b.getInt("rx")), stick(b.getInt("ry")),
+ b.getInt("l2") / 255f, b.getInt("r2") / 255f,
+ )
+ val now = SystemClock.elapsedRealtime()
+ val prev = lastMask
+ val buttonsChanged = mask != prev
+ val axesChanged = !ax.contentEquals(lastAxes)
+ if (buttonsChanged || (axesChanged && now - lastApplyMs >= MIN_AXIS_INTERVAL_MS)) {
+ lastMask = mask
+ ax.copyInto(lastAxes)
+ lastApplyMs = now
+ p.applyExternal({ s: PadState -> fill(s, mask, ax) }, true)
+ if (buttonsChanged) {
+ val diff = mask xor prev
+ for ((bit, grip) in GRIPS) if (((diff shr bit) and 1) != 0) {
+ DeckControls.setGrip(grip, ((mask shr bit) and 1) != 0)
+ }
+ if (MAP_TOUCHPAD && !b.containsKey("padL") && ((diff shr 13) and 1) != 0) {
+ if (((mask shr 13) and 1) != 0) {
+ val sides = sidesOf(b.getIntArray("touch"))
+ clickSide = if (sides == 1) 0 else 1 // only left finger -> left click, else right
+ DeckControls.setClick(clickSide == 1, true)
+ } else if (clickSide >= 0) {
+ DeckControls.setClick(clickSide == 1, false)
+ clickSide = -1
+ }
+ }
+ }
+ }
+ if (MAP_TOUCHPAD && !decoupledTouch(b)) touch(b.getIntArray("touch"))
+ val g = b.getFloatArray("gyro")
+ val a = b.getFloatArray("accel")
+ if (g != null && a != null) {
+ FakeInputWriter.writeMotion(0, shorts(a, ACCEL_LSB_PER_G, ACCEL_SIGN), shorts(g, GYRO_LSB_PER_DPS * GYRO_GAIN, GYRO_SIGN))
+ }
+ }
+
+ private fun fill(s: PadState, mask: Int, ax: FloatArray) {
+ for ((bit, btn) in BUTTONS) s.press(btn, ((mask shr bit) and 1) != 0)
+ s.up = ((mask shr 15) and 1) != 0
+ s.down = ((mask shr 16) and 1) != 0
+ s.left = ((mask shr 17) and 1) != 0
+ s.right = ((mask shr 18) and 1) != 0
+ s.leftX = ax[0]; s.leftY = ax[1]; s.rightX = ax[2]; s.rightY = ax[3]
+ s.leftTrigger = ax[4]; s.rightTrigger = ax[5]
+ }
+
+ /**
+ * Newer bridge builds split the touchpad themselves (see the bridge's Touchpad layout setting):
+ * padL / padR = [down, x, y] with x and y already in -1..1 for that trackpad, padClick = -1 / 0 (left) / 1 (right).
+ * @return false when the bridge does not send this data (older bridge), so the caller falls back to raw fingers.
+ */
+ private fun decoupledTouch(b: Bundle): Boolean {
+ val l = b.getFloatArray("padL") ?: return false
+ val r = b.getFloatArray("padR") ?: return false
+ if (l.size < 3 || r.size < 3) return false
+ decoupledPad(false, l)
+ decoupledPad(true, r)
+ val c = b.getInt("padClick", -1)
+ if (c != clickSide) {
+ if (clickSide >= 0) DeckControls.setClick(clickSide == 1, false)
+ if (c >= 0) DeckControls.setClick(c == 1, true)
+ clickSide = c
+ }
+ return true
+ }
+
+ private fun decoupledPad(right: Boolean, p: FloatArray) {
+ val slot = if (right) 1 else 0
+ val touching = p[0] > 0.5f
+ val key = if (touching) (((p[1] * 1023f).toInt() and 0x7FF) shl 11) or ((p[2] * 1023f).toInt() and 0x7FF) else -1
+ if (key == lastTouchKey[slot]) return
+ lastTouchKey[slot] = key
+ if (touching) {
+ var x = p[1]; var y = p[2]
+ if (TOUCH_SWAP) { val t = x; x = y; y = t }
+ if (TOUCH_INV_X) x = -x
+ if (TOUCH_INV_Y) y = -y
+ DeckControls.setPad(right, true, x, y)
+ } else DeckControls.setPad(right, false)
+ }
+
+ /** Bit 0 = a finger on the left half, bit 1 = a finger on the right half. */
+ private fun sidesOf(t: IntArray?): Int {
+ var r = 0
+ var i = 0
+ while (t != null && i + 2 < t.size) { r = r or (if (t[i + 1] < TOUCH_HALF) 1 else 2); i += 3 }
+ return r
+ }
+
+ private fun touch(t: IntArray?) {
+ var left: IntArray? = null
+ var right: IntArray? = null
+ var i = 0
+ while (t != null && i + 2 < t.size) {
+ val pt = intArrayOf(t[i + 1], t[i + 2])
+ if (pt[0] < TOUCH_HALF) { if (left == null) left = pt } else { if (right == null) right = pt }
+ i += 3
+ }
+ pad(false, left)
+ pad(true, right)
+ }
+
+ private fun pad(right: Boolean, pt: IntArray?) {
+ val slot = if (right) 1 else 0
+ val key = if (pt != null) ((pt[0] shr 4) shl 16) or (pt[1] shr 4) else -1
+ if (key == lastTouchKey[slot]) return
+ lastTouchKey[slot] = key
+ if (pt == null) { DeckControls.setPad(right, false); return }
+ val local = if (right) pt[0] - TOUCH_HALF else pt[0]
+ DeckControls.setPad(right, true, local / TOUCH_HALF.toFloat() * 2f - 1f, pt[1] / TOUCH_H * 2f - 1f)
+ }
+
+ private fun shorts(v: FloatArray, scale: Float, sign: FloatArray) =
+ ShortArray(3) { (v[it] * scale * sign[it]).coerceIn(-32768f, 32767f).toInt().toShort() }
+
+ /** Neutral state: called when the bridge disconnects so nothing stays stuck. */
+ private fun release() {
+ frameMsS[0] = 0L
+ if (lastPadType != 0 && !hookupPadHidden) { lastPadType = 0; applyPadType(0) }
+ updateHookupPad()
+ for (k in 1 until SLOTS) padGone(k)
+ val p = pad ?: return
+ lastMask = 0
+ lastAxes.fill(0f)
+ lastTouchKey.fill(-1)
+ clickSide = -1
+ p.applyExternal({ s: PadState -> fill(s, 0, FloatArray(6)) }, false)
+ for (grip in GRIPS.values) DeckControls.setGrip(grip, false)
+ if (MAP_TOUCHPAD) {
+ DeckControls.setPad(false, false); DeckControls.setPad(true, false)
+ DeckControls.setClick(false, false); DeckControls.setClick(true, false)
+ }
+ }
+}
+
+// ==== FrameMailbox (self-contained: tested on its own) ====
+/**
+ * "Latest wins" queue of controller frames per slot, filled on Binder threads and emptied by the worker thread.
+ * A frame whose digital part (buttons, trackpad touches / click: [sigOf]) equals the newest waiting one replaces
+ * it -- only the newest analog state matters -- while every change of the digital part is kept, so no tap is lost.
+ * A frame that arrives already older than [staleNs] without a digital change is dropped (a backlog upstream).
+ */
+internal class FrameMailbox(
+ slots: Int,
+ private val staleNs: Long,
+ private val maxPending: Int,
+ private val sigOf: (T) -> Long,
+ private val sentNsOf: (T) -> Long,
+ private val nowNs: () -> Long,
+) {
+ private val pending = Array(slots) { ArrayDeque() }
+ private val queued = BooleanArray(slots)
+ private val offeredSig = LongArray(slots) { Long.MIN_VALUE }
+ private var dropped = 0
+
+ /** @return true when the caller must schedule [take] for this slot (it wasn't scheduled yet). */
+ @Synchronized
+ fun offer(slot: Int, frame: T): Boolean {
+ val sig = sigOf(frame)
+ val sent = sentNsOf(frame)
+ if (sent != 0L && nowNs() - sent > staleNs && sig == offeredSig[slot]) { dropped++; return false }
+ offeredSig[slot] = sig
+ val q = pending[slot]
+ val last = q.lastOrNull()
+ if (last != null && sigOf(last) == sig) q.removeAt(q.size - 1)
+ q.addLast(frame)
+ while (q.size > maxPending) q.removeAt(0)
+ if (queued[slot]) return false
+ queued[slot] = true
+ return true
+ }
+
+ /** Everything waiting for this slot, oldest first (usually one frame). */
+ @Synchronized
+ fun take(slot: Int): List {
+ val l = pending[slot].toList()
+ pending[slot].clear()
+ queued[slot] = false
+ return l
+ }
+
+ @Synchronized
+ fun takeDropped(): Int = dropped.also { dropped = 0 }
+}
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 05469e634..7699cf8bc 100644
--- a/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java
+++ b/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java
@@ -88,6 +88,9 @@ public class FakeInputWriter {
public static final int PAD_TYPE_XBOX = 2;
public static final int PAD_TYPE_DS4 = 3;
public static final int PAD_TYPE_DUALSENSE = 4;
+ public static final int PAD_TYPE_DUALSENSE_EDGE = 5;
+ public static final int PAD_TYPE_STEAM_2015 = 6;
+ public static final int PAD_TYPE_NONE = 7;
private static volatile int padType = -1;
private static volatile boolean tritonConnected = false;
private static final int TRITON_OFFSET = RING_SIZE + 64;
diff --git a/app/src/main/java/com/droiddeck/launcher/input/HookupBridge.kt b/app/src/main/java/com/droiddeck/launcher/input/HookupBridge.kt
new file mode 100644
index 000000000..46dc9c716
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/input/HookupBridge.kt
@@ -0,0 +1,380 @@
+package com.droiddeck.launcher.input
+
+import android.content.Context
+import android.os.Bundle
+import android.system.Os
+import android.util.Log
+import com.droiddeck.launcher.runtime.LinuxRuntime
+import java.io.File
+import java.io.RandomAccessFile
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import java.nio.MappedByteBuffer
+import java.nio.channels.FileChannel
+
+/**
+ * SDL3 bridge "Steam Native Hook-up", DroidDeck side.
+ *
+ * The bridge hands a USB controller over raw: its real identity and every HID report. Here each one becomes
+ * /dev/hidraw24..27 -- sysfs description written like the kernel would (real vendor/product id, name, serial,
+ * interface, report descriptor) and the reports moved through a file shared with fakeinput_steam.cpp
+ * (FAKE_HOOKUP_SHM). Steam's own driver then runs on the real controller, like on a PC.
+ *
+ * Layout must match the HookupHeader / HookupSlot structs in fakeinput_steam.cpp.
+ */
+object HookupBridge {
+ private const val TAG = "SessionService"
+ const val FILE = ".hookup"
+ private const val MAGIC = 0x4b4f4f48
+ const val SLOTS = 4
+ private const val IN_FIFO = 256
+ private const val OUT_FIFO = 64
+ private const val REPORT_MAX = 128
+ private const val REPORT_SIZE = 144
+ private const val DESC_MAX = 4096
+ private const val HEADER_SIZE = 4096
+ private const val SLOT_SIZE = 53248
+ private const val SIZE = HEADER_SIZE + SLOTS * SLOT_SIZE
+ private const val MINOR_BASE = 24
+ private const val MAJOR = 240
+
+ // header
+ private const val H_FF_TO_STEAM = 8
+ private const val H_STEAM_MS = 16 // the Steam client's heartbeat (CLOCK_MONOTONIC ms), written by fakeinput
+ private const val STEAM_GONE_MS = 5000L
+ // slot
+ private const val S_PRESENT = 0
+ private const val S_GENERATION = 4
+ private const val S_VENDOR = 8
+ private const val S_PRODUCT = 10
+ private const val S_VERSION = 12
+ private const val S_BUS = 14
+ private const val S_IFACE = 16
+ private const val S_DESC_LEN = 20
+ private const val S_NAME = 24
+ private const val S_SERIAL = 152
+ private const val S_MANUFACTURER = 216
+ private const val S_PRODUCT_NAME = 280
+ private const val S_IN_WRITE = 344
+ private const val S_OUT_WRITE = 352
+ private const val S_OUT_READ = 360
+ private const val S_GET_REQUEST = 368
+ private const val S_GET_REPLY = 376
+ private const val S_GET_REPORT_ID = 384
+ private const val S_GET_LENGTH = 388
+ private const val S_GET = 392
+ private const val S_DIRECT_MS = 520 // SDL3 bridge heartbeat while it serves the slot itself (fast path)
+ private const val S_DESC = 1024
+ private const val S_IN = 5120
+ private const val S_OUT = 41984
+
+ @Volatile private var map: MappedByteBuffer? = null
+ /** The shared file; handed to the SDL3 bridge as a descriptor so it can serve slots directly (fast path). */
+ @Volatile var shmFile: File? = null
+ private set
+ @Volatile private var logFile: File? = null
+
+ /** Also written to the session's pad.log, next to the native side's "hookup:" lines. */
+ internal fun note(line: String) {
+ Log.i(TAG, line)
+ val f = logFile ?: return
+ try {
+ val t = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.US).format(java.util.Date())
+ f.appendText("$t app: $line\n")
+ } catch (_: Exception) {}
+ }
+ private val present = BooleanArray(SLOTS)
+ private val getAsked = LongArray(SLOTS)
+ @Volatile var tritonPassthrough = false
+ set(value) { field = value; updateFfToSteam() }
+
+ /** Creates the shared file; returns it for FAKE_HOOKUP_SHM. Called at session start. */
+ @JvmStatic
+ fun prepare(fakeInputDir: File, padLog: File? = null): File? = try {
+ logFile = padLog
+ val file = File(fakeInputDir, FILE)
+ file.delete()
+ RandomAccessFile(file, "rw").use { raf ->
+ raf.setLength(SIZE.toLong())
+ val m = raf.channel.map(FileChannel.MapMode.READ_WRITE, 0, SIZE.toLong())
+ m.order(ByteOrder.LITTLE_ENDIAN)
+ m.putInt(4, 1)
+ m.putInt(0, MAGIC)
+ map = m
+ }
+ shmFile = file
+ file.setReadable(true, false)
+ file.setWritable(true, false)
+ present.fill(false)
+ note("hookup: shared file ready (${file.path}); waiting for the SDL3 bridge to hand over controllers")
+ file
+ } catch (e: Exception) {
+ note("hookup: no shared file: $e")
+ map = null
+ shmFile = null
+ null
+ }
+
+ private fun slotBase(slot: Int) = HEADER_SIZE + slot * SLOT_SIZE
+
+ /** Inode of the shared file (0 if none): the bridge reports the one it mapped, so a stale mapping is noticed. */
+ @JvmStatic fun shmInode(): Long = try { shmFile?.let { Os.stat(it.path).st_ino } ?: 0L } catch (_: Exception) { 0L }
+
+ /**
+ * The SDL3 bridge writes this slot's reports into the file itself and takes Steam's output / feature reads
+ * from it (its heartbeat is fresh): DroidDeck must leave the slot's FIFOs alone then.
+ */
+ private fun bridgeServes(m: ByteBuffer, slot: Int): Boolean {
+ val beat = m.getLong(slotBase(slot) + S_DIRECT_MS)
+ return beat != 0L && android.os.SystemClock.uptimeMillis() - beat < 1500
+ }
+
+ /**
+ * Is the Steam client running in this session? Its side of the hook-up writes a heartbeat into the shared file
+ * every 0.1 s. (No hook-up file this session: treated as running, so nothing waits for it.)
+ */
+ @JvmStatic fun steamRunning(): Boolean {
+ val m = map ?: return true
+ val beat = m.getLong(H_STEAM_MS)
+ return beat != 0L && android.os.SystemClock.uptimeMillis() - beat < STEAM_GONE_MS
+ }
+
+ @JvmStatic fun anyPresent(): Boolean = present.any { it }
+ @JvmStatic fun isPresent(slot: Int): Boolean = slot in 0 until SLOTS && present[slot]
+
+ /** True while Steam is handed game rumble (a real controller it drives itself is present). */
+ @JvmStatic fun ffToSteam(): Boolean = map != null && (anyPresent() || tritonPassthrough)
+
+ private fun updateFfToSteam() {
+ val m = map ?: return
+ synchronized(this) {
+ FakeInputWriter.storeFence()
+ m.putInt(H_FF_TO_STEAM, if (anyPresent() || tritonPassthrough) 1 else 0)
+ }
+ }
+
+ private fun putString(m: ByteBuffer, at: Int, max: Int, s: String) {
+ val b = s.toByteArray(Charsets.UTF_8)
+ val n = minOf(b.size, max - 1)
+ for (i in 0 until max) m.put(at + i, if (i < n) b[i] else 0)
+ }
+
+ // ---------------- bridge messages (DualSenseBridge's worker thread) ----------------
+
+ @Synchronized
+ fun add(id: Bundle) {
+ val m = map ?: run {
+ note("hookup: the bridge handed over a controller but this session has no hook-up file " +
+ "(DroidDeck build without it, or the session started without the deck pad)")
+ return
+ }
+ val slot = id.getInt("slot", -1)
+ if (slot !in 0 until SLOTS) { note("hookup: the bridge handed over slot $slot, which does not exist"); return }
+ val base = slotBase(slot)
+ val desc = id.getByteArray("desc") ?: ByteArray(0)
+ if (present[slot] && (m.getShort(base + S_VENDOR).toInt() and 0xffff) == id.getInt("vid") &&
+ (m.getShort(base + S_PRODUCT).toInt() and 0xffff) == id.getInt("pid") && m.getInt(base + S_IFACE) == id.getInt("iface")) {
+ return // the same controller resent after a reconnect: keep it as it is (no unplug/replug in Steam)
+ }
+ if (present[slot]) remove(slot)
+ describeSysfs(slot, id, desc)
+ m.putShort(base + S_VENDOR, id.getInt("vid").toShort())
+ m.putShort(base + S_PRODUCT, id.getInt("pid").toShort())
+ m.putShort(base + S_VERSION, id.getInt("version").toShort())
+ m.putShort(base + S_BUS, id.getInt("bus", 3).toShort())
+ m.putInt(base + S_IFACE, id.getInt("iface"))
+ putString(m, base + S_NAME, 128, id.getString("name") ?: "")
+ putString(m, base + S_SERIAL, 64, id.getString("serial") ?: "")
+ putString(m, base + S_MANUFACTURER, 64, id.getString("manufacturer") ?: "")
+ putString(m, base + S_PRODUCT_NAME, 64, id.getString("product") ?: "")
+ val n = minOf(desc.size, DESC_MAX)
+ for (i in 0 until n) m.put(base + S_DESC + i, desc[i])
+ m.putInt(base + S_DESC_LEN, n)
+ // fresh FIFOs: the readers start at the current write position, so only the counters matter
+ m.putLong(base + S_OUT_READ, m.getLong(base + S_OUT_WRITE))
+ getAsked[slot] = m.getLong(base + S_GET_REQUEST)
+ m.putLong(base + S_GET_REPLY, getAsked[slot])
+ m.putInt(base + S_GENERATION, m.getInt(base + S_GENERATION) + 1)
+ FakeInputWriter.storeFence()
+ m.putInt(base + S_PRESENT, 1)
+ present[slot] = true
+ updateFfToSteam()
+ note("hookup: /dev/hidraw${MINOR_BASE + slot} = %04x:%04x \"%s\" interface %d, descriptor %d bytes (Steam Native Hook-up)"
+ .format(id.getInt("vid"), id.getInt("pid"), id.getString("name"), id.getInt("iface"), desc.size))
+ }
+
+ @Synchronized
+ fun input(slot: Int, data: ByteArray) {
+ val m = map ?: return
+ if (slot !in 0 until SLOTS || !present[slot] || data.isEmpty()) return
+ if (bridgeServes(m, slot)) return // a late message from before the fast path took over
+ val base = slotBase(slot)
+ val idx = m.getLong(base + S_IN_WRITE)
+ val at = base + S_IN + ((idx % IN_FIFO).toInt()) * REPORT_SIZE
+ m.putLong(at, 0L)
+ FakeInputWriter.storeFence()
+ val n = minOf(data.size, REPORT_MAX)
+ m.putInt(at + 8, n)
+ m.putInt(at + 12, 0)
+ for (i in 0 until n) m.put(at + 16 + i, data[i])
+ FakeInputWriter.storeFence()
+ m.putLong(at, idx + 1)
+ m.putLong(base + S_IN_WRITE, idx + 1)
+ }
+
+ @Synchronized
+ fun remove(slot: Int) {
+ val m = map ?: return
+ if (slot !in 0 until SLOTS) return
+ m.putInt(slotBase(slot) + S_PRESENT, 0)
+ m.putLong(slotBase(slot) + S_DIRECT_MS, 0L)
+ FakeInputWriter.storeFence()
+ if (present[slot]) note("hookup: /dev/hidraw${MINOR_BASE + slot} unplugged")
+ present[slot] = false
+ unlinkClass(slot)
+ updateFfToSteam()
+ }
+
+ @Synchronized
+ fun removeAll() { for (s in 0 until SLOTS) if (present[s]) remove(s) }
+
+ @Synchronized
+ fun reply(slot: Int, request: Long, data: ByteArray?) {
+ val m = map ?: return
+ if (slot !in 0 until SLOTS || request != getAsked[slot]) return
+ val base = slotBase(slot)
+ val n = if (data == null) 0 else minOf(data.size, REPORT_MAX)
+ for (i in 0 until n) m.put(base + S_GET + i, data!![i])
+ m.putInt(base + S_GET_LENGTH, n)
+ FakeInputWriter.storeFence()
+ m.putLong(base + S_GET_REPLY, request)
+ }
+
+ /**
+ * Steam -> controller. Calls [out](slot, kind, report) for each queued output report / feature write and
+ * [get](slot, request, reportId, input, length) for a new feature/input read. Run every ~1 ms.
+ */
+ @Synchronized
+ fun poll(out: (Int, Int, ByteArray) -> Unit, get: (Int, Long, Int, Boolean, Int) -> Unit) {
+ val m = map ?: return
+ for (slot in 0 until SLOTS) {
+ if (!present[slot]) continue
+ FakeInputWriter.storeFence()
+ if (bridgeServes(m, slot)) continue // the bridge takes Steam's reports from the file itself
+ val base = slotBase(slot)
+ var read = m.getLong(base + S_OUT_READ)
+ val written = m.getLong(base + S_OUT_WRITE)
+ while (read < written) {
+ val at = base + S_OUT + ((read % OUT_FIFO).toInt()) * REPORT_SIZE
+ if (m.getLong(at) != read + 1) break // still being written
+ val n = m.getInt(at + 8).coerceIn(0, REPORT_MAX)
+ val kind = m.getInt(at + 12)
+ val data = ByteArray(n)
+ for (i in 0 until n) data[i] = m.get(at + 16 + i)
+ read++
+ if (n > 0) out(slot, kind, data)
+ }
+ m.putLong(base + S_OUT_READ, read)
+ val request = m.getLong(base + S_GET_REQUEST)
+ if (request != getAsked[slot] && request != m.getLong(base + S_GET_REPLY)) {
+ getAsked[slot] = request
+ val idWord = m.getInt(base + S_GET_REPORT_ID)
+ get(slot, request, idWord and 0xff, (idWord and 0x100) != 0, m.getInt(base + S_GET_LENGTH))
+ }
+ }
+ }
+
+ // ---------------- sysfs, as Linux would describe the device ----------------
+
+ private fun guestHid(slot: Int, id: Bundle): Triple {
+ val bus = 10 + slot
+ val usb = "/sys/devices/droiddeck/usb$bus"
+ val iface = "$usb/$bus-1:1.${maxOf(0, id.getInt("iface"))}"
+ val hid = "$iface/0003:%04X:%04X.%04X".format(id.getInt("vid"), id.getInt("pid"), 0x10 + slot)
+ return Triple(usb, iface, hid)
+ }
+
+ private fun describeSysfs(slot: Int, id: Bundle, desc: ByteArray) {
+ val devices = com.droiddeck.launcher.session.SteamDeckPad.sysDevicesDir
+ val classDir = com.droiddeck.launcher.session.SteamDeckPad.sysHidrawClassDir
+ val context = com.droiddeck.launcher.session.SteamDeckPad.sysContext
+ if (devices == null || classDir == null || context == null) {
+ note("hookup: no session sysfs to describe /dev/hidraw${MINOR_BASE + slot} in (deck pad off?)")
+ return
+ }
+ try {
+ val (usb, iface, hid) = guestHid(slot, id)
+ fun dir(guest: String) = File(devices, guest.removePrefix("/sys/devices/droiddeck/")).apply { mkdirs() }
+ fun write(guest: String, name: String, text: String) = File(dir(guest), name).writeText(text)
+ fun link(file: File, target: String) {
+ file.parentFile?.mkdirs()
+ file.delete()
+ Os.symlink(target, file.path)
+ }
+ deleteTree(File(devices, usb.removePrefix("/sys/devices/droiddeck/"))) // the previous description
+ val vid = id.getInt("vid")
+ val pid = id.getInt("pid")
+ val ver = id.getInt("version")
+ val vidHex = "%04x".format(vid)
+ val pidHex = "%04x".format(pid)
+ val product = "%x/%x/%x".format(vid, pid, ver)
+ val node = "hidraw${MINOR_BASE + slot}"
+ val minor = MINOR_BASE + slot
+ val busNum = "%03d".format(10 + slot)
+ write(usb, "uevent", "DEVTYPE=usb_device\nPRODUCT=$product\nTYPE=0/0/0\nBUSNUM=$busNum\nDEVNUM=002\n")
+ write(usb, "idVendor", "$vidHex\n")
+ write(usb, "idProduct", "$pidHex\n")
+ write(usb, "bcdDevice", "%04x\n".format(ver))
+ write(usb, "manufacturer", "${id.getString("manufacturer") ?: ""}\n")
+ write(usb, "product", "${id.getString("product") ?: ""}\n")
+ write(usb, "serial", "${id.getString("serial") ?: ""}\n")
+ write(usb, "busnum", "${10 + slot}\n")
+ write(usb, "devnum", "2\n")
+ link(File(dir(usb), "subsystem"), "/sys/bus/usb")
+ val cls = id.getInt("ifclass", 3)
+ write(iface, "uevent", "DEVTYPE=usb_interface\nPRODUCT=$product\nINTERFACE=$cls/${id.getInt("ifsub")}/${id.getInt("ifproto")}\n")
+ write(iface, "bInterfaceNumber", "%02x\n".format(maxOf(0, id.getInt("iface"))))
+ write(iface, "bInterfaceClass", "%02x\n".format(cls))
+ write(iface, "bInterfaceSubClass", "%02x\n".format(id.getInt("ifsub")))
+ write(iface, "bInterfaceProtocol", "%02x\n".format(id.getInt("ifproto")))
+ link(File(dir(iface), "subsystem"), "/sys/bus/usb")
+ val name = id.getString("name") ?: ""
+ val serial = id.getString("serial") ?: ""
+ write(hid, "uevent", "DRIVER=hid-generic\nHID_ID=0003:%08X:%08X\nHID_NAME=$name\n".format(vid, pid) +
+ "HID_PHYS=usb-droiddeck-${10 + slot}/input${maxOf(0, id.getInt("iface"))}\nHID_UNIQ=$serial\n" +
+ "MODALIAS=hid:b0003g0001v%08Xp%08X\n".format(vid, pid))
+ File(dir(hid), "report_descriptor").writeBytes(desc)
+ link(File(dir(hid), "subsystem"), "/sys/bus/hid")
+ val hidraw = "$hid/hidraw/$node"
+ write(hidraw, "uevent", "MAJOR=$MAJOR\nMINOR=$minor\nDEVNAME=$node\n")
+ write(hidraw, "dev", "$MAJOR:$minor\n")
+ link(File(dir(hidraw), "subsystem"), "/sys/class/hidraw")
+ link(File(dir(hidraw), "device"), hid)
+ link(File(classDir, node), hidraw)
+ link(File(LinuxRuntime.liveDir(context), "drm/sys/$MAJOR:$minor"), hidraw)
+ val udevData = File(LinuxRuntime.rootDir(context), "run/udev/data").apply { mkdirs() }
+ File(udevData, "c$MAJOR:$minor").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
+ } catch (e: Exception) {
+ note("hookup: could not describe /dev/hidraw${MINOR_BASE + slot}: $e")
+ }
+ }
+
+ /**
+ * Deletes a folder without following symbolic links. (File.deleteRecursively() follows them: the description
+ * links "subsystem" to the phone's real /sys/bus/..., and walking into the real /sys -- full of links back into
+ * itself -- never ends, which froze DroidDeck's bridge thread on the second hand-over of a controller.)
+ */
+ private fun deleteTree(f: File) {
+ val st = try { Os.lstat(f.path) } catch (_: Exception) { return } // gone already
+ if (android.system.OsConstants.S_ISDIR(st.st_mode)) f.list()?.forEach { deleteTree(File(f, it)) }
+ f.delete()
+ }
+
+ private fun unlinkClass(slot: Int) {
+ val classDir = com.droiddeck.launcher.session.SteamDeckPad.sysHidrawClassDir ?: return
+ try { File(classDir, "hidraw${MINOR_BASE + slot}").delete() } catch (_: Exception) {}
+ }
+
+ /** True when DroidDeck should set the hook-up file up this session (the bridge app is installed). */
+ @JvmStatic fun wanted(context: Context): Boolean = DualSenseBridge.isBridgeInstalled(context)
+}
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 cfb0978cb..656e44cf1 100644
--- a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java
+++ b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java
@@ -51,6 +51,7 @@ public final class PadBridge {
private volatile java.util.function.IntPredicate excluded = id -> false;
private final FakeInputWriter[] playerWriters = new FakeInputWriter[SLOTS];
private final PadState[] playerStates = new PadState[SLOTS];
+ private final boolean[] bridgeSlots = new boolean[SLOTS]; // slots fed by the SDL3 bridge app
private final PadState effectiveState = new PadState();
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private boolean open;
@@ -200,6 +201,7 @@ public final class PadBridge {
}
public static boolean isControllerKeyEvent(KeyEvent event) {
+ if (DualSenseBridge.isBridged(event)) return false;
InputDevice device = event.getDevice();
if (device == null || device.isVirtual() || isSystemVirtualInputName(device.getName())) return false;
if (KeyEvent.isGamepadButton(event.getKeyCode())) {
@@ -224,6 +226,7 @@ public final class PadBridge {
}
public static boolean isControllerMotionEvent(MotionEvent event) {
+ if (DualSenseBridge.isBridged(event)) return false;
InputDevice device = event.getDevice();
return event.isFromSource(InputDevice.SOURCE_JOYSTICK) && device != null && !device.isVirtual()
&& !isSystemVirtualInputName(device.getName())
@@ -446,6 +449,39 @@ public final class PadBridge {
publish();
}
+ /** SDL3 bridge: reserves a free player slot (1..3) for an extra controller. @return the slot, or -1 if none is free. */
+ public synchronized int claimBridgeSlot() {
+ for (int slot = 1; slot < SLOTS; slot++) {
+ if (owners[slot] != NO_CONTROLLER || bridgeSlots[slot]) continue;
+ bridgeSlots[slot] = true;
+ playerStates[slot].clear();
+ Log.i(TAG, "SDL3 bridge controller is player " + (slot + 1));
+ return slot;
+ }
+ return -1;
+ }
+
+ /** SDL3 bridge: frees a slot taken with {@link #claimBridgeSlot()}. */
+ public synchronized void releaseBridgeSlot(int slot) {
+ if (slot < 1 || slot >= SLOTS || !bridgeSlots[slot]) return;
+ bridgeSlots[slot] = false;
+ playerStates[slot].clear();
+ if (playerWriters[slot] != null) {
+ playerWriters[slot].destroy();
+ playerWriters[slot] = null;
+ }
+ Log.i(TAG, "SDL3 bridge player " + (slot + 1) + " left");
+ }
+
+ /** SDL3 bridge: updates player [slot] (0 = primary pad, 1..3 = claimed bridge slots). */
+ public synchronized void applyExternalSlot(int slot, java.util.function.Consumer mutation, boolean changed) {
+ if (slot <= 0) { applyExternal(mutation, changed); return; }
+ if (slot >= SLOTS || !bridgeSlots[slot]) return;
+ mutation.accept(playerStates[slot]);
+ if (changed) notePlayerInput();
+ publish(slot);
+ }
+
public synchronized void setSystemButtons(boolean guidePressed, boolean qamPressed) {
if (!builtinPad) return;
if (systemGuidePressed == guidePressed && systemQamPressed == qamPressed) return;
@@ -561,7 +597,7 @@ public final class PadBridge {
return slot;
}
for (int slot = firstPlayerSlot(); slot < SLOTS; slot++) {
- if (owners[slot] != NO_CONTROLLER) continue;
+ if (owners[slot] != NO_CONTROLLER || bridgeSlots[slot]) continue;
owners[slot] = id;
Log.i(TAG, "\"" + device.getName() + "\" (id " + id + ") is player " + (slot + 1));
return slot;
diff --git a/app/src/main/java/com/droiddeck/launcher/input/PadMotion.kt b/app/src/main/java/com/droiddeck/launcher/input/PadMotion.kt
index bc640507a..c311b380d 100644
--- a/app/src/main/java/com/droiddeck/launcher/input/PadMotion.kt
+++ b/app/src/main/java/com/droiddeck/launcher/input/PadMotion.kt
@@ -62,7 +62,7 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
val wanted = FakeInputWriter.readImuMode(SLOT) != 0 ||
touch != null && touch.plugged && touch.gyroRequested && touch.motionEnabled
idlePolls = if (wanted) 0 else idlePolls + 1
- if (wanted && !reading) {
+ if (wanted && !DualSenseBridge.isActive() && !reading) {
reading = true
turnAt = 0L
gyroscope?.let { sensors?.registerListener(this, it, SAMPLING_US, handler) }
@@ -83,6 +83,7 @@ class PadMotion(private val context: Context, private val rotation: () -> Int) :
}
override fun onSensorChanged(event: SensorEvent) {
+ if (DualSenseBridge.isActive()) return
if (!active) return
val values = event.values
if (event.timestamp - turnAt > ROTATION_REFRESH_NS) {
diff --git a/app/src/main/java/com/droiddeck/launcher/session/RumbleComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/RumbleComponent.kt
index f2b1c0a1e..8cc7ca3b8 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/RumbleComponent.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/RumbleComponent.kt
@@ -90,6 +90,7 @@ class RumbleComponent : SessionPart() {
@Synchronized private fun buzz(strong: Int, weak: Int, ms: Int, slot: Int) {
if (!enabled) return
+ if (com.droiddeck.launcher.input.DualSenseBridge.routeRumble(slot, strong, weak, ms)) return
val target = target(slot)
val previous = playing[slot]
if (target !== previous) {
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 c49e3f7ac..26da9446d 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt
@@ -815,14 +815,21 @@ class SessionService : Service() {
guest.add("FAKE_EVDEV_IDENTITY=xbox360")
guest.add("FAKE_EVDEV_VIBRATION=1")
val uinput = !File(Environment.getExternalStorageDirectory(), NO_UINPUT_SWITCH).exists()
- val wantsDeck = uinput && builtinPad && SessionState.mode == MODE_STEAM &&
- SessionState.steamUi != "desktop" &&
+ // sdl3bridge: the desktop (and Steam's desktop UI) get the deck pad too while the SDL3 bridge is installed,
+ // so its "present as" types and Steam Native Hook-up work there like in the Steam session
+ val sdl3Desktop = (SessionState.mode == MODE_DESKTOP ||
+ (SessionState.mode == MODE_STEAM && SessionState.steamUi == "desktop")) &&
+ com.droiddeck.launcher.input.HookupBridge.wanted(this)
+ val wantsDeck = uinput && builtinPad && (SessionState.mode == MODE_STEAM &&
+ SessionState.steamUi != "desktop" || sdl3Desktop) &&
!File(Environment.getExternalStorageDirectory(), NO_DECK_PAD_SWITCH).exists()
- val wantsTouch = wantsDeck && !File(Environment.getExternalStorageDirectory(), NO_STEAM_TOUCH_SWITCH).exists()
- val wantsTriton = wantsDeck && SteamControllerBle.hasPairedController(this)
+ val wantsTouch = wantsDeck && !sdl3Desktop && !File(Environment.getExternalStorageDirectory(), NO_STEAM_TOUCH_SWITCH).exists()
+ val wantsTriton = wantsDeck && (SteamControllerBle.hasPairedController(this) || com.droiddeck.launcher.input.DualSenseBridge.isBridgeInstalled(this))
deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!, wantsTouch, wantsTriton,
SessionPrefs.padType(SessionPrefs.steamController(this))) else emptyList()
SessionState.deckPad = deckBinds.isNotEmpty()
+ if (sdl3Desktop && SessionState.deckPad) Log.i(TAG, "deck pad: on for the SDL3 bridge on the desktop " +
+ "(Steam gets the controller as the bridge presents it; other programs keep Xbox 360 pads)")
if (wantsDeck && !SessionState.deckPad) Log.w(TAG, "deck pad: not available this session; the pad stays an Xbox 360 controller")
logControllersAtStart()
com.droiddeck.launcher.input.FakeInputWriter.setLowLatency(SessionPrefs.lowLatencyController(this), SessionPrefs.lowLatencyTouch(this))
@@ -830,6 +837,7 @@ class SessionService : Service() {
guest.add("FAKE_EVDEV_UINPUT=1")
if (SessionState.deckPad) {
guest.add("FAKE_EVDEV_DECK=1")
+ if (SessionState.mode == MODE_DESKTOP) guest.add("FAKE_EVDEV_DESKTOP=1") // sdl3bridge
if (wantsTriton) guest.add("FAKE_EVDEV_TRITON=1")
val padType = SessionPrefs.steamController(this)
guest.add("FAKE_EVDEV_PAD_TYPE=$padType")
@@ -838,6 +846,8 @@ class SessionService : Service() {
if (wantsTouch) com.droiddeck.launcher.input.SteamTouchDevice.prepare(fakeInputDir)?.let {
guest.add("FAKE_TOUCHCTL_RING=" + it.path)
}
+ if (com.droiddeck.launcher.input.HookupBridge.wanted(this))
+ com.droiddeck.launcher.input.HookupBridge.prepare(fakeInputDir, File(sessionDir, "pad.log"))?.let { guest.add("FAKE_HOOKUP_SHM=" + it.path) }
guest.add("FAKE_DECK_SYSFS_LISTING=" + SteamDeckPad.listingDir(fakeInputDir.parentFile!!.parentFile!!).path)
}
} else if (SessionState.mode == MODE_STEAM) {
@@ -849,7 +859,7 @@ class SessionService : Service() {
guest.add("FAKE_EVDEV_LOG=1")
guest.add("FAKE_EVDEV_LOG_FILE=" + File(sessionDir, "pad.log").path)
inputSysfs = InputSysfsComponent(fakeInputDir, File(fakeInputDir.parentFile!!.parentFile!!, "sys/input"),
- File(LinuxRuntime.rootDir(this), "run/udev/data"), SessionState.deckPad, !uinput && SessionState.mode == MODE_STEAM)
+ File(LinuxRuntime.rootDir(this), "run/udev/data"), SessionState.deckPad && SessionState.mode != MODE_DESKTOP, !uinput && SessionState.mode == MODE_STEAM)
.takeIf { it.prepare() }
SessionState.fakeInputDir = fakeInputDir
}
@@ -1761,6 +1771,7 @@ class SessionService : Service() {
override fun onDestroy() {
stopSession(0)
GameThreadHints.stop() // also when the session never got as far as running
+ com.droiddeck.launcher.input.DualSenseBridge.detach()
if (screenReceiverRegistered) {
unregisterReceiver(screenReceiver)
screenReceiverRegistered = false
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 db9884dd5..39e5c025a 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SteamDeckPad.kt
@@ -27,6 +27,8 @@ object SteamDeckPad {
private const val TRITON_WIRED_HID = "$TRITON_INTERFACE/0003:28DE:1302.0004"
private const val SONY_DS4_MINOR = 20
private const val SONY_DUALSENSE_MINOR = 21
+ private const val SONY_DUALSENSE_EDGE_MINOR = 22
+ private const val SC2015_MINOR = 23
private val DS4_REPORT_DESCRIPTOR = intArrayOf(
0x05, 0x01, 0x09, 0x05, 0xa1, 0x01, 0x06, 0x00, 0xff, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08,
0x85, 0x01, 0x09, 0x20, 0x95, 0x3f, 0x81, 0x02, 0x85, 0x05, 0x09, 0x21, 0x95, 0x1f, 0x91, 0x02,
@@ -82,6 +84,10 @@ object SteamDeckPad {
fun listingDir(sessionRoot: File) = File(sessionRoot, "sys/deck/listing")
+ // sdl3bridge: where the session sysfs lives, for HookupBridge (Steam Native Hook-up) hot-plug
+ @Volatile @JvmStatic var sysDevicesDir: File? = null
+ @Volatile @JvmStatic var sysHidrawClassDir: File? = null
+ @Volatile @JvmStatic var sysContext: Context? = null
private val padNodes = mutableMapOf()
@Volatile private var hidrawClassDir: File? = null
@@ -93,6 +99,8 @@ object SteamDeckPad {
com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_STEAM -> TRITON_PAD_MINOR
com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DS4 -> SONY_DS4_MINOR
com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DUALSENSE -> SONY_DUALSENSE_MINOR
+ com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_DUALSENSE_EDGE -> SONY_DUALSENSE_EDGE_MINOR
+ com.droiddeck.launcher.input.FakeInputWriter.PAD_TYPE_STEAM_2015 -> SC2015_MINOR
else -> -1
}
for ((minor, target) in padNodes) {
@@ -224,12 +232,8 @@ object SteamDeckPad {
File(udevData, "c$MAJOR:$minor").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
Log.i(TAG, "steam controller: /dev/$node described as Triton (28de:$product)")
}
- fun describeSony(dualSense: Boolean) {
- val bus = if (dualSense) 5 else 4
- val product = if (dualSense) "0ce6" else "09cc"
- val minor = if (dualSense) SONY_DUALSENSE_MINOR else SONY_DS4_MINOR
+ fun describeSony(bus: Int, product: String, minor: Int, name: String, sonyDescriptor: ByteArray) {
val node = "hidraw$minor"
- val name = if (dualSense) "DualSense Wireless Controller" else "Wireless Controller"
val usb = "$GUEST_DEVICES/usb$bus"
val iface = "$usb/$bus-1:1.3"
val hid = "$iface/0003:054C:${product.uppercase()}.000${bus + 1}"
@@ -247,7 +251,7 @@ object SteamDeckPad {
write(hid, "uevent", "DRIVER=hid-generic\nHID_ID=0003:0000054C:0000${product.uppercase()}\n" +
"HID_NAME=Sony Interactive Entertainment $name\nHID_PHYS=usb-droiddeck-$bus/input3\n" +
"HID_UNIQ=5e:43:4b:44:44:02\nMODALIAS=hid:b0003g0001v0000054Cp0000${product.uppercase()}\n")
- File(dir(hid), "report_descriptor").writeBytes(if (dualSense) DUALSENSE_REPORT_DESCRIPTOR else DS4_REPORT_DESCRIPTOR)
+ File(dir(hid), "report_descriptor").writeBytes(sonyDescriptor)
link(File(dir(hid), "subsystem"), "/sys/bus/hid")
val hidraw = "$hid/hidraw/$node"
write(hidraw, "uevent", "MAJOR=$MAJOR\nMINOR=$minor\nDEVNAME=$node\n")
@@ -259,11 +263,48 @@ object SteamDeckPad {
File(udevData, "c$MAJOR:$minor").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
Log.i(TAG, "playstation pad: /dev/$node described as 054c:$product")
}
- describeSony(dualSense = false)
- describeSony(dualSense = true)
+ describeSony(4, "09cc", SONY_DS4_MINOR, "Wireless Controller", DS4_REPORT_DESCRIPTOR)
+ describeSony(5, "0ce6", SONY_DUALSENSE_MINOR, "DualSense Wireless Controller", DUALSENSE_REPORT_DESCRIPTOR)
+ describeSony(6, "0df2", SONY_DUALSENSE_EDGE_MINOR, "DualSense Edge Wireless Controller", DUALSENSE_REPORT_DESCRIPTOR)
+ fun describeSteamController2015() {
+ val usb = "$GUEST_DEVICES/usb7"
+ val iface = "$usb/7-1:1.2"
+ val hid = "$iface/0003:28DE:1102.0008"
+ val node = "hidraw$SC2015_MINOR"
+ write(usb, "uevent", "DEVTYPE=usb_device\nPRODUCT=28de/1102/100\nTYPE=0/0/0\nBUSNUM=007\nDEVNUM=002\n")
+ write(usb, "idVendor", "28de\n")
+ write(usb, "idProduct", "1102\n")
+ write(usb, "bcdDevice", "0100\n")
+ write(usb, "manufacturer", "Valve Software\n")
+ write(usb, "product", "Steam Controller\n")
+ write(usb, "serial", "DROIDDECKSC15\n")
+ link(File(dir(usb), "subsystem"), "/sys/bus/usb")
+ write(iface, "uevent", "DEVTYPE=usb_interface\nPRODUCT=28de/1102/100\nINTERFACE=3/0/0\n")
+ write(iface, "bInterfaceNumber", "02\n")
+ write(iface, "bInterfaceClass", "03\n")
+ link(File(dir(iface), "subsystem"), "/sys/bus/usb")
+ write(hid, "uevent", "DRIVER=hid-steam\nHID_ID=0003:000028DE:00001102\n" +
+ "HID_NAME=Valve Software Steam Controller\nHID_PHYS=usb-droiddeck-7/input2\n" +
+ "HID_UNIQ=DROIDDECKSC15\nMODALIAS=hid:b0003g0001v000028DEp00001102\n")
+ File(dir(hid), "report_descriptor").writeBytes(REPORT_DESCRIPTOR)
+ link(File(dir(hid), "subsystem"), "/sys/bus/hid")
+ val hidraw = "$hid/hidraw/$node"
+ write(hidraw, "uevent", "MAJOR=$MAJOR\nMINOR=$SC2015_MINOR\nDEVNAME=$node\n")
+ write(hidraw, "dev", "$MAJOR:$SC2015_MINOR\n")
+ link(File(dir(hidraw), "subsystem"), "/sys/class/hidraw")
+ link(File(dir(hidraw), "device"), hid)
+ padNodes[SC2015_MINOR] = hidraw
+ link(File(LinuxRuntime.liveDir(context), "drm/sys/$MAJOR:$SC2015_MINOR"), hidraw)
+ File(udevData, "c$MAJOR:$SC2015_MINOR").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
+ Log.i(TAG, "steam controller: /dev/$node described as the 2015 Steam Controller (28de:1102)")
+ }
+ describeSteamController2015()
describeTriton(wired = true)
if (triton) describeTriton(wired = false)
hidrawClassDir = hidrawClass
+ sysDevicesDir = devices
+ sysHidrawClassDir = hidrawClass
+ sysContext = context.applicationContext
showPad(padType)
Log.i(TAG, "deck pad: /dev/$NODE described as a Steam Deck controller (28de:1205)")
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt
index 8f92dbbc6..375e31456 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt
@@ -181,7 +181,7 @@ internal fun SetupPanel(
onDismiss = { editDownloadPrefix = false },
onSave = { a.onDownloadPrefix(it); editDownloadPrefix = false },
)
- val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_launcher), stringResource(R.string.setup_tab_storage), stringResource(R.string.setup_tab_diagnostics), stringResource(R.string.setup_tab_glasses))
+ val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_launcher), stringResource(R.string.setup_tab_storage), stringResource(R.string.setup_tab_diagnostics), stringResource(R.string.setup_tab_glasses), "SDL3Bridge")
var tab by rememberSaveable { mutableStateOf(if (SetupTabs.openLauncher) LAUNCHER_TAB else 0) }
SetupTabs.openLauncher = false
val chipFocus = remember { List(tabs.size) { FocusRequester() } }
@@ -460,6 +460,7 @@ internal fun SetupPanel(
}
}
GLASSES_TAB -> GlassesSetupPanel()
+ GLASSES_TAB + 1 -> SdlBridgeSettingsTab(host) // sdl3bridge: SDL3Bridge tab
}
}
}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SdlBridgeSettings.kt b/app/src/main/java/com/droiddeck/launcher/ui/SdlBridgeSettings.kt
new file mode 100644
index 000000000..59381bee0
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/ui/SdlBridgeSettings.kt
@@ -0,0 +1,373 @@
+package com.droiddeck.launcher.ui
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.ServiceConnection
+import android.os.Bundle
+import android.os.Handler
+import android.os.IBinder
+import android.os.Looper
+import android.os.Message
+import android.os.Messenger
+import android.os.RemoteException
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.delay
+
+// sdl3bridge: SDL3Bridge settings tab
+/**
+ * DroidDeck's link to the SDL3 bridge app's settings. A connection of its own (not DualSenseBridge's): it never
+ * registers for controller frames, it only asks for the settings state (MSG_SETTINGS_GET, once a second while
+ * the tab is open) and sends single changes (MSG_SETTINGS_SET); the bridge answers both with MSG_SETTINGS.
+ * The bridge sends the choices too (mode names, button names, ...), so nothing here has to match its lists.
+ */
+internal object SdlBridgeLink {
+ private const val ACTION = "com.example.dsedge.BRIDGE"
+ private const val SCREEN = "com.example.dsedge.BridgeActivity"
+ // the bridge built into this app (SDL3DroidDeck), else the separate SDL3Bridge app
+ private fun pkg(ctx: Context) = com.droiddeck.launcher.input.DualSenseBridge.bridgePackage(ctx)
+ private const val MSG_SETTINGS_GET = 40
+ private const val MSG_SETTINGS_SET = 41
+ private const val MSG_SETTINGS = 42
+
+ var snapshot by mutableStateOf(null)
+ private set
+ private var server: Messenger? = null
+ private var bound = false
+ private var users = 0
+
+ private val inbox = Messenger(object : Handler(Looper.getMainLooper()) {
+ override fun handleMessage(m: Message) {
+ if (m.what == MSG_SETTINGS) snapshot = Bundle(m.data)
+ }
+ })
+
+ private val conn = object : ServiceConnection {
+ override fun onServiceConnected(n: ComponentName, b: IBinder) {
+ server = Messenger(b)
+ refresh()
+ }
+ override fun onServiceDisconnected(n: ComponentName) { server = null }
+ }
+
+ fun installed(ctx: Context): Boolean = try { ctx.packageManager.getPackageInfo(pkg(ctx), 0); true } catch (_: Exception) { false }
+
+ fun start(ctx: Context) {
+ users++
+ if (bound) return
+ bound = try {
+ ctx.applicationContext.bindService(Intent(ACTION).setPackage(pkg(ctx)), conn, Context.BIND_AUTO_CREATE)
+ } catch (_: Exception) { false }
+ }
+
+ fun stop(ctx: Context) {
+ users = maxOf(0, users - 1)
+ if (users > 0 || !bound) return
+ try { ctx.applicationContext.unbindService(conn) } catch (_: Exception) {}
+ bound = false
+ server = null
+ }
+
+ fun connected(): Boolean = server != null
+
+ fun refresh() = send(Message.obtain(null, MSG_SETTINGS_GET))
+
+ /** One change. Shown at once; the bridge's answer then confirms it. */
+ fun set(key: String, p: Int = 0, i: Int = 0, int: Int? = null, bool: Boolean? = null) {
+ snapshot?.let { old -> snapshot = Bundle(old).also { applyLocally(it, key, p, i, int, bool) } }
+ send(Message.obtain(null, MSG_SETTINGS_SET).apply {
+ data = Bundle().apply {
+ putString("key", key); putInt("p", p); putInt("i", i)
+ if (int != null) putInt("int", int)
+ if (bool != null) putBoolean("bool", bool)
+ }
+ })
+ }
+
+ /** The bridge's own screen (controller test, USB access, ...): in this app when built in, else SDL3Bridge. */
+ fun open(ctx: Context) {
+ val screen = Intent().setClassName(pkg(ctx), SCREEN).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ try { ctx.startActivity(screen) } catch (_: Exception) {
+ val launch = ctx.packageManager.getLaunchIntentForPackage(pkg(ctx)) ?: return
+ try { ctx.startActivity(launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } catch (_: Exception) {}
+ }
+ }
+
+ private fun send(m: Message) {
+ val s = server ?: return
+ m.replyTo = inbox
+ try { s.send(m) } catch (_: RemoteException) { server = null }
+ }
+
+ private fun applyLocally(b: Bundle, key: String, p: Int, i: Int, int: Int?, bool: Boolean?) {
+ val remapCount = b.getStringArray("remapTargets")?.size ?: 8
+ fun setInt(k: String, index: Int, v: Int) {
+ val a = b.getIntArray(k)?.clone() ?: return
+ if (index in a.indices) a[index] = v
+ b.putIntArray(k, a)
+ }
+ fun setBool(k: String, index: Int, v: Boolean) {
+ val a = b.getBooleanArray(k)?.clone() ?: return
+ if (index in a.indices) a[index] = v
+ b.putBooleanArray(k, a)
+ }
+ when (key) {
+ "presentAs", "pollMs" -> if (int != null) b.putInt(key, int)
+ "driverEnabled", "forceUsb", "bubble" -> if (bool != null) b.putBoolean(key, bool)
+ "gyroMap" -> if (int != null) setInt(key, p * 3 + i, int)
+ "remap" -> if (int != null) setInt(key, p * remapCount + i, int)
+ "calibrate" -> b.putInt("calibrating", p)
+ "resetCalibration" -> b.getFloatArray("gyroBias")?.clone()?.let { a ->
+ for (k in 0 until 3) if (p * 3 + k in a.indices) a[p * 3 + k] = 0f
+ b.putFloatArray("gyroBias", a)
+ }
+ else -> {
+ if (int != null) setInt(key, p, int)
+ if (bool != null) setBool(key, p, bool)
+ }
+ }
+ }
+}
+
+/**
+ * The SDL3Bridge settings page: a tab of DroidDeck's Setup screen, and a page of the in-session drawer.
+ * [track] gives each control the drawer's focus tracking (controller navigation); Setup passes nothing.
+ */
+@Composable
+internal fun SdlBridgeSettingsTab(host: MenuHost, track: (String) -> Modifier = { Modifier }) {
+ val ctx = LocalContext.current
+ val installed = remember { SdlBridgeLink.installed(ctx) }
+ if (!installed) {
+ SettingsGroup("SDL3Bridge") {
+ SettingsRow("SDL3Bridge is not installed",
+ "Install the SDL3Bridge app to use any SDL3-supported controller with DroidDeck: USB or Bluetooth, " +
+ "re-presented as a Steam Deck / Sony / Steam Controller pad, or handed to Steam as itself.") {}
+ }
+ return
+ }
+ var waited by remember { mutableIntStateOf(0) }
+ DisposableEffect(Unit) {
+ SdlBridgeLink.start(ctx)
+ onDispose { SdlBridgeLink.stop(ctx) }
+ }
+ LaunchedEffect(Unit) {
+ while (true) {
+ SdlBridgeLink.refresh()
+ delay(1000)
+ waited++
+ }
+ }
+ val s = SdlBridgeLink.snapshot
+
+ // ---------------- status ----------------
+ SettingsGroup("SDL3Bridge") {
+ if (s == null) {
+ SettingsRow(if (SdlBridgeLink.connected()) "Waiting for SDL3Bridge…" else "Connecting to SDL3Bridge…",
+ if (waited >= 3) "No answer yet. Update SDL3Bridge (this tab needs a recent build), or open it once." else null) {}
+ } else {
+ SettingsRow("Status", s.getString("status") ?: "") {}
+ val engineError = s.getString("engineError") ?: ""
+ val engineMode = s.getString("engineMode") ?: ""
+ if (engineError.isNotEmpty()) SettingsRow("SDL3 is not running", engineError) {}
+ else if (engineMode.isNotEmpty() && engineMode != "background service") SettingsRow("SDL3 runs in", engineMode) {}
+ if (!s.getBoolean("notifications", true)) SettingsRow("Status bar icon is hidden",
+ "SDL3Bridge may not show notifications: open it once and tap Allow (or allow it in Android's app settings).") {}
+ val padNames = s.getStringArray("padNames") ?: emptyArray()
+ val padIds = s.getStringArray("padIds") ?: emptyArray()
+ val battery = s.getIntArray("padBattery") ?: IntArray(0)
+ val hookNames = s.getStringArray("hookNames") ?: emptyArray()
+ val hookIds = s.getStringArray("hookIds") ?: emptyArray()
+ val triton = s.getString("triton") ?: ""
+ var any = false
+ padNames.forEachIndexed { i, name ->
+ if (name.isEmpty()) return@forEachIndexed
+ any = true
+ val bat = battery.getOrElse(i) { -1 }
+ SettingsRow("Player ${i + 1}: $name",
+ listOfNotNull(padIds.getOrNull(i), if (bat >= 0) "battery $bat%" else null).joinToString(" · ")) {}
+ }
+ hookNames.forEachIndexed { i, name ->
+ if (name.isEmpty()) return@forEachIndexed
+ any = true
+ SettingsRow("Handed to Steam: $name",
+ listOfNotNull(hookIds.getOrNull(i), "/dev/hidraw${24 + i}",
+ if (s.getBoolean("fastPath")) "fast path" else null).joinToString(" · ")) {}
+ }
+ if (triton.isNotEmpty()) {
+ any = true
+ SettingsRow("Steam Controller (2026)", "$triton · raw passthrough") {}
+ }
+ if (!any) SettingsRow("No controller", "Plug a controller in with USB-C, or pair it over Bluetooth.") {}
+ }
+ ActionRow("Open SDL3Bridge", "Controller test screen, USB access, USB audio and everything else.", "Open",
+ onClick = { SdlBridgeLink.open(ctx) }, modifier = track("sdl3-open"))
+ }
+ if (s == null) return
+
+ val ints = { k: String -> s.getIntArray(k) ?: IntArray(0) }
+ val bools = { k: String -> s.getBooleanArray(k) ?: BooleanArray(0) }
+ val names = { k: String -> s.getStringArray(k) ?: emptyArray() }
+ fun indexed(k: String) = names(k).mapIndexed { i, n -> i to n }
+
+ // ---------------- global ----------------
+ SettingsGroup("Controller mode") {
+ BridgeChoiceRow(host, "sdl3-present", "Present controller as",
+ "How Steam sees the controller, in the Steam session and on the desktop. Steam Native Hook-up hands USB " +
+ "controllers to Steam as themselves, like on a PC (on the desktop: while Steam runs; other programs " +
+ "there see an Xbox 360 controller).",
+ indexed("presentNames"), s.getInt("presentAs"), chipModifier = track("sdl3-present")) { SdlBridgeLink.set("presentAs", int = it) }
+ ToggleRow(host, "sdl3-driver", "SDL3 driver",
+ "On: SDL3 reads the controller (all its features). Off: the controller goes back to Android and DroidDeck.",
+ s.getBoolean("driverEnabled"), chipModifier = track("sdl3-driver")) { SdlBridgeLink.set("driverEnabled", bool = it) }
+ BridgeChoiceRow(host, "sdl3-source", "Controller source", "Which controllers SDL3 uses.",
+ listOf(false to "USB + Bluetooth (auto)", true to "USB only (forced)"), s.getBoolean("forceUsb"), chipModifier = track("sdl3-source")) {
+ SdlBridgeLink.set("forceUsb", bool = it)
+ }
+ val rateMs = ints("rateMs")
+ BridgeChoiceRow(host, "sdl3-rate", "Poll rate", "Higher = lower gyro and trackpad latency, a little more battery. In Steam Native Hook-up it is also how often Steam gets the newest report (lower if games lag).",
+ names("rateNames").mapIndexed { i, n -> rateMs.getOrElse(i) { 2 } to n }, s.getInt("pollMs"), chipModifier = track("sdl3-rate")) {
+ SdlBridgeLink.set("pollMs", int = it)
+ }
+ val overlay = s.getBoolean("overlayAllowed")
+ ToggleRow(host, "sdl3-bubble", "Floating bubble",
+ if (overlay) "Tap: SDL3 driver on/off. Double tap: Steam Native Hook-up. Hold 3 s: open SDL3Bridge."
+ else "Needs SDL3Bridge's \"Draw over other apps\" permission: turn the bubble on once in SDL3Bridge.",
+ s.getBoolean("bubble"), enabled = overlay || s.getBoolean("bubble"), chipModifier = track("sdl3-bubble")) { SdlBridgeLink.set("bubble", bool = it) }
+ }
+
+ // ---------------- per player ----------------
+ var player by rememberSaveable { mutableIntStateOf(0) }
+ val p = player
+ Box(Modifier.fillMaxWidth().padding(top = 16.dp)) {
+ SegmentedTabs((0 until 4).map { it to "P${it + 1}" }, player, tabModifier = { track("sdl3-player-$it") }) { player = it }
+ }
+
+ SettingsGroup("Gyro · player ${p + 1}") {
+ SliderRow("Gyro speed", "100 % = 1:1", ints("gyroGain").getOrElse(p) { 100 }, 5..800, step = 5,
+ format = { "$it %" }, modifier = track("sdl3-gyrospeed-$p")) { SdlBridgeLink.set("gyroGain", p, int = it) }
+ BridgeChoiceRow(host, "sdl3-gyroinv-$p", "Invert gyro axes", null, indexed("gyroInvNames"),
+ ints("gyroInvert").getOrElse(p) { 0 }, chipModifier = track("sdl3-gyroinv-$p")) { SdlBridgeLink.set("gyroInvert", p, int = it) }
+ for (i in 0 until 3) {
+ BridgeChoiceRow(host, "sdl3-gyromap-$p-$i", "DroidDeck gyro ${"XYZ"[i]} comes from", null, indexed("axisNames"),
+ ints("gyroMap").getOrElse(p * 3 + i) { i }, chipModifier = track("sdl3-gyromap-$p-$i")) { SdlBridgeLink.set("gyroMap", p, i, int = it) }
+ }
+ ToggleRow(host, "sdl3-accel-$p", "Accelerometer follows the axis remap",
+ "Keeps gravity consistent with rotation.", bools("accelFollows").getOrElse(p) { true }, chipModifier = track("sdl3-accel-$p")) {
+ SdlBridgeLink.set("accelFollows", p, bool = it)
+ }
+ SliderRow("Gyro deadzone", "Motion slower than this is ignored, so the view doesn't drift while you hold still.",
+ ints("gyroDeadzone").getOrElse(p) { 0 }, 0..50, format = { "$it °/s" }, modifier = track("sdl3-gyrodeadzone-$p")) { SdlBridgeLink.set("gyroDeadzone", p, int = it) }
+ val bias = s.getFloatArray("gyroBias") ?: FloatArray(0)
+ val calibrating = s.getInt("calibrating", -1) == p
+ val b = (0 until 3).map { bias.getOrElse(p * 3 + it) { 0f } }
+ ActionRow("Calibrate gyro",
+ when {
+ calibrating -> "Hold player ${p + 1}'s controller flat and still…"
+ b.all { it == 0f } -> "Lay the controller flat and still, then tap. Not calibrated yet."
+ else -> "Drift removed: x %.2f, y %.2f, z %.2f °/s".format(b[0], b[1], b[2])
+ }, "Calibrate", onClick = { SdlBridgeLink.set("calibrate", p) },
+ modifier = track("sdl3-calibrate-$p"))
+ ActionRow("Reset calibration", null, "Reset", onClick = { SdlBridgeLink.set("resetCalibration", p) },
+ modifier = track("sdl3-uncalibrate-$p"))
+ }
+
+ SettingsGroup("Touchpad · player ${p + 1}") {
+ BridgeChoiceRow(host, "sdl3-touch-$p", "Layout", "How the controller's touchpad maps to the Deck's two trackpads.",
+ indexed("touchNames"), ints("touchMode").getOrElse(p) { 0 }, chipModifier = track("sdl3-touch-$p")) {
+ SdlBridgeLink.set("touchMode", p, int = it)
+ }
+ ToggleRow(host, "sdl3-tinvx-$p", "Invert X", null, bools("touchInvX").getOrElse(p) { false }, chipModifier = track("sdl3-tinvx-$p")) { SdlBridgeLink.set("touchInvX", p, bool = it) }
+ ToggleRow(host, "sdl3-tinvy-$p", "Invert Y", null, bools("touchInvY").getOrElse(p) { false }, chipModifier = track("sdl3-tinvy-$p")) { SdlBridgeLink.set("touchInvY", p, bool = it) }
+ ToggleRow(host, "sdl3-tswap-$p", "Swap X and Y", "Applied before inverting.", bools("touchSwap").getOrElse(p) { false }, chipModifier = track("sdl3-tswap-$p")) {
+ SdlBridgeLink.set("touchSwap", p, bool = it)
+ }
+ }
+
+ SettingsGroup("Button remap · player ${p + 1}") {
+ val targets = names("remapTargets")
+ val remap = ints("remap")
+ val sources = indexed("srcNames")
+ targets.forEachIndexed { i, target ->
+ BridgeChoiceRow(host, "sdl3-remap-$p-$i", target, "Pressed by", sources, remap.getOrElse(p * targets.size + i) { 0 }, chipModifier = track("sdl3-remap-$p-$i")) {
+ SdlBridgeLink.set("remap", p, i, int = it)
+ }
+ }
+ }
+
+ SettingsGroup("Lightbar · player ${p + 1}") {
+ SliderRow("Red", null, ints("lightR").getOrElse(p) { 0 }, 0..255, modifier = track("sdl3-red-$p")) { SdlBridgeLink.set("lightR", p, int = it) }
+ SliderRow("Green", null, ints("lightG").getOrElse(p) { 0 }, 0..255, modifier = track("sdl3-green-$p")) { SdlBridgeLink.set("lightG", p, int = it) }
+ SliderRow("Blue", null, ints("lightB").getOrElse(p) { 255 }, 0..255, modifier = track("sdl3-blue-$p")) { SdlBridgeLink.set("lightB", p, int = it) }
+ SliderRow("Brightness", null, ints("brightness").getOrElse(p) { 100 }, 0..100, format = { "$it %" }, modifier = track("sdl3-brightness-$p")) {
+ SdlBridgeLink.set("brightness", p, int = it)
+ }
+ BridgeChoiceRow(host, "sdl3-pled-$p", "Player LEDs", null, indexed("playerLedNames"), ints("playerLed").getOrElse(p) { 0 }, chipModifier = track("sdl3-pled-$p")) {
+ SdlBridgeLink.set("playerLed", p, int = it)
+ }
+ }
+}
+
+/** "Steam Native Hook-up (Steam sees the real controller)" -> "Steam Native Hook-up" + "Steam sees the real controller";
+ * "Split (decoupled): left half = ..." -> "Split (decoupled)" + "left half = ...". */
+private fun splitName(name: String): Pair {
+ val colon = name.indexOf(": ")
+ if (colon > 0) return name.substring(0, colon) to name.substring(colon + 2)
+ val paren = name.indexOf(" (")
+ if (paren > 0 && name.endsWith(")")) return name.substring(0, paren) to name.substring(paren + 2, name.length - 1)
+ return name to null
+}
+
+/**
+ * DroidDeck's ChoiceRow, for the bridge's (long) names. The chip shows the short name and the menu the short name
+ * with the rest under it. When the chip would squeeze the label -- the session drawer is narrow, and a long chip
+ * left the label no width at all, so the rows looked empty -- the chip goes under the label instead of beside it.
+ */
+@Composable
+private fun BridgeChoiceRow(
+ host: MenuHost, key: String, label: String, hint: String?,
+ options: List>, selected: T, chipModifier: Modifier = Modifier,
+ onPick: (T) -> Unit,
+) {
+ val open = host.open == key
+ val parts = remember(options) { options.map { (value, name) -> splitName(name).let { Triple(value, it.first, it.second) } } }
+ val chip = parts.firstOrNull { it.first == selected }?.second ?: "-"
+ BoxWithConstraints(Modifier.fillMaxWidth()) {
+ // ~7 dp per character of the chip's 13 sp text, plus its padding and caret; beside the label only while
+ // the label keeps at least half of the row
+ val chipWidth = (parts.maxOfOrNull { it.second.length } ?: 0) * 7f + 40f
+ val stacked = chipWidth > maxWidth.value * 0.5f
+ Column(Modifier.fillMaxWidth()) {
+ SettingsRow(label, hint, highlighted = open, stacked = stacked) {
+ Box {
+ ValueChip(chip, open, modifier = chipModifier.testTag("setting-$key")) { host.open = if (open) null else key }
+ AnchoredMenu(open, onDismiss = { if (host.open == key) host.open = null }, title = label) { firstItemFocus ->
+ parts.forEachIndexed { index, (value, text, detail) ->
+ MenuItem(text, checked = value == selected, detail = detail,
+ focusRequester = if (index == 0) firstItemFocus else null) {
+ onPick(value)
+ host.open = null
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt
index fb123fbd8..d247fa8ff 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt
@@ -8,6 +8,7 @@ internal enum class SessionDrawerPage(val firstControl: String) {
EFFECTS("look"),
VR("steamvr"),
GAMES("anisotropy"),
+ SDL3BRIDGE("sdl3-open"), // sdl3bridge: SDL3Bridge page
SESSION("suspend"),
}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt
index ef90316ea..17360192a 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt
@@ -42,6 +42,7 @@ import androidx.compose.material.icons.outlined.PictureInPictureAlt
import androidx.compose.material.icons.automirrored.outlined.ExitToApp
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.SportsEsports
+import androidx.compose.material.icons.outlined.Usb
import androidx.compose.material3.Icon
import androidx.compose.material3.RadioButton
import androidx.compose.material3.RadioButtonDefaults
@@ -157,6 +158,7 @@ private val SessionDrawerPage.title: Int get() = when (this) {
SessionDrawerPage.VR -> R.string.vr_title
SessionDrawerPage.EFFECTS -> R.string.drawer_page_effects
SessionDrawerPage.GAMES -> R.string.game_settings_title
+ SessionDrawerPage.SDL3BRIDGE -> R.string.drawer_page_sdl3bridge
SessionDrawerPage.SESSION -> R.string.mode_session
}
@@ -168,6 +170,7 @@ private val SessionDrawerPage.icon get() = when (this) {
SessionDrawerPage.VR -> VrHeadsetIcon
SessionDrawerPage.EFFECTS -> Icons.Outlined.AutoFixHigh
SessionDrawerPage.GAMES -> Icons.Outlined.Layers
+ SessionDrawerPage.SDL3BRIDGE -> Icons.Outlined.Usb
SessionDrawerPage.SESSION -> Icons.Outlined.Settings
}
@@ -642,6 +645,7 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
track = { key -> focus.track(page, key) },
)
}
+ SessionDrawerPage.SDL3BRIDGE -> SdlBridgeSettingsTab(host) { key -> focus.track(page, key) }
SessionDrawerPage.SESSION -> {
if (a.isHomeApp) SettingsGroup(stringResource(R.string.drawer_android_apps)) {
SettingsRow(stringResource(R.string.drawer_launch_app), null) {
diff --git a/app/src/main/java/com/example/dsedge/BridgeActivity.java b/app/src/main/java/com/example/dsedge/BridgeActivity.java
new file mode 100644
index 000000000..822836b22
--- /dev/null
+++ b/app/src/main/java/com/example/dsedge/BridgeActivity.java
@@ -0,0 +1,765 @@
+package com.example.dsedge;
+
+import android.app.PendingIntent;
+import android.content.Intent;
+import android.hardware.usb.UsbInterface;
+import android.os.Build;
+import android.graphics.Color;
+import android.graphics.Typeface;
+import android.graphics.drawable.GradientDrawable;
+import android.content.res.ColorStateList;
+import android.util.TypedValue;
+import android.hardware.usb.UsbDevice;
+import android.hardware.usb.UsbManager;
+import android.media.AudioDeviceInfo;
+import android.media.AudioManager;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.CheckBox;
+import android.widget.LinearLayout;
+import android.widget.ScrollView;
+import android.widget.SeekBar;
+import android.widget.Spinner;
+import android.widget.TextView;
+
+import org.libsdl.app.SDLControllerManager;
+
+/**
+ * The bridge's screen: live controller test and settings. SDL3 itself runs in the background service
+ * (SdlEngine), so this screen can open and close at any time without affecting the controllers. (Fallback, if
+ * SDL3 can't run in the background with this build: opening this screen starts SDL3 in a hidden screen behind it.)
+ */
+public class BridgeActivity extends android.app.Activity implements NativeBridge.Listener {
+ private interface IntSink { void set(int v); }
+
+ private final Handler ui = new Handler(Looper.getMainLooper());
+ private TextView statusView, summaryView, usbView;
+ private Button updateButton;
+ private ControllerView pad;
+ private String updateUrl;
+ private long lastSummary;
+ private static final float RAD_TO_DEG = 57.29578f;
+ private static final int CALIB_SAMPLES = 180; // ~0.7s at 4ms/frame
+ private volatile int calibCount = -1; // -1 = not calibrating
+ private final float[] calibSum = new float[3];
+ private int calibPlayer = 0;
+ private int uiPlayer = 0;
+ private LinearLayout playerBox;
+ private Button[] playerTabs;
+ private TextView gyroCalibView;
+ private boolean pendingBubble; // user asked for the bubble; waiting for overlay permission
+ private TextView audioView;
+
+
+ // ---- design tokens ----
+ private static final int BG = 0xFF0E1116, CARD = 0xFF171C24, CARD2 = 0xFF222A35,
+ ACCENT = 0xFF4FC3F7, TEXT = 0xFFECEFF1, SUB = 0xFF8A97A7,
+ OKC = 0xFF34D399, WARNC = 0xFFF59E0B, BADC = 0xFFEF4444;
+ private TextView statusPill;
+
+ private int dp(int v) { return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, v, getResources().getDisplayMetrics()); }
+
+ /** True while the bridge screen is open. */
+ static volatile boolean running;
+
+ @Override protected void onCreate(Bundle state) {
+ NativeBridge.appContext = getApplicationContext();
+ running = true;
+ super.onCreate(state);
+ Settings.init(this);
+ SdlEngine.start(this);
+ // Android 13+: the status bar icon (the bridge's notification) needs this permission.
+ if (Build.VERSION.SDK_INT >= 33 && checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS)
+ != android.content.pm.PackageManager.PERMISSION_GRANTED) {
+ requestPermissions(new String[] { android.Manifest.permission.POST_NOTIFICATIONS }, 1);
+ }
+ UpdateChecker.BuildInfo info = UpdateChecker.readBuildInfo(this);
+
+ LinearLayout root = new LinearLayout(this);
+ root.setOrientation(LinearLayout.VERTICAL);
+ root.setBackgroundColor(BG);
+ root.setPadding(dp(14), dp(18), dp(14), dp(18));
+
+ // ---- header bar ----
+ LinearLayout bar = new LinearLayout(this);
+ bar.setOrientation(LinearLayout.HORIZONTAL);
+ bar.setGravity(Gravity.CENTER_VERTICAL);
+ LinearLayout titleCol = new LinearLayout(this);
+ titleCol.setOrientation(LinearLayout.VERTICAL);
+ TextView appTitle = new TextView(this);
+ appTitle.setText("SDL3 Bridge");
+ appTitle.setTextColor(TEXT);
+ appTitle.setTextSize(21f);
+ appTitle.setTypeface(Typeface.DEFAULT_BOLD);
+ TextView appSub = new TextView(this);
+ appSub.setText(info.tag + " · SDL " + info.sdl);
+ appSub.setTextColor(SUB);
+ appSub.setTextSize(11f);
+ titleCol.addView(appTitle);
+ titleCol.addView(appSub);
+ bar.addView(titleCol, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
+ statusPill = new TextView(this);
+ statusPill.setText("Starting");
+ statusPill.setTextColor(0xFF0E1116);
+ statusPill.setTextSize(11f);
+ statusPill.setTypeface(Typeface.DEFAULT_BOLD);
+ statusPill.setPadding(dp(12), dp(6), dp(12), dp(6));
+ statusPill.setBackground(pill(SUB));
+ bar.addView(statusPill);
+ root.addView(bar);
+ spacer(root, 14);
+
+ // ---- controller test card ----
+ LinearLayout test = card(root);
+ statusView = sub(NativeBridge.lastStatus, 12f);
+ usbView = sub("USB: checking...", 11f);
+ pad = new ControllerView(this);
+ test.addView(statusView);
+ LinearLayout padWrap = new LinearLayout(this);
+ padWrap.setBackground(rounded(0xFF0B0E13, dp(10)));
+ padWrap.setPadding(dp(4), dp(4), dp(4), dp(4));
+ padWrap.addView(pad, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
+ LinearLayout.LayoutParams pw = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ pw.topMargin = dp(8); pw.bottomMargin = dp(8);
+ test.addView(padWrap, pw);
+ summaryView = sub("Press every button and move everything to test it.", 12f);
+ test.addView(summaryView);
+ test.addView(usbView);
+ LinearLayout row = new LinearLayout(this);
+ row.setOrientation(LinearLayout.HORIZONTAL);
+ LinearLayout.LayoutParams rp = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ rp.topMargin = dp(8);
+ row.addView(primaryButton("Test rumble", v -> rumbleTest()), weight());
+ gap(row);
+ row.addView(button("Reset", v -> { pad.resetTested(); summaryView.setText(pad.summary()); }), weight());
+ gap(row);
+ updateButton = button("Update", v -> { if (updateUrl != null) openUrl(updateUrl); else checkUpdate(); });
+ row.addView(updateButton, weight());
+ test.addView(row, rp);
+
+ // ---- per-player settings ----
+ LinearLayout playerCard = card(root);
+ cardTitle(playerCard, "Player settings");
+ LinearLayout tabs = new LinearLayout(this);
+ tabs.setOrientation(LinearLayout.HORIZONTAL);
+ tabs.setBackground(rounded(CARD2, dp(10)));
+ tabs.setPadding(dp(4), dp(4), dp(4), dp(4));
+ playerTabs = new Button[Settings.PLAYERS];
+ for (int i = 0; i < Settings.PLAYERS; i++) {
+ final int pi = i;
+ Button t = new Button(this);
+ t.setText("P" + (i + 1));
+ t.setAllCaps(false);
+ t.setTextSize(13f);
+ t.setOnClickListener(v -> showPlayer(pi));
+ playerTabs[i] = t;
+ tabs.addView(t, weight());
+ }
+ playerCard.addView(tabs);
+ playerBox = new LinearLayout(this);
+ playerBox.setOrientation(LinearLayout.VERTICAL);
+ LinearLayout.LayoutParams pbp = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ pbp.topMargin = dp(10);
+ playerCard.addView(playerBox, pbp);
+ showPlayer(0);
+
+ // ---- SDL3 driver ----
+ LinearLayout drv = card(root);
+ cardTitle(drv, "SDL3 driver");
+ addSpinner(drv, "Controller source", new String[] {
+ "Auto: any controller (SDL3 USB driver when plugged in, else Android/Bluetooth)",
+ "Forced: only SDL3's USB driver (USB cable only)"
+ }, Settings.forceUsb ? 1 : 0, v -> {
+ Settings.forceUsb = v == 1;
+ Settings.save();
+ NativeBridge.applyForce();
+ });
+ drv.addView(primaryButton("Force SDL3 USB driver", v -> forceUsbDriver()));
+ drv.addView(sub("Plug the controller in with USB-C, tap the button and allow USB access "
+ + "(choose 'always' to skip this next time). Works for any SDL3-supported controller.", 11f));
+
+ // ---- Controller mode ----
+ LinearLayout mode = card(root);
+ cardTitle(mode, "Controller mode");
+ addSpinner(mode, "Present controller as", Settings.PRESENT_NAMES, Settings.presentAs, v -> {
+ Settings.presentAs = v;
+ if (!Settings.isHookup()) Settings.lastPresentAs = v;
+ Settings.save();
+ NativeBridge.applyHookup();
+ BridgeService.refreshBubble();
+ });
+ mode.addView(sub("Steam Deck: the controller is DroidDeck's Deck pad (default, full features). "
+ + "DualSense / DualShock 4: Steam sees a NATIVE Sony controller (hidraw) with gyro, touchpad and back buttons. "
+ + "Steam Native Hook-up: every USB controller is handed to Steam as itself (its real name and ids), "
+ + "so Steam's own driver gives it all its features, like on a PC; Bluetooth controllers stay a Deck pad. "
+ + "Give Steam a few seconds, or restart the session, after changing.", 11f));
+ addSpinner(mode, "Poll rate", Settings.RATE_NAMES, rateIndex(), v -> {
+ Settings.pollMs = Settings.RATE_MS[v];
+ Settings.save();
+ NativeBridge.applyRate();
+ });
+ mode.addView(sub("Higher rate = lower gyro/trackpad latency, a little more CPU and battery. No accuracy is lost either way.", 11f));
+
+ // ---- Floating bubble ----
+ LinearLayout bub = card(root);
+ cardTitle(bub, "Floating bubble");
+ addCheck(bub, "Show floating bubble over other apps", Settings.bubble, on -> {
+ if (on) {
+ if (!android.provider.Settings.canDrawOverlays(this)) {
+ pendingBubble = true;
+ try {
+ startActivity(new Intent(android.provider.Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
+ Uri.parse("package:" + getPackageName())));
+ } catch (RuntimeException ignored) { }
+ return;
+ }
+ Settings.bubble = true;
+ startService(new Intent(this, BridgeService.class).setAction("BUBBLE").putExtra("on", true));
+ } else {
+ Settings.bubble = false;
+ startService(new Intent(this, BridgeService.class).setAction("BUBBLE").putExtra("on", false));
+ }
+ });
+ bub.addView(sub("A draggable chat-head: tap toggles the SDL3 driver (green on / red off), hold 3s opens this app, "
+ + "drag to the bottom to remove. Needs the \"Draw over other apps\" permission.", 11f));
+
+ // ---- USB audio ----
+ LinearLayout aud = card(root);
+ cardTitle(aud, "USB audio");
+ audioView = sub(audioInfo(), 12f);
+ aud.addView(audioView);
+ aud.addView(button("Developer options → USB audio routing", v -> openDevSettings()));
+ aud.addView(sub("When the controller is plugged in via USB-C, Android may grab it as an audio device and route sound to it. "
+ + "This opens Developer options — turn ON \"Disable USB audio routing\" to keep audio on the phone. "
+ + "(If Developer options aren't enabled, tap Build number 7 times in About phone first.)", 11f));
+
+ root.addView(sub("Orange = held now, green = worked at least once. Leave this app open once, then switch to DroidDeck.", 11f));
+
+ ScrollView scroll = new ScrollView(this);
+ scroll.setBackgroundColor(BG);
+ scroll.addView(root);
+ addContentView(scroll, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
+
+ NativeBridge.listeners.add(this);
+ try {
+ startForegroundService(new Intent(this, BridgeService.class));
+ } catch (RuntimeException ignored) { }
+ NativeBridge.applyDriver();
+ NativeBridge.applyRate();
+ NativeBridge.applyOutput();
+ checkUpdate();
+ ui.post(usbRefresh);
+ }
+
+ private void showPlayer(int p) {
+ uiPlayer = p;
+ for (int i = 0; i < playerTabs.length; i++) {
+ boolean sel = i == p;
+ playerTabs[i].setBackground(rounded(sel ? ACCENT : 0x00000000, dp(8)));
+ playerTabs[i].setTextColor(sel ? 0xFF0E1116 : SUB);
+ playerTabs[i].setTypeface(sel ? Typeface.DEFAULT_BOLD : Typeface.DEFAULT);
+ }
+ playerBox.removeAllViews();
+ buildPlayerSection(playerBox, p);
+ }
+
+ private void buildPlayerSection(LinearLayout box, final int p) {
+ subhead(box, "Gyro");
+ addSlider(box, "Gyro speed % (100 = 1:1)", 800, Settings.gyroGain[p], v -> Settings.gyroGain[p] = Math.max(1, v));
+ addSpinner(box, "Invert gyro axes", Settings.GYRO_INV_NAMES, Settings.gyroInvert[p], v -> { Settings.gyroInvert[p] = v; Settings.save(); });
+ for (int i = 0; i < 3; i++) {
+ final int out = i;
+ addSpinner(box, "DroidDeck gyro " + "XYZ".charAt(i) + " comes from", Settings.AXIS_NAMES, Settings.gyroMap[p][i], v -> { Settings.gyroMap[p][out] = v; Settings.save(); });
+ }
+ addSpinner(box, "Apply remap/invert to accelerometer too", new String[] { "Yes (recommended)", "No" },
+ Settings.accelFollows[p] ? 0 : 1, v -> { Settings.accelFollows[p] = v == 0; Settings.save(); });
+ addSlider(box, "Gyro deadzone (deg/s)", 50, Settings.gyroDeadzone[p], v -> Settings.gyroDeadzone[p] = v);
+ gyroCalibView = sub(calibLabel(p), 11f);
+ LinearLayout gc = new LinearLayout(this);
+ gc.setOrientation(LinearLayout.HORIZONTAL);
+ gc.addView(button("Calibrate (hold still)", v -> startGyroCalibration()), weight());
+ gap(gc);
+ gc.addView(button("Reset calibration", v -> resetGyroCalibration()), weight());
+ box.addView(gc);
+ box.addView(gyroCalibView);
+ box.addView(sub("Calibration samples the controller shown above (player 1 slot). Deadzone ignores tiny drift.", 11f));
+
+ subhead(box, "Touchpad");
+ addCheck(box, "Invert touchpad X (left/right)", Settings.touchInvX[p], on -> Settings.touchInvX[p] = on);
+ addCheck(box, "Invert touchpad Y (up/down)", Settings.touchInvY[p], on -> Settings.touchInvY[p] = on);
+ addCheck(box, "Swap touchpad X and Y (before invert)", Settings.touchSwap[p], on -> Settings.touchSwap[p] = on);
+ addSpinner(box, "Layout", Settings.TOUCH_NAMES, Settings.touchMode[p], v -> { Settings.touchMode[p] = v; Settings.save(); pad.postInvalidate(); });
+
+ subhead(box, "Button remap");
+ box.addView(sub("Map any controller button to a Deck grip (L4/R4/L5/R5) or a trackpad touch/click. "
+ + "Useful for extra buttons like a Flydigi's C and Z.", 11f));
+ for (int i = 0; i < Settings.REMAP_COUNT; i++) {
+ final int target = i;
+ addSpinner(box, Settings.REMAP_TARGETS[i] + " pressed by", Settings.SRC_NAMES, Settings.remap[p][i], v -> { Settings.remap[p][target] = v; Settings.save(); });
+ }
+
+ subhead(box, "Light");
+ addSlider(box, "Red", 255, Settings.lightR[p], v -> { Settings.lightR[p] = v; applyLight(p); });
+ addSlider(box, "Green", 255, Settings.lightG[p], v -> { Settings.lightG[p] = v; applyLight(p); });
+ addSlider(box, "Blue", 255, Settings.lightB[p], v -> { Settings.lightB[p] = v; applyLight(p); });
+ addSlider(box, "Brightness %", 100, Settings.brightness[p], v -> { Settings.brightness[p] = v; applyLight(p); });
+ addSpinner(box, "Player LEDs", Settings.PLAYER_NAMES, Settings.playerLed[p], v -> { Settings.playerLed[p] = v; Settings.save(); applyLight(p); });
+ }
+
+ private void applyLight(int p) { NativeBridge.applyOutput(p); }
+
+ // ---------- styled UI helpers ----------
+
+ private GradientDrawable rounded(int color, int radius) {
+ GradientDrawable g = new GradientDrawable();
+ g.setColor(color);
+ g.setCornerRadius(radius);
+ return g;
+ }
+
+ private GradientDrawable pill(int color) {
+ GradientDrawable g = new GradientDrawable();
+ g.setColor(color);
+ g.setCornerRadius(dp(20));
+ return g;
+ }
+
+ private LinearLayout card(LinearLayout parent) {
+ LinearLayout c = new LinearLayout(this);
+ c.setOrientation(LinearLayout.VERTICAL);
+ c.setBackground(rounded(CARD, dp(16)));
+ c.setPadding(dp(16), dp(14), dp(16), dp(14));
+ LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ lp.bottomMargin = dp(12);
+ parent.addView(c, lp);
+ return c;
+ }
+
+ private void cardTitle(LinearLayout card, String s) {
+ TextView t = new TextView(this);
+ t.setText(s.toUpperCase(java.util.Locale.ROOT));
+ t.setTextColor(ACCENT);
+ t.setTextSize(12f);
+ t.setTypeface(Typeface.DEFAULT_BOLD);
+ t.setLetterSpacing(0.08f);
+ t.setPadding(0, 0, 0, dp(8));
+ card.addView(t);
+ }
+
+ private void subhead(LinearLayout box, String s) {
+ TextView t = new TextView(this);
+ t.setText(s);
+ t.setTextColor(TEXT);
+ t.setTextSize(14f);
+ t.setTypeface(Typeface.DEFAULT_BOLD);
+ t.setPadding(0, dp(14), 0, dp(4));
+ box.addView(t);
+ }
+
+ private void spacer(LinearLayout parent, int h) {
+ View v = new View(this);
+ parent.addView(v, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(h)));
+ }
+
+ private void gap(LinearLayout row) {
+ View v = new View(this);
+ row.addView(v, new LinearLayout.LayoutParams(dp(8), 1));
+ }
+
+ private TextView text(String s, float size) {
+ TextView t = new TextView(this);
+ t.setTextColor(TEXT);
+ t.setTextSize(size);
+ t.setPadding(0, dp(4), 0, dp(4));
+ t.setText(s);
+ return t;
+ }
+
+ private TextView sub(String s, float size) {
+ TextView t = text(s, size);
+ t.setTextColor(SUB);
+ t.setLineSpacing(dp(2), 1f);
+ return t;
+ }
+
+ private Button button(String s, View.OnClickListener l) {
+ Button b = new Button(this);
+ b.setText(s);
+ b.setAllCaps(false);
+ b.setTextSize(13f);
+ b.setTextColor(TEXT);
+ b.setBackground(rounded(CARD2, dp(10)));
+ b.setStateListAnimator(null);
+ b.setPadding(dp(14), dp(10), dp(14), dp(10));
+ b.setOnClickListener(l);
+ return b;
+ }
+
+ private Button primaryButton(String s, View.OnClickListener l) {
+ Button b = button(s, l);
+ b.setBackground(rounded(ACCENT, dp(10)));
+ b.setTextColor(0xFF0E1116);
+ b.setTypeface(Typeface.DEFAULT_BOLD);
+ return b;
+ }
+
+ private static LinearLayout.LayoutParams weight() {
+ return new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f);
+ }
+
+ private ArrayAdapter styledAdapter(String[] names) {
+ return new ArrayAdapter(this, android.R.layout.simple_spinner_item, names) {
+ @Override public View getView(int pos, View cv, ViewGroup parent) {
+ TextView t = (TextView) super.getView(pos, cv, parent);
+ t.setTextColor(TEXT); t.setTextSize(13f);
+ return t;
+ }
+ @Override public View getDropDownView(int pos, View cv, ViewGroup parent) {
+ TextView t = (TextView) super.getDropDownView(pos, cv, parent);
+ t.setTextColor(0xFF10151C); t.setTextSize(14f);
+ t.setPadding(dp(14), dp(12), dp(14), dp(12));
+ return t;
+ }
+ };
+ }
+
+ private void addSpinner(LinearLayout root, String label, String[] names, int selected, final IntSink sink) {
+ root.addView(sub(label, 12f));
+ Spinner sp = new Spinner(this);
+ sp.setAdapter(styledAdapter(names));
+ sp.setSelection(selected);
+ sp.setBackground(rounded(CARD2, dp(8)));
+ LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ lp.bottomMargin = dp(6);
+ sp.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
+ @Override public void onItemSelected(AdapterView> parent, View view, int position, long id) { sink.set(position); }
+ @Override public void onNothingSelected(AdapterView> parent) { }
+ });
+ root.addView(sp, lp);
+ }
+
+ private void addSlider(LinearLayout root, final String label, int max, int value, final IntSink sink) {
+ final TextView t = sub(label + ": " + value, 12f);
+ SeekBar sb = new SeekBar(this);
+ sb.setMax(max);
+ sb.setProgress(value);
+ sb.setProgressTintList(ColorStateList.valueOf(ACCENT));
+ sb.setThumbTintList(ColorStateList.valueOf(ACCENT));
+ sb.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
+ @Override public void onProgressChanged(SeekBar s, int v, boolean fromUser) {
+ t.setText(label + ": " + v);
+ if (fromUser) sink.set(v);
+ }
+ @Override public void onStartTrackingTouch(SeekBar s) { }
+ @Override public void onStopTrackingTouch(SeekBar s) { Settings.save(); }
+ });
+ root.addView(t);
+ root.addView(sb);
+ }
+
+ private interface BoolSink { void set(boolean on); }
+
+ private void addCheck(LinearLayout root, String label, boolean value, final BoolSink sink) {
+ CheckBox c = new CheckBox(this);
+ c.setText(label);
+ c.setTextColor(TEXT);
+ c.setTextSize(13f);
+ c.setButtonTintList(ColorStateList.valueOf(ACCENT));
+ c.setPadding(dp(6), dp(6), 0, dp(6));
+ c.setChecked(value);
+ c.setOnCheckedChangeListener((v, on) -> { sink.set(on); Settings.save(); });
+ root.addView(c);
+ }
+
+ // ---------- USB diagnostics ----------
+
+ private final Runnable usbRefresh = new Runnable() {
+ @Override public void run() {
+ int n = BridgeService.clientCount;
+ usbView.setText(usbInfo() + "\nDroidDeck link: " + (n > 0 ? (n + " app(s) bound — sending frames") : "NOT bound (open a DroidDeck game session)"));
+ ui.postDelayed(this, 2000);
+ }
+ };
+
+ /** Android's view of the controller on USB: is it there, and may this app talk to it? */
+ private String usbInfo() {
+ try {
+ UsbManager um = (UsbManager) getSystemService(USB_SERVICE);
+ StringBuilder sb = new StringBuilder("USB: ");
+ boolean any = false;
+ for (UsbDevice d : um.getDeviceList().values()) {
+ if (!looksLikeController(d)) continue;
+ any = true;
+ String name = d.getProductName() != null ? d.getProductName() : "controller";
+ sb.append(String.format(java.util.Locale.ROOT, "%s %04x:%04x access=%s ", name,
+ d.getVendorId(), d.getProductId(), um.hasPermission(d) ? "yes" : "NO (unplug and replug, tap OK)"));
+ }
+ if (!any) sb.append("no controller on USB (use a USB-C data cable)");
+ return sb.toString();
+ } catch (RuntimeException e) {
+ return "USB: unavailable";
+ }
+ }
+
+ /** Lists any USB / headset audio outputs Android currently sees (e.g. the controller's audio device). */
+ private int rateIndex() {
+ for (int i = 0; i < Settings.RATE_MS.length; i++) if (Settings.RATE_MS[i] == Settings.pollMs) return i;
+ return 1;
+ }
+
+ private String audioInfo() {
+ try {
+ AudioManager am = (AudioManager) getSystemService(AUDIO_SERVICE);
+ StringBuilder sb = new StringBuilder("Audio out: ");
+ boolean any = false;
+ for (AudioDeviceInfo d : am.getDevices(AudioManager.GET_DEVICES_OUTPUTS)) {
+ int t = d.getType();
+ if (t == AudioDeviceInfo.TYPE_USB_DEVICE || t == AudioDeviceInfo.TYPE_USB_HEADSET || t == AudioDeviceInfo.TYPE_USB_ACCESSORY) {
+ any = true;
+ CharSequence nm = d.getProductName();
+ sb.append(nm != null ? nm : (t == AudioDeviceInfo.TYPE_USB_HEADSET ? "USB headset" : "USB audio")).append(" ");
+ }
+ }
+ if (!any) sb.append("no USB audio device (plug the controller in with USB-C, or no audio interface)");
+ return sb.toString();
+ } catch (RuntimeException e) {
+ return "Audio out: unavailable";
+ }
+ }
+
+ /** Opens Developer options, where "Disable USB audio routing" keeps audio on the phone. */
+ private void openDevSettings() {
+ // There is no public intent to a single developer toggle, so open the Developer options screen.
+ try {
+ startActivity(new Intent(android.provider.Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
+ return;
+ } catch (RuntimeException ignored) { }
+ // Developer options not enabled or unavailable: fall back to the main Settings app.
+ try {
+ startActivity(new Intent(android.provider.Settings.ACTION_SETTINGS)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
+ } catch (RuntimeException ignored) { }
+ }
+
+ // ---------- tests ----------
+
+ private void rumbleTest() {
+ NativeBridge.setRumble(uiPlayer, 220, 220);
+ ui.postDelayed(() -> NativeBridge.setRumble(uiPlayer, 0, 0), 700);
+ }
+
+ // ---------- updates ----------
+
+ private void checkUpdate() {
+ updateUrl = null;
+ updateButton.setText("Checking for updates...");
+ UpdateChecker.check(this, (msg, url) -> {
+ updateUrl = url;
+ updateButton.setText(msg);
+ });
+ }
+
+ private void openUrl(String url) {
+ try {
+ startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url)));
+ } catch (RuntimeException e) {
+ updateButton.setText("Could not open the download link.");
+ }
+ }
+
+ // ---------- SDL USB handling ----------
+
+ @Override protected void onDestroy() {
+ running = false;
+ ui.removeCallbacks(usbRefresh);
+ NativeBridge.listeners.remove(this);
+ super.onDestroy();
+ }
+
+ // SDL releases the USB controller when the activity pauses or loses focus. Undo that so input
+ // keeps flowing while DroidDeck is in front.
+ /** Asks Android for USB access to every game-controller-like device, using SDL's own permission
+ * action so SDL's receiver opens the device straight away, then restarts SDL's enumeration. */
+ private void forceUsbDriver() {
+ UsbManager um = (UsbManager) getSystemService(USB_SERVICE);
+ int asked = 0, have = 0, seen = 0;
+ if (um != null) {
+ for (UsbDevice d : um.getDeviceList().values()) {
+ if (!looksLikeController(d)) continue;
+ seen++;
+ if (um.hasPermission(d)) { have++; continue; }
+ Intent i = new Intent("org.libsdl.app.USB_PERMISSION").setPackage(getPackageName());
+ int flags = Build.VERSION.SDK_INT >= 31 ? 0x02000000 : 0; // FLAG_MUTABLE
+ try {
+ um.requestPermission(d, PendingIntent.getBroadcast(this, 0, i, flags));
+ asked++;
+ } catch (RuntimeException ignored) { }
+ }
+ }
+ usbView.setText(seen == 0
+ ? "No USB controller found. Use a USB-C cable (Bluetooth is not enough) and try again."
+ : "USB controllers: " + seen + ", already allowed: " + have + ", asked now: " + asked);
+ keepControllerClaimed();
+ ui.postDelayed(NativeBridge::rescan, asked > 0 ? 4000 : 300);
+ }
+
+ /** Any USB device with a HID or vendor-specific interface (SDL3 decides whether it really is a controller). */
+ private static boolean looksLikeController(UsbDevice d) {
+ for (int n = 0; n < d.getInterfaceCount(); n++) {
+ UsbInterface f = d.getInterface(n);
+ int c = f.getInterfaceClass();
+ if (c == 255 || (c == 3 && f.getInterfaceProtocol() == 0)) return true; // vendor-specific (Xbox) or generic HID
+ }
+ return false;
+ }
+
+ @Override protected void onResume() {
+ super.onResume();
+ if (android.provider.Settings.canDrawOverlays(this)) {
+ if (pendingBubble) {
+ pendingBubble = false;
+ Settings.bubble = true;
+ Settings.save();
+ }
+ if (Settings.bubble) {
+ startService(new Intent(this, BridgeService.class).setAction("BUBBLE").putExtra("on", true));
+ }
+ }
+ }
+
+ @Override protected void onPause() {
+ super.onPause();
+ if (isFinishing()) running = false;
+ keepControllerClaimed();
+ }
+
+ @Override public void onBackPressed() {
+ // Fallback only: SDL3 runs in a screen behind this one, and closing them would stop it -- step back instead.
+ if (SdlHostActivity.running) moveTaskToBack(true);
+ else super.onBackPressed();
+ }
+
+ @Override public void onWindowFocusChanged(boolean hasFocus) {
+ super.onWindowFocusChanged(hasFocus);
+ keepControllerClaimed();
+ }
+
+ private void keepControllerClaimed() {
+ // When the driver is off, let go of the controller (freeze = release the USB interface) so Android can use it.
+ SdlEngine.setHidFrozen(!Settings.driverEnabled);
+ }
+
+ /** Release or re-claim the USB interface (the driver was switched off / on). */
+ static void setHidFrozen(boolean frozen) {
+ SdlEngine.setHidFrozen(frozen);
+ }
+
+ // Controllers that reach the phone through Android's input (Bluetooth) deliver their buttons and sticks to the
+ // focused window: while this screen is in front, hand them to SDL like SDL's own activity did.
+ private static boolean fromGamepad(int source) {
+ return (source & android.view.InputDevice.SOURCE_GAMEPAD) == android.view.InputDevice.SOURCE_GAMEPAD
+ || (source & android.view.InputDevice.SOURCE_JOYSTICK) == android.view.InputDevice.SOURCE_JOYSTICK
+ || (source & android.view.InputDevice.SOURCE_DPAD) == android.view.InputDevice.SOURCE_DPAD;
+ }
+
+ @Override public boolean dispatchKeyEvent(android.view.KeyEvent e) {
+ if (SdlEngine.isRunning() && fromGamepad(e.getSource())) {
+ try {
+ if (SDLControllerManager.isDeviceSDLJoystick(e.getDeviceId())) {
+ if (e.getAction() == android.view.KeyEvent.ACTION_DOWN
+ && SDLControllerManager.onNativePadDown(e.getDeviceId(), e.getKeyCode(), e.getScanCode())) return true;
+ if (e.getAction() == android.view.KeyEvent.ACTION_UP
+ && SDLControllerManager.onNativePadUp(e.getDeviceId(), e.getKeyCode(), e.getScanCode())) return true;
+ }
+ } catch (UnsatisfiedLinkError | RuntimeException ignored) { }
+ }
+ return super.dispatchKeyEvent(e);
+ }
+
+ @Override public boolean dispatchGenericMotionEvent(android.view.MotionEvent e) {
+ if (SdlEngine.isRunning() && (e.getSource() & android.view.InputDevice.SOURCE_JOYSTICK) == android.view.InputDevice.SOURCE_JOYSTICK) {
+ try {
+ if (SDLControllerManager.handleJoystickMotionEvent(e)) return true;
+ } catch (UnsatisfiedLinkError | RuntimeException ignored) { }
+ }
+ return super.dispatchGenericMotionEvent(e);
+ }
+
+ // ---------- NativeBridge.Listener ----------
+
+ private String calibLabel(int p) {
+ float[] b = Settings.gyroBias[p];
+ if (b[0] == 0f && b[1] == 0f && b[2] == 0f) return "Calibration: none";
+ return String.format(java.util.Locale.ROOT, "Calibration bias (deg/s): x=%.2f y=%.2f z=%.2f", b[0], b[1], b[2]);
+ }
+
+ private void startGyroCalibration() {
+ calibPlayer = uiPlayer;
+ calibSum[0] = calibSum[1] = calibSum[2] = 0f;
+ calibCount = 0;
+ if (gyroCalibView != null) gyroCalibView.setText("Hold the controller still...");
+ }
+
+ private void resetGyroCalibration() {
+ calibCount = -1;
+ float[] b = Settings.gyroBias[uiPlayer];
+ b[0] = b[1] = b[2] = 0f;
+ Settings.save();
+ if (gyroCalibView != null) gyroCalibView.setText(calibLabel(uiPlayer));
+ }
+
+ @Override public void onFrame(float[] f, int slot) {
+ if (slot != 0) return; // the test screen shows player 1
+ if (calibCount >= 0 && f.length >= 13) {
+ calibSum[0] += f[7]; calibSum[1] += f[8]; calibSum[2] += f[9];
+ if (++calibCount >= CALIB_SAMPLES) {
+ float[] b = Settings.gyroBias[calibPlayer];
+ b[0] = calibSum[0] / CALIB_SAMPLES * RAD_TO_DEG;
+ b[1] = calibSum[1] / CALIB_SAMPLES * RAD_TO_DEG;
+ b[2] = calibSum[2] / CALIB_SAMPLES * RAD_TO_DEG;
+ Settings.save();
+ calibCount = -1;
+ ui.post(() -> { if (gyroCalibView != null && uiPlayer == calibPlayer) gyroCalibView.setText(calibLabel(calibPlayer)); });
+ }
+ }
+ pad.update(f);
+ long now = System.nanoTime();
+ if (now - lastSummary > 300_000_000L) {
+ lastSummary = now;
+ ui.post(() -> summaryView.setText(pad.summary()));
+ }
+ }
+
+ @Override public void onPadCaps(int slot, int present, int axes, int touchpads, String kind) {
+ if (slot != 0) return;
+ pad.setCaps(present, axes, touchpads, kind);
+ }
+
+ @Override public void onStatus(final String s) {
+ ui.post(() -> {
+ statusView.setText(s);
+ boolean off = s.contains("driver OFF");
+ boolean waiting = s.startsWith("Controller disconnected") || s.startsWith("Waiting") || s.startsWith("Starting");
+ if (off) {
+ statusPill.setText("Driver off");
+ statusPill.setBackground(pill(BADC));
+ } else if (waiting) {
+ statusPill.setText("Waiting");
+ statusPill.setBackground(pill(WARNC));
+ } else {
+ statusPill.setText("Connected");
+ statusPill.setBackground(pill(OKC));
+ }
+ if (off || waiting) pad.setDisconnected();
+ });
+ }
+}
diff --git a/app/src/main/java/com/example/dsedge/BridgeService.java b/app/src/main/java/com/example/dsedge/BridgeService.java
new file mode 100644
index 000000000..4e0a437cf
--- /dev/null
+++ b/app/src/main/java/com/example/dsedge/BridgeService.java
@@ -0,0 +1,975 @@
+package com.example.dsedge;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.Service;
+import android.content.Intent;
+import android.content.pm.ServiceInfo;
+import android.graphics.Color;
+import android.graphics.PixelFormat;
+import android.graphics.drawable.GradientDrawable;
+import android.util.TypedValue;
+import android.view.Gravity;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.WindowManager;
+import android.widget.TextView;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.Message;
+import android.os.Messenger;
+import android.os.RemoteException;
+import android.os.SystemClock;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+/**
+ * Streams controller frames (from the SDL3 loop) to bound clients such as DroidDeck, and passes their
+ * rumble / lightbar requests back. Same wire protocol as the first bridge, plus decoupled touchpad data.
+ */
+public class BridgeService extends Service implements NativeBridge.Listener {
+ static final int MSG_REGISTER = 1;
+ static final int MSG_UNREGISTER = 2;
+ static final int MSG_OUTPUT = 3;
+ static final int MSG_FRAME = 10;
+ static final int MSG_STATUS = 11;
+ static final int MSG_PAD_GONE = 12;
+ // Steam Controller (2026) raw passthrough. Client -> bridge:
+ static final int MSG_TRITON_OUTPUT = 4; // "data": report (id first), "feature": feature write?
+ static final int MSG_TRITON_GET = 5; // "request": long, "id": feature report id
+ static final int MSG_TRITON_RUMBLE = 6; // "l", "r": 0..65535
+ // Bridge -> client:
+ static final int MSG_TRITON_STATE = 13; // "id": report id, "data": payload
+ static final int MSG_TRITON_CONN = 14; // "on": boolean, "what": description
+ static final int MSG_TRITON_FEATURE = 15; // "request": long, "data": reply (absent = no answer)
+ // Steam Native Hook-up. Client -> bridge:
+ static final int MSG_HOOKUP_OUT = 7; // "slot", "kind" (0 output report, 1 feature write), "data"
+ static final int MSG_HOOKUP_GET = 8; // "slot", "request", "id", "input", "length"
+ // Bridge -> client:
+ static final int MSG_HOOKUP_ADD = 20; // HookupIdentity bundle
+ static final int MSG_HOOKUP_INPUT = 21; // "slot", "data"
+ static final int MSG_HOOKUP_REMOVE = 22; // "slot"
+ static final int MSG_HOOKUP_REPLY = 23; // "slot", "request", "data" (absent = no answer)
+ static final int MSG_BRIDGE_LOG = 30; // "text": one or more log lines (newline separated)
+ static final int MSG_HOOKUP_SYNC = 9; // client watchdog: resend what is handed over (re-registers if needed)
+ static final int MSG_HOOKUP_STATE = 24; // "slots": int[] of live hook-up slots; "fast_ino": mapped shared file
+ static final int MSG_HOOKUP_SHM = 25; // <- DroidDeck: "fd": its shared hook-up file (fast path)
+ // DroidDeck's SDL3Bridge settings tab (no REGISTER needed: it gets no frames, only replies)
+ static final int MSG_SETTINGS_GET = 40; // <- replyTo: send MSG_SETTINGS
+ static final int MSG_SETTINGS_SET = 41; // <- "key" [+ "p" player, "i" index] + "int" / "bool"; replyTo: send MSG_SETTINGS
+ static final int MSG_SETTINGS = 42; // -> the whole settings state + controller status (see settingsSnapshot)
+
+ private static final float RAD_TO_DEG = 57.29578f;
+ private static final float G = 9.80665f;
+ private static final float PAD_W = 1920f, PAD_H = 1080f;
+
+ private final CopyOnWriteArrayList clients = new CopyOnWriteArrayList<>();
+ static volatile int clientCount; // how many clients (DroidDeck) are bound + registered
+
+ private final Handler handler = new Handler(Looper.getMainLooper()) {
+ @Override public void handleMessage(Message m) {
+ switch (m.what) {
+ case MSG_REGISTER:
+ if (m.replyTo != null) {
+ if (!clients.contains(m.replyTo)) clients.add(m.replyTo); // re-register = resend state, no duplicate
+ clientCount = clients.size(); sendStatus(m.replyTo, NativeBridge.lastStatus);
+ if (NativeBridge.tritonConnected) sendTritonConn(m.replyTo, true, NativeBridge.tritonWhat);
+ String backlog;
+ synchronized (NativeBridge.bridgeLog) { backlog = String.join("\n", NativeBridge.bridgeLog); }
+ if (!backlog.isEmpty()) {
+ Bundle lb = new Bundle();
+ lb.putString("text", "(earlier) " + backlog.replace("\n", "\n(earlier) "));
+ sendOne(m.replyTo, MSG_BRIDGE_LOG, lb);
+ }
+ for (Bundle id : NativeBridge.hookupIdentity) if (id != null) sendOne(m.replyTo, MSG_HOOKUP_ADD, id);
+ }
+ break;
+ case MSG_UNREGISTER:
+ if (m.replyTo != null) { clients.remove(m.replyTo); clientCount = clients.size(); }
+ if (clients.isEmpty()) NativeBridge.setSteamAway(false); // DroidDeck's session ended
+ break;
+ case MSG_OUTPUT:
+ mergeOutput(m.getData());
+ break;
+ case MSG_TRITON_OUTPUT: {
+ byte[] data = m.getData().getByteArray("data");
+ if (data != null) NativeBridge.tritonOutput(data, m.getData().getBoolean("feature"));
+ break;
+ }
+ case MSG_TRITON_GET:
+ NativeBridge.tritonGetFeature(m.getData().getLong("request"), m.getData().getInt("id", 1));
+ break;
+ case MSG_HOOKUP_OUT: {
+ byte[] data = m.getData().getByteArray("data");
+ if (data != null) NativeBridge.hookupOutput(m.getData().getInt("slot"), m.getData().getInt("kind"), data);
+ break;
+ }
+ case MSG_HOOKUP_SYNC:
+ // DroidDeck's desktop: is the Steam client running there? (Absent = yes: the Steam session.)
+ NativeBridge.setSteamAway(m.getData() != null && m.getData().getBoolean("steamAway", false));
+ handler.removeCallbacks(steamAwayLapse);
+ if (NativeBridge.hookupWaitsForSteam()) handler.postDelayed(steamAwayLapse, 10100);
+ if (m.replyTo != null) {
+ // A client the bridge forgot (the bridge was restarted) is registered again.
+ if (!clients.contains(m.replyTo)) {
+ clients.add(m.replyTo); clientCount = clients.size();
+ sendStatus(m.replyTo, NativeBridge.lastStatus);
+ }
+ if (NativeBridge.tritonConnected) sendTritonConn(m.replyTo, true, NativeBridge.tritonWhat);
+ int[] live = new int[NativeBridge.MAX_PADS];
+ int n = 0;
+ for (int i = 0; i < NativeBridge.MAX_PADS; i++) {
+ Bundle id = NativeBridge.hookupIdentity[i];
+ if (id == null) continue;
+ sendOne(m.replyTo, MSG_HOOKUP_ADD, id);
+ live[n++] = i;
+ }
+ NativeBridge.retryUndescribed();
+ Bundle st = new Bundle();
+ st.putIntArray("slots", java.util.Arrays.copyOf(live, n));
+ st.putLong("fast_ino", NativeBridge.hookupShmInode());
+ sendOne(m.replyTo, MSG_HOOKUP_STATE, st);
+ }
+ ensureBridgeLoop();
+ break;
+ case MSG_HOOKUP_SHM: {
+ Bundle d = m.getData();
+ if (d != null) d.setClassLoader(android.os.ParcelFileDescriptor.class.getClassLoader());
+ @SuppressWarnings("deprecation")
+ android.os.ParcelFileDescriptor pfd = d == null ? null : d.getParcelable("fd");
+ NativeBridge.hookupMapShm(pfd);
+ break;
+ }
+ case MSG_SETTINGS_SET:
+ applyRemote(m.getData());
+ // fall through: answer with the new state
+ case MSG_SETTINGS_GET:
+ if (m.replyTo != null) sendOne(m.replyTo, MSG_SETTINGS, settingsSnapshot());
+ if (m.what == MSG_SETTINGS_GET) ensureBridgeLoop(); // the tab shows live controllers
+ break;
+ case MSG_HOOKUP_GET:
+ NativeBridge.hookupGet(m.getData().getInt("slot"), m.getData().getLong("request"), m.getData().getInt("id"),
+ m.getData().getBoolean("input"), m.getData().getInt("length", 64));
+ break;
+ case MSG_TRITON_RUMBLE:
+ NativeBridge.tritonRumble(m.getData().getInt("l"), m.getData().getInt("r"));
+ break;
+ }
+ }
+ };
+ private final Messenger messenger = new Messenger(handler);
+ /** DroidDeck stopped saying that Steam is away (it went away itself): hook-up goes on again. */
+ private final Runnable steamAwayLapse = NativeBridge::recheckHookup;
+
+ // ---- floating bubble ----
+ private final Handler main = new Handler(Looper.getMainLooper());
+ private WindowManager wm;
+ private TextView bubble;
+ private WindowManager.LayoutParams bubbleLp;
+ private int bubbleSize;
+ private View removeZone;
+ private boolean longPressFired;
+ private Runnable bubbleLongPress;
+ private volatile boolean connectedState;
+
+ static volatile BridgeService instance;
+
+ @Override public void onCreate() {
+ NativeBridge.appContext = getApplicationContext();
+ NativeBridge.clients = clients::size;
+ instance = this;
+ super.onCreate();
+ Settings.init(this);
+ NativeBridge.listeners.add(this);
+ // SDL3 runs here, in the background: nothing has to open when DroidDeck (or a plugged-in controller)
+ // starts the bridge. The notification is the bridge's icon in the status bar.
+ SdlEngine.start(this);
+ // The status bar icon. Started by DroidDeck's connection it is a plain ongoing notification (that
+ // connection keeps the bridge alive); started from the bridge screen or a plugged-in controller,
+ // onStartCommand turns it into the foreground service's notification.
+ postNotification(false);
+ }
+
+ // ---------- status bar notification ----------
+ private static final int NOTE_ID = 1;
+ private String noteText = "";
+ private long noteMs;
+
+ private Notification buildNotification() {
+ NotificationManager nm = getSystemService(NotificationManager.class);
+ nm.createNotificationChannel(new NotificationChannel("bridge", "Controller bridge", NotificationManager.IMPORTANCE_LOW));
+ android.app.PendingIntent open = android.app.PendingIntent.getActivity(this, 0,
+ new Intent(this, BridgeActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
+ android.app.PendingIntent.FLAG_IMMUTABLE | android.app.PendingIntent.FLAG_UPDATE_CURRENT);
+ String text = SdlEngine.error != null ? SdlEngine.error : NativeBridge.lastStatus;
+ return new Notification.Builder(this, "bridge")
+ .setContentTitle(!Settings.isHookup() ? "SDL3Bridge"
+ : NativeBridge.hookupWaitsForSteam() ? "SDL3Bridge · Hook-up waits for Steam (DroidDeck desktop)"
+ : "SDL3Bridge · Steam Native Hook-up")
+ .setContentText(text)
+ .setStyle(new Notification.BigTextStyle().bigText(text))
+ .setSmallIcon(com.droiddeck.launcher.R.drawable.sdl3_ic_stat_bridge)
+ .setColor(0xFF4FC3F7)
+ .setContentIntent(open)
+ .setOngoing(true)
+ .setShowWhen(false)
+ .setOnlyAlertOnce(true)
+ .build();
+ }
+
+ /** foreground: as the foreground service's notification; else a plain ongoing notification. */
+ private boolean postNotification(boolean foreground) {
+ try {
+ Notification n = buildNotification();
+ if (foreground) {
+ if (Build.VERSION.SDK_INT >= 34) startForeground(NOTE_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE);
+ else startForeground(NOTE_ID, n);
+ } else {
+ getSystemService(NotificationManager.class).notify(NOTE_ID, n);
+ }
+ return true;
+ } catch (RuntimeException e) {
+ android.util.Log.w("SDL3Bridge", "notification: " + e);
+ return false;
+ }
+ }
+
+ /** Status changed: refresh the notification text (at most every 2 s). */
+ private void refreshNotification(String status) {
+ if (status == null || status.equals(noteText)) return;
+ long now = SystemClock.uptimeMillis();
+ if (now - noteMs < 2000) {
+ main.removeCallbacks(noteRefresh);
+ main.postDelayed(noteRefresh, 2000 - (now - noteMs));
+ return;
+ }
+ noteText = status;
+ noteMs = now;
+ try { getSystemService(NotificationManager.class).notify(NOTE_ID, buildNotification()); } catch (RuntimeException ignored) { }
+ }
+ private final Runnable noteRefresh = () -> refreshNotification(NativeBridge.lastStatus);
+
+ @Override public IBinder onBind(Intent i) { return messenger.getBinder(); }
+
+ @Override public int onStartCommand(Intent i, int flags, int startId) {
+ SdlEngine.start(this);
+ if (i != null && "BUBBLE".equals(i.getAction())) {
+ if (i.getBooleanExtra("on", false)) showBubble(); else hideBubble();
+ } else if (Settings.bubble) {
+ showBubble();
+ }
+ if (!postNotification(true)) postNotification(false);
+ return START_NOT_STICKY;
+ }
+
+ @Override public void onDestroy() {
+ if (instance == this) instance = null;
+ NativeBridge.listeners.remove(this);
+ hideBubble();
+ super.onDestroy();
+ }
+
+ // ---------- floating bubble (chat-head) ----------
+ // tap -> toggle the SDL3 forced driver (green = on, red = off)
+ // double tap -> toggle Steam Native Hook-up (blue = on: Steam gets the real controllers)
+ // hold 3s -> open the bridge app
+ // drag to the bottom remove zone -> dismiss the bubble
+
+ @SuppressWarnings("ClickableViewAccessibility")
+ private void showBubble() {
+ if (bubble != null) return;
+ if (!android.provider.Settings.canDrawOverlays(this)) return; // no permission yet
+ wm = (WindowManager) getSystemService(WINDOW_SERVICE);
+
+ bubbleSize = dp(52);
+ TextView b = new TextView(this);
+ b.setText(Settings.isHookup() ? "HOOK" : "SDL3");
+ b.setTextColor(Color.WHITE);
+ b.setGravity(Gravity.CENTER);
+ b.setTextSize(12f);
+ b.setWidth(bubbleSize);
+ b.setHeight(bubbleSize);
+ b.setBackground(bubbleBg(Settings.driverEnabled));
+ b.setElevation(dp(4));
+
+ int type = Build.VERSION.SDK_INT >= 26
+ ? WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
+ : WindowManager.LayoutParams.TYPE_PHONE;
+ WindowManager.LayoutParams lp = new WindowManager.LayoutParams(
+ WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT, type,
+ WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
+ PixelFormat.TRANSLUCENT);
+ lp.gravity = Gravity.TOP | Gravity.START;
+ lp.x = Settings.bubbleX;
+ lp.y = Settings.bubbleY;
+
+ final int slop = dp(10);
+ b.setOnTouchListener(new View.OnTouchListener() {
+ float downX, downY; int startX, startY; boolean moved;
+ @Override public boolean onTouch(View v, MotionEvent e) {
+ switch (e.getAction()) {
+ case MotionEvent.ACTION_DOWN:
+ downX = e.getRawX(); downY = e.getRawY();
+ startX = bubbleLp.x; startY = bubbleLp.y; moved = false; longPressFired = false;
+ bubbleLongPress = () -> { longPressFired = true; openBridgeApp(); };
+ main.postDelayed(bubbleLongPress, 3000);
+ return true;
+ case MotionEvent.ACTION_MOVE: {
+ int nx = startX + (int) (e.getRawX() - downX);
+ int ny = startY + (int) (e.getRawY() - downY);
+ if (!moved && Math.abs(nx - startX) + Math.abs(ny - startY) > slop) {
+ moved = true;
+ if (bubbleLongPress != null) main.removeCallbacks(bubbleLongPress);
+ showRemoveZone();
+ }
+ bubbleLp.x = nx; bubbleLp.y = ny;
+ try { wm.updateViewLayout(bubble, bubbleLp); } catch (RuntimeException ignored) { }
+ if (moved) highlightRemoveZone(overRemoveZone());
+ return true;
+ }
+ case MotionEvent.ACTION_CANCEL:
+ case MotionEvent.ACTION_UP:
+ if (bubbleLongPress != null) main.removeCallbacks(bubbleLongPress);
+ boolean removed = false;
+ if (moved && overRemoveZone()) { removeBubbleByUser(); removed = true; }
+ hideRemoveZone();
+ if (!removed) {
+ Settings.bubbleX = bubbleLp.x; Settings.bubbleY = bubbleLp.y; Settings.save();
+ if (!moved && !longPressFired) onBubbleTap();
+ }
+ return true;
+ }
+ return false;
+ }
+ });
+
+ try {
+ wm.addView(b, lp);
+ bubble = b;
+ bubbleLp = lp;
+ } catch (RuntimeException e) {
+ bubble = null;
+ }
+ }
+
+ private void openBridgeApp() {
+ try {
+ startActivity(new Intent(BridgeService.this, BridgeActivity.class)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
+ } catch (RuntimeException ignored) { }
+ }
+
+ // A single tap waits this long for a second tap before it acts, so a double tap doesn't also flip the driver.
+ private static final long DOUBLE_TAP_MS = 300;
+ private boolean tapPending;
+
+ private void onBubbleTap() {
+ if (tapPending) {
+ tapPending = false; // the pending single tap sees this and does nothing
+ toggleHookup();
+ } else {
+ tapPending = true;
+ main.postDelayed(() -> { if (tapPending) { tapPending = false; toggleDriver(); } }, DOUBLE_TAP_MS);
+ }
+ }
+
+ /**
+ * Double-tap action: Steam Native Hook-up on/off. Hook-up needs the SDL3 driver, so switching it on also
+ * switches the driver on; switching it off puts back what was there before -- including the driver being
+ * off (red bubble), so the controller goes back to Android / DroidDeck's own input instead of SDL3.
+ */
+ private void toggleHookup() {
+ boolean on = !Settings.isHookup();
+ boolean freeze = false;
+ if (on) {
+ Settings.lastPresentAs = Settings.presentAs;
+ Settings.driverBeforeHookup = Settings.driverEnabled;
+ Settings.presentAs = Settings.PRESENT_HOOKUP;
+ if (!Settings.driverEnabled) {
+ Settings.driverEnabled = true;
+ try { SdlEngine.setHidFrozen(false); } catch (Throwable ignored) { }
+ }
+ } else {
+ Settings.presentAs = Settings.lastPresentAs;
+ if (!Settings.driverBeforeHookup && Settings.driverEnabled) {
+ Settings.driverEnabled = false;
+ freeze = true;
+ }
+ }
+ Settings.save();
+ NativeBridge.applyDriver(); // also applies hook-up (native code releases the hooked-up controllers)
+ if (freeze) {
+ try { SdlEngine.setHidFrozen(true); } catch (Throwable ignored) { }
+ }
+ updateBubble();
+ try {
+ android.widget.Toast.makeText(this, on
+ ? "Steam Native Hook-up ON: Steam gets the real USB controllers"
+ : !Settings.driverEnabled ? "Steam Native Hook-up OFF: SDL3 driver off again"
+ : "Steam Native Hook-up OFF: back to " + Settings.PRESENT_NAMES[Settings.presentAs],
+ android.widget.Toast.LENGTH_SHORT).show();
+ } catch (RuntimeException ignored) { }
+ }
+
+ /** Recolor the bubble after a settings change made elsewhere (the app screen). */
+ static void refreshBubble() {
+ BridgeService s = instance;
+ if (s != null) s.updateBubble();
+ }
+
+ /** Tap action: flip the SDL3 forced driver and recolor the bubble. */
+ private void toggleDriver() {
+ Settings.driverEnabled = !Settings.driverEnabled;
+ Settings.save();
+ NativeBridge.applyDriver();
+ try { SdlEngine.setHidFrozen(!Settings.driverEnabled); } catch (Throwable ignored) { }
+ updateBubble();
+ }
+
+ private void removeBubbleByUser() {
+ Settings.bubble = false;
+ Settings.save();
+ hideBubble();
+ }
+
+ private void hideBubble() {
+ if (bubble != null && wm != null) {
+ try { wm.removeView(bubble); } catch (RuntimeException ignored) { }
+ }
+ bubble = null;
+ hideRemoveZone();
+ }
+
+ // ---- remove zone (the drop target at the bottom) ----
+
+ private void showRemoveZone() {
+ if (removeZone != null || wm == null) return;
+ int zs = dp(64);
+ TextView z = new TextView(this);
+ z.setText("✕");
+ z.setTextColor(Color.WHITE);
+ z.setGravity(Gravity.CENTER);
+ z.setTextSize(22f);
+ z.setWidth(zs);
+ z.setHeight(zs);
+ z.setBackground(removeZoneBg(false));
+ WindowManager.LayoutParams lp = new WindowManager.LayoutParams(
+ WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT,
+ bubbleLp.type,
+ WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE
+ | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
+ PixelFormat.TRANSLUCENT);
+ lp.gravity = Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL;
+ lp.y = dp(48);
+ try { wm.addView(z, lp); removeZone = z; } catch (RuntimeException e) { removeZone = null; }
+ }
+
+ private void hideRemoveZone() {
+ if (removeZone != null && wm != null) {
+ try { wm.removeView(removeZone); } catch (RuntimeException ignored) { }
+ }
+ removeZone = null;
+ }
+
+ private void highlightRemoveZone(boolean over) {
+ if (removeZone instanceof TextView) {
+ removeZone.setBackground(removeZoneBg(over));
+ ((TextView) removeZone).setScaleX(over ? 1.25f : 1f);
+ ((TextView) removeZone).setScaleY(over ? 1.25f : 1f);
+ }
+ }
+
+ private GradientDrawable removeZoneBg(boolean over) {
+ GradientDrawable g = new GradientDrawable();
+ g.setShape(GradientDrawable.OVAL);
+ g.setColor(over ? 0xFFC62828 : 0xCC424242);
+ g.setStroke(dp(2), 0xCCFFFFFF);
+ return g;
+ }
+
+ /** True when the bubble's centre is over the bottom remove zone. */
+ private boolean overRemoveZone() {
+ if (removeZone == null) return false;
+ android.util.DisplayMetrics dm = getResources().getDisplayMetrics();
+ float bx = bubbleLp.x + bubbleSize / 2f;
+ float by = bubbleLp.y + bubbleSize / 2f;
+ float zx = dm.widthPixels / 2f;
+ float zy = dm.heightPixels - dp(48) - dp(32); // zone centre (bottom margin + half height)
+ float dx = bx - zx, dy = by - zy;
+ return Math.sqrt(dx * dx + dy * dy) < dp(80);
+ }
+
+ private GradientDrawable bubbleBg(boolean driverOn) {
+ GradientDrawable g = new GradientDrawable();
+ g.setShape(GradientDrawable.OVAL);
+ // blue = Steam Native Hook-up, green = SDL3 driver on, red = off
+ g.setColor(!driverOn ? 0xFFC62828 : Settings.isHookup() ? 0xFF1565C0 : 0xFF2E7D32);
+ g.setStroke(dp(2), 0xCCFFFFFF);
+ return g;
+ }
+
+ private void updateBubble() {
+ if (bubble == null) return;
+ main.post(() -> {
+ if (bubble == null) return;
+ bubble.setBackground(bubbleBg(Settings.driverEnabled));
+ bubble.setText(Settings.isHookup() ? "HOOK" : "SDL3");
+ });
+ }
+
+ private int dp(int v) {
+ return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, v, getResources().getDisplayMetrics());
+ }
+
+ // ---------- client -> controller ----------
+
+ /** Applies the touchpad swap / invert settings to one trackpad's [down, x, y]. */
+ private static void orient(float[] p, int player) {
+ if (Settings.touchSwap[player]) { float t = p[1]; p[1] = p[2]; p[2] = t; }
+ if (Settings.touchInvX[player]) p[1] = -p[1];
+ if (Settings.touchInvY[player]) p[2] = -p[2];
+ }
+
+ private void mergeOutput(Bundle b) {
+ if (b == null) return;
+ if (b.containsKey("rl") || b.containsKey("rr")) {
+ NativeBridge.setRumble(b.getInt("slot", 0), clamp(b.getInt("rl")), clamp(b.getInt("rr")));
+ }
+ // A client that sets the lightbar overrides the saved colour until the bridge screen changes it again.
+ if (b.containsKey("r") || b.containsKey("g") || b.containsKey("b")) {
+ int ps = Math.max(0, Math.min(Settings.PLAYERS - 1, b.getInt("slot", 0)));
+ if (b.containsKey("r")) Settings.lightR[ps] = clamp(b.getInt("r"));
+ if (b.containsKey("g")) Settings.lightG[ps] = clamp(b.getInt("g"));
+ if (b.containsKey("b")) Settings.lightB[ps] = clamp(b.getInt("b"));
+ NativeBridge.applyOutput(ps);
+ }
+ }
+
+ private static int clamp(int v) { return Math.max(0, Math.min(255, v)); }
+
+ // ---------- controller -> clients ----------
+
+ // A frame goes to DroidDeck only when the controller state changed (and for FRAME_SETTLE_MS after a change, so
+ // DroidDeck's stick throttle always gets the final position), plus a keep-alive every FRAME_KEEPALIVE_MS. The
+ // loop runs at up to 1000 Hz but most controllers report at 250-500 Hz, and an idle controller not at all:
+ // sending every pass only fills DroidDeck's queue with copies.
+ private static final long FRAME_SETTLE_MS = 20, FRAME_KEEPALIVE_MS = 100;
+ private final float[][] lastFrame = new float[NativeBridge.MAX_PADS][];
+ private final long[] lastFrameChangeMs = new long[NativeBridge.MAX_PADS], lastFrameSentMs = new long[NativeBridge.MAX_PADS];
+
+ private boolean frameWorthSending(float[] f, int slot) {
+ if (slot < 0 || slot >= NativeBridge.MAX_PADS) return true;
+ long now = SystemClock.uptimeMillis();
+ float[] last = lastFrame[slot];
+ if (last == null || last.length != f.length) last = lastFrame[slot] = new float[f.length];
+ if (!java.util.Arrays.equals(f, last)) {
+ System.arraycopy(f, 0, last, 0, f.length); // f is native's reused array: keep a copy
+ lastFrameChangeMs[slot] = now;
+ } else if (now - lastFrameChangeMs[slot] > FRAME_SETTLE_MS && now - lastFrameSentMs[slot] < FRAME_KEEPALIVE_MS) {
+ return false;
+ }
+ lastFrameSentMs[slot] = now;
+ return true;
+ }
+
+ @Override public void onFrame(float[] f, int slot) {
+ if (slot >= 0 && slot < NativeBridge.MAX_PADS && f.length >= NativeBridge.FRAME_LEN) {
+ lastBattery[slot] = (int) f[19];
+ calibrationSample(f, slot);
+ }
+ if (clients.isEmpty() || f.length < NativeBridge.FRAME_LEN) return;
+ if (!frameWorthSending(f, slot)) return;
+ Bundle b = new Bundle();
+ b.putByteArray("raw", new byte[0]);
+ b.putLong("t", SystemClock.elapsedRealtimeNanos());
+ int rawMask = (int) f[0];
+ int mask2 = f.length >= 22 ? (int) f[21] : 0; // extra buttons (MISC2..6, e.g. Flydigi C/Z)
+ int mask = Settings.applyGripRemap(rawMask, mask2, slot); // user button -> L4/R4/L5/R5
+ b.putInt("mask", mask);
+ b.putInt("lx", (int) f[1]); b.putInt("ly", (int) f[2]);
+ b.putInt("rx", (int) f[3]); b.putInt("ry", (int) f[4]);
+ b.putInt("l2", (int) f[5]); b.putInt("r2", (int) f[6]);
+ float gk = Settings.gyroGain[slot] / 100f;
+ int inv = Settings.gyroInvert[slot];
+ float dz = Settings.gyroDeadzone[slot];
+ float[] bias = Settings.gyroBias[slot];
+ int[] gmap = Settings.gyroMap[slot];
+ boolean accFollows = Settings.accelFollows[slot];
+ float[] gy = {f[7] * RAD_TO_DEG, f[8] * RAD_TO_DEG, f[9] * RAD_TO_DEG}; // deg/s
+ float[] ac = {f[10] / G, f[11] / G, f[12] / G}; // g
+ for (int k = 0; k < 3; k++) {
+ gy[k] -= bias[k]; // remove resting drift (from Calibrate)
+ if (Math.abs(gy[k]) < dz) gy[k] = 0f; // deadzone, in real deg/s (before gain)
+ }
+ float[] go = new float[3], ao = new float[3];
+ for (int i = 0; i < 3; i++) {
+ float sign = (inv & (1 << i)) != 0 ? -1f : 1f;
+ int src = gmap[i];
+ go[i] = gy[src] * gk * sign;
+ ao[i] = accFollows ? ac[src] * sign : ac[i];
+ }
+ // Sony family only (DualShock 4 = 3, DualSense = 4, DualSense Edge = 5): their report has ONE
+ // full-width multitouch pad and remaps the IMU axes (X, Z, -Y) vs the Deck IMU. The Steam Deck (0)
+ // and Steam Controller (1) have two trackpads and use the Deck IMU convention, so they are left as-is.
+ int presentType = Settings.PRESENT_PAD_TYPE[Settings.presentAs];
+ boolean sony = Settings.isSonyPadType(presentType);
+ if (sony) {
+ float tg = go[1]; go[1] = -go[2]; go[2] = tg;
+ float ta = ao[1]; ao[1] = -ao[2]; ao[2] = ta;
+ }
+ b.putFloatArray("gyro", go);
+ b.putFloatArray("accel", ao);
+
+
+ // Raw fingers (kept for older clients) ...
+ List t = new ArrayList<>();
+ for (int i = 0; i < 2; i++) {
+ if (f[13 + 3 * i] > 0.5f) { t.add(i + 1); t.add((int) f[14 + 3 * i]); t.add((int) f[15 + 3 * i]); }
+ }
+ int[] touch = new int[t.size()];
+ for (int i = 0; i < touch.length; i++) touch[i] = t.get(i);
+ b.putIntArray("touch", touch);
+
+ // ... and the decoupled version: each half is its own trackpad with local -1..1 coordinates.
+ float[] padL = new float[3], padR = new float[3];
+ int mode = Settings.touchMode[slot];
+ // A 2015 Steam Controller has two real trackpads: always split the touchpad into left/right halves.
+ if (presentType == Settings.PAD_TYPE_STEAM_2015 && mode != Settings.TOUCH_OFF) mode = Settings.TOUCH_SPLIT;
+ // A native Sony pad (DualShock 4, DualSense, DualSense Edge) has ONE full-width multitouch
+ // surface, so ignore the Deck split there: keep SDL's finger slots (finger 0 is the primary
+ // touch the game tracks) and map both across the whole pad. padL carries finger 0, padR finger 1.
+ // Only the Steam Deck pad (type 0) keeps the left/right split, since it has two real trackpads.
+ if (mode != Settings.TOUCH_OFF) {
+ if (sony) {
+ for (int i = 0; i < 2; i++) {
+ if (f[13 + 3 * i] <= 0.5f) continue;
+ float[] p = (i == 0) ? padL : padR;
+ p[0] = 1f; p[1] = f[14 + 3 * i] / PAD_W * 2f - 1f; p[2] = f[15 + 3 * i] / PAD_H * 2f - 1f;
+ }
+ } else for (int i = 0; i < 2; i++) {
+ if (f[13 + 3 * i] <= 0.5f) continue;
+ float x = f[14 + 3 * i], y = f[15 + 3 * i];
+ float ny = y / PAD_H * 2f - 1f;
+ if (mode == Settings.TOUCH_SINGLE) {
+ if (padR[0] == 0f) { padR[0] = 1f; padR[1] = x / PAD_W * 2f - 1f; padR[2] = ny; }
+ } else if (x < PAD_W / 2f) {
+ if (padL[0] == 0f) { padL[0] = 1f; padL[1] = x / (PAD_W / 2f) * 2f - 1f; padL[2] = ny; }
+ } else {
+ if (padR[0] == 0f) { padR[0] = 1f; padR[1] = (x - PAD_W / 2f) / (PAD_W / 2f) * 2f - 1f; padR[2] = ny; }
+ }
+ }
+ }
+ int click = -1; // -1 none, 0 left trackpad, 1 right trackpad
+ if (((mask >> 13) & 1) != 0 && mode != Settings.TOUCH_OFF) {
+ click = (mode == Settings.TOUCH_SPLIT && padL[0] > 0f && padR[0] == 0f) ? 0 : 1;
+ }
+ // Button -> trackpad touch / click remaps (targets 4..7)
+ int[] rm = Settings.remap[slot];
+ if (Settings.srcDown(rawMask, mask2, rm[6]) && padL[0] == 0f) { padL[0] = 1f; padL[1] = 0f; padL[2] = 0f; }
+ if (Settings.srcDown(rawMask, mask2, rm[7]) && padR[0] == 0f) { padR[0] = 1f; padR[1] = 0f; padR[2] = 0f; }
+ if (Settings.srcDown(rawMask, mask2, rm[4])) { if (padL[0] == 0f) padL[0] = 1f; click = 0; }
+ if (Settings.srcDown(rawMask, mask2, rm[5])) { if (padR[0] == 0f) padR[0] = 1f; click = 1; }
+ orient(padL, slot);
+ orient(padR, slot);
+ b.putFloatArray("padL", padL);
+ b.putFloatArray("padR", padR);
+ b.putInt("padClick", click);
+
+ int state = (int) f[20];
+ b.putInt("slot", slot);
+ b.putInt("ptype", Settings.PRESENT_PAD_TYPE[Settings.presentAs]);
+ b.putInt("vid", NativeBridge.vids[slot]);
+ b.putInt("pid", NativeBridge.pids[slot]);
+ b.putInt("battery", Math.max(0, (int) f[19]));
+ b.putString("charge", state == 3 ? "charging" : state == 4 ? "full" : state == 1 ? "discharging" : "unknown");
+ for (Messenger c : clients) {
+ try {
+ Message m = Message.obtain(null, MSG_FRAME);
+ m.setData(b);
+ c.send(m);
+ } catch (RemoteException e) { clients.remove(c); clientCount = clients.size(); }
+ }
+ }
+
+ @Override public void onPadGone(int slot) {
+ for (Messenger c : clients) {
+ try {
+ Bundle b = new Bundle();
+ b.putInt("slot", slot);
+ Message m = Message.obtain(null, MSG_PAD_GONE);
+ m.setData(b);
+ c.send(m);
+ } catch (RemoteException e) { clients.remove(c); }
+ }
+ }
+
+ // ---------- Steam Controller (2026) raw passthrough -> clients ----------
+ @Override public void onTritonState(int reportId, byte[] payload) {
+ if (clients.isEmpty() || payload == null) return;
+ Bundle b = new Bundle();
+ b.putInt("id", reportId);
+ b.putByteArray("data", payload);
+ sendAll(MSG_TRITON_STATE, b);
+ }
+
+ @Override public void onTritonConnected(boolean on, String what) {
+ for (Messenger c : clients) sendTritonConn(c, on, what);
+ }
+
+ @Override public void onTritonFeature(long request, byte[] report) {
+ Bundle b = new Bundle();
+ b.putLong("request", request);
+ if (report != null) b.putByteArray("data", report);
+ sendAll(MSG_TRITON_FEATURE, b);
+ }
+
+ private void sendTritonConn(Messenger c, boolean on, String what) {
+ try {
+ Bundle b = new Bundle();
+ b.putBoolean("on", on);
+ b.putString("what", what);
+ Message m = Message.obtain(null, MSG_TRITON_CONN);
+ m.setData(b);
+ c.send(m);
+ } catch (RemoteException e) { clients.remove(c); clientCount = clients.size(); }
+ }
+
+ /** SDL3's loop must run for controllers to work: it runs in this service, no screen needed. */
+ private void ensureBridgeLoop() {
+ SdlEngine.start(this);
+ }
+
+ // ---------- Steam Native Hook-up -> clients ----------
+ @Override public void onHookupAdded(int slot, Bundle identity) { sendAll(MSG_HOOKUP_ADD, identity); }
+
+ @Override public void onHookupInput(int slot, byte[] report) {
+ if (clients.isEmpty() || report == null) return;
+ Bundle b = new Bundle();
+ b.putInt("slot", slot);
+ b.putByteArray("data", report);
+ sendAll(MSG_HOOKUP_INPUT, b);
+ }
+
+ @Override public void onBridgeLog(String line) {
+ Bundle b = new Bundle();
+ b.putString("text", line);
+ sendAll(MSG_BRIDGE_LOG, b);
+ }
+
+ @Override public void onHookupRemoved(int slot) {
+ Bundle b = new Bundle();
+ b.putInt("slot", slot);
+ sendAll(MSG_HOOKUP_REMOVE, b);
+ }
+
+ @Override public void onHookupReply(int slot, long request, byte[] report) {
+ Bundle b = new Bundle();
+ b.putInt("slot", slot);
+ b.putLong("request", request);
+ if (report != null) b.putByteArray("data", report);
+ sendAll(MSG_HOOKUP_REPLY, b);
+ }
+
+ // ---------- DroidDeck's SDL3Bridge settings tab ----------
+ private final int[] lastBattery = { -1, -1, -1, -1 };
+
+ // Gyro calibration started from DroidDeck: averages the resting gyro of that player's own controller.
+ private static final int CALIB_SAMPLES = 180; // ~0.7 s at 4 ms per frame
+ private final Object calibLock = new Object();
+ private int calibPlayer = -1, calibCount;
+ private final float[] calibSum = new float[3];
+
+ private void startCalibration(int p) {
+ synchronized (calibLock) { calibPlayer = p; calibCount = 0; calibSum[0] = calibSum[1] = calibSum[2] = 0f; }
+ }
+
+ private void calibrationSample(float[] f, int slot) {
+ synchronized (calibLock) {
+ if (calibPlayer != slot) return;
+ calibSum[0] += f[7]; calibSum[1] += f[8]; calibSum[2] += f[9];
+ if (++calibCount < CALIB_SAMPLES) return;
+ float[] b = Settings.gyroBias[slot];
+ for (int k = 0; k < 3; k++) b[k] = calibSum[k] / CALIB_SAMPLES * RAD_TO_DEG;
+ calibPlayer = -1;
+ }
+ Settings.save();
+ }
+
+ /** Everything the tab shows: settings, their choices (so DroidDeck needs no copy of them) and live status. */
+ private Bundle settingsSnapshot() {
+ Bundle b = new Bundle();
+ b.putInt("v", 1);
+ b.putString("status", NativeBridge.lastStatus);
+ b.putBoolean("overlayAllowed", android.provider.Settings.canDrawOverlays(this));
+ b.putBoolean("notifications", getSystemService(NotificationManager.class).areNotificationsEnabled());
+ b.putString("engineError", SdlEngine.error == null ? "" : SdlEngine.error);
+ b.putString("engineMode", SdlEngine.mode);
+ b.putBoolean("fastPath", NativeBridge.hookupShmInode() != 0);
+ b.putString("triton", NativeBridge.tritonConnected ? NativeBridge.tritonWhat : "");
+ String[] padNames = new String[NativeBridge.MAX_PADS], padIds = new String[NativeBridge.MAX_PADS];
+ String[] hookNames = new String[NativeBridge.MAX_PADS], hookIds = new String[NativeBridge.MAX_PADS];
+ for (int i = 0; i < NativeBridge.MAX_PADS; i++) {
+ String n = NativeBridge.names[i];
+ padNames[i] = n == null ? "" : n;
+ padIds[i] = n == null ? "" : String.format("%04x:%04x", NativeBridge.vids[i], NativeBridge.pids[i]);
+ Bundle h = NativeBridge.hookupIdentity[i];
+ hookNames[i] = h == null ? "" : h.getString("name", "");
+ hookIds[i] = h == null ? "" : String.format("%04x:%04x", h.getInt("vid"), h.getInt("pid"));
+ }
+ b.putStringArray("padNames", padNames);
+ b.putStringArray("padIds", padIds);
+ b.putIntArray("padBattery", lastBattery.clone());
+ b.putStringArray("hookNames", hookNames);
+ b.putStringArray("hookIds", hookIds);
+ synchronized (calibLock) { b.putInt("calibrating", calibPlayer); }
+ // global settings
+ b.putInt("presentAs", Settings.presentAs);
+ b.putBoolean("driverEnabled", Settings.driverEnabled);
+ b.putBoolean("forceUsb", Settings.forceUsb);
+ b.putInt("pollMs", Settings.pollMs);
+ b.putBoolean("bubble", Settings.bubble);
+ // choices
+ b.putStringArray("presentNames", Settings.PRESENT_NAMES);
+ b.putStringArray("rateNames", Settings.RATE_NAMES);
+ b.putIntArray("rateMs", Settings.RATE_MS);
+ b.putStringArray("touchNames", Settings.TOUCH_NAMES);
+ b.putStringArray("srcNames", Settings.SRC_NAMES);
+ b.putStringArray("remapTargets", Settings.REMAP_TARGETS);
+ b.putStringArray("axisNames", Settings.AXIS_NAMES);
+ b.putStringArray("gyroInvNames", Settings.GYRO_INV_NAMES);
+ b.putStringArray("playerLedNames", Settings.PLAYER_NAMES);
+ // per player (index p, or p * n + i for the ones with several entries)
+ int P = Settings.PLAYERS;
+ int[] gyroMap = new int[P * 3], remap = new int[P * Settings.REMAP_COUNT];
+ float[] bias = new float[P * 3];
+ for (int p = 0; p < P; p++) {
+ for (int i = 0; i < 3; i++) { gyroMap[p * 3 + i] = Settings.gyroMap[p][i]; bias[p * 3 + i] = Settings.gyroBias[p][i]; }
+ for (int i = 0; i < Settings.REMAP_COUNT; i++) remap[p * Settings.REMAP_COUNT + i] = Settings.remap[p][i];
+ }
+ b.putIntArray("touchMode", Settings.touchMode.clone());
+ b.putBooleanArray("touchInvX", Settings.touchInvX.clone());
+ b.putBooleanArray("touchInvY", Settings.touchInvY.clone());
+ b.putBooleanArray("touchSwap", Settings.touchSwap.clone());
+ b.putIntArray("gyroGain", Settings.gyroGain.clone());
+ b.putIntArray("gyroInvert", Settings.gyroInvert.clone());
+ b.putIntArray("gyroDeadzone", Settings.gyroDeadzone.clone());
+ b.putIntArray("gyroMap", gyroMap);
+ b.putFloatArray("gyroBias", bias);
+ b.putBooleanArray("accelFollows", Settings.accelFollows.clone());
+ b.putIntArray("remap", remap);
+ b.putIntArray("lightR", Settings.lightR.clone());
+ b.putIntArray("lightG", Settings.lightG.clone());
+ b.putIntArray("lightB", Settings.lightB.clone());
+ b.putIntArray("brightness", Settings.brightness.clone());
+ b.putIntArray("playerLed", Settings.playerLed.clone());
+ return b;
+ }
+
+ private static int clampInt(int v, int lo, int hi) { return Math.max(lo, Math.min(hi, v)); }
+
+ /** One change from DroidDeck's tab, with the same effect as the same change on this app's own screen. */
+ private void applyRemote(Bundle d) {
+ if (d == null) return;
+ String key = d.getString("key", "");
+ int p = clampInt(d.getInt("p", 0), 0, Settings.PLAYERS - 1);
+ int i = d.getInt("i", 0);
+ int v = d.getInt("int", 0);
+ boolean on = d.getBoolean("bool", false);
+ switch (key) {
+ case "presentAs":
+ Settings.presentAs = clampInt(v, 0, Settings.PRESENT_NAMES.length - 1);
+ if (!Settings.isHookup()) Settings.lastPresentAs = Settings.presentAs;
+ Settings.save();
+ NativeBridge.applyHookup();
+ updateBubble();
+ break;
+ case "driverEnabled":
+ if (Settings.driverEnabled != on) toggleDriver();
+ break;
+ case "forceUsb":
+ Settings.forceUsb = on;
+ Settings.save();
+ NativeBridge.applyForce();
+ break;
+ case "pollMs":
+ Settings.pollMs = clampInt(v, 1, 16);
+ Settings.save();
+ NativeBridge.applyRate();
+ break;
+ case "bubble":
+ if (on && !android.provider.Settings.canDrawOverlays(this)) break; // needs the permission first
+ Settings.bubble = on;
+ Settings.save();
+ if (on) showBubble(); else hideBubble();
+ break;
+ case "touchMode": Settings.touchMode[p] = clampInt(v, 0, 2); Settings.save(); break;
+ case "touchInvX": Settings.touchInvX[p] = on; Settings.save(); break;
+ case "touchInvY": Settings.touchInvY[p] = on; Settings.save(); break;
+ case "touchSwap": Settings.touchSwap[p] = on; Settings.save(); break;
+ case "gyroGain": Settings.gyroGain[p] = clampInt(v, 1, 800); Settings.save(); break;
+ case "gyroInvert": Settings.gyroInvert[p] = clampInt(v, 0, 7); Settings.save(); break;
+ case "gyroDeadzone": Settings.gyroDeadzone[p] = clampInt(v, 0, 50); Settings.save(); break;
+ case "gyroMap":
+ if (i >= 0 && i < 3) { Settings.gyroMap[p][i] = clampInt(v, 0, 2); Settings.save(); }
+ break;
+ case "accelFollows": Settings.accelFollows[p] = on; Settings.save(); break;
+ case "remap":
+ if (i >= 0 && i < Settings.REMAP_COUNT) { Settings.remap[p][i] = clampInt(v, 0, Settings.SRC_NAMES.length - 1); Settings.save(); }
+ break;
+ case "lightR": Settings.lightR[p] = clampInt(v, 0, 255); Settings.save(); NativeBridge.applyOutput(p); break;
+ case "lightG": Settings.lightG[p] = clampInt(v, 0, 255); Settings.save(); NativeBridge.applyOutput(p); break;
+ case "lightB": Settings.lightB[p] = clampInt(v, 0, 255); Settings.save(); NativeBridge.applyOutput(p); break;
+ case "brightness": Settings.brightness[p] = clampInt(v, 0, 100); Settings.save(); NativeBridge.applyOutput(p); break;
+ case "playerLed": Settings.playerLed[p] = clampInt(v, 0, 4); Settings.save(); NativeBridge.applyOutput(p); break;
+ case "calibrate": startCalibration(p); break;
+ case "resetCalibration":
+ synchronized (calibLock) { if (calibPlayer == p) calibPlayer = -1; }
+ Settings.gyroBias[p][0] = Settings.gyroBias[p][1] = Settings.gyroBias[p][2] = 0f;
+ Settings.save();
+ break;
+ default:
+ android.util.Log.w("SDL3Bridge", "settings: unknown key from DroidDeck: " + key);
+ }
+ }
+
+ private void sendOne(Messenger c, int what, Bundle b) {
+ try {
+ Message m = Message.obtain(null, what);
+ m.setData(b);
+ c.send(m);
+ } catch (RemoteException e) { clients.remove(c); clientCount = clients.size(); }
+ }
+
+ private void sendAll(int what, Bundle b) {
+ for (Messenger c : clients) {
+ try {
+ Message m = Message.obtain(null, what);
+ m.setData(b);
+ c.send(m);
+ } catch (RemoteException e) { clients.remove(c); clientCount = clients.size(); }
+ }
+ }
+
+ @Override public void onStatus(String s) {
+ connectedState = s != null && s.startsWith("Connected");
+ updateBubble();
+ main.post(() -> refreshNotification(s));
+ for (Messenger c : clients) sendStatus(c, s);
+ }
+
+ private void sendStatus(Messenger c, String s) {
+ try {
+ Bundle b = new Bundle();
+ b.putString("text", s);
+ Message m = Message.obtain(null, MSG_STATUS);
+ m.setData(b);
+ c.send(m);
+ } catch (RemoteException e) { clients.remove(c); }
+ }
+}
diff --git a/app/src/main/java/com/example/dsedge/ControllerView.java b/app/src/main/java/com/example/dsedge/ControllerView.java
new file mode 100644
index 000000000..69b4328f6
--- /dev/null
+++ b/app/src/main/java/com/example/dsedge/ControllerView.java
@@ -0,0 +1,329 @@
+package com.example.dsedge;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.RectF;
+import android.view.View;
+
+/**
+ * Universal live gamepad diagram for any SDL3 controller (DualSense / Edge, Xbox, Switch Pro,
+ * Steam Controller 2015 & 2026, generic pads). It draws only the controls the controller actually
+ * reports (via NativeBridge caps) and labels the face / menu buttons for the controller kind.
+ *
+ * Orange = held now, green = pressed at least once (works), grey = present but untested.
+ */
+public class ControllerView extends View {
+ private static final int ORANGE = 0xFFFF9800, GREEN = 0xFF2E7D32, GRAY = 0xFF37474F, LINE = 0xFF90A4AE;
+
+ // frame-mask bit numbers
+ private static final int B_SOUTH = 0, B_EAST = 1, B_WEST = 2, B_NORTH = 3, B_L1 = 4, B_R1 = 5,
+ B_L2 = 6, B_R2 = 7, B_BACK = 8, B_START = 9, B_L3 = 10, B_R3 = 11, B_GUIDE = 12,
+ B_TOUCH = 13, B_MISC = 14, B_UP = 15, B_DOWN = 16, B_LEFT = 17, B_RIGHT = 18,
+ B_P1 = 19, B_P2 = 20, B_P3 = 21, B_P4 = 22;
+
+ // tested-flag bits (not button bits)
+ private static final int T_LS = 1, T_RS = 2, T_L2 = 4, T_R2 = 8, T_GYRO = 16, T_ACCEL = 32, T_TOUCH = 64;
+
+ private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint stroke = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final RectF rect = new RectF();
+
+ private volatile float[] frame = new float[NativeBridge.FRAME_LEN];
+ private volatile int mask;
+ private volatile int testedButtons;
+ private volatile int testedFlags;
+ private volatile boolean connected;
+ private long lastInvalidate;
+
+ // capabilities (default: assume everything until the controller reports otherwise)
+ private volatile int present = (1 << 23) - 1;
+ private volatile int axes = 0xF;
+ private volatile int touchpads = 1;
+ private volatile String kind = "generic";
+
+ public ControllerView(Context c) {
+ super(c);
+ setBackgroundColor(0xFF111111);
+ stroke.setStyle(Paint.Style.STROKE);
+ stroke.setStrokeWidth(0.5f);
+ text.setTextAlign(Paint.Align.CENTER);
+ text.setColor(0xFFECEFF1);
+ frame[1] = frame[2] = frame[3] = frame[4] = 128f;
+ }
+
+ /** Capabilities of the displayed controller, from the native layer. */
+ public void setCaps(int present, int axes, int touchpads, String kind) {
+ this.present = present;
+ this.axes = axes;
+ this.touchpads = touchpads;
+ this.kind = kind == null ? "generic" : kind;
+ postInvalidate();
+ }
+
+ /** Called from any thread with each native frame. */
+ public void update(float[] f) {
+ if (f == null || f.length < NativeBridge.FRAME_LEN) return;
+ float[] c = f.clone();
+ frame = c;
+ connected = true;
+ int m = (int) c[0];
+ mask = m;
+ testedButtons |= m;
+ int t = testedFlags;
+ if (Math.abs(c[1] - 128f) > 60f || Math.abs(c[2] - 128f) > 60f) t |= T_LS;
+ if (Math.abs(c[3] - 128f) > 60f || Math.abs(c[4] - 128f) > 60f) t |= T_RS;
+ if (c[5] > 100f) t |= T_L2;
+ if (c[6] > 100f) t |= T_R2;
+ if (Math.abs(c[7]) + Math.abs(c[8]) + Math.abs(c[9]) > 0.6f) t |= T_GYRO;
+ if (Math.abs(c[10]) + Math.abs(c[11]) + Math.abs(c[12]) > 3f) t |= T_ACCEL;
+ if (c[13] > 0.5f || c[16] > 0.5f) t |= T_TOUCH;
+ testedFlags = t;
+ long now = System.nanoTime();
+ if (now - lastInvalidate > 16_000_000L) {
+ lastInvalidate = now;
+ postInvalidate();
+ }
+ }
+
+ public void setDisconnected() { connected = false; mask = 0; postInvalidate(); }
+
+ public void resetTested() { testedButtons = 0; testedFlags = 0; postInvalidate(); }
+
+ /** One-line summary of what has been verified so far (only counts controls the pad has). */
+ public String summary() {
+ int p = present & ((1 << 23) - 1);
+ int n = Integer.bitCount(p);
+ int done = Integer.bitCount(testedButtons & p);
+ int t = testedFlags;
+ StringBuilder sb = new StringBuilder("Tested: buttons ").append(done).append('/').append(n);
+ if ((axes & 1) != 0 || (axes & 2) != 0) sb.append(" sticks ").append(mark(stickOk(t)));
+ if ((axes & 12) != 0) sb.append(" triggers ").append(mark(trigOk(t)));
+ sb.append(" gyro ").append(mark((t & T_GYRO) != 0));
+ sb.append(" accel ").append(mark((t & T_ACCEL) != 0));
+ if (touchpads > 0) sb.append(" touchpad ").append(mark((t & T_TOUCH) != 0));
+ return sb.toString();
+ }
+
+ private boolean stickOk(int t) {
+ boolean l = (axes & 1) == 0 || (t & T_LS) != 0;
+ boolean r = (axes & 2) == 0 || (t & T_RS) != 0;
+ return l && r;
+ }
+
+ private boolean trigOk(int t) {
+ boolean l = (axes & 4) == 0 || (t & T_L2) != 0;
+ boolean r = (axes & 8) == 0 || (t & T_R2) != 0;
+ return l && r;
+ }
+
+ private static String mark(boolean ok) { return ok ? "OK" : "-"; }
+
+ @Override protected void onMeasure(int w, int h) {
+ int width = MeasureSpec.getSize(w);
+ setMeasuredDimension(width, (int) (width * 0.82f));
+ }
+
+ // ---------------- labels per controller kind ----------------
+
+ private String[] faceLabels() {
+ switch (kind) {
+ case "ps": return new String[] {"✕", "●", "■", "▲"}; // ✕ ● ■ ▲
+ case "nintendo": return new String[] {"B", "A", "Y", "X"};
+ default: return new String[] {"A", "B", "X", "Y"}; // xbox / steam / generic
+ }
+ }
+
+ private String backLabel() { switch (kind) { case "ps": return "Create"; case "xbox": return "View"; case "nintendo": return "-"; case "steam": return "Back"; default: return "Back"; } }
+ private String startLabel() { switch (kind) { case "ps": return "Options"; case "xbox": return "Menu"; case "nintendo": return "+"; case "steam": return "Start"; default: return "Start"; } }
+ private String guideLabel() { switch (kind) { case "ps": return "PS"; case "nintendo": return "Home"; case "steam": return "Steam"; default: return "Guide"; } }
+ private String miscLabel() { switch (kind) { case "ps": return "Mute"; case "xbox": return "Share"; case "nintendo": return "Capt"; default: return "Misc"; } }
+
+ // ---------------- drawing (100 x 72 unit canvas) ----------------
+
+ private boolean has(int bit) { return ((present >> bit) & 1) != 0; }
+
+ private int col(int bit) {
+ if (((mask >> bit) & 1) != 0) return ORANGE;
+ if (((testedButtons >> bit) & 1) != 0) return GREEN;
+ return GRAY;
+ }
+
+ private void label(Canvas c, String s, float x, float y, float size) {
+ text.setTextSize(size);
+ c.drawText(s, x, y + size * 0.35f, text);
+ }
+
+ /** Rounded rectangle button, drawn only if present. */
+ private void rbtn(Canvas c, float l, float t, float r, float b, int bit, String s) {
+ if (!has(bit)) return;
+ rect.set(l, t, r, b);
+ fill.setColor(col(bit));
+ c.drawRoundRect(rect, 1f, 1f, fill);
+ stroke.setColor(LINE);
+ c.drawRoundRect(rect, 1f, 1f, stroke);
+ if (s != null && !s.isEmpty()) label(c, s, (l + r) / 2f, (t + b) / 2f, 2.2f);
+ }
+
+ private void cbtn(Canvas c, float cx, float cy, float rad, int bit, String s) {
+ if (!has(bit)) return;
+ fill.setColor(col(bit));
+ c.drawCircle(cx, cy, rad, fill);
+ stroke.setColor(LINE);
+ c.drawCircle(cx, cy, rad, stroke);
+ label(c, s, cx, cy, 2.4f);
+ }
+
+ private void meter(Canvas c, float l, float t, float r, float b, float value01, boolean tested) {
+ rect.set(l, t, r, b);
+ fill.setColor(0xFF263238);
+ c.drawRect(rect, fill);
+ rect.set(l, t, l + (r - l) * Math.max(0f, Math.min(1f, value01)), b);
+ fill.setColor(value01 > 0.02f ? ORANGE : (tested ? GREEN : GRAY));
+ c.drawRect(rect, fill);
+ rect.set(l, t, r, b);
+ stroke.setColor(LINE);
+ c.drawRect(rect, stroke);
+ }
+
+ private void stick(Canvas c, float cx, float cy, float x, float y, int clickBit, boolean tested) {
+ boolean clickable = has(clickBit);
+ boolean down = clickable && ((mask >> clickBit) & 1) != 0;
+ stroke.setColor(down ? ORANGE : (tested ? 0xFF66BB6A : LINE));
+ stroke.setStrokeWidth(1f);
+ c.drawCircle(cx, cy, 9f, stroke);
+ stroke.setStrokeWidth(0.5f);
+ fill.setColor(down ? ORANGE : (tested ? GREEN : GRAY));
+ float dx = (x - 128f) / 128f * 6f, dy = (y - 128f) / 128f * 6f;
+ c.drawCircle(cx + dx, cy + dy, 3.6f, fill);
+ }
+
+ @Override protected void onDraw(Canvas c) {
+ super.onDraw(c);
+ float u = getWidth() / 100f;
+ c.save();
+ c.scale(u, u);
+ float[] f = frame;
+ int t = testedFlags;
+ String[] fl = faceLabels();
+
+ // shoulders + triggers
+ if ((axes & 4) != 0) meter(c, 6, 1, 30, 4, f[5] / 255f, (t & T_L2) != 0); else rbtn(c, 6, 1, 30, 4, B_L2, "L2");
+ if ((axes & 8) != 0) meter(c, 70, 1, 94, 4, f[6] / 255f, (t & T_R2) != 0); else rbtn(c, 70, 1, 94, 4, B_R2, "R2");
+ rbtn(c, 6, 5.5f, 30, 10, B_L1, "L1");
+ rbtn(c, 70, 5.5f, 94, 10, B_R1, "R1");
+
+ drawTouchpads(c);
+
+ rbtn(c, 27, 14, 36, 18, B_BACK, backLabel());
+ rbtn(c, 64, 14, 73, 18, B_START, startLabel());
+ rbtn(c, 45, 36, 55, 39.5f, B_MISC, miscLabel());
+ cbtn(c, 50, 44, 2.8f, B_GUIDE, guideLabel());
+
+ // d-pad
+ if (has(B_UP) || has(B_DOWN) || has(B_LEFT) || has(B_RIGHT)) {
+ rbtn(c, 18, 20, 22, 24, B_UP, "");
+ rbtn(c, 18, 32, 22, 36, B_DOWN, "");
+ rbtn(c, 14, 26, 18, 30, B_LEFT, "");
+ rbtn(c, 22, 26, 26, 30, B_RIGHT, "");
+ label(c, "D-pad", 20, 40, 2.2f);
+ }
+
+ // face buttons (diamond): N top, S bottom, W left, E right
+ cbtn(c, 80, 21, 3.2f, B_NORTH, fl[3]);
+ cbtn(c, 80, 35, 3.2f, B_SOUTH, fl[0]);
+ cbtn(c, 73, 28, 3.2f, B_WEST, fl[2]);
+ cbtn(c, 87, 28, 3.2f, B_EAST, fl[1]);
+
+ // sticks
+ if ((axes & 1) != 0) { stick(c, 34, 53, f[1], f[2], B_L3, (t & T_LS) != 0); label(c, has(B_L3) ? "L3" : "L", 34, 64.5f, 2.2f); }
+ if ((axes & 2) != 0) { stick(c, 66, 53, f[3], f[4], B_R3, (t & T_RS) != 0); label(c, has(B_R3) ? "R3" : "R", 66, 64.5f, 2.2f); }
+
+ // paddles / grips
+ rbtn(c, 1.5f, 40, 6.5f, 56, B_P1, "L4");
+ rbtn(c, 93.5f, 40, 98.5f, 56, B_P2, "R4");
+ rbtn(c, 9, 60, 21, 64.5f, B_P3, "L5");
+ rbtn(c, 79, 60, 91, 64.5f, B_P4, "R5");
+
+ // ---- bottom readout strip (own background + divider, clear of the diagram) ----
+ rect.set(0, 66.5f, 100, getHeight() / u);
+ fill.setColor(0xFF0B0E13);
+ c.drawRect(rect, fill);
+ stroke.setColor(0xFF2A3340);
+ c.drawLine(3, 66.5f, 97, 66.5f, stroke);
+
+ String s1 = connected
+ ? String.format(java.util.Locale.ROOT, "%s · Gyro %.0f, %.0f, %.0f °/s",
+ kindName(), f[7] * 57.29578f, f[8] * 57.29578f, f[9] * 57.29578f)
+ : "No controller connected";
+ String s2 = connected
+ ? String.format(java.util.Locale.ROOT, "Accel %.2f, %.2f, %.2f g · Battery %d%%",
+ f[10] / 9.80665f, f[11] / 9.80665f, f[12] / 9.80665f, (int) Math.max(0f, f[19]))
+ : "";
+ label(c, s1, 50, 71.3f, 2.6f);
+ if (!s2.isEmpty()) label(c, s2, 50, 76.3f, 2.6f);
+
+ c.restore();
+ }
+
+ private String kindName() {
+ switch (kind) {
+ case "ps": return "PlayStation";
+ case "xbox": return "Xbox";
+ case "nintendo": return "Nintendo";
+ case "steam": return "Steam Controller";
+ default: return "Gamepad";
+ }
+ }
+
+
+ /** Draws 0, 1 or 2 trackpads with their finger dots. */
+ private void drawTouchpads(Canvas c) {
+ float[] f = frame;
+ if (touchpads <= 0) {
+ label(c, "no touchpad", 50, 24, 2.2f);
+ return;
+ }
+ if (touchpads >= 2) {
+ padRect(c, 30, 12, 48, 34, "L pad", B_TOUCH);
+ padRect(c, 52, 12, 70, 34, "R pad", B_TOUCH);
+ fingerOnRect(c, 30, 48, 12, 34, 0, 0xFFFFFFFF); // finger slot 0 -> left pad
+ fingerOnRect(c, 52, 70, 12, 34, 1, 0xFF4FC3F7); // finger slot 1 -> right pad
+ return;
+ }
+ // single touchpad
+ padRect(c, 36, 12, 64, 34, null, B_TOUCH);
+ if (Settings.touchMode[0] == Settings.TOUCH_SPLIT) {
+ stroke.setColor(LINE);
+ c.drawLine(50, 12, 50, 34, stroke);
+ label(c, "L pad", 43, 32.5f, 2f);
+ label(c, "R pad", 57, 32.5f, 2f);
+ } else if (Settings.touchMode[0] == Settings.TOUCH_OFF) {
+ label(c, "touchpad off", 50, 32.5f, 2f);
+ } else {
+ label(c, "whole pad", 50, 32.5f, 2f);
+ }
+ fingerOnRect(c, 36, 64, 12, 34, 0, 0xFFFFFFFF);
+ fingerOnRect(c, 36, 64, 12, 34, 1, 0xFF4FC3F7);
+ }
+
+ private void padRect(Canvas c, float l, float t, float r, float b, String lbl, int bit) {
+ rect.set(l, t, r, b);
+ fill.setColor(((mask >> bit) & 1) != 0 ? ORANGE : (((testedButtons >> bit) & 1) != 0 ? GREEN : 0xFF263238));
+ c.drawRoundRect(rect, 2f, 2f, fill);
+ stroke.setColor(LINE);
+ c.drawRoundRect(rect, 2f, 2f, stroke);
+ if (lbl != null) label(c, lbl, (l + r) / 2f, b - 1.5f, 2f);
+ }
+
+ private void fingerOnRect(Canvas c, float l, float r, float t, float b, int fingerSlot, int color) {
+ float[] f = frame;
+ if (f[13 + 3 * fingerSlot] <= 0.5f) return;
+ float nx = f[14 + 3 * fingerSlot] / 1919f, ny = f[15 + 3 * fingerSlot] / 1079f;
+ if (Settings.touchInvX[0]) nx = 1f - nx;
+ if (Settings.touchInvY[0]) ny = 1f - ny;
+ fill.setColor(color);
+ c.drawCircle(l + nx * (r - l), t + ny * (b - t), 1.8f, fill);
+ }
+}
diff --git a/app/src/main/java/com/example/dsedge/HookupIdentity.java b/app/src/main/java/com/example/dsedge/HookupIdentity.java
new file mode 100644
index 000000000..87aaa9b39
--- /dev/null
+++ b/app/src/main/java/com/example/dsedge/HookupIdentity.java
@@ -0,0 +1,186 @@
+package com.example.dsedge;
+
+import android.content.Context;
+import android.hardware.usb.UsbConstants;
+import android.hardware.usb.UsbDevice;
+import android.hardware.usb.UsbDeviceConnection;
+import android.hardware.usb.UsbInterface;
+import android.hardware.usb.UsbManager;
+import android.os.Bundle;
+import android.util.Log;
+
+/**
+ * Steam Native Hook-up: everything Steam on Linux reads about a USB HID controller before it picks a driver --
+ * vendor/product id, release number, name, serial, interface and the HID report descriptor -- taken from the
+ * real USB device, so DroidDeck can describe a hidraw node that is indistinguishable from it.
+ */
+final class HookupIdentity {
+ private static final String TAG = "SDL3Bridge";
+
+ // Generic Desktop / Gamepad collection with 64-byte vendor in/out reports. Used for controllers that have
+ // no HID descriptor of their own (Xbox controllers speak GIP on a vendor-class interface).
+ private static final byte[] VENDOR_GAMEPAD_DESCRIPTOR = bytes(
+ 0x05, 0x01, 0x09, 0x05, 0xa1, 0x01, 0x06, 0x00, 0xff, 0x09, 0x01, 0x15, 0x00, 0x26, 0xff, 0x00,
+ 0x75, 0x08, 0x95, 0x40, 0x81, 0x02, 0x09, 0x02, 0x95, 0x40, 0x91, 0x02, 0xc0);
+
+ private HookupIdentity() {}
+
+ /** Report descriptors read before the raw open, by SDL hid path. */
+ private static final java.util.Map prefetched = new java.util.concurrent.ConcurrentHashMap<>();
+ /** Report descriptors already read once, by "vid:pid:interface" -- reused when the same controller is handed
+ * over again (hook-up off/on, replug), so the USB device is not asked again while a connection holds it. */
+ private static final java.util.Map known = new java.util.concurrent.ConcurrentHashMap<>();
+ /** How the last build() got its descriptor ("prefetched", "remembered", "read", "fallback"). */
+ static volatile String lastSource = "";
+
+ private static String key(String path) {
+ String[] p = path.split("/");
+ int n = p.length;
+ return n >= 3 ? p[n - 3] + ":" + p[n - 2] + ":" + p[n - 1] : path;
+ }
+
+ static void prefetch(Context context, String path) {
+ if (known.containsKey(key(path))) return; // nothing to read: it is remembered
+ try {
+ UsbManager um = (UsbManager) context.getSystemService(Context.USB_SERVICE);
+ UsbDevice dev = um == null ? null : um.getDeviceList().get(deviceName(path));
+ int index = ifaceIndex(path);
+ if (dev == null || index < 0 || index >= dev.getInterfaceCount()) return;
+ UsbInterface f = dev.getInterface(index);
+ if (f.getInterfaceClass() != UsbConstants.USB_CLASS_HID) return;
+ byte[] d = readWithTimeout(um, dev, f);
+ if (d != null) { prefetched.put(path, d); known.put(key(path), d); }
+ Log.i(TAG, "hookup: report descriptor of " + path + ": " + (d == null ? "unreadable" : d.length + " bytes"));
+ } catch (RuntimeException e) {
+ Log.w(TAG, "hookup: descriptor prefetch failed: " + e);
+ }
+ }
+
+ private static String deviceName(String path) {
+ String n = path;
+ for (int i = 0; i < 3 && n.lastIndexOf('/') > 0; i++) n = n.substring(0, n.lastIndexOf('/'));
+ return n;
+ }
+
+ private static int ifaceIndex(String path) {
+ try { return Integer.parseInt(path.substring(path.lastIndexOf('/') + 1)); } catch (RuntimeException e) { return -1; }
+ }
+
+ private static byte[] bytes(int... v) {
+ byte[] b = new byte[v.length];
+ for (int i = 0; i < v.length; i++) b[i] = (byte) v[i];
+ return b;
+ }
+
+ /** SDL's Android HID path is "///". */
+ static Bundle build(Context context, int slot, String path, int vid, int pid, int ifaceIndex) {
+ Bundle b = new Bundle();
+ b.putInt("slot", slot);
+ b.putInt("vid", vid);
+ b.putInt("pid", pid);
+ b.putInt("bus", 3);
+ String name = String.format("Controller %04x:%04x", vid, pid);
+ String manufacturer = "", product = "", serial = "";
+ int ifaceNumber = ifaceIndex, ifaceClass = UsbConstants.USB_CLASS_HID, ifaceSub = 0, ifaceProto = 0, version = 0x0100;
+ byte[] descriptor = null;
+ try {
+ UsbManager um = (UsbManager) context.getSystemService(Context.USB_SERVICE);
+ UsbDevice dev = um == null ? null : um.getDeviceList().get(deviceName(path));
+ if (dev != null) {
+ if (ifaceIndex >= 0 && ifaceIndex < dev.getInterfaceCount()) {
+ UsbInterface f = dev.getInterface(ifaceIndex);
+ ifaceNumber = f.getId();
+ ifaceClass = f.getInterfaceClass();
+ ifaceSub = f.getInterfaceSubclass();
+ ifaceProto = f.getInterfaceProtocol();
+ }
+ try { manufacturer = nz(dev.getManufacturerName()); } catch (SecurityException ignored) { }
+ try { product = nz(dev.getProductName()); } catch (SecurityException ignored) { }
+ try { serial = nz(dev.getSerialNumber()); } catch (SecurityException ignored) { }
+ version = parseBcd(dev.getVersion());
+ descriptor = prefetched.remove(path);
+ if (descriptor != null) lastSource = "prefetched";
+ if (descriptor == null && (descriptor = known.get(key(path))) != null) lastSource = "remembered";
+ if (descriptor == null && ifaceClass == UsbConstants.USB_CLASS_HID && ifaceIndex >= 0 && ifaceIndex < dev.getInterfaceCount()) {
+ descriptor = readWithTimeout(um, dev, dev.getInterface(ifaceIndex));
+ if (descriptor != null) { known.put(key(path), descriptor); lastSource = "read"; }
+ }
+ String joined = (manufacturer.isEmpty() || product.startsWith(manufacturer)) ? product : manufacturer + " " + product;
+ if (!joined.trim().isEmpty()) name = joined.trim();
+ }
+ } catch (RuntimeException e) {
+ Log.w(TAG, "hookup: identity of " + path + " incomplete: " + e);
+ }
+ if (descriptor == null || descriptor.length == 0) { descriptor = VENDOR_GAMEPAD_DESCRIPTOR; lastSource = "fallback"; }
+ b.putInt("version", version);
+ b.putInt("iface", ifaceNumber);
+ b.putInt("ifclass", ifaceClass);
+ b.putInt("ifsub", ifaceSub);
+ b.putInt("ifproto", ifaceProto);
+ b.putString("name", name);
+ b.putString("manufacturer", manufacturer);
+ b.putString("product", product.isEmpty() ? name : product);
+ b.putString("serial", serial);
+ b.putByteArray("desc", descriptor);
+ Log.i(TAG, String.format("hookup: slot %d = %04x:%04x \"%s\" interface %d class %d, descriptor %d bytes",
+ slot, vid, pid, name, ifaceNumber, ifaceClass, descriptor.length));
+ return b;
+ }
+
+ private static String nz(String s) { return s == null ? "" : s.trim(); }
+
+ /** "1.00" / "2.24" -> bcdDevice. */
+ private static int parseBcd(String v) {
+ try {
+ if (v == null) return 0x0100;
+ String[] p = v.split("\\.");
+ int major = Integer.parseInt(p[0], 16), minor = p.length > 1 ? Integer.parseInt(p[1], 16) : 0;
+ return ((major & 0xff) << 8) | (minor & 0xff);
+ } catch (RuntimeException e) {
+ return 0x0100;
+ }
+ }
+
+ /** [readReportDescriptor] on its own thread, given up after 1.5 s: a USB request that never comes back must
+ * not freeze the SDL thread (that would stop every controller until the app is restarted). */
+ private static byte[] readWithTimeout(UsbManager um, UsbDevice dev, UsbInterface iface) {
+ final byte[][] out = new byte[1][];
+ Thread t = new Thread(() -> out[0] = readReportDescriptor(um, dev, iface), "hookup-descriptor");
+ t.setDaemon(true);
+ t.start();
+ try { t.join(1500); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
+ if (t.isAlive()) {
+ Log.w(TAG, "hookup: report descriptor read timed out");
+ return null;
+ }
+ return out[0];
+ }
+
+ /** GET_DESCRIPTOR(HID report) on the interface, over a second connection. */
+ private static byte[] readReportDescriptor(UsbManager um, UsbDevice dev, UsbInterface iface) {
+ UsbDeviceConnection c = null;
+ try {
+ c = um.openDevice(dev);
+ if (c == null) return null;
+ byte[] buf = new byte[4096];
+ int ifaceNumber = iface.getId();
+ // Interface-recipient request (the standard one). If another connection holds the interface,
+ // Android refuses it; claim it briefly (nobody holds it right before the raw open), and as a last
+ // resort ask with device recipient, which most controllers answer the same way.
+ int n = c.controlTransfer(0x81, 0x06, 0x2200, ifaceNumber, buf, buf.length, 500);
+ if (n <= 0 && c.claimInterface(iface, false)) {
+ n = c.controlTransfer(0x81, 0x06, 0x2200, ifaceNumber, buf, buf.length, 500);
+ c.releaseInterface(iface);
+ }
+ if (n <= 0) n = c.controlTransfer(0x80, 0x06, 0x2200, ifaceNumber, buf, buf.length, 500);
+ if (n <= 0) return null;
+ byte[] out = new byte[n];
+ System.arraycopy(buf, 0, out, 0, n);
+ return out;
+ } catch (RuntimeException e) {
+ return null;
+ } finally {
+ if (c != null) c.close();
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/dsedge/Host.java b/app/src/main/java/com/example/dsedge/Host.java
new file mode 100644
index 000000000..4f4e61bb9
--- /dev/null
+++ b/app/src/main/java/com/example/dsedge/Host.java
@@ -0,0 +1,33 @@
+package com.example.dsedge;
+
+import android.content.Context;
+
+/**
+ * Where this bridge runs: as its own app (SDL3Bridge, package com.example.dsedge), or built into SDL3DroidDeck --
+ * the same code, in that app's ":sdl3" process. Built in, its settings are kept under their own names next to
+ * DroidDeck's, and it updates together with SDL3DroidDeck (no update check of its own).
+ */
+final class Host {
+ static final String OWN_PACKAGE = "com.example.dsedge";
+
+ private Host() {}
+
+ static boolean embedded(Context c) {
+ return c != null && !OWN_PACKAGE.equals(c.getPackageName());
+ }
+
+ /** A preferences file name of the bridge: unchanged as its own app, prefixed when built into another app. */
+ static String prefs(Context c, String name) {
+ return embedded(c) ? "sdl3bridge_" + name : name;
+ }
+
+ /** The app's name as Android shows it ("SDL3DroidDeck" when built in). */
+ static String appName(Context c) {
+ try {
+ CharSequence label = c.getPackageManager().getApplicationLabel(c.getApplicationInfo());
+ return label != null ? label.toString() : c.getPackageName();
+ } catch (RuntimeException e) {
+ return c.getPackageName();
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/dsedge/NativeBridge.java b/app/src/main/java/com/example/dsedge/NativeBridge.java
new file mode 100644
index 000000000..e5ee0bf30
--- /dev/null
+++ b/app/src/main/java/com/example/dsedge/NativeBridge.java
@@ -0,0 +1,342 @@
+package com.example.dsedge;
+
+import java.util.concurrent.CopyOnWriteArrayList;
+
+/** Glue between the native SDL3 loop (libmain.so) and the Java service. */
+public final class NativeBridge {
+ public interface Listener {
+ void onFrame(float[] f, int slot);
+ void onStatus(String s);
+ /** The controller in this slot (0..3) was unplugged. */
+ default void onPadGone(int slot) { }
+ /** Capabilities of the controller in this slot: which buttons/axes exist, touchpad count, and kind. */
+ default void onPadCaps(int slot, int present, int axes, int touchpads, String kind) { }
+ /** Steam Controller (2026) raw passthrough: one input report (id + payload without the id byte). */
+ default void onTritonState(int reportId, byte[] payload) { }
+ /** The passed-through Steam Controller (2026) appeared / went away. */
+ default void onTritonConnected(boolean connected, String what) { }
+ /** Answer to a feature read DroidDeck forwarded (null = the controller did not answer). */
+ default void onTritonFeature(long request, byte[] report) { }
+ /** Steam Native Hook-up: a controller was handed to Steam in [slot]; identity = HookupIdentity bundle. */
+ default void onHookupAdded(int slot, android.os.Bundle identity) { }
+ default void onHookupInput(int slot, byte[] report) { }
+ default void onHookupRemoved(int slot) { }
+ default void onHookupReply(int slot, long request, byte[] report) { }
+ /** A line from the bridge's hook-up / passthrough code, for DroidDeck's session log. */
+ default void onBridgeLog(String line) { }
+ }
+
+ /** Layout of the float[] frame sent from native code. */
+ public static final int FRAME_LEN = 22;
+ // 0 mask | 1..4 lx ly rx ry (0..255) | 5,6 l2 r2 (0..255) | 7..9 gyro rad/s | 10..12 accel m/s^2
+ // 13..15 finger0 (down,x,y) | 16..18 finger1 | 19 battery % (-1 unknown) | 20 SDL power state | 21 extra-button mask (MISC2..6)
+
+ static final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>();
+ static volatile String lastStatus = "Starting...";
+
+ static final int MAX_PADS = 4;
+ /** USB ids and names of the controller in each slot, so DroidDeck can ignore the Android duplicate of it. */
+ static final int[] vids = new int[MAX_PADS], pids = new int[MAX_PADS];
+ static final String[] names = new String[MAX_PADS];
+
+ private NativeBridge() {}
+
+ /** Called from the native SDL thread. */
+ static void onFrame(float[] f, int slot) {
+ for (Listener l : listeners) l.onFrame(f, slot);
+ }
+
+ /** Called from the native SDL thread when a controller is opened in a slot. */
+ static void onPadInfo(int slot, int vid, int pid, String name) {
+ if (slot < 0 || slot >= MAX_PADS) return;
+ vids[slot] = vid;
+ pids[slot] = pid;
+ names[slot] = name;
+ }
+
+ /** Capabilities of each slot's controller (present-button mask, axis flags, touchpad count, kind). */
+ static final int[] present = new int[MAX_PADS];
+ static final int[] axesMask = new int[MAX_PADS];
+ static final int[] touchpadCount = new int[MAX_PADS];
+ static final String[] kind = new String[MAX_PADS];
+
+ /** Called from the native SDL thread when a controller is opened in a slot. */
+ static void onPadCaps(int slot, int p, int a, int tp, String k) {
+ if (slot < 0 || slot >= MAX_PADS) return;
+ present[slot] = p;
+ axesMask[slot] = a;
+ touchpadCount[slot] = tp;
+ kind[slot] = k;
+ for (Listener l : listeners) l.onPadCaps(slot, p, a, tp, k);
+ }
+
+ /** Called from the native SDL thread when a slot's controller goes away. */
+ static void onPadGone(int slot) {
+ if (slot < 0 || slot >= MAX_PADS) return;
+ vids[slot] = 0;
+ pids[slot] = 0;
+ names[slot] = null;
+ for (Listener l : listeners) l.onPadGone(slot);
+ }
+
+ // ---------- Steam Controller (2026) raw passthrough (native/triton.h) ----------
+ static volatile boolean tritonConnected;
+ static volatile String tritonWhat = "";
+
+ /** Called from the native SDL thread for every state report. */
+ static void onTritonState(int reportId, byte[] payload) {
+ for (Listener l : listeners) l.onTritonState(reportId, payload);
+ }
+
+ /** Called from the native SDL thread. */
+ static void onTritonConnected(boolean connected, String what) {
+ tritonConnected = connected;
+ tritonWhat = what == null ? "" : what;
+ for (Listener l : listeners) l.onTritonConnected(connected, tritonWhat);
+ }
+
+ /** Called from the native SDL thread. */
+ static void onTritonFeature(long request, byte[] report) {
+ for (Listener l : listeners) l.onTritonFeature(request, report);
+ }
+
+ /** An output report (feature = false) or a feature-report write (feature = true) from Steam, report id first. */
+ static void tritonOutput(byte[] report, boolean feature) {
+ try { nativeTritonOutput(report, feature); } catch (UnsatisfiedLinkError e) { }
+ }
+
+ static void tritonGetFeature(long request, int reportId) {
+ try { nativeTritonGetFeature(request, reportId); } catch (UnsatisfiedLinkError e) { }
+ }
+
+ /** Game rumble for the passed-through controller, 0..65535 per motor. */
+ static void tritonRumble(int left, int right) {
+ try { nativeTritonRumble(left, right); } catch (UnsatisfiedLinkError e) { }
+ }
+
+ static native void nativeTritonOutput(byte[] report, boolean feature);
+ static native void nativeTritonGetFeature(long request, int reportId);
+ static native void nativeTritonRumble(int left, int right);
+
+ // ---------- Steam Native Hook-up (native/hookup.h) ----------
+ static volatile android.content.Context appContext;
+ static final android.os.Bundle[] hookupIdentity = new android.os.Bundle[MAX_PADS];
+ /** Controllers opened raw by native code: {path, vid, pid, interface}; kept until they are released. */
+ static final Object[][] hookupPending = new Object[MAX_PADS][];
+
+ /** A controller that is open but was never described (that failed): try again, off the SDL thread. */
+ static void retryUndescribed() {
+ for (int i = 0; i < MAX_PADS; i++) {
+ Object[] p = hookupPending[i];
+ if (p == null || hookupIdentity[i] != null) continue;
+ final int slot = i;
+ new Thread(() -> {
+ if (hookupPending[slot] == p && hookupIdentity[slot] == null)
+ onHookupAdded(slot, (String) p[0], (Integer) p[1], (Integer) p[2], (Integer) p[3]);
+ }, "hookup-retry").start();
+ }
+ }
+
+ static void onHookupAdded(int slot, String path, int vid, int pid, int ifaceIndex) {
+ if (slot < 0 || slot >= MAX_PADS) return;
+ hookupPending[slot] = new Object[] { path, vid, pid, ifaceIndex };
+ android.content.Context c = appContext;
+ if (c == null) {
+ onBridgeLog("hookup: slot " + slot + " cannot be described (the app is not fully started); it is retried");
+ return;
+ }
+ long t0 = android.os.SystemClock.uptimeMillis();
+ android.os.Bundle id;
+ try {
+ id = HookupIdentity.build(c, slot, path, vid, pid, ifaceIndex);
+ } catch (Throwable e) {
+ onBridgeLog("hookup: slot " + slot + " could not be described: " + e);
+ return;
+ }
+ hookupIdentity[slot] = id;
+ byte[] d = id.getByteArray("desc");
+ onBridgeLog(String.format(java.util.Locale.US, "hookup: slot %d described in %d ms (descriptor %d bytes, %s); sent to %d DroidDeck connection(s)",
+ slot, android.os.SystemClock.uptimeMillis() - t0, d == null ? 0 : d.length, HookupIdentity.lastSource, clientCount()));
+ for (Listener l : listeners) {
+ try { l.onHookupAdded(slot, id); } catch (RuntimeException e) { android.util.Log.w("SDL3Bridge", "hookup: listener failed: " + e); }
+ }
+ }
+
+ /** DroidDeck connections, for the log (set by BridgeService). */
+ static volatile java.util.function.IntSupplier clients;
+ private static int clientCount() {
+ java.util.function.IntSupplier s = clients;
+ return s == null ? -1 : s.getAsInt();
+ }
+
+ /** Recent bridge log lines (newest last), replayed to DroidDeck when it connects. */
+ static final java.util.ArrayDeque bridgeLog = new java.util.ArrayDeque<>();
+
+ /** Called from the native SDL thread. */
+ static void onBridgeLog(String line) {
+ String stamped = new java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.US).format(new java.util.Date()) + " " + line;
+ synchronized (bridgeLog) {
+ bridgeLog.addLast(stamped);
+ while (bridgeLog.size() > 200) bridgeLog.removeFirst();
+ }
+ for (Listener l : listeners) l.onBridgeLog(stamped);
+ }
+
+ /** Called from the native SDL thread just before a controller is opened raw. */
+ static void prefetchHookupDescriptor(String path) {
+ android.content.Context c = appContext;
+ if (c != null) HookupIdentity.prefetch(c, path);
+ }
+
+ static void onHookupInput(int slot, byte[] report) {
+ for (Listener l : listeners) l.onHookupInput(slot, report);
+ }
+
+ static void onHookupRemoved(int slot) {
+ if (slot < 0 || slot >= MAX_PADS) return;
+ hookupPending[slot] = null;
+ hookupIdentity[slot] = null;
+ for (Listener l : listeners) l.onHookupRemoved(slot);
+ }
+
+ static void onHookupReply(int slot, long request, byte[] report) {
+ for (Listener l : listeners) l.onHookupReply(slot, request, report);
+ }
+
+ static void applyHookup() {
+ try { nativeSetHookup(hookupMode()); } catch (UnsatisfiedLinkError e) { }
+ }
+
+ // ---- DroidDeck's desktop: Steam Native Hook-up waits for Steam ----
+ // On DroidDeck's desktop Steam is one program among others and may not be running. DroidDeck says so with each
+ // watchdog sync (every 1.5 s); while it does, hook-up waits: controllers stay ordinary gamepads every program
+ // can use, and are handed to Steam once its client runs. Lapses by itself if DroidDeck stops saying it.
+ private static volatile long steamAwayUntilMs;
+ private static volatile int hookupApplied = -1;
+
+ /** 0 off, 1 on, 2 on but waiting for Steam (DroidDeck's desktop without Steam running). */
+ static int hookupMode() {
+ if (!Settings.isHookup()) return 0;
+ return android.os.SystemClock.uptimeMillis() < steamAwayUntilMs ? 2 : 1;
+ }
+
+ static boolean hookupWaitsForSteam() { return hookupMode() == 2; }
+
+ /** From DroidDeck's sync: is Steam away (its desktop without the Steam client running)? */
+ static void setSteamAway(boolean away) {
+ steamAwayUntilMs = away ? android.os.SystemClock.uptimeMillis() + 10000 : 0;
+ recheckHookup();
+ }
+
+ /** Applies hook-up again if waiting for Steam started or ended (also when the wait lapsed). */
+ static void recheckHookup() {
+ int mode = hookupMode();
+ if (mode == hookupApplied) return;
+ int was = hookupApplied;
+ hookupApplied = mode;
+ if (mode == 2) onBridgeLog("hookup: DroidDeck's desktop has no Steam running: controllers stay ordinary gamepads until it starts");
+ else if (was == 2 && mode == 1) onBridgeLog("hookup: Steam runs on DroidDeck's desktop: controllers are handed to Steam");
+ applyHookup();
+ }
+
+ static void hookupOutput(int slot, int kind, byte[] report) {
+ try { nativeHookupOutput(slot, kind, report); } catch (UnsatisfiedLinkError e) { }
+ }
+
+ static void hookupGet(int slot, long request, int reportId, boolean input, int length) {
+ try { nativeHookupGet(slot, request, reportId, input, length); } catch (UnsatisfiedLinkError e) { }
+ }
+
+ static native void nativeSetHookup(int on);
+ static native int nativeHookupMapShm(int fd);
+ static native long nativeHookupShmInode();
+
+ /** DroidDeck's shared hook-up file (fast path: reports written straight into it). Takes ownership of pfd. */
+ static void hookupMapShm(android.os.ParcelFileDescriptor pfd) {
+ if (pfd == null) return;
+ try { nativeHookupMapShm(pfd.detachFd()); }
+ catch (UnsatisfiedLinkError e) { try { pfd.close(); } catch (java.io.IOException ignored) { } }
+ }
+
+ /** Inode of the mapped (still live) shared file, 0 if none. */
+ static long hookupShmInode() {
+ try { return nativeHookupShmInode(); } catch (UnsatisfiedLinkError e) { return 0; }
+ }
+ static native void nativeHookupOutput(int slot, int kind, byte[] report);
+ static native void nativeHookupGet(int slot, long request, int reportId, boolean input, int length);
+
+ /** Called from the native SDL thread. */
+ static void onStatus(String s) {
+ SdlEngine.onNativeStatus();
+ lastStatus = s;
+ for (Listener l : listeners) l.onStatus(s);
+ }
+
+ /** Rumble for any player slot (0..3). */
+ static void setRumble(int slot, int strong, int weak) {
+ if (slot < 0 || slot >= MAX_PADS) return;
+ try { nativeSetRumble(slot, strong, weak); } catch (UnsatisfiedLinkError e) { }
+ }
+
+ static native void nativeSetRumble(int slot, int rumbleStrong, int rumbleWeak);
+
+ /** Pushes each player's lightbar (scaled by brightness) and player LED to its controller. */
+ static void applyOutput() {
+ try {
+ for (int p = 0; p < MAX_PADS; p++) {
+ int b = Settings.brightness[p];
+ nativeSetLed(p, Settings.lightR[p] * b / 100, Settings.lightG[p] * b / 100, Settings.lightB[p] * b / 100);
+ nativeSetPlayerLed(p, Settings.playerLed[p] - 1); // -1 = player LEDs off
+ }
+ } catch (UnsatisfiedLinkError e) {
+ // The bridge screen has not been opened yet, so the native library is not loaded.
+ }
+ }
+
+ /** Apply just one player's light (on a settings change). */
+ static void applyOutput(int p) {
+ if (p < 0 || p >= MAX_PADS) return;
+ try {
+ int b = Settings.brightness[p];
+ nativeSetLed(p, Settings.lightR[p] * b / 100, Settings.lightG[p] * b / 100, Settings.lightB[p] * b / 100);
+ nativeSetPlayerLed(p, Settings.playerLed[p] - 1);
+ } catch (UnsatisfiedLinkError e) { }
+ }
+
+ /** Forced mode: ignore Android's basic input and only use SDL's own USB driver. */
+ static void applyForce() {
+ try { nativeSetForce(Settings.forceUsb ? 1 : 0); } catch (UnsatisfiedLinkError e) { }
+ }
+
+ static void rescan() {
+ try { nativeRescan(); } catch (UnsatisfiedLinkError e) { }
+ }
+
+ static native void nativeSetForce(int on);
+
+ static native void nativeRescan();
+
+ /** Hard on/off for the SDL3 driver: on = forced SDL3 USB, off = release the controller to Android. */
+ static void applyDriver() {
+ try {
+ nativeSetDriver(Settings.driverEnabled ? 1 : 0);
+ nativeSetHookup(hookupMode());
+ if (Settings.driverEnabled) nativeSetForce(Settings.forceUsb ? 1 : 0);
+ } catch (UnsatisfiedLinkError e) { }
+ }
+
+ static native void nativeSetDriver(int on);
+
+ /** Controller poll rate (loop period in ms). */
+ static void applyRate() {
+ try { nativeSetPollMs(Settings.pollMs); } catch (UnsatisfiedLinkError e) { }
+ }
+
+ static native void nativeSetPollMs(int ms);
+
+ /** Per-slot lightbar rgb 0..255. */
+ static native void nativeSetLed(int slot, int r, int g, int b);
+
+ /** Per-slot player LEDs: -1 = off, 0..3 = player 1..4. */
+ static native void nativeSetPlayerLed(int slot, int index);
+}
diff --git a/app/src/main/java/com/example/dsedge/SdlEngine.java b/app/src/main/java/com/example/dsedge/SdlEngine.java
new file mode 100644
index 000000000..71dc14eda
--- /dev/null
+++ b/app/src/main/java/com/example/dsedge/SdlEngine.java
@@ -0,0 +1,271 @@
+package com.example.dsedge;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.SystemClock;
+import android.util.Log;
+
+import org.libsdl.app.HIDDeviceManager;
+import org.libsdl.app.SDL;
+import org.libsdl.app.SDLActivity;
+
+/**
+ * Where SDL3 runs.
+ *
+ * Normally headless, inside the bridge's background service -- no screen has to open for the bridge to work. It
+ * does what SDL's own SDLActivity does at start-up, minus the window: load the libraries, set up SDL's Java side,
+ * take USB/HID access (HIDDeviceManager), then run SDL_main (main.c) on its own thread. The bridge only uses SDL's
+ * gamepad / joystick / hidapi parts, which need no window.
+ *
+ * SDL's Java side does expect "the app's screen" as its context (SDL.getContext()): SDL3 reads the app's files
+ * and assets through it while it starts (gamepad mappings), and crashes the whole app without one. Headless there
+ * is no screen, so AppContext stands in for it -- an Activity object that is never started or shown, whose calls
+ * all go to the app. The mappings file is also handed to SDL3 directly, so it never needs the assets for it.
+ *
+ * Self-protecting: a marker is saved before every background start and cleared when SDL3's loop reports its first
+ * status. If SDL3 stays silent for 10 s (the process is then restarted), or the app died during the attempt, this
+ * build uses the fallback instead: SDL3 runs inside the bridge's own screen, started when you open SDL3Bridge
+ * (nothing opens by itself). Every step, and the previous run's crash report if there was one, is sent to
+ * DroidDeck's session log ("sdl3bridge: engine: ...").
+ */
+final class SdlEngine {
+ private static final String TAG = "SDL3Bridge";
+ private static final long CONFIRM_MS = 10_000;
+ private static volatile boolean started, confirmed, fallback, failed;
+ private static volatile HIDDeviceManager hid;
+ private static long hostStartMs;
+ /** Why SDL3 is not running (shown in the status, the notification and DroidDeck's tab), or null. */
+ static volatile String error;
+ /** How SDL3 runs now, for the status / DroidDeck's tab. */
+ static volatile String mode = "not started";
+ private static String fallbackWhy = "";
+
+ private SdlEngine() {}
+
+ static boolean isRunning() { return started && (fallback ? SdlHostActivity.running : true); }
+
+ private static void log(String line) {
+ Log.i(TAG, line);
+ NativeBridge.onBridgeLog("engine: " + line);
+ }
+
+ private static SharedPreferences prefs(Context c) { return c.getSharedPreferences(Host.prefs(c, "engine"), Context.MODE_PRIVATE); }
+
+ /** Which build this is: the version code, or -- built into SDL3DroidDeck, whose version code stays the same from
+ * build to build -- when it was installed, so a new build tries the background again. */
+ private static long version(Context c) {
+ try {
+ android.content.pm.PackageInfo pi = c.getPackageManager().getPackageInfo(c.getPackageName(), 0);
+ if (Host.embedded(c)) return pi.lastUpdateTime;
+ return android.os.Build.VERSION.SDK_INT >= 28 ? pi.getLongVersionCode() : pi.versionCode;
+ } catch (Exception e) { return 0; }
+ }
+
+ /** Stands in for "the app's screen" in SDL's Java side (see the class comment). Never started or shown. */
+ static final class AppContext extends Activity {
+ AppContext(Context base) { attachBaseContext(base); }
+ }
+
+ private static Activity standIn(Context app) {
+ try {
+ Context base;
+ // its own copy of the app's context, so nothing set on the stand-in reaches the app's real one
+ try { base = app.createConfigurationContext(app.getResources().getConfiguration()); }
+ catch (RuntimeException e) { base = app; }
+ return new AppContext(base);
+ } catch (Throwable e) {
+ log("could not make SDL3's stand-in context (" + e + "); starting without one");
+ return null;
+ }
+ }
+
+ /** SDL3's gamepad mappings file (empty unless you add mappings), as an absolute path; null if unavailable. */
+ static String mappingFile(Context c) {
+ try {
+ java.io.File f = new java.io.File(c.getFilesDir(), "gamepad_map.txt");
+ if (!f.exists() && !f.createNewFile()) return null;
+ return f.getAbsolutePath();
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ /** Starts SDL once per process (safe to call again, from any thread). */
+ static void start(Context context) {
+ if (Looper.myLooper() != Looper.getMainLooper()) { // the stand-in context must be made on the main thread
+ new Handler(Looper.getMainLooper()).post(() -> start(context));
+ return;
+ }
+ startOnMain(context);
+ }
+
+ private static synchronized void startOnMain(Context context) {
+ final Context app = context.getApplicationContext();
+ NativeBridge.appContext = app;
+ Settings.init(app);
+ if (started) {
+ if (fallback && !SdlHostActivity.running && context instanceof Activity) startHost((Activity) context);
+ return;
+ }
+ started = true;
+ SharedPreferences p = prefs(app);
+ long ver = version(app);
+ String last = p.getString("state", "");
+ boolean sameBuild = p.getLong("version", -1) == ver;
+ if (sameBuild && (last.equals("starting") || last.startsWith("failed"))) {
+ boolean crashed = last.equals("starting");
+ fallbackWhy = crashed ? "the app stopped while SDL3 was starting in the background" : last.substring(last.indexOf(':') + 1).trim();
+ if (crashed) p.edit().putString("state", "failed: " + fallbackWhy).commit();
+ fallback = true;
+ mode = "the SDL3Bridge screen (fallback)";
+ error = "Open SDL3Bridge once to start SDL3 (it could not run in the background on this phone: " + fallbackWhy + ")";
+ log("SDL3 can't run in the background service with this build (" + fallbackWhy + "). Fallback: SDL3 runs "
+ + "inside the SDL3Bridge screen -- open SDL3Bridge once (nothing opens by itself). A new build tries "
+ + "the background again.");
+ if (crashed) forwardPreviousLog();
+ if (context instanceof Activity) startHost((Activity) context);
+ return;
+ }
+ p.edit().putString("state", "starting").putLong("version", ver).commit(); // commit: must survive a crash
+ log("starting SDL3 in the background service (build " + ver + ")");
+ try {
+ System.loadLibrary("SDL3");
+ System.loadLibrary("main");
+ log("libraries loaded");
+ SDL.setupJNI();
+ SDL.initialize(); // (this also clears SDL's context)
+ Activity ctx = standIn(app);
+ if (ctx != null) SDL.setContext(ctx);
+ log("SDL3's Java side ready" + (ctx != null ? " (stand-in context)" : " (no context)"));
+ hid = HIDDeviceManager.acquire(app);
+ hid.setFrozen(!Settings.driverEnabled);
+ log("USB/HID access ready");
+ } catch (Throwable e) {
+ fail(app, "SDL3 could not start: " + e);
+ restartForFallback("SDL3 could not start in the background service");
+ return;
+ }
+ final String main = app.getApplicationInfo().nativeLibraryDir + "/libmain.so";
+ final String map = mappingFile(app);
+ final String[] args = map != null ? new String[] { map } : new String[0];
+ Thread t = new Thread(() -> {
+ try { android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_DISPLAY); } catch (RuntimeException ignored) { }
+ try {
+ SDLActivity.nativeInitMainThread();
+ int rc = SDLActivity.nativeRunMain(main, "SDL_main", args);
+ SDLActivity.nativeCleanupMainThread();
+ fail(app, "SDL3's loop ended (" + rc + ")");
+ } catch (Throwable e) {
+ fail(app, "SDL3 stopped: " + e);
+ }
+ restartForFallback("SDL3's loop is not running");
+ }, "SDLThread");
+ error = null;
+ mode = "background service";
+ t.start();
+ log("SDL3 thread started; waiting for its first status");
+ // Settings the native loop reads (driver, hook-up, rate, lights), as the bridge screen used to apply.
+ NativeBridge.applyDriver();
+ NativeBridge.applyRate();
+ NativeBridge.applyOutput();
+ new Handler(Looper.getMainLooper()).postDelayed(() -> {
+ if (confirmed || fallback || failed) return; // working, or already failed (and restarting)
+ fail(app, "SDL3 did not answer within " + (CONFIRM_MS / 1000) + " s in the background service");
+ restartForFallback("SDL3 did not answer");
+ }, CONFIRM_MS);
+ }
+
+ /** SDL3's loop reported a status: it runs. */
+ static void onNativeStatus() {
+ if (!started) return;
+ if (fallback) {
+ if (error != null && SdlHostActivity.running) {
+ error = null;
+ log("SDL3 is running in the SDL3Bridge screen (fallback); you can leave the screen, it keeps running");
+ }
+ return;
+ }
+ if (confirmed) return;
+ confirmed = true;
+ Context c = NativeBridge.appContext;
+ if (c != null) prefs(c).edit().putString("state", "ok").apply();
+ log("SDL3 is running in the background service");
+ }
+
+ /**
+ * The fallback screen was closed for good (its task was swiped away): SDL3 can't be restarted cleanly in the
+ * same process, so the process restarts (DroidDeck's connection brings the service back) and the bridge waits
+ * to be opened again.
+ */
+ static void onHostGone() {
+ if (!fallback) return;
+ error = "Open SDL3Bridge to start SDL3 again (fallback: " + fallbackWhy + ")";
+ log("the SDL3Bridge screen that ran SDL3 was closed; restarting SDL3Bridge -- open it again to start SDL3");
+ new Handler(Looper.getMainLooper()).postDelayed(
+ () -> android.os.Process.killProcess(android.os.Process.myPid()), 1500);
+ }
+
+ /** The process restarts (DroidDeck's connection brings the service back) and the next start uses the fallback. */
+ private static void restartForFallback(String why) {
+ log(why + "; restarting SDL3Bridge in 2 s so the fallback takes over");
+ new Handler(Looper.getMainLooper()).postDelayed(
+ () -> android.os.Process.killProcess(android.os.Process.myPid()), 2000); // lets the log reach DroidDeck
+ }
+
+ private static void fail(Context app, String why) {
+ failed = true;
+ error = why;
+ Log.e(TAG, why);
+ NativeBridge.onBridgeLog("engine: " + why);
+ prefs(app).edit().putString("state", "failed: " + why).commit();
+ }
+
+ /** Fallback only, and only from the bridge's own screen while you look at it: SDL3's host screen on top of it. */
+ private static void startHost(Activity from) {
+ long now = SystemClock.uptimeMillis();
+ if (hostStartMs != 0 && now - hostStartMs < 5000) return; // never in a loop
+ hostStartMs = now;
+ try {
+ from.startActivity(new Intent(from, SdlHostActivity.class).addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION));
+ log("starting SDL3 in the SDL3Bridge screen (fallback)");
+ } catch (RuntimeException e) {
+ log("could not start SDL3 in the SDL3Bridge screen: " + e);
+ }
+ }
+
+ /** The previous run's own log lines about SDL / the bridge / a crash, into DroidDeck's session log. */
+ private static void forwardPreviousLog() {
+ new Thread(() -> {
+ java.util.ArrayDeque keep = new java.util.ArrayDeque<>();
+ try {
+ java.lang.Process p = Runtime.getRuntime().exec(new String[] { "logcat", "-d", "-v", "time", "-t", "3000" });
+ try (java.io.BufferedReader r = new java.io.BufferedReader(new java.io.InputStreamReader(p.getInputStream()))) {
+ String line;
+ while ((line = r.readLine()) != null) {
+ if (line.contains("AndroidRuntime") || line.contains(" F/") || line.contains("/SDL")
+ || line.contains("SDL3Bridge") || line.contains("libc") || line.contains("DEBUG")
+ || line.contains("/hidapi") || line.contains("JNI DETECTED")) {
+ keep.addLast(line);
+ while (keep.size() > 80) keep.removeFirst();
+ }
+ }
+ }
+ } catch (Exception e) {
+ keep.addLast("(could not read the log: " + e + ")");
+ }
+ NativeBridge.onBridgeLog("engine: ---- the bridge's own log (last " + keep.size() + " relevant lines) ----");
+ for (String l : keep) NativeBridge.onBridgeLog("engine: | " + l);
+ }, "engine-log").start();
+ }
+
+ /** Driver off = release the USB interface so Android can use the controller; on = claim it again. */
+ static void setHidFrozen(boolean frozen) {
+ HIDDeviceManager h = hid;
+ if (h != null) h.setFrozen(frozen);
+ else SdlHostActivity.setHidFrozen(frozen);
+ }
+}
diff --git a/app/src/main/java/com/example/dsedge/SdlHostActivity.java b/app/src/main/java/com/example/dsedge/SdlHostActivity.java
new file mode 100644
index 000000000..dcd23c882
--- /dev/null
+++ b/app/src/main/java/com/example/dsedge/SdlHostActivity.java
@@ -0,0 +1,104 @@
+package com.example.dsedge;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.ViewGroup;
+
+import org.libsdl.app.SDLActivity;
+
+/**
+ * Fallback host for SDL3, used only when SDL3 could not run in the background service with this build
+ * (SdlEngine decides, and says why). SDL's own screen class starts SDL3 the classic way; it is opened only from the
+ * bridge's screen while you look at it, never by itself.
+ *
+ * It stays out of sight: its window is transparent and SDL's drawing surface is shrunk to one pixel (the bridge
+ * draws nothing). SDL3 starts once this screen is in front, then the bridge's screen is brought back on top of it
+ * in the same task; SDL3 keeps running behind it, and when you leave SDL3Bridge (Home, Back, switching apps).
+ */
+public class SdlHostActivity extends SDLActivity {
+ static volatile boolean running;
+ private final Handler ui = new Handler(Looper.getMainLooper());
+ private int waits;
+
+ @Override protected String[] getLibraries() {
+ return new String[] { "SDL3", "main" };
+ }
+
+ @Override protected String[] getArguments() {
+ String map = SdlEngine.mappingFile(this);
+ return map != null ? new String[] { map } : new String[0];
+ }
+
+ @Override protected void onCreate(Bundle state) {
+ running = true;
+ NativeBridge.appContext = getApplicationContext();
+ Settings.init(this);
+ super.onCreate(state);
+ if (mSurface != null) { // SDL's drawing surface: the bridge draws nothing, keep it out of sight
+ ViewGroup.LayoutParams lp = mSurface.getLayoutParams();
+ if (lp != null) {
+ lp.width = 1;
+ lp.height = 1;
+ mSurface.setLayoutParams(lp);
+ }
+ }
+ }
+
+ @Override protected void onResume() {
+ super.onResume();
+ keepControllerClaimed();
+ waits = 0;
+ ui.removeCallbacks(handBack);
+ ui.post(handBack);
+ }
+
+ /** As soon as SDL3's thread runs: the bridge's screen back on top (or, if it is gone, out of the way). */
+ private final Runnable handBack = new Runnable() {
+ @Override public void run() {
+ if (isFinishing()) return;
+ if (mSDLThread == null && ++waits < 60) { // SDL starts once its surface is ready (~100 ms)
+ ui.postDelayed(this, 50);
+ return;
+ }
+ if (BridgeActivity.running) {
+ startActivity(new Intent(SdlHostActivity.this, BridgeActivity.class)
+ .addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT | Intent.FLAG_ACTIVITY_NO_ANIMATION));
+ } else {
+ moveTaskToBack(true);
+ }
+ }
+ };
+
+ @Override public void onBackPressed() {
+ moveTaskToBack(true); // closing this screen would stop SDL3
+ }
+
+ @Override protected void onPause() {
+ super.onPause();
+ ui.removeCallbacks(handBack);
+ keepControllerClaimed(); // SDL releases the USB controller on pause; keep it while DroidDeck is in front
+ }
+
+ @Override public void onWindowFocusChanged(boolean hasFocus) {
+ super.onWindowFocusChanged(hasFocus);
+ keepControllerClaimed();
+ }
+
+ @Override protected void onDestroy() {
+ ui.removeCallbacks(handBack);
+ running = false;
+ boolean forGood = !isChangingConfigurations();
+ super.onDestroy();
+ if (forGood) SdlEngine.onHostGone();
+ }
+
+ private static void keepControllerClaimed() {
+ if (mHIDDeviceManager != null) mHIDDeviceManager.setFrozen(!Settings.driverEnabled);
+ }
+
+ static void setHidFrozen(boolean frozen) {
+ if (mHIDDeviceManager != null) mHIDDeviceManager.setFrozen(frozen);
+ }
+}
diff --git a/app/src/main/java/com/example/dsedge/Settings.java b/app/src/main/java/com/example/dsedge/Settings.java
new file mode 100644
index 000000000..2a70e5b04
--- /dev/null
+++ b/app/src/main/java/com/example/dsedge/Settings.java
@@ -0,0 +1,184 @@
+package com.example.dsedge;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+/**
+ * User settings, shared by the screen and the background service (same process).
+ * Gyro / touchpad / back-button remap / lightbar are per player (0..3); the rest are global.
+ */
+final class Settings {
+ static final int PLAYERS = 4;
+
+ static final int TOUCH_SPLIT = 0, TOUCH_SINGLE = 1, TOUCH_OFF = 2;
+ static final String[] TOUCH_NAMES = {
+ "Split (decoupled): left half = left trackpad, right half = right trackpad",
+ "Single: whole touchpad = right trackpad",
+ "Off"
+ };
+
+ /** Remap targets: 4 Deck grips + the two trackpads' touch / click. */
+ static final String[] REMAP_TARGETS = {
+ "L4", "R4", "L5", "R5",
+ "Left trackpad click", "Right trackpad click",
+ "Left trackpad touch", "Right trackpad touch"
+ };
+ static final int REMAP_COUNT = 8;
+ static final int[] TARGET_BITS = { 19, 20, 21, 22 }; // grip targets (first 4) -> mask bits
+
+ /** Any controller button can be a source. sel: -1 none, 0 = main mask, 1 = extra mask (MISC2..6). */
+ static final String[] SRC_NAMES = {
+ "None",
+ "A / Cross", "B / Circle", "X / Square", "Y / Triangle",
+ "L1", "R1", "L2", "R2",
+ "Create / Back / Share", "Options / Start / Menu", "L3", "R3",
+ "Guide / PS", "Touchpad click", "Mute / Misc1",
+ "D-pad Up", "D-pad Down", "D-pad Left", "D-pad Right",
+ "Left paddle (L4)", "Right paddle (R4)", "Left Fn (L5)", "Right Fn (R5)",
+ "Extra C / Misc2", "Extra Z / Misc3", "Misc4", "Misc5", "Misc6"
+ };
+ static final int[] SRC_SEL = { -1, 0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0, 0,0,0,0, 0,0,0,0, 1,1,1,1,1 };
+ static final int[] SRC_BIT = { -1, 0,1,2,3, 4,5,6,7, 8,9,10,11, 12,13,14, 15,16,17,18, 19,20,21,22, 0,1,2,3,4 };
+ static final String[] PLAYER_NAMES = { "Off", "Player 1", "Player 2", "Player 3", "Player 4" };
+ static final String[] AXIS_NAMES = { "Controller X (pitch)", "Controller Y (yaw)", "Controller Z (roll)" };
+ static final String[] GYRO_INV_NAMES = { "None", "X", "Y", "Z", "X + Y", "X + Z", "Y + Z", "X + Y + Z" };
+
+ // ---- per-player settings (index 0..3) ----
+ static final int[] touchMode = fill(TOUCH_SPLIT);
+ static final int[][] remap = new int[PLAYERS][REMAP_COUNT];
+ static final boolean[] touchInvX = new boolean[PLAYERS];
+ static final boolean[] touchInvY = new boolean[PLAYERS];
+ static final boolean[] touchSwap = new boolean[PLAYERS];
+ static final int[] gyroGain = fill(100);
+ static final int[] gyroInvert = new int[PLAYERS];
+ static final int[] gyroDeadzone = new int[PLAYERS];
+ static final float[][] gyroBias = new float[PLAYERS][3];
+ static final int[][] gyroMap = new int[PLAYERS][3];
+ static final boolean[] accelFollows = fill(true);
+ static final int[] lightR = new int[PLAYERS];
+ static final int[] lightG = new int[PLAYERS];
+ static final int[] lightB = fill(255);
+ static final int[] brightness = fill(100);
+ static final int[] playerLed = new int[PLAYERS];
+
+ // ---- global settings ----
+ // Auto by default: any SDL3-supported controller is used -- through SDL3's own USB driver when possible
+ // (full features), otherwise through Android's input (e.g. Bluetooth). An Android duplicate of a controller
+ // SDL3 already drives over USB is still ignored, so nothing is doubled.
+ static volatile boolean forceUsb = false;
+ static volatile boolean driverEnabled = true;
+ static volatile int presentAs = 0;
+ static final String[] PRESENT_NAMES = { "Steam Deck (default)", "DualSense (native)", "DualSense Edge (native)", "DualShock 4 (native)",
+ "Steam Controller 2026 (native)", "Steam Controller 2015 (native)",
+ "Steam Native Hook-up (Steam sees the real controller)" };
+ // DroidDeck pad types: 0 Deck, 4 DualSense, 5 DualSense Edge, 3 DualShock 4, 1 Steam Controller 2026 (28de:1302),
+ // 6 Steam Controller 2015 (28de:1102)
+ // Hook-up hands USB controllers to Steam as themselves; any controller it can't (Bluetooth) stays a Deck pad.
+ static final int[] PRESENT_PAD_TYPE = { 0, 4, 5, 3, 1, 6, 0 };
+ static final int PRESENT_HOOKUP = 6;
+ static boolean isHookup() { return presentAs == PRESENT_HOOKUP; }
+ /** The "Present controller as" choice to go back to when hook-up is switched off from the bubble. */
+ static volatile int lastPresentAs = 0;
+ /** Was the SDL3 driver on before the bubble switched hook-up on? Switching hook-up off goes back to it. */
+ static volatile boolean driverBeforeHookup = true;
+ static final int PAD_TYPE_STEAM_2015 = 6;
+ static boolean isSonyPadType(int t) { return t >= 3 && t <= 5; }
+ static volatile int pollMs = 2;
+ static final String[] RATE_NAMES = { "250 Hz (low CPU)", "500 Hz (recommended)", "1000 Hz (max)" };
+ static final int[] RATE_MS = { 4, 2, 1 };
+ static volatile boolean bubble = false;
+ static volatile int bubbleX = 24, bubbleY = 160;
+
+ private static SharedPreferences prefs;
+
+ private Settings() {}
+
+ private static int[] fill(int v) { int[] a = new int[PLAYERS]; java.util.Arrays.fill(a, v); return a; }
+ private static boolean[] fill(boolean v) { boolean[] a = new boolean[PLAYERS]; java.util.Arrays.fill(a, v); return a; }
+
+ static synchronized void init(Context c) {
+ if (prefs != null) return;
+ prefs = c.getApplicationContext().getSharedPreferences(Host.prefs(c, "bridge"), Context.MODE_PRIVATE);
+ for (int p = 0; p < PLAYERS; p++) {
+ String s = "_p" + p;
+ touchMode[p] = clamp(prefs.getInt("touchMode" + s, TOUCH_SPLIT), 0, 2);
+ for (int i = 0; i < REMAP_COUNT; i++) remap[p][i] = clamp(prefs.getInt("remap" + i + s, 0), 0, SRC_NAMES.length - 1);
+ touchInvX[p] = prefs.getBoolean("touchInvX" + s, false);
+ touchInvY[p] = prefs.getBoolean("touchInvY" + s, false);
+ touchSwap[p] = prefs.getBoolean("touchSwap" + s, false);
+ gyroGain[p] = clamp(prefs.getInt("gyroGain" + s, 100), 1, 800);
+ gyroInvert[p] = clamp(prefs.getInt("gyroInvert" + s, 0), 0, 7);
+ gyroDeadzone[p] = clamp(prefs.getInt("gyroDeadzone" + s, 0), 0, 50);
+ gyroBias[p][0] = prefs.getFloat("gyroBiasX" + s, 0f);
+ gyroBias[p][1] = prefs.getFloat("gyroBiasY" + s, 0f);
+ gyroBias[p][2] = prefs.getFloat("gyroBiasZ" + s, 0f);
+ for (int i = 0; i < 3; i++) gyroMap[p][i] = clamp(prefs.getInt("gyroMap" + i + s, i), 0, 2);
+ accelFollows[p] = prefs.getBoolean("accelFollows" + s, true);
+ lightR[p] = clamp(prefs.getInt("lightR" + s, 0), 0, 255);
+ lightG[p] = clamp(prefs.getInt("lightG" + s, 0), 0, 255);
+ lightB[p] = clamp(prefs.getInt("lightB" + s, 255), 0, 255);
+ brightness[p] = clamp(prefs.getInt("brightness" + s, 100), 0, 100);
+ playerLed[p] = clamp(prefs.getInt("playerLed" + s, 0), 0, 4);
+ }
+ forceUsb = prefs.getBoolean("forceUsbV2", false);
+ driverEnabled = prefs.getBoolean("driverEnabled", true);
+ presentAs = clamp(prefs.getInt("presentAs", 0), 0, PRESENT_NAMES.length - 1);
+ lastPresentAs = clamp(prefs.getInt("lastPresentAs", 0), 0, PRESENT_NAMES.length - 1);
+ if (lastPresentAs == PRESENT_HOOKUP) lastPresentAs = 0;
+ driverBeforeHookup = prefs.getBoolean("driverBeforeHookup", true);
+ pollMs = clamp(prefs.getInt("pollMs", 2), 1, 16);
+ bubble = prefs.getBoolean("bubble", false);
+ bubbleX = prefs.getInt("bubbleX", 24);
+ bubbleY = prefs.getInt("bubbleY", 160);
+ }
+
+ static void save() {
+ SharedPreferences pr = prefs;
+ if (pr == null) return;
+ SharedPreferences.Editor e = pr.edit();
+ for (int p = 0; p < PLAYERS; p++) {
+ String s = "_p" + p;
+ e.putInt("touchMode" + s, touchMode[p]);
+ for (int i = 0; i < REMAP_COUNT; i++) e.putInt("remap" + i + s, remap[p][i]);
+ e.putBoolean("touchInvX" + s, touchInvX[p]).putBoolean("touchInvY" + s, touchInvY[p]).putBoolean("touchSwap" + s, touchSwap[p]);
+ e.putInt("gyroGain" + s, gyroGain[p]).putInt("gyroInvert" + s, gyroInvert[p]).putInt("gyroDeadzone" + s, gyroDeadzone[p]);
+ e.putFloat("gyroBiasX" + s, gyroBias[p][0]).putFloat("gyroBiasY" + s, gyroBias[p][1]).putFloat("gyroBiasZ" + s, gyroBias[p][2]);
+ for (int i = 0; i < 3; i++) e.putInt("gyroMap" + i + s, gyroMap[p][i]);
+ e.putBoolean("accelFollows" + s, accelFollows[p]);
+ e.putInt("lightR" + s, lightR[p]).putInt("lightG" + s, lightG[p]).putInt("lightB" + s, lightB[p]);
+ e.putInt("brightness" + s, brightness[p]).putInt("playerLed" + s, playerLed[p]);
+ }
+ e.putBoolean("forceUsbV2", forceUsb);
+ e.putBoolean("driverEnabled", driverEnabled);
+ e.putInt("presentAs", presentAs);
+ e.putInt("lastPresentAs", lastPresentAs);
+ e.putBoolean("driverBeforeHookup", driverBeforeHookup);
+ e.putInt("pollMs", pollMs);
+ e.putBoolean("bubble", bubble).putInt("bubbleX", bubbleX).putInt("bubbleY", bubbleY);
+ e.apply();
+ }
+
+ private static int clamp(int v, int lo, int hi) { return Math.max(lo, Math.min(hi, v)); }
+
+ /** Is the source button [srcIndex] currently pressed, given the two button masks? */
+ static boolean srcDown(int mask, int mask2, int srcIndex) {
+ if (srcIndex <= 0 || srcIndex >= SRC_NAMES.length) return false;
+ int m = SRC_SEL[srcIndex] == 1 ? mask2 : mask;
+ return ((m >> SRC_BIT[srcIndex]) & 1) != 0;
+ }
+
+ /** Applies the 4 grip remaps (targets 0..3) to the mask for player [p]; a mapped source stops acting as itself. */
+ static int applyGripRemap(int mask, int mask2, int p) {
+ if (p < 0 || p >= PLAYERS) p = 0;
+ int[] rm = remap[p];
+ int add = 0, consumed = 0;
+ for (int t = 0; t < 4; t++) {
+ int srcIndex = rm[t];
+ if (srcDown(mask, mask2, srcIndex)) {
+ add |= 1 << TARGET_BITS[t];
+ if (SRC_SEL[srcIndex] == 0) consumed |= 1 << SRC_BIT[srcIndex];
+ }
+ }
+ return (mask | add) & ~consumed;
+ }
+}
diff --git a/app/src/main/java/com/example/dsedge/UpdateChecker.java b/app/src/main/java/com/example/dsedge/UpdateChecker.java
new file mode 100644
index 000000000..c4203e57b
--- /dev/null
+++ b/app/src/main/java/com/example/dsedge/UpdateChecker.java
@@ -0,0 +1,116 @@
+package com.example.dsedge;
+
+import android.content.Context;
+import android.os.Handler;
+import android.os.Looper;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.io.BufferedReader;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.net.HttpURLConnection;
+import java.net.URL;
+
+/**
+ * Checks the GitHub Releases of the repo this APK was built in (CI writes assets/build.txt).
+ * The repo must be public for the unauthenticated GitHub API to see its releases.
+ */
+final class UpdateChecker {
+ interface Callback {
+ /** url is non-null only when a newer build exists. Called on the main thread. */
+ void onResult(String message, String url);
+ }
+
+ static final class BuildInfo {
+ String repo = "UNKNOWN", tag = "dev", sdl = "unknown";
+ }
+
+ private UpdateChecker() {}
+
+ static BuildInfo readBuildInfo(Context ctx) {
+ BuildInfo b = new BuildInfo();
+ // Built into another app (SDL3DroidDeck), the bridge's build info has its own name next to that app's assets.
+ try (InputStream in = ctx.getAssets().open(Host.embedded(ctx) ? "sdl3bridge-build.txt" : "build.txt");
+ BufferedReader r = new BufferedReader(new InputStreamReader(in))) {
+ String line;
+ while ((line = r.readLine()) != null) {
+ int eq = line.indexOf('=');
+ if (eq <= 0) continue;
+ String k = line.substring(0, eq).trim(), v = line.substring(eq + 1).trim();
+ if (k.equals("repo")) b.repo = v;
+ else if (k.equals("tag")) b.tag = v;
+ else if (k.equals("sdl")) b.sdl = v;
+ }
+ } catch (Exception ignored) { }
+ return b;
+ }
+
+ static void check(final Context ctx, final Callback cb) {
+ final Handler main = new Handler(Looper.getMainLooper());
+ final BuildInfo cur = readBuildInfo(ctx);
+ new Thread(() -> {
+ String msg;
+ String url = null;
+ if (Host.embedded(ctx)) {
+ msg = "Built into " + Host.appName(ctx) + ": it updates together with it.";
+ } else if (cur.repo.equals("UNKNOWN")) {
+ msg = "Updates are off: this APK was not built by the GitHub workflow.";
+ } else {
+ try {
+ HttpURLConnection c = (HttpURLConnection) new URL(
+ "https://api.github.com/repos/" + cur.repo + "/releases/latest").openConnection();
+ c.setConnectTimeout(8000);
+ c.setReadTimeout(8000);
+ c.setRequestProperty("Accept", "application/vnd.github+json");
+ int code = c.getResponseCode();
+ if (code == 404) {
+ msg = "No release found. The repo must be public and have a finished build.";
+ } else if (code != 200) {
+ msg = "Update check failed (HTTP " + code + ").";
+ } else {
+ StringBuilder sb = new StringBuilder();
+ try (BufferedReader r = new BufferedReader(new InputStreamReader(c.getInputStream()))) {
+ String line;
+ while ((line = r.readLine()) != null) sb.append(line);
+ }
+ JSONObject j = new JSONObject(sb.toString());
+ String latest = j.optString("tag_name", "");
+ String notes = j.optString("body", "");
+ String sdl = "";
+ int i = notes.indexOf("sdl=");
+ if (i >= 0) sdl = notes.substring(i + 4).split("\\s")[0];
+ String apk = null;
+ JSONArray assets = j.optJSONArray("assets");
+ for (int k = 0; assets != null && k < assets.length(); k++) {
+ JSONObject a = assets.getJSONObject(k);
+ if (a.optString("name").endsWith(".apk")) { apk = a.optString("browser_download_url"); break; }
+ }
+ if (apk != null && newer(latest, cur.tag)) {
+ msg = "Update available: " + latest + (sdl.isEmpty() ? "" : " (SDL " + sdl + ")") + ". Tap to download.";
+ url = apk;
+ } else {
+ msg = "Up to date (" + cur.tag + ", SDL " + cur.sdl + ").";
+ }
+ }
+ } catch (Exception e) {
+ msg = "Update check failed: " + e.getClass().getSimpleName();
+ }
+ }
+ final String m = msg;
+ final String u = url;
+ main.post(() -> cb.onResult(m, u));
+ }, "update-check").start();
+ }
+
+ /** Tags look like "build-12". Anything unparsable on our side ("dev") counts as older. */
+ private static boolean newer(String latest, String current) {
+ long a = number(latest), b = number(current);
+ return a > b;
+ }
+
+ private static long number(String tag) {
+ try { return Long.parseLong(tag.replaceAll("[^0-9]", "")); } catch (NumberFormatException e) { return -1; }
+ }
+}
diff --git a/app/src/main/java/com/example/dsedge/UsbAttachActivity.java b/app/src/main/java/com/example/dsedge/UsbAttachActivity.java
new file mode 100644
index 000000000..396fb2575
--- /dev/null
+++ b/app/src/main/java/com/example/dsedge/UsbAttachActivity.java
@@ -0,0 +1,22 @@
+package com.example.dsedge;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+
+/**
+ * Receives "controller plugged in" from Android. Accepting Android's prompt gives the bridge USB access to the
+ * controller; nothing needs to be shown for that. The bridge's background service (with SDL3 in it) is started
+ * if it isn't running yet, and picks the controller up; no screen opens.
+ */
+public class UsbAttachActivity extends Activity {
+ @Override protected void onCreate(Bundle state) {
+ super.onCreate(state);
+ // The bridge runs in its background service (SDL3 included): start that, nothing comes to the front.
+ try {
+ startForegroundService(new Intent(this, BridgeService.class));
+ } catch (RuntimeException ignored) { }
+ finish();
+ overridePendingTransition(0, 0);
+ }
+}
diff --git a/app/src/main/java/org/libsdl/app/HIDDevice.java b/app/src/main/java/org/libsdl/app/HIDDevice.java
new file mode 100644
index 000000000..f96095324
--- /dev/null
+++ b/app/src/main/java/org/libsdl/app/HIDDevice.java
@@ -0,0 +1,21 @@
+package org.libsdl.app;
+
+import android.hardware.usb.UsbDevice;
+
+interface HIDDevice
+{
+ public int getId();
+ public int getVendorId();
+ public int getProductId();
+ public String getSerialNumber();
+ public int getVersion();
+ public String getManufacturerName();
+ public String getProductName();
+ public UsbDevice getDevice();
+ public boolean open();
+ public int writeReport(byte[] report, boolean feature);
+ public boolean readReport(byte[] report, boolean feature);
+ public void setFrozen(boolean frozen);
+ public void close();
+ public void shutdown();
+}
diff --git a/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java b/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java
new file mode 100644
index 000000000..bcd8806c4
--- /dev/null
+++ b/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java
@@ -0,0 +1,829 @@
+package org.libsdl.app;
+
+import android.content.Context;
+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.bluetooth.BluetoothGattService;
+import android.hardware.usb.UsbDevice;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+import android.os.*;
+
+//import com.android.internal.util.HexDump;
+
+import java.lang.Runnable;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.UUID;
+
+import java.util.regex.Pattern;
+import java.util.regex.Matcher;
+
+class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDevice {
+
+ private static final String TAG = "hidapi";
+ private HIDDeviceManager mManager;
+ private BluetoothDevice mDevice;
+ private int mDeviceId;
+ private BluetoothGatt mGatt;
+ private boolean mIsRegistered = false;
+ private boolean mIsConnected = false;
+ private boolean mIsChromebook = false;
+ private boolean mIsReconnecting = false;
+ private boolean mHasEnabledNotifications = false;
+ private boolean mHasSeenInputUpdate = false;
+ private boolean mFrozen = false;
+ private LinkedList mOperations;
+ GattOperation mCurrentOperation = null;
+ private Handler mHandler;
+ private int mProductId = -1;
+ private int mReportId = 0;
+ private UUID mInputCharacteristic;
+
+ private static final int D0G_BLE2_PID = 0x1106;
+ private static final int TRITON_BLE_PID = 0x1303;
+
+
+ private static final int TRANSPORT_AUTO = 0;
+ private static final int TRANSPORT_BREDR = 1;
+ private static final int TRANSPORT_LE = 2;
+
+ private static final int CHROMEBOOK_CONNECTION_CHECK_INTERVAL = 10000;
+
+ static final UUID steamControllerService = UUID.fromString("100F6C32-1735-4313-B402-38567131E5F3");
+ static final UUID inputCharacteristicD0G = UUID.fromString("100F6C33-1735-4313-B402-38567131E5F3");
+ static final UUID inputCharacteristicTriton_0x45 = UUID.fromString("100F6C7A-1735-4313-B402-38567131E5F3");
+ static final UUID inputCharacteristicTriton_0x47 = UUID.fromString("100F6C7C-1735-4313-B402-38567131E5F3");
+ static final UUID reportCharacteristic = UUID.fromString("100F6C34-1735-4313-B402-38567131E5F3");
+ static private final byte[] enterValveMode = new byte[] { (byte)0xC0, (byte)0x87, 0x03, 0x08, 0x07, 0x00 };
+
+ private HashMap mOutputReportChars = new HashMap