chore: consolidate all in-progress parallel-session WIP
audit / bun-audit (push) Successful in 27s
windows-drivers / probe-and-proto (push) Successful in 49s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m10s
apple / swift (push) Successful in 1m18s
decky / build-publish (push) Successful in 34s
windows-drivers / driver-build (push) Successful in 1m37s
audit / cargo-audit (push) Successful in 3m1s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 42s
ci / bench (push) Successful in 5m56s
windows-host / package (push) Failing after 5m17s
apple / screenshots (push) Successful in 4m45s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m38s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11m3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m2s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8m6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11m6s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m15s
arch / build-publish (push) Successful in 16m50s
android / android (push) Successful in 17m7s
docker / deploy-docs (push) Successful in 28s
deb / build-publish (push) Successful in 17m6s
ci / rust (push) Failing after 19m1s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m3s
flatpak / build-publish (push) Successful in 8m0s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m29s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m14s
release / apple (push) Successful in 5m44s
audit / bun-audit (push) Successful in 27s
windows-drivers / probe-and-proto (push) Successful in 49s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m10s
apple / swift (push) Successful in 1m18s
decky / build-publish (push) Successful in 34s
windows-drivers / driver-build (push) Successful in 1m37s
audit / cargo-audit (push) Successful in 3m1s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 42s
ci / bench (push) Successful in 5m56s
windows-host / package (push) Failing after 5m17s
apple / screenshots (push) Successful in 4m45s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m38s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11m3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m2s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8m6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11m6s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m15s
arch / build-publish (push) Successful in 16m50s
android / android (push) Successful in 17m7s
docker / deploy-docs (push) Successful in 28s
deb / build-publish (push) Successful in 17m6s
ci / rust (push) Failing after 19m1s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m3s
flatpak / build-publish (push) Successful in 8m0s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m29s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m14s
release / apple (push) Successful in 5m44s
Wholesale commit of every uncommitted change across the tree, at the user's explicit request — host refactor-campaign W1 (native.rs facade + native/ dir, library/ + mgmt/ splits), Android, core. These streams were mid-flight and not individually built/tested together; this supersedes the per-session HOLD markers. Consolidating so everything lands on main in one pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
//! The native audio plane (plan §W1 — carved out of the [`super`] module): desktop capture → Opus
|
||||
//! (48 kHz, 5 ms, CBR — the same tuning as the GameStream path) → `AUDIO_MAGIC` QUIC datagrams, at
|
||||
//! the negotiated channel count. The encoder ([`NativeAudioEnc`]) and the capture/encode/send loop
|
||||
//! ([`audio_thread`]) are gated to linux/windows (libopus + a real capturer); other targets get the
|
||||
//! stub, so a dev build streams video-only rather than failing to compile.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Opus encoder for the native audio plane: a plain stereo encoder (the live-validated,
|
||||
/// byte-identical path) or a libopus *multistream* encoder for 5.1/7.1, both behind one
|
||||
/// `encode_float`. Surround uses the safe `opus::MSEncoder` (no `audiopus_sys`).
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
enum NativeAudioEnc {
|
||||
Stereo(opus::Encoder),
|
||||
Surround(opus::MSEncoder),
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
impl NativeAudioEnc {
|
||||
/// Build the encoder for `channels` (2/6/8), hard-CBR + RESTRICTED_LOWDELAY like the
|
||||
/// GameStream path; bitrate from the shared layout table (stereo keeps the validated 128 kbps).
|
||||
fn new(channels: u8) -> Result<NativeAudioEnc, opus::Error> {
|
||||
if channels == 2 {
|
||||
let mut e = opus::Encoder::new(
|
||||
crate::audio::SAMPLE_RATE,
|
||||
opus::Channels::Stereo,
|
||||
opus::Application::LowDelay,
|
||||
)?;
|
||||
e.set_bitrate(opus::Bitrate::Bits(128_000)).ok();
|
||||
e.set_vbr(false).ok();
|
||||
Ok(NativeAudioEnc::Stereo(e))
|
||||
} else {
|
||||
let l = punktfunk_core::audio::layout_for(channels, false);
|
||||
let mut e = opus::MSEncoder::new(
|
||||
crate::audio::SAMPLE_RATE,
|
||||
l.streams,
|
||||
l.coupled,
|
||||
l.mapping,
|
||||
opus::Application::LowDelay,
|
||||
)?;
|
||||
e.set_bitrate(opus::Bitrate::Bits(l.bitrate)).ok();
|
||||
e.set_vbr(false).ok();
|
||||
Ok(NativeAudioEnc::Surround(e))
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_float(&mut self, frame: &[f32], out: &mut [u8]) -> Result<usize, opus::Error> {
|
||||
match self {
|
||||
NativeAudioEnc::Stereo(e) => e.encode_float(frame, out),
|
||||
NativeAudioEnc::Surround(e) => e.encode_float(frame, out),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The audio thread: desktop capture → Opus (48 kHz, 5 ms, CBR — same tuning as the GameStream
|
||||
/// path) → `AUDIO_MAGIC` datagrams, at the negotiated `channels` (2 stereo / 6 = 5.1 / 8 = 7.1,
|
||||
/// canonical wire order FL FR FC LFE RL RR SL SR). QUIC already encrypts; no extra layer. The
|
||||
/// capturer comes from (and returns to) the persistent slot — see [`AudioCapSlot`].
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
pub(super) fn audio_thread(
|
||||
conn: quinn::Connection,
|
||||
stop: Arc<AtomicBool>,
|
||||
audio_cap: AudioCapSlot,
|
||||
channels: u8,
|
||||
) {
|
||||
use crate::audio::SAMPLE_RATE;
|
||||
const FRAME_MS: usize = 5;
|
||||
const SAMPLES_PER_FRAME: usize = SAMPLE_RATE as usize * FRAME_MS / 1000; // 240
|
||||
let want = punktfunk_core::audio::normalize_channels(channels);
|
||||
|
||||
// Reuse the cached capturer ONLY when its channel count matches this session's; a stereo
|
||||
// capturer left by a prior session must not feed a 5.1/7.1 session (the encoder + the client's
|
||||
// decoder are sized for `want`, so a mismatched capturer would garble/desync the audio).
|
||||
let capturer = match audio_cap.lock().unwrap().take() {
|
||||
Some(mut c) if c.channels() == want as u32 => {
|
||||
c.drain(); // discard audio captured between sessions
|
||||
c
|
||||
}
|
||||
prev => {
|
||||
drop(prev); // wrong channel count (or none): clean teardown, open fresh at `want`
|
||||
match crate::audio::open_audio_capture(want as u32) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "punktfunk/1 audio unavailable — session continues without it");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut enc = match NativeAudioEnc::new(want) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "opus encoder init failed — session continues without audio");
|
||||
*audio_cap.lock().unwrap() = Some(capturer);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let frame_len = SAMPLES_PER_FRAME * want as usize;
|
||||
let mut acc: Vec<f32> = Vec::with_capacity(frame_len * 4);
|
||||
// Sized for the largest surround frame (7.1 HQ ≈ 1.3 KB at 5 ms); ample for normal quality.
|
||||
let mut opus_buf = vec![0u8; 4096];
|
||||
let mut seq: u32 = 0;
|
||||
// Reopen-with-backoff: hold the capturer in an Option so a mid-session capture-thread death
|
||||
// (device unplug, daemon restart) reopens instead of muting the rest of a multi-hour session.
|
||||
// A quiet sink is NOT a death — `next_chunk` returns an empty chunk on its idle timeout — so only
|
||||
// a genuine thread-ended Err drops the capturer. Reopens are throttled by INJECTOR_REOPEN_BACKOFF.
|
||||
// The Opus encoder and the monotonic `seq` are kept across reopens (the client sees a gap, not a
|
||||
// restart). The first open already happened above; failing THAT still ends the session quietly.
|
||||
let mut capturer = Some(capturer);
|
||||
let mut last_failed: Option<std::time::Instant> = None;
|
||||
// A stuck Opus encoder would fail on every 5 ms frame (~200/s); power-of-two throttle the
|
||||
// warn so it can't flood stderr + the log ring while still surfacing that it's failing.
|
||||
let mut opus_encode_errs: u64 = 0;
|
||||
tracing::info!(
|
||||
channels = want,
|
||||
"punktfunk/1 audio streaming (Opus 48 kHz, 5 ms datagrams)"
|
||||
);
|
||||
'session: while !stop.load(Ordering::SeqCst) {
|
||||
if capturer.is_none() {
|
||||
if last_failed.is_some_and(|t| t.elapsed() < INJECTOR_REOPEN_BACKOFF) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
continue;
|
||||
}
|
||||
match crate::audio::open_audio_capture(want as u32) {
|
||||
Ok(c) => {
|
||||
tracing::info!("punktfunk/1 audio capture reopened");
|
||||
capturer = Some(c);
|
||||
last_failed = None;
|
||||
acc.clear(); // drop the partial frame straddling the gap
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %format!("{e:#}"), "audio reopen failed — will retry");
|
||||
last_failed = Some(std::time::Instant::now());
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
let chunk = match capturer.as_mut().unwrap().next_chunk() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "audio capture lost — reopening");
|
||||
capturer = None;
|
||||
last_failed = Some(std::time::Instant::now());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
acc.extend_from_slice(&chunk);
|
||||
while acc.len() >= frame_len {
|
||||
let frame: Vec<f32> = acc.drain(..frame_len).collect();
|
||||
let pts_ns = now_ns();
|
||||
match enc.encode_float(&frame, &mut opus_buf) {
|
||||
Ok(n) => {
|
||||
let d =
|
||||
punktfunk_core::quic::encode_audio_datagram(seq, pts_ns, &opus_buf[..n]);
|
||||
if conn.send_datagram(d.into()).is_err() {
|
||||
break 'session; // connection gone
|
||||
}
|
||||
seq = seq.wrapping_add(1);
|
||||
}
|
||||
Err(e) => {
|
||||
opus_encode_errs += 1;
|
||||
if opus_encode_errs.is_power_of_two() {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
count = opus_encode_errs,
|
||||
"opus encode failed — dropping audio frame"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Return the live capturer for the next session (None if it died and never reopened).
|
||||
if let Some(c) = capturer {
|
||||
*audio_cap.lock().unwrap() = Some(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stub — punktfunk/1 audio needs Linux (PipeWire capture + libopus); non-Linux dev builds
|
||||
/// run sessions without it, same as when the capturer fails to open.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||
pub(super) fn audio_thread(
|
||||
_conn: quinn::Connection,
|
||||
_stop: Arc<AtomicBool>,
|
||||
_audio_cap: AudioCapSlot,
|
||||
_channels: u8,
|
||||
) {
|
||||
tracing::warn!("punktfunk/1 audio requires Linux or Windows — session continues without it");
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Compositor-preference resolution for the native handshake (plan §W1 — carved out of the
|
||||
//! [`super`] module): map the client's [`CompositorPref`] to a concrete `crate::vdisplay::Compositor`
|
||||
//! backend, honoring an explicit request when the named backend is live and otherwise auto-detecting
|
||||
//! the active graphical session. The pure decision ([`pick_compositor`]) is separated from the I/O
|
||||
//! shell ([`resolve_compositor`]) that runs the blocking session probes.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Pure selection: choose the backend to drive from the client's `pref`, the set `available`
|
||||
/// right now, and the auto-`detected` default. A concrete preference wins only if it's available;
|
||||
/// otherwise (and for `Auto`) fall back to the detected default. `None` only when nothing is
|
||||
/// available *and* nothing was detected — the caller turns that into a handshake error.
|
||||
fn pick_compositor(
|
||||
pref: CompositorPref,
|
||||
available: &[crate::vdisplay::Compositor],
|
||||
detected: Option<crate::vdisplay::Compositor>,
|
||||
) -> Option<crate::vdisplay::Compositor> {
|
||||
use crate::vdisplay::Compositor;
|
||||
match Compositor::from_pref(pref) {
|
||||
Some(want) if available.contains(&want) => Some(want),
|
||||
// `CompositorPref::Wlroots` names the wlroots *family* (D2): sway/river ([`Wlroots`]) and
|
||||
// Hyprland are distinct backends but mutually-exclusive live sessions, so honor the request
|
||||
// with whichever family member is actually available — the detected one if it's a family
|
||||
// member, else the first available of the two.
|
||||
Some(Compositor::Wlroots) => match detected {
|
||||
Some(d @ (Compositor::Wlroots | Compositor::Hyprland)) => Some(d),
|
||||
_ => [Compositor::Wlroots, Compositor::Hyprland]
|
||||
.into_iter()
|
||||
.find(|c| available.contains(c))
|
||||
.or(detected),
|
||||
},
|
||||
_ => detected,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the client's compositor preference to a concrete backend (the I/O shell around
|
||||
/// [`pick_compositor`]): enumerate what's available, auto-detect the default, pick, and log
|
||||
/// whether the explicit request was honored or fell back. Runs blocking probes — call off the
|
||||
/// async reactor (`spawn_blocking`).
|
||||
pub(super) fn resolve_compositor(
|
||||
pref: CompositorPref,
|
||||
dedicated_launch: bool,
|
||||
) -> Result<crate::vdisplay::Compositor> {
|
||||
use crate::vdisplay::Compositor;
|
||||
// Windows has a single virtual-display backend (pf-vdisplay); vdisplay::open ignores the compositor
|
||||
// arg there, so short-circuit the Linux session-detection state machine with a placeholder.
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = (pref, dedicated_launch);
|
||||
Ok(Compositor::Kwin)
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// A client is (re)connecting → cancel any pending TV-session restore so the box stays in the
|
||||
// streamed session (covers the keep-alive REUSE reconnect, which skips create_managed_session's
|
||||
// own cancel — review #3). No-op when nothing is pending.
|
||||
crate::vdisplay::cancel_pending_tv_restore();
|
||||
// Explicit operator override (legacy / CI / forcing a backend for a test) wins and is assumed
|
||||
// to come with a hand-set env — don't retarget the process env in that case.
|
||||
let overridden = crate::config::config().compositor.is_some();
|
||||
let detected = if overridden {
|
||||
crate::vdisplay::detect().ok()
|
||||
} else {
|
||||
// Auto: detect the LIVE session (Gaming vs Desktop) and retarget the process env at it so
|
||||
// every backend (video capture + input) this connect opens against the active session —
|
||||
// this is the state machine that lets one host follow a Bazzite box across Gaming↔Desktop.
|
||||
let active = crate::vdisplay::detect_active_session();
|
||||
// A4: if the compositor instance changed since the last connect (an idle-time Game↔Desktop
|
||||
// switch), bump the epoch + invalidate the old backend's kept displays so this connect never
|
||||
// reuses a node id from the dead instance.
|
||||
crate::vdisplay::observe_session_instance(&active);
|
||||
crate::vdisplay::apply_session_env(&active);
|
||||
tracing::info!(
|
||||
active = ?active.kind,
|
||||
wayland = active.env.wayland_display.as_deref().unwrap_or("-"),
|
||||
"detected active graphical session"
|
||||
);
|
||||
crate::vdisplay::compositor_for_kind(active.kind)
|
||||
};
|
||||
// Dedicated game session (design/gamemode-and-dedicated-sessions.md B0): a launching session
|
||||
// under `game_session=dedicated` (gamescope confirmed available) forces its OWN headless
|
||||
// gamescope spawn at the client's mode, overriding the detected desktop/game-mode backend. The
|
||||
// env was already retargeted above (for XDG_RUNTIME_DIR / the PipeWire daemon); we just pin the
|
||||
// backend + input to the spawn sub-mode. Skipped under an explicit operator compositor pin.
|
||||
if dedicated_launch && !overridden {
|
||||
crate::vdisplay::apply_input_env(Compositor::Gamescope, true);
|
||||
tracing::info!(
|
||||
"dedicated game session — routing to a headless gamescope spawn at the client mode"
|
||||
);
|
||||
return Ok(Compositor::Gamescope);
|
||||
}
|
||||
let available = crate::vdisplay::available();
|
||||
let chosen = match pick_compositor(pref, &available, detected) {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
// No live session — the state a compositor crash leaves behind (gnome-shell
|
||||
// SIGSEGV → GDM greeter, whose auto-login is once-per-boot). If the operator
|
||||
// configured a recovery hook, fire it (debounced) and tell the client to retry:
|
||||
// its next knock lands in the recovered desktop.
|
||||
if crate::vdisplay::try_recover_session() {
|
||||
anyhow::bail!(
|
||||
"no live graphical session for this uid — host session recovery launched \
|
||||
(PUNKTFUNK_RECOVER_SESSION_CMD); retry in a few seconds"
|
||||
);
|
||||
}
|
||||
anyhow::bail!(
|
||||
"no usable compositor (no live graphical session for this uid; set \
|
||||
PUNKTFUNK_COMPOSITOR or start a desktop/gaming session)"
|
||||
);
|
||||
}
|
||||
};
|
||||
if !overridden {
|
||||
// Point input at the same backend and resolve the gamescope sub-mode (managed where the
|
||||
// session infra exists, attach to a foreign gamescope, else per-session bare spawn).
|
||||
crate::vdisplay::apply_input_env(chosen, false);
|
||||
}
|
||||
let avail_ids: Vec<&str> = available.iter().map(|c| c.id()).collect();
|
||||
match Compositor::from_pref(pref) {
|
||||
Some(want) if want == chosen => {
|
||||
tracing::info!(
|
||||
compositor = chosen.id(),
|
||||
"honoring client compositor request"
|
||||
)
|
||||
}
|
||||
Some(want) => tracing::warn!(
|
||||
requested = want.id(),
|
||||
chosen = chosen.id(),
|
||||
available = ?avail_ids,
|
||||
"client-requested compositor unavailable — falling back to auto-detect"
|
||||
),
|
||||
None => tracing::info!(
|
||||
compositor = chosen.id(),
|
||||
"auto-detected compositor (client: auto)"
|
||||
),
|
||||
}
|
||||
Ok(chosen)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::pick_compositor;
|
||||
use punktfunk_core::config::CompositorPref;
|
||||
|
||||
#[test]
|
||||
fn compositor_resolution_precedence() {
|
||||
use crate::vdisplay::Compositor::*;
|
||||
// A concrete, available preference is honored.
|
||||
assert_eq!(
|
||||
pick_compositor(CompositorPref::Gamescope, &[Kwin, Gamescope], Some(Kwin)),
|
||||
Some(Gamescope)
|
||||
);
|
||||
// A concrete but UNavailable preference falls back to the detected default.
|
||||
assert_eq!(
|
||||
pick_compositor(CompositorPref::Mutter, &[Kwin, Gamescope], Some(Kwin)),
|
||||
Some(Kwin)
|
||||
);
|
||||
// Auto always uses the detected default.
|
||||
assert_eq!(
|
||||
pick_compositor(CompositorPref::Auto, &[Kwin, Gamescope], Some(Kwin)),
|
||||
Some(Kwin)
|
||||
);
|
||||
// Unavailable preference + nothing detected → None (caller errors the handshake).
|
||||
assert_eq!(
|
||||
pick_compositor(CompositorPref::Mutter, &[Gamescope], None),
|
||||
None
|
||||
);
|
||||
// Available preference still wins even when nothing was auto-detected.
|
||||
assert_eq!(
|
||||
pick_compositor(CompositorPref::Gamescope, &[Gamescope], None),
|
||||
Some(Gamescope)
|
||||
);
|
||||
// Wlroots family (D2): the shared `Wlroots` pref resolves to whichever of sway/river
|
||||
// (Wlroots) and Hyprland is the live session.
|
||||
assert_eq!(
|
||||
pick_compositor(CompositorPref::Wlroots, &[Hyprland], Some(Hyprland)),
|
||||
Some(Hyprland)
|
||||
);
|
||||
// …and to Wlroots-proper on a sway/river host.
|
||||
assert_eq!(
|
||||
pick_compositor(CompositorPref::Wlroots, &[Wlroots], Some(Wlroots)),
|
||||
Some(Wlroots)
|
||||
);
|
||||
// Family fallback even if detection came back empty but a member is available.
|
||||
assert_eq!(
|
||||
pick_compositor(CompositorPref::Wlroots, &[Hyprland], None),
|
||||
Some(Hyprland)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
//! Virtual-gamepad backend resolution for the native session (plan §W1 — carved out of the
|
||||
//! [`super`] module). Maps the client's [`GamepadPref`] (plus the host `PUNKTFUNK_GAMEPAD` env and
|
||||
//! the live platform) to a backend this host can actually build, applying the runtime UHID /
|
||||
//! Steam-conflict degrades. Pure selection ([`pick_gamepad`]) is separated from the env/logging
|
||||
//! shell ([`resolve_gamepad`]) and the per-pad variant ([`resolve_pad_kind`]); the platform degrades
|
||||
//! (`degrade_if_no_uhid`, `physical_steam_controller_present`, `degrade_steam_on_conflict`) are
|
||||
//! cfg-split linux/other and MUST be re-verified on Windows when touched (Linux clippy can't see the
|
||||
//! non-linux copies).
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The per-pad routing decision for one frame ([`Pads::handle`]): given `owner` (the manager
|
||||
/// holding a live device at this index, if any), the client-`declared` kind, and whether this is a
|
||||
/// create/update frame (`present`) vs a removal, return `(kind to route to, new owner)`.
|
||||
///
|
||||
/// A live device stays in its owning manager even if the declared kind later changes (so a pad is
|
||||
/// never duplicated across managers); the declared kind takes effect only when no device exists
|
||||
/// yet; a removal routes to the owner's manager (so it tears the right device down) and clears the
|
||||
/// owner.
|
||||
pub(super) fn route_decision(
|
||||
owner: Option<GamepadPref>,
|
||||
declared: GamepadPref,
|
||||
present: bool,
|
||||
) -> (GamepadPref, Option<GamepadPref>) {
|
||||
match (owner, present) {
|
||||
(Some(k), true) => (k, Some(k)), // keep the existing device in its manager
|
||||
(Some(k), false) => (k, None), // removal → owner's manager, then clear
|
||||
(None, true) => (declared, Some(declared)), // create in the declared kind's manager
|
||||
(None, false) => (declared, None), // removal with no device — a harmless no-op
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve one client-declared per-pad kind to a backend this host can actually build (mixed
|
||||
/// types): the platform map + the runtime UHID / Steam-conflict degrades that [`resolve_gamepad`]
|
||||
/// applies to the session default, minus the Auto/env session logic (a per-pad declaration is
|
||||
/// always a concrete kind).
|
||||
pub(super) fn resolve_pad_kind(kind: GamepadPref) -> GamepadPref {
|
||||
let chosen = pick_gamepad(
|
||||
kind,
|
||||
None,
|
||||
cfg!(target_os = "linux"),
|
||||
cfg!(target_os = "windows"),
|
||||
);
|
||||
degrade_steam_on_conflict(degrade_if_no_uhid(chosen))
|
||||
}
|
||||
|
||||
/// Pure selection of the session's virtual-gamepad backend: the client's explicit `pref` wins,
|
||||
/// then the host's `PUNKTFUNK_GAMEPAD` env var (under a client `Auto`), then X-Box 360.
|
||||
///
|
||||
/// `linux`/`windows` flag the host platform. DualSense and DualShock 4 each have both a Linux (UHID
|
||||
/// hid-playstation) and a Windows (UMDF minidriver) backend; on any other platform such a wish degrades
|
||||
/// to X-Box 360 (never an error: a session without rich pads still streams). X-Box One/Series is a
|
||||
/// distinct uinput *identity* on Linux, but XInput-identical to the 360 pad on Windows (the XUSB
|
||||
/// companion presents a 360 identity), so it degrades to `Xbox360` there.
|
||||
fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool) -> GamepadPref {
|
||||
let want = match pref {
|
||||
GamepadPref::Auto => env
|
||||
.and_then(GamepadPref::from_name)
|
||||
.unwrap_or(GamepadPref::Auto),
|
||||
explicit => explicit,
|
||||
};
|
||||
match want {
|
||||
// DualSense / DualShock 4: Linux UHID hid-playstation, or the Windows UMDF minidriver backend.
|
||||
GamepadPref::DualSense if linux || windows => GamepadPref::DualSense,
|
||||
GamepadPref::DualShock4 if linux || windows => GamepadPref::DualShock4,
|
||||
// One/Series: a real, distinct uinput identity on Linux; folded into the 360 backend on
|
||||
// Windows (XInput can't tell them apart anyway).
|
||||
GamepadPref::XboxOne if linux => GamepadPref::XboxOne,
|
||||
// Steam Deck / classic Steam Controller: Linux UHID hid-steam (Windows Steam devices
|
||||
// are the N4 spike).
|
||||
GamepadPref::SteamDeck if linux => GamepadPref::SteamDeck,
|
||||
GamepadPref::SteamController if linux => GamepadPref::SteamController,
|
||||
// Windows virtual Deck: the UMDF device-type-3 identity, Steam-Input-promoted via the
|
||||
// MI_02 hardware-id synthesis (gamepad-new-types N4) — native Deck glyphs + trackpads +
|
||||
// gyro + back grips, replacing the old fold to DualSense.
|
||||
GamepadPref::SteamDeck if windows => GamepadPref::SteamDeck,
|
||||
// DualSense Edge: Linux UHID hid-playstation / Windows UMDF (device-type 2) — the plain
|
||||
// DualSense plus native back/Fn buttons, so the wire paddles stop hitting the fold/drop
|
||||
// policy. Degrades to Xbox360 elsewhere like its siblings.
|
||||
GamepadPref::DualSenseEdge if linux || windows => GamepadPref::DualSenseEdge,
|
||||
// Switch Pro: Linux UHID hid-nintendo (≥ 5.16) — correct Nintendo glyphs + positional
|
||||
// layout + gyro + HD rumble. No Windows backend; folds to Xbox360 there.
|
||||
GamepadPref::SwitchPro if linux => GamepadPref::SwitchPro,
|
||||
// New Steam Controller (2026, `28DE:1302`): passed through as-is on Linux — the Triton
|
||||
// UHID backend mirrors the client's raw reports under the real identity and Steam on
|
||||
// the host drives it over hidraw (no kernel driver binds the PID; Steam Input is the
|
||||
// consumer). No Windows backend; folds to Xbox360 there.
|
||||
GamepadPref::SteamController2 if linux => GamepadPref::SteamController2,
|
||||
GamepadPref::SteamController2Puck if linux => GamepadPref::SteamController2Puck,
|
||||
_ => GamepadPref::Xbox360,
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime degrade for the Linux UHID backends (DualSense / DualShock 4 / Steam Deck): if
|
||||
/// `/dev/uhid` can't be opened for write *now*, fall back to the uinput X-Box 360 pad rather than a
|
||||
/// dead controller (the UHID device-create would just fail). Cheap — opens + drops the char device,
|
||||
/// no `UHID_CREATE2`, so no device is created. A no-op on non-Linux (those backends are UMDF/uinput).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref {
|
||||
let needs_uhid = matches!(
|
||||
chosen,
|
||||
GamepadPref::DualSense
|
||||
| GamepadPref::DualSenseEdge
|
||||
| GamepadPref::DualShock4
|
||||
| GamepadPref::SteamDeck
|
||||
| GamepadPref::SteamController
|
||||
| GamepadPref::SteamController2
|
||||
| GamepadPref::SteamController2Puck
|
||||
| GamepadPref::SwitchPro
|
||||
);
|
||||
if needs_uhid
|
||||
&& std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open("/dev/uhid")
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(
|
||||
wanted = chosen.as_str(),
|
||||
"/dev/uhid not writable — falling back to the X-Box 360 pad"
|
||||
);
|
||||
return GamepadPref::Xbox360;
|
||||
}
|
||||
chosen
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref {
|
||||
chosen
|
||||
}
|
||||
|
||||
/// True if a **physical** Valve Steam controller (`28DE`) is already attached. The host's own Steam
|
||||
/// Input is then managing a `28DE` device, and presenting a second (virtual) one makes Steam juggle
|
||||
/// two Decks — confirmed conflict-prone on a Deck-as-host (the physical `28DE:1205` + Steam's
|
||||
/// `28DE:11FF` XInput output pad are both live). HID device dirs are named `BUS:VID:PID.INST`
|
||||
/// (uppercase); a UHID virtual device resolves through `/devices/virtual/…`, a real one does not.
|
||||
///
|
||||
/// Punktfunk's OWN virtual Decks must never count: the usbip/gadget transports present a real USB
|
||||
/// device (vhci resolves through `vhci_hcd`, NOT `/devices/virtual/`), so a just-ended session's
|
||||
/// pad still detaching — or a concurrent session's live one — read as "physical" and degraded
|
||||
/// every back-to-back Deck session to DualSense (observed live on Bazzite 2026-07-04). Ours are
|
||||
/// recognizable by the `FVPF…` serial ([`steam_proto::deck_serial`]) in `HID_UNIQ`, with the
|
||||
/// vhci path as belt and braces.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn physical_steam_controller_present() -> bool {
|
||||
let Ok(entries) = std::fs::read_dir("/sys/bus/hid/devices") else {
|
||||
return false;
|
||||
};
|
||||
entries.flatten().any(|e| {
|
||||
if !e.file_name().to_string_lossy().contains(":28DE:") {
|
||||
return false;
|
||||
}
|
||||
if std::fs::read_to_string(e.path().join("uevent"))
|
||||
.is_ok_and(|u| u.lines().any(|l| l.starts_with("HID_UNIQ=FVPF")))
|
||||
{
|
||||
return false; // one of our own virtual Decks
|
||||
}
|
||||
match std::fs::read_link(e.path()) {
|
||||
Ok(target) => {
|
||||
let t = target.to_string_lossy();
|
||||
!t.contains("/virtual/") && !t.contains("vhci_hcd")
|
||||
}
|
||||
Err(_) => true,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Gate a virtual Steam pad off when a physical Steam controller is attached (§ conflict). Degrade to
|
||||
/// DualSense (then the uhid ladder), which Steam treats as an ordinary, distinct pad. Override with
|
||||
/// `PUNKTFUNK_STEAM_FORCE=1` when the host has no competing Steam Input (e.g. a remote-only box).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn degrade_steam_on_conflict(chosen: GamepadPref) -> GamepadPref {
|
||||
if !matches!(
|
||||
chosen,
|
||||
GamepadPref::SteamDeck
|
||||
| GamepadPref::SteamController
|
||||
| GamepadPref::SteamController2
|
||||
| GamepadPref::SteamController2Puck
|
||||
) {
|
||||
return chosen;
|
||||
}
|
||||
let forced = std::env::var("PUNKTFUNK_STEAM_FORCE")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false);
|
||||
if !forced && physical_steam_controller_present() {
|
||||
tracing::warn!(
|
||||
wanted = chosen.as_str(),
|
||||
"a physical Steam controller is attached — the host's Steam Input would manage two 28DE \
|
||||
devices; falling back to DualSense (set PUNKTFUNK_STEAM_FORCE=1 to override)"
|
||||
);
|
||||
return degrade_if_no_uhid(GamepadPref::DualSense);
|
||||
}
|
||||
chosen
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn degrade_steam_on_conflict(chosen: GamepadPref) -> GamepadPref {
|
||||
chosen
|
||||
}
|
||||
|
||||
/// Resolve the client's gamepad-backend preference (the env/logging shell around
|
||||
/// [`pick_gamepad`]). Always concrete — the `Welcome` reports what the session will drive.
|
||||
pub(super) fn resolve_gamepad(pref: GamepadPref) -> GamepadPref {
|
||||
let env = crate::config::config().gamepad.clone();
|
||||
let chosen = pick_gamepad(
|
||||
pref,
|
||||
env.as_deref(),
|
||||
cfg!(target_os = "linux"),
|
||||
cfg!(target_os = "windows"),
|
||||
);
|
||||
// Runtime degrade (separate from the compile-time platform check above): the Linux UHID
|
||||
// backends need `/dev/uhid` usable *now*, else creating the device just fails and the controller
|
||||
// goes dead — fall back to the always-available uinput X-Box 360 pad instead.
|
||||
let chosen = degrade_if_no_uhid(chosen);
|
||||
// Conflict gate: don't present a virtual Steam (28DE) pad when the host already has a physical
|
||||
// Steam controller — its own Steam Input would then manage two Decks (confirmed conflict-prone on
|
||||
// a Deck-as-host). `PUNKTFUNK_STEAM_FORCE=1` overrides.
|
||||
let chosen = degrade_steam_on_conflict(chosen);
|
||||
match pref {
|
||||
GamepadPref::Auto => {
|
||||
// The operator's env knob deserves a diagnostic when it didn't drive the
|
||||
// choice — a typo, or a DualSense wish on a non-UHID host, would otherwise
|
||||
// degrade silently.
|
||||
if let Some(env) = env.as_deref() {
|
||||
if GamepadPref::from_name(env) != Some(chosen) {
|
||||
tracing::warn!(
|
||||
env,
|
||||
chosen = chosen.as_str(),
|
||||
"PUNKTFUNK_GAMEPAD unrecognized or unavailable — falling back"
|
||||
);
|
||||
}
|
||||
}
|
||||
tracing::info!(gamepad = chosen.as_str(), "gamepad backend (client: auto)")
|
||||
}
|
||||
want if want == chosen => {
|
||||
tracing::info!(gamepad = chosen.as_str(), "honoring client gamepad request")
|
||||
}
|
||||
want => tracing::warn!(
|
||||
requested = want.as_str(),
|
||||
chosen = chosen.as_str(),
|
||||
"client-requested gamepad backend unavailable — falling back"
|
||||
),
|
||||
}
|
||||
chosen
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{pick_gamepad, route_decision};
|
||||
use punktfunk_core::config::GamepadPref;
|
||||
|
||||
#[test]
|
||||
fn per_pad_route_decision() {
|
||||
use GamepadPref::{DualSense, Xbox360};
|
||||
// First frame with no device: create in the declared kind's manager, record ownership.
|
||||
assert_eq!(
|
||||
route_decision(None, DualSense, true),
|
||||
(DualSense, Some(DualSense))
|
||||
);
|
||||
// Subsequent frame: stays in the owning manager even if the declared kind now differs
|
||||
// (the arrival-after-first-frame reorder) — never a second device in another manager.
|
||||
assert_eq!(
|
||||
route_decision(Some(DualSense), Xbox360, true),
|
||||
(DualSense, Some(DualSense))
|
||||
);
|
||||
// Removal (cleared bit): routes to the owner so the RIGHT device is torn down, then clears.
|
||||
assert_eq!(
|
||||
route_decision(Some(DualSense), Xbox360, false),
|
||||
(DualSense, None)
|
||||
);
|
||||
// Removal with no device is a harmless no-op route (owner stays cleared).
|
||||
assert_eq!(route_decision(None, Xbox360, false), (Xbox360, None));
|
||||
// A fresh device after a re-plug picks up the newly-declared kind (owner was cleared).
|
||||
assert_eq!(
|
||||
route_decision(None, Xbox360, true),
|
||||
(Xbox360, Some(Xbox360))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepad_resolution_precedence() {
|
||||
use GamepadPref::*;
|
||||
// Trailing args are (linux, windows).
|
||||
// An explicit client choice wins over the env var.
|
||||
assert_eq!(
|
||||
pick_gamepad(DualSense, Some("xbox360"), true, false),
|
||||
DualSense
|
||||
);
|
||||
assert_eq!(
|
||||
pick_gamepad(Xbox360, Some("dualsense"), true, false),
|
||||
Xbox360
|
||||
);
|
||||
// Client Auto defers to the env var.
|
||||
assert_eq!(
|
||||
pick_gamepad(Auto, Some("dualsense"), true, false),
|
||||
DualSense
|
||||
);
|
||||
assert_eq!(pick_gamepad(Auto, Some("xbox360"), true, false), Xbox360);
|
||||
// Auto + no env (or an unparseable one) → X-Box 360.
|
||||
assert_eq!(pick_gamepad(Auto, None, true, false), Xbox360);
|
||||
assert_eq!(pick_gamepad(Auto, Some("bogus"), true, false), Xbox360);
|
||||
// DualSense: honored on Linux (UHID) AND Windows (UMDF minidriver); degrades elsewhere.
|
||||
assert_eq!(pick_gamepad(DualSense, None, false, true), DualSense);
|
||||
assert_eq!(
|
||||
pick_gamepad(Auto, Some("dualsense"), false, true),
|
||||
DualSense
|
||||
);
|
||||
assert_eq!(pick_gamepad(DualSense, None, false, false), Xbox360);
|
||||
assert_eq!(pick_gamepad(Auto, Some("dualsense"), false, false), Xbox360);
|
||||
// DualShock 4: honored on Linux (UHID) AND Windows (UMDF minidriver); degrades elsewhere.
|
||||
assert_eq!(pick_gamepad(DualShock4, None, true, false), DualShock4);
|
||||
assert_eq!(pick_gamepad(Auto, Some("ps4"), true, false), DualShock4);
|
||||
assert_eq!(pick_gamepad(DualShock4, None, false, true), DualShock4);
|
||||
assert_eq!(pick_gamepad(DualShock4, None, false, false), Xbox360);
|
||||
// X-Box One: a distinct uinput identity on Linux, folded into the 360 pad on Windows.
|
||||
assert_eq!(pick_gamepad(XboxOne, None, true, false), XboxOne);
|
||||
assert_eq!(pick_gamepad(Auto, Some("series"), true, false), XboxOne);
|
||||
assert_eq!(pick_gamepad(XboxOne, None, false, true), Xbox360);
|
||||
|
||||
// Steam Deck: native on Linux (UHID/usbip/gadget) AND Windows (UMDF device-type 3,
|
||||
// Steam-Input-promoted via MI_02 — gamepad-new-types N4); Xbox360 elsewhere.
|
||||
assert_eq!(pick_gamepad(SteamDeck, None, true, false), SteamDeck);
|
||||
assert_eq!(pick_gamepad(SteamDeck, None, false, true), SteamDeck);
|
||||
assert_eq!(pick_gamepad(Auto, Some("deck"), false, true), SteamDeck);
|
||||
assert_eq!(pick_gamepad(SteamDeck, None, false, false), Xbox360);
|
||||
// Classic Steam Controller: native on Linux (UHID hid-steam); Xbox360 elsewhere.
|
||||
assert_eq!(
|
||||
pick_gamepad(SteamController, None, true, false),
|
||||
SteamController
|
||||
);
|
||||
assert_eq!(
|
||||
pick_gamepad(Auto, Some("steamcontroller"), true, false),
|
||||
SteamController
|
||||
);
|
||||
assert_eq!(pick_gamepad(SteamController, None, false, true), Xbox360);
|
||||
|
||||
// DualSense Edge: native on Linux (UHID) AND Windows (UMDF device-type 2); Xbox360
|
||||
// elsewhere.
|
||||
assert_eq!(
|
||||
pick_gamepad(DualSenseEdge, None, true, false),
|
||||
DualSenseEdge
|
||||
);
|
||||
assert_eq!(
|
||||
pick_gamepad(DualSenseEdge, None, false, true),
|
||||
DualSenseEdge
|
||||
);
|
||||
assert_eq!(pick_gamepad(Auto, Some("edge"), true, false), DualSenseEdge);
|
||||
assert_eq!(pick_gamepad(DualSenseEdge, None, false, false), Xbox360);
|
||||
// Switch Pro: native on Linux (UHID hid-nintendo); Xbox360 on Windows and elsewhere.
|
||||
assert_eq!(pick_gamepad(SwitchPro, None, true, false), SwitchPro);
|
||||
assert_eq!(
|
||||
pick_gamepad(Auto, Some("switchpro"), true, false),
|
||||
SwitchPro
|
||||
);
|
||||
assert_eq!(pick_gamepad(Auto, Some("switch"), true, false), SwitchPro);
|
||||
assert_eq!(pick_gamepad(SwitchPro, None, false, true), Xbox360);
|
||||
assert_eq!(pick_gamepad(SwitchPro, None, false, false), Xbox360);
|
||||
// New Steam Controller (as-is Triton passthrough): native on Linux (UHID, Steam-driven);
|
||||
// Xbox360 on Windows and elsewhere.
|
||||
assert_eq!(
|
||||
pick_gamepad(SteamController2, None, true, false),
|
||||
SteamController2
|
||||
);
|
||||
assert_eq!(
|
||||
pick_gamepad(Auto, Some("sc2"), true, false),
|
||||
SteamController2
|
||||
);
|
||||
assert_eq!(
|
||||
pick_gamepad(Auto, Some("ibex"), true, false),
|
||||
SteamController2
|
||||
);
|
||||
assert_eq!(pick_gamepad(SteamController2, None, false, true), Xbox360);
|
||||
assert_eq!(pick_gamepad(SteamController2, None, false, false), Xbox360);
|
||||
assert_eq!(
|
||||
pick_gamepad(SteamController2Puck, None, true, false),
|
||||
SteamController2Puck
|
||||
);
|
||||
assert_eq!(
|
||||
pick_gamepad(Auto, Some("sc2puck"), true, false),
|
||||
SteamController2Puck
|
||||
);
|
||||
assert_eq!(
|
||||
pick_gamepad(SteamController2Puck, None, false, true),
|
||||
Xbox360
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
//! The native `punktfunk/1` handshake negotiation (plan §W1 — carved out of the [`super`] module).
|
||||
//! After the pairing gate (which stays in `serve_session`, since its delegated-approval wait must
|
||||
//! outlive the short handshake timeout and release the session permit), this decodes the client's
|
||||
//! [`Hello`], runs mode-conflict admission, negotiates codec / compositor / gamepad / bitrate /
|
||||
//! audio channels / bit-depth / chroma, reserves the data-plane UDP socket, sends the [`Welcome`],
|
||||
//! and reads the client's [`Start`] — returning everything `serve_session` needs to stand the
|
||||
//! session up.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Run the Hello→Welcome→Start negotiation. Borrows the control streams (the caller keeps them for
|
||||
/// mid-stream renegotiation afterwards). `first` is the already-read first control message.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(super) async fn negotiate(
|
||||
conn: &quinn::Connection,
|
||||
send: &mut quinn::SendStream,
|
||||
recv: &mut quinn::RecvStream,
|
||||
first: &[u8],
|
||||
source: Punktfunk1Source,
|
||||
frames: u32,
|
||||
data_port: Option<u16>,
|
||||
) -> Result<(
|
||||
Hello,
|
||||
Welcome,
|
||||
u16,
|
||||
std::net::UdpSocket,
|
||||
bool,
|
||||
Start,
|
||||
Option<crate::vdisplay::Compositor>,
|
||||
)> {
|
||||
let peer = conn.remote_address();
|
||||
let mut hello = Hello::decode(first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
|
||||
if hello.abi_version != punktfunk_core::WIRE_VERSION {
|
||||
close_rejected(
|
||||
conn,
|
||||
punktfunk_core::reject::RejectReason::WireVersionMismatch,
|
||||
);
|
||||
anyhow::bail!(
|
||||
"wire version mismatch: client {} host {}",
|
||||
hello.abi_version,
|
||||
punktfunk_core::WIRE_VERSION
|
||||
);
|
||||
}
|
||||
// The pairing gate (require_pairing → paired? else park for delegated approval) ran above,
|
||||
// before this future, so a client reaching here is paired (or the host is `--open`).
|
||||
|
||||
// Codec negotiation: pick the one codec this host will emit (its GPU-probed backend
|
||||
// capability ∩ the client's advertised codecs, honoring the client's soft preference).
|
||||
// A GPU-less software host emits H.264 only, so an HEVC-only client shares nothing with
|
||||
// it → refuse honestly rather than send a stream it can't decode.
|
||||
let host_codecs = crate::encode::Codec::host_wire_caps();
|
||||
let codec_bit =
|
||||
punktfunk_core::quic::resolve_codec(hello.video_codecs, host_codecs, hello.preferred_codec)
|
||||
.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"no shared video codec: client advertised 0x{:02x}, host can emit 0x{:02x} \
|
||||
(a software-encode host produces H.264 — the client must advertise CODEC_H264)",
|
||||
hello.video_codecs,
|
||||
host_codecs
|
||||
)
|
||||
})?;
|
||||
let codec = crate::encode::Codec::from_wire(codec_bit);
|
||||
tracing::info!(
|
||||
?codec,
|
||||
client_codecs = format_args!("0x{:02x}", hello.video_codecs),
|
||||
host_codecs = format_args!("0x{host_codecs:02x}"),
|
||||
"video codec negotiated"
|
||||
);
|
||||
|
||||
// Mode-conflict ADMISSION (Stage 4): a DIFFERENT client connecting while another client's
|
||||
// session is live is resolved by the `mode_conflict` policy BEFORE the Welcome — `separate`
|
||||
// (default, no change), `join` (serve at the live mode — an honest downgrade the client
|
||||
// renders from the Welcome), `steal` (preempt the victim), or `reject` (refuse the handshake).
|
||||
// A same-client reconnect never conflicts. THIS session registers in the live set once its
|
||||
// data plane is up (below the handshake), so a later client can see + steal it.
|
||||
{
|
||||
use crate::vdisplay::admission::{admit, preempt_same_identity, Admission};
|
||||
let peer_fp = endpoint::peer_fingerprint(conn);
|
||||
|
||||
// Same-client RECONNECT preempt (design §5.3 "preempts downstream"): if THIS client
|
||||
// already has a live session, it's the zombie of an unwanted disconnect whose QUIC idle
|
||||
// timer hasn't fired yet (detection lags a drop by up to `max_idle_timeout`). Signal it to
|
||||
// stop and give it the release grace so it tears its display down — which, keep-alive on,
|
||||
// lingers — and THIS reconnect REUSES that kept display below instead of landing on a
|
||||
// fresh SECOND one. Independent of the mode_conflict arm (it's our OWN prior session, not
|
||||
// a conflict with a different client), and it runs before we register ourselves so we
|
||||
// never signal our own stop flag.
|
||||
let own_zombies = preempt_same_identity(peer_fp);
|
||||
if !own_zombies.is_empty() {
|
||||
tracing::info!(
|
||||
count = own_zombies.len(),
|
||||
"reconnect: preempting this client's own zombie session(s) so the kept display is reused"
|
||||
);
|
||||
for z in &own_zombies {
|
||||
z.store(true, Ordering::SeqCst);
|
||||
}
|
||||
// Same blind release grace the steal path uses — lets the zombie's loops notice the
|
||||
// stop flag and drop its display (→ Lingering) before we acquire below.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
||||
}
|
||||
|
||||
match admit(peer_fp) {
|
||||
Admission::Separate => {}
|
||||
Admission::Join(m) => {
|
||||
tracing::info!(
|
||||
requested =
|
||||
%format_args!("{}x{}@{}", hello.mode.width, hello.mode.height, hello.mode.refresh_hz),
|
||||
live = %format_args!("{}x{}@{}", m.0, m.1, m.2),
|
||||
"mode-conflict: JOIN — admitting at the live display's mode"
|
||||
);
|
||||
hello.mode.width = m.0;
|
||||
hello.mode.height = m.1;
|
||||
hello.mode.refresh_hz = m.2;
|
||||
}
|
||||
Admission::Steal(victims) => {
|
||||
tracing::info!(
|
||||
victims = victims.len(),
|
||||
"mode-conflict: STEAL — preempting the live session(s)"
|
||||
);
|
||||
for v in &victims {
|
||||
v.store(true, Ordering::SeqCst);
|
||||
}
|
||||
// Give the victims the release grace to tear their display down before we acquire.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
||||
}
|
||||
Admission::Reject(reason) => {
|
||||
tracing::warn!("mode-conflict: REJECT — {reason}");
|
||||
// Deliver the reason to the client as a TYPED refusal: close the QUIC connection
|
||||
// with the BUSY application code + the reason bytes, which the client reads from
|
||||
// the `ApplicationClosed` error (so its UI can say "host is streaming X to <name>")
|
||||
// instead of seeing a bare connection drop. Then end the handshake.
|
||||
conn.close(REJECT_BUSY_CODE.into(), reason.as_bytes());
|
||||
anyhow::bail!("{reason}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
crate::encode::validate_dimensions(codec, hello.mode.width, hello.mode.height)
|
||||
.context("client-requested mode")?;
|
||||
|
||||
// Resolve the client's compositor preference to a concrete backend *now*, so the Welcome
|
||||
// can report what we'll actually drive. Only the Virtual source has a compositor; the
|
||||
// synthetic source has no virtual output. Blocking probes → spawn_blocking.
|
||||
let compositor = match source {
|
||||
Punktfunk1Source::Virtual => {
|
||||
let pref = hello.compositor;
|
||||
// Dedicated game session (B0): a launching client under `game_session=dedicated`
|
||||
// (gamescope available) gets its own headless gamescope spawn at the client mode. Gate on
|
||||
// whether the launch id actually RESOLVES to a command in the host's library — an unknown
|
||||
// id must fall back to normal auto routing, not a blank "sleep infinity" gamescope
|
||||
// (review #9). (dedicated is Linux-only; the resolver is the non-Windows launch_command.)
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let has_resolvable_launch = hello
|
||||
.launch
|
||||
.as_deref()
|
||||
.and_then(crate::library::launch_command)
|
||||
.is_some();
|
||||
#[cfg(target_os = "windows")]
|
||||
let has_resolvable_launch = false;
|
||||
let dedicated = crate::vdisplay::wants_dedicated_game_session(has_resolvable_launch);
|
||||
Some(
|
||||
tokio::task::spawn_blocking(move || resolve_compositor(pref, dedicated))
|
||||
.await
|
||||
.context("resolve compositor task")??,
|
||||
)
|
||||
}
|
||||
Punktfunk1Source::Synthetic => None,
|
||||
};
|
||||
|
||||
// A requested library launch (the client sends only the store-qualified id; we look it up
|
||||
// in OUR library so a client can't inject a command) is resolved below — after the Welcome,
|
||||
// where it's threaded per-session into the data plane as `SessionContext.launch` (no
|
||||
// process-global env: the old `PUNKTFUNK_GAMESCOPE_APP` write leaked across sessions, and
|
||||
// only gamescope's bare-spawn path ever read it, so launches on every other backend were
|
||||
// silently dropped).
|
||||
|
||||
// Resolve the client's gamepad-backend preference (pure env/cfg check — no probing
|
||||
// needed; the actual pads are created lazily by the input thread).
|
||||
let gamepad = resolve_gamepad(hello.gamepad);
|
||||
|
||||
// Resolve the encoder bitrate (client request clamped to a sane range, or a
|
||||
// codec-aware host default — PyroWave pins ~1.6 bpp for the mode).
|
||||
let bitrate_kbps = resolve_bitrate_kbps_for(codec, hello.bitrate_kbps, &hello.mode);
|
||||
tracing::info!(
|
||||
requested_kbps = hello.bitrate_kbps,
|
||||
resolved_kbps = bitrate_kbps,
|
||||
"encoder bitrate"
|
||||
);
|
||||
|
||||
// Resolve the audio channel count (client request → stereo / 5.1 / 7.1). The capturer opens
|
||||
// at this count: PipeWire synthesizes the requested positions (padding with silence when the
|
||||
// sink has fewer), WASAPI loopback up/downmixes via AUTOCONVERTPCM — so a client always gets
|
||||
// the channels it asked for, and the Welcome echoes the value the audio thread will encode.
|
||||
let audio_channels = resolve_audio_channels(hello.audio_channels);
|
||||
tracing::info!(
|
||||
requested = hello.audio_channels,
|
||||
resolved = audio_channels,
|
||||
"audio channels"
|
||||
);
|
||||
|
||||
// Resolve the encode bit depth: 10-bit (HEVC Main10 / AV1 10-bit) only when ALL of — the
|
||||
// host allows it (PUNKTFUNK_10BIT, default ON with explicit-off grammar; the CLIENT's HDR
|
||||
// setting behind VIDEO_CAP_10BIT is the per-session policy switch), the client advertised
|
||||
// VIDEO_CAP_10BIT (a client that can't decode 10-bit, or an older client, always gets the
|
||||
// 8-bit stream), the codec has a 10-bit path (HEVC/AV1 — H.264 never), and the active
|
||||
// GPU/backend actually encodes 10-bit for that codec (probed, cached). Resolved BEFORE the
|
||||
// Welcome, exactly like the 4:4:4 gate below, so `color` reflects what we'll really emit —
|
||||
// the honest-downgrade channel: a GPU/backend that can't 10-bit yields 8-bit AND an SDR
|
||||
// label that matches the stream.
|
||||
let host_wants_10bit = crate::config::config().ten_bit;
|
||||
let client_supports_10bit = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_10BIT != 0;
|
||||
// The GPU probe may open a tiny encoder on first use, so run it off the reactor like the
|
||||
// 4:4:4 probe below (blocking probes → spawn_blocking), short-circuited behind the cheap
|
||||
// gates. The result is cached process-wide per (GPU, codec).
|
||||
let gpu_can_10bit = if host_wants_10bit && client_supports_10bit && codec.supports_10bit() {
|
||||
tokio::task::spawn_blocking(move || crate::encode::can_encode_10bit(codec))
|
||||
.await
|
||||
.context("10-bit capability probe task")?
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let bit_depth: u8 = if gpu_can_10bit { 10 } else { 8 };
|
||||
tracing::info!(
|
||||
bit_depth,
|
||||
host_wants_10bit,
|
||||
client_supports_10bit,
|
||||
codec = ?codec,
|
||||
gpu_can_10bit,
|
||||
client_video_caps = hello.video_caps,
|
||||
"encode bit depth"
|
||||
);
|
||||
|
||||
// Resolve the chroma subsampling: full-chroma HEVC 4:4:4 only when ALL of — the host
|
||||
// allows it (PUNKTFUNK_444, default ON; the CLIENT's 4:4:4 setting — default OFF — is the
|
||||
// per-session policy switch behind VIDEO_CAP_444), the client advertised VIDEO_CAP_444,
|
||||
// the session is single-process (the two-process WGC relay encodes 4:2:0 in v1), and the
|
||||
// active GPU/driver actually supports a 4:4:4 encode (probed, cached). The native path
|
||||
// always encodes HEVC. We resolve this BEFORE the Welcome so `chroma_format` reflects
|
||||
// what we'll really emit — the honest-downgrade channel: if any gate fails the client is
|
||||
// told 4:2:0 before it builds its decoder. The probe opens a tiny encoder; it runs only
|
||||
// when the earlier gates pass and is cached after the first.
|
||||
let host_wants_444 = crate::config::config().four_four_four;
|
||||
let client_supports_444 = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_444 != 0;
|
||||
// The active capturer must be able to deliver a full-chroma (RGB) source — the honest-downgrade
|
||||
// gate. Linux's portal capturer can; the Windows IDD-push path delivers subsampled NV12/P010
|
||||
// today (full-chroma IDD-push capture is a follow-up), so it returns false there and the host
|
||||
// negotiates 4:2:0. (Replaces the old `single_process` gate — single-process is now the only
|
||||
// topology, and 4:4:4 routed to DDA, which was removed.)
|
||||
let capture_supports_444 = crate::capture::capturer_supports_444();
|
||||
// The GPU probe opens a real (tiny) encoder on first use, so run it off the reactor like the
|
||||
// compositor probe above (blocking probes → spawn_blocking). Short-circuit so it only runs when
|
||||
// the cheap gates already pass. The result is cached process-wide (a negative latches until
|
||||
// restart — acceptable: a GPU either supports HEVC 4:4:4 or it doesn't, and a transient open
|
||||
// failure here is rare since the session's own encoder isn't open yet).
|
||||
let gpu_supports_444 = if codec == crate::encode::Codec::H265
|
||||
&& host_wants_444
|
||||
&& client_supports_444
|
||||
&& capture_supports_444
|
||||
{
|
||||
tokio::task::spawn_blocking(|| crate::encode::can_encode_444(crate::encode::Codec::H265))
|
||||
.await
|
||||
.context("4:4:4 capability probe task")?
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let chroma = if gpu_supports_444 {
|
||||
crate::encode::ChromaFormat::Yuv444
|
||||
} else {
|
||||
crate::encode::ChromaFormat::Yuv420
|
||||
};
|
||||
tracing::info!(
|
||||
chroma = ?chroma,
|
||||
host_wants_444,
|
||||
client_supports_444,
|
||||
capture_supports_444,
|
||||
"encode chroma"
|
||||
);
|
||||
|
||||
// Linux 4:4:4 rides the CPU swscale → 8-bit `YUV444P` path (see `encode/linux`) — there
|
||||
// is no 10-bit 4:4:4 input there, so a 10-bit-negotiated session would silently encode
|
||||
// 8-bit. Resolve the depth DOWN before the Welcome so the wire never overstates what the
|
||||
// stream carries. (Windows NVENC composes Main 4:4:4 10 from an RGB input, so it keeps
|
||||
// the resolved depth — this clamp is Linux-only.)
|
||||
#[cfg(target_os = "linux")]
|
||||
let bit_depth: u8 = if chroma.is_444() && bit_depth == 10 {
|
||||
tracing::info!("4:4:4 on the Linux path encodes 8-bit YUV444P — resolving bit depth 8");
|
||||
8
|
||||
} else {
|
||||
bit_depth
|
||||
};
|
||||
|
||||
// Reserve the data-plane UDP socket up front and HOLD it through streaming (no
|
||||
// bind→read→drop→rebind window a concurrent session could race for a fixed port). A fixed
|
||||
// `--data-port` yields `direct = true` (stream straight to the client's reported address,
|
||||
// no punch-wait); otherwise a random ephemeral port + hole-punch.
|
||||
let (data_sock, direct) = bind_data_socket(data_port)?;
|
||||
let udp_port = data_sock.local_addr()?.port();
|
||||
|
||||
let mut key = [0u8; 16];
|
||||
rand::thread_rng().fill_bytes(&mut key);
|
||||
// Fresh per-session salt alongside the fresh key. GCM nonce uniqueness only *requires* one
|
||||
// of the two to be unique per session (the nonce is salt || sequence under the session
|
||||
// key), but a constant salt would make a key-reuse bug catastrophic instead of merely
|
||||
// wrong — this keeps the second line of defense real. Negotiated via Welcome, so clients
|
||||
// just follow.
|
||||
let mut salt = [0u8; 4];
|
||||
rand::thread_rng().fill_bytes(&mut salt);
|
||||
let welcome = Welcome {
|
||||
abi_version: punktfunk_core::WIRE_VERSION,
|
||||
udp_port,
|
||||
mode: hello.mode,
|
||||
// The post-GameStream point of punktfunk/1: Leopard GF(2¹⁶) FEC + real encryption.
|
||||
fec: FecConfig {
|
||||
scheme: FecScheme::Gf16,
|
||||
// Static override pins it; otherwise sessions start at the adaptive midpoint and the
|
||||
// host re-sizes FEC live from the client's LossReports (adaptive FEC).
|
||||
fec_percent: fec_static_override().unwrap_or(FEC_ADAPTIVE_START),
|
||||
max_data_per_block: 4096,
|
||||
},
|
||||
// The largest even payload whose sealed datagram (header + shard + crypto) fits an
|
||||
// unfragmented UDP packet on a 1500 MTU for THIS client's address family — 1408 over
|
||||
// IPv4 (1472 = the exact ceiling), 1388 over IPv6 (40-byte header, and v6 routers
|
||||
// don't fragment: overshooting there blackholes instead of degrading). The data plane
|
||||
// dials the same family as this QUIC connection, so the remote decides. The previous
|
||||
// hardcoded 1452 overshot the v4 ceiling (its math forgot the header/crypto ride
|
||||
// inside the UDP payload) and silently IP-fragmented EVERY video datagram, doubling
|
||||
// per-datagram loss on Wi-Fi — the "100 Mbps badly fails on the phone" root cause.
|
||||
// Negotiated, so the client follows. Jumbo (≈8900) is a future negotiated bump (needs
|
||||
// MAX_DATAGRAM_BYTES raised + end-to-end 9000 MTU).
|
||||
shard_payload: mtu1500_shard_payload_for(peer.ip()) as u16,
|
||||
encrypt: true,
|
||||
key,
|
||||
salt,
|
||||
frames: match source {
|
||||
Punktfunk1Source::Synthetic => frames,
|
||||
Punktfunk1Source::Virtual => 0, // unbounded — client streams until we close
|
||||
},
|
||||
// Report the resolved backends back to the client (compositor: Auto for the
|
||||
// synthetic source).
|
||||
compositor: compositor
|
||||
.map(|c| c.as_pref())
|
||||
.unwrap_or(CompositorPref::Auto),
|
||||
gamepad,
|
||||
bitrate_kbps,
|
||||
bit_depth,
|
||||
// Colour signalling the client configures its decoder/presenter from. A negotiated
|
||||
// 10-bit session is our HDR path (BT.2020 PQ — what the NVENC HEVC VUI emits from a
|
||||
// 10-bit capture format); 8-bit stays BT.709 SDR. The mastering metadata (ST.2086 +
|
||||
// CLL) rides the 0xCE datagram below. (A future step can refine this to the capturer's
|
||||
// actual monitor HDR state and announce a mid-stream flip.)
|
||||
color: if bit_depth >= 10 {
|
||||
ColorInfo::HDR10_BT2020_PQ
|
||||
} else {
|
||||
ColorInfo::SDR_BT709
|
||||
},
|
||||
// The chroma the encoder will actually emit (resolved + GPU-probed above) — 4:4:4 only
|
||||
// when every gate passed, else 4:2:0. The client sizes its decoder from this.
|
||||
chroma_format: chroma.idc(),
|
||||
// The resolved audio channel count the audio thread will capture + Opus-(multi)stream
|
||||
// encode (2/6/8). The client builds its decoder from this echoed value.
|
||||
audio_channels,
|
||||
// The negotiated codec the encoder will emit (client preference ∩ GPU capability;
|
||||
// HEVC-precedence tie-break). The client builds its decoder from this instead of
|
||||
// assuming HEVC.
|
||||
codec: codec_bit,
|
||||
// This host applies sequence-gated gamepad-state snapshots (InputKind::GamepadState),
|
||||
// so capable clients send those instead of the loss-fragile per-transition events.
|
||||
host_caps: punktfunk_core::quic::HOST_CAP_GAMEPAD_STATE,
|
||||
};
|
||||
io::write_msg(send, &welcome.encode()).await?;
|
||||
|
||||
let start =
|
||||
Start::decode(&io::read_msg(recv).await?).map_err(|e| anyhow!("Start decode: {e:?}"))?;
|
||||
Ok::<_, anyhow::Error>((
|
||||
hello, welcome, udp_port, data_sock, direct, start, compositor,
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,998 @@
|
||||
//! The native input plane (plan §W1 — carved out of the [`super`] module): the client→host input
|
||||
//! thread and the per-pad virtual-gamepad router ([`Pads`]) that fans mixed controller kinds out to
|
||||
//! the right injector backend (uinput / UHID on Linux, XUSB / UMDF on Windows), plus rumble
|
||||
//! feedback. `serve_session` spawns [`input_thread`] and feeds it a channel of [`ClientInput`].
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Per-pad accumulated state: punktfunk/1 gamepad events are incremental (one button or axis
|
||||
/// per datagram, see `punktfunk_core::input::gamepad`), the virtual xpad applies full frames.
|
||||
/// A snapshot-capable client replaces the whole state at once ([`PadState::set_snapshot`]).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
struct PadState {
|
||||
buttons: u32,
|
||||
left_trigger: u8,
|
||||
right_trigger: u8,
|
||||
ls_x: i16,
|
||||
ls_y: i16,
|
||||
rs_x: i16,
|
||||
rs_y: i16,
|
||||
}
|
||||
|
||||
impl PadState {
|
||||
/// Fold one wire event into the state. `false` = unknown axis id (event dropped).
|
||||
fn apply(&mut self, ev: &InputEvent) -> bool {
|
||||
if ev.kind == InputKind::GamepadButton {
|
||||
if ev.x != 0 {
|
||||
self.buttons |= ev.code;
|
||||
} else {
|
||||
self.buttons &= !ev.code;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
use punktfunk_core::input::gamepad::*;
|
||||
let stick = ev.x.clamp(i16::MIN as i32, i16::MAX as i32) as i16;
|
||||
let trigger = ev.x.clamp(0, 255) as u8;
|
||||
match ev.code {
|
||||
AXIS_LS_X => self.ls_x = stick,
|
||||
AXIS_LS_Y => self.ls_y = stick,
|
||||
AXIS_RS_X => self.rs_x = stick,
|
||||
AXIS_RS_Y => self.rs_y = stick,
|
||||
AXIS_LT => self.left_trigger = trigger,
|
||||
AXIS_RT => self.right_trigger = trigger,
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Replace the whole state from one client snapshot (the [`InputKind::GamepadState`] form).
|
||||
fn set_snapshot(&mut self, s: &punktfunk_core::input::GamepadSnapshot) {
|
||||
self.buttons = s.buttons;
|
||||
self.left_trigger = s.left_trigger;
|
||||
self.right_trigger = s.right_trigger;
|
||||
self.ls_x = s.ls_x;
|
||||
self.ls_y = s.ls_y;
|
||||
self.rs_x = s.rs_x;
|
||||
self.rs_y = s.rs_y;
|
||||
}
|
||||
|
||||
fn frame(&self, index: usize, active_mask: u16) -> crate::gamestream::gamepad::GamepadFrame {
|
||||
crate::gamestream::gamepad::GamepadFrame {
|
||||
index: index as i16,
|
||||
active_mask,
|
||||
buttons: self.buttons,
|
||||
left_trigger: self.left_trigger,
|
||||
right_trigger: self.right_trigger,
|
||||
ls_x: self.ls_x,
|
||||
ls_y: self.ls_y,
|
||||
rs_x: self.rs_x,
|
||||
rs_y: self.rs_y,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Highest pad index addressable on the wire (`flags` field / snapshot `pad`); the uinput
|
||||
/// manager caps actual pad creation at its own MAX_PADS.
|
||||
const MAX_WIRE_PADS: usize = punktfunk_core::input::MAX_PADS;
|
||||
|
||||
/// Per-pad virtual-gamepad router: each pad index is served by a backend of that pad's declared
|
||||
/// kind ([`InputKind::GamepadArrival`](punktfunk_core::input::InputKind::GamepadArrival)), so ONE
|
||||
/// session can MIX controller types — pad 0 a DualSense, pad 1 an Xbox pad. A pad the client never
|
||||
/// declares uses `default` (the session kind resolved from the Hello — the pre-existing single-kind
|
||||
/// behaviour).
|
||||
///
|
||||
/// Backends are created lazily per kind (an empty manager holds no device), and each owns only the
|
||||
/// indices routed to it. A manager's `active_mask` unplug sweep stays correct across managers
|
||||
/// because an index another manager owns is `None` in this one, so the sweep never touches it.
|
||||
///
|
||||
/// - Xbox 360 / One — uinput on Linux ([`GamepadManager`](crate::inject::gamepad::GamepadManager),
|
||||
/// two identities), the XUSB companion driver (classic XInput) on Windows.
|
||||
/// - DualSense / DualSense Edge / DualShock 4 — Linux UHID `hid-playstation`, or the Windows UMDF
|
||||
/// minidriver (device-type 0/2/1).
|
||||
/// - Steam Deck — Linux UHID `hid-steam` (or usbip/gadget), or the Windows UMDF minidriver
|
||||
/// (device-type 3, Steam-Input-promoted).
|
||||
///
|
||||
/// [`resolve_pad_kind`] folds any kind a platform can't build into one it can, so this never
|
||||
/// constructs a manager the build lacks.
|
||||
struct Pads {
|
||||
/// Declared (and host-resolved) kind per pad index; `default` until a `GamepadArrival` lands.
|
||||
kinds: [GamepadPref; MAX_WIRE_PADS],
|
||||
/// The kind of the manager that currently OWNS a built device at each index (`None` = no
|
||||
/// device). A live device stays in its manager even if `kinds[idx]` later changes (the rare
|
||||
/// arrival-after-first-frame reorder), so a pad is never duplicated across managers and its
|
||||
/// removal always reaches the manager that actually holds it.
|
||||
owner: [Option<GamepadPref>; MAX_WIRE_PADS],
|
||||
xbox360: Option<crate::inject::gamepad::GamepadManager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
xboxone: Option<crate::inject::gamepad::GamepadManager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
dualsense: Option<crate::inject::dualsense::DualSenseManager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
dualsense_edge: Option<crate::inject::dualsense::DualSenseEdgeManager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
dualshock4: Option<crate::inject::dualshock4::DualShock4Manager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamdeck: Option<crate::inject::steam_controller::SteamControllerManager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
switchpro: Option<crate::inject::switch_pro::SwitchProManager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamctrl: Option<crate::inject::steam_controller::SteamCtrlManager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamctrl2: Option<crate::inject::steam_controller2::Triton2Manager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamctrl2_puck: Option<crate::inject::steam_controller2::Triton2Manager>,
|
||||
#[cfg(target_os = "windows")]
|
||||
dualsense_win: Option<crate::inject::dualsense_windows::DualSenseWindowsManager>,
|
||||
#[cfg(target_os = "windows")]
|
||||
dualsense_edge_win: Option<crate::inject::dualsense_edge_windows::DualSenseEdgeWindowsManager>,
|
||||
#[cfg(target_os = "windows")]
|
||||
dualshock4_win: Option<crate::inject::dualshock4_windows::DualShock4WindowsManager>,
|
||||
#[cfg(target_os = "windows")]
|
||||
steamdeck_win: Option<crate::inject::steam_deck_windows::SteamDeckWindowsManager>,
|
||||
}
|
||||
|
||||
impl Pads {
|
||||
/// `default` is the session kind (see [`resolve_gamepad`]); every pad starts on it until the
|
||||
/// client declares its own kind.
|
||||
fn new(default: GamepadPref) -> Pads {
|
||||
let default = resolve_pad_kind(default);
|
||||
tracing::info!(
|
||||
default = default.as_str(),
|
||||
"gamepad backends: per-pad router (session default)"
|
||||
);
|
||||
Pads {
|
||||
kinds: [default; MAX_WIRE_PADS],
|
||||
owner: [None; MAX_WIRE_PADS],
|
||||
xbox360: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
xboxone: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
dualsense: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
dualsense_edge: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
dualshock4: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamdeck: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
switchpro: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamctrl: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamctrl2: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamctrl2_puck: None,
|
||||
#[cfg(target_os = "windows")]
|
||||
dualsense_win: None,
|
||||
#[cfg(target_os = "windows")]
|
||||
dualsense_edge_win: None,
|
||||
#[cfg(target_os = "windows")]
|
||||
dualshock4_win: None,
|
||||
#[cfg(target_os = "windows")]
|
||||
steamdeck_win: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a pad's client-declared kind (resolved to a buildable backend). Takes effect on the
|
||||
/// pad's next frame; the arrival is sent before the pad's first input, so a device already
|
||||
/// built under the wrong kind is only the rare arrival-after-first-frame reorder — it then
|
||||
/// keeps the earlier kind until re-plug (no live device swap).
|
||||
fn set_kind(&mut self, idx: usize, kind: GamepadPref) {
|
||||
if idx >= MAX_WIRE_PADS {
|
||||
return;
|
||||
}
|
||||
let resolved = resolve_pad_kind(kind);
|
||||
if self.kinds[idx] != resolved {
|
||||
tracing::info!(
|
||||
pad = idx,
|
||||
kind = resolved.as_str(),
|
||||
"gamepad kind declared (per-pad)"
|
||||
);
|
||||
}
|
||||
self.kinds[idx] = resolved;
|
||||
}
|
||||
|
||||
fn handle(&mut self, ev: &crate::gamestream::gamepad::GamepadEvent) {
|
||||
use crate::gamestream::gamepad::GamepadEvent;
|
||||
// Present = a create/update frame (the pad's mask bit is set); a cleared bit is the
|
||||
// removal frame emitted by the native detach path (`GamepadRemove`).
|
||||
let (idx, present) = match ev {
|
||||
GamepadEvent::State(f) => {
|
||||
let idx = f.index as usize;
|
||||
(idx, f.active_mask & (1 << idx) != 0)
|
||||
}
|
||||
GamepadEvent::Arrival { index, .. } => (*index as usize, true),
|
||||
};
|
||||
if idx >= MAX_WIRE_PADS {
|
||||
return;
|
||||
}
|
||||
let (kind, new_owner) = route_decision(self.owner[idx], self.kinds[idx], present);
|
||||
self.owner[idx] = new_owner;
|
||||
self.route_handle(kind, ev);
|
||||
}
|
||||
|
||||
/// Dispatch a decoded event to the manager for `kind`, creating it lazily.
|
||||
fn route_handle(&mut self, kind: GamepadPref, ev: &crate::gamestream::gamepad::GamepadEvent) {
|
||||
match kind {
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::DualSense => self
|
||||
.dualsense
|
||||
.get_or_insert_with(crate::inject::dualsense::DualSenseManager::new)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::DualSenseEdge => self
|
||||
.dualsense_edge
|
||||
.get_or_insert_with(crate::inject::dualsense::DualSenseEdgeManager::new)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::DualShock4 => self
|
||||
.dualshock4
|
||||
.get_or_insert_with(crate::inject::dualshock4::DualShock4Manager::new)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::SteamDeck => self
|
||||
.steamdeck
|
||||
.get_or_insert_with(crate::inject::steam_controller::SteamControllerManager::new)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::SwitchPro => self
|
||||
.switchpro
|
||||
.get_or_insert_with(crate::inject::switch_pro::SwitchProManager::new)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::SteamController => self
|
||||
.steamctrl
|
||||
.get_or_insert_with(crate::inject::steam_controller::SteamCtrlManager::new)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::SteamController2 => self
|
||||
.steamctrl2
|
||||
.get_or_insert_with(crate::inject::steam_controller2::Triton2Manager::new)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::SteamController2Puck => self
|
||||
.steamctrl2_puck
|
||||
.get_or_insert_with(|| {
|
||||
crate::inject::steam_controller2::Triton2Manager::with_backend(
|
||||
crate::inject::steam_controller2::TritonProto::puck(),
|
||||
)
|
||||
})
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::XboxOne => self
|
||||
.xboxone
|
||||
.get_or_insert_with(|| {
|
||||
crate::inject::gamepad::GamepadManager::with_identity(
|
||||
crate::inject::gamepad::PadIdentity::xbox_one(),
|
||||
)
|
||||
})
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "windows")]
|
||||
GamepadPref::DualSense => self
|
||||
.dualsense_win
|
||||
.get_or_insert_with(crate::inject::dualsense_windows::DualSenseWindowsManager::new)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "windows")]
|
||||
GamepadPref::DualSenseEdge => self
|
||||
.dualsense_edge_win
|
||||
.get_or_insert_with(
|
||||
crate::inject::dualsense_edge_windows::DualSenseEdgeWindowsManager::new,
|
||||
)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "windows")]
|
||||
GamepadPref::DualShock4 => self
|
||||
.dualshock4_win
|
||||
.get_or_insert_with(
|
||||
crate::inject::dualshock4_windows::DualShock4WindowsManager::new,
|
||||
)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "windows")]
|
||||
GamepadPref::SteamDeck => self
|
||||
.steamdeck_win
|
||||
.get_or_insert_with(crate::inject::steam_deck_windows::SteamDeckWindowsManager::new)
|
||||
.handle(ev),
|
||||
_ => self
|
||||
.xbox360
|
||||
.get_or_insert_with(crate::inject::gamepad::GamepadManager::new)
|
||||
.handle(ev),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a rich client→host event (touchpad / motion) to the pad's kind manager, if it exists
|
||||
/// (rich before the first frame = no device yet = a no-op anyway). The X-Box pads have no rich
|
||||
/// plane, so those indices ignore it.
|
||||
fn apply_rich(&mut self, rich: punktfunk_core::quic::RichInput) {
|
||||
use punktfunk_core::quic::RichInput;
|
||||
let idx = match rich {
|
||||
RichInput::Touchpad { pad, .. }
|
||||
| RichInput::Motion { pad, .. }
|
||||
| RichInput::TouchpadEx { pad, .. }
|
||||
| RichInput::HidReport { pad, .. } => pad as usize,
|
||||
};
|
||||
// Route to the manager that actually owns the device (falling back to the declared kind
|
||||
// before the first frame builds it), so a pad's touchpad/motion never lands on the wrong
|
||||
// backend after a kind change.
|
||||
let kind = self
|
||||
.owner
|
||||
.get(idx)
|
||||
.copied()
|
||||
.flatten()
|
||||
.or_else(|| self.kinds.get(idx).copied())
|
||||
.unwrap_or(GamepadPref::Xbox360);
|
||||
match kind {
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::DualSense => {
|
||||
if let Some(m) = &mut self.dualsense {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::DualSenseEdge => {
|
||||
if let Some(m) = &mut self.dualsense_edge {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::DualShock4 => {
|
||||
if let Some(m) = &mut self.dualshock4 {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::SteamDeck => {
|
||||
if let Some(m) = &mut self.steamdeck {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::SwitchPro => {
|
||||
if let Some(m) = &mut self.switchpro {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::SteamController => {
|
||||
if let Some(m) = &mut self.steamctrl {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::SteamController2 => {
|
||||
if let Some(m) = &mut self.steamctrl2 {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::SteamController2Puck => {
|
||||
if let Some(m) = &mut self.steamctrl2_puck {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
GamepadPref::DualSense => {
|
||||
if let Some(m) = &mut self.dualsense_win {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
GamepadPref::DualSenseEdge => {
|
||||
if let Some(m) = &mut self.dualsense_edge_win {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
GamepadPref::DualShock4 => {
|
||||
if let Some(m) = &mut self.dualshock4_win {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
GamepadPref::SteamDeck => {
|
||||
if let Some(m) = &mut self.steamdeck_win {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Triton's USB output endpoint is polled at 1 kHz. Service its raw haptic writes on the same
|
||||
/// cadence so PC-generated trackpad pulses do not sit for up to 4 ms and then arrive at the
|
||||
/// client in bursts. Other backends keep the lower-frequency poll to avoid idle churn.
|
||||
fn feedback_poll_interval(&self) -> std::time::Duration {
|
||||
#[cfg(target_os = "linux")]
|
||||
if self.steamctrl2.is_some() || self.steamctrl2_puck.is_some() {
|
||||
return std::time::Duration::from_millis(1);
|
||||
}
|
||||
std::time::Duration::from_millis(4)
|
||||
}
|
||||
|
||||
/// Service feedback for every instantiated backend each cycle. `rumble` carries motor
|
||||
/// force-feedback on the universal plane (every backend, tagged with its own pad index);
|
||||
/// `hidout` carries rich feedback (lightbar / player LEDs / adaptive triggers) for the UHID/UMDF
|
||||
/// pads. The `&mut` closure re-borrows satisfy `FnMut` for each backend.
|
||||
fn pump(
|
||||
&mut self,
|
||||
mut rumble: impl FnMut(u16, u16, u16),
|
||||
mut hidout: impl FnMut(punktfunk_core::quic::HidOutput),
|
||||
) {
|
||||
if let Some(m) = &mut self.xbox360 {
|
||||
m.pump_rumble(&mut rumble); // the X-Box pad has no rich-feedback plane
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Some(m) = &mut self.xboxone {
|
||||
m.pump_rumble(&mut rumble);
|
||||
}
|
||||
if let Some(m) = &mut self.dualsense {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.dualsense_edge {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.dualshock4 {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.steamdeck {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.switchpro {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.steamctrl {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.steamctrl2 {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.steamctrl2_puck {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(m) = &mut self.dualsense_win {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.dualsense_edge_win {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.dualshock4_win {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.steamdeck_win {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep every instantiated virtual UHID/UMDF pad alive during input silence (re-emit its HID
|
||||
/// report so the kernel driver / SDL don't drop a held-steady pad). The X-Box pads need no
|
||||
/// heartbeat (evdev holds last-known state). Per-pad gap timers inside each manager govern the
|
||||
/// actual emit cadence, not this per-tick call.
|
||||
fn heartbeat(&mut self) {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let gap = std::time::Duration::from_millis(8);
|
||||
if let Some(m) = &mut self.dualsense {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
if let Some(m) = &mut self.dualsense_edge {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
if let Some(m) = &mut self.dualshock4 {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
if let Some(m) = &mut self.steamdeck {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
if let Some(m) = &mut self.switchpro {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
if let Some(m) = &mut self.steamctrl {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
if let Some(m) = &mut self.steamctrl2 {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let gap = std::time::Duration::from_millis(8);
|
||||
if let Some(m) = &mut self.dualsense_win {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
if let Some(m) = &mut self.dualsense_edge_win {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
if let Some(m) = &mut self.dualshock4_win {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
if let Some(m) = &mut self.steamdeck_win {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One client→host input item, both planes on ONE channel so the input thread wakes the
|
||||
/// moment either arrives (a second rich channel drained after the 4 ms recv timeout cost
|
||||
/// every pure-gyro motion sample up to 4 ms of quantization).
|
||||
pub(super) enum ClientInput {
|
||||
/// The 0xC8 plane: pointer / keyboard / gamepad button+axis.
|
||||
Event(InputEvent),
|
||||
/// The 0xCC plane: touchpad contacts + motion samples.
|
||||
Rich(punktfunk_core::quic::RichInput),
|
||||
}
|
||||
|
||||
/// Default TTL stamped on a non-zero rumble envelope (0xCA v2): how long the client renders the
|
||||
/// level before silencing unless the host renews it. Tolerates 2–3 lost renewals (same loss
|
||||
/// margin the old flat 500 ms refresh gave) while capping a host-abandoned rumble at this on every
|
||||
/// client — versus the per-platform client heuristics it replaces (SDL 1.5 s, Apple 1.6 s, Android
|
||||
/// up to the QUIC idle-timeout). Overridable via `PUNKTFUNK_RUMBLE_TTL_MS` (floored at
|
||||
/// [`RUMBLE_TTL_FLOOR_MS`] so expiry jitter stays below the clients' tick granularity).
|
||||
const RUMBLE_TTL_MS: u16 = 400;
|
||||
/// Floor for the `PUNKTFUNK_RUMBLE_TTL_MS` hatch — below this the ~50 ms client ticks make expiry
|
||||
/// audible (see `rumble-envelope-plan.md` §5).
|
||||
const RUMBLE_TTL_FLOOR_MS: u16 = 150;
|
||||
/// Ceiling for the `PUNKTFUNK_RUMBLE_TTL_MS` hatch. A lease longer than a few seconds defeats the
|
||||
/// design's "an abandoned rumble stops promptly" goal, and keeping it well under `u16::MAX` means
|
||||
/// the wire never emits a TTL a narrower client-side slot could mistake for a sentinel.
|
||||
const RUMBLE_TTL_CEIL_MS: u16 = 5_000;
|
||||
/// Floor for the derived renewal interval (renew = ttl × 3/10) so an aggressive TTL hatch can't
|
||||
/// spin the renewal loop faster than this.
|
||||
const RUMBLE_RENEW_FLOOR_MS: u64 = 60;
|
||||
/// How many times a transition-to-zero (a stop) is re-sent on the renewal ticks after the
|
||||
/// immediate stop datagram, before the pad goes quiet. Covers stop-datagram loss for legacy
|
||||
/// clients (a v2 client also self-silences at TTL); even a fully lost burst heals via the client's
|
||||
/// own expiry. `3` total zero sends = the immediate one + this many renewal re-sends.
|
||||
const RUMBLE_STOP_BURST: u8 = 2;
|
||||
|
||||
/// Send one rumble datagram on the universal 0xCA plane. `envelope_on` picks the self-terminating
|
||||
/// v2 form (`[level][seq][ttl_ms]`, the default) or the legacy v1 level datagram (the
|
||||
/// `PUNKTFUNK_RUMBLE_ENVELOPE=0` bisect hatch). Best-effort like every side-plane datagram.
|
||||
fn send_rumble(
|
||||
conn: &quinn::Connection,
|
||||
envelope_on: bool,
|
||||
pad: u16,
|
||||
low: u16,
|
||||
high: u16,
|
||||
seq: u8,
|
||||
ttl_ms: u16,
|
||||
) {
|
||||
let d: Vec<u8> = if envelope_on {
|
||||
punktfunk_core::quic::encode_rumble_datagram_v2(pad, low, high, seq, ttl_ms).to_vec()
|
||||
} else {
|
||||
punktfunk_core::quic::encode_rumble_datagram(pad, low, high).to_vec()
|
||||
};
|
||||
let _ = conn.send_datagram(d.into());
|
||||
}
|
||||
|
||||
/// The per-session input thread: route pointer/keyboard events to the host-lifetime injector
|
||||
/// service (`inj_tx`) and gamepad events to this session's [`Pads`] router (`gamepad` — the
|
||||
/// resolved Hello preference is the per-pad default; clients declare each pad's kind so a session
|
||||
/// can mix uinput X-Box pads and virtual DualSense pads), with rich
|
||||
/// client→host input (touchpad / motion, [`ClientInput::Rich`]) applied on arrival and
|
||||
/// feedback pumped between events — rumble on the universal datagram plane, DualSense
|
||||
/// LED/trigger feedback on the HID-output plane. The gamepads are created and torn down with
|
||||
/// the session; the pointer/keyboard injector (and its portal grant) lives in the service,
|
||||
/// across sessions.
|
||||
///
|
||||
/// Rumble is emitted as self-terminating 0xCA v2 envelopes (`[level][seq][ttl_ms]`): the host owns
|
||||
/// the timeline, renewing an active level every ~`RUMBLE_TTL_MS × 3/10` ms and letting an
|
||||
/// abandoned one expire client-side, so "stuck rumble" is inexpressible on the wire (see
|
||||
/// `punktfunk-planning/design/rumble-envelope-plan.md`). `PUNKTFUNK_RUMBLE_ENVELOPE=0` reverts to
|
||||
/// legacy v1 level datagrams + the flat 500 ms refresh (bisect hatch).
|
||||
pub(super) fn input_thread(
|
||||
rx: std::sync::mpsc::Receiver<ClientInput>,
|
||||
conn: quinn::Connection,
|
||||
inj_tx: std::sync::mpsc::Sender<InputEvent>,
|
||||
gamepad: GamepadPref,
|
||||
) {
|
||||
let mut pads = Pads::new(gamepad);
|
||||
// Motion-cadence observability (debug level): inter-arrival percentiles per 5 s window,
|
||||
// the measurement a "gyro feels floaty" report needs. Bounded: 5 s at even a 1 kHz pad
|
||||
// is 5000 u32s.
|
||||
let mut motion_gaps_us: Vec<u32> = Vec::new();
|
||||
let mut last_motion: Option<std::time::Instant> = None;
|
||||
let mut motion_window = std::time::Instant::now();
|
||||
let mut pad_state = [PadState::default(); MAX_WIRE_PADS];
|
||||
let mut pad_mask = 0u16;
|
||||
// Last applied snapshot seq per pad (`None` until the first one): the reorder gate for
|
||||
// `InputKind::GamepadState` — a late datagram with an older seq must not roll held state back.
|
||||
let mut pad_seq: [Option<u8>; MAX_WIRE_PADS] = [None; MAX_WIRE_PADS];
|
||||
// Rumble self-terminating envelopes (0xCA v2). Each non-zero level is authorized for
|
||||
// `rumble_ttl_ms`; the host renews an active pad every `rumble_renew` and lets an abandoned
|
||||
// one expire on the client, so a dropped transition heals on the next renewal and a stop that
|
||||
// is lost heals via the stop burst (or the client's own TTL expiry). `rumble_seq` is the
|
||||
// per-pad wrapping reorder counter (bumped on changes AND renewals) the client gates on;
|
||||
// `rumble_stop_burst` counts the post-stop zero re-sends still owed. `PUNKTFUNK_RUMBLE_ENVELOPE=0`
|
||||
// reverts to legacy v1 datagrams re-sent flat every 500 ms.
|
||||
let mut rumble_state = [(0u16, 0u16); MAX_WIRE_PADS];
|
||||
let mut rumble_seen = [false; MAX_WIRE_PADS];
|
||||
let mut rumble_seq = [0u8; MAX_WIRE_PADS];
|
||||
let mut rumble_stop_burst = [0u8; MAX_WIRE_PADS];
|
||||
let mut last_refresh = std::time::Instant::now();
|
||||
let rumble_envelope_on = std::env::var("PUNKTFUNK_RUMBLE_ENVELOPE").as_deref() != Ok("0");
|
||||
let rumble_ttl_ms: u16 = std::env::var("PUNKTFUNK_RUMBLE_TTL_MS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u16>().ok())
|
||||
.map(|v| v.clamp(RUMBLE_TTL_FLOOR_MS, RUMBLE_TTL_CEIL_MS))
|
||||
.unwrap_or(RUMBLE_TTL_MS);
|
||||
// Renew at 30 % of the TTL (≈120 ms for the 400 ms default) so 2–3 renewals cover the lease;
|
||||
// in legacy mode the periodic block instead runs the old flat 500 ms full-state refresh.
|
||||
let rumble_refresh_interval = if rumble_envelope_on {
|
||||
std::time::Duration::from_millis((rumble_ttl_ms as u64 * 3 / 10).max(RUMBLE_RENEW_FLOOR_MS))
|
||||
} else {
|
||||
std::time::Duration::from_millis(500)
|
||||
};
|
||||
// Pointer buttons / keys the client currently holds down. The injector is host-lifetime, so a
|
||||
// press left dangling by an abrupt client disconnect stays latched in the compositor across the
|
||||
// reconnect (Mutter keeps the implicit pointer grab of the still-pressed button — a stuck
|
||||
// left-button-down then turns every later click into a drag: windows move, but clicking buttons
|
||||
// and text inputs does nothing). We synthesize the matching up-events when this session ends —
|
||||
// see the release loop after the `break`.
|
||||
// Sets (not Vecs) so the presence test is O(1), not O(n) per event, and bounded by `MAX_HELD`
|
||||
// so a client flooding distinct never-released codes can't grow the tracking state or spike the
|
||||
// input thread (security-review 2026-06-28 S3). A real keyboard+mouse holds far fewer at once;
|
||||
// codes past the cap simply aren't tracked for end-of-session release (worst case: one unreleased
|
||||
// key on a pathological disconnect, which the injector's own state still bounds).
|
||||
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();
|
||||
loop {
|
||||
match rx.recv_timeout(pads.feedback_poll_interval()) {
|
||||
// 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)) => {
|
||||
if matches!(rich, punktfunk_core::quic::RichInput::Motion { .. }) {
|
||||
let now = std::time::Instant::now();
|
||||
if let Some(prev) = last_motion.replace(now) {
|
||||
let gap = now.duration_since(prev);
|
||||
if gap < std::time::Duration::from_secs(1) {
|
||||
motion_gaps_us.push(gap.as_micros() as u32);
|
||||
}
|
||||
}
|
||||
if motion_window.elapsed() >= std::time::Duration::from_secs(5)
|
||||
&& !motion_gaps_us.is_empty()
|
||||
{
|
||||
motion_gaps_us.sort_unstable();
|
||||
let p = |q: f64| {
|
||||
motion_gaps_us[(q * (motion_gaps_us.len() - 1) as f64) as usize]
|
||||
};
|
||||
tracing::debug!(
|
||||
samples = motion_gaps_us.len() + 1,
|
||||
gap_p50_us = p(0.5),
|
||||
gap_p95_us = p(0.95),
|
||||
gap_max_us = motion_gaps_us.last().copied().unwrap_or(0),
|
||||
"motion cadence (client gyro inter-arrival, 5 s window)"
|
||||
);
|
||||
motion_gaps_us.clear();
|
||||
motion_window = std::time::Instant::now();
|
||||
}
|
||||
}
|
||||
pads.apply_rich(rich);
|
||||
}
|
||||
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
|
||||
// `continue`) so the rich-input drain + feedback pump below still run every
|
||||
// iteration (the DualSense GET_REPORT handshake must be serviced promptly).
|
||||
let idx = ev.flags as usize;
|
||||
if idx < MAX_WIRE_PADS && pad_state[idx].apply(&ev) {
|
||||
pad_mask |= 1 << idx;
|
||||
let frame = pad_state[idx].frame(idx, pad_mask);
|
||||
pads.handle(&crate::gamestream::gamepad::GamepadEvent::State(frame));
|
||||
}
|
||||
}
|
||||
InputKind::GamepadState => {
|
||||
// Idempotent full-state snapshot from a capable client (see
|
||||
// `GamepadSnapshot`): applied only when its seq supersedes the last one, so
|
||||
// a datagram the network reordered can't roll held state backwards. The
|
||||
// client refreshes touched pads every ~100 ms, so an unchanged refresh is
|
||||
// the common case — skip the frame emit then (an XInput packet-number bump
|
||||
// for identical state is pure churn), but always advance the gate.
|
||||
use punktfunk_core::input::GamepadSnapshot;
|
||||
if let Some(snap) = GamepadSnapshot::from_event(&ev) {
|
||||
let idx = snap.pad as usize;
|
||||
if idx < MAX_WIRE_PADS && GamepadSnapshot::seq_newer(snap.seq, pad_seq[idx])
|
||||
{
|
||||
pad_seq[idx] = Some(snap.seq);
|
||||
let before = pad_state[idx];
|
||||
pad_state[idx].set_snapshot(&snap);
|
||||
let first = pad_mask & (1 << idx) == 0;
|
||||
if first || pad_state[idx] != before {
|
||||
pad_mask |= 1 << idx;
|
||||
let frame = pad_state[idx].frame(idx, pad_mask);
|
||||
pads.handle(&crate::gamestream::gamepad::GamepadEvent::State(
|
||||
frame,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
InputKind::GamepadRemove => {
|
||||
// Mid-session hot-unplug from a snapshot-capable client (the native plane's
|
||||
// `activeGamepadMask` equivalent). Seq-gated in the SAME per-pad sequence
|
||||
// space as snapshots, so a snapshot the network reordered past this removal
|
||||
// is dropped (older seq) and can't resurrect the pad — while a later re-plug
|
||||
// on the same index arrives with a still-newer seq and is accepted. Clearing
|
||||
// the `active_mask` bit and re-emitting the frame fires every backend's
|
||||
// unplug sweep (`inject/*/gamepad.rs`), tearing down just this pad's device.
|
||||
let (pad, seq) = punktfunk_core::input::decode_gamepad_remove(ev.flags);
|
||||
let idx = pad as usize;
|
||||
if idx < MAX_WIRE_PADS
|
||||
&& punktfunk_core::input::GamepadSnapshot::seq_newer(seq, pad_seq[idx])
|
||||
{
|
||||
pad_seq[idx] = Some(seq);
|
||||
if pad_mask & (1 << idx) != 0 {
|
||||
pad_mask &= !(1 << idx);
|
||||
pad_state[idx] = PadState::default();
|
||||
let frame = pad_state[idx].frame(idx, pad_mask);
|
||||
pads.handle(&crate::gamestream::gamepad::GamepadEvent::State(frame));
|
||||
tracing::info!(pad = idx, "gamepad unplugged (native detach)");
|
||||
}
|
||||
// Fresh feedback bookkeeping so a later re-plug on this index inherits no
|
||||
// stale rumble lease/seq (a lease still ticking would buzz the new pad).
|
||||
rumble_state[idx] = (0, 0);
|
||||
rumble_seen[idx] = false;
|
||||
rumble_seq[idx] = 0;
|
||||
rumble_stop_burst[idx] = 0;
|
||||
}
|
||||
}
|
||||
InputKind::GamepadArrival => {
|
||||
// Per-pad controller kind declaration (mixed types): route this pad's future
|
||||
// frames to a backend of the declared kind. `code` = the GamepadPref wire byte,
|
||||
// `flags` = pad index. Applied before the pad's first frame (the client sends it
|
||||
// on slot open), so the device is built as the right type from the start.
|
||||
let idx = ev.flags as usize;
|
||||
let kind = GamepadPref::from_u8(ev.code as u8);
|
||||
pads.set_kind(idx, kind);
|
||||
}
|
||||
_ => {
|
||||
// Track press/release so a mid-press disconnect can be undone below.
|
||||
match ev.kind {
|
||||
InputKind::MouseButtonDown if held_buttons.len() < MAX_HELD => {
|
||||
held_buttons.insert(ev.code);
|
||||
}
|
||||
InputKind::MouseButtonUp => {
|
||||
held_buttons.remove(&ev.code);
|
||||
}
|
||||
InputKind::KeyDown if held_keys.len() < MAX_HELD => {
|
||||
held_keys.insert(ev.code);
|
||||
}
|
||||
InputKind::KeyUp => {
|
||||
held_keys.remove(&ev.code);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Pointer/keyboard → the host-lifetime injector service (one persistent
|
||||
// portal session for every punktfunk/1 session). A send error only means the
|
||||
// service thread is gone (host shutting down) — dropping the event is fine,
|
||||
// input is lossy by design.
|
||||
let _ = inj_tx.send(ev);
|
||||
}
|
||||
},
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
// 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.
|
||||
pads.pump(
|
||||
|pad, low, high| {
|
||||
let idx = pad as usize;
|
||||
if idx < MAX_WIRE_PADS {
|
||||
let prev = rumble_state[idx];
|
||||
// Log the silent→active transition (once per buzz) so a live test can tell
|
||||
// "host never gets rumble from the game" apart from "client doesn't render it".
|
||||
if prev == (0, 0) && (low != 0 || high != 0) {
|
||||
tracing::debug!(pad, low, high, "rumble: forwarding to client (0xCA)");
|
||||
}
|
||||
rumble_state[idx] = (low, high);
|
||||
rumble_seen[idx] = true;
|
||||
// Bump the reorder counter on every change, then arm the stop burst on a
|
||||
// transition to zero (so a lost stop still reaches a legacy client) and clear
|
||||
// it when the game re-asserts a non-zero level.
|
||||
rumble_seq[idx] = rumble_seq[idx].wrapping_add(1);
|
||||
if (low, high) == (0, 0) {
|
||||
rumble_stop_burst[idx] = if prev != (0, 0) { RUMBLE_STOP_BURST } else { 0 };
|
||||
} else {
|
||||
rumble_stop_burst[idx] = 0;
|
||||
}
|
||||
let ttl = if (low, high) == (0, 0) {
|
||||
0
|
||||
} else {
|
||||
rumble_ttl_ms
|
||||
};
|
||||
send_rumble(
|
||||
&conn,
|
||||
rumble_envelope_on,
|
||||
pad,
|
||||
low,
|
||||
high,
|
||||
rumble_seq[idx],
|
||||
ttl,
|
||||
);
|
||||
} else {
|
||||
// Out-of-range pad (a backend never produces these) — forward without gating.
|
||||
send_rumble(&conn, rumble_envelope_on, pad, low, high, 0, rumble_ttl_ms);
|
||||
}
|
||||
},
|
||||
|h| {
|
||||
let _ = conn.send_datagram(h.encode().into());
|
||||
},
|
||||
);
|
||||
// Keep the virtual DualSense from going silent during steady input (no-op for X-Box): a
|
||||
// held-steady pad sends no wire events, so without a periodic re-emit the kernel/SDL drop
|
||||
// it as unplugged. The 8 ms gap inside heartbeat() governs the rate, not this ≤4 ms tick.
|
||||
pads.heartbeat();
|
||||
if last_refresh.elapsed() >= rumble_refresh_interval {
|
||||
last_refresh = std::time::Instant::now();
|
||||
if rumble_envelope_on {
|
||||
// Renewal: refresh an active pad's lease (bump seq, fresh TTL), and drain each
|
||||
// pad's post-stop zero burst, then let it go quiet — no perpetual zero refreshes.
|
||||
for i in 0..MAX_WIRE_PADS {
|
||||
if !rumble_seen[i] {
|
||||
continue;
|
||||
}
|
||||
let (low, high) = rumble_state[i];
|
||||
if (low, high) != (0, 0) {
|
||||
rumble_seq[i] = rumble_seq[i].wrapping_add(1);
|
||||
send_rumble(
|
||||
&conn,
|
||||
true,
|
||||
i as u16,
|
||||
low,
|
||||
high,
|
||||
rumble_seq[i],
|
||||
rumble_ttl_ms,
|
||||
);
|
||||
} else if rumble_stop_burst[i] > 0 {
|
||||
rumble_stop_burst[i] -= 1;
|
||||
rumble_seq[i] = rumble_seq[i].wrapping_add(1);
|
||||
send_rumble(&conn, true, i as u16, 0, 0, rumble_seq[i], 0);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Legacy: re-send the current level of every seen pad every 500 ms (v1).
|
||||
for (i, &(low, high)) in rumble_state.iter().enumerate() {
|
||||
if rumble_seen[i] {
|
||||
let d = punktfunk_core::quic::encode_rumble_datagram(i as u16, low, high);
|
||||
let _ = conn.send_datagram(d.to_vec().into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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
|
||||
// the next session. Mirror of the injector's own release_all, but keyed off the session, which
|
||||
// is where a client actually vanishes mid-press.
|
||||
if !held_buttons.is_empty() || !held_keys.is_empty() {
|
||||
tracing::debug!(
|
||||
buttons = held_buttons.len(),
|
||||
keys = held_keys.len(),
|
||||
"input: releasing held buttons/keys at session end"
|
||||
);
|
||||
}
|
||||
for code in held_buttons {
|
||||
let _ = inj_tx.send(InputEvent {
|
||||
kind: InputKind::MouseButtonUp,
|
||||
_pad: [0; 3],
|
||||
code,
|
||||
x: 0,
|
||||
y: 0,
|
||||
flags: 0,
|
||||
});
|
||||
}
|
||||
for code in held_keys {
|
||||
let _ = inj_tx.send(InputEvent {
|
||||
kind: InputKind::KeyUp,
|
||||
_pad: [0; 3],
|
||||
code,
|
||||
x: 0,
|
||||
y: 0,
|
||||
flags: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use punktfunk_core::input::{InputEvent, InputKind};
|
||||
|
||||
#[test]
|
||||
fn pad_snapshot_replaces_state_and_seq_gates() {
|
||||
use punktfunk_core::input::{gamepad, GamepadSnapshot};
|
||||
let mut state = PadState::default();
|
||||
let mut last_seq: Option<u8> = None;
|
||||
|
||||
// Legacy accumulation first (an older client), then a snapshot replaces it wholesale.
|
||||
let axis = InputEvent {
|
||||
kind: InputKind::GamepadAxis,
|
||||
_pad: [0; 3],
|
||||
code: gamepad::AXIS_LT,
|
||||
x: 200,
|
||||
y: 0,
|
||||
flags: 0,
|
||||
};
|
||||
assert!(state.apply(&axis));
|
||||
assert_eq!(state.left_trigger, 200);
|
||||
|
||||
let snap = GamepadSnapshot {
|
||||
pad: 0,
|
||||
seq: 1,
|
||||
buttons: gamepad::BTN_A,
|
||||
left_trigger: 255,
|
||||
right_trigger: 0,
|
||||
ls_x: 100,
|
||||
ls_y: -100,
|
||||
rs_x: 0,
|
||||
rs_y: 0,
|
||||
};
|
||||
assert!(GamepadSnapshot::seq_newer(snap.seq, last_seq));
|
||||
last_seq = Some(snap.seq);
|
||||
state.set_snapshot(&snap);
|
||||
assert_eq!(state.left_trigger, 255);
|
||||
assert_eq!(state.buttons, gamepad::BTN_A);
|
||||
assert_eq!((state.ls_x, state.ls_y), (100, -100));
|
||||
|
||||
// A reordered (stale) snapshot must not roll the trigger back.
|
||||
let stale = GamepadSnapshot {
|
||||
seq: 0,
|
||||
left_trigger: 10,
|
||||
..snap
|
||||
};
|
||||
assert!(!GamepadSnapshot::seq_newer(stale.seq, last_seq));
|
||||
|
||||
// The unchanged-refresh case the input thread skips the frame emit for: identical
|
||||
// payload with a newer seq compares equal after apply.
|
||||
let refresh = GamepadSnapshot { seq: 2, ..snap };
|
||||
assert!(GamepadSnapshot::seq_newer(refresh.seq, last_seq));
|
||||
let before = state;
|
||||
state.set_snapshot(&refresh);
|
||||
assert_eq!(state, before);
|
||||
|
||||
// The snapshot survives the wire roundtrip into the same PadState shape.
|
||||
let dec =
|
||||
GamepadSnapshot::from_event(&InputEvent::decode(&snap.to_event().encode()).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(dec, snap);
|
||||
}
|
||||
|
||||
fn gp(kind: InputKind, code: u32, x: i32, pad: u32) -> InputEvent {
|
||||
InputEvent {
|
||||
kind,
|
||||
_pad: [0; 3],
|
||||
code,
|
||||
x,
|
||||
y: 0,
|
||||
flags: pad,
|
||||
}
|
||||
}
|
||||
|
||||
/// Incremental wire events accumulate into the full pad frame the virtual xpad applies.
|
||||
#[test]
|
||||
fn gamepad_accumulator() {
|
||||
use punktfunk_core::input::gamepad::*;
|
||||
let mut s = PadState::default();
|
||||
assert!(s.apply(&gp(InputKind::GamepadButton, BTN_A, 1, 0)));
|
||||
assert!(s.apply(&gp(InputKind::GamepadButton, BTN_LB, 1, 0)));
|
||||
assert!(s.apply(&gp(InputKind::GamepadAxis, AXIS_LS_X, -32768, 0)));
|
||||
assert!(s.apply(&gp(InputKind::GamepadAxis, AXIS_RT, 255, 0)));
|
||||
let f = s.frame(2, 0b0100);
|
||||
assert_eq!(f.buttons, BTN_A | BTN_LB);
|
||||
assert_eq!((f.ls_x, f.right_trigger), (-32768, 255));
|
||||
assert_eq!((f.index, f.active_mask), (2, 0b0100));
|
||||
|
||||
// Release folds out; axis values clamp; unknown axis ids are rejected.
|
||||
assert!(s.apply(&gp(InputKind::GamepadButton, BTN_A, 0, 0)));
|
||||
assert_eq!(s.frame(0, 1).buttons, BTN_LB);
|
||||
assert!(s.apply(&gp(InputKind::GamepadAxis, AXIS_LT, 9_999, 0)));
|
||||
assert_eq!(s.left_trigger, 255);
|
||||
assert!(!s.apply(&gp(InputKind::GamepadAxis, 42, 1, 0)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! The host side of the native SPAKE2 pairing ceremony (plan §W1 — carved out of the [`super`]
|
||||
//! module). `serve_session` dispatches a connection whose first message is a `PairRequest` here,
|
||||
//! after it has resolved the live arming PIN (honoring fingerprint binding, #9); this runs the
|
||||
//! ceremony, enforces the single online guess, and persists the client's fingerprint on success.
|
||||
|
||||
use super::*;
|
||||
// The ceremony-only wire messages: imported directly (native.rs no longer references them, so they
|
||||
// were dropped from its `use` and won't come through `use super::*`). `PairRequest` still arrives
|
||||
// via the glob (serve_session decodes it).
|
||||
use punktfunk_core::quic::{PairChallenge, PairProof, PairResult};
|
||||
|
||||
/// Pairing needs a human in the loop (reading the PIN off the host, typing it into the
|
||||
/// client), so its budget is far larger than the machine-speed session handshake.
|
||||
const PAIRING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
/// The host side of the SPAKE2 pairing ceremony (see `punktfunk_core::quic::pake`):
|
||||
/// generate + display a PIN, run SPAKE2 as B binding both cert fingerprints, verify the
|
||||
/// client's key-confirmation MAC (its single online guess), and persist the client's
|
||||
/// fingerprint on success.
|
||||
pub(super) async fn pair_ceremony(
|
||||
conn: &quinn::Connection,
|
||||
mut send: quinn::SendStream,
|
||||
mut recv: quinn::RecvStream,
|
||||
req: PairRequest,
|
||||
host_fp: &[u8; 32],
|
||||
np: &NativePairing,
|
||||
pin: &str,
|
||||
) -> Result<()> {
|
||||
use punktfunk_core::quic::pake;
|
||||
let client_fp = endpoint::peer_fingerprint(conn)
|
||||
.ok_or_else(|| anyhow!("pairing requires the client to present a certificate"))?;
|
||||
|
||||
tracing::info!(
|
||||
name = %req.name,
|
||||
client = %fingerprint_hex(&client_fp),
|
||||
"PAIRING REQUEST — verifying against the armed PIN"
|
||||
);
|
||||
|
||||
// SPAKE2 as B; bind our own host_fp + the client cert we actually received.
|
||||
let (pake, spake_b) = pake::start(false, pin, &client_fp, host_fp);
|
||||
let confirms = pake.finish(&req.spake_a)?; // Err only on a malformed peer message
|
||||
|
||||
io::write_msg(
|
||||
&mut send,
|
||||
&PairChallenge {
|
||||
spake_b,
|
||||
confirm: confirms.host,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// SINGLE-USE PIN: we've now sent the host key-confirmation, which lets the client TEST this one
|
||||
// guess (a right PIN → its proof will match; a wrong PIN → the client detects the mismatch and
|
||||
// aborts *without* sending its proof). So consume the PIN HERE — before reading the proof —
|
||||
// regardless of the outcome: an attacker gets EXACTLY ONE online guess (the documented guarantee),
|
||||
// not an unbounded brute-force of the 4-digit space against a static, never-rotating PIN. A
|
||||
// malformed request that errored at `pake.finish` above never reached here, so it doesn't burn the
|
||||
// window (no DoS from garbage). The operator re-arms (web console / restart) for the next device —
|
||||
// including after a successful pair; the protocol gives no reliable host-observable "wrong PIN"
|
||||
// signal to scope this to failures only (the client just disconnects).
|
||||
np.disarm();
|
||||
|
||||
let proof = tokio::time::timeout(PAIRING_TIMEOUT, io::read_msg(&mut recv))
|
||||
.await
|
||||
.map_err(|_| anyhow!("pairing timed out waiting for the client's confirmation"))??;
|
||||
let proof = PairProof::decode(&proof).map_err(|e| anyhow!("PairProof decode: {e:?}"))?;
|
||||
|
||||
// A wrong PIN (or a MITM with mismatched cert views) yields a different SPAKE2 key, so
|
||||
// the client's confirmation MAC won't match ours — one online attempt, no offline search.
|
||||
let ok = pake::verify(&confirms.client, &proof.confirm);
|
||||
|
||||
if ok {
|
||||
if let Err(e) = np.add(&req.name, &fingerprint_hex(&client_fp)) {
|
||||
tracing::error!(error = %format!("{e:#}"), "could not persist paired clients");
|
||||
}
|
||||
tracing::info!(name = %req.name, "pairing complete — client trusted");
|
||||
} else {
|
||||
tracing::warn!(name = %req.name, "pairing rejected (wrong PIN) — fingerprint not stored");
|
||||
}
|
||||
io::write_msg(&mut send, &PairResult { ok }.encode()).await?;
|
||||
let _ = send.finish();
|
||||
// Wait for the client to acknowledge by closing, so the PairResult isn't dropped by our
|
||||
// close on a slow link (bounded so a vanished client can't wedge the sequential host).
|
||||
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), conn.closed()).await;
|
||||
conn.close(0u32.into(), b"pairing done");
|
||||
anyhow::ensure!(ok, "pairing rejected (wrong PIN)");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Per-thread OS scheduling QoS for the native data plane (plan §W1 — carved out of the [`super`]
|
||||
//! module). The capture/encode and send threads raise their own priority so a CPU-saturating game
|
||||
//! can't deschedule them; the GameStream path and the direct-NVENC send thread reach this the same
|
||||
//! way (`crate::native::boost_thread_priority`).
|
||||
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
/// Raise the current thread's OS scheduling priority so a CPU-heavy game can't deschedule our
|
||||
/// capture/encode/send threads. This matters even though our GPU work is already HIGH priority: the
|
||||
/// GPU scheduler can only favour commands we've actually SUBMITTED, so if a normal-priority thread is
|
||||
/// descheduled by the game it submits the convert/encode late and the GPU priority never bites. Apollo
|
||||
/// does the same (capture thread CRITICAL, encoder ABOVE_NORMAL). The Linux host needs this too: an
|
||||
/// uncapped GPU-saturating title (e.g. CS2 direct on a virtual output, not capped by gamescope) is
|
||||
/// also a CPU hog and can deschedule our submit threads. `critical` → highest non-realtime class
|
||||
/// (the capture+encode loop); otherwise above-normal (the send/relay thread).
|
||||
pub(crate) fn boost_thread_priority(critical: bool) {
|
||||
// Windows host-process/thread session tuning (timer 1ms, DWM MMCSS, HIGH class once; MMCSS +
|
||||
// keep-display-awake per thread). No-op off Windows. Both stream threads call us, so this covers
|
||||
// capture/encode (critical) and send (non-critical).
|
||||
crate::session_tuning::on_hot_thread();
|
||||
#[cfg(target_os = "windows")]
|
||||
// SAFETY: `GetCurrentThread()` returns the constant pseudo-handle for the calling thread — always
|
||||
// valid, thread-local in meaning, and never closed (no leak/double-close). `SetThreadPriority`
|
||||
// takes that handle plus a `THREAD_PRIORITY_*` value the windows crate defines (HIGHEST or
|
||||
// ABOVE_NORMAL here); it only reprioritizes this OS thread, borrows no Rust memory, and its
|
||||
// `Result` is matched (a failure is logged, never UB). No pointers, lifetimes, or aliasing.
|
||||
unsafe {
|
||||
use windows::Win32::System::Threading::{
|
||||
GetCurrentThread, SetThreadPriority, THREAD_PRIORITY_ABOVE_NORMAL,
|
||||
THREAD_PRIORITY_HIGHEST,
|
||||
};
|
||||
let prio = if critical {
|
||||
THREAD_PRIORITY_HIGHEST
|
||||
} else {
|
||||
THREAD_PRIORITY_ABOVE_NORMAL
|
||||
};
|
||||
match SetThreadPriority(GetCurrentThread(), prio) {
|
||||
Ok(()) => tracing::debug!(critical, "thread priority raised"),
|
||||
Err(e) => {
|
||||
tracing::debug!(critical, error = ?e, "SetThreadPriority failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Best-effort nice of the CALLING thread. On Linux `setpriority(PRIO_PROCESS, 0, …)` acts on
|
||||
// the calling thread (the kernel resolves who==0 to the current task/tid), and both call
|
||||
// sites run inside their worker thread — so this nices exactly the capture/encode (critical)
|
||||
// and send (non-critical) threads, nothing else. Silently no-ops without CAP_SYS_NICE / a
|
||||
// raised RLIMIT_NICE, which is fine. We deliberately do NOT use SCHED_RR/FIFO by default: a
|
||||
// realtime CPU class can preempt the compositor AND the game's own render thread, adding the
|
||||
// very frame-time we refuse to add (opt-in only — see PUNKTFUNK_SCHED_RR).
|
||||
let nice = if critical { -10 } else { -5 };
|
||||
// SAFETY: `setpriority` takes three by-value integers and no pointers, so there is nothing to
|
||||
// alias or outlive. `PRIO_PROCESS` with `who == 0` targets the calling task on Linux and
|
||||
// `nice` is in range; the call only adjusts this thread's scheduling nice value and returns an
|
||||
// `int` we inspect. No memory is touched.
|
||||
let rc = unsafe { libc::setpriority(libc::PRIO_PROCESS, 0, nice) };
|
||||
if rc == 0 {
|
||||
tracing::debug!(critical, nice, "thread nice raised");
|
||||
} else {
|
||||
tracing::debug!(
|
||||
critical,
|
||||
"setpriority(nice) no-op (needs CAP_SYS_NICE / RLIMIT_NICE)"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||
{
|
||||
let _ = critical;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user