01c55aed38
Carry the rich Steam Controller / Steam Deck inputs end-to-end on the wire — strictly additive + forward-compatible (unknown kinds/bits drop on old peers). Core (punktfunk-core): - input.rs: BTN_PADDLE1..4 + BTN_MISC1 in Moonlight's buttonFlags2<<16 namespace (so the GameStream paddle path and native grips share one host injector map; Steam L4/L5/R4/R5 reuse the four Xbox-Elite paddle slots). - quic.rs: RichInput::TouchpadEx (kind 0x03 — surface 0/1/2, touch+click, signed coords, pressure; the second trackpad the single Touchpad can't express) and HidOutput::TrackpadHaptic (kind 0x04 — the SC voice-coil pulse). Round-tripped. - abi.rs: PUNKTFUNK_GAMEPAD_STEAMDECK=6 / _STEAMCONTROLLER=5, the paddle bits, RICH_TOUCHPAD_EX / HIDOUT_TRACKPAD_HAPTIC constants. from_hid packs TrackpadHaptic into the existing which + effect[0..6] — the legacy structs do NOT grow (guarded by new size_of==20/19 asserts); GamepadPref lockstep + paddle-bit lockstep asserts extended. include/punktfunk_core.h regenerated. Host (punktfunk-host): - steam_proto::from_gamepad maps the wire paddles -> the four Deck grips + QAM; apply_rich routes TouchpadEx left/right -> the matching pad. - every DualSense/DS4 manager (Linux + Windows) gained a TouchpadEx arm (surface 0/2 -> its one touchpad; surface 1 ignored) so the variant compiles everywhere and a Steam client streaming to a DS host keeps its right pad. - the xpad BUTTON_MAP finally consumes the GameStream paddle bits (BTN_TRIGGER_HAPPY5-8) — Sunshine/Moonlight paddle clients were silently no-op'd before (design §5.6). - Android feedback: drop TrackpadHaptic (no coils; rumble rides 0xCA). Validated on-box: the ignored backend test now drives the full wire path — from_gamepad (BTN_A + the L4 grip) + apply_rich (a left-pad TouchpadEx) reach the evdev as BTN_A + ABS_HAT0X=-8000. Wire round-trips + paddle/TouchpadEx mapping unit-tested. Workspace clippy/fmt/test green. Not pushed. Deferred to M4: the C-ABI PunktfunkRichInputEx + send_rich_input2 (only the Apple/embedder *send* path needs it; the host decodes TouchpadEx today). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
126 lines
5.3 KiB
Rust
126 lines
5.3 KiB
Rust
//! Host→client gamepad feedback pulls (Option B): blocking JNI shims that forward to the connector's
|
|
//! rumble (0xCA) / HID-output (0xCD) planes and return one decoded event. Kotlin owns the poll
|
|
//! threads + the Android Vibrator/Lights rendering (see `GamepadFeedback.kt`) — no JNI upcalls, no
|
|
//! `JavaVM` attach, no cached method ids. Mirrors the audio plane's one-thread-per-plane contract,
|
|
//! except the thread lives in Kotlin and we just expose the blocking pull.
|
|
//!
|
|
//! Not android-gated: `next_rumble`/`next_hidout` are pure-Rust on the `quic` feature, so these
|
|
//! compile on the host build too (parity with the input shims in [`crate::session`]).
|
|
|
|
use crate::session::{jni_guard, SessionHandle};
|
|
use jni::objects::{JByteBuffer, JObject};
|
|
use jni::sys::{jint, jlong};
|
|
use jni::JNIEnv;
|
|
use punktfunk_core::quic::HidOutput;
|
|
use std::time::Duration;
|
|
|
|
/// Short blocking timeout: long enough not to busy-spin, short enough that the Kotlin poll thread
|
|
/// observes its `running=false` flag promptly on teardown.
|
|
const PULL_TIMEOUT: Duration = Duration::from_millis(100);
|
|
|
|
// HID-output kind tags written into the returned ByteBuffer (Kotlin reads them back).
|
|
const TAG_LED: u8 = 0x01;
|
|
const TAG_PLAYER_LEDS: u8 = 0x02;
|
|
const TAG_TRIGGER: u8 = 0x03;
|
|
|
|
/// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next rumble update.
|
|
/// Returns `(low << 16) | high` (each 0..=0xFFFF; `0` = stop), or `-1` on timeout / session closed.
|
|
/// Pad index is dropped (single-pad model). Run from a dedicated Kotlin poll thread.
|
|
#[no_mangle]
|
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
|
_env: JNIEnv,
|
|
_this: JObject,
|
|
handle: jlong,
|
|
) -> jlong {
|
|
// Runs on a Kotlin poll thread, so a panic here would abort the process; guard the boundary.
|
|
jni_guard(-1, || {
|
|
if handle == 0 {
|
|
return -1;
|
|
}
|
|
// SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble is &self on the
|
|
// Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll
|
|
// threads (and joins them — unbounded) before nativeClose frees the handle.
|
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
|
match h.client.next_rumble(PULL_TIMEOUT) {
|
|
Ok((_pad, low, high)) => (jlong::from(low) << 16) | jlong::from(high),
|
|
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
|
}
|
|
})
|
|
}
|
|
|
|
/// `NativeBridge.nativeNextHidout(handle, buf): Int` — block up to ~100 ms for the next DualSense
|
|
/// HID-output event, written into the caller's direct ByteBuffer as `[kind][fields…]`:
|
|
/// Led → `[0x01][r][g][b]` (len 4)
|
|
/// PlayerLeds → `[0x02][bits]` (len 2)
|
|
/// Trigger → `[0x03][which][effect…]` (len 2 + effect.len())
|
|
/// Returns the byte count written, or `-1` on timeout / session closed / buffer too small.
|
|
#[no_mangle]
|
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
|
env: JNIEnv,
|
|
_this: JObject,
|
|
handle: jlong,
|
|
buf: JByteBuffer,
|
|
) -> jint {
|
|
// Runs on a Kotlin poll thread, so a panic here would abort the process; guard the boundary.
|
|
jni_guard(-1, || {
|
|
if handle == 0 {
|
|
return -1;
|
|
}
|
|
// SAFETY: live handle per the contract; next_hidout is &self on the Sync connector.
|
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
|
let ev = match h.client.next_hidout(PULL_TIMEOUT) {
|
|
Ok(ev) => ev,
|
|
Err(_) => return -1, // timeout or closed — Kotlin loops
|
|
};
|
|
|
|
// The caller passes a direct ByteBuffer (allocateDirect) so we write its backing store directly.
|
|
let cap = match env.get_direct_buffer_capacity(&buf) {
|
|
Ok(c) => c,
|
|
Err(_) => return -1,
|
|
};
|
|
let ptr = match env.get_direct_buffer_address(&buf) {
|
|
Ok(p) if !p.is_null() => p,
|
|
_ => return -1,
|
|
};
|
|
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call.
|
|
let out = unsafe { std::slice::from_raw_parts_mut(ptr, cap) };
|
|
|
|
let n = match ev {
|
|
HidOutput::Led { r, g, b, .. } => {
|
|
if cap < 4 {
|
|
return -1;
|
|
}
|
|
out[0] = TAG_LED;
|
|
out[1] = r;
|
|
out[2] = g;
|
|
out[3] = b;
|
|
4
|
|
}
|
|
HidOutput::PlayerLeds { bits, .. } => {
|
|
if cap < 2 {
|
|
return -1;
|
|
}
|
|
out[0] = TAG_PLAYER_LEDS;
|
|
out[1] = bits;
|
|
2
|
|
}
|
|
HidOutput::Trigger { which, effect, .. } => {
|
|
let n = 2 + effect.len();
|
|
if cap < n {
|
|
return -1; // the raw DS5 trigger block is ~11 bytes; Kotlin allocates 64
|
|
}
|
|
out[0] = TAG_TRIGGER;
|
|
out[1] = which;
|
|
out[2..n].copy_from_slice(&effect);
|
|
n
|
|
}
|
|
HidOutput::TrackpadHaptic { .. } => {
|
|
// Steam Controller trackpad-coil haptics — no Android equivalent; drop it (motor
|
|
// rumble already rides the universal 0xCA plane).
|
|
return -1;
|
|
}
|
|
};
|
|
n as jint
|
|
})
|
|
}
|