feat: M2 — gamepad input: uinput virtual X-Box pads + rumble back-channel (Phase 1)
Full controller path for the SteamOS-like session, mirroring Sunshine byte-for-byte
(wire formats verified against moonlight-common-c + Sunshine source; ioctl numbers and
struct layouts verified by compiling against this box's <linux/uinput.h>, locked in with
const asserts):
- gamestream/gamepad.rs: decode MULTI_CONTROLLER (magic 0x0C, mixed BE-size/LE-body) incl.
the Sunshine buttonFlags2 extension (paddles/touchpad/Misc — our appversion already
advertises Sunshine, so clients send it) and CONTROLLER_ARRIVAL (0x55000004); build the
0x010B rumble plaintext (with the mandatory 4-byte filler). Unit-tested.
- inject/gamepad.rs: VirtualPad clones the kernel xpad identity ("Microsoft X-Box 360 pad",
045e:028e, exact button/axis codes + absinfo) so SDL/Steam/Proton match their built-in
mapping with zero config. GamepadManager creates/destroys pads from activeGamepadMask
(hotplug), emits button transitions + axes (+Y-up → evdev +Y-down negation, D-pad as
HAT0X/Y) per frame. Rumble: non-blocking FF pump answers UI_BEGIN/END_FF_UPLOAD/ERASE
(games block in EVIOCSFF until answered), tracks effects with replay expiry + FF_GAIN,
mixes to (low, high) motor levels, dedups.
- control.rs: channel_limit 8 → 0x30 — Moonlight sends gamepad input on ENet channel
0x10+n, so the old limit silently discarded ALL controller input. Gamepad events route to
the manager; rumble is sealed with the client's detected GCM scheme direction-flipped
(V2 marker 'H?', own seq counter) and sent on the control peer every service tick.
- scripts/60-lumen.rules: udev rule (Sunshine-style) granting the input group /dev/uinput.
Live validation needs the udev rule installed (root-only /dev/uinput on this box) + a
Moonlight client with a controller; everything else is gated and unit/static-checked.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,9 +23,10 @@
|
||||
//! Runs on its own native thread for the host's lifetime.
|
||||
|
||||
use super::{AppState, CONTROL_PORT};
|
||||
use crate::inject::gamepad::GamepadManager;
|
||||
use crate::inject::InputInjector;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use rusty_enet::{Event, Host, HostSettings};
|
||||
use rusty_enet::{Event, Host, HostSettings, Packet, PeerID};
|
||||
use std::net::UdpSocket;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -40,7 +41,9 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
||||
socket,
|
||||
HostSettings {
|
||||
peer_limit: 4,
|
||||
channel_limit: 8,
|
||||
// Moonlight connects with CTRL_CHANNEL_COUNT (0x30) channels and sends gamepad
|
||||
// input on channel 0x10+n — a smaller limit silently discards controller input.
|
||||
channel_limit: 0x30,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
@@ -56,16 +59,24 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
||||
let mut detected: Option<Scheme> = None;
|
||||
// Lazily opened on the first input event (Sway's Wayland socket is up by then).
|
||||
let mut injector: Option<Box<dyn InputInjector>> = None;
|
||||
// Virtual gamepads (uinput) + the host→client rumble sequence counter.
|
||||
let mut pads = GamepadManager::new();
|
||||
let mut rumble_seq: u32 = 0;
|
||||
let mut peer: Option<PeerID> = None;
|
||||
loop {
|
||||
loop {
|
||||
match host.service() {
|
||||
Ok(Some(event)) => match event {
|
||||
Event::Connect { .. } => {
|
||||
Event::Connect { peer: p, .. } => {
|
||||
tracing::info!("control: client connected");
|
||||
peer = Some(p.id());
|
||||
}
|
||||
Event::Disconnect { .. } => {
|
||||
tracing::info!("control: client disconnected");
|
||||
detected = None;
|
||||
peer = None;
|
||||
// Unplug the session's virtual pads.
|
||||
pads = GamepadManager::new();
|
||||
}
|
||||
Event::Receive {
|
||||
channel_id, packet, ..
|
||||
@@ -76,6 +87,7 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
||||
packet.data(),
|
||||
&mut detected,
|
||||
&mut injector,
|
||||
&mut pads,
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -86,6 +98,28 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Service the pads' force-feedback protocol every tick (games block inside
|
||||
// EVIOCSFF until answered) and relay mixed rumble levels to the client.
|
||||
if let (Some(pid), Some(scheme)) = (peer, detected) {
|
||||
let key = state.launch.lock().unwrap().map(|s| s.gcm_key);
|
||||
if let Some(key) = key {
|
||||
let mut out: Vec<Vec<u8>> = Vec::new();
|
||||
pads.pump_rumble(|index, low, high| {
|
||||
let pt = super::gamepad::rumble_plaintext(index, low, high);
|
||||
out.push(encrypt_control(&key, &scheme, rumble_seq, &pt));
|
||||
rumble_seq = rumble_seq.wrapping_add(1);
|
||||
});
|
||||
for wire in out {
|
||||
if let Err(e) = host.peer_mut(pid).send(0, &Packet::reliable(&wire[..]))
|
||||
{
|
||||
tracing::warn!(error = %format!("{e:?}"), "rumble send failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No client/scheme yet: still answer FF uploads so games don't block.
|
||||
pads.pump_rumble(|_, _, _| {});
|
||||
}
|
||||
// ENet needs frequent servicing for handshake/keepalive/retransmit.
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
@@ -102,6 +136,7 @@ fn on_receive(
|
||||
d: &[u8],
|
||||
detected: &mut Option<Scheme>,
|
||||
injector: &mut Option<Box<dyn InputInjector>>,
|
||||
pads: &mut GamepadManager,
|
||||
) {
|
||||
let Some(key) = state.launch.lock().unwrap().map(|s| s.gcm_key) else {
|
||||
return; // control traffic before /launch — no key yet
|
||||
@@ -141,6 +176,12 @@ fn on_receive(
|
||||
}
|
||||
}
|
||||
|
||||
// Controller events go to the uinput virtual pads (created on demand per the mask).
|
||||
if let Some(gp) = super::gamepad::decode(&pt) {
|
||||
pads.handle(&gp);
|
||||
return;
|
||||
}
|
||||
|
||||
let events = super::input::decode(&pt);
|
||||
if events.is_empty() {
|
||||
return; // keepalive / QoS / unhandled input kind
|
||||
@@ -310,6 +351,60 @@ fn decrypt_control(
|
||||
None
|
||||
}
|
||||
|
||||
/// Seal a host→client control message, mirroring the client's `detected` scheme with the
|
||||
/// direction flipped: V2 nonces use marker `H?` (host-originated) instead of `C?`; legacy
|
||||
/// nonces keep their construction with our own independent `seq` counter. Wire layout matches
|
||||
/// what the client sends us: `[0x0001][length][seq][tag|ct per scheme.tag_first]`.
|
||||
fn encrypt_control(key: &[u8; 16], scheme: &Scheme, seq: u32, pt: &[u8]) -> Vec<u8> {
|
||||
let nonce_kind = match scheme.nonce {
|
||||
NonceKind::V2 { seq_be, marker } => NonceKind::V2 {
|
||||
seq_be,
|
||||
marker: [b'H', marker[1]],
|
||||
},
|
||||
other => other,
|
||||
};
|
||||
let length = (4 + 16 + pt.len()) as u16;
|
||||
let mut wire = Vec::with_capacity(8 + 16 + pt.len());
|
||||
wire.extend_from_slice(&0x0001u16.to_le_bytes());
|
||||
wire.extend_from_slice(&length.to_le_bytes());
|
||||
wire.extend_from_slice(&seq.to_le_bytes());
|
||||
let aad: Vec<u8> = match scheme.aad {
|
||||
Aad::None => Vec::new(),
|
||||
Aad::Header4 => wire[0..4].to_vec(),
|
||||
};
|
||||
let ct_tag = gcm_seal(&scheme.key(key), &nonce_kind.nonce(seq), pt, &aad);
|
||||
let (ct, tag) = ct_tag.split_at(ct_tag.len() - 16);
|
||||
if scheme.tag_first {
|
||||
wire.extend_from_slice(tag);
|
||||
wire.extend_from_slice(ct);
|
||||
} else {
|
||||
wire.extend_from_slice(ct);
|
||||
wire.extend_from_slice(tag);
|
||||
}
|
||||
wire
|
||||
}
|
||||
|
||||
/// AES-128-GCM seal (companion to [`gcm_open`]); returns `ciphertext || tag`.
|
||||
fn gcm_seal(key: &[u8; 16], nonce: &[u8], pt: &[u8], aad: &[u8]) -> Vec<u8> {
|
||||
use aes_gcm::aead::consts::{U12, U16};
|
||||
use aes_gcm::aead::generic_array::GenericArray;
|
||||
use aes_gcm::aead::{Aead, KeyInit, Payload};
|
||||
use aes_gcm::{aes::Aes128, AesGcm};
|
||||
|
||||
let p = Payload { msg: pt, aad };
|
||||
match nonce.len() {
|
||||
12 => AesGcm::<Aes128, U12>::new_from_slice(key)
|
||||
.unwrap()
|
||||
.encrypt(GenericArray::from_slice(nonce), p)
|
||||
.expect("GCM seal"),
|
||||
16 => AesGcm::<Aes128, U16>::new_from_slice(key)
|
||||
.unwrap()
|
||||
.encrypt(GenericArray::from_slice(nonce), p)
|
||||
.expect("GCM seal"),
|
||||
_ => unreachable!("nonce length"),
|
||||
}
|
||||
}
|
||||
|
||||
/// AES-128-GCM open with a 12- or 16-byte nonce and explicit AAD. Returns the plaintext iff
|
||||
/// the tag authenticates. `ct_tag` is `ciphertext || tag` (aes-gcm's expected order).
|
||||
fn gcm_open(key: &[u8; 16], nonce: &[u8], ct_tag: &[u8], aad: &[u8]) -> Option<Vec<u8>> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ mod audio;
|
||||
mod cert;
|
||||
mod control;
|
||||
mod crypto;
|
||||
pub mod gamepad;
|
||||
mod input;
|
||||
mod mdns;
|
||||
mod nvhttp;
|
||||
|
||||
@@ -251,6 +251,21 @@ fn gs_button_to_evdev(b: u32) -> Option<u32> {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod gamepad;
|
||||
/// Stub — virtual gamepads need Linux uinput; events are dropped elsewhere.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub mod gamepad {
|
||||
#[derive(Default)]
|
||||
pub struct GamepadManager;
|
||||
impl GamepadManager {
|
||||
pub fn new() -> Self {
|
||||
GamepadManager
|
||||
}
|
||||
pub fn handle(&mut self, _ev: &crate::gamestream::gamepad::GamepadEvent) {}
|
||||
pub fn pump_rumble(&mut self, _send: impl FnMut(u16, u16, u16)) {}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
mod libei;
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
//! Virtual gamepads via `/dev/uinput`, cloning the kernel `xpad` identity ("Microsoft X-Box
|
||||
//! 360 pad", `045e:028e`) so SDL/Steam/Proton match their built-in mapping with zero
|
||||
//! configuration — exactly what Sunshine emulates. One [`VirtualPad`] per attached client
|
||||
//! controller, managed by [`GamepadManager`] from decoded
|
||||
//! [`GamepadFrame`](crate::gamestream::gamepad::GamepadFrame)s.
|
||||
//!
|
||||
//! Rumble flows the *other* way on the same fd: games upload force-feedback effects
|
||||
//! (`EV_UINPUT`/`UI_FF_UPLOAD` → `UI_BEGIN/END_FF_UPLOAD` ioctls) and trigger them with
|
||||
//! `EV_FF` writes; [`GamepadManager::pump_rumble`] services that protocol non-blockingly
|
||||
//! (the control thread calls it every tick) and reports mixed `(low, high)` motor levels for
|
||||
//! the host to send to the client. Note: a game's `EVIOCSFF` ioctl BLOCKS until we answer
|
||||
//! `UI_END_FF_UPLOAD`, so the pump must run regularly.
|
||||
//!
|
||||
//! All ioctl numbers/struct layouts below were verified against this generation's
|
||||
//! `<linux/uinput.h>` on x86_64. `/dev/uinput` needs a udev rule + `input` group membership
|
||||
//! (see `scripts/60-lumen.rules`); creation fails with a clear error otherwise.
|
||||
|
||||
use crate::gamestream::gamepad::{self, GamepadFrame, MAX_PADS};
|
||||
use anyhow::{bail, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::os::fd::{AsRawFd, OwnedFd};
|
||||
use std::time::Instant;
|
||||
|
||||
// ioctls (x86_64).
|
||||
const UI_DEV_CREATE: libc::c_ulong = 0x5501;
|
||||
const UI_DEV_DESTROY: libc::c_ulong = 0x5502;
|
||||
const UI_DEV_SETUP: libc::c_ulong = 0x405c_5503;
|
||||
const UI_ABS_SETUP: libc::c_ulong = 0x401c_5504;
|
||||
const UI_SET_EVBIT: libc::c_ulong = 0x4004_5564;
|
||||
const UI_SET_KEYBIT: libc::c_ulong = 0x4004_5565;
|
||||
const UI_SET_FFBIT: libc::c_ulong = 0x4004_556b;
|
||||
const UI_BEGIN_FF_UPLOAD: libc::c_ulong = 0xc068_55c8;
|
||||
const UI_END_FF_UPLOAD: libc::c_ulong = 0x4068_55c9;
|
||||
const UI_BEGIN_FF_ERASE: libc::c_ulong = 0xc00c_55ca;
|
||||
const UI_END_FF_ERASE: libc::c_ulong = 0x400c_55cb;
|
||||
|
||||
// Event types/codes.
|
||||
const EV_SYN: u16 = 0x00;
|
||||
const EV_KEY: u16 = 0x01;
|
||||
const EV_ABS: u16 = 0x03;
|
||||
const EV_FF: u16 = 0x15;
|
||||
const EV_UINPUT: u16 = 0x0101;
|
||||
const SYN_REPORT: u16 = 0;
|
||||
const UI_FF_UPLOAD: u16 = 1;
|
||||
const UI_FF_ERASE: u16 = 2;
|
||||
const FF_RUMBLE: u16 = 0x50;
|
||||
const FF_GAIN: u16 = 0x60;
|
||||
|
||||
const ABS_X: u16 = 0x00;
|
||||
const ABS_Y: u16 = 0x01;
|
||||
const ABS_Z: u16 = 0x02;
|
||||
const ABS_RX: u16 = 0x03;
|
||||
const ABS_RY: u16 = 0x04;
|
||||
const ABS_RZ: u16 = 0x05;
|
||||
const ABS_HAT0X: u16 = 0x10;
|
||||
const ABS_HAT0Y: u16 = 0x11;
|
||||
|
||||
const BTN_SOUTH: u16 = 0x130; // A
|
||||
const BTN_EAST: u16 = 0x131; // B
|
||||
const BTN_NORTH: u16 = 0x133; // X (kernel calls it BTN_NORTH/BTN_X)
|
||||
const BTN_WEST: u16 = 0x134; // Y
|
||||
const BTN_TL: u16 = 0x136;
|
||||
const BTN_TR: u16 = 0x137;
|
||||
const BTN_SELECT: u16 = 0x13a;
|
||||
const BTN_START: u16 = 0x13b;
|
||||
const BTN_MODE: u16 = 0x13c;
|
||||
const BTN_THUMBL: u16 = 0x13d;
|
||||
const BTN_THUMBR: u16 = 0x13e;
|
||||
|
||||
/// `(GameStream button bit, evdev key code)` — D-pad is emitted as HAT axes instead.
|
||||
const BUTTON_MAP: [(u32, u16); 11] = [
|
||||
(gamepad::BTN_A, BTN_SOUTH),
|
||||
(gamepad::BTN_B, BTN_EAST),
|
||||
(gamepad::BTN_X, BTN_NORTH),
|
||||
(gamepad::BTN_Y, BTN_WEST),
|
||||
(gamepad::BTN_LB, BTN_TL),
|
||||
(gamepad::BTN_RB, BTN_TR),
|
||||
(gamepad::BTN_BACK, BTN_SELECT),
|
||||
(gamepad::BTN_START, BTN_START),
|
||||
(gamepad::BTN_GUIDE, BTN_MODE),
|
||||
(gamepad::BTN_LS_CLK, BTN_THUMBL),
|
||||
(gamepad::BTN_RS_CLK, BTN_THUMBR),
|
||||
];
|
||||
|
||||
#[repr(C)]
|
||||
struct InputId {
|
||||
bustype: u16,
|
||||
vendor: u16,
|
||||
product: u16,
|
||||
version: u16,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct UinputSetup {
|
||||
id: InputId,
|
||||
name: [u8; 80],
|
||||
ff_effects_max: u32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Default, Clone, Copy)]
|
||||
struct AbsInfo {
|
||||
value: i32,
|
||||
minimum: i32,
|
||||
maximum: i32,
|
||||
fuzz: i32,
|
||||
flat: i32,
|
||||
resolution: i32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct UinputAbsSetup {
|
||||
code: u16,
|
||||
_pad: u16,
|
||||
absinfo: AbsInfo,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct InputEventRaw {
|
||||
time: libc::timeval,
|
||||
type_: u16,
|
||||
code: u16,
|
||||
value: i32,
|
||||
}
|
||||
|
||||
/// `struct ff_effect` (48 bytes; the union starts 8-aligned at offset 16).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct FfEffect {
|
||||
type_: u16,
|
||||
id: i16,
|
||||
direction: u16,
|
||||
trigger_button: u16,
|
||||
trigger_interval: u16,
|
||||
replay_length: u16,
|
||||
replay_delay: u16,
|
||||
_pad: u16,
|
||||
/// Union; for `FF_RUMBLE`: `u16 strong_magnitude` at [0..2], `u16 weak_magnitude` at [2..4].
|
||||
u: [u8; 32],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct UinputFfUpload {
|
||||
request_id: u32,
|
||||
retval: i32,
|
||||
effect: FfEffect,
|
||||
old: FfEffect,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct UinputFfErase {
|
||||
request_id: u32,
|
||||
retval: i32,
|
||||
effect_id: u32,
|
||||
}
|
||||
|
||||
// Layouts verified by compiling a probe against this generation's <linux/uinput.h> (x86_64).
|
||||
const _: () = {
|
||||
assert!(std::mem::size_of::<UinputSetup>() == 92);
|
||||
assert!(std::mem::size_of::<UinputAbsSetup>() == 28);
|
||||
assert!(std::mem::size_of::<InputEventRaw>() == 24);
|
||||
assert!(std::mem::size_of::<FfEffect>() == 48);
|
||||
assert!(std::mem::size_of::<UinputFfUpload>() == 104);
|
||||
assert!(std::mem::size_of::<UinputFfErase>() == 12);
|
||||
};
|
||||
|
||||
fn ioctl_int(fd: i32, req: libc::c_ulong, arg: libc::c_int, what: &str) -> Result<()> {
|
||||
if unsafe { libc::ioctl(fd, req, arg) } < 0 {
|
||||
bail!("{what}: {}", std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ioctl_ptr<T>(fd: i32, req: libc::c_ulong, arg: *mut T, what: &str) -> Result<()> {
|
||||
if unsafe { libc::ioctl(fd, req, arg) } < 0 {
|
||||
bail!("{what}: {}", std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One FF effect a game uploaded: rumble magnitudes + playback state.
|
||||
struct Effect {
|
||||
strong: u16,
|
||||
weak: u16,
|
||||
/// `Some(deadline)` while playing (replay length 0 = until stopped).
|
||||
playing: Option<Option<Instant>>,
|
||||
replay_ms: u16,
|
||||
}
|
||||
|
||||
/// One virtual X-Box-360 pad backed by a uinput device.
|
||||
pub struct VirtualPad {
|
||||
fd: OwnedFd,
|
||||
prev_buttons: u32,
|
||||
effects: HashMap<i16, Effect>,
|
||||
next_effect_id: i16,
|
||||
gain: u32,
|
||||
/// Last `(low, high)` reported, to dedup.
|
||||
last_mix: (u16, u16),
|
||||
}
|
||||
|
||||
impl VirtualPad {
|
||||
pub fn create(index: usize) -> Result<VirtualPad> {
|
||||
use std::os::fd::FromRawFd;
|
||||
let raw = unsafe {
|
||||
libc::open(
|
||||
c"/dev/uinput".as_ptr(),
|
||||
libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC,
|
||||
)
|
||||
};
|
||||
if raw < 0 {
|
||||
bail!(
|
||||
"open /dev/uinput: {} (install the udev rule granting the 'input' group access \
|
||||
— see scripts/60-lumen.rules — and add the user to the 'input' group)",
|
||||
std::io::Error::last_os_error()
|
||||
);
|
||||
}
|
||||
let fd = unsafe { OwnedFd::from_raw_fd(raw) };
|
||||
|
||||
ioctl_int(raw, UI_SET_EVBIT, EV_KEY as i32, "UI_SET_EVBIT(EV_KEY)")?;
|
||||
ioctl_int(raw, UI_SET_EVBIT, EV_ABS as i32, "UI_SET_EVBIT(EV_ABS)")?;
|
||||
ioctl_int(raw, UI_SET_EVBIT, EV_FF as i32, "UI_SET_EVBIT(EV_FF)")?;
|
||||
for (_, key) in BUTTON_MAP {
|
||||
ioctl_int(raw, UI_SET_KEYBIT, key as i32, "UI_SET_KEYBIT")?;
|
||||
}
|
||||
ioctl_int(
|
||||
raw,
|
||||
UI_SET_FFBIT,
|
||||
FF_RUMBLE as i32,
|
||||
"UI_SET_FFBIT(FF_RUMBLE)",
|
||||
)?;
|
||||
ioctl_int(raw, UI_SET_FFBIT, FF_GAIN as i32, "UI_SET_FFBIT(FF_GAIN)")?;
|
||||
|
||||
let stick = AbsInfo {
|
||||
minimum: -32768,
|
||||
maximum: 32767,
|
||||
fuzz: 16,
|
||||
flat: 128,
|
||||
..Default::default()
|
||||
};
|
||||
let trigger = AbsInfo {
|
||||
minimum: 0,
|
||||
maximum: 255,
|
||||
..Default::default()
|
||||
};
|
||||
let hat = AbsInfo {
|
||||
minimum: -1,
|
||||
maximum: 1,
|
||||
..Default::default()
|
||||
};
|
||||
for (code, info) in [
|
||||
(ABS_X, stick),
|
||||
(ABS_Y, stick),
|
||||
(ABS_RX, stick),
|
||||
(ABS_RY, stick),
|
||||
(ABS_Z, trigger),
|
||||
(ABS_RZ, trigger),
|
||||
(ABS_HAT0X, hat),
|
||||
(ABS_HAT0Y, hat),
|
||||
] {
|
||||
let mut a = UinputAbsSetup {
|
||||
code,
|
||||
_pad: 0,
|
||||
absinfo: info,
|
||||
};
|
||||
ioctl_ptr(raw, UI_ABS_SETUP, &mut a, "UI_ABS_SETUP")?;
|
||||
}
|
||||
|
||||
// The xpad identity: SDL keys its built-in mapping off bustype/vendor/product/version.
|
||||
let mut setup = UinputSetup {
|
||||
id: InputId {
|
||||
bustype: 0x0003, // BUS_USB
|
||||
vendor: 0x045e,
|
||||
product: 0x028e,
|
||||
version: 0x0110,
|
||||
},
|
||||
name: [0; 80],
|
||||
ff_effects_max: 16, // must be > 0 or FF uploads are never delivered
|
||||
};
|
||||
let name = b"Microsoft X-Box 360 pad";
|
||||
setup.name[..name.len()].copy_from_slice(name);
|
||||
ioctl_ptr(raw, UI_DEV_SETUP, &mut setup, "UI_DEV_SETUP")?;
|
||||
ioctl_int(raw, UI_DEV_CREATE, 0, "UI_DEV_CREATE")?;
|
||||
tracing::info!(index, "virtual gamepad created (X-Box 360 pad via uinput)");
|
||||
|
||||
Ok(VirtualPad {
|
||||
fd,
|
||||
prev_buttons: 0,
|
||||
effects: HashMap::new(),
|
||||
next_effect_id: 0,
|
||||
gain: 0xFFFF,
|
||||
last_mix: (0, 0),
|
||||
})
|
||||
}
|
||||
|
||||
fn emit(&self, type_: u16, code: u16, value: i32) {
|
||||
let ev = InputEventRaw {
|
||||
time: libc::timeval {
|
||||
tv_sec: 0,
|
||||
tv_usec: 0,
|
||||
},
|
||||
type_,
|
||||
code,
|
||||
value,
|
||||
};
|
||||
let bytes = unsafe {
|
||||
std::slice::from_raw_parts(
|
||||
&ev as *const _ as *const u8,
|
||||
std::mem::size_of::<InputEventRaw>(),
|
||||
)
|
||||
};
|
||||
// Best-effort: a full kernel queue drops the event; the next frame re-syncs state.
|
||||
let _ = unsafe {
|
||||
libc::write(
|
||||
self.fd.as_raw_fd(),
|
||||
bytes.as_ptr() as *const libc::c_void,
|
||||
bytes.len(),
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/// Apply one decoded frame: button transitions, axes, D-pad hat, one SYN_REPORT.
|
||||
pub fn apply(&mut self, f: &GamepadFrame) {
|
||||
let changed = self.prev_buttons ^ f.buttons;
|
||||
for (bit, key) in BUTTON_MAP {
|
||||
if changed & bit != 0 {
|
||||
self.emit(EV_KEY, key, ((f.buttons & bit) != 0) as i32);
|
||||
}
|
||||
}
|
||||
self.prev_buttons = f.buttons;
|
||||
|
||||
// Moonlight: +Y = up; evdev: +Y = down → negate (i32 math avoids -(-32768) overflow).
|
||||
self.emit(EV_ABS, ABS_X, f.ls_x as i32);
|
||||
self.emit(EV_ABS, ABS_Y, -(f.ls_y as i32));
|
||||
self.emit(EV_ABS, ABS_RX, f.rs_x as i32);
|
||||
self.emit(EV_ABS, ABS_RY, -(f.rs_y as i32));
|
||||
self.emit(EV_ABS, ABS_Z, f.left_trigger as i32);
|
||||
self.emit(EV_ABS, ABS_RZ, f.right_trigger as i32);
|
||||
let hat_x = ((f.buttons & gamepad::BTN_DPAD_RIGHT != 0) as i32)
|
||||
- ((f.buttons & gamepad::BTN_DPAD_LEFT != 0) as i32);
|
||||
let hat_y = ((f.buttons & gamepad::BTN_DPAD_DOWN != 0) as i32)
|
||||
- ((f.buttons & gamepad::BTN_DPAD_UP != 0) as i32);
|
||||
self.emit(EV_ABS, ABS_HAT0X, hat_x);
|
||||
self.emit(EV_ABS, ABS_HAT0Y, hat_y);
|
||||
self.emit(EV_SYN, SYN_REPORT, 0);
|
||||
}
|
||||
|
||||
/// Service the FF protocol on this pad's fd (non-blocking). Returns the new mixed
|
||||
/// `(low, high)` motor levels if they changed since last call.
|
||||
fn pump_ff(&mut self) -> Option<(u16, u16)> {
|
||||
let raw = self.fd.as_raw_fd();
|
||||
let mut buf = [0u8; std::mem::size_of::<InputEventRaw>()];
|
||||
loop {
|
||||
let n = unsafe { libc::read(raw, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
|
||||
if n != buf.len() as isize {
|
||||
break; // EAGAIN / short read — queue drained
|
||||
}
|
||||
let ev: InputEventRaw = unsafe { std::ptr::read(buf.as_ptr() as *const _) };
|
||||
match (ev.type_, ev.code) {
|
||||
(EV_UINPUT, UI_FF_UPLOAD) => {
|
||||
let mut up: UinputFfUpload = unsafe { std::mem::zeroed() };
|
||||
up.request_id = ev.value as u32;
|
||||
if ioctl_ptr(raw, UI_BEGIN_FF_UPLOAD, &mut up, "UI_BEGIN_FF_UPLOAD").is_ok() {
|
||||
let mut e = up.effect;
|
||||
if e.id == -1 {
|
||||
e.id = self.next_effect_id;
|
||||
self.next_effect_id = self.next_effect_id.wrapping_add(1);
|
||||
}
|
||||
if e.type_ == FF_RUMBLE {
|
||||
let strong = u16::from_ne_bytes([e.u[0], e.u[1]]);
|
||||
let weak = u16::from_ne_bytes([e.u[2], e.u[3]]);
|
||||
let slot = self.effects.entry(e.id).or_insert(Effect {
|
||||
strong: 0,
|
||||
weak: 0,
|
||||
playing: None,
|
||||
replay_ms: 0,
|
||||
});
|
||||
slot.strong = strong;
|
||||
slot.weak = weak;
|
||||
slot.replay_ms = e.replay_length;
|
||||
}
|
||||
up.effect.id = e.id; // hand the assigned slot back to the kernel
|
||||
up.retval = 0;
|
||||
let _ = ioctl_ptr(raw, UI_END_FF_UPLOAD, &mut up, "UI_END_FF_UPLOAD");
|
||||
}
|
||||
}
|
||||
(EV_UINPUT, UI_FF_ERASE) => {
|
||||
let mut er: UinputFfErase = unsafe { std::mem::zeroed() };
|
||||
er.request_id = ev.value as u32;
|
||||
if ioctl_ptr(raw, UI_BEGIN_FF_ERASE, &mut er, "UI_BEGIN_FF_ERASE").is_ok() {
|
||||
self.effects.remove(&(er.effect_id as i16));
|
||||
er.retval = 0;
|
||||
let _ = ioctl_ptr(raw, UI_END_FF_ERASE, &mut er, "UI_END_FF_ERASE");
|
||||
}
|
||||
}
|
||||
(EV_FF, FF_GAIN) => self.gain = (ev.value as u32).min(0xFFFF),
|
||||
(EV_FF, code) => {
|
||||
if let Some(e) = self.effects.get_mut(&(code as i16)) {
|
||||
e.playing = if ev.value != 0 {
|
||||
Some((e.replay_ms > 0).then(|| {
|
||||
Instant::now()
|
||||
+ std::time::Duration::from_millis(e.replay_ms as u64)
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Mix: sum playing effects (expiring finished ones), scale by gain.
|
||||
let now = Instant::now();
|
||||
let (mut strong, mut weak) = (0u32, 0u32);
|
||||
for e in self.effects.values_mut() {
|
||||
if let Some(deadline) = e.playing {
|
||||
if deadline.is_some_and(|d| now >= d) {
|
||||
e.playing = None;
|
||||
} else {
|
||||
strong = strong.saturating_add(e.strong as u32);
|
||||
weak = weak.saturating_add(e.weak as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Linux FF: strong = low-frequency (big) motor, weak = high-frequency motor.
|
||||
let low = ((strong.min(0xFFFF) * self.gain) >> 16) as u16;
|
||||
let high = ((weak.min(0xFFFF) * self.gain) >> 16) as u16;
|
||||
(self.last_mix != (low, high)).then(|| {
|
||||
self.last_mix = (low, high);
|
||||
(low, high)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for VirtualPad {
|
||||
fn drop(&mut self) {
|
||||
let _ = unsafe { libc::ioctl(self.fd.as_raw_fd(), UI_DEV_DESTROY, 0) };
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual pads of a session, driven from decoded controller events.
|
||||
#[derive(Default)]
|
||||
pub struct GamepadManager {
|
||||
pads: Vec<Option<VirtualPad>>,
|
||||
/// Pad creation failed (e.g. /dev/uinput permissions) — warn once, drop events.
|
||||
broken: bool,
|
||||
}
|
||||
|
||||
impl GamepadManager {
|
||||
pub fn new() -> GamepadManager {
|
||||
GamepadManager {
|
||||
pads: (0..MAX_PADS).map(|_| None).collect(),
|
||||
broken: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle one decoded controller event (create/destroy by mask, then apply state).
|
||||
pub fn handle(&mut self, ev: &crate::gamestream::gamepad::GamepadEvent) {
|
||||
use crate::gamestream::gamepad::GamepadEvent;
|
||||
match ev {
|
||||
GamepadEvent::Arrival { index, kind, .. } => {
|
||||
tracing::info!(index, kind, "controller arrival");
|
||||
self.ensure(*index as usize);
|
||||
}
|
||||
GamepadEvent::State(f) => {
|
||||
let idx = f.index as usize;
|
||||
if idx >= MAX_PADS {
|
||||
return;
|
||||
}
|
||||
// Unplugs: drop any allocated pad whose mask bit cleared.
|
||||
for (i, slot) in self.pads.iter_mut().enumerate() {
|
||||
if slot.is_some() && f.active_mask & (1 << i) == 0 {
|
||||
tracing::info!(index = i, "controller unplugged");
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
if f.active_mask & (1 << idx) == 0 {
|
||||
return; // this event WAS the unplug
|
||||
}
|
||||
self.ensure(idx);
|
||||
if let Some(pad) = self.pads[idx].as_mut() {
|
||||
pad.apply(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure(&mut self, idx: usize) {
|
||||
if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken {
|
||||
return;
|
||||
}
|
||||
match VirtualPad::create(idx) {
|
||||
Ok(p) => self.pads[idx] = Some(p),
|
||||
Err(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), "virtual gamepad creation failed — controller input disabled");
|
||||
self.broken = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Service every pad's FF protocol; `send(index, low, high)` is invoked for each pad whose
|
||||
/// mixed rumble level changed. Call frequently (games block in `EVIOCSFF` until answered).
|
||||
pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) {
|
||||
for (i, slot) in self.pads.iter_mut().enumerate() {
|
||||
if let Some(pad) = slot {
|
||||
if let Some((low, high)) = pad.pump_ff() {
|
||||
send(i as u16, low, high);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user