feat(android): a USB Sony pad is captured — rumble, adaptive triggers, lightbar, gyro

A DualSense on a phone had rumble only where the kernel exposed force
feedback, and adaptive triggers / lightbar / player LEDs nowhere — Android
has no platform API for any of them, and Bluetooth offers no raw path
(L2CAP is LE-only; hidraw is root-sealed — Sony's own Remote Play declares
Android triggers unsupported). Claiming the pad's HID interface over USB is
the one unrooted route, so that is what the client now does.

- HidUsbLink: the device-agnostic half of Sc2UsbLink (claim, multiplexed
  UsbRequest loop, newest-wins write queue, signalled-unplug discipline),
  parameterized by device match / interface filter / keep-alive. Sc2UsbLink
  keeps only its SC2 specifics (Puck interfaces 2..5, lizard refresh).
- GamepadFeedback.PadFeedbackSink: 0xCA rumble + 0xCD Led/PlayerLeds/
  Trigger now route to a capture link that owns the pad BEFORE the
  InputDevice vibrator/lights paths — Trigger stops being log-and-drop.
- DsDevice: the byte-exact inverse of the host's dualsense_proto /
  dualshock4_proto — input report 0x01 parse (buttons/sticks/triggers,
  gyro+accel, both touch points; Edge FN/BACK → wire paddles) and output
  builders (DS5 0x02 valid-flag-selective incl. the 11-byte trigger blocks
  and the lightbar-animation release; DS4 0x05 as composed full-state
  writes). Covered by DsDeviceTest (pure JVM).
- DsCapture: stream-mode capture for DualSense / Edge / DS4 — lazy wire
  slot on the first parsed report, typed mirror (exit chord included),
  touch normalized onto the rich plane + per-report motion, feedback
  rendering with a rumble backstop (a USB pad holds its level, so a
  stalled poll thread self-terminates via a scheduled zero-write) and a
  teardown motor-stop over EP0. The claim releases the pad's InputDevice
  slot itself so the wire index hands over deterministically; uncaptured
  (toggle off / permission denied / Bluetooth) the pad stays on the
  ordinary InputDevice path.
- Rich-input shims: nativeSendPadTouch / nativeSendPadMotion →
  RichInput::Touchpad / Motion — the plane the desktop and Apple clients
  already feed; Android pads gain gyro + touchpad on the virtual pad.
- Settings: "DualSense / DualShock passthrough (USB)" (ds_capture, opt-out
  like the SC2 toggle); Controllers screen card with capture status and a
  front-loaded USB grant so streams start without the permission dialog.

The host needs nothing: the DS5/DS4 backends already consume the typed +
rich planes and already emit every feedback event rendered here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 18:34:57 +02:00
co-authored by Claude Fable 5
parent 696386dee7
commit 1984ddb942
13 changed files with 1653 additions and 364 deletions
@@ -406,3 +406,66 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadHidR
data,
});
}
/// `NativeBridge.nativeSendPadTouch(handle, pad, finger, active, x, y)` — one touchpad contact
/// from a client-captured controller (the Sony USB capture), forwarded on the rich-input plane
/// (`RichInput::Touchpad`, 0xCC). `finger`: contact slot 0/1; `x`/`y`: normalized 0..=65535 in
/// SCREEN convention (+y down — the wire's fixed meaning); `active` 0 lifts the finger. The
/// host's DualSense-family backends scale onto the virtual pad's touch surface. On-change only —
/// the capture diffs, the host holds per-slot state.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadTouch(
_env: JNIEnv,
_this: JObject,
handle: jlong,
pad: jint,
finger: jint,
active: jboolean,
x: jint,
y: jint,
) {
if handle == 0 {
return;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_rich_input is &self.
let h = unsafe { &*(handle as *const SessionHandle) };
let _ = h.client.send_rich_input(RichInput::Touchpad {
pad: (pad as u32 & 0xF) as u8,
finger: (finger as u32 & 0x1) as u8,
active: active != 0,
x: (x as i64).clamp(0, 65535) as u16,
y: (y as i64).clamp(0, 65535) as u16,
});
}
/// `NativeBridge.nativeSendPadMotion(handle, pad, gp, gy, gr, ax, ay, az)` — one motion sample
/// from a client-captured controller (`RichInput::Motion`, 0xCC): gyro pitch/yaw/roll + accel,
/// raw signed-16 values in the pad's own units, passed straight into the host's virtual
/// DualSense report (the wire is a unit passthrough). Called from the capture thread at the
/// controller's report rate.
#[no_mangle]
#[allow(clippy::too_many_arguments)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadMotion(
_env: JNIEnv,
_this: JObject,
handle: jlong,
pad: jint,
gyro_pitch: jint,
gyro_yaw: jint,
gyro_roll: jint,
accel_x: jint,
accel_y: jint,
accel_z: jint,
) {
if handle == 0 {
return;
}
let c = |v: jint| (v as i64).clamp(i64::from(i16::MIN), i64::from(i16::MAX)) as i16;
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_rich_input is &self.
let h = unsafe { &*(handle as *const SessionHandle) };
let _ = h.client.send_rich_input(RichInput::Motion {
pad: (pad as u32 & 0xF) as u8,
gyro: [c(gyro_pitch), c(gyro_yaw), c(gyro_roll)],
accel: [c(accel_x), c(accel_y), c(accel_z)],
});
}