feat(host+inject): pen P1 — per-session uinput virtual tablet, HOST_CAP_PEN live
The Linux injection leg of design/pen-tablet-input.md: 0xCC/0x05 pen batches now route through the per-session PenTracker into a lazily-created 'Punktfunk Pen' uinput tablet (BTN_TOOL_PEN/RUBBER, pressure, tilt-from-polar, ABS_Z barrel roll, hover distance, INPUT_PROP_DIRECT) — compositors pick it up via libinput and hand apps zwp_tablet_v2 with full fidelity. The host now advertises HOST_CAP_PEN when /dev/uinput is accessible (PUNKTFUNK_PEN=0 kill-switch); transitions group into SYN frames so proximity-enter carries its position. Stroke failsafe: clients heartbeat ≤100ms while in range (documented wire contract — capture APIs are silent for a stationary pen); 200ms of silence force-releases. 'punktfunk-host pen-test' draws a pressure-ramped sine stroke through the real tracker→uinput chain, no client needed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,75 @@
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
|
||||
/// Draw a scripted stylus stroke through the REAL pen chain — wire-shaped samples → the core
|
||||
/// [`PenTracker`](punktfunk_core::quic::PenTracker) → the "Punktfunk Pen" uinput tablet — so
|
||||
/// full-fidelity pen injection is validated without any client (design/pen-tablet-input.md P1):
|
||||
/// hover in from the left, tip down, a sine stroke across the mapped output with a pressure
|
||||
/// ramp + tilt sweep, tip up, hover out. Observe in Krita/GIMP with a pressure brush, or
|
||||
/// `sudo libinput debug-events` (expect `TABLET_TOOL_PROXIMITY/TIP/AXIS`).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn pen_test() -> Result<()> {
|
||||
use punktfunk_core::quic::{
|
||||
PenBatch, PenSample, PenTracker, PenTransition, PEN_IN_RANGE, PEN_TOUCHING,
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
let mut dev = crate::inject::pen::VirtualPen::create()?;
|
||||
let mut tracker = PenTracker::default();
|
||||
let mut out: Vec<PenTransition> = Vec::new();
|
||||
// Compositors need a beat to enumerate the new evdev node before events count.
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
|
||||
let mut seq = 0u16;
|
||||
let mut send = |tracker: &mut PenTracker, out: &mut Vec<PenTransition>, s: PenSample| {
|
||||
out.clear();
|
||||
tracker.apply(&PenBatch::new(seq, &[s]), out);
|
||||
seq = seq.wrapping_add(1);
|
||||
dev.apply_batch(out);
|
||||
};
|
||||
|
||||
tracing::info!("pen-test: hover in, then a 3 s pressure-ramped sine stroke");
|
||||
let hover = |x: f32| PenSample {
|
||||
state: PEN_IN_RANGE,
|
||||
x,
|
||||
y: 0.5,
|
||||
distance: 300,
|
||||
..Default::default()
|
||||
};
|
||||
for i in 0..20 {
|
||||
send(&mut tracker, &mut out, hover(0.05 + i as f32 * 0.005));
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
const STEPS: u32 = 360;
|
||||
for i in 0..=STEPS {
|
||||
let t = i as f32 / STEPS as f32;
|
||||
send(
|
||||
&mut tracker,
|
||||
&mut out,
|
||||
PenSample {
|
||||
state: PEN_IN_RANGE | PEN_TOUCHING,
|
||||
x: 0.15 + 0.7 * t,
|
||||
y: 0.5 + 0.2 * (t * std::f32::consts::TAU * 2.0).sin(),
|
||||
// Ramp 10 % → 100 % so a pressure brush visibly widens along the stroke.
|
||||
pressure: (6553.0 + 58982.0 * t) as u16,
|
||||
distance: 0,
|
||||
tilt_deg: 25 + (20.0 * t) as u8,
|
||||
azimuth_deg: ((90.0 + 180.0 * t) as u16) % 360,
|
||||
roll_deg: ((360.0 * t) as u16) % 360,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(8));
|
||||
}
|
||||
for i in 0..10 {
|
||||
send(&mut tracker, &mut out, hover(0.85 + i as f32 * 0.005));
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
send(&mut tracker, &mut out, PenSample::default()); // state 0 = out of range
|
||||
tracing::info!("pen-test: done (stroke drawn, pen out of range) — device destroyed on exit");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Inject a scripted mouse + keyboard pattern through the session's input backend (libei on
|
||||
/// KWin/GNOME, wlr on Sway). Lets us validate input injection without a Moonlight client.
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
@@ -307,6 +307,11 @@ fn real_main() -> Result<()> {
|
||||
// Standalone input-injection smoke test (no client needed): open the session's input
|
||||
// backend and inject a scripted mouse/keyboard pattern. Watch a focused app / `wev`.
|
||||
Some("input-test") => devtest::input_test(),
|
||||
// Standalone stylus smoke test (no client needed): create the "Punktfunk Pen" virtual
|
||||
// tablet and draw a pressure-ramped stroke through the real tracker→uinput chain.
|
||||
// Watch in Krita/GIMP (pressure brush) or `libinput debug-events`.
|
||||
#[cfg(target_os = "linux")]
|
||||
Some("pen-test") => devtest::pen_test(),
|
||||
// Zero-copy FFI/GPU probe: init the EGL importer + CUDA context (no capture needed).
|
||||
#[cfg(target_os = "linux")]
|
||||
Some("zerocopy-probe") => zerocopy::probe(),
|
||||
|
||||
@@ -1066,6 +1066,14 @@ async fn serve_session(
|
||||
if rich_tx.send(ClientInput::Rich(rich)).is_err() {
|
||||
break;
|
||||
}
|
||||
} else if let Some(pen) = punktfunk_core::quic::PenBatch::decode(&d) {
|
||||
// 0xCC kind 0x05 — the stylus plane (RichInput::decode returns None for it by
|
||||
// design; see punktfunk_core::quic::pen). Routed to the same input thread,
|
||||
// which owns the per-session tracker + virtual tablet.
|
||||
rich_count += 1;
|
||||
if rich_tx.send(ClientInput::Pen(pen)).is_err() {
|
||||
break;
|
||||
}
|
||||
} else if let Some(mut ev) = InputEvent::decode(&d) {
|
||||
input_count += 1;
|
||||
// Wire hygiene: KEY_FLAG_SEMANTIC_VK is an in-process tag (GameStream ingest
|
||||
|
||||
@@ -494,6 +494,15 @@ pub(super) async fn negotiate(
|
||||
punktfunk_core::quic::HOST_CAP_CURSOR
|
||||
} else {
|
||||
0
|
||||
}
|
||||
// Full-fidelity stylus (0xCC/0x05 pen batches → the per-session uinput tablet):
|
||||
// Linux with /dev/uinput access, minus the PUNKTFUNK_PEN=0 kill-switch. Clients
|
||||
// without the bit keep folding pen into touch/pointer (and NativeClient::send_pen
|
||||
// refuses toward us if we don't set it).
|
||||
| if crate::inject::pen_supported() {
|
||||
punktfunk_core::quic::HOST_CAP_PEN
|
||||
} else {
|
||||
0
|
||||
},
|
||||
// The negotiated session AEAD (resolved above) + its 32-byte key toward a ChaCha
|
||||
// client; toward everyone else cipher 0 keeps the Welcome byte-identical to the
|
||||
|
||||
@@ -523,6 +523,87 @@ pub(super) enum ClientInput {
|
||||
Event(InputEvent),
|
||||
/// The 0xCC plane: touchpad contacts + motion samples.
|
||||
Rich(punktfunk_core::quic::RichInput),
|
||||
/// The 0xCC/0x05 stylus plane: state-full pen sample batches, diffed into a per-session
|
||||
/// virtual tablet (design/pen-tablet-input.md).
|
||||
Pen(punktfunk_core::quic::PenBatch),
|
||||
}
|
||||
|
||||
/// The per-session stylus lane: the core [`PenTracker`](punktfunk_core::quic::PenTracker)
|
||||
/// diffs state-full batches into transitions, applied to a lazily-created uinput tablet
|
||||
/// ([`crate::inject::pen::VirtualPen`]) — a session that never draws never creates a device,
|
||||
/// and the device dies with the session (kernel removes the tablet, apps see the pen unplug).
|
||||
struct PenSession {
|
||||
tracker: punktfunk_core::quic::PenTracker,
|
||||
dev: Option<crate::inject::pen::VirtualPen>,
|
||||
/// Creation failed once — don't retry per batch (240 Hz of ioctl churn + log spam); the
|
||||
/// tracker still consumes batches so its state stays coherent.
|
||||
create_failed: bool,
|
||||
last_rx: std::time::Instant,
|
||||
/// Reused transition buffer (a batch yields at most a few).
|
||||
out: Vec<punktfunk_core::quic::PenTransition>,
|
||||
}
|
||||
|
||||
impl PenSession {
|
||||
fn new() -> PenSession {
|
||||
PenSession {
|
||||
tracker: punktfunk_core::quic::PenTracker::default(),
|
||||
dev: None,
|
||||
create_failed: false,
|
||||
last_rx: std::time::Instant::now(),
|
||||
out: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(&mut self, batch: &punktfunk_core::quic::PenBatch) {
|
||||
self.last_rx = std::time::Instant::now();
|
||||
if self.dev.is_none() && !self.create_failed {
|
||||
match crate::inject::pen::VirtualPen::create() {
|
||||
Ok(d) => self.dev = Some(d),
|
||||
Err(e) => {
|
||||
// Shouldn't happen when the Welcome advertised HOST_CAP_PEN off the same
|
||||
// uinput probe — but permissions can change between then and first ink.
|
||||
self.create_failed = true;
|
||||
tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
"pen: virtual tablet creation failed — dropping pen input this session"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.out.clear();
|
||||
self.tracker.apply(batch, &mut self.out);
|
||||
if let Some(dev) = self.dev.as_mut() {
|
||||
dev.apply_batch(&self.out);
|
||||
}
|
||||
}
|
||||
|
||||
/// The dead-client failsafe ([`PEN_TOUCH_TIMEOUT_MS`](punktfunk_core::quic::PEN_TOUCH_TIMEOUT_MS)):
|
||||
/// clients repeat the last sample (≤100 ms) while the pen is in range — a stationary
|
||||
/// touching pen included — so silence past the timeout means the client is gone, and the
|
||||
/// host must not leave the stroke inked-down. Called every input-loop wake; the loop caps
|
||||
/// its recv timeout at 100 ms while the pen is active so this actually gets to run.
|
||||
fn check_timeout(&mut self) {
|
||||
if self.tracker.is_active()
|
||||
&& self.last_rx.elapsed().as_millis()
|
||||
>= punktfunk_core::quic::PEN_TOUCH_TIMEOUT_MS as u128
|
||||
{
|
||||
tracing::debug!("pen: sample stream went silent — force-releasing the stroke");
|
||||
self.release_all();
|
||||
}
|
||||
}
|
||||
|
||||
/// Lift buttons/tip/proximity in order (a no-op when idle). Session end + the timeout.
|
||||
fn release_all(&mut self) {
|
||||
self.out.clear();
|
||||
self.tracker.force_release(&mut self.out);
|
||||
if let Some(dev) = self.dev.as_mut() {
|
||||
dev.apply_batch(&self.out);
|
||||
}
|
||||
}
|
||||
|
||||
fn active(&self) -> bool {
|
||||
self.tracker.is_active()
|
||||
}
|
||||
}
|
||||
|
||||
/// Default TTL stamped on a non-zero rumble envelope (0xCA v2): how long the client renders the
|
||||
@@ -640,8 +721,17 @@ pub(super) fn input_thread(
|
||||
const MAX_HELD: usize = 256;
|
||||
let mut held_buttons: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||
let mut held_keys: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||
let mut pen = PenSession::new();
|
||||
loop {
|
||||
match rx.recv_timeout(pads.feedback_poll_interval()) {
|
||||
// While a pen is in range/touching, wake at least every 100 ms so the stroke failsafe
|
||||
// (PenSession::check_timeout) has real granularity against its 200 ms deadline.
|
||||
let poll = if pen.active() {
|
||||
pads.feedback_poll_interval()
|
||||
.min(std::time::Duration::from_millis(100))
|
||||
} else {
|
||||
pads.feedback_poll_interval()
|
||||
};
|
||||
match rx.recv_timeout(poll) {
|
||||
// Rich input (touchpad / motion) is applied the moment it arrives; the single channel
|
||||
// wakes for gyro samples instead of making them wait out the feedback poll interval.
|
||||
Ok(ClientInput::Rich(rich)) => {
|
||||
@@ -673,6 +763,9 @@ pub(super) fn input_thread(
|
||||
}
|
||||
pads.apply_rich(rich);
|
||||
}
|
||||
// Stylus batches apply on arrival like rich input — the tracker synthesizes the
|
||||
// transitions, the lazily-created virtual tablet renders them.
|
||||
Ok(ClientInput::Pen(batch)) => pen.apply(&batch),
|
||||
Ok(ClientInput::Event(ev)) => match ev.kind {
|
||||
InputKind::GamepadButton | InputKind::GamepadAxis => {
|
||||
// A bad index / unknown axis just doesn't update a pad — fall through (no
|
||||
@@ -774,6 +867,9 @@ pub(super) fn input_thread(
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
// Pen stroke failsafe: a silent sample stream past the deadline force-releases (see
|
||||
// PenSession::check_timeout — clients heartbeat while the pen is down).
|
||||
pen.check_timeout();
|
||||
// Service feedback every iteration (≤1 ms for Triton, ≤4 ms otherwise; games block on
|
||||
// EVIOCSFF, and HID handshakes must be answered promptly). Rumble → the universal 0xCA
|
||||
// plane; rich/raw HID feedback → 0xCD.
|
||||
@@ -863,6 +959,9 @@ pub(super) fn input_thread(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pen: lift anything still inked (buttons → tip → proximity), then the device dies with
|
||||
// this thread (VirtualPen::drop → UI_DEV_DESTROY; apps see the tablet unplug).
|
||||
pen.release_all();
|
||||
// Session ended (client gone). Release anything still held through the host-lifetime injector —
|
||||
// its EIS connection (and any implicit grab Mutter holds for our pressed button) outlives this
|
||||
// session, so without this a button pressed at disconnect stays latched and breaks clicks for
|
||||
|
||||
Reference in New Issue
Block a user