diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index c6a1dbc13..0c32b8808 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -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 diff --git a/app/src/main/java/com/droiddeck/launcher/input/DualSenseBridge.kt b/app/src/main/java/com/droiddeck/launcher/input/DualSenseBridge.kt index 0e77c1984..87b174f27 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/DualSenseBridge.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/DualSenseBridge.kt @@ -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() 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 7699cf8bc..f5442702f 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java +++ b/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java @@ -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; diff --git a/app/src/main/java/com/droiddeck/launcher/input/HookupBridge.kt b/app/src/main/java/com/droiddeck/launcher/input/HookupBridge.kt index 46dc9c716..9709d891a 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/HookupBridge.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/HookupBridge.kt @@ -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) } 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 8cc7ca3b8..3075454f8 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/RumbleComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/RumbleComponent.kt @@ -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) { diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index d77cced0b..44a5d985c 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -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, 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 26da9446d..91df5a9d8 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -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 diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SdlBridgeSettings.kt b/app/src/main/java/com/droiddeck/launcher/ui/SdlBridgeSettings.kt index 36f67c02e..e31044181 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SdlBridgeSettings.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SdlBridgeSettings.kt @@ -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) { diff --git a/app/src/test/java/com/droiddeck/launcher/input/SdlBridgeLifecycleTest.kt b/app/src/test/java/com/droiddeck/launcher/input/SdlBridgeLifecycleTest.kt new file mode 100644 index 000000000..1ca97df75 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/SdlBridgeLifecycleTest.kt @@ -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() + private val pads = mutableListOf() + + 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())) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/RumbleComponentTest.kt b/app/src/test/java/com/droiddeck/launcher/session/RumbleComponentTest.kt index 8c5361aec..db7dec2a3 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/RumbleComponentTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/RumbleComponentTest.kt @@ -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) diff --git a/app/src/test/java/com/example/dsedge/EmbeddedBridgeTest.kt b/app/src/test/java/com/example/dsedge/EmbeddedBridgeTest.kt index 93ed5682d..9a1e73958 100644 --- a/app/src/test/java/com/example/dsedge/EmbeddedBridgeTest.kt +++ b/app/src/test/java/com/example/dsedge/EmbeddedBridgeTest.kt @@ -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() diff --git a/docs/development/sdl3-controllers.md b/docs/development/sdl3-controllers.md index 2a16f1261..eb7dcd331 100644 --- a/docs/development/sdl3-controllers.md +++ b/docs/development/sdl3-controllers.md @@ -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 diff --git a/third_party/sdlbridge/patches/0002-controller-state-fixes.patch b/third_party/sdlbridge/patches/0002-controller-state-fixes.patch new file mode 100644 index 000000000..90e6c80dd --- /dev/null +++ b/third_party/sdlbridge/patches/0002-controller-state-fixes.patch @@ -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) { diff --git a/third_party/sdlbridge/upstream.json b/third_party/sdlbridge/upstream.json index c24b60fea..8aa840297 100644 --- a/third_party/sdlbridge/upstream.json +++ b/third_party/sdlbridge/upstream.json @@ -69,6 +69,7 @@ } }, "patches": [ - "patches/0001-droiddeck-integration.patch" + "patches/0001-droiddeck-integration.patch", + "patches/0002-controller-state-fixes.patch" ] }