Files
punktfunk/crates/punktfunk-host/src/gamestream/gamepad.rs
T
enricobuehler bfd64ce871
ci / rust (push) Has been cancelled
rename: lumen → punktfunk, everywhere
Full project rename, decided 2026-06-10:
- Crates/binaries: punktfunk-core / punktfunk-host / punktfunk-client-rs.
- C ABI: punktfunk_* symbols, Punktfunk* types, include/punktfunk_core.h,
  PUNKTFUNK_FEATURE_QUIC guard (header regenerated; cbindgen renames updated, incl.
  PUNKTFUNK_BTN_*/PUNKTFUNK_AXIS_* wire constants).
- Protocol: punktfunk/1 — control-plane magic LMN1 → PKF1, nonce salt lmn1 → pkf1.
  WIRE BREAK: clients must be rebuilt from this revision.
- Env knobs: PUNKTFUNK_VIDEO_SOURCE / PUNKTFUNK_COMPOSITOR / PUNKTFUNK_ZEROCOPY / ….
- Host config dir: ~/.config/punktfunk (the box's dir was migrated in place — the
  persistent identity is unchanged, pinned fingerprints stay valid).
- Swift package: PunktfunkKit + PunktfunkCore.xcframework + PunktfunkConnection
  (Sources/PunktfunkClient app + tests renamed with it); build-xcframework.sh updated.
- scripts/: 60-punktfunk.rules, punktfunk-host.service; OpenAPI doc regenerated.

Also: scripts/headless/run-headless-kde.sh — full headless Plasma bringup. Root cause of
"desktop but no apps/settings" over the stream: plasmashell launched without
XDG_MENU_PREFIX=plasma-, so the launcher resolved a nonexistent applications.menu and
rendered an empty menu. The script sets the complete KDE session env (menu prefix,
KDE_FULL_SESSION, session version) and rebuilds ksycoca before starting plasmashell.

Gate: 97/97 tests, clippy -D warnings (both feature sets), fmt, C-ABI harness PASS,
zero lumen references left outside .git.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:11:59 +00:00

