bfd64ce871
ci / rust (push) Has been cancelled
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>
204 lines
8.2 KiB
Rust
204 lines
8.2 KiB
Rust
//! 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);
|
||
}
|
||
}
|