fix: harden SDL3 controller lifecycle

This commit is contained in:
XxJSONDeruloxX committed 2026-10-25 11:08:20 -04:00
1 parent 6d6b6726fa
commit 497b92e63d
14 files changed
+294 -19

No files matched your search

@@ -3527,6 +3527,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
handCamera?.stop()
handCamera = null
padBridge?.stop()
if (isFinishing && !SessionState.running) com.droiddeck.launcher.input.DualSenseBridge.detach(this)
com.droiddeck.launcher.input.DualSenseBridge.screenClosed(this)
steamController?.stop()
if (SessionState.padBridge === padBridge) SessionState.padBridge = null
@@ -26,7 +26,7 @@ import android.view.MotionEvent
* gyro / accel -> FakeInputWriter.writeMotion (slot 0)
*
* Usage: DualSenseBridge.attach(context, padBridge) when the session starts,
* DualSenseBridge.detach() when it stops.
* DualSenseBridge.endSession(token) when it stops.
*/
object DualSenseBridge {
private const val BRIDGE_PKG = "com.example.dsedge"
@@ -95,6 +95,7 @@ object DualSenseBridge {
private var appContext: Context? = null
@Volatile private var owner: Any? = null
private var sessionOwner: Any? = null
private var pad: PadBridge? = null
private var server: Messenger? = null
private var bound = false
@@ -160,6 +161,7 @@ object DualSenseBridge {
}
override fun handleMessage(m: Message) {
if (!sessionActive) return
when (m.what) {
MSG_FRAME -> onFrame(m.data)
MSG_STATUS -> if (m.data.getString("text")?.startsWith("Connected") != true) release()
@@ -210,6 +212,22 @@ object DualSenseBridge {
@JvmStatic
@Synchronized
fun attach(context: Context, padBridge: PadBridge): Boolean {
if (!com.droiddeck.launcher.session.SessionPrefs.sdl3Controllers(context)) return false
if (pad !== padBridge) {
release()
releaseSep()
lastMask = -1
lastAxes.fill(Float.NaN)
lastApplyMs = 0L
for (slot in 1 until SLOTS) {
maskS[slot] = -1
axesS[slot].fill(Float.NaN)
applyMsS[slot] = 0L
}
sepMask = -1
sepAxes.fill(Float.NaN)
sepApplyMs = 0L
}
pad = padBridge
owner = context
if (!sessionActive) {
@@ -235,7 +253,15 @@ object DualSenseBridge {
@JvmStatic
@Synchronized
fun detach() = detach(null)
fun beginSession(token: Any) { sessionOwner = token }
@JvmStatic
@Synchronized
fun endSession(token: Any) {
if (sessionOwner !== token) return
sessionOwner = null
disconnect()
}
/**
* The session screen is closing (Android also does this when you switch apps; a new screen attaches when you
@@ -248,17 +274,23 @@ object DualSenseBridge {
if (who !== owner) return
HookupBridge.note("bridge: session screen closed; the bridge stays connected until the session ends")
owner = null
release()
releaseSep()
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) {
fun detach(who: Any) {
if (who !== owner || sessionOwner != null) {
HookupBridge.note("bridge: an old session screen closed; the bridge stays connected")
return
}
disconnect()
}
private fun disconnect() {
HookupBridge.note("bridge: disconnected (session ended)")
sessionActive = false
steamAwayNoted = null
@@ -270,7 +302,9 @@ object DualSenseBridge {
bound = false
}
release()
workerHandler.post { setTriton(false, "session ended"); HookupBridge.removeAll() }
releaseSep()
setTriton(false, "session ended")
HookupBridge.removeAll()
pad = null
server = null
}
@@ -419,10 +453,7 @@ object DualSenseBridge {
// 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 currentPadType(): Int = FakeInputWriter.currentPadType()
private fun updateHookupPad() {
val hide = HookupBridge.anyPresent() && !isActive()
@@ -720,7 +751,7 @@ object DualSenseBridge {
@JvmStatic fun isBridged(e: MotionEvent): Boolean = isBridged(e.device)
/** A controller in bridge slot [slot] was unplugged. */
private fun padGone(slot: Int) {
@Synchronized private fun padGone(slot: Int) {
if (slot !in 0 until SLOTS) return
if (slot == 0) { release(); return }
val p = pad
@@ -838,7 +869,7 @@ object DualSenseBridge {
return floatArrayOf(x, y)
}
private fun releaseSep() {
@Synchronized private fun releaseSep() {
val p = pad
if (p != null && sepSlot > 0) {
FakeInputWriter.writeDeckControls(sepSlot, 0, ShortArray(4), ShortArray(2))
@@ -860,7 +891,8 @@ object DualSenseBridge {
FakeInputWriter.setPadType(pt)
}
private fun onFrame(b: Bundle) {
@Synchronized private fun onFrame(b: Bundle) {
if (pad == null) return
val slot = b.getInt("slot", 0).coerceIn(0, SLOTS - 1)
if (slot != 0) { onExtraFrame(slot, b); return }
val pt = b.getInt("ptype", 0)
@@ -988,7 +1020,7 @@ object DualSenseBridge {
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() {
@Synchronized private fun release() {
frameMsS[0] = 0L
if (lastPadType != 0 && !hookupPadHidden) { lastPadType = 0; applyPadType(0) }
updateHookupPad()
@@ -92,6 +92,7 @@ public class FakeInputWriter {
public static final int PAD_TYPE_STEAM_2015 = 6;
public static final int PAD_TYPE_NONE = 7;
private static volatile int padType = -1;
public static int currentPadType() { return padType; }
private static volatile boolean tritonConnected = false;
private static final int TRITON_OFFSET = RING_SIZE + 64;
private static final int TRITON_MAGIC = 0x4e525454;
@@ -376,5 +376,6 @@ object HookupBridge {
}
/** 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)
@JvmStatic fun wanted(context: Context): Boolean =
com.droiddeck.launcher.session.SessionPrefs.sdl3Controllers(context) && DualSenseBridge.isBridgeInstalled(context)
}
@@ -37,6 +37,7 @@ class RumbleComponent : SessionPart() {
}
internal var controllerMotors: (Int) -> Motors? = ::lookupController
internal var bridgeRumble: (Int, Int, Int, Int) -> Boolean = com.droiddeck.launcher.input.DualSenseBridge::routeRumble
internal var motorLooper: Looper? = null
@Synchronized private fun refreshEnabled() {
@@ -90,7 +91,11 @@ 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
if (bridgeRumble(slot, strong, weak, ms)) {
playing.remove(slot)?.cancel()
forget(slot)
return
}
val target = target(slot)
val previous = playing[slot]
if (target !== previous) {
@@ -42,6 +42,12 @@ object SessionPrefs {
private fun prefs(context: Context) = context.getSharedPreferences("session", Context.MODE_PRIVATE)
fun sdl3Controllers(context: Context): Boolean = prefs(context).getBoolean("sdl3Controllers", false)
fun setSdl3Controllers(context: Context, enabled: Boolean) {
prefs(context).edit().putBoolean("sdl3Controllers", enabled).apply()
}
val orientationOptions = listOf(
ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE to R.string.setup_orientation_auto,
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE to R.string.setup_orientation_landscape,
@@ -169,6 +169,7 @@ class SessionService : Service() {
updateSuspendPolicy()
}
@Volatile private var sessionGen = 0
private var bridgeSession: Any? = null
override fun onCreate() {
super.onCreate()
@@ -314,6 +315,7 @@ class SessionService : Service() {
}
startForeground(NOTIFICATION_ID, buildNotification())
if (SessionState.running) return START_NOT_STICKY
bridgeSession = Any().also { com.droiddeck.launcher.input.DualSenseBridge.beginSession(it) }
if (SessionState.stopRequested) {
SessionState.running = true
stopSession(0)
@@ -824,7 +826,7 @@ class SessionService : Service() {
SessionState.steamUi != "desktop" || sdl3Desktop) &&
!File(Environment.getExternalStorageDirectory(), NO_DECK_PAD_SWITCH).exists()
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))
val wantsTriton = wantsDeck && (SteamControllerBle.hasPairedController(this) || com.droiddeck.launcher.input.HookupBridge.wanted(this))
deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!, wantsTouch, wantsTriton,
SessionPrefs.padType(SessionPrefs.steamController(this))) else emptyList()
SessionState.deckPad = deckBinds.isNotEmpty()
@@ -1686,6 +1688,8 @@ class SessionService : Service() {
SessionEvents.record("guest.exited", mapOf("status" to status))
SessionEvents.transition(SessionPhase.STOPPING, "session.stopping", mapOf("status" to status))
SessionState.running = false
bridgeSession?.let { com.droiddeck.launcher.input.DualSenseBridge.endSession(it) }
bridgeSession = null
SessionPaths.take() to SessionState.steamClientArch
}
SessionState.micAvailable = false
@@ -1771,7 +1775,8 @@ 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()
bridgeSession?.let { com.droiddeck.launcher.input.DualSenseBridge.endSession(it) }
bridgeSession = null
if (screenReceiverRegistered) {
unregisterReceiver(screenReceiver)
screenReceiverRegistered = false
@@ -176,6 +176,16 @@ internal fun SdlBridgeSettingsTab(host: MenuHost, track: (String) -> Modifier =
}
val s = SdlBridgeLink.snapshot
var sessionEnabled by remember { mutableStateOf(com.droiddeck.launcher.session.SessionPrefs.sdl3Controllers(ctx)) }
SettingsGroup("Session integration") {
ToggleRow(host, "sdl3-session", "Use SDL3 controllers in sessions",
"Enable USB controller input, controller presentation and Steam Native Hook-up for the next session.",
sessionEnabled, chipModifier = track("sdl3-session")) {
sessionEnabled = it
com.droiddeck.launcher.session.SessionPrefs.setSdl3Controllers(ctx, it)
}
}
// ---------------- status ----------------
SettingsGroup("SDL3Bridge") {
if (s == null) {
@@ -0,0 +1,109 @@
package com.droiddeck.launcher.input
import android.content.ContextWrapper
import android.os.Bundle
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
import java.nio.file.Files
import org.junit.After
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], manifest = Config.NONE,
instrumentedPackages = ["com.droiddeck.launcher.input.FakeInputWriter"],
shadows = [JoyConPadTest.RecordingWriter::class])
class SdlBridgeLifecycleTest {
private val app get() = RuntimeEnvironment.getApplication()
private val dirs = mutableListOf<File>()
private val pads = mutableListOf<PadBridge>()
private fun pad() = PadBridge(Files.createTempDirectory("sdl-pad").toFile().also { dirs += it })
.also { pads += it }
private fun attach(pad: PadBridge): ContextWrapper {
SessionPrefs.setSdl3Controllers(app, true)
return ContextWrapper(app).also { DualSenseBridge.attach(it, pad) }
}
private fun frame(slot: Int, mask: Int = 1) {
DualSenseBridge::class.java.getDeclaredMethod("onFrame", Bundle::class.java)
.apply { isAccessible = true }.invoke(DualSenseBridge, Bundle().apply {
putInt("slot", slot); putInt("mask", mask); putInt("lx", 16000)
})
}
@After fun close() {
val token = Any()
DualSenseBridge.beginSession(token)
DualSenseBridge.endSession(token)
pads.forEach { it.stop() }
dirs.forEach { it.deleteRecursively() }
JoyConPadTest.RecordingWriter.pads.clear()
}
@Test fun replacingTheScreenReclaimsExtraPlayersAndResendsHeldInput() {
val first = pad()
attach(first)
frame(0); frame(1); frame(2)
assertTrue(JoyConPadTest.RecordingWriter.pads.getValue(1).isDown(PadState.A))
attach(pad())
JoyConPadTest.RecordingWriter.pads.clear()
frame(0); frame(1); frame(2)
for (slot in 0..2) {
assertTrue("player $slot lost a held button", JoyConPadTest.RecordingWriter.pads.getValue(slot).isDown(PadState.A))
}
}
@Test fun anOldServiceCannotDisconnectANewerSession() {
val old = Any()
val current = Any()
DualSenseBridge.beginSession(old)
attach(pad())
DualSenseBridge.beginSession(current)
attach(pad())
DualSenseBridge.endSession(old)
frame(1)
assertTrue(JoyConPadTest.RecordingWriter.pads.getValue(1).isDown(PadState.A))
DualSenseBridge.endSession(current)
JoyConPadTest.RecordingWriter.pads.clear()
frame(1)
assertTrue(JoyConPadTest.RecordingWriter.pads.isEmpty())
}
@Test fun aCanceledLaunchCanDisconnectItsScreenWithoutAService() {
val screen = attach(pad())
DualSenseBridge.detach(screen)
frame(1)
assertFalse(DualSenseBridge.isBridgedSlot(1))
assertFalse(DualSenseBridge::class.java.getDeclaredField("sessionActive")
.apply { isAccessible = true }.getBoolean(DualSenseBridge))
}
@Test fun anOldScreenCannotDisconnectItsReplacement() {
val old = attach(pad())
attach(pad())
DualSenseBridge.detach(old)
DualSenseBridge.screenClosed(old)
frame(1)
assertTrue(JoyConPadTest.RecordingWriter.pads.getValue(1).isDown(PadState.A))
}
@Test fun attachingTheSamePadPreservesItsPlayerClaims() {
val current = pad()
attach(current)
frame(1)
attach(current)
assertEquals(2, current.claimBridgeSlot())
}
@Test fun sessionIntegrationRequiresExplicitOptIn() {
SessionPrefs.setSdl3Controllers(app, false)
assertFalse(HookupBridge.wanted(app))
assertFalse(DualSenseBridge.attach(app, pad()))
}
}
@@ -41,6 +41,16 @@ class RumbleComponentTest {
@After fun stopListener() { rumble.stop() }
@Test fun switchingToTheBridgeCancelsLocalMotorsAndTheirKeepalive() {
effect(ms = 65535)
assertTrue(shadowOf(vibrator).isVibrating)
rumble.bridgeRumble = { _, _, _, _ -> true }
effect(ms = 65535)
assertFalse(shadowOf(vibrator).isVibrating)
idle(2000)
assertFalse("the old keepalive restarted local rumble", shadowOf(vibrator).isVibrating)
}
@Test fun disablingCancelsCurrentEffectAndDropsSubsequentEffectsUntilEnabled() {
effect()
assertTrue(shadowOf(vibrator).isVibrating)
@@ -11,11 +11,29 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Robolectric
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = Application::class)
class EmbeddedBridgeTest {
@Test
fun calibrationUsesOnlyTheSelectedPlayer() {
Settings.init(RuntimeEnvironment.getApplication())
val activity = Robolectric.buildActivity(BridgeActivity::class.java).get()
fun field(name: String) = BridgeActivity::class.java.getDeclaredField(name).apply { isAccessible = true }
field("calibPlayer").setInt(activity, 2)
field("calibCount").setInt(activity, 0)
val frame = FloatArray(13).apply { this[7] = 0.5f; this[8] = -1f; this[9] = 2f }
activity.onFrame(frame, 1)
assertEquals(0, field("calibCount").getInt(activity))
repeat(180) { activity.onFrame(frame, 2) }
assertEquals(-1, field("calibCount").getInt(activity))
assertEquals(28.64789f, Settings.gyroBias[2][0], 0.0001f)
assertEquals(-57.29578f, Settings.gyroBias[2][1], 0.0001f)
assertEquals(114.59156f, Settings.gyroBias[2][2], 0.0001f)
}
@Test
fun driverComponentsStayPrivateAndRunOutsideTheSessionProcess() {
val app = RuntimeEnvironment.getApplication()
+3 -1
View File
@@ -1,7 +1,9 @@
# SDL3 controller bridge
The integrated bridge runs in DroidDeck's `:sdl3` process. Open **Settings → SDL3Bridge**
The integrated bridge runs in DroidDeck's `:sdl3` process. Open **Setup → SDL3Bridge**
or the SDL3Bridge page in the session drawer to configure up to four controllers.
Enable **Use SDL3 controllers in sessions** before starting a session. This opt-in
also enables SDL3 controller presentation on the desktop and Steam Native Hook-up.
USB controllers use SDL3's HID drivers; Android input remains the fallback for
Bluetooth and built-in controllers. The embedded driver only claims USB controllers:
Android events are delivered to DroidDeck's activity and cannot reach the separate
@@ -0,0 +1,74 @@
--- a/native/main.c
+++ b/native/main.c
@@ -279,7 +279,7 @@
SDL_free(ids);
}
-static char g_last_diag[512];
+static char g_last_diag[560];
/* In forced mode an Android-path gamepad (no SDL USB path) is never used. */
static bool usable_id(SDL_JoystickID id)
@@ -328,7 +328,7 @@
static void refresh_status(JNIEnv *env)
{
- char diag[560];
+ char diag[sizeof(g_last_diag)];
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]) {
@@ -453,6 +453,10 @@
g_pads[0] = g_pads[k];
g_ids[0] = g_ids[k];
g_pads[k] = NULL;
+ SDL_RumbleGamepad(g_pads[0], 0, 0, 0);
+ atomic_store(&g_rl[k], 0);
+ atomic_store(&g_rr[k], 0);
+ rumble_on[k] = 0;
send_gone(env, k);
setup_slot(env, 0);
break;
--- a/native/hookup.h
+++ b/native/hookup.h
@@ -211,6 +211,11 @@
memset(&g_hk[i], 0, sizeof(g_hk[i]));
atomic_store(&g_hk_gone[i], 0);
atomic_store(&g_hk_fast_now[i], 0);
+ if (g_hks.base) {
+ uint8_t *s = hks_slot(&g_hks, i);
+ __atomic_store_n(hks_u32(s, HKS_PRESENT), 0, __ATOMIC_RELEASE);
+ __atomic_store_n(hks_u64(s, HKS_DIRECT_MS), 0, __ATOMIC_RELEASE);
+ }
pthread_mutex_unlock(&g_hk_io);
pthread_mutex_unlock(&g_hk_wr);
g_hk_fast_logged[i] = 0;
--- a/java/com/example/dsedge/BridgeActivity.java
+++ b/java/com/example/dsedge/BridgeActivity.java
@@ -290,7 +290,7 @@
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));
+ box.addView(sub("Calibration samples the selected player. Deadzone ignores tiny drift.", 11f));
subhead(box, "Touchpad");
addCheck(box, "Invert touchpad X (left/right)", Settings.touchInvX[p], on -> Settings.touchInvX[p] = on);
@@ -718,8 +718,7 @@
}
@Override public void onFrame(float[] f, int slot) {
- if (slot != 0) return; // the test screen shows player 1
- if (calibCount >= 0 && f.length >= 13) {
+ if (slot == calibPlayer && 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];
@@ -731,6 +730,7 @@
ui.post(() -> { if (gyroCalibView != null && uiPlayer == calibPlayer) gyroCalibView.setText(calibLabel(calibPlayer)); });
}
}
+ if (slot != 0) return; // the test screen shows player 1
pad.update(f);
long now = System.nanoTime();
if (now - lastSummary > 300_000_000L) {
+2 -1
View File
@@ -69,6 +69,7 @@
}
},
"patches": [
"patches/0001-droiddeck-integration.patch"
"patches/0001-droiddeck-integration.patch",
"patches/0002-controller-state-fixes.patch"
]
}