204 lines
8.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Decode GameStream controller packets (carried on the same encrypted control stream as
//! mouse/keyboard — see [`super::input`]) into [`GamepadFrame`]s for the uinput virtual pads.
//!
//! Layouts mirror moonlight-common-c `Input.h` (all `#pragma pack(1)`; the `size` header field
//! is big-endian, everything else little-endian). We implement the Gen5+ `MULTI_CONTROLLER`
//! event (magic `0x0C`) — the only controller event Sunshine-class hosts receive — plus the
//! Sunshine-extension `CONTROLLER_ARRIVAL` (`0x55000004`). Because our serverinfo advertises a
//! Sunshine appversion (4th component negative), clients also send `buttonFlags2` (paddles /
//! touchpad-click / Share) inside the MC packet.
/// Inner control-message type for input (same as [`super::input`]).
const INPUT_DATA_TYPE: u16 = 0x0206;
/// `NV_INPUT_HEADER.magic` for the Gen5+ multi-controller event.
const MAGIC_MULTI_CONTROLLER: u32 = 0x0C;
/// Sunshine extension: controller arrival metadata (type/capabilities).
const MAGIC_CONTROLLER_ARRIVAL: u32 = 0x5500_0004;
/// Most controllers a session tracks (Sunshine's MAX_GAMEPADS).
pub const MAX_PADS: usize = 16;
/// One decoded controller event.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GamepadEvent {
/// Full state of one controller + the set of attached controllers.
State(GamepadFrame),
/// Sunshine arrival metadata (precedes the first State for that pad).
Arrival {
index: u8,
/// 0 unknown, 1 xbox, 2 ps, 3 nintendo.
kind: u8,
/// LI_CCAP_* bits (0x02 = rumble).
capabilities: u16,
},
}
/// Snapshot of one controller's inputs (Moonlight conventions: sticks 32768..32767 with +Y
/// up, triggers 0..255, buttons = `buttonFlags | buttonFlags2 << 16`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct GamepadFrame {
pub index: i16,
/// Bit n set = controller n attached; a clear bit for an allocated pad means unplug.
pub active_mask: u16,
pub buttons: u32,
pub left_trigger: u8,
pub right_trigger: u8,
pub ls_x: i16,
pub ls_y: i16,
pub rs_x: i16,
pub rs_y: i16,
}
// buttonFlags bits (Limelight.h).
pub const BTN_DPAD_UP: u32 = 0x0001;
pub const BTN_DPAD_DOWN: u32 = 0x0002;
pub const BTN_DPAD_LEFT: u32 = 0x0004;
pub const BTN_DPAD_RIGHT: u32 = 0x0008;
pub const BTN_START: u32 = 0x0010;
pub const BTN_BACK: u32 = 0x0020;
pub const BTN_LS_CLK: u32 = 0x0040;
pub const BTN_RS_CLK: u32 = 0x0080;
pub const BTN_LB: u32 = 0x0100;
pub const BTN_RB: u32 = 0x0200;
pub const BTN_GUIDE: u32 = 0x0400;
pub const BTN_A: u32 = 0x1000;
pub const BTN_B: u32 = 0x2000;
pub const BTN_X: u32 = 0x4000;
pub const BTN_Y: u32 = 0x8000;
/// Decode one decrypted control plaintext into a controller event, if it is one. Mouse,
/// keyboard, keepalives etc. yield `None` (they're handled by [`super::input::decode`]).
pub fn decode(plaintext: &[u8]) -> Option<GamepadEvent> {
if plaintext.len() < 4 || u16::from_le_bytes([plaintext[0], plaintext[1]]) != INPUT_DATA_TYPE {
return None;
}
let p = &plaintext[4..];
if p.len() < 8 {
return None;
}
let magic = u32::from_le_bytes([p[4], p[5], p[6], p[7]]);
let b = &p[8..]; // body after NV_INPUT_HEADER
let le16 = |o: usize| -> Option<i16> { Some(i16::from_le_bytes([*b.get(o)?, *b.get(o + 1)?])) };
match magic {
MAGIC_MULTI_CONTROLLER => {
// Body: headerB@0, controllerNumber@2, activeGamepadMask@4, midB@6, buttonFlags@8,
// LT@10, RT@11, lsX@12, lsY@14, rsX@16, rsY@18, tailA@20, buttonFlags2@22, tailB@24.
// The constants (headerB/midB/tail*) are never validated, mirroring Sunshine.
let buttons_lo = le16(8)? as u16 as u32;
// buttonFlags2 is absent on pre-extension clients (shorter packet) — treat as 0.
let buttons_hi = le16(22).map(|v| v as u16 as u32).unwrap_or(0);
Some(GamepadEvent::State(GamepadFrame {
index: le16(2)?,
active_mask: le16(4)? as u16,
buttons: buttons_lo | (buttons_hi << 16),
left_trigger: *b.get(10)?,
right_trigger: *b.get(11)?,
ls_x: le16(12)?,
ls_y: le16(14)?,
rs_x: le16(16)?,
rs_y: le16(18)?,
}))
}
MAGIC_CONTROLLER_ARRIVAL => Some(GamepadEvent::Arrival {
index: *b.first()?,
kind: *b.get(1)?,
capabilities: le16(2)? as u16,
}),
_ => None,
}
}
/// Build the host→client rumble plaintext (type `0x010B`): `[type][len=10][u32 filler]
/// [controllerNumber][lowFreqMotor][highFreqMotor]` (all LE; motors 0..0xFFFF). The caller
/// seals it with the host-direction GCM scheme and sends it on the ENet control peer.
pub fn rumble_plaintext(index: u16, low: u16, high: u16) -> Vec<u8> {
let mut pt = Vec::with_capacity(14);
pt.extend_from_slice(&0x010Bu16.to_le_bytes());
pt.extend_from_slice(&10u16.to_le_bytes());
pt.extend_from_slice(&0x00C0_FFEEu32.to_le_bytes()); // filler — present but ignored
pt.extend_from_slice(&index.to_le_bytes());
pt.extend_from_slice(&low.to_le_bytes());
pt.extend_from_slice(&high.to_le_bytes());
pt
}
#[cfg(test)]
mod tests {
use super::*;
fn wrap(magic: u32, body: &[u8]) -> Vec<u8> {
let mut inp = Vec::new();
inp.extend_from_slice(&((4 + body.len()) as u32).to_be_bytes());
inp.extend_from_slice(&magic.to_le_bytes());
inp.extend_from_slice(body);
let mut pt = Vec::new();
pt.extend_from_slice(&INPUT_DATA_TYPE.to_le_bytes());
pt.extend_from_slice(&(inp.len() as u16).to_le_bytes());
pt.extend_from_slice(&inp);
pt
}
#[test]
fn decodes_multi_controller() {
// Pad 1 attached (mask 0b10), A+RB held, LT=10 RT=200, LS=(1000,-2000), RS=(-1,32767),
// paddle1 via buttonFlags2.
let mut body = Vec::new();
body.extend_from_slice(&0x001Ai16.to_le_bytes()); // headerB
body.extend_from_slice(&1i16.to_le_bytes()); // controllerNumber
body.extend_from_slice(&0b10i16.to_le_bytes()); // activeGamepadMask
body.extend_from_slice(&0x0014i16.to_le_bytes()); // midB
body.extend_from_slice(&((BTN_A | BTN_RB) as u16).to_le_bytes());
body.push(10); // LT
body.push(200); // RT
body.extend_from_slice(&1000i16.to_le_bytes());
body.extend_from_slice(&(-2000i16).to_le_bytes());
body.extend_from_slice(&(-1i16).to_le_bytes());
body.extend_from_slice(&32767i16.to_le_bytes());
body.extend_from_slice(&0x009Ci16.to_le_bytes()); // tailA
body.extend_from_slice(&0x0001u16.to_le_bytes()); // buttonFlags2 (paddle1)
body.extend_from_slice(&0x0055i16.to_le_bytes()); // tailB
let Some(GamepadEvent::State(f)) = decode(&wrap(MAGIC_MULTI_CONTROLLER, &body)) else {
panic!("expected State");
};
assert_eq!(f.index, 1);
assert_eq!(f.active_mask, 0b10);
assert_eq!(f.buttons, BTN_A | BTN_RB | 0x0001_0000);
assert_eq!((f.left_trigger, f.right_trigger), (10, 200));
assert_eq!((f.ls_x, f.ls_y, f.rs_x, f.rs_y), (1000, -2000, -1, 32767));
}
#[test]
fn decodes_arrival() {
let body = [0u8, 1, 0x02, 0x00, 0xFF, 0xFF, 0x0F, 0x00]; // pad 0, xbox, rumble cap
let Some(GamepadEvent::Arrival {
index,
kind,
capabilities,
}) = decode(&wrap(MAGIC_CONTROLLER_ARRIVAL, &body))
else {
panic!("expected Arrival");
};
assert_eq!((index, kind, capabilities), (0, 1, 0x0002));
}
#[test]
fn ignores_mouse_and_short_packets() {
assert!(decode(&wrap(0x07, &[0, 1, 0, 2])).is_none()); // relative mouse
assert!(decode(&[0u8; 3]).is_none());
}
#[test]
fn rumble_layout() {
let pt = rumble_plaintext(2, 0x1234, 0xBEEF);
assert_eq!(pt.len(), 14);
assert_eq!(u16::from_le_bytes([pt[0], pt[1]]), 0x010B);
assert_eq!(u16::from_le_bytes([pt[2], pt[3]]), 10);
assert_eq!(u16::from_le_bytes([pt[8], pt[9]]), 2);
assert_eq!(u16::from_le_bytes([pt[10], pt[11]]), 0x1234);
assert_eq!(u16::from_le_bytes([pt[12], pt[13]]), 0xBEEF);
}
}