Input: one writer per trigger when a pad reports both ways

Retroid pads have an L2/R2 mode called "both": the trigger sends
KEYCODE_BUTTON_L2/R2 AND an analog axis. sendTrigger writes the pad button from
the axis, and the key path wrote it again -- one physical squeeze, two independent
writers on the same button, which arrives as a delayed or doubled press. Long
jumps in the Ratchet games hold R2 and were unreliable because of it; a reporter
had it in 0.9.2 and says 0.8 was fine.

sendTrigger already carries the opposite guard: a pad with no trigger axis at all
leaves "the key path in sole charge", added when Switch Pro controllers had their
held trigger cancelled by every stick movement. This is that guard's mirror, and
the two together mean exactly one writer owns a trigger on every pad -- the axis
where there is an axis, the key where there is not.

Keyed on the physical keycode before remapping, because what decides ownership is
how the hardware reports the trigger, not what the user bound it to.

Pads that report triggers ONLY as keys are unaffected: they have none of the three
axes, so the guard does not fire and the key path still owns them.
This commit is contained in:
jpolo1224
2026-08-19 17:27:42 -04:00
parent 5a34aeb06f
commit e73a40d9ce
@@ -3290,6 +3290,35 @@ open class MainActivityRuntime : ComponentActivity() {
val physicalCode = event.keyCode
if (physicalCode == KeyEvent.KEYCODE_UNKNOWN) return false
// A trigger that reports BOTH ways gets written by both paths, and they fight.
//
// Retroid pads have an L2/R2 mode called "both": the trigger sends KEYCODE_BUTTON_L2/R2
// AND an analog axis. sendTrigger already writes the button from the axis, so letting
// the key through as well gives one physical squeeze two independent writers on the
// same pad button -- which lands as a delayed or doubled press, and made long jumps in
// the Ratchet games (hold R2) unreliable.
//
// sendTrigger has the opposite guard already: a pad with no trigger axis at all leaves
// "the key path in sole charge". This is that guard's mirror, and the two together mean
// exactly one writer owns a trigger on every pad -- axis where there is an axis, key
// where there is not.
//
// Deliberately keyed on the PHYSICAL code before remapping: what decides ownership is
// how the hardware reports the trigger, not what the user bound it to.
if (physicalCode == KeyEvent.KEYCODE_BUTTON_L2 || physicalCode == KeyEvent.KEYCODE_BUTTON_R2)
{
val isLeft = physicalCode == KeyEvent.KEYCODE_BUTTON_L2
val axisA = if (isLeft) MotionEvent.AXIS_LTRIGGER else MotionEvent.AXIS_RTRIGGER
val axisB = if (isLeft) MotionEvent.AXIS_BRAKE else MotionEvent.AXIS_GAS
val axisC = if (isLeft) -1 else rightTriggerExtraAxis(event.deviceId)
if (deviceHasAxis(event.deviceId, axisA) || deviceHasAxis(event.deviceId, axisB) ||
deviceHasAxis(event.deviceId, axisC))
{
return true
}
}
// Local co-op routing and macro precedence exactly match the old Compose
// onKeyEvent path; only the dispatch layer has changed.
val port = com.armsx2.input.PadRouter.portForDevice(event.deviceId)