rename: lumen → punktfunk, everywhere
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>
This commit is contained in:
2026-06-10 13:11:59 +00:00
parent b8b23c8fb2
commit bfd64ce871
119 changed files with 1245 additions and 1185 deletions
@@ -0,0 +1,203 @@
//! 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);
}
}