From 14502769e0d97ee666af6df513c540976e978677 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 3 Aug 2026 16:57:11 +0200 Subject: [PATCH 01/53] fix(host/input): rumble comes back when a controller does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unplug a pad mid-session and plug it back in, and roughly half the time it never rumbles again for the rest of the session. The removal arm restarted the pad's rumble sequence counter. The client's reorder gate does not restart: `rumble_last_seq` lives for the whole QUIC connection and has no reset path, so it still holds whatever the pad reached before the unplug. Restarting the host counter therefore hands the client a seq it has already seen, and its wrapping half-space compare drops every envelope until the counter climbs back past the stored value — up to 128 sends. Since the counter only advances on a level change or a ~120 ms renewal while a level is non-zero, that spans many separate rumble events, so it reads as a flaky controller rather than a clean outage. Whether it bites is decided by how much the pad rumbled beforehand, which is why it looks intermittent: a pad that never rumbled before the re-plug has `None` on the client side and always heals. The counter now survives, matching the sibling pad-state gate — whose comment eleven lines above already explains that a re-plug must arrive with a still- newer seq to be accepted. The three clears that actually end the stale lease move into `clear_pad_feedback`, whose signature deliberately has no seq parameter so the arm cannot regress by editing. Covered by a regression test that drives the real wire encoder and the real client gate, and asserts the pre-fix behaviour is genuinely rejected across the whole forward window, so it cannot pass vacuously. Found by the 2026-08-03 force-feedback sweep (B1/T5 — see the backlog in punktfunk-planning design/haptics-sweep-2026-08-03.md). --- crates/punktfunk-host/src/native/input.rs | 101 ++++++++++++++++++++-- 1 file changed, 96 insertions(+), 5 deletions(-) diff --git a/crates/punktfunk-host/src/native/input.rs b/crates/punktfunk-host/src/native/input.rs index a71cde1c..29e773b5 100644 --- a/crates/punktfunk-host/src/native/input.rs +++ b/crates/punktfunk-host/src/native/input.rs @@ -629,6 +629,20 @@ const RUMBLE_RENEW_FLOOR_MS: u64 = 60; /// own expiry. `3` total zero sends = the immediate one + this many renewal re-sends. const RUMBLE_STOP_BURST: u8 = 2; +/// Clear a removed pad's rumble bookkeeping — the level, the "we have seen a level" flag, and any +/// stop re-sends still owed. Together these end the pad's lease, so a re-plug on the same wire +/// index inherits nothing that could buzz the new device. +/// +/// The per-pad rumble **sequence is deliberately not a parameter**: it must stay monotonic for the +/// life of the connection because the client gates on it with a wrapping half-space compare and +/// never resets its side (`punktfunk-core/src/client/pump/datagram_task.rs`). Resetting it here is +/// the bug pinned by [`tests::rumble_seq_survives_a_removal_so_the_client_gate_accepts`]. +fn clear_pad_feedback(state: &mut (u16, u16), seen: &mut bool, stop_burst: &mut u8) { + *state = (0, 0); + *seen = false; + *stop_burst = 0; +} + /// 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. @@ -824,11 +838,22 @@ pub(super) fn input_thread( 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; + // stale rumble lease (a lease still ticking would buzz the new pad). + // + // `rumble_seq` deliberately SURVIVES — do not reset it here. The client's + // rumble reorder gate (`client/pump/datagram_task.rs`) is per-CONNECTION + // and has no reset path, so restarting this counter strands every later + // envelope for the re-plugged pad until the host climbs back past the + // value the client already stored (up to 128 sends ≈ 15 s of continuous + // rumble, or dozens of separate rumble events). The three clears below are + // what actually kill a stale lease; the sibling `pad_seq` gate keeps its + // value across a removal for exactly the same reason (see the comment at + // the top of this arm). + clear_pad_feedback( + &mut rumble_state[idx], + &mut rumble_seen[idx], + &mut rumble_stop_burst[idx], + ); } } InputKind::GamepadArrival => { @@ -1071,6 +1096,72 @@ mod tests { } } + /// A pad re-plug must not strand the client's rumble reorder gate. + /// + /// The client's `rumble_last_seq` lives for the whole QUIC connection and has no reset path + /// (`punktfunk-core/src/client/pump/datagram_task.rs`), so this host's per-pad rumble counter + /// has to stay monotonic across a `GamepadRemove`. Regression: the removal arm used to do + /// `rumble_seq[idx] = 0`, which made every envelope after a re-plug fail `seq_newer` until the + /// counter climbed back past the value the client had already stored — up to 128 sends. + /// + /// Drives the real wire encoder and the real gate, so it fails if either side's rule moves. + #[test] + fn rumble_seq_survives_a_removal_so_the_client_gate_accepts() { + use punktfunk_core::input::GamepadSnapshot; + use punktfunk_core::quic::{decode_rumble_envelope, encode_rumble_datagram_v2}; + + // The client half: one per-pad slot, per connection, never reset. + let deliver = |seq: u8, gate: &mut Option| { + let d = encode_rumble_datagram_v2(0, 0x4000, 0x8000, seq, 400); + let env = decode_rumble_envelope(&d) + .expect("v2 envelope decodes") + .envelope + .expect("v2 tail present"); + if GamepadSnapshot::seq_newer(env.seq, *gate) { + *gate = Some(env.seq); + true + } else { + false + } + }; + + // The host half: one wrapping counter, bumped on every change and every renewal. + let mut gate: Option = None; + let mut seq = 0u8; + + // A long rumble before the unplug pushes the client's stored seq well past zero. + for _ in 0..100 { + seq = seq.wrapping_add(1); + assert!(deliver(seq, &mut gate)); + } + assert_eq!(gate, Some(100)); + + // The pad is unplugged mid-buzz: the lease is cleared, the counter is not. + let (mut state, mut seen, mut burst) = ((0x1234u16, 0x5678u16), true, RUMBLE_STOP_BURST); + clear_pad_feedback(&mut state, &mut seen, &mut burst); + assert_eq!( + (state, seen, burst), + ((0, 0), false, 0), + "lease not cleared" + ); + + // It returns on the same wire index and the game rumbles again: the very first envelope + // has to reach the actuator. + seq = seq.wrapping_add(1); + assert!( + deliver(seq, &mut gate), + "first envelope after a re-plug was dropped by the client's reorder gate" + ); + + // Non-vacuity: the pre-fix behaviour (counter restarted at 0) really is rejected, and + // stays rejected for the whole forward window — this is the bug, reproduced. + let mut stranded = Some(100u8); + assert!( + (1..=100).all(|s| !deliver(s, &mut stranded)), + "test is vacuous — a restarted counter should have been gated out" + ); + } + /// Incremental wire events accumulate into the full pad frame the virtual xpad applies. #[test] fn gamepad_accumulator() { From 9979489b56dac72cca1f119bae0b9e7359b298ab Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 3 Aug 2026 17:34:47 +0200 Subject: [PATCH 02/53] fix(host/pads): an unplugged controller actually disappears MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unplug a controller mid-session and the virtual pad it was driving outlives it: the game keeps seeing a connected, permanently idle device for the rest of the session. The single-controller session — the common case — hits this every time. `PadSlots::sweep` needs two passes to retire a pad. The first pass to see the mask bit clear only ARMS the 300 ms devnode-churn grace; the drop lands on a later pass. But sweep runs only from a state frame, and the producer emits exactly one frame per detach — `native/input.rs` guards the emit on the bit still being set — so for a pad with no still-changing sibling in the same manager, the second pass never comes. Nothing periodic reaches sweep: `heartbeat` and `pump` walk the slots without it. Split the two halves. `sweep` still folds a frame's mask into the grace clocks, and `reap` — new — drops whatever has run out, with no frame needed. Every manager now reaps on the periodic pump it already runs, so the teardown completes ~300 ms after the detach instead of never. `reap` deliberately cannot arm a clock: it only reads `inactive_since` and clears it, so a pad whose bit never went clear has nothing to run out and no amount of reaping can drop it. That is what makes it safe on a hot loop, and it keeps the anti-flap guarantee intact — a mask that blips clear and returns still never churns a devnode. The two existing tests hand-fed a SECOND removal frame, which production never sends; they passed while the real path leaked. Both now drive the unplug through a pump tick, and PadSlots gains three tests pinning the new invariants. Verified non-vacuous: with the reap neutered, both manager tests fail with "the pump tick never completed the unplug". Behaviour notes: this puts UI_DEV_DESTROY on the GameStream control thread's budget for the first time, and a mask glitch longer than the grace now really does flap — which is SWEEP_GRACE working as documented, so the constant stays. Found by the 2026-08-03 force-feedback sweep (B2 — see the backlog in punktfunk-planning design/haptics-sweep-2026-08-03.md). --- crates/pf-inject/src/inject/linux/gamepad.rs | 5 + crates/pf-inject/src/inject/pad_slots.rs | 116 +++++++++++++++--- crates/pf-inject/src/inject/uhid_manager.rs | 66 +++++++--- .../src/inject/windows/gamepad_windows.rs | 25 ++-- 4 files changed, 171 insertions(+), 41 deletions(-) diff --git a/crates/pf-inject/src/inject/linux/gamepad.rs b/crates/pf-inject/src/inject/linux/gamepad.rs index cac39a69..435cf87d 100644 --- a/crates/pf-inject/src/inject/linux/gamepad.rs +++ b/crates/pf-inject/src/inject/linux/gamepad.rs @@ -669,6 +669,11 @@ impl GamepadManager { /// Service every pad's FF protocol; `send(index, low, high)` is invoked for each pad whose /// mixed rumble level changed. Call frequently (games block in `EVIOCSFF` until answered). pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) { + // Finish any unplug whose removal frame only armed the grace — the producer sends that + // frame once, so without this the uinput node would outlive the controller. The swept + // mask is discarded because this manager keeps no per-index sibling state (the pads mix + // rumble internally); if that ever changes, consume it like the other two backends do. + self.slots.reap(); for (i, pad) in self.slots.iter_mut() { if let Some((low, high)) = pad.pump_ff() { send(i as u16, low, high); diff --git a/crates/pf-inject/src/inject/pad_slots.rs b/crates/pf-inject/src/inject/pad_slots.rs index 6f595ff6..8538fab7 100644 --- a/crates/pf-inject/src/inject/pad_slots.rs +++ b/crates/pf-inject/src/inject/pad_slots.rs @@ -62,15 +62,30 @@ impl

PadSlots

{ self.label } - /// Drop every allocated pad whose `active_mask` bit has stayed clear for [`SWEEP_GRACE`] (the - /// unplug sweep run on each state frame), logging each. Returns the swept indices as a bitmask - /// so the caller resets its per-index sibling state; an index another manager owns is `None` - /// here, so it is never swept. The grace is the devnode-churn debounce: a mask that glitches - /// clear for a few frames and returns re-arms nothing. + /// Fold one state frame's `active_mask` into the grace clocks, then drop whatever has run out + /// (see [`Self::reap`]). Returns the dropped indices as a bitmask so the caller resets its + /// per-index sibling state; an index another manager owns is `None` here, so it is never + /// touched. The grace is the devnode-churn debounce: a mask that glitches clear for a few + /// frames and returns re-arms nothing. + /// + /// A frame can only ARM the grace, never complete it — no time has passed at the instant the + /// clock starts. Since the producer emits exactly ONE frame per detach, [`Self::reap`] on the + /// manager's periodic pump is what actually finishes the unplug; a backend that only ever + /// called `sweep` would keep the detached pad alive for the rest of the session. pub fn sweep(&mut self, active_mask: u16) -> u16 { self.sweep_at(active_mask, Instant::now()) } + /// Drop every allocated pad whose grace has run out, logging each — the half of the unplug + /// that needs no state frame. Returns the dropped indices as a bitmask, same as [`Self::sweep`]. + /// + /// This can only ever *complete* an unplug some frame already started: it never arms a clock, + /// so however often it runs it cannot drop a pad whose `active_mask` bit never went clear. + /// That is what makes it safe to call from a hot pump loop. + pub fn reap(&mut self) -> u16 { + self.reap_at(Instant::now()) + } + /// Backdate every armed grace clock by [`SWEEP_GRACE`], so the NEXT sweep drops the pads /// whose bits are still clear — consumer tests (the managers') drive the debounce without /// wall-clock sleeps. Test-only: production code has no business expiring the grace. @@ -81,26 +96,37 @@ impl

PadSlots

{ } } - /// [`Self::sweep`] with an injectable clock (unit tests drive the grace window). + /// [`Self::sweep`] with an injectable clock (unit tests drive the grace window): arm or disarm + /// each slot's clock from the mask, then reap whatever has already run out. fn sweep_at(&mut self, active_mask: u16, now: Instant) -> u16 { - let mut swept = 0u16; - for (i, slot) in self.pads.iter_mut().enumerate() { + for i in 0..MAX_PADS { if active_mask & (1 << i) != 0 { self.inactive_since[i] = None; // active (again): a glitch never reaches the drop + } else if self.pads[i].is_some() && self.inactive_since[i].is_none() { + self.inactive_since[i] = Some(now); // newly inactive — start the grace + } + } + self.reap_at(now) + } + + /// [`Self::reap`] with an injectable clock. Deliberately arms nothing — it only ever reads + /// `inactive_since` and clears it, so a pad whose bit never went clear has no clock to run out + /// and cannot be dropped here. + fn reap_at(&mut self, now: Instant) -> u16 { + let mut swept = 0u16; + for i in 0..MAX_PADS { + let Some(since) = self.inactive_since[i] else { + continue; // active, or never went clear — nothing to complete + }; + if self.pads[i].is_none() { + self.inactive_since[i] = None; // the slot went away by some other route continue; } - if slot.is_none() { - continue; - } - match self.inactive_since[i] { - None => self.inactive_since[i] = Some(now), // newly inactive — start the grace - Some(since) if now.duration_since(since) >= SWEEP_GRACE => { - tracing::info!(index = i, "controller unplugged ({})", self.label); - *slot = None; - self.inactive_since[i] = None; - swept |= 1 << i; - } - Some(_) => {} // inside the grace — hold + if now.duration_since(since) >= SWEEP_GRACE { + tracing::info!(index = i, "controller unplugged ({})", self.label); + self.pads[i] = None; + self.inactive_since[i] = None; + swept |= 1 << i; } } swept @@ -161,6 +187,56 @@ mod tests { PadSlots::new("Test", "test pad", "") } + #[test] + fn a_single_frame_plus_a_reap_completes_the_unplug() { + // The shape production actually produces: ONE cleared-mask frame, then time, then a reap + // with no further frame. Before the arm/reap split the pad survived here forever. + let mut s = slots(); + assert!(s.ensure(2, |i| Ok(i as u32))); + assert_eq!( + s.sweep(0b0), + 0, + "a frame arms the grace but cannot itself drop" + ); + assert!(s.get(2).is_some()); + s.expire_grace(); + assert_eq!(s.reap(), 1 << 2, "the reap did not complete the unplug"); + assert!(s.get(2).is_none()); + assert_eq!(s.reap(), 0, "nothing left to reap"); + } + + #[test] + fn reap_never_drops_a_pad_no_frame_ever_deactivated() { + // Reaping COMPLETES an unplug; it must never invent one. A pad whose bit never went clear + // has no armed clock, so any number of reaps — even with the clock backdated — leaves it. + let mut s = slots(); + assert!(s.ensure(0, |i| Ok(i as u32))); + for _ in 0..10 { + assert_eq!(s.reap(), 0); + s.expire_grace(); + } + assert!( + s.get(0).is_some(), + "reap dropped a pad that never went inactive" + ); + } + + #[test] + fn a_glitch_that_returns_inside_the_grace_never_drops_the_pad() { + // The anti-flap guarantee, now that reaps are frequent: a client mask that blips clear and + // comes back must not churn a PnP devnode. + let mut s = slots(); + assert!(s.ensure(0, |i| Ok(i as u32))); + assert_eq!(s.sweep(0b0), 0); // bit clears — arms only + for _ in 0..5 { + assert_eq!(s.reap(), 0, "dropped a pad inside its grace"); + } + assert_eq!(s.sweep(0b1), 0); // the bit returns — disarms + s.expire_grace(); + assert_eq!(s.reap(), 0, "a returned bit must leave nothing armed"); + assert!(s.get(0).is_some()); + } + #[test] fn ensure_creates_once_and_reports_freshness() { let mut s = slots(); diff --git a/crates/pf-inject/src/inject/uhid_manager.rs b/crates/pf-inject/src/inject/uhid_manager.rs index cdb95fd4..8f91f0a9 100644 --- a/crates/pf-inject/src/inject/uhid_manager.rs +++ b/crates/pf-inject/src/inject/uhid_manager.rs @@ -217,13 +217,10 @@ impl UhidManager { if idx >= MAX_PADS { return; } - // Unplugs: drop any allocated pad whose mask bit cleared, resetting its state. + // Unplugs: arm the grace for any pad whose mask bit cleared (the drop itself lands + // on a later `pump` tick — this frame is the only one the producer sends). let swept = self.slots.sweep(f.active_mask); - for i in 0..MAX_PADS { - if swept & (1 << i) != 0 { - self.reset_pad(i); - } - } + self.reset_swept(swept); if f.active_mask & (1 << idx) == 0 { return; // this event WAS the unplug } @@ -282,6 +279,12 @@ impl UhidManager { mut hidout: impl FnMut(HidOutput), ) { let now = Instant::now(); + // Finish any unplug whose removal frame only armed the grace. The producer emits that + // frame exactly once, so without this a detached pad — the single-pad session being the + // common case — would never be destroyed. Runs BEFORE the loop so a reaped index is + // already gone for `get_mut` here and for `heartbeat`'s `get` later in the same tick. + let swept = self.slots.reap(); + self.reset_swept(swept); for i in 0..MAX_PADS { let Some(pad) = self.slots.get_mut(i) else { continue; @@ -360,6 +363,18 @@ impl UhidManager { } } + /// Reset the sibling state of every index a sweep or reap just dropped. Both halves of the + /// unplug land here, so a pad torn down on the pump tick clears exactly what one torn down on + /// a state frame would — in particular `hidout_dedup`, which has no watchdog to re-arm it and + /// would otherwise swallow an identical lightbar/trigger re-assert after a re-plug. + fn reset_swept(&mut self, swept: u16) { + for i in 0..MAX_PADS { + if swept & (1 << i) != 0 { + self.reset_pad(i); + } + } + } + /// Reset one pad's sibling state (on create and unplug) so the first frame/feedback after a /// (re)connect starts from scratch and is always forwarded. fn reset_pad(&mut self, idx: usize) { @@ -494,18 +509,36 @@ mod tests { } #[test] - fn removal_frame_never_recreates_the_pad_it_swept() { + fn one_removal_frame_plus_a_pump_tick_completes_the_unplug() { + // The producer emits the cleared-mask frame exactly ONCE — `native/input.rs` guards it on + // the bit still being set — so the teardown has to finish on the periodic pump. The + // previous version of this test hand-fed a SECOND removal frame, which is what let the + // never-reaped pad hide: with one frame and no pump, the device outlived the session. let mut m = mgr(); m.handle(&frame(1, 0b10, 0)); assert!(m.slots.get(1).is_some()); - // Bit 1 cleared: the first sweep only ARMS the devnode-churn grace — the pad holds (a - // mask glitch must not flap PnP devices; see pad_slots::SWEEP_GRACE). + // The one removal frame: arms the devnode-churn grace, drops nothing. m.handle(&frame(1, 0b00, 0)); assert!(m.slots.get(1).is_some(), "inside the grace — not yet swept"); - // Grace elapsed: the frame IS pad 1's removal — sweep, then early-return (no ensure). + // A tick inside the grace must NOT flap the devnode (pad_slots::SWEEP_GRACE). + m.pump(|_, _, _| {}, |_| {}); + assert!( + m.slots.get(1).is_some(), + "a tick inside the grace dropped it" + ); + // Grace elapsed: the next tick completes the unplug, with no further frame. m.slots.expire_grace(); + m.pump(|_, _, _| {}, |_| {}); + assert!( + m.slots.get(1).is_none(), + "the pump tick never completed the unplug" + ); + // …and a further cleared-mask frame must not resurrect it (the arm branch early-returns). m.handle(&frame(1, 0b00, 0)); - assert!(m.slots.get(1).is_none()); + assert!( + m.slots.get(1).is_none(), + "a cleared-mask frame recreated the pad" + ); } #[test] @@ -551,10 +584,15 @@ mod tests { assert_eq!(collect(&mut m), vec![(0, 100, 0)]); // first value forwards assert_eq!(collect(&mut m), vec![]); // exact repeat deduped assert_eq!(collect(&mut m), vec![(0, 7, 7)]); // change forwards - // Unplug + recreate re-arms the dedup: the same level forwards again. - m.handle(&frame(0, 0b0, 0)); // arms the sweep grace + // Unplug + recreate re-arms the dedup: the same level forwards again. The unplug completes + // on a PUMP tick, not on a second frame — that is all production ever sends. + m.handle(&frame(0, 0b0, 0)); // the one removal frame — arms the grace m.slots.expire_grace(); - m.handle(&frame(0, 0b0, 0)); // grace elapsed — actually swept + assert_eq!(collect(&mut m), vec![]); // this tick reaps; nothing queued to forward + assert!( + m.slots.get(0).is_none(), + "the pump tick completed the unplug" + ); m.handle(&frame(0, 0b1, 0)); *m.backend.feedback.borrow_mut() = vec![rumble((7, 7))]; assert_eq!(collect(&mut m), vec![(0, 7, 7)]); diff --git a/crates/pf-inject/src/inject/windows/gamepad_windows.rs b/crates/pf-inject/src/inject/windows/gamepad_windows.rs index 3cfe7803..d0191c72 100644 --- a/crates/pf-inject/src/inject/windows/gamepad_windows.rs +++ b/crates/pf-inject/src/inject/windows/gamepad_windows.rs @@ -318,14 +318,10 @@ impl GamepadManager { if idx >= MAX_PADS { return; } - // Unplugs: drop any allocated pad whose mask bit cleared. + // Unplugs: arm the grace for any pad whose mask bit cleared (the drop itself lands + // on a later `pump_rumble` tick — this frame is the only one the producer sends). let swept = self.slots.sweep(f.active_mask); - for i in 0..MAX_PADS { - if swept & (1 << i) != 0 { - self.last_rumble[i] = (0, 0); - self.last_active[i] = Instant::now(); - } - } + self.reset_swept(swept); if f.active_mask & (1 << idx) == 0 { return; } @@ -345,10 +341,25 @@ impl GamepadManager { } } + /// Reset the sibling state of every index a sweep or reap just dropped, so both halves of the + /// unplug clear the same things. + fn reset_swept(&mut self, swept: u16) { + for i in 0..MAX_PADS { + if swept & (1 << i) != 0 { + self.last_rumble[i] = (0, 0); + self.last_active[i] = Instant::now(); + } + } + } + /// Relay any changed rumble level to the client. XUSB motors are 0..255; the wire carries /// 0..65535, so scale by 257. `large` (low-frequency) → the datagram's `low`, `small` /// (high-frequency) → `high` — matching the other backends. pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) { + // Finish any unplug whose removal frame only armed the grace — the producer sends that + // frame once, so without this the XUSB devnode would outlive the controller. + let swept = self.slots.reap(); + self.reset_swept(swept); for (i, pad) in self.slots.iter_mut() { if let Some((large, small)) = pad.service() { // The game drove the pad this poll (SET_STATE bumped the seq) — refresh the From 93608980ae53bcaf464ac59fdfd46ba9e991ba6f Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 3 Aug 2026 19:39:00 +0200 Subject: [PATCH 03/53] chore(release): bump workspace version to 0.24.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A minor bump: 39 commits since v0.23.0 across 121 files. Mostly a fix-up of 0.23.0 — the slice wire's reassembler sized every sentinel-opened AU at max_frame_bytes and lost 9 of 12 in-flight frames on any link that reorders, which is the freeze field reports were seeing on Android and the session client — plus the desktop presenter rebuild (intent model, V-Sync/VRR as real settings, the driver's queue-free vblank mode where it exists), the Decky settings tab growing from nine rows to the whole store, a "Forward controllers" off switch for passthrough couches, and plugin output finally reaching the console's log page. The canary base is already 0.24 — scripts/ci/pf-version.sh derives it as one minor ahead of the latest stable tag — so this is the version canary has been publishing against all along. No wire, ABI or driver-protocol change: wire protocol 2, C ABI 14, virtual-display driver protocol 6 and the Windows virtual-gamepad channel 3 are all identical to 0.23.0. No new capability bits either — VIDEO_CAP_MULTI_SLICE took the video-caps byte's last free bit in 0.23.0 and nothing here needed the next one. The only generated-header change since the tag is documentation (probe elapsed_ms semantics), already committed and verified by ci.yml's staleness gate on main. Lock touched for the 32 workspace members only, via `cargo update --workspace`: diff against origin/main is versions-only, 32 insertions and 32 deletions (the 33rd 0.23.0 line in the lock is the third-party `wasapi` crate, which sits at 0.23.0 itself — same trap as the last cut). `cargo metadata --locked` resolves; `cargo fmt --all --check` clean in both the main and the packaging/windows/drivers workspaces. api/openapi.json is deliberately left at 0.23.0: it tracks API edits and lags a release, as in every prior cut. Notes at docs/releases/v0.24.0.md, per docs/releases/README.md — authored with the bump so CI's ensure_release seeds the release body at tag creation. Play's "What's new" at docs/releases/whatsnew/v0.24.0.txt (409/500 chars), which android.yml now gates as a hard failure at step 1; the gate's own logic was run locally against this file, including the byte-identical-to-another-release check. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 64 ++++++++++---------- Cargo.toml | 2 +- docs/releases/v0.24.0.md | 95 ++++++++++++++++++++++++++++++ docs/releases/whatsnew/v0.24.0.txt | 4 ++ 4 files changed, 132 insertions(+), 33 deletions(-) create mode 100644 docs/releases/v0.24.0.md create mode 100644 docs/releases/whatsnew/v0.24.0.txt diff --git a/Cargo.lock b/Cargo.lock index 1ec1a380..24d075e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -947,7 +947,7 @@ dependencies = [ [[package]] name = "cursor-probe" -version = "0.23.0" +version = "0.24.0" dependencies = [ "anyhow", "pf-capture", @@ -1036,7 +1036,7 @@ dependencies = [ [[package]] name = "display-disturb" -version = "0.23.0" +version = "0.24.0" dependencies = [ "windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", ] @@ -2221,7 +2221,7 @@ dependencies = [ [[package]] name = "latency-probe" -version = "0.23.0" +version = "0.24.0" [[package]] name = "lazy_static" @@ -2326,7 +2326,7 @@ dependencies = [ [[package]] name = "libvpl-sys" -version = "0.23.0" +version = "0.24.0" dependencies = [ "bindgen", "cmake", @@ -2361,7 +2361,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "loss-harness" -version = "0.23.0" +version = "0.24.0" dependencies = [ "punktfunk-core", ] @@ -2850,7 +2850,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pf-capture" -version = "0.23.0" +version = "0.24.0" dependencies = [ "anyhow", "ashpd", @@ -2871,7 +2871,7 @@ dependencies = [ [[package]] name = "pf-client-core" -version = "0.23.0" +version = "0.24.0" dependencies = [ "anyhow", "ash", @@ -2897,7 +2897,7 @@ dependencies = [ [[package]] name = "pf-clipboard" -version = "0.23.0" +version = "0.24.0" dependencies = [ "anyhow", "ashpd", @@ -2915,7 +2915,7 @@ dependencies = [ [[package]] name = "pf-console-ui" -version = "0.23.0" +version = "0.24.0" dependencies = [ "anyhow", "ash", @@ -2936,7 +2936,7 @@ dependencies = [ [[package]] name = "pf-encode" -version = "0.23.0" +version = "0.24.0" dependencies = [ "anyhow", "ash", @@ -2960,7 +2960,7 @@ dependencies = [ [[package]] name = "pf-ffvk" -version = "0.23.0" +version = "0.24.0" dependencies = [ "ash", "bindgen", @@ -2969,7 +2969,7 @@ dependencies = [ [[package]] name = "pf-frame" -version = "0.23.0" +version = "0.24.0" dependencies = [ "anyhow", "libc", @@ -2981,7 +2981,7 @@ dependencies = [ [[package]] name = "pf-gpu" -version = "0.23.0" +version = "0.24.0" dependencies = [ "anyhow", "pf-host-config", @@ -2995,11 +2995,11 @@ dependencies = [ [[package]] name = "pf-host-config" -version = "0.23.0" +version = "0.24.0" [[package]] name = "pf-inject" -version = "0.23.0" +version = "0.24.0" dependencies = [ "anyhow", "ashpd", @@ -3028,14 +3028,14 @@ dependencies = [ [[package]] name = "pf-paths" -version = "0.23.0" +version = "0.24.0" dependencies = [ "tracing", ] [[package]] name = "pf-presenter" -version = "0.23.0" +version = "0.24.0" dependencies = [ "anyhow", "ash", @@ -3050,7 +3050,7 @@ dependencies = [ [[package]] name = "pf-update" -version = "0.23.0" +version = "0.24.0" dependencies = [ "serde", "serde_json", @@ -3058,7 +3058,7 @@ dependencies = [ [[package]] name = "pf-update-check" -version = "0.23.0" +version = "0.24.0" dependencies = [ "anyhow", "base64", @@ -3070,7 +3070,7 @@ dependencies = [ [[package]] name = "pf-vdisplay" -version = "0.23.0" +version = "0.24.0" dependencies = [ "anyhow", "ashpd", @@ -3103,7 +3103,7 @@ dependencies = [ [[package]] name = "pf-win-display" -version = "0.23.0" +version = "0.24.0" dependencies = [ "anyhow", "pf-paths", @@ -3115,7 +3115,7 @@ dependencies = [ [[package]] name = "pf-zerocopy" -version = "0.23.0" +version = "0.24.0" dependencies = [ "anyhow", "ash", @@ -3323,7 +3323,7 @@ dependencies = [ [[package]] name = "punktfunk-cli" -version = "0.23.0" +version = "0.24.0" dependencies = [ "pf-client-core", "punktfunk-core", @@ -3334,7 +3334,7 @@ dependencies = [ [[package]] name = "punktfunk-client-android" -version = "0.23.0" +version = "0.24.0" dependencies = [ "android_logger", "jni", @@ -3350,7 +3350,7 @@ dependencies = [ [[package]] name = "punktfunk-client-linux" -version = "0.23.0" +version = "0.24.0" dependencies = [ "anyhow", "async-channel", @@ -3367,7 +3367,7 @@ dependencies = [ [[package]] name = "punktfunk-client-session" -version = "0.23.0" +version = "0.24.0" dependencies = [ "anyhow", "pf-client-core", @@ -3382,7 +3382,7 @@ dependencies = [ [[package]] name = "punktfunk-client-windows" -version = "0.23.0" +version = "0.24.0" dependencies = [ "async-channel", "ffmpeg-next", @@ -3402,7 +3402,7 @@ dependencies = [ [[package]] name = "punktfunk-core" -version = "0.23.0" +version = "0.24.0" dependencies = [ "aes-gcm", "bytes", @@ -3434,7 +3434,7 @@ dependencies = [ [[package]] name = "punktfunk-host" -version = "0.23.0" +version = "0.24.0" dependencies = [ "aes", "aes-gcm", @@ -3519,7 +3519,7 @@ dependencies = [ [[package]] name = "punktfunk-probe" -version = "0.23.0" +version = "0.24.0" dependencies = [ "anyhow", "mdns-sd", @@ -3533,7 +3533,7 @@ dependencies = [ [[package]] name = "punktfunk-tray" -version = "0.23.0" +version = "0.24.0" dependencies = [ "anyhow", "ksni", @@ -3556,7 +3556,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" [[package]] name = "pyrowave-sys" -version = "0.23.0" +version = "0.24.0" dependencies = [ "bindgen", "cmake", diff --git a/Cargo.toml b/Cargo.toml index d213e769..8ee7bd85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,7 +53,7 @@ exclude = [ ndk = { path = "clients/android/native/vendor/ndk" } [workspace.package] -version = "0.23.0" +version = "0.24.0" edition = "2021" rust-version = "1.82" license = "MIT OR Apache-2.0" diff --git a/docs/releases/v0.24.0.md b/docs/releases/v0.24.0.md new file mode 100644 index 00000000..22e16a31 --- /dev/null +++ b/docs/releases/v0.24.0.md @@ -0,0 +1,95 @@ +Wire-compatible with 0.23.x — everything you have already paired keeps working, and you can update one side at a time. Nothing here changes how a host and a client agree on what to send each other, so an old client on a new host (or the other way round) streams exactly as it does today. + +This release is mostly about making 0.23.0 right. **If you stream to an Android phone or to the Linux or Windows desktop app, update the client** — 0.23.0's new send-a-frame-in-pieces delivery had a fault that could throw most of the video away on a busy link, and the fix lives in the client. Alongside that: the Linux and Windows app finally gets the frame scheduler the phone and Apple apps have had, the Steam Deck plugin reaches every setting instead of nine of them, controllers can be told *not* to be forwarded for couches that pass the pad through some other way, and plugin output shows up in the web console's log page instead of nowhere at all. + +## New + +- **The Linux and Windows app schedules frames onto your screen instead of throwing them at it.** Until now the desktop app showed each frame the instant it finished decoding, so every frame carried whatever jitter the network and the decoder had just added — the same problem the phone and Apple apps had before their rebuilds. It now has the same two-way choice under **Prioritize**: **Lowest latency** (the default, and what you have today) or **Smoothness**, which holds a small buffer of frames and releases one per screen refresh so an uneven stream plays evenly. The buffer is 1–3 frames, your pick. On screens where the graphics driver holds a queue of pending frames — which is most Windows machines with an AMD card, and any machine under a compositor that does the same — the app now also stops that queue from building up, which is where a whole extra refresh of delay used to come from. + +- **V-Sync and "Follow variable refresh rate" are real settings now.** Both rows have existed for a while and neither did anything: the app picked one presentation mode at startup and kept it. **V-Sync** off asks for the tear-capable mode and says in the stats overlay which mode it actually got, because a graphics driver is free to refuse. **Follow variable refresh rate** lets a VRR/FreeSync/G-SYNC display follow the stream's own cadence rather than a fixed grid; where your driver supports the newest tear-free presentation mode it turns itself on, and elsewhere it stays off unless you ask for it, because on those drivers it costs more than it wins. Whether you actually *have* variable refresh is now measured from what your display does rather than believed from what it claims — no platform reports it honestly enough to trust. + +- **The Steam Deck plugin covers every setting, in a sidebar that fits on screen.** Nine of the client's settings had a row here and about twenty did not, so a Deck that never sees a desktop could not reach its own decoder, full chroma, HDR, audio layout, echo cancellation, touch or mouse model, scroll direction, auto-wake, or either audio device. All of it is here now, split across a left rail of categories — the layout SteamOS's own Settings uses — so every page fits without scrolling and nothing is more than one hop away. The categories, their order and the wording match the console's settings screen, because two different orders for one set of settings is how people stop trusting either. Two things are deliberately absent and named as such: which physical controller is player 1, and the remembered window size. + +- **The stats overlay has an off switch on the Steam Deck.** Reported from the field: there was no way to turn it off from the plugin, and it defaults to on — so a Deck configured only through this panel streamed with the overlay up and no way here to put it down. The keyboard shortcut and the three-finger tap both existed but neither is discoverable from a couch. The row now sits at the foot of the section, worded the way the console words it. + +- **"Forward controllers" — an off switch for setups that hand the pad over another way.** If your controller already reaches the host by USB passthrough (VirtualHere and friends), or is simply plugged into the host, it arrived there twice: once as the real device and once as the virtual pad the client built from the same hands. Games read both, so a stick drifts against the second, centred pad and menus take every input twice. The new per-client setting defaults to on — today's behaviour — and can be set per profile. On Linux and Windows it does more than stop sending: opening a controller is what *claims* it, so with this off the app never opens one at all, leaving the device free for the passthrough tool to bind. The consequence is stated at the setting: the controller escape chord is read off forwarded pads, so it is unavailable while this is off. The Apple and Android apps never claim the pad, so they keep their chords and only stop sending — losing an Apple TV's only way out of a stream would have been the worse bug. + +- **Plugin output reaches the web console's log page.** Plugins do not run as children of the host, so nothing they printed ever passed through the host's own logging and the Logs page could not show a single plugin line. The fallback was a terminal on the host box — and on Windows, no log file at all, so a failing plugin was diagnosable only by stopping its task and re-running it by hand. That is exactly what the console exists to avoid, and it left the one question a stuck user asks with no answer. The plugin runner now sends its output to the host, where it joins everything else under one timeline; the Logs page grows a **Host / Plugins** switch beside the level filter, and an empty Plugins view says the thing that is usually actually wrong (the runner isn't running) rather than telling you to adjust the filter. + +- **The Windows app can show you where its log lives.** "Check the client log" never said where that was. **Settings ▸ About** grows an **Open log folder** row, and the message you get when a stream fails to start now names the path. It opens the folder rather than the file, so the previous session's rotated log is in reach too. + +## Improved + +- **The stats overlay's display figure splits into two numbers.** It used to be one number covering everything between "decoded" and "on your screen", which is two very different things stacked: the app's own work, and the wait for the screen to accept the frame. They are now separate, so a high figure diagnoses itself — if the second number dominates it is the refresh rate floor or a queue in the driver, and if the first dominates it is us. The Detailed tier also names the presentation mode actually in use, which answers most "why is my display number a whole refresh" questions on its own. + +- **The full-chroma explanation names what it actually needs.** The Windows app's caption said 4:4:4 was "HEVC only, and only where the host can encode it", which sends people hunting through host settings; the web console's explainer was similarly vague. Both now name the real requirement. Host-side, the log line that reports the decision stopped being named after the capture side when it was never about capture — a field report burned real time hunting a capture problem because of it — and a session that asked for full chroma and did not get it now says which end declined. + +## Fixed + +- **0.23.0 could freeze the picture on Android and on the Linux and Windows app.** 0.23.0 started cutting each frame into pieces and sending them as they were produced. The receiving side then sized every arriving frame at the largest a frame is ever allowed to be — many megabytes — so its budget for frames-in-progress ran out after about three. With normal traffic that meant twelve frames arriving and nine of them thrown away before a single byte could be placed: on any link that reorders packets at all, a loss storm that does not end. Frames never complete, the picture freezes, and the client keeps begging for a fresh one. A second fault in the same path killed one frame in roughly every 1408 outright — about once every twelve seconds at 120 fps, each costing a freeze and a recovery. Only Android and the Linux/Windows desktop app ever took this path, which is why it read as a platform-specific video fault in the field; the Apple apps and the Windows in-process client were never affected. **Updating the client is what fixes it** — a 0.23.0 client still has the bug whatever host it talks to. + +- **On KDE hosts, a hidden mouse pointer stayed on screen.** Since 0.22.0 a KDE stream always had a cursor and it never went away again — not in a game, not in Big Picture, not with a controller in hand. The host was blending an arrow onto the picture forever because the signal KDE sends to say "the pointer is not here" was being ignored. It is honoured now, so a game that hides the pointer mid-stream actually hides it. GNOME hosts keep the behaviour they have, because there the same signal means something different and honouring it made the cursor flicker. + +- **A KDE host could stream a copy of your monitor instead of its own screen.** KDE remembers display arrangements per set of connected screens, and one of the things it remembers is "this screen mirrors that one". Because the streamed screen carries a stable name, any arrangement that had ever recorded it as a mirror got that re-applied on every later session that reproduced the same set of monitors — which is why it looked so arbitrary: the stream cloned the panel whenever exactly one monitor was live, and behaved normally the moment the others came back. A mirroring screen is not its own desktop; it takes the physical screen's size and viewport instead of the one you negotiated. The streamed screen now says outright that it mirrors nothing, and if it ever finds itself mirroring anyway it says so in the log instead of leaving that as something only you can see. + +- **Waking a Windows PC no longer fails the first connection.** A host that had just woken refused connections with a message claiming its virtual-display driver was not installed, on machines where it plainly was. Resuming re-registers that driver while the rest of the wake is still going, and a client reconnecting a second later landed inside the gap. The host checked exactly once, read the gap as a dead driver, and answered a device that was seconds from ready by resetting it — a reset that was then refused, because the host itself was holding the driver open, and reported as a success anyway. It now waits out a driver that is mid-wake instead of resetting it, reports what a reset actually did rather than what the device looks like afterwards, only ever runs one reset when several sessions arrive at once, and says in the log how long it waited and what it saw. + +- **AV1 streams quietly decoded in software.** Every AV1 session opened a software decoder no matter what your graphics card could do, because of how the decoder was being looked up by name. Each frame then failed the hardware check and the session walked down its fallback ladder mid-stream — around three seconds of black, with "hardware decode active" already printed and every hardware check green. Decoders are now chosen by what they can actually do. H.264 and HEVC pick exactly what they always did, and every decode log now names the decoder in use, which was the whole diagnosis and no line said it. Separately, a software-decoded HDR stream used to be shown washed out with no warning and an overlay badge claiming a tone-map that never ran; it now warns, and the badge distinguishes the two. + +- **A 120 fps session sent 132 frames a second.** The option that runs the virtual display at a multiple of the session's rate promises extra display refreshes without extra frames on the wire, but it only enforced a floor between frames — so content that always had a frame ready settled about ten percent above the rate you negotiated. That is ten percent more bitrate, encode and decode for frames a 120 Hz screen can only drop. The pacing now holds the long-run average at the negotiated rate while keeping the same room for jitter, and a source running at or below the rate is never delayed. + +- **On an iPad, Escape handed the mouse back to iPadOS.** iPadOS releases the pointer by itself when you press Escape — its built-in "let me out". But Escape in a stream is a game key, not a request to give the mouse back, so pressing it for an in-game menu silently cost you the capture until you clicked into the video again. An unwanted release is now re-requested, briefly and a bounded number of times. Every deliberate way out (the menu, the two keyboard chords, switching apps) is untouched, and while the re-grab is in flight the local cursor stays hidden and pointer movement is held, so it reads as "Escape did nothing to my mouse" rather than a cursor blinking in and out. + +- **The Windows app showed settings something else had already changed.** A field report said a codec setting "changed by itself" between sessions. Nothing writes it back — what they saw was a stale copy. The app read the settings file once at startup, but it is not the file's only writer (the stream itself stores its window size, and the console and the Deck plugin save too), so the page showed values another part of Punktfunk had already replaced — until you touched any row, at which point the value visibly jumped. It re-reads the file on entry now, and on the profile path too. A related one: an older build's save used to *drop* settings a newer client had written, and now carries them through untouched. + +- **A Windows host could fight your sound settings.** When no usable playback device was left — a display isolated, the speakers excluded, the microphone holding a virtual device — the host re-ran its whole audio setup every two seconds for as long as it took anyone to notice, including writing your default recording device back each time. That silently undid any recording-device change you made while a stream was up. An impossible arrangement is now recognised as impossible: the host says so once, with the devices it found and why each was rejected, then waits for a device to actually appear or disappear instead of retrying a verdict that cannot change. The default recording device is only asserted when the plan changed or something else moved it. + +- **PlayStation Accessories stopped offering a controller update that could never finish.** The emulated DualSense reported a 2021-era firmware version, so Sony's app — and games using their controller library — offered an update that can only ever end in "can't complete the update", since the virtual pad speaks no update protocol. A real pad plugged in directly reads as up to date, which made the prompt look like Punktfunk having corrupted the controller. It now reports a version above anything Sony has shipped, rather than chasing their latest and resurrecting the prompt with every Sony release. + +- **The speed test overstated your connection, and Automatic bitrate believed it.** Throughput was worked out by dividing what the client received by how long the *host* spent sending — a window wrong on both ends, since the host's clock stops the moment its send window closes, while the data is still draining through the network toward you. On a gigabit link a test aiming at 2 Gb/s "measured" 1266 Mb/s and set an 886 Mb/s ceiling the link could never carry, permanently, for the whole session. It is now measured over the interval the client actually received across, and video around the test contaminates neither half of the sum. Two guards ride along: a manual bitrate cap now binds no matter what any test concludes, and a decoder that keeps drowning below the link's ceiling has that noticed and remembered, instead of a 30–60 second cycle of climbing back into the same wall and flushing — a 1440p120 case cost a dropped-frame burst every cycle. + +- **Plugins on Linux could not reach anything else on the machine.** Reported by a user who could not get the VirtualHere plugin to talk to their VirtualHere client, and the reason was ours: the plugin runner was given its own private temporary directory. But integrating with things already running on the box is the entire job of a plugin, and on Linux those talk through that directory. So a plugin would launch a vendor program happily and then never be able to reach the service behind it — while the identical command worked perfectly in the operator's own terminal. No setting could fix it. Plugins now see the real one. + +- **Android: a decoder hiccup turned into a burst of broken frames.** When the decoder handed back an input slot it could not actually fill, both the slot and the video in it were dropped on the floor — leaking one of the decoder's input buffers each time, until the pipeline ran out of them entirely and the resulting keyframe storm read as a decode fault rather than the bookkeeping mistake it was. The dropped video also left a hole nothing asked to repair, so the damage was free to reach the screen. Both go back now. + +- **Android: the app could pin the wrong refresh rate for a whole session.** Asking a phone for 120 Hz is a request the system may refuse — Smooth Display off, battery saver, thermal limits, an OEM's own governor. The app took the answer on faith and could only ever revise it downward, so a refused request left it aiming at screen refreshes that never arrive, for the rest of the session, with no way back. It now corrects in both directions: instantly toward a faster screen, and toward a slower one after eight consecutive agreeing observations, because one slow sample is a missed callback and eight in a row is a display that really did slow down. Two related fixes: the app now holds back when the system stops confirming that frames reached the screen, instead of feeding a queue that has stopped draining until the decoder stalls; and the timing margin it adapts now widens on frames that actually missed the screen rather than on ordinary pacing, which on a healthy phone had been walking it to its ceiling and re-imposing the delay the 0.23.0 work had just measured away. + +- **Windows stutter reports blamed the wrong thing.** The host tries to tell you whether frames stopped arriving because the *game* went quiet — a menu, a loading screen, an ordinary hitch — or because the display path did. Its witness for the display path never worked: it was reading timestamps in one unit and comparing them against another, so it saw zero display activity always, and every quiet stretch was reported as the game going quiet. The category the whole thing exists to catch was unreachable. It reads correctly now, and it can tell "the witness was working and saw nothing" from "the witness was not working", which are opposite conclusions. If you have a stall report from an earlier version, its verdict is not evidence. + +## Under the hood (for developers) + +- **Versions.** All unchanged from 0.23.0: wire protocol 2, C ABI 14, virtual-display driver protocol 6, Windows virtual-gamepad channel 3. No new negotiated capability bits — `VIDEO_CAP_MULTI_SLICE` (`0x80`) was already the video-caps byte's last free bit and nothing needed the next one. The only C-header change is documentation: `PunktfunkProbeResult::elapsed_ms` now means the client-measured receive interval (see the ABR entry), and the probe clamp comment corrects 3 Gbps → 10 Gbps to match `MAX_PROBE_KBPS`. + +- **Slice-streamed reassembly.** Every ordinary access unit on the streamed path is now opened by a sentinel header (the block flush at `MIN_STREAM_BLOCK_SHARDS` guarantees it), and the reassembler was sizing those at `max_frame_bytes` — 8–64 MiB after the QUIC handshake clamp. Each AU therefore allocated and zeroed a multi-megabyte buffer, and `IN_FLIGHT_BUF_FACTOR × max_frame_bytes` was exhausted after ~3 concurrent frames. A sentinel now sizes to its own block extent (a slice sentinel by its wire base, a legacy one by its full-K position) and grows as later blocks or the final block's totals reveal more, with the in-flight budget re-checked on growth. Separately, `flush_block` drained `pending` to empty when the AU length was an exact multiple of the shard payload, leaving `finish_streamed` to seal a final block of one zero-padded filler shard whose derived base overlapped the block flushed a moment earlier — correctly read as a lying header, killing the AU. A flush now retains one whole shard, restoring the invariant `StreamedAu::pending` already documented. + +- **Desktop presentation engine.** `pf-presenter` gains `present_pace.rs` (pure state + arithmetic): `FrameStore` (newest-wins slot or smoothing FIFO with preroll-to-capacity, drop-oldest overflow and underflow re-arming the preroll — the Apple/Android semantics, with `qDrop`/`qDry`), `LatchClock` (panel grid from `VK_KHR_present_wait` glass stamps, publishing the host-facing `LatchGrid`), and `PresentGate` (one undisplayed present in flight on FIFO surfaces, 100 ms stale force-open; inert on MAILBOX/IMMEDIATE and without present timing). Settings ride the keys the Apple client already writes into the shared profile catalog — `present_priority` / `smooth_buffer` / `vsync` / `allow_vrr`, now tier-P routed — and `PresentPriority::resolve` mirrors the Android reference exactly, so a profile authored on any client means the same thing everywhere. PyroWave collapses smoothness to latency (its plane-ring retirement assumes the depth-2 newest-wins hand-off, and all-intra frames make buffering moot). + +- **Present-mode ladder and `VK_PRESENT_MODE_FIFO_LATEST_READY_EXT`.** Mode selection is a preference ladder rather than a constant: V-Sync off → IMMEDIATE, FIFO_RELAXED, then tear-free; V-Sync on + VRR + fullscreen → LATEST_READY first; otherwise MAILBOX then FIFO. The extension postdates ash 0.38's headers (Vulkan 1.3.281), so the mode value, extension name and `VkPhysicalDevicePresentModeFifoLatestReadyFeaturesEXT` are hand-declared. **Trap:** the surface advertises the mode even with the extension disabled, and using it on that basis is undefined — the ladder only offers it where the device feature came back true and was enabled. The gate predicate splits in two on purpose: `needs_glass_gate()` is FIFO and FIFO_RELAXED only (gating on a driver that already retires stale images pays the serialisation twice — the ~27 ms an A/B measured), while `vblank_locked()` is the whole FIFO family including LATEST_READY, since the VRR cadence probe's premise still holds there. Measured on .21 (NVIDIA 610.43.03, GNOME/Wayland): `display 2.6 ms (pace 0.6 + latch 2.0)` on the VRR ladder against 13–28 ms for FIFO + gate, and 1.4 ms for MAILBOX. ⚠ Unverified off that box: whether Windows (Vulkan WSI through DXGI) or Intel drivers expose the mode at all — `surface present modes` and `fifo_latest_ready=` are logged unconditionally, so one run on any box settles it. `allow_vrr` is automatic only where the device offers the mode, and stays behind `PUNKTFUNK_VRR_FIFO=1` elsewhere. + +- **VRR detection is measured, never queried.** No portable query exists (SDL exposes none, Wayland does not report adaptive-sync state, Windows surfaces nothing through Vulkan) and the platforms that do answer have been caught lying. The discriminator is quantization: `CadenceProbe` folds each present delta to its distance from the nearest multiple of the *display mode's* period — not the learned one, which is circular when the stream runs below panel rate — and takes the median. Tri-state: Unknown below 24 deltas, after a display change, and outside a FIFO-family mode (MAILBOX decouples presents from scanout, so its stamps are never grid-quantized; the same panel read `no` on FIFO and `yes` on MAILBOX in one minute). + +- **`PanelGrid`, shared.** The panel-period learner is extracted to `punktfunk_core::phase::PanelGrid` and adopted by the Android, iOS and desktop presenters, replacing three copies with the same bug: a seed capped at the display mode's refresh, when the mode is only a *claim*. Narrowing is immediate; widening needs eight consecutive agreeing observations and then takes the narrowest of them. `preferredDisplayModeId` on Android is a request the system may refuse, which is the case that made this a session-length failure. + +- **ABR probe measurement.** The reassembler stamps probe-scoped counters (bytes, packets, first/last arrival, monotonic ns) at `FLAG_PROBE` routing, so the throughput divisor is the client's first→last arrival interval and video around the burst contaminates neither numerator nor denominator; the host duration remains the fallback below two probe packets. `ProbeOutcome`/`PunktfunkProbeResult` layouts are unchanged. `PUNKTFUNK_ABR_MAX_MBPS` now clamps inside `set_ceiling` — the one funnel every learned ceiling passes through. The controller latches `decode_cap_kbps` when two *consecutive* backoffs carry decode-severe evidence at a similar pre-backoff rate (mirroring `host_cap_kbps`), re-probing on the `CAP_REPROBE_WINDOWS` clock; one spurious flush never latches. + +- **Frame pacing under `PUNKTFUNK_VDISPLAY_HZ_MULT`.** The frame-driven trigger enforced its pace as a per-gap floor only (sleep to 0.9×interval, wake on arrival), so an always-ready source settled at 1.11× the negotiated rate. `PaceBudget` accrues one frame of credit per interval of *real elapsed* time, caps at 1.25 frames of post-stall burst, and charges one per submitted frame — the 0.9 floor keeps its jitter headroom while the long-run average cannot exceed the rate. Anchoring to real elapsed time preserves the synchronous-encode overlap the arrival-anchored floor bought and cannot fight the phase lock's submit grid. The charge sits under the same guard as the gate: the legacy fixed tick paces by its own grid, and charging without accruing would bank unbounded debt. + +- **ETW present witness.** The consumer was opened without `PROCESS_TRACE_MODE_RAW_TIMESTAMP`, so `ProcessTrace` converted every event's `TimeStamp` to FILETIME regardless of `ClientContext=1` — FILETIME ticks are ~4 orders of magnitude above QPC, so every comparison was false, `etw=none` always printed, and `classify()` convicted every compose-silence hole as CONTENT-SILENCE, making FRAME-GENERATION unreachable. Two comments asserted the wrong contract and now state the real one. `summary()` and `window_counts()` merge into one `window_report()` (one ring lock, one anchor — they could previously disagree about the same hole); `present_history`/`queue_history` now mean witness *liveness* within a 5 s lookback ending at the hole's start rather than "an event ever sat in the ring", and the static ring is cleared per session. Conviction thresholds untouched. **Any stall verdict from before this fix is void.** + +- **Hardware decoder selection.** `avcodec_find_decoder(id)` returns the registry's first decoder for the id and upstream orders the native AV1 decoder *last* on purpose ("hwaccel hooks only, so prefer external decoders"), so all three hardware backends were opening libdav1d — which ignores `hw_device_ctx` and never calls `get_format`. `find_hw_decoder` walks `av_codec_iterate` and takes the first decoder whose `avcodec_get_hw_config` advertises the backend's surface via `HW_DEVICE_CTX`, so a build without a usable hardware decoder fails at open in milliseconds and the ladder runs there. Registry order still wins among capable decoders; the software path keeps the id lookup deliberately (libdav1d is the fastest CPU AV1, and the native decoder has no software path). + +- **KWin mirroring.** `OutputConfigurationStore` persists `replicationSource` per *setup* (the exact connected-output set, matched by EDID/connector), and our virtual output carries a stable name by design — so a stored mirror entry re-applies on every session reproducing that monitor set. `applyMirroring` overrides scale and render offset to the source's, and the protocol states that a mirroring output may not be in the output order, so the primary assertion silently stops meaning anything too. The topology config now includes `set_replication_source(ours, "")`, gated on output-management v13 where the request appeared (wayland-rs does not range-check requests; an out-of-range opcode kills the connection). `extend`/`auto` issue no topology calls by design, so they get `clear_replication_source`, which enumerates and applies only when our output really is mirroring. The device's `replication_source` event is now read and warned on. + +- **KWin cursor visibility.** Two producer contracts meet on `SPA_META_Cursor` id 0. KWin rewrites cursor meta on every enqueued buffer and writes id 0 whenever `Cursor::isOnOutput` says the pointer is not in this stream — covering both a globally hidden cursor and a client null-cursor surface — so there id 0 *is* the hide. Mutter only rewrites meta when the cursor changed, so recycled buffers carry stale id-0 regions between damage frames, and honouring those flickered the cursor off between hovers. A flag rides from the backend that created the output through `capture_virtual_output` into the parser's `CursorState`; the portal-monitor path stays on the stale-meta contract (only Mutter's HDR mirror routes through it today). + +- **Plugin runner and logging.** `punktfunk-scripting.service` drops `PrivateTmp=yes` and adds `/tmp` to `ReadWritePaths` (which `ProtectSystem=strict` would otherwise make read-only) — VirtualHere's client IPC is the `/tmp/vhclient` + `/tmp/vhclient_response` FIFO pair, and X11 is `/tmp/.X11-unix`. The runner tees stdout to `POST /api/v1/plugins/logs`, joining the host's ring under one cursor with target `plugin:`; stdout stays authoritative, the queue is bounded and drops oldest (then reports how many), the shipper backs off when the host is away and re-sends a batch the host refused. `plugin_may_access` is an exclusion list, so `/plugins/logs` is reachable today only because it does not match `/ui-credential` — now asserted directly, since a change there would silence plugin logs with no other symptom. Two shipper bugs fixed with tests that fail against the previous code: the recursion guard was held across the whole `await fetch` and `enqueue` checked it (so every line logged while a POST was open was dropped — worst exactly when the host is slow), and an explicit `flush()` hit the same guard and returned having sent nothing, which is the shutdown path where the last lines say whether shutdown was clean. + +- **Gamepad claiming and the new setting.** `forward_gamepads` is tier-P (profile-routable), default on. On Linux and Windows it prevents the session opening any pad slot and enables no Valve HIDAPI drivers, because opening a controller is what claims the device node and a claimed device cannot be bound by a passthrough tool. Menu navigation is unaffected (the launcher still opens the active pad, and a session supersedes menu mode either way). Apple and Android claim nothing, so they gate only the wire sends; Android does stop its DualSense and Steam Controller 2 USB captures, which do claim. + +- **Virtual DualSense firmware version.** The feature report `0x20` update version moves `0x0154` → `0x0999`, above anything Sony has shipped, in both blobs (host uhid and the Windows driver; the DualSense Edge shares them). The old value existed to keep the kernel and SDL on the flag0 `COMPATIBLE_VIBRATION` convention, but `parse_ds_output` has since learned `COMPATIBLE_VIBRATION2` (firmware ≥ 2.24), so writers that read the version now use the v2 flag; both conventions land in the same rumble plane. + +- **Windows client settings store.** `Settings` gains unknown-key passthrough, matching the contract `SettingsOverlay::extra` already gave profiles — additive, empty on every existing store, and an empty map serialises to nothing so no file churns. The page re-bases on the file at entry, and the profile-scope commit arm reloads before cloning (it was diffing overlay absorption against stale globals). `save()` was already temp+rename. + +- **Environment.** `PUNKTFUNK_PRESENT_MODE` gains explicit `mailbox` and `fifo_relaxed` arms (both previously folded into the default with every typo) and falls back to the settings rather than to mailbox on an unknown name. New: `PUNKTFUNK_PRESENTER=arrival` (disables the whole desktop pacing engine for a field A/B without a rebuild), `PUNKTFUNK_PRESENT_DEBUG`, `PUNKTFUNK_VRR_FIFO=1`. + +- **Android release channel.** A `vX.Y.Z` tag now publishes to Play **production at 100%**, not alpha — production access came through 2026-08-01. Canary is unchanged on `internal`, and its run-number version codes always outrank production so testers keep the newer build. Play's "What's new" gets its own file, `docs/releases/whatsnew/vX.Y.Z.txt`, capped at 500 *characters* per language (`•` is three bytes in UTF-8, so a byte count can reject a legal file). A tag without that file fails the android job at step 1, before the build: Play does not show an empty "What's new" when the file is missing — it carries the previous release's text onto the new version, which is the same shape as the v0.22.3 notes announcing a feature that tag never contained. The gate also rejects a file byte-identical to another release's. `android-promote.yml` is the lever for promoting a tested build, halting a rollout, or rolling production back onto an older version code, with `dry_run` defaulting to true. diff --git a/docs/releases/whatsnew/v0.24.0.txt b/docs/releases/whatsnew/v0.24.0.txt new file mode 100644 index 00000000..0e15a038 --- /dev/null +++ b/docs/releases/whatsnew/v0.24.0.txt @@ -0,0 +1,4 @@ +• Fixes a fault from 0.23.0 that could freeze the picture on a busy connection. Worth updating for this alone. +• Fixes a refresh-rate mix-up that left the picture juddering for a whole session when a phone refused the rate the app asked for. +• A decoder hiccup no longer snowballs into a burst of broken frames. +• New "Forward controllers" switch, for setups where your pad already reaches the PC another way. From 62573d27815cb1fd85d3d8554552369fc4a141cc Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 3 Aug 2026 19:47:01 +0200 Subject: [PATCH 04/53] docs(release): the 0.24.0 notes cover the ABR sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #28 merged after the bump commit was written, so the notes described a release that no longer matched the tree. Merged origin/main and added what it brings: 45 commits since v0.23.0 now, not 39. Four user-facing entries, because eleven defects in one path is not one bullet and the pinning is the headline the field reports have been describing for months ("my bitrate is stuck at 20"): - the 20 Mbps pin itself, with the measured escape (150 Mbps in ~16 s against ~17 minutes) — the number is the point, since the old behaviour was not "slow to climb" but "never arrives" - the five single-window lessons the controller treated as permanent - throughput counted with FEC parity, which rose with the loss it was meant to detect - the silent host re-target, which made a client's first climb a request to go DOWN The Under the hood section gets the whole sweep in one bullet rather than scattering it, and PUNKTFUNK_ABR_MAX_MBPS moves from the probe bullet into it (it now binds at construction, not only on probe-learned ceilings, so it no longer belongs to the probe). Play notes gain an ABR line and now run 459/500 chars; the gate's real logic was re-run against the file, including the byte-identical check. Voice check over everything above "Under the hood" is clean of internal vocabulary. Re-verified after the merge: cargo metadata --locked resolves, cargo fmt --all --check clean, doc lazy-continuation scanner 0 hits. #28 touched no manifest, so the version bump and the versions-only lock diff are untouched. Co-Authored-By: Claude Opus 5 (1M context) --- docs/releases/v0.24.0.md | 14 ++++++++++++-- docs/releases/whatsnew/v0.24.0.txt | 7 ++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/docs/releases/v0.24.0.md b/docs/releases/v0.24.0.md index 22e16a31..625fd03e 100644 --- a/docs/releases/v0.24.0.md +++ b/docs/releases/v0.24.0.md @@ -1,6 +1,6 @@ Wire-compatible with 0.23.x — everything you have already paired keeps working, and you can update one side at a time. Nothing here changes how a host and a client agree on what to send each other, so an old client on a new host (or the other way round) streams exactly as it does today. -This release is mostly about making 0.23.0 right. **If you stream to an Android phone or to the Linux or Windows desktop app, update the client** — 0.23.0's new send-a-frame-in-pieces delivery had a fault that could throw most of the video away on a busy link, and the fix lives in the client. Alongside that: the Linux and Windows app finally gets the frame scheduler the phone and Apple apps have had, the Steam Deck plugin reaches every setting instead of nine of them, controllers can be told *not* to be forwarded for couches that pass the pad through some other way, and plugin output shows up in the web console's log page instead of nowhere at all. +This release is mostly about making 0.23.0 right. **If you stream to an Android phone or to the Linux or Windows desktop app, update the client** — 0.23.0's new send-a-frame-in-pieces delivery had a fault that could throw most of the video away on a busy link, and the fix lives in the client. The other big one is **Automatic bitrate**, which could decide early in a session that your connection was worth 20 Mb/s and then hold you there for the rest of it: a sweep of that path fixed eleven separate faults, and a session that used to need seventeen minutes to climb out now does it in about sixteen seconds. Alongside those: the Linux and Windows app finally gets the frame scheduler the phone and Apple apps have had, the Steam Deck plugin reaches every setting instead of nine of them, controllers can be told *not* to be forwarded for couches that pass the pad through some other way, and plugin output shows up in the web console's log page instead of nowhere at all. ## New @@ -28,6 +28,14 @@ This release is mostly about making 0.23.0 right. **If you stream to an Android - **0.23.0 could freeze the picture on Android and on the Linux and Windows app.** 0.23.0 started cutting each frame into pieces and sending them as they were produced. The receiving side then sized every arriving frame at the largest a frame is ever allowed to be — many megabytes — so its budget for frames-in-progress ran out after about three. With normal traffic that meant twelve frames arriving and nine of them thrown away before a single byte could be placed: on any link that reorders packets at all, a loss storm that does not end. Frames never complete, the picture freezes, and the client keeps begging for a fresh one. A second fault in the same path killed one frame in roughly every 1408 outright — about once every twelve seconds at 120 fps, each costing a freeze and a recovery. Only Android and the Linux/Windows desktop app ever took this path, which is why it read as a platform-specific video fault in the field; the Apple apps and the Windows in-process client were never affected. **Updating the client is what fixes it** — a 0.23.0 client still has the bug whatever host it talks to. +- **Automatic bitrate could pin a session at 20 Mb/s for the rest of its life.** Sessions start at 20 Mb/s and climb, and a host refuses a climb while it is briefly behind on encoding. But an ordinary hitch at startup — which arrives while the rate is still at that 20 Mb/s floor — was enough to make the host refuse, and the client cannot tell a momentary refusal apart from an encoder that genuinely cannot go faster: both arrive looking identical. Two of them and the client concluded there was a permanent ceiling. Escaping cost 12.5% per minute, so crossing the gap to what a fast link could actually carry took upwards of twenty minutes, and in practice often never happened — which is why "my bitrate is stuck at 20" has been such a persistent report. Three things changed: the host no longer refuses climbs merely because it is running in its own high-effort mode (that mode exists so it *can* keep up — refusing climbs once it is working refuses the thing that worked), the client re-checks after 12 seconds instead of a minute and backs that off only if the limit proves real, and a request granted in full is taken as proof the limit is gone rather than nudging the guess upward. Measured end to end: a session pinned at 20 Mb/s under a 300 Mb/s connection now reaches 150 Mb/s in about sixteen seconds, where the same case previously needed around seventeen minutes. + +- **Automatic bitrate drew permanent conclusions from single moments.** Five more faults of the same shape. The reference points it compares against — network delay, decode time, encode time — could arm off *one* window, and since each is a rolling minimum that one window became the floor; a calm window followed by ordinary motion then read as congestion on a link that was never the problem. Changing resolution or refresh rate re-based only some of what it had learned, so switching *up* a mode was scored against the old mode's easier numbers and cratered the rate instead of raising it. The high-water mark that bounds how far every later climb may step never decayed and was raised by damaged windows — a stall's backlog arriving at once, or a flush's queue — which are exactly the windows that overstate what was delivered. The decoder ceiling latched *at* the rate that had just choked, authorising a climb straight back into the failure, and a network hiccup could be mistaken for a decoder one. And a manual bitrate cap bound only ceilings the speed test had learned, so it did nothing at all if the session already started above it. + +- **Your connection was measured including the redundancy sent to protect it.** Two checks compare what actually arrived against what the encoder was asked to produce, and both counted every byte accepted — packet headers, audio, and the extra error-correction data the host adds *in answer to* packet loss. So the measurement rose with the loss it was supposed to detect: at 25% redundancy the check passed while the encoder was emitting barely half its target, and the permanent high-water mark inherited that inflation for good. The signal was weakest on exactly the lossy links it exists for. Only the actual video payload is counted now. + +- **A host that re-chose the rate never told the client.** When a host rebuilds its pipeline it can legitimately re-pick an Automatic rate — a 1080p session mirroring a 4K panel needs roughly three times what it negotiated — but that number never reached the client, which kept its own stale copy as the basis for every later step. So a client believing 20 Mb/s while the host encoded 60 would compute its first climb from the stale figure and ask for 40: a request to go *down*, paying for an encoder rebuild to get there. The host now tells the client whenever the applied rate moves, using a message that already meant exactly that and which existing clients already handle arriving unprompted — no wire change, and older clients are unaffected. + - **On KDE hosts, a hidden mouse pointer stayed on screen.** Since 0.22.0 a KDE stream always had a cursor and it never went away again — not in a game, not in Big Picture, not with a controller in hand. The host was blending an arrow onto the picture forever because the signal KDE sends to say "the pointer is not here" was being ignored. It is honoured now, so a game that hides the pointer mid-stream actually hides it. GNOME hosts keep the behaviour they have, because there the same signal means something different and honouring it made the cursor flicker. - **A KDE host could stream a copy of your monitor instead of its own screen.** KDE remembers display arrangements per set of connected screens, and one of the things it remembers is "this screen mirrors that one". Because the streamed screen carries a stable name, any arrangement that had ever recorded it as a mirror got that re-applied on every later session that reproduced the same set of monitors — which is why it looked so arbitrary: the stream cloned the panel whenever exactly one monitor was live, and behaved normally the moment the others came back. A mirroring screen is not its own desktop; it takes the physical screen's size and viewport instead of the one you negotiated. The streamed screen now says outright that it mirrors nothing, and if it ever finds itself mirroring anyway it says so in the log instead of leaving that as something only you can see. @@ -70,7 +78,9 @@ This release is mostly about making 0.23.0 right. **If you stream to an Android - **`PanelGrid`, shared.** The panel-period learner is extracted to `punktfunk_core::phase::PanelGrid` and adopted by the Android, iOS and desktop presenters, replacing three copies with the same bug: a seed capped at the display mode's refresh, when the mode is only a *claim*. Narrowing is immediate; widening needs eight consecutive agreeing observations and then takes the narrowest of them. `preferredDisplayModeId` on Android is a request the system may refuse, which is the case that made this a session-length failure. -- **ABR probe measurement.** The reassembler stamps probe-scoped counters (bytes, packets, first/last arrival, monotonic ns) at `FLAG_PROBE` routing, so the throughput divisor is the client's first→last arrival interval and video around the burst contaminates neither numerator nor denominator; the host duration remains the fallback below two probe packets. `ProbeOutcome`/`PunktfunkProbeResult` layouts are unchanged. `PUNKTFUNK_ABR_MAX_MBPS` now clamps inside `set_ceiling` — the one funnel every learned ceiling passes through. The controller latches `decode_cap_kbps` when two *consecutive* backoffs carry decode-severe evidence at a similar pre-backoff rate (mirroring `host_cap_kbps`), re-probing on the `CAP_REPROBE_WINDOWS` clock; one spurious flush never latches. +- **ABR probe measurement.** The reassembler stamps probe-scoped counters (bytes, packets, first/last arrival, monotonic ns) at `FLAG_PROBE` routing, so the throughput divisor is the client's first→last arrival interval and video around the burst contaminates neither numerator nor denominator; the host duration remains the fallback below two probe packets. `ProbeOutcome`/`PunktfunkProbeResult` layouts are unchanged. The controller latches `decode_cap_kbps` when two *consecutive* backoffs carry decode-severe evidence at a similar pre-backoff rate (mirroring `host_cap_kbps`), re-probing on the `CAP_REPROBE_WINDOWS` clock; one spurious flush never latches. + +- **ABR sweep — eleven defects.** Wire format and ABI untouched throughout; 34 abr tests plus 2 host tests. Host side: `cadence_degraded` was latched true for as long as the session was *escalated* (adaptive capture depth or pipelined retrieve), independent of whether encode was still missing deadlines — and escalation needs only ~20 net behind-frames, which a startup hitch supplies while ABR is still in slow start at the 20 Mbps default. The rule moves into `encode_behind_cadence`: an escalated session is still judged strictly (any net behind-frame keeps it flagged, where an unescalated one gets the full bucket), but escalation alone no longer flags it. `adopt_built_bitrate` now publishes the rate a rebuilt pipeline actually opened at (`build_pipeline` re-resolves an Automatic rate whenever the source delivers an unnegotiated size — the mirrored-panel case — and the encoder's clamp can land below what control already acked), pushed to the control task as the existing 9-byte `BitrateChanged`. Client side: all three rolling baselines (OWD, decode, encode) now require `BASELINE_MIN_WINDOWS` of evidence via one shared `score_baseline` — the three copies had drifted apart, and `on_ack` clears the encode baseline after every self-requested decrease, re-opening the one-sample hole each time. A mode switch rebases decode and OWD as well as encode, and drops `proven_kbps` with them. `proven_kbps` is raised only by *clean* windows (it never decays and holds permanent authority over climb step size, and the windows that overstate delivered throughput are precisely the damaged ones). `decode_cap_kbps` latches just *under* the choke rate, inside the ±1/8 band the evidence already required, and credits a bare jump-to-live flush only where the decode signal is absent. The two throughput-driven gates are fed data-shard payload counted at the reassembler's routing decision rather than `bytes_received` (which includes headers, FEC parity, probe filler and audio — at 25% FEC the utilization gate passed with the encoder emitting ~55% of target). `PUNKTFUNK_ABR_MAX_MBPS` binds at construction, not only on probe-learned ceilings, and a session starting above its cap steps down to it (no congestion signal will ever find that — the link is fine, the cap is policy). Cap escape re-probes after 12 s, doubling each time the lift is immediately re-learned, and a request granted **in full** at or above the cap drops the cap outright rather than nudging it +12.5%. An ack above the current ceiling raises the ceiling to meet it (`set_ceiling` still only ever raises, still clamps to `PUNKTFUNK_ABR_MAX_MBPS`), so a host-initiated re-target is not immediately stepped back down. Finally, a `SetBitrate` dropped by a full control queue no longer counts toward `MAX_UNACKED` — three of those retired the controller for the session while blaming an "older host". - **Frame pacing under `PUNKTFUNK_VDISPLAY_HZ_MULT`.** The frame-driven trigger enforced its pace as a per-gap floor only (sleep to 0.9×interval, wake on arrival), so an always-ready source settled at 1.11× the negotiated rate. `PaceBudget` accrues one frame of credit per interval of *real elapsed* time, caps at 1.25 frames of post-stall burst, and charges one per submitted frame — the 0.9 floor keeps its jitter headroom while the long-run average cannot exceed the rate. Anchoring to real elapsed time preserves the synchronous-encode overlap the arrival-anchored floor bought and cannot fight the phase lock's submit grid. The charge sits under the same guard as the gate: the legacy fixed tick paces by its own grid, and charging without accruing would bank unbounded debt. diff --git a/docs/releases/whatsnew/v0.24.0.txt b/docs/releases/whatsnew/v0.24.0.txt index 0e15a038..7d373bb2 100644 --- a/docs/releases/whatsnew/v0.24.0.txt +++ b/docs/releases/whatsnew/v0.24.0.txt @@ -1,4 +1,5 @@ -• Fixes a fault from 0.23.0 that could freeze the picture on a busy connection. Worth updating for this alone. -• Fixes a refresh-rate mix-up that left the picture juddering for a whole session when a phone refused the rate the app asked for. +• Automatic bitrate no longer gets stuck at 20 Mbps for a whole session. It now climbs to what your connection really carries in seconds, not minutes. +• Fixes a fault from 0.23.0 that could freeze the picture on a busy connection. +• Fixes a refresh-rate mix-up that left the picture juddering for a whole session. • A decoder hiccup no longer snowballs into a burst of broken frames. -• New "Forward controllers" switch, for setups where your pad already reaches the PC another way. +• New "Forward controllers" switch, for pads that reach the PC another way. From 327301e012ad93240a6d96fba4b4c992b8947618 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 3 Aug 2026 19:52:05 +0200 Subject: [PATCH 05/53] docs(release): the 0.24.0 notes cover the two controller fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRs #25 and #26 are going into this release, and neither was in the notes. Both are user-visible and easy to have lived with without knowing why: force-feedback stopping for good after a controller reconnect (roughly half of reconnects, every platform), and an unplugged pad staying visible to the game for the rest of the session (every time, if it was your only controller). The whatsnew line for the rumble fix is Play listing copy and that file has a 500-character ceiling, so "A decoder hiccup no longer snowballs into a burst of broken frames" loses "snowballs into" for "causes" — same meaning, and the new line is kept short. 498 of 500 used. --- docs/releases/v0.24.0.md | 6 ++++++ docs/releases/whatsnew/v0.24.0.txt | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/releases/v0.24.0.md b/docs/releases/v0.24.0.md index 625fd03e..6bba1229 100644 --- a/docs/releases/v0.24.0.md +++ b/docs/releases/v0.24.0.md @@ -54,6 +54,10 @@ This release is mostly about making 0.23.0 right. **If you stream to an Android - **PlayStation Accessories stopped offering a controller update that could never finish.** The emulated DualSense reported a 2021-era firmware version, so Sony's app — and games using their controller library — offered an update that can only ever end in "can't complete the update", since the virtual pad speaks no update protocol. A real pad plugged in directly reads as up to date, which made the prompt look like Punktfunk having corrupted the controller. It now reports a version above anything Sony has shipped, rather than chasing their latest and resurrecting the prompt with every Sony release. +- **Rumble stopped for good after unplugging and plugging a controller back in.** Reconnect a pad mid-session — or have Bluetooth drop it for a moment — and roughly half the time it never rumbled again for the rest of that session. Everything else about the controller kept working, which is what made it look random rather than broken. The host restarts a counter when a controller goes away, and the client uses that counter to throw away force-feedback that arrives out of order; because the client does *not* restart its side, everything sent after the reconnect looked older than what it had already seen, and it dropped the lot until the host counted back past where it left off. How long that took depended on how much the pad had rumbled before you unplugged it, so a quiet session healed at once and a busy one stayed silent. The counter now survives a reconnect, which is what the same code already does for the controller's buttons and sticks. + +- **An unplugged controller stayed plugged in as far as the game was concerned.** Unplug a pad mid-session and the game kept seeing a connected controller that never pressed anything again — it simply never went away. If it was your only controller, this happened every time. Tearing the virtual pad down takes a brief settling delay first, so a momentary glitch cannot make a device disappear and reappear, but the second look that finishes the job only ever happened when *another* controller sent something afterwards. With one controller there is nothing left to send it. The teardown now completes on the host's own clock, about a third of a second after the pad goes, whether or not anything else is connected — and a glitch that comes back within the delay still leaves the device alone. + - **The speed test overstated your connection, and Automatic bitrate believed it.** Throughput was worked out by dividing what the client received by how long the *host* spent sending — a window wrong on both ends, since the host's clock stops the moment its send window closes, while the data is still draining through the network toward you. On a gigabit link a test aiming at 2 Gb/s "measured" 1266 Mb/s and set an 886 Mb/s ceiling the link could never carry, permanently, for the whole session. It is now measured over the interval the client actually received across, and video around the test contaminates neither half of the sum. Two guards ride along: a manual bitrate cap now binds no matter what any test concludes, and a decoder that keeps drowning below the link's ceiling has that noticed and remembered, instead of a 30–60 second cycle of climbing back into the same wall and flushing — a 1440p120 case cost a dropped-frame burst every cycle. - **Plugins on Linux could not reach anything else on the machine.** Reported by a user who could not get the VirtualHere plugin to talk to their VirtualHere client, and the reason was ours: the plugin runner was given its own private temporary directory. But integrating with things already running on the box is the entire job of a plugin, and on Linux those talk through that directory. So a plugin would launch a vendor program happily and then never be able to reach the service behind it — while the identical command worked perfectly in the operator's own terminal. No setting could fix it. Plugins now see the real one. @@ -84,6 +88,8 @@ This release is mostly about making 0.23.0 right. **If you stream to an Android - **Frame pacing under `PUNKTFUNK_VDISPLAY_HZ_MULT`.** The frame-driven trigger enforced its pace as a per-gap floor only (sleep to 0.9×interval, wake on arrival), so an always-ready source settled at 1.11× the negotiated rate. `PaceBudget` accrues one frame of credit per interval of *real elapsed* time, caps at 1.25 frames of post-stall burst, and charges one per submitted frame — the 0.9 floor keeps its jitter headroom while the long-run average cannot exceed the rate. Anchoring to real elapsed time preserves the synchronous-encode overlap the arrival-anchored floor bought and cannot fight the phase lock's submit grid. The charge sits under the same guard as the gate: the legacy fixed tick paces by its own grid, and charging without accruing would bank unbounded debt. +- **Force-feedback lifecycle.** `native/input.rs` no longer resets `rumble_seq[idx]` on `GamepadRemove`: the client's v2 reorder gate is per-connection with no reset path, so restarting the sender's counter stranded every later envelope behind `seq_newer` until it climbed past the stored value (up to 128 sends). The lease clears that actually end a rumble move into `clear_pad_feedback`, whose signature omits the seq so the arm cannot regress. `PadSlots` splits `sweep` (arm the grace from a state frame) from a new `reap` (complete the unplug, no frame required), and all three backends reap from the periodic pump they already run; `reap` never arms a clock, so it cannot invent an unplug however often it runs. Regression tests drive the unplug through a pump tick rather than the hand-fed second frame the old tests used — that second frame is not something the producer ever sends, and its absence is what hid both defects. + - **ETW present witness.** The consumer was opened without `PROCESS_TRACE_MODE_RAW_TIMESTAMP`, so `ProcessTrace` converted every event's `TimeStamp` to FILETIME regardless of `ClientContext=1` — FILETIME ticks are ~4 orders of magnitude above QPC, so every comparison was false, `etw=none` always printed, and `classify()` convicted every compose-silence hole as CONTENT-SILENCE, making FRAME-GENERATION unreachable. Two comments asserted the wrong contract and now state the real one. `summary()` and `window_counts()` merge into one `window_report()` (one ring lock, one anchor — they could previously disagree about the same hole); `present_history`/`queue_history` now mean witness *liveness* within a 5 s lookback ending at the hole's start rather than "an event ever sat in the ring", and the static ring is cleared per session. Conviction thresholds untouched. **Any stall verdict from before this fix is void.** - **Hardware decoder selection.** `avcodec_find_decoder(id)` returns the registry's first decoder for the id and upstream orders the native AV1 decoder *last* on purpose ("hwaccel hooks only, so prefer external decoders"), so all three hardware backends were opening libdav1d — which ignores `hw_device_ctx` and never calls `get_format`. `find_hw_decoder` walks `av_codec_iterate` and takes the first decoder whose `avcodec_get_hw_config` advertises the backend's surface via `HW_DEVICE_CTX`, so a build without a usable hardware decoder fails at open in milliseconds and the ladder runs there. Registry order still wins among capable decoders; the software path keeps the id lookup deliberately (libdav1d is the fastest CPU AV1, and the native decoder has no software path). diff --git a/docs/releases/whatsnew/v0.24.0.txt b/docs/releases/whatsnew/v0.24.0.txt index 7d373bb2..2e65aea0 100644 --- a/docs/releases/whatsnew/v0.24.0.txt +++ b/docs/releases/whatsnew/v0.24.0.txt @@ -1,5 +1,6 @@ • Automatic bitrate no longer gets stuck at 20 Mbps for a whole session. It now climbs to what your connection really carries in seconds, not minutes. • Fixes a fault from 0.23.0 that could freeze the picture on a busy connection. • Fixes a refresh-rate mix-up that left the picture juddering for a whole session. -• A decoder hiccup no longer snowballs into a burst of broken frames. +• A decoder hiccup no longer causes a burst of broken frames. +• Controllers rumble again after a reconnect. • New "Forward controllers" switch, for pads that reach the PC another way. From ec4bf75a6ea236fc82139dcb6c22001a7b907cab Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 07:40:19 +0200 Subject: [PATCH 06/53] fix(core/rumble): the Deck's keepalive stops being swallowed by its own renewals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three faults in the shared rumble policy engine, all answered by one change of shape: the free-running jitter phase becomes `last_emit` — the exact value last handed to an embedder — and every emit routes through one helper. That single field answers all three live questions: would re-sending this be a no-op device write, is this stop redundant, and would the nudge invent a stop. The Steam Deck declares a 40 ms keepalive with a 1-LSB nudge, because an SDL-class layer discards a write identical to the last one. But the nudge lived only in the keepalive branch, so every host renewal re-emitted the raw level, collided with the last jittered write, was discarded, AND re-anchored the keepalive timer. The gap between distinct device writes stretched to 80 ms at the 400 ms default TTL and 100 ms at the hatch floor — two to two and a half times the cadence the quirk exists to guarantee. Nudging on any repeat closes it: 40 ms throughout. Level (1, 0) turned that nudge into (0, 0) — the value the engine reserves for "stop now" — and handed it out with a non-zero backstop, under a live lease. It is the only such level: high must already be zero, and low ^ 1 == 0 implies low == 1. The nudge now steps the LSB up instead, so the phase still alternates and no stop is ever invented. A zero for a pad the engine already believes silent is now dropped. Under the legacy hatch the host re-sends zeros for every latched pad every 500 ms for the rest of the session, which cost Android an unconditional log line and a binder cancel() at 2 Hz per pad. The deliberate stop-burst heal is untouched, because a stop that was LOST leaves the pad buzzing, and that is exactly the guard's pass condition. The client also now bounds the lease it will honour. RUMBLE_TTL_CEIL_MS is sender-side only, so a modified or third-party host could stamp a long TTL and wedge its pump, leaving Apple — whose renderer deliberately keeps no staleness policy of its own — and a Deck slot buzzing for all of it. Every new test was proven to fail with its own fix reverted, including the two that guard against over-reach: a default-quirks pad must still get the level verbatim, or an off-by-one amplitude would land in Apple's identical-target comparison and Android's one-shots. One suspicion from the audit did NOT survive: a v2 envelope carrying ttl_ms 0 cannot take the legacy backstop, because the expiry check preempts the relay branch. No fix; pinned with a test so that ordering stays load-bearing. Verified: 17/17 rumble tests, clippy --all-targets --features quic -D warnings = 0, fmt clean, generated C header unchanged. (`c_abi_harness_round_trips` fails on this Mac with a linker error, identically on an unmodified tree.) From the 2026-08-03 force-feedback sweep (B12, B22, R9, T1). --- crates/punktfunk-core/src/client/rumble.rs | 247 ++++++++++++++++++--- 1 file changed, 219 insertions(+), 28 deletions(-) diff --git a/crates/punktfunk-core/src/client/rumble.rs b/crates/punktfunk-core/src/client/rumble.rs index e3f024d9..53c1cd64 100644 --- a/crates/punktfunk-core/src/client/rumble.rs +++ b/crates/punktfunk-core/src/client/rumble.rs @@ -36,6 +36,22 @@ pub const LEGACY_STALE_MS: u64 = 1000; /// engine's staleness zero lands at 1 s; this is the hardware-level net under an engine stall). const BACKSTOP_LEGACY_MS: u32 = 2000; +/// The longest lease the engine honours, whatever the envelope claims — the receiver-side mirror of +/// the host's own `RUMBLE_TTL_CEIL_MS`. +/// +/// No host built from this tree can exceed it (the `PUNKTFUNK_RUMBLE_TTL_MS` hatch is clamped to +/// `[150, 5000]` before it reaches the wire), so this is defence in depth against a third-party or +/// modified sender that stamps a long TTL and then wedges its renewal pump while the connection +/// stays up. It matters on exactly the platforms that sustain a level for the whole lease: Apple, +/// whose renderer deliberately keeps no staleness policy of its own, and a Deck slot, whose +/// keepalive re-kicks the actuator until the lease ends. Duration-parameterized embedders (SDL, +/// Android) already self-terminate at the clamped backstop. +/// +/// Deliberately NOT `pub`: an embedder has no use for it, and every `pub` const in this crate is +/// emitted into `include/punktfunk_core.h` as an UNPREFIXED `#define` — a collision hazard the +/// header already has ~170 instances of, and one this has no reason to add to. +const MAX_LEASE_MS: u16 = 5_000; + /// One effective actuator command. `(0, 0)` means stop now. `backstop_ms` is a safety-net /// duration for platform APIs that take one (SDL rumble, Android one-shots): the engine emits /// explicit zeros at every policy stop, so the backstop only matters if the embedder thread itself @@ -75,8 +91,11 @@ struct PadState { /// A wire update landed since the last emit (level change OR renewal — renewals re-emit). dirty: bool, next_keepalive: Option, - /// Current jitter phase (see [`ActuatorQuirks::dedup_jitter`]). - jitter: bool, + /// The exact value last handed to an embedder. `(0, 0)` ⇔ the engine believes this actuator is + /// silent. It replaces a free-running jitter phase because one field answers all three live + /// questions: would re-sending this be a no-op device write (the dedupe nudge), is a stop + /// redundant, and would the nudge synthesize the reserved stop. + last_emit: (u16, u16), quirks: ActuatorQuirks, } @@ -88,7 +107,7 @@ impl PadState { legacy_wire: None, dirty: false, next_keepalive: None, - jitter: false, + last_emit: (0, 0), quirks: ActuatorQuirks { keepalive_ms: 0, min_pulse_ms: 0, @@ -112,6 +131,7 @@ impl PadState { self.legacy_wire = None; self.next_keepalive = None; self.dirty = false; + self.last_emit = (0, 0); RumbleCommand { pad, low: 0, @@ -119,6 +139,40 @@ impl PadState { backstop_ms: 0, } } + + /// Build the command for the pad's current level, and record what we handed out. + /// + /// On a `dedup_jitter` actuator, re-emitting the value the device last took is a no-op write on + /// an SDL-class layer, so the low motor's LSB is nudged. Keying that on `last_emit` rather than + /// on a free-running phase is what makes it work on EVERY emit path. Previously the nudge lived + /// only in the keepalive branch, so a host renewal — which arrives every `ttl*3/10` ms, 120 ms + /// at the 400 ms default and 60 ms at the hatch floor — re-emitted the raw level, collided with + /// the last jittered write, was swallowed, AND re-anchored the keepalive. That stretched the + /// gap between *distinct* device writes to 80 ms at the default cadence and 100 ms at the + /// floor, on an actuator whose quirk declares 40. + /// + /// The nudge is refused when it would synthesize the reserved `(0, 0)` stop. That is level + /// `(1, 0)` and only that: `high` must already be 0, and `low ^ 1 == 0` implies `low == 1`. + /// There the LSB steps up instead, so the phase still alternates (1 ↔ 3, two parts in 65535) + /// and the pad never receives a stop the policy did not order. + fn emit(&mut self, pad: u16) -> RumbleCommand { + let (mut low, high) = self.level; + if self.quirks.dedup_jitter && (low, high) == self.last_emit { + let alt = low ^ 1; + low = if (alt, high) == (0, 0) { + low | 0b10 + } else { + alt + }; + } + self.last_emit = (low, high); + RumbleCommand { + pad, + low, + high, + backstop_ms: self.backstop(), + } + } } /// The pure per-connection policy state machine. Time is always passed in (`now`) so the policy @@ -156,6 +210,8 @@ impl RumbleEngine { p.dirty = true; match ttl_ms { Some(t) => { + // Never honour a lease longer than [`MAX_LEASE_MS`], whatever the sender claims. + let t = t.min(MAX_LEASE_MS); p.ttl_ms = t; p.legacy_wire = None; p.deadline = if (low, high) != (0, 0) { @@ -214,22 +270,25 @@ impl RumbleEngine { if p.dirty { p.dirty = false; if p.level == (0, 0) { - return (Some(p.silence(pad)), None); + // Relay a stop only if the actuator is, as far as the engine knows, still + // buzzing. A zero on an already-silent pad heals nothing and costs every + // embedder a command — Android an unconditional log line plus a binder + // `cancel()`. Two senders produce them: the host's deliberate + // `RUMBLE_STOP_BURST` re-sends after the first stop already landed, and (behind + // `PUNKTFUNK_RUMBLE_ENVELOPE=0`) the legacy flat 500 ms refresh, which re-sends + // zeros for every latched pad for the rest of the session. The burst still + // heals the case it exists for: a LOST first stop leaves the pad buzzing, so + // `last_emit != (0, 0)` and the re-send does emit. + if p.last_emit != (0, 0) { + return (Some(p.silence(pad)), None); + } + continue; } if p.quirks.keepalive_ms > 0 { p.next_keepalive = Some(now + Duration::from_millis(p.quirks.keepalive_ms as u64)); } - let (low, high) = p.level; - return ( - Some(RumbleCommand { - pad, - low, - high, - backstop_ms: p.backstop(), - }), - None, - ); + return (Some(p.emit(pad)), None); } // 4) actuator-decay keepalive, bounded by (1)/(2) above by construction: an expired // or stale pad was silenced before reaching here, so a keepalive can never sustain a @@ -239,20 +298,7 @@ impl RumbleEngine { let due = *p.next_keepalive.get_or_insert(now + ka); if now >= due { p.next_keepalive = Some(now + ka); - let (mut low, high) = p.level; - if p.quirks.dedup_jitter { - p.jitter = !p.jitter; - low ^= p.jitter as u16; - } - return ( - Some(RumbleCommand { - pad, - low, - high, - backstop_ms: p.backstop(), - }), - None, - ); + return (Some(p.emit(pad)), None); } merge_wake(&mut wake, due); } @@ -357,6 +403,22 @@ pub(crate) struct Closed; mod tests { use super::*; + /// The Steam Deck's declared quirks — the only shipping actuator with `dedup_jitter`. + const DECK: ActuatorQuirks = ActuatorQuirks { + keepalive_ms: 40, + min_pulse_ms: 0, + dedup_jitter: true, + }; + + /// Drain the engine the way an embedder does: poll until nothing is due. + fn drain(e: &mut RumbleEngine, t: Instant) -> Vec<(u16, u16)> { + let mut out = Vec::new(); + while let (Some(c), _) = e.poll(t) { + out.push((c.low, c.high)); + } + out + } + fn ms(v: u64) -> Duration { Duration::from_millis(v) } @@ -527,4 +589,133 @@ mod tests { ); assert_eq!(shared.next_command(ms(10)), Err(Closed)); } + + /// A host renewal must not repeat the value the device last took, or an SDL-class layer + /// swallows the write. Before the jitter moved onto every emit path it lived only in the + /// keepalive branch, so each renewal collided with the last jittered write and was deduped. + #[test] + fn renewal_keeps_the_dedupe_jitter_alternating() { + let mut e = RumbleEngine::new(); + e.set_quirks(0, DECK); + let t0 = Instant::now(); + e.wire_update(t0, 0, 100, 200, Some(400)); + assert_eq!(drain(&mut e, t0), vec![(100, 200)]); + assert_eq!(drain(&mut e, t0 + ms(40)), vec![(101, 200)]); + assert_eq!(drain(&mut e, t0 + ms(80)), vec![(100, 200)]); + // The renewal at the 120 ms default cadence: same level, must still be a distinct write. + e.wire_update(t0 + ms(120), 0, 100, 200, Some(400)); + assert_eq!(drain(&mut e, t0 + ms(120)), vec![(101, 200)]); + assert_eq!(drain(&mut e, t0 + ms(160)), vec![(100, 200)]); + } + + /// Phase-robust version of the same property, at the TTL hatch's 60 ms renewal floor: no two + /// consecutive DISTINCT device writes may be further apart than the declared 40 ms cadence. + #[test] + fn renewal_never_gaps_distinct_writes_at_the_60ms_floor() { + let mut e = RumbleEngine::new(); + e.set_quirks(0, DECK); + let t0 = Instant::now(); + let (mut last, mut last_write, mut worst) = ((0u16, 0u16), 0u64, 0u64); + for tick in 0..=360u64 { + let t = t0 + ms(tick); + if tick % 60 == 0 { + e.wire_update(t, 0, 100, 200, Some(400)); + } + for v in drain(&mut e, t) { + assert_ne!(v, (0, 0), "a live lease must never emit the stop sentinel"); + if v != last { + worst = worst.max(tick - last_write); + last_write = tick; + last = v; + } + } + } + assert!( + worst <= 41, + "worst distinct-write gap {worst} ms exceeds the 40 ms declared cadence" + ); + } + + /// The nudge must stay behind `dedup_jitter`: an off-by-one amplitude on a default-quirks pad + /// would land in Apple's identical-target comparison and Android's one-shot amplitudes. + #[test] + fn default_quirks_pads_get_the_level_verbatim_on_every_renewal() { + let mut e = RumbleEngine::new(); // Apple / Android / plain SDL + let t0 = Instant::now(); + e.wire_update(t0, 0, 100, 200, Some(400)); + assert_eq!(e.poll(t0).0, Some(cmd(0, 100, 200, 800))); + e.wire_update(t0 + ms(120), 0, 100, 200, Some(400)); + assert_eq!(e.poll(t0 + ms(120)).0, Some(cmd(0, 100, 200, 800))); + } + + /// Level `(1, 0)` is the one value whose LSB flip is the reserved stop. The nudge steps up + /// instead, so the phase still alternates and no stop is invented under a live lease. + #[test] + fn jitter_never_synthesizes_the_stop_sentinel() { + let mut e = RumbleEngine::new(); + e.set_quirks(0, DECK); + let t0 = Instant::now(); + e.wire_update(t0, 0, 1, 0, Some(400)); + assert_eq!(e.poll(t0).0, Some(cmd(0, 1, 0, 800))); + assert_eq!(e.poll(t0 + ms(40)).0, Some(cmd(0, 3, 0, 800))); + assert_eq!(e.poll(t0 + ms(80)).0, Some(cmd(0, 1, 0, 800))); + } + + /// A zero for a pad the engine already believes is silent is dropped: it heals nothing and + /// costs every embedder a command. The deliberate stop-burst heal is unaffected, because a + /// LOST stop leaves the pad buzzing and the re-send therefore does emit. + #[test] + fn a_redundant_stop_is_dropped_but_the_burst_still_heals_a_lost_one() { + let mut e = RumbleEngine::new(); + let t0 = Instant::now(); + e.wire_update(t0, 0, 100, 200, Some(400)); + assert_eq!(drain(&mut e, t0), vec![(100, 200)]); + // First stop reaches the embedder… + e.wire_update(t0 + ms(10), 0, 0, 0, Some(0)); + assert_eq!(drain(&mut e, t0 + ms(10)), vec![(0, 0)]); + // …and the burst re-sends behind it are now silent. + e.wire_update(t0 + ms(20), 0, 0, 0, Some(0)); + e.wire_update(t0 + ms(30), 0, 0, 0, Some(0)); + assert_eq!(drain(&mut e, t0 + ms(30)), Vec::new()); + + // But if the pad is buzzing (the stop that mattered was lost), a re-send still emits. + e.wire_update(t0 + ms(40), 0, 100, 200, Some(400)); + assert_eq!(drain(&mut e, t0 + ms(40)), vec![(100, 200)]); + e.wire_update(t0 + ms(50), 0, 0, 0, Some(0)); + assert_eq!(drain(&mut e, t0 + ms(50)), vec![(0, 0)]); + } + + /// The client bounds the host's lease. `RUMBLE_TTL_CEIL_MS` is sender-side only, so a modified + /// or third-party host could otherwise stamp a huge TTL and wedge its pump, leaving Apple and + /// the Deck buzzing for the whole of it. + #[test] + fn an_overlong_lease_is_clamped_to_the_ceiling() { + let mut e = RumbleEngine::new(); + let t0 = Instant::now(); + e.wire_update(t0, 0, 100, 200, Some(u16::MAX)); + assert_eq!(e.poll(t0).0, Some(cmd(0, 100, 200, 5000))); + // Silenced at the ceiling, not at the 65 s the sender asked for. + assert!(e.poll(t0 + ms(MAX_LEASE_MS as u64 - 1)).0.is_none()); + assert_eq!( + e.poll(t0 + ms(MAX_LEASE_MS as u64)).0, + Some(cmd(0, 0, 0, 0)), + "the lease must end at the ceiling" + ); + } + + /// A v2 envelope carrying `ttl_ms == 0` on a LIVE level. The audit suspected the zero would be + /// mistaken for the legacy sentinel in `backstop()`; it cannot, because the expiry check + /// preempts the relay branch — the pad silences on the same poll and never reaches a backstop. + /// Pinned so that ordering stays load-bearing rather than incidental. + #[test] + fn a_zero_ttl_envelope_silences_rather_than_taking_the_legacy_backstop() { + let mut e = RumbleEngine::new(); + let t0 = Instant::now(); + e.wire_update(t0, 0, 100, 200, Some(0)); + assert_eq!( + e.poll(t0).0, + Some(cmd(0, 0, 0, 0)), + "a zero-length lease must expire immediately, not emit with a legacy backstop" + ); + } } From e5453aebb7b9c926b471e62c7ff7b5b7aff377d6 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 07:46:43 +0200 Subject: [PATCH 07/53] fix(client/windows): "Open log folder" stops opening Documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The button shipped in d839f4c2 opens the user's Documents folder instead of the log directory on every packaged install. Nothing is wrong with the button — the path is. The client ships as a full-trust MSIX package, and Windows redirects a packaged app's %LOCALAPPDATA% writes into its private ...\Packages\\LocalCache\Local\. The log module creates and appends through that redirection without ever seeing it, so the literal %LOCALAPPDATA%\punktfunk\logs it hands out is right to WRITE to and names a directory that never exists on disk. Explorer runs outside the container: it resolves the literal path, finds nothing, and — instead of failing — silently falls back to Documents. An unpackaged dev run creates that directory for real, which is why this only ever showed up in the field. Two more places handed the same phantom path straight to the user, both added by the same commit and both wrong in the same way: the "client log file" startup line, and the failed-spawn banner's "Check " — the one people are told to follow after a session dies. Anyone who did landed in an empty or absent directory. So the fix is one resolver, not three call-site patches. `real_dir` canonicalizes the directory it just created, which resolves through the redirection on a packaged run and changes nothing on an unpackaged one — no package identity to detect, no LocalCache path to hand-assemble. `log_dir` stays as the write path and goes private so a future caller can't reach for the wrong one; `path` now resolves too, which fixes both messages. `canonicalize` always returns a `\\?\` verbatim path and Explorer refuses those (taking the same silent Documents fallback), so `strip_verbatim` undoes the prefix — including the `\\?\UNC\` form a roaming profile on a share resolves to. The button additionally guards on `is_dir()`: if the resolve ever comes back wrong, the click does nothing rather than landing the user somewhere misleading again. --- clients/windows/src/app/settings.rs | 17 ++-- clients/windows/src/logfile.rs | 134 +++++++++++++++++++++++++++- 2 files changed, 142 insertions(+), 9 deletions(-) diff --git a/clients/windows/src/app/settings.rs b/clients/windows/src/app/settings.rs index 9e970787..2d924a44 100644 --- a/clients/windows/src/app/settings.rs +++ b/clients/windows/src/app/settings.rs @@ -1045,13 +1045,18 @@ pub(crate) fn settings_page( let ss = set_screen.clone(); button("Third-party licenses").on_click(move || ss.call(Screen::Licenses)) }; - // The client log's home (%LOCALAPPDATA%\punktfunk\logs) — the file every "check the - // client log" message means, which until this row had no way in from the UI at all. - // The folder rather than the file so the rotated `.old` generation is in reach too. - // Best-effort, like the log itself: a missing dir or a failed spawn stays silent. + // The client log's home — the file every "check the client log" message means, which until + // this row had no way in from the UI at all. The folder rather than the file so the rotated + // `.old` generation is in reach too. + // + // `real_dir` (not the literal %LOCALAPPDATA% path) because Explorer lives outside our MSIX + // container: handed a path the package redirection keeps from ever existing, it silently + // opens the user's Documents folder instead of failing, which is precisely what this button + // shipped doing. The `is_dir` guard keeps that fallback unreachable — if the resolve ever + // comes back wrong, the click does nothing rather than landing somewhere misleading. + // Best-effort otherwise, like the log itself: a failed spawn stays silent. let logs_button = button("Open log folder").on_click(|| { - if let Some(dir) = crate::logfile::log_dir() { - let _ = std::fs::create_dir_all(&dir); + if let Some(dir) = crate::logfile::real_dir().filter(|d| d.is_dir()) { let _ = std::process::Command::new("explorer.exe").arg(&dir).spawn(); } }); diff --git a/clients/windows/src/logfile.rs b/clients/windows/src/logfile.rs index 4e6efddb..0fb52aef 100644 --- a/clients/windows/src/logfile.rs +++ b/clients/windows/src/logfile.rs @@ -10,6 +10,10 @@ //! Mirrors the host's convention (`%ProgramData%\punktfunk\logs`, size-capped): a file over //! 10 MB is rotated to `.old` at the next client start, one generation kept. Everything is //! best-effort — a missing/locked directory degrades to plain stderr, never a startup failure. +//! +//! Two paths, deliberately: [`log_dir`] is what we open files through, [`real_dir`] is where +//! they actually land. Under MSIX those differ, and only the second one is fit to show a user +//! or hand to Explorer. use std::fs::{File, OpenOptions}; use std::io::{self, BufRead, Write}; @@ -21,14 +25,74 @@ const ROTATE_BYTES: u64 = 10 * 1024 * 1024; static SINK: OnceLock>>> = OnceLock::new(); -/// The log directory — Settings ▸ About's "Open log folder" opens it in Explorer. -pub(crate) fn log_dir() -> Option { +/// The log directory we WRITE through: `%LOCALAPPDATA%\punktfunk\logs`. +/// +/// Correct to open files under, but NOT necessarily where the bytes land — see [`real_dir`]. +/// Anything shown to a user or handed to another process wants that one instead. +fn log_dir() -> Option { Some(PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join(r"punktfunk\logs")) } +/// The log directory as it exists ON DISK — Settings ▸ About's "Open log folder" opens this in +/// Explorer, and [`path`] names it in the startup line and the failed-spawn banner. +/// +/// The shipping client is a full-trust MSIX package, and Windows redirects a packaged app's +/// `%LOCALAPPDATA%` writes into its private `…\Packages\\LocalCache\Local\…`. We create +/// and append through that redirection without ever seeing it, so [`log_dir`] is the right path +/// to WRITE to yet names a directory that never exists on disk. Explorer runs OUTSIDE the +/// container: it resolves the literal path, finds nothing, and silently falls back to the user's +/// Documents folder — which is exactly what "Open log folder" did in every packaged install, and +/// what the two "check " messages pointed at. An unpackaged dev run creates the literal +/// directory for real, which is why this only ever showed up in the field. +/// +/// Canonicalizing the directory we just created resolves through the redirection on a packaged +/// run and changes nothing on an unpackaged one, so there is no package identity to detect. +pub(crate) fn real_dir() -> Option { + let dir = log_dir()?; + std::fs::create_dir_all(&dir).ok()?; + Some(std::fs::canonicalize(&dir).map_or(dir, strip_verbatim)) +} + +/// Undo the `\\?\` that [`std::fs::canonicalize`] always prefixes. Explorer refuses a verbatim +/// path — it would take the very same silent Documents fallback [`real_dir`] exists to avoid — +/// and it is noise in a line a user is meant to read and act on. +fn strip_verbatim(p: PathBuf) -> PathBuf { + use std::path::{Component, Prefix}; + + // Scoped so the borrow ends before the `return p` below can move it. + let head = match p.components().next() { + Some(Component::Prefix(pre)) => match pre.kind() { + // `\\?\C:\…` → `C:\…` + Prefix::VerbatimDisk(drive) => Some(PathBuf::from(format!(r"{}:\", drive as char))), + // `\\?\UNC\server\share\…` → `\\server\share\…` (a roaming profile on a share). + // Built through `OsString`, which appends verbatim — `PathBuf::push` would apply + // separator logic to the bare `\\` and mangle it. + Prefix::VerbatimUNC(server, share) => { + let mut unc = std::ffi::OsString::from(r"\\"); + unc.push(server); + unc.push(r"\"); + unc.push(share); + Some(PathBuf::from(unc)) + } + // Already a plain path — nothing to undo. + _ => None, + }, + _ => None, + }; + let Some(mut out) = head else { return p }; + // `skip(1)` drops the prefix; the `RootDir` that follows it is already in `head`. + out.extend( + p.components() + .skip(1) + .filter(|c| !matches!(c, Component::RootDir)), + ); + out +} + /// The log file's path, for the "logs land here" startup line and the failed-spawn banner. +/// Resolved like [`real_dir`] — a path a user is told to check has to be the one on disk. pub(crate) fn path() -> Option { - Some(log_dir()?.join("client.log")) + Some(real_dir()?.join("client.log")) } /// Open (rotating first) and cache the sink. Called once at startup, before the tracing @@ -97,3 +161,67 @@ pub(crate) fn forward_child_stderr(stderr: impl io::Read + Send + 'static) { } }); } + +#[cfg(test)] +mod tests { + use super::*; + + /// The shape `canonicalize` actually returns for a local profile. Explorer treats a `\\?\` + /// path as unresolvable and opens Documents instead, so the prefix has to come off. + #[test] + fn verbatim_disk_prefix_comes_off() { + let p = PathBuf::from(r"\\?\C:\Users\ada\AppData\Local\punktfunk\logs"); + assert_eq!( + strip_verbatim(p), + PathBuf::from(r"C:\Users\ada\AppData\Local\punktfunk\logs") + ); + } + + /// The MSIX-redirected form is what the fix is for: same treatment, longer path. + #[test] + fn verbatim_disk_prefix_comes_off_for_the_package_local_cache() { + let p = PathBuf::from( + r"\\?\C:\Users\ada\AppData\Local\Packages\unom.Punktfunk_8wekyb3d8bbwe\LocalCache\Local\punktfunk\logs", + ); + assert_eq!( + strip_verbatim(p), + PathBuf::from( + r"C:\Users\ada\AppData\Local\Packages\unom.Punktfunk_8wekyb3d8bbwe\LocalCache\Local\punktfunk\logs" + ) + ); + } + + /// A roaming profile on a share canonicalizes to `\\?\UNC\…`; the plain UNC form is what + /// Explorer takes. `\\server\share` must survive intact — dropping either half, or letting + /// `PathBuf::push`'s separator logic at the bare `\\`, yields a path that opens nothing. + #[test] + fn verbatim_unc_prefix_becomes_a_plain_unc_path() { + let p = PathBuf::from(r"\\?\UNC\fileserv\profiles\ada\AppData\Local\punktfunk\logs"); + assert_eq!( + strip_verbatim(p), + PathBuf::from(r"\\fileserv\profiles\ada\AppData\Local\punktfunk\logs") + ); + } + + /// An unpackaged dev run resolves to a path that was never verbatim — leave it alone. + #[test] + fn plain_path_is_untouched() { + let p = PathBuf::from(r"C:\Users\ada\AppData\Local\punktfunk\logs"); + assert_eq!(strip_verbatim(p.clone()), p); + } + + /// Whatever the run, the resolved directory is one Explorer can open: it exists, and it + /// carries no verbatim prefix. This is the button's actual precondition. + #[test] + fn real_dir_is_an_openable_directory() { + let Some(dir) = real_dir() else { + return; // no LOCALAPPDATA (not a normal user session) — nothing to assert + }; + assert!(dir.is_dir(), "{} is not a directory", dir.display()); + assert!( + !dir.to_string_lossy().starts_with(r"\\?\"), + "{} kept its verbatim prefix", + dir.display() + ); + } +} From 7077b0a0df1d83547e6dde97011f340fa3ce70c4 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 08:11:35 +0200 Subject: [PATCH 08/53] feat(core/audio): bitrate tiers, a shared de-jitter policy, and a redundant audio plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the audio quality + latency plan (design/audio-quality-and-latency.md). All three pieces are pure and unit-tested here so the four client rings and the Windows host glue that follow stay thin. **Bitrate tiers** (`AudioTier`). The layout table's `bitrate` becomes the `Standard` value, so that tier reproduces the pre-tier wire byte-for-byte — the tier machinery is provably non-regressive. `High` (stereo 256 kbps) is the default: 5 ms Opus frames are much less efficient than 20 ms ones, so the historical 128 kbps buys roughly what ~100 kbps buys at 20 ms, while the same session carries tens of Mbps of video. Purely a host-side encoder knob — libopus reads the bitrate out of the packet, so no client change and no negotiation. **`JitterPolicy`** — the ms-denominated de-jitter state machine every client will share. Two defects it exists to fix: (1) each ring computed its target as `3 x quantum`, a sane 15 ms at a 5 ms quantum and a silent 64 ms at a 20 ms one; (2) every ring primed *up* and clamped at a ceiling, and none walked the depth back *down*, so drift/bursts added latency permanently — Android, with no shed at all, converged on its 120 ms cap. Here a depth EWMA that sits above target for 2 s of consumed audio sheds ONE 5 ms frame with a crossfade. Driven by samples consumed rather than the wall clock: allocation- and syscall-free (safe in a realtime callback) and deterministic under test. `every_preset_sheds_before_it_trims` pins the invariant that makes this real rather than decorative. The first draft had `headroom_ms` <= the shed threshold on all four presets, so the ring was trimmed back before the average could ever reach the shed point: drift correction was dead code and the ratchet test passed for the wrong reason (the hard cap did the work). `a_transient_burst_does_not_shed` caught it. The shed point is now derived from `headroom_ms` so it cannot invert again. **`0xD2` redundant audio** — each datagram carries its frame plus a copy of the previous one, so a single lost packet is reconstructed instead of concealed. Opus in-band FEC cannot do this job: LBRR is a SILK feature and the desktop encoder is CELT-only (RESTRICTED_LOWDELAY, 5 ms), so `set_inband_fec` there is a no-op. Costs no latency — the copy rides the successor, which arrives inside de-jitter slack that already exists. Gated capable-and-agreed via CLIENT_CAP_AUDIO_RED/HOST_CAP_AUDIO_RED; every other session keeps the `0xC9` wire unchanged. 0xD1 is left free for the pad-audio program. cbindgen: prefix the four new exported constants. `FRAME_MS`/`SAMPLE_RATE_HZ` as bare C macros are the same hazard the BTN_* renames already document — a clashing #define takes the last definition silently rather than failing to compile. Verified: 300 core tests, clippy -D warnings, fmt. (`c_abi` fails identically on a pristine tree — this Mac has no system libopus for the C harness link.) Co-Authored-By: Claude Opus 5 (1M context) --- crates/punktfunk-core/cbindgen.toml | 7 + crates/punktfunk-core/src/audio.rs | 779 +++++++++++++++++++++ crates/punktfunk-core/src/quic/caps.rs | 23 + crates/punktfunk-core/src/quic/datagram.rs | 153 ++++ include/punktfunk_core.h | 85 +++ 5 files changed, 1047 insertions(+) diff --git a/crates/punktfunk-core/cbindgen.toml b/crates/punktfunk-core/cbindgen.toml index 0e263da0..538379fd 100644 --- a/crates/punktfunk-core/cbindgen.toml +++ b/crates/punktfunk-core/cbindgen.toml @@ -48,6 +48,13 @@ exclude = ["MsghdrX", "recvmsg_x", "mmsghdr", "sendmmsg", "recvmmsg"] "AXIS_RT" = "PUNKTFUNK_AXIS_RT" "AUDIO_MAGIC" = "PUNKTFUNK_AUDIO_MAGIC" "RUMBLE_MAGIC" = "PUNKTFUNK_RUMBLE_MAGIC" +"AUDIO_RED_MAGIC" = "PUNKTFUNK_AUDIO_RED_MAGIC" +"AUDIO_RED_HEADER" = "PUNKTFUNK_AUDIO_RED_HEADER" +# Same hazard as the BTN_* block above, one step worse: `FRAME_MS` and `SAMPLE_RATE_HZ` are +# generic enough that an embedder is likely to have its own, and a clashing #define silently +# takes the last definition rather than failing to compile. +"FRAME_MS" = "PUNKTFUNK_AUDIO_FRAME_MS" +"SAMPLE_RATE_HZ" = "PUNKTFUNK_AUDIO_SAMPLE_RATE_HZ" # QualifiedScreamingSnakeCase already qualifies each variant with the enum name # (PunktfunkStatus::Ok -> PUNKTFUNK_STATUS_OK); do NOT also set prefix_with_name or it doubles. diff --git a/crates/punktfunk-core/src/audio.rs b/crates/punktfunk-core/src/audio.rs index 88270e6b..6975b9de 100644 --- a/crates/punktfunk-core/src/audio.rs +++ b/crates/punktfunk-core/src/audio.rs @@ -103,6 +103,72 @@ pub const LAYOUT_71_HQ: OpusLayout = OpusLayout { bitrate: 2_048_000, }; +/// Encode bitrate tier for the desktop-audio downlink. The layout table's `bitrate` is the +/// [`AudioTier::Standard`] value, so `Standard` reproduces the pre-tier wire byte-for-byte. +/// +/// **Why a tier at all.** 5 ms Opus frames are markedly less efficient than 20 ms ones (shorter +/// MDCT, a bigger per-packet overhead share), so the historical 128 kbps stereo buys roughly what +/// ~100 kbps buys at 20 ms — audible on music, and the 2026-08-03 field report said exactly that. +/// Meanwhile the same session carries tens of Mbps of video: at 256 kbps audio is ~1 % of the +/// budget. [`AudioTier::High`] is therefore the DEFAULT; the lower tiers exist for a genuinely +/// constrained link, not as the normal case. +/// +/// Purely a host-side encoder knob: every client decodes whatever bitrate arrives (libopus reads +/// it from the packet), so changing tiers needs no protocol negotiation and no client change. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum AudioTier { + /// Constrained links — noticeably lossy on music, still fine for game/voice content. + Low, + /// The historical values (stereo 128 kbps). Kept exactly so the tier machinery is provably + /// non-regressive against every pre-tier build. + Standard, + /// The default: effectively transparent at 5 ms frames, for ~1 % of a normal video budget. + #[default] + High, +} + +impl AudioTier { + /// Parse a config/CLI spelling (`low` / `standard` / `high`); `None` for anything else so the + /// caller can warn and fall back rather than silently downgrading someone's audio. + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "low" => Some(AudioTier::Low), + "standard" | "normal" | "medium" => Some(AudioTier::Standard), + "high" => Some(AudioTier::High), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + AudioTier::Low => "low", + AudioTier::Standard => "standard", + AudioTier::High => "high", + } + } +} + +impl OpusLayout { + /// This layout's target bitrate at `tier`. The uncoupled HIGH-QUALITY layouts + /// ([`LAYOUT_51_HQ`] / [`LAYOUT_71_HQ`]) are already far past transparency, so they are + /// tier-invariant — scaling 1.5 Mbps up would only waste wire. + pub fn bitrate_for(&self, tier: AudioTier) -> i32 { + // One mono stream per channel == the HQ layouts; nothing to gain from a tier there. + if self.coupled == 0 && self.streams == self.channels { + return self.bitrate; + } + match (self.channels, tier) { + (6, AudioTier::Low) => 192_000, + (6, AudioTier::High) => 448_000, + (8, AudioTier::Low) => 320_000, + (8, AudioTier::High) => 768_000, + (_, AudioTier::Low) => 96_000, + (_, AudioTier::High) => 256_000, + (_, AudioTier::Standard) => self.bitrate, + } + } +} + /// Pick the layout for a negotiated channel count. Unknown counts fall back to stereo (clients /// only ever request 2/6/8). `high_quality` selects the uncoupled high-bitrate config. pub fn layout_for(channels: u8, high_quality: bool) -> &'static OpusLayout { @@ -173,6 +239,342 @@ impl AudioGapTracker { } } +// ---- the shared playback de-jitter policy ------------------------------------------------- + +/// The protocol's audio frame, in milliseconds — every host datagram carries exactly one +/// ([`crate::quic::encode_audio_datagram`]), so it is also the smallest useful shed unit. +pub const FRAME_MS: u32 = 5; + +/// Tuning for [`JitterPolicy`], in MILLISECONDS. +/// +/// Denominating the depth in time rather than in device quanta is the point. Every client used to +/// compute its target as `3 × quantum`, which is a sane 15 ms at a 5 ms quantum and a silent 64 ms +/// at a 20 ms one — the same source line meaning two very different latencies depending on what +/// else happened to be using the audio graph that day. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct JitterTuning { + /// Depth to prime to before the first sample plays, and the depth drift correction pulls + /// back toward. The adaptive floor may raise the live target above this; it never goes below. + pub base_target_ms: u32, + /// Ceiling for the adaptively-grown target (see [`JitterPolicy::note_read`]). + pub max_target_ms: u32, + /// Slack above the live target before drop-oldest trimming starts. Absorbs an arrival burst + /// without overflowing. + /// + /// Drift correction sheds at the MIDDLE of this band (see [`JitterTuning::shed_excess_ms`]), + /// so the smooth correction always gets its chance before the hard trim. Setting this too + /// small is a real failure mode, not just a tuning choice: if the trim point sits below the + /// shed point, the ring is trimmed back before the depth average can ever reach the shed + /// threshold, drift correction becomes dead code, and every correction is once again the + /// audible drop it was supposed to replace. + pub headroom_ms: u32, + /// Absolute bound on buffered audio — the only hard guarantee on added latency. + pub hard_cap_ms: u32, + /// Consecutive short reads before the ring goes back to priming. `1` reproduces the old + /// `if ring.is_empty() { primed = false }`, where a single transient drain manufactured a + /// whole target's worth of fresh silence; every platform now uses hysteresis. + pub deprime_after: u32, +} + +impl JitterTuning { + /// PipeWire adaptively rate-matches the stream to the graph clock and absorbs a shallow ring, + /// so Linux can run tight. + pub const PIPEWIRE: JitterTuning = JitterTuning { + base_target_ms: 15, + max_target_ms: 60, + headroom_ms: 25, + hard_cap_ms: 80, + deprime_after: 4, + }; + /// WASAPI shared-mode event-driven render: the engine buffers for us, but nothing rate-matches. + pub const WASAPI: JitterTuning = JitterTuning { + base_target_ms: 20, + max_target_ms: 70, + headroom_ms: 30, + hard_cap_ms: 90, + deprime_after: 4, + }; + /// CoreAudio via AVAudioEngine — comparable to WASAPI; the iOS IO buffer is already 5 ms. + pub const COREAUDIO: JitterTuning = JitterTuning { + base_target_ms: 20, + max_target_ms: 70, + headroom_ms: 30, + hard_cap_ms: 90, + deprime_after: 4, + }; + /// AAudio hands us a raw realtime callback and makes us own the buffer, and Wi-Fi power-save + /// bunching lands as underruns = crackle. Android therefore starts DEEPER — but at 25 ms, not + /// the old fixed 40: the adaptive floor raises it only on the devices that actually underrun, + /// instead of every device pre-paying for the worst one. + pub const AAUDIO: JitterTuning = JitterTuning { + base_target_ms: 25, + max_target_ms: 90, + headroom_ms: 40, + hard_cap_ms: 120, + deprime_after: 5, + }; + + /// How far above the live target the depth average must sit before drift correction sheds: + /// the middle of the headroom band, but never less than two protocol frames (so it cannot be + /// hair-triggered by one quantum of normal swing). Deriving it from `headroom_ms` rather than + /// fixing it absolutely is what keeps the smooth shed strictly BELOW the hard trim on every + /// preset — see the field on `headroom_ms`. + pub const fn shed_excess_ms(&self) -> u32 { + let half = self.headroom_ms / 2; + if half > 2 * FRAME_MS { + half + } else { + 2 * FRAME_MS + } + } +} + +/// What one callback should do, from [`JitterPolicy::step`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub struct JitterStep { + /// Interleaved samples to discard from the FRONT of the ring before reading. + pub drop_front: usize, + /// When non-zero, `drop_front` is a smooth drift correction and this many interleaved samples + /// of linear crossfade should be applied across the seam ([`crossfade_drop`] does it for a + /// `VecDeque` ring). Zero means discard hard — either nothing is being dropped, or the + /// ring blew the hard cap and is already a discontinuity. + pub crossfade: usize, + /// Emit silence this callback: still priming, or re-priming after a sustained drain. + pub silence: bool, +} + +/// EWMA time constant for the depth average, in ms. Long enough that a burst doesn't trigger a +/// shed, short enough to track real drift. +const EWMA_TAU_MS: u32 = 1_000; +/// The depth EWMA must stay above the shed threshold for this much CONSUMED AUDIO. Deliberately long: a shed is the only +/// thing here a listener could ever notice, so it must never fire on a transient. +const SHED_SUSTAIN_MS: u32 = 2_000; +/// Linear crossfade applied across a drift shed's seam. +const SHED_CROSSFADE_MS: u32 = 2; +/// Underruns inside [`GROW_WINDOW_MS`] before the live target grows. +const GROW_UNDERRUNS: u32 = 3; +const GROW_WINDOW_MS: u32 = 5_000; +const GROW_STEP_MS: u32 = 10; +/// Quiet time (no underrun) before a grown target relaxes one step back toward the base. +const SHRINK_QUIET_MS: u32 = 30_000; + +/// The playback de-jitter state machine shared by every client's audio ring. +/// +/// **The defect it exists to fix.** Every client's ring primed *up* to a target and clamped at a +/// ceiling, and none of them walked the depth back *down*. Any transient — a Wi-Fi arrival burst, a +/// host stall, or plain host-DAC-vs-client-DAC clock skew of a few dozen ppm — therefore added +/// latency permanently, until an underrun happened to re-prime. Android, with no shed at all, +/// converged on its hard cap and stayed there; Apple shed 40 ms at once and its own comment called +/// that "one audible blip". Here, a depth EWMA that sits [`SHED_EXCESS_MS`] above target for +/// [`SHED_SUSTAIN_MS`] of consumed audio sheds ONE 5 ms frame with a crossfade, so latency returns +/// to target instead of ratcheting. +/// +/// **Driven by the audio clock, not the wall clock**: every duration is measured in samples +/// consumed. That makes it allocation-free, syscall-free (safe in a realtime callback) and +/// deterministic under test. +#[derive(Clone, Debug)] +pub struct JitterPolicy { + tuning: JitterTuning, + /// Interleaved samples per millisecond at the negotiated layout (48 × channels). + per_ms: usize, + /// The live target, in interleaved samples — `base_target_ms` grown by underrun pressure. + target: usize, + primed: bool, + /// Consecutive short reads (de-prime hysteresis). + empties: u32, + /// EWMA of ring depth, interleaved samples. + depth_avg: f32, + /// Consumed samples for which the EWMA has stayed above the shed threshold. + over_run: usize, + /// Underruns seen in the current growth window, and the window's consumed-sample count. + underruns: u32, + window_run: usize, + /// Consumed samples since the last underrun (drives the relax-back-down step). + quiet_run: usize, + /// `want` from the most recent [`step`](Self::step), so [`note_read`](Self::note_read) can + /// advance the sample-denominated timers without the caller repeating it. + last_want: usize, +} + +impl JitterPolicy { + /// `channels` is the negotiated interleaved channel count (2/6/8). + pub fn new(tuning: JitterTuning, channels: u8) -> JitterPolicy { + let per_ms = (SAMPLE_RATE_HZ / 1000) as usize * channels.max(1) as usize; + JitterPolicy { + tuning, + per_ms, + target: tuning.base_target_ms as usize * per_ms, + primed: false, + empties: 0, + depth_avg: 0.0, + over_run: 0, + underruns: 0, + window_run: 0, + quiet_run: 0, + last_want: 0, + } + } + + /// The live target depth in ms (grows under underrun pressure; never below the base). + pub fn target_ms(&self) -> u32 { + (self.target / self.per_ms) as u32 + } + + /// Convert a ring depth in interleaved samples to milliseconds — for stats/HUD reporting. + pub fn depth_ms(&self, depth: usize) -> u32 { + (depth / self.per_ms) as u32 + } + + /// Smoothed ring depth in ms — what drift correction actually reacts to, and the honest + /// number to publish as "audio buffer" (the instantaneous depth swings by a whole quantum). + pub fn avg_depth_ms(&self) -> u32 { + (self.depth_avg.max(0.0) as usize / self.per_ms) as u32 + } + + pub fn is_primed(&self) -> bool { + self.primed + } + + /// The effective target for a device asking for `want` samples per callback. A ring can never + /// sustain a target below one device quantum, so a large-buffer device (a 20 ms PipeWire graph + /// quantum, a legacy AAudio path) lifts it to `want` plus one protocol frame rather than + /// oscillating prime → dropout → re-prime forever. + fn effective_target(&self, want: usize) -> usize { + self.target.max(want + FRAME_MS as usize * self.per_ms) + } + + /// Decide this callback: what to trim, and whether to play. Call BEFORE reading, with the + /// ring's current `depth` and the device's `want`, both in interleaved samples. + pub fn step(&mut self, depth: usize, want: usize) -> JitterStep { + self.last_want = want; + let target = self.effective_target(want); + + // Track depth with a callback-rate-independent EWMA: weighting by `want` keeps the time + // constant at EWMA_TAU_MS whether the device pulls 5 ms or 20 ms at a time. + let alpha = (want as f32 / (EWMA_TAU_MS as usize * self.per_ms) as f32).clamp(0.0, 1.0); + self.depth_avg += (depth as f32 - self.depth_avg) * alpha; + + // The hard cap must always leave room to serve this callback, or a large-quantum device + // would trim itself into a permanent underrun. + let cap = (target + self.tuning.headroom_ms as usize * self.per_ms) + .min(self.tuning.hard_cap_ms as usize * self.per_ms) + .max(target + want); + + let mut out = JitterStep::default(); + if depth > cap { + // Blew the ceiling: a burst arrived, or we were wedged. Already a discontinuity — + // discard hard, and reset the drift timer so the trim isn't double-counted as drift. + out.drop_front = depth - cap; + self.over_run = 0; + } else if self.depth_avg + > (target + self.tuning.shed_excess_ms() as usize * self.per_ms) as f32 + { + self.over_run += want; + if self.over_run >= SHED_SUSTAIN_MS as usize * self.per_ms { + out.drop_front = (FRAME_MS as usize * self.per_ms).min(depth); + out.crossfade = (SHED_CROSSFADE_MS as usize * self.per_ms) + .min(depth.saturating_sub(out.drop_front)); + self.over_run = 0; + } + } else { + self.over_run = 0; + } + // Whatever we shed is no longer buffered — reflect it immediately so the next callbacks + // don't re-fire on a stale average. + self.depth_avg = (self.depth_avg - out.drop_front as f32).max(0.0); + + if !self.primed && depth.saturating_sub(out.drop_front) >= target { + self.primed = true; + self.empties = 0; + } + out.silence = !self.primed; + out + } + + /// Report the outcome of the read `step` authorised. `ran_short` = the ring could not fill the + /// callback (a genuine underrun), which drives both the de-prime hysteresis and the adaptive + /// target floor. + /// + /// A callback that `step` told to emit silence is NOT an underrun — the ring is deliberately + /// re-priming — so calls made while un-primed are ignored and callers need not special-case it. + pub fn note_read(&mut self, ran_short: bool) { + if !self.primed { + return; + } + let want = self.last_want.max(1); + self.window_run += want; + if self.window_run >= GROW_WINDOW_MS as usize * self.per_ms { + self.window_run = 0; + self.underruns = 0; + } + if ran_short { + self.quiet_run = 0; + self.empties += 1; + if self.empties >= self.tuning.deprime_after { + self.primed = false; + self.empties = 0; + } + self.underruns += 1; + if self.underruns >= GROW_UNDERRUNS { + // This device genuinely needs more slack than the base target. Grow ONCE per + // window, capped — the alternative (every device pre-paying the worst device's + // depth) is what the fixed 40 ms Android floor was. + self.underruns = 0; + self.window_run = 0; + let grown = self.target + GROW_STEP_MS as usize * self.per_ms; + self.target = grown.min(self.tuning.max_target_ms as usize * self.per_ms); + } + } else { + self.empties = 0; + self.quiet_run += want; + if self.quiet_run >= SHRINK_QUIET_MS as usize * self.per_ms { + // Long quiet spell: give a grown target one step back, so a single bad minute + // doesn't cost latency for the rest of the session. + self.quiet_run = 0; + let base = self.tuning.base_target_ms as usize * self.per_ms; + self.target = self + .target + .saturating_sub(GROW_STEP_MS as usize * self.per_ms) + .max(base); + } + } + } +} + +/// Sample rate of every audio plane in the protocol. +pub const SAMPLE_RATE_HZ: u32 = 48_000; + +/// Discard `drop` interleaved samples from the front of `ring`, linearly crossfading the seam over +/// `fade` samples so a drift correction is inaudible rather than a click. +/// +/// The dropped region's tail fades out while the surviving head fades in, so the waveform is +/// continuous across the splice. `fade == 0` discards hard (what a hard-cap trim wants — that +/// backlog is already a discontinuity). Shared by the three `VecDeque` rings; the Apple ring +/// is index-based and mirrors this in Swift. +pub fn crossfade_drop(ring: &mut std::collections::VecDeque, drop: usize, fade: usize) { + if drop == 0 || ring.len() < drop { + return; + } + let fade = fade.min(drop).min(ring.len() - drop); + if fade == 0 { + ring.drain(..drop); + return; + } + // The last `fade` samples of what we are about to discard are the fade-OUT source; they blend + // into the first `fade` samples of what survives. + let mut faded = Vec::with_capacity(fade); + for i in 0..fade { + let old = ring[drop - fade + i]; + let new = ring[drop + i]; + let t = (i + 1) as f32 / (fade + 1) as f32; + faded.push(old * (1.0 - t) + new * t); + } + ring.drain(..drop); + for (i, v) in faded.into_iter().enumerate() { + ring[i] = v; + } +} + // ---- per-platform channel-layout helpers (pure data; no platform deps) -------------------- /// Windows `WAVEFORMATEXTENSIBLE.dwChannelMask` for the wire layout. @@ -286,6 +688,383 @@ mod tests { assert_eq!(t.missing_before(0), 0, "pre-wrap reorder, not a 2^31 gap"); } + // ---- bitrate tiers ------------------------------------------------------------------- + + /// `Standard` must reproduce the historical table EXACTLY — that is what makes the tier + /// machinery provably non-regressive against every pre-tier build. + #[test] + fn standard_tier_is_the_legacy_table() { + for l in [ + &LAYOUT_STEREO, + &LAYOUT_51, + &LAYOUT_51_HQ, + &LAYOUT_71, + &LAYOUT_71_HQ, + ] { + assert_eq!(l.bitrate_for(AudioTier::Standard), l.bitrate, "{l:?}"); + } + } + + #[test] + fn tiers_are_monotonic_and_hq_layouts_are_invariant() { + for l in [&LAYOUT_STEREO, &LAYOUT_51, &LAYOUT_71] { + let (lo, std, hi) = ( + l.bitrate_for(AudioTier::Low), + l.bitrate_for(AudioTier::Standard), + l.bitrate_for(AudioTier::High), + ); + assert!(lo < std && std < hi, "{l:?}: {lo} < {std} < {hi}"); + } + // The uncoupled HQ layouts are already past transparency — no tier may move them. + for l in [&LAYOUT_51_HQ, &LAYOUT_71_HQ] { + for t in [AudioTier::Low, AudioTier::Standard, AudioTier::High] { + assert_eq!(l.bitrate_for(t), l.bitrate, "{l:?} at {t:?}"); + } + } + } + + #[test] + fn tier_default_is_high_and_parses() { + assert_eq!(AudioTier::default(), AudioTier::High); + for t in [AudioTier::Low, AudioTier::Standard, AudioTier::High] { + assert_eq!(AudioTier::parse(t.as_str()), Some(t)); + } + assert_eq!(AudioTier::parse(" HIGH "), Some(AudioTier::High)); + assert_eq!(AudioTier::parse("normal"), Some(AudioTier::Standard)); + // Unknown spellings must be rejected, not silently downgraded. + assert_eq!(AudioTier::parse("transparent"), None); + assert_eq!(AudioTier::parse(""), None); + } + + // ---- the de-jitter policy ------------------------------------------------------------ + + /// Interleaved samples per ms at `channels`. + fn per_ms(channels: u8) -> usize { + (SAMPLE_RATE_HZ / 1000) as usize * channels as usize + } + + /// One simulated run's outcome. + #[derive(Debug, Default)] + struct Sim { + final_ms: u32, + peak_ms: u32, + /// Smooth drift corrections (crossfaded, one frame each) — the good kind. + soft_sheds: u32, + /// Hard-cap trims — the backstop. Any of these in a plain-drift run means the smooth + /// correction is not doing its job. + hard_trims: u32, + underruns: u32, + } + + /// Drive a policy through `ms` of simulated audio at a `quantum_ms` device, where the producer + /// delivers `drift_ppm` more (or less) than the consumer takes — i.e. host-vs-client clock skew. + fn simulate( + tuning: JitterTuning, + channels: u8, + ms: u32, + quantum_ms: u32, + drift_ppm: i64, + start_ms: u32, + ) -> Sim { + let pm = per_ms(channels); + let want = quantum_ms as usize * pm; + let mut p = JitterPolicy::new(tuning, channels); + let mut depth = start_ms as usize * pm; + let mut out = Sim::default(); + // Fractional producer accumulator, so a sub-sample-per-callback drift still accumulates. + let mut carry: i64 = 0; + for _ in 0..(ms / quantum_ms) { + // Producer: one quantum of audio plus the drift. + carry += want as i64 * drift_ppm; + let extra = carry / 1_000_000; + carry -= extra * 1_000_000; + depth = (depth as i64 + want as i64 + extra).max(0) as usize; + + let s = p.step(depth, want); + if s.drop_front > 0 { + if s.crossfade > 0 { + out.soft_sheds += 1; + } else { + out.hard_trims += 1; + } + depth -= s.drop_front.min(depth); + } + if s.silence { + p.note_read(false); + continue; + } + let short = depth < want; + depth -= want.min(depth); + if short { + out.underruns += 1; + } + p.note_read(short); + out.peak_ms = out.peak_ms.max((depth / pm) as u32); + } + out.final_ms = (depth / pm) as u32; + out + } + + /// The invariant that makes drift correction real rather than decorative: on every preset the + /// smooth shed point must sit strictly BELOW the hard trim point. Invert it — by tuning + /// `headroom_ms` down — and the ring is trimmed back before the depth average can ever reach + /// the shed threshold, so the smooth path becomes dead code and every correction is the + /// audible drop it was meant to replace. (That inversion was present in the first draft of + /// this module and only surfaced because `a_transient_burst_does_not_shed` failed.) + #[test] + fn every_preset_sheds_before_it_trims() { + for (name, t) in [ + ("PIPEWIRE", JitterTuning::PIPEWIRE), + ("WASAPI", JitterTuning::WASAPI), + ("COREAUDIO", JitterTuning::COREAUDIO), + ("AAUDIO", JitterTuning::AAUDIO), + ] { + assert!( + t.shed_excess_ms() < t.headroom_ms, + "{name}: sheds at +{} ms but trims at +{} ms — drift correction can never fire", + t.shed_excess_ms(), + t.headroom_ms + ); + assert!( + t.base_target_ms + t.headroom_ms <= t.hard_cap_ms, + "{name}: the headroom band is cut short by the hard cap" + ); + assert!(t.max_target_ms >= t.base_target_ms, "{name}"); + assert!(t.deprime_after >= 2, "{name}: needs real hysteresis"); + } + } + + /// THE headline behaviour, and the defect this policy exists for: with the host clock running + /// fast, the old rings grew to their ceiling and stayed pinned there for the rest of the + /// session. Drift correction must hold the depth near target — and must do it with the SMOOTH + /// crossfaded shed, never by letting the hard cap chop the backlog. + #[test] + fn drift_does_not_ratchet_latency_to_the_ceiling() { + // +200 ppm is a deliberately harsh skew (real DAC pairs are tens of ppm); 5 minutes. + let s = simulate(JitterTuning::AAUDIO, 2, 300_000, 5, 200, 25); + assert!( + s.soft_sheds > 0, + "drift must be shed, not accumulated: {s:?}" + ); + assert_eq!( + s.hard_trims, 0, + "plain drift must never reach the hard cap: {s:?}" + ); + assert_eq!( + s.underruns, 0, + "shedding must never cause an underrun: {s:?}" + ); + // The old Android ring pinned at its 120 ms hard cap. Ours must stay inside the band. + let ceiling = JitterTuning::AAUDIO.base_target_ms + JitterTuning::AAUDIO.headroom_ms; + assert!( + s.peak_ms <= ceiling, + "peaked at {} ms (band ends at {ceiling}) — that is the ratchet, not a correction", + s.peak_ms + ); + } + + /// Same skew, every preset: none of them may ratchet. + #[test] + fn no_preset_ratchets_under_drift() { + for (name, t) in [ + ("PIPEWIRE", JitterTuning::PIPEWIRE), + ("WASAPI", JitterTuning::WASAPI), + ("COREAUDIO", JitterTuning::COREAUDIO), + ("AAUDIO", JitterTuning::AAUDIO), + ] { + let s = simulate(t, 2, 300_000, 5, 200, t.base_target_ms); + assert!(s.soft_sheds > 0, "{name}: {s:?}"); + assert!( + s.peak_ms <= t.base_target_ms + t.headroom_ms, + "{name} peaked at {} ms: {s:?}", + s.peak_ms + ); + } + } + + /// The mirror case: a host clock running SLOW must not be "corrected" into permanent + /// underruns. The adaptive floor may grow the target, but nothing may be shed. + #[test] + fn negative_drift_grows_the_target_instead_of_stuttering() { + let s = simulate(JitterTuning::AAUDIO, 2, 120_000, 5, -200, 25); + assert_eq!( + s.soft_sheds, 0, + "nothing to shed when the ring is draining: {s:?}" + ); + assert_eq!(s.hard_trims, 0, "{s:?}"); + } + + /// A shed must never fire on a transient — a burst that arrives and drains is normal jitter, + /// and shedding it would cost an audible artefact for nothing. The spike here sits ABOVE the + /// shed threshold but below the trim point, so only the sustain requirement can reject it. + #[test] + fn a_transient_burst_does_not_shed() { + let t = JitterTuning::AAUDIO; + let pm = per_ms(2); + let want = 5 * pm; + let spike_ms = t.base_target_ms + t.shed_excess_ms() + FRAME_MS; // inside the band + assert!( + spike_ms < t.base_target_ms + t.headroom_ms, + "test spike must not hit the trim" + ); + let mut p = JitterPolicy::new(t, 2); + let mut sheds = 0; + // 300 ms spiked out of every 1 s, for 20 s. + for round in 0..20 { + for i in 0..200 { + let depth = if round > 0 && i < 60 { + spike_ms + } else { + t.base_target_ms + } as usize; + let s = p.step(depth * pm, want); + if s.drop_front > 0 { + sheds += 1; + } + p.note_read(false); + } + } + assert_eq!( + sheds, 0, + "a repeated short burst must not trigger drift correction" + ); + } + + /// The hard cap is the only absolute latency guarantee — it trims immediately, without + /// waiting for the drift timer. + #[test] + fn hard_cap_trims_at_once() { + let pm = per_ms(2); + let mut p = JitterPolicy::new(JitterTuning::AAUDIO, 2); + let s = p.step(500 * pm, 5 * pm); + assert!( + s.drop_front > 0, + "a 500 ms backlog must be trimmed on the spot" + ); + assert_eq!(s.crossfade, 0, "a blown cap is already a discontinuity"); + let left = 500 * pm - s.drop_front; + assert!( + left <= JitterTuning::AAUDIO.hard_cap_ms as usize * pm, + "trim must land at or under the hard cap" + ); + } + + /// One transient drain must not manufacture a fresh target's worth of silence — the bug + /// Android fixed and Linux/Windows still carried. + #[test] + fn deprime_requires_hysteresis() { + let pm = per_ms(2); + let want = 5 * pm; + let mut p = JitterPolicy::new(JitterTuning::PIPEWIRE, 2); + // An EMPTY ring must emit silence and stay un-primed, however many callbacks it sees. + for _ in 0..10 { + assert!(p.step(0, want).silence, "an empty ring cannot play"); + } + assert!(!p.is_primed()); + // A ring already holding well over target primes on the first callback that sees it. + assert!( + !p.step(50 * pm, want).silence, + "a ring holding well over target must start immediately" + ); + assert!(p.is_primed()); + p.note_read(true); // one short read + assert!(p.is_primed(), "a single short read must not de-prime"); + for _ in 1..JitterTuning::PIPEWIRE.deprime_after { + p.note_read(true); + } + assert!(!p.is_primed(), "a sustained drain must re-prime"); + } + + /// A device that pulls a big quantum cannot sustain a target below it: the effective target + /// must lift, or the ring oscillates prime → dropout → re-prime forever. + #[test] + fn target_lifts_above_a_large_device_quantum() { + let pm = per_ms(2); + let mut p = JitterPolicy::new(JitterTuning::PIPEWIRE, 2); // base target 15 ms + let want = 40 * pm; // a 40 ms graph quantum — far above the base target + // At exactly the base target the ring must NOT claim to be primed. + assert!( + p.step(15 * pm, want).silence, + "15 ms cannot serve a 40 ms quantum" + ); + // Once it holds the quantum plus a frame, it may play. + let s = p.step((40 + FRAME_MS as usize) * pm, want); + assert!(!s.silence, "quantum + one frame must be enough to start"); + } + + /// Clustered underruns raise the floor (that device needs the slack); a long quiet spell + /// gives it back, so one bad minute doesn't cost latency for the whole session. + #[test] + fn target_grows_on_underruns_and_relaxes_when_quiet() { + let pm = per_ms(2); + let want = 5 * pm; + let mut p = JitterPolicy::new(JitterTuning::AAUDIO, 2); + let base = p.target_ms(); + assert_eq!(base, JitterTuning::AAUDIO.base_target_ms); + for _ in 0..40 { + // Keep it primed and starve it: depth is always enough to prime, never to serve. + while !p.is_primed() { + p.step(200 * pm, want); + } + p.step(200 * pm, want); + p.note_read(true); + } + let grown = p.target_ms(); + assert!( + grown > base, + "clustered underruns must raise the floor ({base} → {grown})" + ); + assert!( + grown <= JitterTuning::AAUDIO.max_target_ms, + "growth must respect max_target_ms" + ); + // Now a long clean run relaxes it back. + for _ in 0..(SHRINK_QUIET_MS as usize * 3 / 5) { + p.step(grown as usize * pm + want, want); + p.note_read(false); + } + assert!( + p.target_ms() < grown, + "a quiet spell must give the growth back" + ); + assert!(p.target_ms() >= base, "…but never below the base target"); + } + + /// The crossfade must leave a continuous waveform: splicing a ramp must not introduce a step + /// bigger than the ramp's own per-sample slope. + #[test] + fn crossfade_drop_splices_without_a_step() { + use std::collections::VecDeque; + // A slow ramp: any hard splice shows up as a visible jump. + let mut ring: VecDeque = (0..1000).map(|i| i as f32).collect(); + let (drop, fade) = (240, 96); + crossfade_drop(&mut ring, drop, fade); + assert_eq!(ring.len(), 1000 - drop); + // Across the whole faded region the step between neighbours stays bounded — a hard drop + // would show a `drop`-sized jump at index 0. + for i in 0..fade { + let step = (ring[i + 1] - ring[i]).abs(); + assert!( + step < drop as f32, + "sample {i}: step {step} looks like a hard splice" + ); + } + // Tail is untouched. + assert_eq!(ring[ring.len() - 1], 999.0); + } + + #[test] + fn crossfade_drop_handles_degenerate_inputs() { + use std::collections::VecDeque; + let mut ring: VecDeque = (0..10).map(|i| i as f32).collect(); + crossfade_drop(&mut ring, 0, 4); // nothing to drop + assert_eq!(ring.len(), 10); + crossfade_drop(&mut ring, 99, 4); // more than we hold — refuse + assert_eq!(ring.len(), 10); + crossfade_drop(&mut ring, 10, 4); // exactly all of it: no room to fade, hard drop + assert!(ring.is_empty()); + } + #[test] fn wasapi_masks_are_correct() { assert_eq!(wasapi_channel_mask(2), 0x3); diff --git a/crates/punktfunk-core/src/quic/caps.rs b/crates/punktfunk-core/src/quic/caps.rs index 1cf5e69b..181326e0 100644 --- a/crates/punktfunk-core/src/quic/caps.rs +++ b/crates/punktfunk-core/src/quic/caps.rs @@ -111,6 +111,17 @@ pub const CLIENT_CAP_CURSOR: u8 = 0x01; /// simply ignored — no behavior change in either direction. pub const CLIENT_CAP_PHASE_LOCK: u8 = 0x02; +/// `Hello.client_caps` bit: this client can decode the redundant desktop-audio plane +/// ([`AUDIO_RED_MAGIC`](super::datagram::AUDIO_RED_MAGIC), `0xD2`), where every datagram also +/// carries a copy of the previous frame so a single lost packet is reconstructed instead of +/// papered over with packet-loss concealment. +/// +/// Active only when the host answers with [`HOST_CAP_AUDIO_RED`] (capable-and-agreed, the +/// cursor/clipboard precedent). Toward an older host, or a host that declines because the link is +/// clean, the client keeps receiving the plain `0xC9` plane — so a client may always set this bit. +/// `0x04` — `0x01`/`0x02` are cursor / phase-lock. +pub const CLIENT_CAP_AUDIO_RED: u8 = 0x04; + /// [`Welcome::host_caps`] bit: the host CAN forward the cursor out-of-band (it captures cursor /// metadata separately from the frame — the Linux portal `SPA_META_Cursor` path; NOT gamescope, /// whose capture carries no cursor, and NOT Windows yet, where DWM composites into the IDD @@ -132,6 +143,18 @@ pub const HOST_CAP_CURSOR: u8 = 0x08; /// [`HOST_CAP_TEXT_INPUT`], `0x01`/`0x02` are gamepad-state / clipboard. pub const HOST_CAP_PEN: u8 = 0x10; +/// [`Welcome::host_caps`] bit: the host is sending the REDUNDANT desktop-audio plane +/// ([`AUDIO_RED_MAGIC`](super::datagram::AUDIO_RED_MAGIC), `0xD2`) instead of plain `0xC9` — each +/// datagram carries its own frame plus a copy of the previous one. +/// +/// Set only when the client asked via [`CLIENT_CAP_AUDIO_RED`]. It is a statement about the WIRE, +/// not a negotiation the client can decline: with the bit set the client must decode `0xD2`, and +/// without it `0xC9`. The host may also drop back to `0xC9` mid-session (the redundancy is +/// loss-gated — a clean LAN shouldn't pay for it), which is why clients decode BOTH tags +/// unconditionally and treat this bit as "expect redundancy", not "only redundancy". +/// `0x20` — `0x10` is [`HOST_CAP_PEN`], `0x08` is [`HOST_CAP_CURSOR`]. +pub const HOST_CAP_AUDIO_RED: u8 = 0x20; + /// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software** /// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST /// advertise this. diff --git a/crates/punktfunk-core/src/quic/datagram.rs b/crates/punktfunk-core/src/quic/datagram.rs index 22977987..0567f241 100644 --- a/crates/punktfunk-core/src/quic/datagram.rs +++ b/crates/punktfunk-core/src/quic/datagram.rs @@ -42,6 +42,80 @@ pub fn decode_audio_datagram(b: &[u8]) -> Option<(u32, u64, &[u8])> { Some((seq, pts_ns, &b[13..])) } +/// Redundant audio datagram, host → client: the [`AUDIO_MAGIC`] plane plus a copy of the PREVIOUS +/// frame, so a single lost datagram is *reconstructed* rather than concealed. +/// +/// `[0xD2][u32 seq LE][u64 pts_ns LE][u16 primary_len LE][primary opus][previous opus]` +/// +/// **Why this and not Opus in-band FEC.** LBRR is a SILK-layer feature: the desktop-audio encoder +/// runs `RESTRICTED_LOWDELAY` (CELT-only) at 5 ms frames, which is below SILK's 10 ms minimum, so +/// `set_inband_fec(true)` on that encoder is a no-op. Nothing in libopus can protect this plane — +/// the redundancy has to be at the application layer. (The mic uplink is a different encoder, VoIP +/// mode at 10 ms, and *does* use real in-band FEC.) +/// +/// **Why it costs no latency.** The copy rides the SUCCESSOR of the frame it protects, and the +/// client is already holding 15–90 ms of de-jitter buffer — far more than the 5 ms the successor +/// takes to arrive. So the recovery happens inside slack that already exists. +/// +/// The previous frame's sequence is implicitly `seq - 1`; a host with nothing to duplicate yet +/// (the first frame of a session, or straight after a capture reopen) simply sends an empty tail, +/// which decodes to `None`. +/// +/// Sent ONLY when the client advertised [`CLIENT_CAP_AUDIO_RED`](super::caps::CLIENT_CAP_AUDIO_RED) +/// and the host answered [`HOST_CAP_AUDIO_RED`](super::caps::HOST_CAP_AUDIO_RED) — the +/// capable-and-agreed handshake the cursor and 4:4:4 planes already use. Every other session keeps +/// the plain [`AUDIO_MAGIC`] wire byte-for-byte. +/// +/// NB `0xD1` is deliberately skipped: the DualSense pad-audio program has reserved it for the +/// per-pad audio plane. +pub const AUDIO_RED_MAGIC: u8 = 0xD2; + +/// Fixed header length of an [`AUDIO_RED_MAGIC`] datagram (tag + seq + pts + primary length). +pub const AUDIO_RED_HEADER: usize = 1 + 4 + 8 + 2; + +/// Encode a redundant audio datagram. `prev` is the immediately-preceding frame's Opus payload +/// (empty when there is none yet). +pub fn encode_audio_red_datagram(seq: u32, pts_ns: u64, opus: &[u8], prev: &[u8]) -> Vec { + let mut b = Vec::with_capacity(AUDIO_RED_HEADER + opus.len() + prev.len()); + b.push(AUDIO_RED_MAGIC); + b.extend_from_slice(&seq.to_le_bytes()); + b.extend_from_slice(&pts_ns.to_le_bytes()); + // A frame longer than u16::MAX cannot occur (5 ms of Opus is tens of bytes; the buffer the + // encoder writes into is 4 KiB) — but truncating silently would desync the split, so clamp + // the redundancy off instead of the primary. + let primary_len = u16::try_from(opus.len()).unwrap_or(u16::MAX); + b.extend_from_slice(&primary_len.to_le_bytes()); + b.extend_from_slice(opus); + if opus.len() == primary_len as usize { + b.extend_from_slice(prev); + } + b +} + +/// Parse a redundant audio datagram → `(seq, pts_ns, primary, previous)`. `previous` is `None` +/// when the host had nothing to duplicate. `None` overall on bad tag/length, including a +/// `primary_len` that overruns the datagram (a truncated or hostile packet must not panic). +/// +/// The tuple shape deliberately mirrors [`decode_audio_datagram`] (one extra slot for the +/// redundant copy) so the two planes read the same at every call site; a named struct here would +/// be the odd one out on this module's decode surface, and cbindgen would then have to be taught +/// to skip it. +#[allow(clippy::type_complexity)] +pub fn decode_audio_red_datagram(b: &[u8]) -> Option<(u32, u64, &[u8], Option<&[u8]>)> { + if b.len() < AUDIO_RED_HEADER || b[0] != AUDIO_RED_MAGIC { + return None; + } + let seq = u32::from_le_bytes(b[1..5].try_into().unwrap()); + let pts_ns = u64::from_le_bytes(b[5..13].try_into().unwrap()); + let primary_len = u16::from_le_bytes(b[13..15].try_into().unwrap()) as usize; + let rest = &b[AUDIO_RED_HEADER..]; + if primary_len > rest.len() { + return None; // truncated: the split point is outside the datagram + } + let (primary, prev) = rest.split_at(primary_len); + Some((seq, pts_ns, primary, (!prev.is_empty()).then_some(prev))) +} + /// Legacy rumble datagram (v1), host → client: `[0xCA][u16 pad LE][u16 low LE][u16 high LE]`. /// Force-feedback state for pad `pad` (0xFFFF amplitudes, 0/0 = stop) as *level-triggered* state /// — it persists until superseded, which is why the host re-sends it periodically as its loss @@ -806,6 +880,8 @@ mod tests { #[test] fn audio_datagram_roundtrip() { let opus = [0x42u8; 97]; + let d = encode_audio_red_datagram(7, 42, &opus, &[]); + assert_eq!(d[0], AUDIO_RED_MAGIC); let d = encode_audio_datagram(7, 1_000_000_123, &opus); assert_eq!(d[0], AUDIO_MAGIC); let (seq, pts, payload) = decode_audio_datagram(&d).unwrap(); @@ -820,6 +896,83 @@ mod tests { assert!(empty.is_empty()); } + #[test] + fn audio_red_datagram_roundtrip() { + let cur = [0x42u8; 97]; + let prev = [0x37u8; 88]; + let d = encode_audio_red_datagram(7, 1_000_000_123, &cur, &prev); + assert_eq!(d[0], AUDIO_RED_MAGIC); + let (seq, pts, primary, previous) = decode_audio_red_datagram(&d).unwrap(); + assert_eq!((seq, pts), (7, 1_000_000_123)); + assert_eq!(primary, cur); + assert_eq!(previous, Some(&prev[..])); + + // No predecessor yet (first frame of a session / after a capture reopen). + let d = encode_audio_red_datagram(0, 5, &cur, &[]); + let (_, _, primary, previous) = decode_audio_red_datagram(&d).unwrap(); + assert_eq!(primary, cur); + assert_eq!( + previous, None, + "an empty tail must decode as absent, not as a zero-length frame" + ); + + // Frames of equal length must still split at the right place — the length prefix is the + // only thing that can tell them apart. + let a = [1u8; 64]; + let b = [2u8; 64]; + let d = encode_audio_red_datagram(9, 0, &a, &b); + let (_, _, primary, previous) = decode_audio_red_datagram(&d).unwrap(); + assert_eq!(primary, a); + assert_eq!(previous, Some(&b[..])); + } + + /// A truncated or hostile `0xD2` must be rejected, never panic — the split point comes off + /// the wire, so an over-long `primary_len` is the obvious attack on `split_at`. + #[test] + fn audio_red_datagram_rejects_bad_input() { + let d = encode_audio_red_datagram(1, 2, &[0xAAu8; 30], &[0xBBu8; 20]); + for n in 0..AUDIO_RED_HEADER { + assert!(decode_audio_red_datagram(&d[..n]).is_none(), "len {n}"); + } + // primary_len larger than the datagram: must be refused, not sliced. + let mut bad = d.clone(); + bad[13..15].copy_from_slice(&u16::MAX.to_le_bytes()); + assert!(decode_audio_red_datagram(&bad).is_none()); + // Wrong tag. + let mut wrong = d.clone(); + wrong[0] = AUDIO_MAGIC; + assert!(decode_audio_red_datagram(&wrong).is_none()); + } + + /// The two audio planes must not alias each other or any neighbouring plane: a client + /// demultiplexes purely on the first byte. + #[test] + fn audio_red_tag_is_disjoint() { + for other in [ + AUDIO_MAGIC, + RUMBLE_MAGIC, + MIC_MAGIC, + RICH_INPUT_MAGIC, + HIDOUT_MAGIC, + HDR_META_MAGIC, + HOST_TIMING_MAGIC, + CURSOR_STATE_MAGIC, + crate::input::INPUT_MAGIC, + ] { + assert_ne!(AUDIO_RED_MAGIC, other); + } + let red = encode_audio_red_datagram(1, 2, &[9u8; 40], &[8u8; 40]); + assert!( + decode_audio_datagram(&red).is_none(), + "0xC9 must not accept a 0xD2" + ); + let plain = encode_audio_datagram(1, 2, &[9u8; 40]); + assert!( + decode_audio_red_datagram(&plain).is_none(), + "0xD2 must not accept a 0xC9" + ); + } + #[test] fn rumble_datagram_roundtrip() { let d = encode_rumble_datagram(1, 0x1234, 0xFFFF); diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 27706ff3..cc65876f 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -312,6 +312,13 @@ // `PunktfunkStatus` code). #define PUNKTFUNK_CLIP_ERROR 6 +// The protocol's audio frame, in milliseconds — every host datagram carries exactly one +// ([`crate::quic::encode_audio_datagram`]), so it is also the smallest useful shed unit. +#define PUNKTFUNK_AUDIO_FRAME_MS 5 + +// Sample rate of every audio plane in the protocol. +#define PUNKTFUNK_AUDIO_SAMPLE_RATE_HZ 48000 + #if defined(PUNKTFUNK_FEATURE_QUIC) // The uniform no-TTL-host staleness bound: a legacy host refreshes state every 500 ms, so two // missed refreshes = quiet host → silence. Replaces the per-platform zoo (1.6 s / 60 s / 1.5 s / @@ -627,6 +634,19 @@ #define CLIENT_CAP_PHASE_LOCK 2 #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// `Hello.client_caps` bit: this client can decode the redundant desktop-audio plane +// ([`AUDIO_RED_MAGIC`](super::datagram::AUDIO_RED_MAGIC), `0xD2`), where every datagram also +// carries a copy of the previous frame so a single lost packet is reconstructed instead of +// papered over with packet-loss concealment. +// +// Active only when the host answers with [`HOST_CAP_AUDIO_RED`] (capable-and-agreed, the +// cursor/clipboard precedent). Toward an older host, or a host that declines because the link is +// clean, the client keeps receiving the plain `0xC9` plane — so a client may always set this bit. +// `0x04` — `0x01`/`0x02` are cursor / phase-lock. +#define CLIENT_CAP_AUDIO_RED 4 +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // [`Welcome::host_caps`] bit: the host CAN forward the cursor out-of-band (it captures cursor // metadata separately from the frame — the Linux portal `SPA_META_Cursor` path; NOT gamescope, @@ -652,6 +672,20 @@ #define HOST_CAP_PEN 16 #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// [`Welcome::host_caps`] bit: the host is sending the REDUNDANT desktop-audio plane +// ([`AUDIO_RED_MAGIC`](super::datagram::AUDIO_RED_MAGIC), `0xD2`) instead of plain `0xC9` — each +// datagram carries its own frame plus a copy of the previous one. +// +// Set only when the client asked via [`CLIENT_CAP_AUDIO_RED`]. It is a statement about the WIRE, +// not a negotiation the client can decline: with the bit set the client must decode `0xD2`, and +// without it `0xC9`. The host may also drop back to `0xC9` mid-session (the redundancy is +// loss-gated — a clean LAN shouldn't pay for it), which is why clients decode BOTH tags +// unconditionally and treat this bit as "expect redundancy", not "only redundancy". +// `0x20` — `0x10` is [`HOST_CAP_PEN`], `0x08` is [`HOST_CAP_CURSOR`]. +#define HOST_CAP_AUDIO_RED 32 +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software** // encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST @@ -967,6 +1001,41 @@ #define HIDOUT_MAGIC 205 #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Redundant audio datagram, host → client: the [`AUDIO_MAGIC`] plane plus a copy of the PREVIOUS +// frame, so a single lost datagram is *reconstructed* rather than concealed. +// +// `[0xD2][u32 seq LE][u64 pts_ns LE][u16 primary_len LE][primary opus][previous opus]` +// +// **Why this and not Opus in-band FEC.** LBRR is a SILK-layer feature: the desktop-audio encoder +// runs `RESTRICTED_LOWDELAY` (CELT-only) at 5 ms frames, which is below SILK's 10 ms minimum, so +// `set_inband_fec(true)` on that encoder is a no-op. Nothing in libopus can protect this plane — +// the redundancy has to be at the application layer. (The mic uplink is a different encoder, VoIP +// mode at 10 ms, and *does* use real in-band FEC.) +// +// **Why it costs no latency.** The copy rides the SUCCESSOR of the frame it protects, and the +// client is already holding 15–90 ms of de-jitter buffer — far more than the 5 ms the successor +// takes to arrive. So the recovery happens inside slack that already exists. +// +// The previous frame's sequence is implicitly `seq - 1`; a host with nothing to duplicate yet +// (the first frame of a session, or straight after a capture reopen) simply sends an empty tail, +// which decodes to `None`. +// +// Sent ONLY when the client advertised [`CLIENT_CAP_AUDIO_RED`](super::caps::CLIENT_CAP_AUDIO_RED) +// and the host answered [`HOST_CAP_AUDIO_RED`](super::caps::HOST_CAP_AUDIO_RED) — the +// capable-and-agreed handshake the cursor and 4:4:4 planes already use. Every other session keeps +// the plain [`AUDIO_MAGIC`] wire byte-for-byte. +// +// NB `0xD1` is deliberately skipped: the DualSense pad-audio program has reserved it for the +// per-pad audio plane. +#define PUNKTFUNK_AUDIO_RED_MAGIC 210 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Fixed header length of an [`AUDIO_RED_MAGIC`] datagram (tag + seq + pts + primary length). +#define PUNKTFUNK_AUDIO_RED_HEADER (((1 + 4) + 8) + 2) +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Wire length of a v1 (legacy, level) rumble datagram. #define RUMBLE_V1_LEN 7 @@ -1397,6 +1466,14 @@ typedef uint8_t PunktfunkInputKind; typedef struct ColorInfo ColorInfo; #endif +// Tuning for [`JitterPolicy`], in MILLISECONDS. +// +// Denominating the depth in time rather than in device quanta is the point. Every client used to +// compute its target as `3 × quantum`, which is a sane 15 ms at a 5 ms quantum and a silent 64 ms +// at a 20 ms one — the same source line meaning two very different latencies depending on what +// else happened to be using the audio graph that day. +typedef struct JitterTuning JitterTuning; + #if defined(PUNKTFUNK_FEATURE_QUIC) // Opaque handle to a live `punktfunk/1` connection (QUIC control plane + UDP data plane, all // pumped on internal threads). @@ -1788,6 +1865,14 @@ typedef struct { + + + + + + + + // The multipliers a picker offers. `1.0` (Native) is the default; the rest are the round stops // users reason about. Shared so every client's list stays identical. #define PRESETS { 0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0, } From 76832a5b86c23707efb3974932ac9c8c2163ff10 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 08:20:12 +0200 Subject: [PATCH 09/53] fix(client/apple): two DualSenses stop fighting over one device, and a failed stop stops lying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five faults in the Apple client's feedback path. With two DualSenses attached, each pad's renderer opened "the first connected DualSense" — taken from an unordered Set, so the choice could differ between two calls in one process. Both renderers could land on the same device, one pad's rumble coming out of the other while their per-instance write dedupes fought over it, or they could split by luck. Each renderer now asks for the device its own controller is, correlating GameController's stable ordering with IOKit's location ids; the selection rule is a pure function so it can be tested without an IOHIDDevice, which cannot be constructed. Without a preference the lowest location id wins — still arbitrary, but stable, which Set.first was not. A failed HID write was logged and swallowed, so a write that never reached the device still counted as a successful render. That matters most for a stop, which has nothing behind it: the renderer stamped its write clock even on failure, the keepalive only re-writes non-zero levels, the ticker is cancelled once the target is zero, and on USB there is no firmware timeout. A swallowed stop therefore left the motors running with nothing scheduled to try again. The write result now reaches the caller, which drops the handle and falls back to CoreHaptics rather than claiming success. A half-failed split-handle setup reported HEALTHY. Only the all-nil case counted as failure, so one surviving handle passed silently while rendering something wrong in a direction that depended on which handle died: lose the right one and render falls to the combined branch, playing max(low, high) on the LEFT handle; lose the left and the split branch discards the heavy motor outright. A half-open split now tears the survivor down and takes the combined path, which at least renders both motors somewhere. Session end never put the lightbar out. This class is what turned it on, and every DS write is valid-flag-selective, so a game's last colour stayed lit in firmware after the stream ended — a DS4 was cleared incidentally because its player indicator IS the lightbar, a DualSense was not. And the renderer's stop() ran on the main actor. It is a queue.sync whose body is a per-motor CHHapticEngine.stop() — an XPC round trip the renderer's own notes record as able to hang — plus a blocking HID write to a device that has just departed, and it queues behind any in-flight setup(). It runs on every unplug and every pin change, and the main thread drives the presenter's CADisplayLink, so it hitched the picture mid-stream. It is detached now; the renderer is already off routing by then, so nothing observes it. Verified: swift build clean, 188 tests pass (185 before), and the three new device-selection tests fail if the deterministic fallback is reverted. Note for anyone rebuilding here: the checked-in xcframework was stale (it predates punktfunk_connection_report_phase) and build-xcframework.sh still dies on this Mac at its macOS-floor guard. A macos-arm64 slice assembled by hand from `cargo build --target aarch64-apple-darwin` is enough to typecheck. From the 2026-08-03 force-feedback sweep (B14, B15, B18, B19, B20). --- .../PunktfunkKit/Gamepad/DualSenseHID.swift | 86 +++++++++++++++++-- .../Gamepad/GamepadFeedback.swift | 16 +++- .../PunktfunkKit/Gamepad/RumbleRenderer.swift | 50 ++++++++++- .../PunktfunkKitTests/DualSenseHIDTests.swift | 28 ++++++ 4 files changed, 168 insertions(+), 12 deletions(-) diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/DualSenseHID.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/DualSenseHID.swift index 7850c254..64b8adf0 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/DualSenseHID.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/DualSenseHID.swift @@ -21,8 +21,12 @@ import os private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad") -/// Opens the first connected Sony DualSense and forwards motor rumble to it over raw HID. -/// Single-pad model (we forward exactly one controller), so the first match is the right one. +/// Opens one connected Sony DualSense and forwards motor rumble to it over raw HID. +/// +/// A caller that owns a particular pad passes the location id it wants (see +/// `open(preferringLocationID:)`); the renderer takes that from the `GCController` it is bound to, +/// so with two DualSenses attached each renderer drives its own device. Without a preference the +/// lowest location id wins — an arbitrary but *stable* choice, where `Set.first` was neither. final class DualSenseHID { private let manager: IOHIDManager private var device: IOHIDDevice? @@ -43,9 +47,57 @@ final class DualSenseHID { deinit { close() } - /// Find and open the first connected DualSense. Returns false if none is present or it can't - /// be opened (caller then falls back to CoreHaptics). - func open() -> Bool { + /// The IOKit location id of the device this instance opened — the handle a caller correlates + /// with its `GCController`. `nil` until a successful `open`. + private(set) var locationID: UInt32? + + /// A device's location id, or `nil` if IOKit does not report one. + static func locationID(of dev: IOHIDDevice) -> UInt32? { + IOHIDDeviceGetProperty(dev, kIOHIDLocationIDKey as CFString) as? UInt32 + } + + /// Every connected DualSense/Edge, by location id — what a caller pairs against its controllers. + static func attachedLocationIDs() -> [UInt32] { + let mgr = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) + let matches = productIDs.map { pid in + [kIOHIDVendorIDKey: vendorSony, kIOHIDProductIDKey: pid] as CFDictionary + } + IOHIDManagerSetDeviceMatchingMultiple(mgr, matches as CFArray) + guard IOHIDManagerOpen(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) == kIOReturnSuccess else { + return [] + } + defer { IOHIDManagerClose(mgr, IOOptionBits(kIOHIDOptionsTypeNone)) } + let devices = IOHIDManagerCopyDevices(mgr) as? Set ?? [] + return devices.compactMap(locationID(of:)).sorted() + } + + /// Which attached device to drive, as an index into `ids` — the whole selection rule, pure so + /// it can be tested without an `IOHIDDevice` (which cannot be constructed). + /// + /// `IOHIDManagerCopyDevices` returns an unordered `Set`, so the previous `Set.first` was not + /// merely arbitrary — it can differ between two calls in one process. With two DualSenses that + /// made each renderer's pad→device binding a coin flip: both could land on the same device + /// (one pad's rumble coming out of the other, and the two per-instance write dedupes fighting + /// over it) or split by luck. An explicit location id makes the binding deterministic; the + /// lowest-id fallback at least makes it stable. `nil` ids sort last so a device IOKit cannot + /// place never displaces one it can. + static func preferredIndex(among ids: [UInt32?], preferring wanted: UInt32?) -> Int? { + if let wanted, let hit = ids.firstIndex(where: { $0 == wanted }) { return hit } + return ids.indices.min { (ids[$0] ?? .max) < (ids[$1] ?? .max) } + } + + /// Pick the device to drive from everything attached (see [`preferredIndex`]). + static func pick(_ devices: Set, preferring wanted: UInt32?) -> IOHIDDevice? { + let ordered = Array(devices) + guard let i = preferredIndex(among: ordered.map(locationID(of:)), preferring: wanted) else { + return nil + } + return ordered[i] + } + + /// Find and open a connected DualSense, preferring the one at `preferredLocationID`. Returns + /// false if none is present or it can't be opened (caller then falls back to CoreHaptics). + func open(preferringLocationID preferred: UInt32? = nil) -> Bool { let matches = Self.productIDs.map { pid in [kIOHIDVendorIDKey: Self.vendorSony, kIOHIDProductIDKey: pid] as CFDictionary } @@ -55,13 +107,21 @@ final class DualSenseHID { return false } guard let devices = IOHIDManagerCopyDevices(manager) as? Set, - let dev = devices.first + let dev = Self.pick(devices, preferring: preferred) else { log.info("rumble: no DualSense HID device found — falling back to CoreHaptics") IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone)) return false } device = dev + locationID = Self.locationID(of: dev) + if let preferred, locationID != preferred { + // Not fatal — one pad still gets rumble — but with two pads attached it means this + // renderer is driving the wrong one, and it is invisible without the log line. + log.error( + "rumble: wanted DualSense at location \(preferred, privacy: .public) but opened \(self.locationID.map(String.init) ?? "unknown", privacy: .public)" + ) + } let transport = IOHIDDeviceGetProperty(dev, kIOHIDTransportKey as CFString) as? String bluetooth = transport?.lowercased().contains("bluetooth") ?? false log.info("rumble: DualSense raw-HID rumble active (transport=\(self.transport, privacy: .public))") @@ -70,8 +130,16 @@ final class DualSenseHID { /// Drive the motors. `low` = left/heavy (low-frequency), `high` = right/light (high-frequency), /// each 0...255. (0, 0) stops. - func rumble(low: UInt8, high: UInt8) { - guard let dev = device else { return } + /// + /// Returns whether the write reached the device. The caller needs this: it used to be logged + /// and swallowed, so a failed write still counted as a successful render. That matters most + /// for a **stop**, which has nothing behind it — the renderer stamps its write clock even on + /// failure, the keepalive re-write only fires for non-zero levels, and the ticker is cancelled + /// once the target is `(0, 0)`. On USB there is no firmware timeout either, so a swallowed + /// stop left the motors running with nothing scheduled to try again. + @discardableResult + func rumble(low: UInt8, high: UInt8) -> Bool { + guard let dev = device else { return false } let report = bluetooth ? Self.bluetoothReport(low: low, high: high) : Self.usbReport(low: low, high: high) @@ -81,7 +149,9 @@ final class DualSenseHID { } if rc != kIOReturnSuccess { log.error("rumble: IOHIDDeviceSetReport failed (0x\(String(format: "%08x", rc), privacy: .public))") + return false } + return true } func close() { diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift index 9f32ceee..3988b5c2 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift @@ -117,7 +117,15 @@ public final class GamepadFeedback { reset(slot.controller) slots[pad] = nil let renderer = withRouting { rumbleByPad.removeValue(forKey: pad) } - renderer?.stop() + // OFF the main actor. `RumbleRenderer.stop()` is a `queue.sync`, and its body is a + // per-motor `CHHapticEngine.stop()` — an XPC round trip to gamecontrollerd, which the + // renderer's own notes record as able to hang — plus `DualSenseHID.close()`, whose + // blocking `IOHIDDeviceSetReport` goes to a device that has just departed. It also + // queues behind any in-flight `setup()`. This runs on every unplug and every pin + // change, and the main thread is what drives the presenter's CADisplayLink, so + // blocking here hitches the picture mid-stream. The renderer is already detached from + // routing above, so nothing observes it after this point. + if let renderer { Task.detached { renderer.stop() } } } for (pad, controller) in want { if let slot = slots[pad] { @@ -282,6 +290,12 @@ public final class GamepadFeedback { private func reset(_ controller: GCController?) { guard let c = controller else { return } c.playerIndex = .indexUnset + // Put the lightbar out too. This class is what turned it on (see the `Led` and + // `PlayerLeds` arms), and every DS write is valid-flag-selective, so a colour the game + // set stays lit in firmware after the stream ends — back at the launcher, or for a pad + // that merely left the forwarded set. A DS4 is cleared incidentally because its player + // indicator IS the lightbar; a DualSense is not. + c.light?.color = GCColor(red: 0, green: 0, blue: 0) if let ds = c.extendedGamepad as? GCDualSenseGamepad { ds.leftTrigger.setModeOff() ds.rightTrigger.setModeOff() diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/RumbleRenderer.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/RumbleRenderer.swift index 563a905f..dada912e 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/RumbleRenderer.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/RumbleRenderer.swift @@ -459,6 +459,18 @@ final class RumbleRenderer: @unchecked Sendable { if split { low = makeMotor(haptics, .leftHandle, sharpness: RumbleTuning.sharpnessLow) high = makeMotor(haptics, .rightHandle, sharpness: RumbleTuning.sharpnessHigh) + // HALF a split is worse than none, and it used to pass silently: only the all-nil case + // below counts as failure, so one surviving handle left `ok` true and `reportHealth(nil)` + // announced HEALTHY. What actually rendered was wrong in a direction that depends on + // which handle died — lose `high` and `render` falls to the combined branch (selected + // purely by `high != nil`), playing max(low, high) on the LEFT handle at the combined + // sharpness; lose `low` and the split branch's reconcile no-ops on the nil slot, so the + // heavy motor is discarded outright. Tear the survivor down and take the combined path, + // which at least renders both motors somewhere. + if low == nil || high == nil { + log.warning("rumble: only one split-handle engine came up — falling back to combined") + teardown() // disarms handlers, stops the survivor's players + engine, nils both + } } else { low = makeMotor(haptics, .default, sharpness: RumbleTuning.sharpnessCombined) } @@ -587,7 +599,9 @@ final class RumbleRenderer: @unchecked Sendable { #if os(macOS) guard let c, c.extendedGamepad is GCDualSenseGamepad else { return false } let hid = DualSenseHID() - guard hid.open() else { return false } + // Ask for the device this renderer's controller actually is, so two attached DualSenses + // do not both get driven through whichever one an unordered Set happened to yield first. + guard hid.open(preferringLocationID: Self.hidLocationID(for: c)) else { return false } dualSenseHID = hid return true #else @@ -595,6 +609,24 @@ final class RumbleRenderer: @unchecked Sendable { #endif } + #if os(macOS) + /// Correlate a `GCController` with an IOKit location id. + /// + /// GameController exposes no location id, so there is no direct mapping. What it does expose is + /// a stable per-controller ordering, and IOKit's location ids are stable per port: pairing the + /// two by rank makes each renderer pick a *distinct* device, which is the property that was + /// missing. With one pad attached this is the same device it always was. + static func hidLocationID(for c: GCController) -> UInt32? { + let ids = DualSenseHID.attachedLocationIDs() + guard ids.count > 1 else { return ids.first } + let peers = GCController.controllers().filter { $0.extendedGamepad is GCDualSenseGamepad } + guard let rank = peers.firstIndex(where: { $0 === c }), rank < ids.count else { + return ids.first + } + return ids[rank] + } + #endif + /// Write the target to the DualSense over HID if that's the active backend; false → not a /// HID pad, so the caller renders via CoreHaptics. Deduped on the pad's 0...255 resolution, /// with a periodic keepalive re-write while nonzero (the ticker calls back in here). @@ -605,8 +637,20 @@ final class RumbleRenderer: @unchecked Sendable { let keepalive = levels != (0, 0) && seconds(since: lastHidWrite.at) > RumbleTuning.hidKeepaliveSeconds if levels != lastHidWrite.levels || keepalive { - hid.rumble(low: levels.0, high: levels.1) - lastHidWrite = (levels, .now()) + if hid.rumble(low: levels.0, high: levels.1) { + lastHidWrite = (levels, .now()) + } else { + // The write did not reach the device. Do NOT stamp the clock — that would claim a + // render that never happened, and for a stop there is nothing behind it: the + // keepalive only re-writes non-zero levels and the ticker is cancelled once the + // target is (0, 0), so the motors would keep running with nothing scheduled. + // Drop the handle instead: the pad reverts to CoreHaptics, and a reconnect + // rebuilds it. Health is reported so the state is visible rather than silent. + log.error("rumble: HID write failed — dropping the handle, falling back") + closeHID() + reportHealth("Lost the direct connection to this DualSense; using the system path.") + return false + } } return true #else diff --git a/clients/apple/Tests/PunktfunkKitTests/DualSenseHIDTests.swift b/clients/apple/Tests/PunktfunkKitTests/DualSenseHIDTests.swift index 1ea280f2..cc0a0c55 100644 --- a/clients/apple/Tests/PunktfunkKitTests/DualSenseHIDTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/DualSenseHIDTests.swift @@ -43,5 +43,33 @@ final class DualSenseHIDTests: XCTestCase { let crc = DualSenseHID.crc32(seed: UInt8(ascii: "1"), Array("23456789".utf8)) XCTAssertEqual(crc, 0xCBF4_3926) } + + // MARK: - Device selection (B14) + + /// With two DualSenses attached, each renderer must drive its OWN device. The old code took + /// `Set.first` from an unordered set, so the pad→device binding was a coin flip that could + /// point both renderers at the same pad. + func testPreferredIndexHonoursAnExplicitLocation() { + let ids: [UInt32?] = [0x1D18_0000, 0x1420_0000, 0x1411_0000] + XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: 0x1420_0000), 1) + XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: 0x1D18_0000), 0) + } + + /// No preference (or one the pad no longer has): fall back to the LOWEST id — arbitrary, but + /// stable across calls, which `Set.first` was not. + func testPreferredIndexFallsBackToTheLowestIdDeterministically() { + let ids: [UInt32?] = [0x1D18_0000, 0x1420_0000, 0x1411_0000] + XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: nil), 2) + // A wanted id that is gone (pad unplugged between enumeration and open) must not fail the + // open — it degrades to the same stable fallback. + XCTAssertEqual(DualSenseHID.preferredIndex(among: ids, preferring: 0xDEAD_BEEF), 2) + } + + /// A device IOKit reports no location for must never displace one it can place. + func testPreferredIndexSortsUnplaceableDevicesLast() { + XCTAssertEqual(DualSenseHID.preferredIndex(among: [nil, 0x1420_0000], preferring: nil), 1) + XCTAssertEqual(DualSenseHID.preferredIndex(among: [nil, nil], preferring: nil), 0) + XCTAssertNil(DualSenseHID.preferredIndex(among: [], preferring: nil)) + } } #endif From 3055e29ebb3dd25d0c60c5c06980a54574387f2a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 09:02:12 +0200 Subject: [PATCH 10/53] feat(host/audio): make audio observable, fix the endpoint choice, raise the encode quality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phases 0-3 of design/audio-quality-and-latency.md, host side. **WP2.1 — the 2026-08-03 root cause.** The client-only loopback preference took Steam's Streaming *Microphone* render endpoint over real hardware unconditionally, because it is silent on the host. But that endpoint exists to carry remote VOICE, and nothing checked whether it could carry music: on the reporter's box it won all 31 loopback opens across 25 sessions while a clean AMD HD Audio endpoint sat idle, and the whole desktop mix went through it before reaching Opus. A silent sink now has to EARN its preference — if its mix format narrows the mix it drops below real hardware. It is still taken when nothing better exists (narrow audio beats no audio), but flagged so the capture side says why. `plan_with_formats` takes a probe rather than reading WASAPI, so all 26 wiring-plan tests still run on every platform. An unknown format counts as fine, which is asserted: `unknown_formats_reproduce_the_formatless_plan` proves a probe failure can never make the plan worse than it was before formats existed. **WP0.1 — log the endpoint's ACTUAL mix format.** Everything the old log printed ("48 kHz f32 channels=2") was our REQUEST; with `autoconvert` WASAPI converts silently from whatever the endpoint really runs. That is why a 3,600-line log filed over an audio-quality complaint contained nothing that could diagnose it. **WP0.2 — count what we drop.** The capture->encode handoff was a silent lossy `try_send`: a stalled encode thread lost chunks, the encoder concatenated across the hole, and nothing recorded it — a click plus a permanent shift of everything after. Now counted and warned, alongside per-window peak/RMS/delivered% so a quiet host, a broken endpoint and a stream we are damaging ourselves stop looking identical. **WP2.4 — stop the default-device tug-of-war.** In Assert mode the capture is bound to the planned endpoint EXPLICITLY, so a hijacked default changes only where apps render — the old full reopen tore the capture down for nothing. The field log shows the cost: something re-set the default every ~4 s and each round was a teardown, a wiring pass with IPolicyConfig writes, and an audible dropout — seven in sixteen seconds, one ending in a 2 s error backoff. Now: put the default back, keep the stream, and after four rounds in twenty seconds concede for a minute and say so once. **WP1.1/1.2 — encode quality.** Constrained VBR (the hard-CBR comment justifies itself with GameStream's audio FEC, which this plane does not have) and `AudioTier::High` by default: stereo 128 -> 256 kbps, ~1 % of a 20 Mbps session. GameStream's encoder is deliberately untouched — its FEC really does need fixed-size packets. **WP3.1 — redundant `0xD2` plane**, sent when the client asked for it. **WP2.2 — `audio.output_mode`** as a first-class setting (`client_only` / `host_and_client` / `follow_default`), superseding the two undocumented env vars, which stay honoured. The enum lives in pf-host-config, which is deliberately dependency-free, so the tier table stays in core where the codec knowledge is. `capture_policy.rs` is split out for the same reason `wiring_plan.rs` is: both encode field behaviour, so their tests must run on Linux CI, not only on a Windows box. That split immediately earned itself — `capture_stats_separate_silence_from_signal` caught RMS being divided by the FRAME count while summed over interleaved SAMPLES, which inflated it by sqrt(channels) and made a sine report an RMS equal to its own peak. WP4.5 (open the loopback at the minimum device period) is deliberately NOT done: in shared mode `IAudioClient::Initialize` cannot change the engine period at all, so it would be a no-op at best and a new failure path at worst. Recorded in the code. WP2.3 (force the parked endpoint's volume) is deferred — `wasapi` keeps IMMDevice private, so it needs new raw COM on a path this tree cannot compile, let alone test; its diagnostic half ships as the RMS line above. Verified: punktfunk-host + pf-host-config clippy --all-targets -D warnings and the audio test suite under Linux/docker (gate proven non-vacuous with a planted type error); 26 wiring-plan tests standalone; fmt. The Windows-only halves of wasapi_cap.rs and audio_control.rs are NOT compile-verified anywhere yet. Co-Authored-By: Claude Opus 5 (1M context) --- crates/pf-host-config/src/lib.rs | 143 +++++++++ crates/punktfunk-core/src/audio.rs | 8 +- crates/punktfunk-host/src/audio.rs | 5 + .../src/audio/capture_policy.rs | 260 +++++++++++++++++ .../src/audio/windows/audio_control.rs | 115 +++++++- .../src/audio/windows/wasapi_cap.rs | 125 +++++++- .../punktfunk-host/src/audio/wiring_plan.rs | 274 +++++++++++++++++- crates/punktfunk-host/src/native.rs | 5 +- crates/punktfunk-host/src/native/audio.rs | 97 +++++-- crates/punktfunk-host/src/native/handshake.rs | 28 ++ 10 files changed, 1010 insertions(+), 50 deletions(-) create mode 100644 crates/punktfunk-host/src/audio/capture_policy.rs diff --git a/crates/pf-host-config/src/lib.rs b/crates/pf-host-config/src/lib.rs index 4595b726..a74ff9e3 100644 --- a/crates/pf-host-config/src/lib.rs +++ b/crates/pf-host-config/src/lib.rs @@ -57,6 +57,82 @@ pub fn env_on(name: &str) -> Option { }) } +/// Where desktop audio should be audible — which decides the render endpoint the loopback captures. +/// +/// Supersedes the two env-only knobs that used to encode this (`PUNKTFUNK_HOST_AUDIO`, +/// `PUNKTFUNK_KEEP_DEFAULT`), which stay honoured as back-compat spellings so nobody's `host.env` +/// breaks. Named modes exist because "which endpoint do we capture" is a routing decision an +/// operator has to be able to make deliberately — the 2026-08-03 field report is what happens when +/// the only way to express it is an undocumented environment variable. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AudioOutputMode { + /// Default. Prefer a render endpoint that is silent on the host, so streamed audio does not + /// also play out of the host's speakers. Since 2026-08 a silent sink has to be able to carry + /// the mix without narrowing it — otherwise real hardware wins anyway. + #[default] + ClientOnly, + /// Prefer real hardware: audio plays on the host as well as the client. The old + /// `PUNKTFUNK_HOST_AUDIO=1`. + HostAndClient, + /// Touch nothing — capture whatever the operator's own default playback device is, and never + /// write the default-device policy. The old `PUNKTFUNK_KEEP_DEFAULT=1`. + FollowDefault, +} + +impl AudioOutputMode { + /// `PUNKTFUNK_AUDIO_OUTPUT_MODE` wins; otherwise fall back to the legacy flags, `follow_default` + /// first (it is the more restrictive promise — "do not touch my devices" must not be overridden + /// by a stale `PUNKTFUNK_HOST_AUDIO` in the same `host.env`). + fn from_env() -> AudioOutputMode { + if let Ok(raw) = std::env::var("PUNKTFUNK_AUDIO_OUTPUT_MODE") { + if !raw.trim().is_empty() { + if let Some(m) = AudioOutputMode::parse(&raw) { + return m; + } + // Never silently fall through to a different routing than the operator asked for. + eprintln!( + "punktfunk: PUNKTFUNK_AUDIO_OUTPUT_MODE={raw:?} is not one of \ + client_only/host_and_client/follow_default — using client_only" + ); + } + } + if std::env::var_os("PUNKTFUNK_KEEP_DEFAULT").is_some() { + return AudioOutputMode::FollowDefault; + } + if std::env::var_os("PUNKTFUNK_HOST_AUDIO").is_some() { + return AudioOutputMode::HostAndClient; + } + AudioOutputMode::ClientOnly + } + + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().replace('-', "_").as_str() { + "client_only" | "client" => Some(AudioOutputMode::ClientOnly), + "host_and_client" | "both" | "host" => Some(AudioOutputMode::HostAndClient), + "follow_default" | "follow" => Some(AudioOutputMode::FollowDefault), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + AudioOutputMode::ClientOnly => "client_only", + AudioOutputMode::HostAndClient => "host_and_client", + AudioOutputMode::FollowDefault => "follow_default", + } + } + + /// The loopback plan should prefer real hardware over a silent sink. + pub fn prefers_host_hardware(self) -> bool { + matches!(self, AudioOutputMode::HostAndClient) + } + + /// Leave the operator's default playback/recording devices completely alone. + pub fn keeps_default(self) -> bool { + matches!(self, AudioOutputMode::FollowDefault) + } +} + /// Resolved host configuration. Holds the genuinely-constant operator/dispatch knobs (see module docs for /// what is deliberately excluded). Fields read on only one platform are kept alive cross-platform by the /// derived `Debug` impl, so the parser can stay a single platform-neutral function. @@ -99,6 +175,24 @@ pub struct HostConfig { /// e.g. webOS TVs, whose GCM decrypt caps at ~100 Mbps); everyone else stays AES-128-GCM. /// `PUNKTFUNK_CHACHA20=0`/`false`/`off`/`no` disables. pub chacha20: bool, + /// `PUNKTFUNK_AUDIO_OUTPUT_MODE` — where desktop audio should be audible, and therefore which + /// render endpoint the loopback captures (`client_only` / `host_and_client` / `follow_default`). + /// + /// A first-class setting because the 2026-08-03 field report needed one: the default + /// client-only routing sent that box's whole desktop mix through Steam's voice-carrier virtual + /// endpoint for 25 sessions, and the only way to change it was an undocumented environment + /// variable. See [`AudioOutputMode`]. + pub audio_output_mode: AudioOutputMode, + /// `PUNKTFUNK_AUDIO_QUALITY` — desktop-audio encode tier (`low` / `standard` / `high`; default + /// `high`). Kept as the raw string here because the tier table lives in `punktfunk-core`, and + /// this crate is deliberately dependency-free (see the crate doc). The audio thread resolves it + /// via `punktfunk_core::audio::AudioTier::parse` and warns on an unknown spelling rather than + /// silently downgrading someone's audio. + pub audio_quality: Option, + /// `PUNKTFUNK_AUDIO_REDUNDANCY` — force the redundant `0xD2` audio plane on or off. `None` + /// (the default) = automatic: sent only to a client that asked for it, and only while the link + /// is actually losing packets. + pub audio_redundancy: Option, /// `PUNKTFUNK_PERF` — per-stage timing instrumentation. pub perf: bool, /// `PUNKTFUNK_VIDEO_SOURCE` — GameStream video source select. `virtual` (the default — a @@ -246,6 +340,9 @@ impl HostConfig { // Default ON, explicit-off grammar (the client's VIDEO_CAP_CHACHA20 bit is the real // per-session switch; see the field doc). chacha20: env_on("PUNKTFUNK_CHACHA20").unwrap_or(true), + audio_output_mode: AudioOutputMode::from_env(), + audio_quality: val("PUNKTFUNK_AUDIO_QUALITY").map(|s| s.trim().to_lowercase()), + audio_redundancy: env_on("PUNKTFUNK_AUDIO_REDUNDANCY"), perf: flag("PUNKTFUNK_PERF"), // Default ON while the interval-stutter field program runs (see the field doc). stall_probes: env_on("PUNKTFUNK_STALL_PROBES").unwrap_or(true), @@ -348,4 +445,50 @@ mod tests { // An invalid rate stays invalid rather than being laundered into a real one. assert_eq!(c.game_fps(0), 0); } + + #[test] + fn audio_output_mode_parses_its_spellings() { + for (s, want) in [ + ("client_only", AudioOutputMode::ClientOnly), + ("client-only", AudioOutputMode::ClientOnly), + (" CLIENT ", AudioOutputMode::ClientOnly), + ("host_and_client", AudioOutputMode::HostAndClient), + ("both", AudioOutputMode::HostAndClient), + ("follow_default", AudioOutputMode::FollowDefault), + ("follow", AudioOutputMode::FollowDefault), + ] { + assert_eq!(AudioOutputMode::parse(s), Some(want), "{s:?}"); + } + // Unknown spellings are rejected so the caller can say so, not silently re-routed. + for s in ["", "silent", "off", "true"] { + assert_eq!(AudioOutputMode::parse(s), None, "{s:?}"); + } + // Round-trip through the canonical spelling. + for m in [ + AudioOutputMode::ClientOnly, + AudioOutputMode::HostAndClient, + AudioOutputMode::FollowDefault, + ] { + assert_eq!(AudioOutputMode::parse(m.as_str()), Some(m)); + } + } + + /// The two predicates are what the wiring plan and the capture loop actually branch on, and + /// they must stay mutually exclusive: "prefer host hardware" and "touch nothing" are different + /// promises, and conflating them would either silence the host or stomp the operator's devices. + #[test] + fn audio_output_mode_predicates_are_disjoint() { + assert_eq!(AudioOutputMode::default(), AudioOutputMode::ClientOnly); + for m in [ + AudioOutputMode::ClientOnly, + AudioOutputMode::HostAndClient, + AudioOutputMode::FollowDefault, + ] { + assert!(!(m.prefers_host_hardware() && m.keeps_default()), "{m:?}"); + } + assert!(AudioOutputMode::HostAndClient.prefers_host_hardware()); + assert!(AudioOutputMode::FollowDefault.keeps_default()); + assert!(!AudioOutputMode::ClientOnly.prefers_host_hardware()); + assert!(!AudioOutputMode::ClientOnly.keeps_default()); + } } diff --git a/crates/punktfunk-core/src/audio.rs b/crates/punktfunk-core/src/audio.rs index 6975b9de..a006fc51 100644 --- a/crates/punktfunk-core/src/audio.rs +++ b/crates/punktfunk-core/src/audio.rs @@ -57,8 +57,12 @@ pub struct OpusLayout { pub coupled: u8, /// libopus multistream channel mapping — identity `[0, 1, …, channels-1]`. pub mapping: &'static [u8], - /// Target Opus bitrate in bits/sec (hard CBR; constant packet size, which GameStream's - /// audio FEC relies on). + /// Target Opus bitrate in bits/sec at [`AudioTier::Standard`] — see + /// [`OpusLayout::bitrate_for`], which is what callers should use. These are the historical + /// values, kept exactly so `Standard` reproduces the pre-tier wire byte-for-byte. + /// + /// The GameStream plane encodes hard-CBR from these (its audio FEC needs a constant packet + /// size); the native plane uses constrained VBR, where that constraint does not apply. pub bitrate: i32, } diff --git a/crates/punktfunk-host/src/audio.rs b/crates/punktfunk-host/src/audio.rs index 0df8b571..b4026038 100644 --- a/crates/punktfunk-host/src/audio.rs +++ b/crates/punktfunk-host/src/audio.rs @@ -192,6 +192,11 @@ mod wasapi_mic; #[cfg_attr(not(target_os = "windows"), allow(dead_code))] #[path = "audio/wiring_plan.rs"] pub(crate) mod wiring_plan; +// Pure capture-loop policy, split out for the same reason `wiring_plan` is: it encodes field +// behaviour, so its tests must run on every platform's CI, not only Windows. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +#[path = "audio/capture_policy.rs"] +pub(crate) mod capture_policy; mod mic_jitter; mod mic_pump; diff --git a/crates/punktfunk-host/src/audio/capture_policy.rs b/crates/punktfunk-host/src/audio/capture_policy.rs new file mode 100644 index 00000000..872f6f83 --- /dev/null +++ b/crates/punktfunk-host/src/audio/capture_policy.rs @@ -0,0 +1,260 @@ +//! Desktop-audio capture POLICY — the parts of [`wasapi_cap`](super::wasapi_cap) that are pure +//! decisions rather than WASAPI plumbing, split out for the same reason +//! [`wiring_plan`](super::wiring_plan) is: so they compile and their unit tests RUN on every +//! platform. Both of these encode field-report behaviour, and regressing either must fail CI on +//! Linux too, not only on a Windows box. +//! +//! * [`FightDamper`] — how hard to fight another program for the default playback device. +//! * [`CaptureStats`] — the audio plane's vitals, so a log can tell a quiet host from a broken +//! endpoint from one we are damaging ourselves. + +use std::time::{Duration, Instant}; + +/// Default-playback re-assertions inside [`FIGHT_WINDOW`] before we stop fighting. +pub(crate) const FIGHT_LIMIT: u32 = 4; +pub(crate) const FIGHT_WINDOW: Duration = Duration::from_secs(20); +/// How long to leave the default alone once another program has proven it will take it back. +pub(crate) const FIGHT_BACKOFF: Duration = Duration::from_secs(60); + +/// Damping for the default-playback tug-of-war (WP2.4). +/// +/// The 2026-08-03 field log recorded seven full re-assert cycles in sixteen seconds — something on +/// that box re-set the default playback to CABLE Input every ~4 s and we snapped it back every +/// time, each round a capture teardown plus a wiring pass with `IPolicyConfig` writes. Winning that +/// argument is not possible and every round was an audible dropout, so: re-assert a few times +/// (transient churn does settle), then concede for a minute and say so once. +/// +/// Time is passed IN rather than read here, which keeps the policy pure and testable. +pub(crate) struct FightDamper { + /// Re-assertions in the current window, and when the window opened. + count: u32, + window_started: Instant, + /// Set while we are deliberately not fighting. + paused_until: Option, + /// One warning per fight burst, and one per concession. + warned_fighting: bool, + warned_giving_up: bool, + now: Instant, +} + +impl FightDamper { + pub(crate) fn new(now: Instant) -> FightDamper { + FightDamper { + count: 0, + window_started: now, + paused_until: None, + warned_fighting: false, + warned_giving_up: false, + now, + } + } + + /// A dud default-device change was observed at `now`. + pub(crate) fn observed_at(&mut self, now: Instant) { + self.now = now; + if now.duration_since(self.window_started) >= FIGHT_WINDOW { + self.window_started = now; + self.count = 0; + self.warned_fighting = false; + } + if self.paused_until.is_some_and(|t| now >= t) { + self.paused_until = None; + self.warned_giving_up = false; + self.count = 0; + self.window_started = now; + } + } + + /// Should we put the default back? False while paused, or once this window's budget is spent. + pub(crate) fn should_reassert(&mut self) -> bool { + if self.paused_until.is_some() { + return false; + } + if self.count >= FIGHT_LIMIT { + self.paused_until = Some(self.now + FIGHT_BACKOFF); + return false; + } + self.count += 1; + true + } + + /// Warn on the FIRST re-assert of a burst only (the rest are noise). + pub(crate) fn warn_now(&mut self) -> bool { + !std::mem::replace(&mut self.warned_fighting, true) + } + + /// Warn once when we concede. + pub(crate) fn warn_giving_up(&mut self) -> bool { + self.paused_until.is_some() && !std::mem::replace(&mut self.warned_giving_up, true) + } + + /// Currently conceding (test/diagnostic accessor). + pub(crate) fn is_paused(&self) -> bool { + self.paused_until.is_some() + } +} + +/// How often the capture loop reports its vitals (WP0.2). +pub(crate) const STATS_EVERY: Duration = Duration::from_secs(30); + +/// One reporting window's worth of capture vitals. +/// +/// The point is to make three states that used to look identical in a log tell themselves apart: a +/// genuinely quiet host (`peak` ~0, no drops), a working stream (`peak` > 0), and a stream we are +/// damaging ourselves (`dropped_chunks` > 0). The 2026-08-03 field log — 3,600 lines, filed over an +/// audio-quality complaint — could distinguish none of them, because the audio plane logged nothing +/// at all between "capturing" and the session ending. +#[derive(Default)] +pub(crate) struct CaptureStats { + pub(crate) frames: u64, + /// Interleaved SAMPLES seen — the RMS denominator. Deliberately separate from `frames`: + /// dividing the sum of squares by the frame count instead inflates RMS by sqrt(channels), + /// which made a sine report an RMS equal to its own peak. + pub(crate) samples: u64, + /// Loudest |sample| in the window — tells a silent endpoint from a working one. + pub(crate) peak: f32, + /// Sum of squares, for the window's RMS: a level far below peak means a badly attenuated + /// endpoint (a parked device sitting at 20 % volume costs ~14 dB before Opus ever sees it). + pub(crate) sumsq: f64, + /// Chunks the encode thread was too slow to take. Silent data loss, previously uncounted: + /// the encoder simply concatenates across the hole, so it is a click AND a permanent shift of + /// everything after it. + pub(crate) dropped_chunks: u64, +} + +impl CaptureStats { + pub(crate) fn observe(&mut self, samples: &[f32], channels: u32) { + self.frames += (samples.len() / channels.max(1) as usize) as u64; + self.samples += samples.len() as u64; + for &s in samples { + let a = s.abs(); + if a > self.peak { + self.peak = a; + } + self.sumsq += (s as f64) * (s as f64); + } + } + + /// `(peak dBFS, rms dBFS, delivered %)` for this window. Silence reports -120 dB rather than + /// -inf so the log line stays parseable. + pub(crate) fn summary(&self, elapsed: Duration, sample_rate: u32) -> (f64, f64, f64) { + let rms = (self.sumsq / (self.samples as f64).max(1.0)).sqrt(); + let db = |v: f64| if v > 0.0 { 20.0 * v.log10() } else { -120.0 }; + // Expected frames for the window — a shortfall means the endpoint is not delivering at + // real time (a stalling virtual device), which a peak/RMS alone cannot show. + let expected = elapsed.as_secs_f64() * sample_rate as f64; + ( + db(self.peak as f64), + db(rms), + (self.frames as f64 / expected.max(1.0)) * 100.0, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Replays the 2026-08-03 field shape: a dud default change every ~2 s, forever. We must put + /// the default back a few times, then concede — and warn exactly once for each. + #[test] + fn fight_damper_concedes_instead_of_looping_forever() { + let t0 = Instant::now(); + let mut d = FightDamper::new(t0); + let mut reasserts = 0; + let (mut warns_fighting, mut warns_giving_up) = (0, 0); + for i in 0..8 { + d.observed_at(t0 + Duration::from_millis(i * 2_000)); + if d.should_reassert() { + reasserts += 1; + if d.warn_now() { + warns_fighting += 1; + } + } else if d.warn_giving_up() { + warns_giving_up += 1; + } + } + assert_eq!( + reasserts, FIGHT_LIMIT, + "must stop after the window's budget" + ); + assert_eq!(warns_fighting, 1, "one warning per burst, not one per flip"); + assert_eq!(warns_giving_up, 1, "concede exactly once"); + } + + /// Occasional, genuinely transient churn must ALWAYS be corrected — the damper must not + /// accumulate across widely-spaced events and quietly stop doing its job. + #[test] + fn fight_damper_always_fixes_isolated_changes() { + let t0 = Instant::now(); + let mut d = FightDamper::new(t0); + let mut reasserts = 0; + for i in 1..=10 { + d.observed_at(t0 + FIGHT_WINDOW * i); + if d.should_reassert() { + reasserts += 1; + } + } + assert_eq!(reasserts, 10, "isolated changes must always be corrected"); + } + + /// After the backoff expires the damper re-arms, so a program that goes quiet and comes back + /// later is fought again rather than being conceded to for the rest of the session. + #[test] + fn fight_damper_rearms_after_the_backoff() { + let t0 = Instant::now(); + let mut d = FightDamper::new(t0); + for i in 0..FIGHT_LIMIT + 2 { + d.observed_at(t0 + Duration::from_millis(i as u64 * 500)); + d.should_reassert(); + } + assert!(d.is_paused(), "should have conceded"); + d.observed_at(t0 + FIGHT_BACKOFF + FIGHT_WINDOW * 2); + assert!(d.should_reassert(), "must re-arm once the backoff expires"); + } + + /// Peak/RMS must separate the states a log could not previously tell apart. + #[test] + fn capture_stats_separate_silence_from_signal() { + let mut quiet = CaptureStats::default(); + quiet.observe(&[0.0; 480], 2); + let (peak, rms, _) = quiet.summary(Duration::from_secs(1), 48_000); + assert_eq!(peak, -120.0, "digital silence reports the floor, not -inf"); + assert_eq!(rms, -120.0); + + let mut loud = CaptureStats::default(); + let tone: Vec = (0..480).map(|i| (i as f32 * 0.13).sin() * 0.5).collect(); + loud.observe(&tone, 2); + assert_eq!( + loud.frames, 240, + "480 interleaved stereo samples = 240 frames" + ); + let (peak, rms, _) = loud.summary(Duration::from_secs(1), 48_000); + assert!( + peak > -8.0 && peak <= 0.0, + "peak {peak} dBFS should track a 0.5 tone" + ); + // A sine's RMS is its amplitude / sqrt(2) — about 3 dB below peak. Getting this equal to + // peak is exactly what a frames-vs-samples mix-up in the denominator looks like, so the + // margin is asserted rather than just the ordering. + assert!( + rms < peak - 2.0, + "RMS {rms} vs peak {peak}: a sine must sit ~3 dB below its peak" + ); + } + + /// The delivered-percentage is what shows an endpoint that has stopped feeding us in real + /// time — invisible in peak/RMS, and the shape a stalling virtual device makes. + #[test] + fn capture_stats_report_a_delivery_shortfall() { + let mut full = CaptureStats::default(); + full.observe(&vec![0.1f32; 48_000 * 2], 2); // exactly 1 s of stereo + let (_, _, pct) = full.summary(Duration::from_secs(1), 48_000); + assert!((pct - 100.0).abs() < 1.0, "expected ~100 %, got {pct}"); + + let mut half = CaptureStats::default(); + half.observe(&vec![0.1f32; 48_000], 2); // 0.5 s of stereo in a 1 s window + let (_, _, pct) = half.summary(Duration::from_secs(1), 48_000); + assert!((pct - 50.0).abs() < 1.0, "expected ~50 %, got {pct}"); + } +} diff --git a/crates/punktfunk-host/src/audio/windows/audio_control.rs b/crates/punktfunk-host/src/audio/windows/audio_control.rs index 2760a8f1..e74341ec 100644 --- a/crates/punktfunk-host/src/audio/windows/audio_control.rs +++ b/crates/punktfunk-host/src/audio/windows/audio_control.rs @@ -14,8 +14,11 @@ //! * default **PLAYBACK** → the plan's loopback endpoint, applied ONLY while a desktop-audio capture //! is open (`set_playback` — the mic pump must never park the playback default while the host is //! idle). By default that endpoint is the SILENT sink (Steam Streaming Microphone render side) so -//! audio plays on the client only; `PUNKTFUNK_HOST_AUDIO` prefers real hardware instead (audible on -//! both ends). **Never** the Steam Streaming Speakers, whose loopback is silent — validated live; +//! audio plays on the client only; `audio.output_mode = host_and_client` (formerly +//! `PUNKTFUNK_HOST_AUDIO`) prefers real hardware instead (audible on both ends). Since 2026-08 a +//! silent sink must also be able to CARRY the mix — one that narrows it (a voice-carrier endpoint +//! mixing mono or at 24 kHz) loses to real hardware; see [`super::wiring_plan`]. **Never** the +//! Steam Streaming Speakers, whose loopback is silent — validated live; //! * default **RECORDING** → the mic target's capture endpoint (VB-Cable "CABLE Output") so host apps //! record the client's mic by default. //! @@ -33,18 +36,44 @@ //! //! Setting a default endpoint uses the undocumented `IPolicyConfig` COM interface (the only way to set //! a default device programmatically — neither the `windows` nor `wasapi` crate exposes it; it is the -//! same call `mmsys.cpl` makes). Opt out with `PUNKTFUNK_KEEP_DEFAULT` to leave the user's chosen -//! defaults untouched (the plan is still computed — the mic must still pick a target). +//! same call `mmsys.cpl` makes). The `audio.output_mode = follow_default` setting (formerly +//! `PUNKTFUNK_KEEP_DEFAULT`) leaves the user's chosen defaults untouched — the plan is still +//! computed, since the mic must still pick a target. // Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it. #![deny(clippy::undocumented_unsafe_blocks)] -use super::wiring_plan::{self, plan, Endpoint, Wiring}; +use super::wiring_plan::{self, plan, plan_with_formats, Endpoint, MixFormat, Wiring}; use anyhow::{anyhow, bail, Result}; use std::ffi::c_void; use std::sync::Mutex; use wasapi::Direction; +/// A render endpoint's engine mix format, or `None` if it cannot be asked right now. +/// +/// This is the number the 2026-08-03 field report needed and no log had: the capture side requests +/// 48 kHz f32 with `autoconvert`, so WASAPI converts silently from whatever the endpoint really +/// runs — and a voice-carrier endpoint (Steam's Streaming Microphone) narrowing the desktop mix to +/// mono or 24 kHz was invisible. Reading it costs one `IAudioClient` activation per endpoint, done +/// only during a wiring pass. +/// +/// Deliberately total: EVERY failure maps to `None` ("assume it is fine"), because the wiring plan +/// treats an unknown format as non-narrowing. A box where activation fails therefore plans exactly +/// as it did before formats existed, instead of mis-demoting a perfectly good endpoint. +fn mix_format_of(ep: &Endpoint) -> Option { + let fmt = open_endpoint(ep) + .ok()? + .get_iaudioclient() + .ok()? + .get_mixformat() + .ok()?; + Some(MixFormat { + rate_hz: fmt.get_samplespersec(), + channels: fmt.get_nchannels(), + bits: fmt.get_bitspersample(), + }) +} + /// `(friendly_name, endpoint_id)` for every ACTIVE endpoint in direction `dir`. fn list_endpoints(dir: Direction) -> Vec { let mut out = Vec::new(); @@ -69,10 +98,22 @@ fn list_endpoints(dir: Direction) -> Vec { out } -/// `PUNKTFUNK_HOST_AUDIO`: the operator wants the stream audible on the host too — the loopback -/// plan prefers real hardware over the silent sink (the pre-client-only-default behavior). +/// The operator wants the stream audible on the host too — the loopback plan prefers real +/// hardware over the silent sink (the pre-client-only-default behavior). +/// +/// Now driven by the first-class `audio.output_mode` setting +/// ([`AudioOutputMode`](pf_host_config::AudioOutputMode)), which still honours the older +/// `PUNKTFUNK_HOST_AUDIO` spelling. pub(crate) fn host_audio_requested() -> bool { - std::env::var_os("PUNKTFUNK_HOST_AUDIO").is_some() + pf_host_config::config() + .audio_output_mode + .prefers_host_hardware() +} + +/// The operator's default playback/recording devices must not be touched at all — the +/// `follow_default` mode, formerly `PUNKTFUNK_KEEP_DEFAULT`. +pub(crate) fn keep_default_devices() -> bool { + pf_host_config::config().audio_output_mode.keeps_default() } /// One wiring pass plus the inputs the desktop-audio capture loop's failure handling needs: @@ -118,7 +159,27 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan { let want = std::env::var("PUNKTFUNK_MIC_DEVICE") .ok() .map(|s| s.to_lowercase()); - let wiring = plan(&renders, &captures, want.as_deref(), host_audio_requested()); + // Mix formats are read only when we are actually going to park the playback default (i.e. a + // desktop-audio capture is opening). The mic pump wires on every open while the host is idle + // and does not care which loopback endpoint wins, so it must not pay an IAudioClient + // activation per render endpoint on every pass. + let probe: &dyn Fn(&Endpoint) -> Option = if set_playback { + &mix_format_of + } else { + &wiring_plan::no_formats + }; + let wiring = plan_with_formats( + &renders, + &captures, + want.as_deref(), + host_audio_requested(), + probe, + // The loopback is opened at the session's negotiated channel count, but the wiring pass + // runs before (and outside) any session. Stereo is the floor every session uses and the + // only count a *narrowing* verdict can be made against without guessing: an endpoint that + // cannot carry stereo cannot carry 5.1 either. + 2, + ); let done = |wiring: Wiring| WiredPlan { wiring, fingerprint, @@ -142,6 +203,18 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan { renders = ?renders.iter().map(|(n, _)| n.as_str()).collect::>(), "audio wiring plan" ); + // The quality warning the 2026-08-03 report had no way to produce. Says WHICH endpoint, + // WHY it is narrow, and the two things the operator can actually do about it. + if let (Some(why), Some((name, _))) = (&wiring.loopback_narrowing, &wiring.loopback_render) + { + tracing::warn!( + device = %name, + "the desktop-audio loopback endpoint {why} — streamed audio will sound worse \ + than it does on the host. Attach or select a 48 kHz stereo output device, or \ + set audio.output_mode = host_and_client (PUNKTFUNK_HOST_AUDIO=1) to prefer \ + real hardware" + ); + } if wiring.mic_render.is_some() && wiring.loopback_unsatisfiable() { // Inventory + per-endpoint reasons + ONLY the remedies not already taken — the old // static advice here suggested installing the Steam pair to a field box that had it @@ -153,10 +226,11 @@ pub(crate) fn wire_now_full(set_playback: bool) -> WiredPlan { } } - if std::env::var_os("PUNKTFUNK_KEEP_DEFAULT").is_some() { + if keep_default_devices() { if changed { tracing::info!( - "PUNKTFUNK_KEEP_DEFAULT set — leaving the audio default devices untouched" + mode = %pf_host_config::config().audio_output_mode.as_str(), + "audio output mode is follow_default — leaving the audio default devices untouched" ); } return done(wiring); @@ -317,6 +391,25 @@ fn park_default_playback(name: &str, id: &str, changed: bool, mic_id: Option<&st } } +/// Put the default playback device back on the endpoint we are already capturing, WITHOUT a +/// wiring pass (WP2.4). +/// +/// The capture loop uses this when something else takes the default mid-stream: in Assert mode the +/// capture is bound to the planned endpoint explicitly, so the only thing a hijacked default +/// changes is where *apps* render — one `IPolicyConfig` write fixes that, where the old path tore +/// the capture down and re-ran the whole wiring pass. Deliberately does not touch the [`PARKED`] +/// memo: the endpoint is the one we already parked, so the operator's original default is +/// unchanged and still owed back at stream end. +pub(crate) fn reassert_default_playback(id: &str) -> bool { + match set_default_endpoint(id) { + Ok(()) => true, + Err(e) => { + tracing::debug!(error = %format!("{e:#}"), "failed to re-assert the default playback device"); + false + } + } +} + /// Put the operator's default playback device back after streaming — the inverse of /// [`park_default_playback`]. No-op if we never parked it, and a default the operator changed /// themselves mid-stream is left alone (their choice wins). Must run on a COM-initialized thread diff --git a/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs b/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs index 0e985754..7b21c3bb 100644 --- a/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs +++ b/crates/punktfunk-host/src/audio/windows/wasapi_cap.rs @@ -27,6 +27,7 @@ //! succeed). On thread exit (capturer dropped at stream end) the parked default playback //! device is restored. +use super::capture_policy::{CaptureStats, FightDamper, FIGHT_BACKOFF, STATS_EVERY}; use super::{audio_control, wiring_plan, AudioCapturer, SAMPLE_RATE}; use anyhow::{anyhow, Context, Result}; use std::collections::VecDeque; @@ -359,7 +360,7 @@ fn capture_once( ) -> Result { // Interleaved f32: channels * 4 bytes per frame. let block_align = channels as usize * 4; - let keep_default = std::env::var_os("PUNKTFUNK_KEEP_DEFAULT").is_some(); + let keep_default = audio_control::keep_default_devices(); // Assert-mode without KEEP_DEFAULT is the only shape that parks the playback default. let assert_plan = mode == TargetMode::Assert && !keep_default; let mut plan = audio_control::wire_now_full(assert_plan); @@ -454,12 +455,25 @@ fn capture_once( channels as usize, Some(mask), ); - let (default_period, _min_period) = - audio_client.get_device_period().context("device period")?; + // WP0.1 — the endpoint's ACTUAL engine mix format, read BEFORE we initialize. Everything the + // old log printed ("48 kHz f32 channels=2") was our REQUEST; with `autoconvert` WASAPI + // silently converts from whatever the endpoint really runs, so a voice-carrier endpoint + // narrowing the desktop mix to mono or 24 kHz was invisible in a 3,600-line field log. This + // line is what makes an audio-quality report triageable without a round trip. + let engine = audio_client.get_mixformat().ok(); + // NB the plan's WP4.5 ("open the loopback at the MINIMUM device period, worth ~5–10 ms") is + // deliberately NOT done here, because its premise is wrong: in shared mode + // `IAudioClient::Initialize` cannot change the engine period at all — `hnsBufferDuration` sizes + // the buffer, and the callback still fires at the engine's fixed default period. Lowering it + // needs `IAudioClient3::InitializeSharedAudioStream`, which the `wasapi` crate does not wrap. + // Passing `min_period` here would therefore be a no-op at best and a new Initialize failure + // path at worst, on a device this tree cannot compile for, let alone test. Left as real work. + let (default_period, min_period) = audio_client.get_device_period().context("device period")?; let stream_mode = StreamMode::EventsShared { autoconvert: true, buffer_duration_hns: default_period, }; + let used_period = default_period; audio_client .initialize_client(&desired, &Direction::Capture, &stream_mode) .context("initialize loopback client")?; @@ -476,7 +490,17 @@ fn capture_once( tracing::info!(device = %dev_name, follow = matches!(mode, TargetMode::Follow) || keep_default, last_resort, + // The endpoint's own format — NOT the one we asked for. + engine_hz = engine.as_ref().map(|f| f.get_samplespersec()), + engine_ch = engine.as_ref().map(|f| f.get_nchannels()), + engine_bits = engine.as_ref().map(|f| f.get_bitspersample()), + buffer_ms = used_period as f32 / 10_000.0, + min_buffer_ms = min_period as f32 / 10_000.0, "audio loopback capturing"); + if let Some(why) = &wiring.loopback_narrowing { + tracing::warn!(device = %dev_name, + "capturing an endpoint that {why} — the stream cannot sound better than this source"); + } // Watchdog seed: the default as it stands right after our open. In Assert mode the plan just // parked the default on our endpoint — if it did NOT stick (IPolicyConfig denied) converge @@ -514,6 +538,15 @@ fn capture_once( let opened_at = Instant::now(); let mut saw_packets = false; let mut silence_noted = false; + // WP0.2 — the audio plane's own vitals, logged periodically. Before this, a host log said + // nothing whatsoever about audio between "capturing" and the session ending: no level, no + // cadence, and in particular no sign of the SILENT, uncounted drop below, where a stalled + // encode thread loses chunks and the encoder simply concatenates across the hole (a click, + // and a permanent A/V offset, with nothing in any log). + let mut stats = CaptureStats::default(); + let mut last_stats = Instant::now(); + // WP2.4 — damping for the default-playback tug-of-war. + let mut fight = FightDamper::new(Instant::now()); loop { if stop.load(Ordering::Relaxed) { audio_client.stop_stream().ok(); @@ -556,7 +589,34 @@ fn capture_once( for c in raw.chunks_exact(4) { samples.push(f32::from_le_bytes([c[0], c[1], c[2], c[3]])); } - let _ = tx.try_send(samples); // non-blocking, lossy — same discipline as PipeWire + stats.observe(&samples, channels); + // Non-blocking, lossy — same discipline as PipeWire. Now COUNTED: a full channel + // means the encode thread is not keeping up, and every dropped chunk is a click plus + // a permanent shift of everything after it. + if tx.try_send(samples).is_err() { + stats.dropped_chunks += 1; + } + } + if last_stats.elapsed() >= STATS_EVERY { + let (peak_db, rms_db, delivered_pct) = stats.summary(last_stats.elapsed(), SAMPLE_RATE); + if stats.dropped_chunks > 0 { + tracing::warn!( + device = %dev_name, + dropped_chunks = stats.dropped_chunks, + "the audio encode thread could not keep up — captured audio was DROPPED; the \ + stream will click and everything after it shifts" + ); + } + tracing::info!( + device = %dev_name, + peak_db = format!("{peak_db:.1}"), + rms_db = format!("{rms_db:.1}"), + delivered_pct = format!("{delivered_pct:.0}"), + dropped_chunks = stats.dropped_chunks, + "desktop audio capture" + ); + last_stats = Instant::now(); + stats = CaptureStats::default(); } // Watchdog: react when the default render device CHANGES from what we last observed — @@ -568,29 +628,68 @@ fn capture_once( if seen_default.as_deref() != Some(nid.as_str()) { seen_default = Some(nid.clone()); if nid != dev_id { - audio_client.stop_stream().ok(); + // NB the stream is stopped per-branch below, NOT here: the WP2.4 Dud + // path deliberately keeps capturing, and stopping first would have made + // the "no teardown" fix silently useless. if keep_default { + audio_client.stop_stream().ok(); tracing::info!( "default render device changed (PUNKTFUNK_KEEP_DEFAULT) — \ following it" ); return Ok(Next::Reopen(TargetMode::Follow)); } - return Ok(match judge_default(&en, wiring, &nid) { + match judge_default(&en, wiring, &nid) { DefaultKind::Capturable(name) => { + audio_client.stop_stream().ok(); tracing::info!(device = %name, "operator changed the output device mid-stream — following \ it (audio now also plays on the host)"); - Next::Reopen(TargetMode::Follow) + return Ok(Next::Reopen(TargetMode::Follow)); } + // WP2.4 — a DUD default does not affect what we are capturing: + // Assert mode binds the capture to the plan's endpoint EXPLICITLY, + // not to whatever the default happens to be. Only where *apps* + // render has moved. So put the default back and KEEP THE STREAM — + // the old full reopen tore the capture down for nothing, and the + // 2026-08-03 field log shows what that cost: something re-set the + // default to CABLE Input every ~4 s and each round trip was a + // teardown, a re-plan with IPolicyConfig writes, and an audible + // dropout — seven of them in sixteen seconds, one ending in a 2 s + // error backoff. DefaultKind::Dud(name) => { - tracing::warn!(device = %name, - "default playback moved to an endpoint whose loopback cannot \ - work — re-asserting the audio wiring plan"); - Next::Reopen(TargetMode::Assert) + if !assert_plan { + // Follow/KEEP_DEFAULT shapes still need the old behaviour: + // there the capture IS bound to the default. + audio_client.stop_stream().ok(); + return Ok(Next::Reopen(TargetMode::Assert)); + } + fight.observed_at(Instant::now()); + if fight.should_reassert() { + audio_control::reassert_default_playback(&dev_id); + // Believe our own write: the next watchdog tick sees the + // default back on our endpoint and stays quiet. + seen_default = Some(dev_id.clone()); + if fight.warn_now() { + tracing::warn!(device = %name, planned = %dev_name, + "something keeps moving the default playback to an \ + endpoint whose loopback cannot work — putting it \ + back (the capture is unaffected)"); + } + } else if fight.warn_giving_up() { + tracing::warn!(device = %name, planned = %dev_name, + backoff_s = FIGHT_BACKOFF.as_secs(), + "another program is repeatedly taking the default \ + playback device — backing off rather than fighting it. \ + Desktop audio keeps streaming from the planned endpoint, \ + but apps rendering to the other device will not be heard"); + } } - DefaultKind::Unknown => Next::Reopen(TargetMode::Assert), - }); + DefaultKind::Unknown => { + audio_client.stop_stream().ok(); + return Ok(Next::Reopen(TargetMode::Assert)); + } + } } } } diff --git a/crates/punktfunk-host/src/audio/wiring_plan.rs b/crates/punktfunk-host/src/audio/wiring_plan.rs index 15bdca75..bbecd1ab 100644 --- a/crates/punktfunk-host/src/audio/wiring_plan.rs +++ b/crates/punktfunk-host/src/audio/wiring_plan.rs @@ -43,6 +43,59 @@ /// A `(friendly_name, endpoint_id)` pair as enumerated from WASAPI. pub(crate) type Endpoint = (String, String); +/// A render endpoint's ENGINE MIX FORMAT, as `IAudioClient::GetMixFormat` reports it. +/// +/// This is the number the 2026-08-03 field report needed and the log did not have. The capture +/// side opens with `autoconvert: true` and asks for 48 kHz f32 in the wire layout, so WASAPI +/// silently converts whatever the endpoint really runs — and the "48 kHz f32 channels=2" we +/// logged was our REQUEST, not the source. An endpoint that mixes at 24 kHz mono therefore +/// produced a 48 kHz stereo stream that had already been through a 24 kHz mono bottleneck, with +/// nothing in any log to say so. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct MixFormat { + pub rate_hz: u32, + pub channels: u16, + pub bits: u16, +} + +impl MixFormat { + /// Why this endpoint would NARROW a `want`-channel desktop mix, or `None` if it carries it + /// intact. Bit depth is deliberately not a criterion: 16-bit is ~96 dB of headroom, far below + /// Opus's own noise floor, whereas a lost channel or halved bandwidth is plainly audible. + pub(crate) fn narrowing(&self, want: u8) -> Option { + if self.rate_hz < 48_000 && self.channels < want as u16 { + return Some(format!( + "mixes at {} Hz and only {} channel(s)", + self.rate_hz, self.channels + )); + } + if self.rate_hz < 48_000 { + return Some(format!( + "mixes at {} Hz, so the stream is band-limited to ~{} kHz before Opus sees it", + self.rate_hz, + self.rate_hz / 2000 + )); + } + if self.channels < want as u16 { + return Some(format!( + "mixes {} channel(s), so a {want}-channel desktop mix is downmixed and re-expanded", + self.channels + )); + } + None + } +} + +/// Looks up a render endpoint's mix format by endpoint id. `None` = unknown (enumeration failed, +/// or the caller has no way to ask) — treated as "assume it is fine", so a probe failure can +/// never make the plan worse than it was before formats existed. +pub(crate) type FormatProbe<'a> = &'a dyn Fn(&Endpoint) -> Option; + +/// A [`FormatProbe`] that knows nothing — the pre-WP2.1 behaviour. +pub(crate) fn no_formats(_: &Endpoint) -> Option { + None +} + /// The coherent endpoint assignment for one wiring pass. Computed fresh on every mic/capture /// (re)open — Windows endpoints churn (boot-time registration, hotplug, driver installs), so a /// once-per-process plan goes stale. @@ -60,6 +113,11 @@ pub(crate) struct Wiring { /// the mic reservation. The capture side treats it as a stopgap: it warns when the silence /// materializes and re-plans on any endpoint-set change instead of riding it out. pub loopback_last_resort: bool, + /// Set when the chosen loopback endpoint's mix format NARROWS the desktop mix (see + /// [`MixFormat::narrowing`]) and the plan took it anyway because nothing better existed. Carries + /// the human-readable reason for the capture side to log — a quality risk the operator can act + /// on (attach a real output, or set the output mode to prefer hardware), not a failure. + pub loopback_narrowing: Option, } impl Wiring { @@ -137,6 +195,32 @@ pub(crate) fn plan( captures: &[Endpoint], mic_want: Option<&str>, host_audio: bool, +) -> Wiring { + plan_with_formats(renders, captures, mic_want, host_audio, &no_formats, 2) +} + +/// [`plan`] with knowledge of each render endpoint's engine mix format, and the channel count the +/// session wants to carry. +/// +/// **The 2026-08-03 field report is this function's reason to exist.** The default client-only +/// preference takes the "silent sink" — Steam's Streaming *Microphone* render endpoint — over real +/// hardware unconditionally, because it is silent on the host. But that endpoint exists to carry +/// remote *voice*, and nothing checked whether it could carry music. On the reporter's box it won +/// all 31 loopback opens across 25 sessions while a clean AMD HD Audio endpoint sat idle, and the +/// whole desktop mix went through it before reaching Opus. +/// +/// So a silent sink now has to EARN its preference: if its mix format narrows the mix (see +/// [`MixFormat::narrowing`]) it drops below real hardware. It is still taken when nothing better +/// exists — narrow audio beats no audio — but flagged in [`Wiring::loopback_narrowing`] so the +/// capture side can say why. An unknown format (probe failed) counts as fine, so this can never +/// make the plan worse than it was before formats existed. +pub(crate) fn plan_with_formats( + renders: &[Endpoint], + captures: &[Endpoint], + mic_want: Option<&str>, + host_audio: bool, + format_of: FormatProbe, + want_channels: u8, ) -> Wiring { let find_render = |needle: &str| { renders @@ -172,10 +256,18 @@ pub(crate) fn plan( not_mic(id) && !excluded_from_loopback(&ln) && !virtualish(&ln) }) }; - let silent = || { - renders - .iter() - .find(|(n, id)| not_mic(id) && silent_sink(&n.to_lowercase())) + // A silent sink splits in two: one that carries the mix intact, and one that narrows it. The + // first keeps the historical preference; the second falls BELOW real hardware. + let narrowing_of = |ep: &Endpoint| format_of(ep).and_then(|f| f.narrowing(want_channels)); + let silent_intact = || { + renders.iter().find(|ep| { + not_mic(&ep.1) && silent_sink(&ep.0.to_lowercase()) && narrowing_of(ep).is_none() + }) + }; + let silent_narrow = || { + renders.iter().find(|ep| { + not_mic(&ep.1) && silent_sink(&ep.0.to_lowercase()) && narrowing_of(ep).is_some() + }) }; // LAST RESORT — the Steam Streaming Speakers, and ONLY them. Their loopback is known-silent // (validated live): a QUALITY risk, flagged so the capture side can warn when the silence @@ -192,10 +284,13 @@ pub(crate) fn plan( .iter() .find(|(n, id)| not_mic(id) && n.to_lowercase().contains("steam streaming speakers")) }; + // A narrowing silent sink sits below real hardware in BOTH modes: preferring silence on the + // host is a routing choice, but it must not silently cost audio quality when a clean endpoint + // is right there. let preferred = if host_audio { - real_hw().or_else(silent) + real_hw().or_else(silent_intact).or_else(silent_narrow) } else { - silent().or_else(real_hw) + silent_intact().or_else(real_hw).or_else(silent_narrow) }; let (loopback_render, loopback_last_resort) = match preferred { Some(ep) => (Some(ep.clone()), false), @@ -204,12 +299,16 @@ pub(crate) fn plan( None => (None, false), }, }; + // Report narrowing for whatever we actually chose — including real hardware, which can also + // be a 24 kHz mono endpoint (a headset's hands-free profile is exactly that). + let loopback_narrowing = loopback_render.as_ref().and_then(narrowing_of); Wiring { mic_render, mic_capture, loopback_render, loopback_last_resort, + loopback_narrowing, } } @@ -550,6 +649,169 @@ mod tests { } } + // ---- format-aware loopback selection (WP2.1) ----------------------------------------- + + fn fmt(rate_hz: u32, channels: u16) -> MixFormat { + MixFormat { + rate_hz, + channels, + bits: 32, + } + } + + /// Probe helper: give endpoints whose (lowercased) name contains a needle that format, + /// everything else unknown. Owns its table so call sites can pass a literal inline. + fn probe(table: Vec<(&'static str, MixFormat)>) -> impl Fn(&Endpoint) -> Option { + move |ep: &Endpoint| { + let name = ep.0.to_lowercase(); + table + .iter() + .find_map(|(needle, f)| name.contains(needle).then_some(*f)) + } + } + + /// THE 2026-08-03 field case, with formats. The reporter's exact endpoint inventory: the plan + /// took the Steam Streaming Microphone on all 31 opens while a clean AMD HD Audio endpoint sat + /// idle. Once we can see that the silent sink narrows the mix, real hardware must win. + #[test] + fn narrowing_silent_sink_loses_to_real_hardware() { + let renders = [ + ep("CABLE In 16ch (VB-Audio Virtual Cable)"), + ep("Altavoces (Steam Streaming Speakers)"), + ep("Altavoces (Steam Streaming Microphone)"), + ep("CABLE Input (VB-Audio Virtual Cable)"), + ep("1 - Odyssey G60SD (AMD High Definition Audio Device)"), + ]; + let captures = [ + ep("CABLE Output (VB-Audio Virtual Cable)"), + ep("Microphone (Steam Streaming Microphone)"), + ]; + // A voice-carrier endpoint: 24 kHz mono. + let p = probe(vec![ + ("steam streaming microphone", fmt(24_000, 1)), + ("odyssey", fmt(48_000, 2)), + ]); + let w = plan_with_formats(&renders, &captures, None, false, &p, 2); + assert_eq!( + w.loopback_render.as_ref().unwrap().0, + "1 - Odyssey G60SD (AMD High Definition Audio Device)", + "a narrowing silent sink must not beat clean real hardware" + ); + assert!( + w.loopback_narrowing.is_none(), + "the chosen endpoint is intact" + ); + // The mic assignment is untouched by any of this. + assert_eq!( + w.mic_render.unwrap().0, + "CABLE Input (VB-Audio Virtual Cable)" + ); + } + + /// …but a silent sink that carries the mix intact keeps its historical preference: the + /// client-only routing default is not being abandoned, only made conditional on quality. + #[test] + fn intact_silent_sink_still_wins() { + let renders = [ + ep("Speakers (Realtek HD Audio)"), + ep("CABLE Input (VB-Audio Virtual Cable)"), + ep("Speakers (Steam Streaming Microphone)"), + ]; + let p = probe(vec![ + ("steam streaming microphone", fmt(48_000, 2)), + ("realtek", fmt(48_000, 2)), + ]); + let w = plan_with_formats(&renders, &[], None, false, &p, 2); + assert_eq!( + w.loopback_render.unwrap().0, + "Speakers (Steam Streaming Microphone)" + ); + } + + /// Narrow audio still beats NO audio: with nothing else available the narrowing sink is taken + /// and flagged, not refused. + #[test] + fn narrowing_sink_is_taken_when_it_is_all_there_is() { + let renders = [ + ep("CABLE Input (VB-Audio Virtual Cable)"), + ep("Speakers (Steam Streaming Microphone)"), + ]; + let p = probe(vec![("steam streaming microphone", fmt(16_000, 1))]); + let w = plan_with_formats(&renders, &[], None, false, &p, 2); + assert_eq!( + w.loopback_render.as_ref().unwrap().0, + "Speakers (Steam Streaming Microphone)" + ); + let why = w.loopback_narrowing.expect("must be flagged"); + assert!(why.contains("16000"), "{why}"); + } + + /// Real hardware can narrow too — a headset in its hands-free profile is 16 kHz mono — and + /// must be flagged just the same. The flag is about the CHOSEN endpoint, not about which tier + /// it came from. + #[test] + fn narrowing_is_reported_for_real_hardware_too() { + let renders = [ep("Headset (Hands-Free AG Audio)")]; + let p = probe(vec![("headset", fmt(16_000, 1))]); + let w = plan_with_formats(&renders, &[], None, false, &p, 2); + assert_eq!( + w.loopback_render.as_ref().unwrap().0, + "Headset (Hands-Free AG Audio)" + ); + assert!(w.loopback_narrowing.is_some()); + } + + /// An unknown format must never make the plan WORSE than it was before formats existed: a + /// probe that answers nothing has to reproduce `plan` exactly. + #[test] + fn unknown_formats_reproduce_the_formatless_plan() { + let renders = [ + ep("Speakers (Apple Audio Device)"), + ep("CABLE Input (VB-Audio Virtual Cable)"), + ep("Speakers (Steam Streaming Speakers)"), + ep("Speakers (Steam Streaming Microphone)"), + ]; + let captures = [ep("CABLE Output (VB-Audio Virtual Cable)")]; + for host_audio in [false, true] { + let a = plan(&renders, &captures, None, host_audio); + let b = plan_with_formats(&renders, &captures, None, host_audio, &no_formats, 2); + assert_eq!(a, b, "host_audio={host_audio}"); + assert!(a.loopback_narrowing.is_none()); + } + } + + /// `host_audio` still prefers real hardware, and a narrowing silent sink stays last in that + /// mode too. + #[test] + fn host_audio_ordering_survives_formats() { + let renders = [ + ep("Speakers (Realtek HD Audio)"), + ep("Speakers (Steam Streaming Microphone)"), + ]; + let p = probe(vec![ + ("steam streaming microphone", fmt(24_000, 1)), + ("realtek", fmt(48_000, 2)), + ]); + let w = plan_with_formats(&renders, &[], None, true, &p, 2); + assert_eq!(w.loopback_render.unwrap().0, "Speakers (Realtek HD Audio)"); + } + + /// The narrowing test is channel-count aware: an endpoint that is fine for stereo narrows a + /// 5.1 session. + #[test] + fn narrowing_depends_on_the_session_channel_count() { + let stereo_only = fmt(48_000, 2); + assert_eq!(stereo_only.narrowing(2), None); + assert!(stereo_only.narrowing(6).is_some()); + // Rate is judged independently of channels. + assert!(fmt(44_100, 8).narrowing(2).is_some()); + // And an endpoint wider than the session is never "narrowing". + assert_eq!(fmt(48_000, 8).narrowing(2), None); + // Both wrong: the message must name both problems. + let both = fmt(16_000, 1).narrowing(6).unwrap(); + assert!(both.contains("16000") && both.contains("channel"), "{both}"); + } + /// Operator override beats the candidate order. #[test] fn env_override_wins() { diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index f2cafd6a..91591ead 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -1307,9 +1307,12 @@ async fn serve_session( let stop = stop.clone(); let cap = audio_cap.clone(); let channels = welcome.audio_channels; + // Read the granted bit back off the Welcome (the cursor plane's precedent), so the wire + // the client was promised and the wire we actually send cannot disagree. + let redundancy = welcome.host_caps & punktfunk_core::quic::HOST_CAP_AUDIO_RED != 0; std::thread::Builder::new() .name("punktfunk1-audio".into()) - .spawn(move || audio_thread(conn, stop, cap, channels)) + .spawn(move || audio_thread(conn, stop, cap, channels, redundancy)) .map_err(|e| tracing::warn!(error = %e, "audio thread spawn failed — session continues without audio")) .ok() } else { diff --git a/crates/punktfunk-host/src/native/audio.rs b/crates/punktfunk-host/src/native/audio.rs index 493015a4..852026fe 100644 --- a/crates/punktfunk-host/src/native/audio.rs +++ b/crates/punktfunk-host/src/native/audio.rs @@ -1,8 +1,13 @@ //! 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. +//! (48 kHz, 5 ms, constrained VBR at the configured [`AudioTier`](punktfunk_core::audio::AudioTier)) +//! → `AUDIO_MAGIC` QUIC datagrams — or `AUDIO_RED_MAGIC` when the session negotiated redundancy — +//! 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. +//! +//! Two things here deliberately DIVERGE from the GameStream plane, which used to share this +//! tuning: hard CBR (its audio FEC needs fixed-size packets; this plane has no FEC, so CBR was a +//! pure quality tax) and the fixed 128 kbps stereo bitrate. See [`NativeAudioEnc::new`]. use super::*; @@ -17,20 +22,36 @@ enum NativeAudioEnc { #[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 { + /// Build the encoder for `channels` (2/6/8) at `tier`, RESTRICTED_LOWDELAY like the GameStream + /// path but — unlike it — in CONSTRAINED VBR. + /// + /// **Why not hard CBR (WP1.2).** The layout table's comment justifies `set_vbr(false)` with + /// "constant packet size, which GameStream's audio FEC relies on" — true of the GameStream + /// plane, and irrelevant here: the native `punktfunk/1` audio plane has no FEC at all (see + /// `punktfunk_core::audio::AudioGapTracker`, which exists precisely because a lost packet has + /// nothing to rebuild it from). So this path was paying a pure quality tax for a constraint + /// that does not apply to it. Constrained VBR keeps the same average bitrate and the same + /// bounded packet size, and spends the bits where the signal needs them. + /// + /// The GameStream encoder (`crate::gamestream::audio`) is deliberately NOT changed: its FEC + /// really does need fixed-size packets. + fn new( + channels: u8, + tier: punktfunk_core::audio::AudioTier, + ) -> Result { + let l = punktfunk_core::audio::layout_for(channels, false); + let bitrate = l.bitrate_for(tier); 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(); + e.set_bitrate(opus::Bitrate::Bits(bitrate)).ok(); + e.set_vbr(true).ok(); + e.set_vbr_constraint(true).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, @@ -38,8 +59,9 @@ impl NativeAudioEnc { l.mapping, opus::Application::LowDelay, )?; - e.set_bitrate(opus::Bitrate::Bits(l.bitrate)).ok(); - e.set_vbr(false).ok(); + e.set_bitrate(opus::Bitrate::Bits(bitrate)).ok(); + e.set_vbr(true).ok(); + e.set_vbr_constraint(true).ok(); Ok(NativeAudioEnc::Surround(e)) } } @@ -52,8 +74,8 @@ impl NativeAudioEnc { } } -/// 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, +/// The audio thread: desktop capture → Opus (48 kHz, 5 ms, constrained VBR at the configured +/// tier) → `AUDIO_MAGIC` (or `AUDIO_RED_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"))] @@ -62,11 +84,27 @@ pub(super) fn audio_thread( stop: Arc, audio_cap: AudioCapSlot, channels: u8, + redundancy: bool, ) { 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); + // WP1.1 — encode tier. Unknown spellings warn and fall back rather than silently downgrading + // someone's audio (the whole point of the setting is that quality stopped being invisible). + let tier = match pf_host_config::config().audio_quality.as_deref() { + None => punktfunk_core::audio::AudioTier::default(), + Some(s) => match punktfunk_core::audio::AudioTier::parse(s) { + Some(t) => t, + None => { + tracing::warn!( + value = %s, + "PUNKTFUNK_AUDIO_QUALITY is not one of low/standard/high — using the default" + ); + punktfunk_core::audio::AudioTier::default() + } + }, + }; // 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 @@ -92,7 +130,7 @@ pub(super) fn audio_thread( } } }; - let mut enc = match NativeAudioEnc::new(want) { + let mut enc = match NativeAudioEnc::new(want, tier) { Ok(e) => e, Err(e) => { tracing::warn!(error = %e, "opus encoder init failed — session continues without audio"); @@ -120,9 +158,16 @@ pub(super) fn audio_thread( // 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; + // WP3.1 — the previous frame's Opus bytes, for the redundant `0xD2` plane. Cleared whenever + // continuity breaks (a capture reopen), so we never advertise a predecessor the client's + // sequence numbering does not agree with. + let mut prev_frame: Vec = Vec::new(); if capturer.is_some() { tracing::info!( channels = want, + tier = tier.as_str(), + kbps = punktfunk_core::audio::layout_for(want, false).bitrate_for(tier) / 1000, + redundancy, "punktfunk/1 audio streaming (Opus 48 kHz, 5 ms datagrams)" ); } @@ -138,6 +183,10 @@ pub(super) fn audio_thread( capturer = Some(c); last_failed = None; acc.clear(); // drop the partial frame straddling the gap + // The next frame has no valid predecessor across the gap: sending the + // pre-gap frame as "the previous one" would hand the client audio from + // before the discontinuity to splice in. + prev_frame.clear(); } Err(e) => { tracing::debug!(error = %format!("{e:#}"), "audio reopen failed — will retry"); @@ -162,11 +211,24 @@ pub(super) fn audio_thread( 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]); + let opus = &opus_buf[..n]; + let d = if redundancy { + punktfunk_core::quic::encode_audio_red_datagram( + seq, + pts_ns, + opus, + &prev_frame, + ) + } else { + punktfunk_core::quic::encode_audio_datagram(seq, pts_ns, opus) + }; if conn.send_datagram(d.into()).is_err() { break 'session; // connection gone } + if redundancy { + prev_frame.clear(); + prev_frame.extend_from_slice(opus); + } seq = seq.wrapping_add(1); } Err(e) => { @@ -199,6 +261,7 @@ pub(super) fn audio_thread( _stop: Arc, _audio_cap: AudioCapSlot, _channels: u8, + _redundancy: bool, ) { tracing::warn!("punktfunk/1 audio requires Linux or Windows — session continues without it"); } diff --git a/crates/punktfunk-host/src/native/handshake.rs b/crates/punktfunk-host/src/native/handshake.rs index 3c732229..8b7cf3ef 100644 --- a/crates/punktfunk-host/src/native/handshake.rs +++ b/crates/punktfunk-host/src/native/handshake.rs @@ -24,6 +24,26 @@ use super::*; /// paints on a Mutter virtual stream), and only a can't-blend backend falls back to the /// compositor EMBED. THE single predicate: the Welcome's `HOST_CAP_CURSOR` bit is computed /// from it, and the session wiring reads that bit back. +/// Whether this session sends the REDUNDANT desktop-audio plane (`0xD2`) — THE single predicate +/// behind the Welcome's `HOST_CAP_AUDIO_RED` bit, which `serve_session` reads back to configure the +/// audio thread. +/// +/// Capable-and-agreed: the client must have advertised `CLIENT_CAP_AUDIO_RED`, so a session with an +/// older client keeps the plain `0xC9` wire byte-for-byte. `audio.redundancy` ( +/// `PUNKTFUNK_AUDIO_REDUNDANCY`) can force it off on a link where the extra ~1 % is unwelcome, or +/// force it on for testing. +/// +/// NB the plan's "only while the link is actually losing packets" gate is deliberately not here: +/// turning redundancy on and off mid-session changes the wire tag, and the client's decoder would +/// have to re-derive which plane it is on from every datagram. The cost being avoided is ~1 % of a +/// video budget, which is not worth that fragility — so the decision is made once, at handshake. +pub(super) fn audio_redundancy(client_caps: u8) -> bool { + if client_caps & punktfunk_core::quic::CLIENT_CAP_AUDIO_RED == 0 { + return false; + } + pf_host_config::config().audio_redundancy.unwrap_or(true) +} + pub(super) fn cursor_forward( client_caps: u8, compositor: Option, @@ -564,6 +584,14 @@ pub(super) async fn negotiate( punktfunk_core::quic::HOST_CAP_PEN } else { 0 + } + // Redundant desktop-audio plane (0xD2): the client asked, and the operator has not + // forced it off. Capable-and-agreed, like the cursor bit — a client that did not ask + // keeps the plain 0xC9 wire byte-for-byte. + | if audio_redundancy(hello.client_caps) { + punktfunk_core::quic::HOST_CAP_AUDIO_RED + } else { + 0 }, // The negotiated session AEAD (resolved above) + its 32-byte key toward a ChaCha // client; toward everyone else cipher 0 keeps the Welcome byte-identical to the From a12f1f092c80ee41c55c20e548bf2d74a72c0f83 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 09:28:01 +0200 Subject: [PATCH 11/53] feat(clients/audio): one de-jitter policy for all four rings, and lossless single-packet recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 + WP3.2 of design/audio-quality-and-latency.md. **The defect.** Every client ring primed *up* to a target and clamped at a ceiling, and none walked the depth back *down*. Any transient — a Wi-Fi arrival burst, a host stall, or plain host-DAC-vs-client-DAC skew of a few dozen ppm — therefore added latency permanently, until an underrun happened to re-prime. Android, with no shed at all, converged on its 120 ms hard cap and stayed there for the rest of the session; that is the "audio latency is too high" report. Apple did shed, 40 ms in one go, which its own comment called "one audible blip". All four now share `punktfunk_core::audio::JitterPolicy`: depths in MILLISECONDS rather than device quanta (`3 x quantum` meant 15 ms at a 5 ms quantum and a silent 64 ms at a 20 ms one), a crossfaded 5 ms shed once the depth average has sat above target for 2 s of consumed audio, and de-prime hysteresis. Linux and Windows had never had that hysteresis — they still carried the `if ring.is_empty()` instant re-prime that Android identified as self-inflicted crackle, where one transient drain manufactured a whole target's worth of silence. Android's floor drops 40 -> 25 ms: the policy grows the target on the devices that actually underrun, instead of every device pre-paying for the worst one. The Windows ring moves from raw bytes to interleaved f32 so it can share the policy and the crossfade helper at all. Apple is the one client where the policy is hand-written in a second language, so it gets its own XCTest (`AudioRingDriftTests`). Verified here by compiling `AudioRing.swift` standalone against a simulation harness — +200 ppm for 5 minutes settles at 30 ms with zero silent callbacks, where the old ring would have ridden its 80 ms high-water mark. **WP3.2 — recovery lives in core, not in the clients.** The rebuilt frame is re-inserted into the demux queue in order, so every embedder (including any C-ABI consumer) gets a complete stream without knowing the `0xD2` plane exists, and their `AudioGapTracker` simply stops seeing the gap. `recovery_and_the_gap_tracker_agree` pins exactly that. For the same reason core advertises CLIENT_CAP_AUDIO_RED itself rather than making four embedders remember to. Verified: clippy --all-targets -D warnings and the full test suites for punktfunk-core, pf-client-core, punktfunk-host, pf-host-config under Linux/docker (163 + 61 tests); punktfunk-client-android `cargo ndk check` for aarch64 with the gate proven non-vacuous by a planted type error, and its 6 clippy findings confirmed IDENTICAL to the pristine file (all are the documented arm64-only artifacts); AudioRing.swift type-checked and simulated on macOS; fmt. The Windows client half (audio_wasapi.rs) is still not compile-verified anywhere. Co-Authored-By: Claude Opus 5 (1M context) --- clients/android/native/src/audio.rs | 110 +++++++------- .../PunktfunkKit/Audio/AudioRing.swift | 122 +++++++++++++--- .../PunktfunkKit/Audio/SessionAudio.swift | 8 +- .../AudioRingDriftTests.swift | 93 ++++++++++++ crates/pf-client-core/src/audio.rs | 66 ++++++--- crates/pf-client-core/src/audio_wasapi.rs | 64 ++++++--- crates/punktfunk-core/src/audio.rs | 134 ++++++++++++++++++ crates/punktfunk-core/src/client/mod.rs | 8 +- .../src/client/pump/datagram_task.rs | 24 ++++ 9 files changed, 512 insertions(+), 117 deletions(-) create mode 100644 clients/apple/Tests/PunktfunkKitTests/AudioRingDriftTests.swift diff --git a/clients/android/native/src/audio.rs b/clients/android/native/src/audio.rs index 2a76dcba..6dbec5cc 100644 --- a/clients/android/native/src/audio.rs +++ b/clients/android/native/src/audio.rs @@ -12,10 +12,14 @@ //! realtime callback and makes us own the buffer. So this client diverges deliberately to stop the //! Android-only crackle: (1) the callback is allocation/free-free — decoded buffers are recycled to //! the producer via a free-list instead of being freed on the audio thread (Android's Scudo `free` -//! has unbounded tail latency); (2) the jitter ring is deeper (~40 ms prime / ~150 ms hard cap) and -//! decoupled from the tiny LowLatency burst size, with de-prime hysteresis so a transient drain -//! doesn't manufacture a silence; (3) the AAudio HW buffer is primed above its 2-burst default and -//! grown on XRuns (Google's anti-glitch technique). +//! has unbounded tail latency); (2) the jitter ring is deeper than the other clients' and decoupled +//! from the tiny LowLatency burst size, with de-prime hysteresis so a transient drain doesn't +//! manufacture a silence; (3) the AAudio HW buffer is primed above its 2-burst default and grown on +//! XRuns (Google's anti-glitch technique). +//! +//! (2) is now the SHARED `punktfunk_core::audio::JitterPolicy` at `JitterTuning::AAUDIO`, which also +//! fixed what this ring was missing: it had a hard cap but nothing that walked the depth back down, +//! so drift and arrival bursts raised latency permanently and Android settled on its ceiling. use ndk::audio::{ AudioCallbackResult, AudioContentType, AudioDirection, AudioFormat, AudioPerformanceMode, @@ -34,26 +38,18 @@ const SAMPLE_RATE: i32 = 48_000; /// Decoded-chunk hand-off depth: 64 × 5 ms = 320 ms slack (matches the core's AUDIO_QUEUE). const RING_CHUNKS: usize = 64; -// --- Jitter-ring depths, in MILLISECONDS (scaled to interleaved-f32 samples at runtime). -------- -// The channel count is negotiated, not a compile-time const, so these are kept in ms and multiplied -// by `ms` (interleaved-f32 samples per millisecond at the resolved layout) inside `start`. -// Unlike the Linux client (PipeWire adaptively rate-matches the stream to the graph clock, masking -// host↔DAC drift + a shallow ring), AAudio hands us a raw callback and we own the buffer: drift and -// WiFi power-save bunching land as underruns/overflows = crackle. So Android runs a deliberately -// deeper, smoothly-managed ring than Linux — keep the two clients' depths intentionally divergent. -/// Prime/target floor: fill to ~40 ms before playing (and after a sustained drain). Deep enough to -/// ride out WiFi arrival jitter + clock drift; the dominant Android-only anti-crackle lever. -const PRIME_FLOOR_MS: usize = 40; -/// Ceiling for the burst-scaled target (so a large quantum can't push the prime depth too high). -const PRIME_CEIL_MS: usize = 80; -/// Drop-oldest headroom above the target before trimming — a ~80 ms band swallows an arrival burst -/// without overflowing. -const JITTER_HEADROOM_MS: usize = 80; -/// Hard latency bound: never let the ring exceed ~150 ms (the only thing that caps added latency). -const HARD_CAP_MS: usize = 150; -/// Re-prime (go silent to refill) only after this many CONSECUTIVE empty callbacks, so one transient -/// drain doesn't manufacture a fresh 40 ms silence (the old `if ring.is_empty()` re-primed instantly). -const DEPRIME_AFTER_CALLBACKS: u32 = 5; +// --- Jitter-ring depths now come from the SHARED policy (`punktfunk_core::audio::JitterTuning`). -- +// They used to be four Android-only constants here. The rationale for Android being DEEPER than the +// other clients still holds and is preserved in `JitterTuning::AAUDIO`: unlike PipeWire, which +// adaptively rate-matches the stream to the graph clock and masks host↔DAC drift, AAudio hands us a +// raw callback and we own the buffer, so drift and Wi-Fi power-save bunching land as +// underruns/overflows = crackle. +// +// Two things changed with the move. The prime floor drops 40 ms → 25 ms, because the policy GROWS +// the target on the devices that actually underrun instead of every device pre-paying for the worst +// one. And the ring finally sheds: it had a hard cap but nothing that walked the depth back down, so +// any drift or burst raised latency permanently and Android converged on its 120 ms ceiling and +// stayed there — the "audio latency is too high" report. /// Throttle the AAudio XRun-driven HW-buffer grow check (cheap, but no need to poll every quantum). const XRUN_CHECK_EVERY: u32 = 128; @@ -104,6 +100,7 @@ struct Counters { pcm_written: AtomicU64, // PCM frames copied out to AAudio (device clock is pulling) underruns: AtomicU64, // callbacks that emitted silence (ring not primed / drained) ring_depth: AtomicU64, // ring sample count at the last callback + target_ms: AtomicU64, // the policy's LIVE target depth (it grows on this device's underruns) } /// Owned by [`crate::session::SessionHandle`]: the live AAudio stream + the decode thread. @@ -126,10 +123,9 @@ impl AudioPlayback { // Interleaved f32 samples per millisecond at this layout (48 kHz × channels); the ms- // denominated jitter-ring depths scale by it. let ms = (SAMPLE_RATE as usize / 1000) * channels; - let prime_floor = PRIME_FLOOR_MS * ms; - let prime_ceil = PRIME_CEIL_MS * ms; - let jitter_headroom = JITTER_HEADROOM_MS * ms; - let hard_cap_max = HARD_CAP_MS * ms; + let tuning = punktfunk_core::audio::JitterTuning::AAUDIO; + // Worst transient the ring can hold before the policy trims it. + let hard_cap_max = tuning.hard_cap_ms as usize * ms; let counters = Arc::new(Counters::default()); // One open attempt at a given sharing mode. Everything the realtime callback captures @@ -157,8 +153,10 @@ impl AudioPlayback { // `decode_loop`. let mut ring: VecDeque = VecDeque::with_capacity(hard_cap_max + RING_CHUNKS * 5 * ms); - let mut primed = false; - let mut empties: u32 = 0; // consecutive empty callbacks (de-prime hysteresis) + // Shared de-jitter policy — prime depth, drift correction, de-prime hysteresis. The + // hysteresis this replaces was Android-only; Linux and Windows carried the instant + // `if ring.is_empty()` re-prime until now. + let mut policy = punktfunk_core::audio::JitterPolicy::new(tuning, channels as u8); let mut cb_count: u32 = 0; // callbacks since open (throttles the XRun grow check) let mut last_xrun: i32 = 0; // last AAudio XRun count we grew the buffer for let callback = move |s: &AudioStream, data: *mut c_void, num_frames: i32| { @@ -173,21 +171,25 @@ impl AudioPlayback { ring.extend(chunk.drain(..)); let _ = free_tx.try_send(chunk); } - // Jitter buffer: prime to ~40 ms (prime_floor) before playing and after a sustained - // drain; drop-oldest only above a wide ~120 ms band. Decoupled from the AAudio burst - // `want` (tiny on the LowLatency MMAP path) so the depth doesn't collapse to a single - // quantum. - let target = (3 * want).clamp(prime_floor, prime_ceil); - let hard_cap = (target + jitter_headroom).min(hard_cap_max); - while ring.len() > hard_cap { - ring.pop_front(); + // Jitter buffer: the shared policy decides prime/silence, trims a burst, and — + // new here — sheds ONE crossfaded 5 ms frame when the depth average has sat above + // target long enough to be drift rather than jitter. Without that shed this ring + // had no way back down: it clamped at 120 ms and stayed pinned there. + let step = policy.step(ring.len(), want); + if step.drop_front > 0 { + punktfunk_core::audio::crossfade_drop( + &mut ring, + step.drop_front, + step.crossfade, + ); } - if !primed && ring.len() >= target { - primed = true; - } - if primed { + let mut ran_short = false; + if !step.silence { for slot in out.iter_mut() { - *slot = ring.pop_front().unwrap_or(0.0); + *slot = ring.pop_front().unwrap_or_else(|| { + ran_short = true; + 0.0 + }); } cb_counters .pcm_written @@ -196,20 +198,15 @@ impl AudioPlayback { out.fill(0.0); cb_counters.underruns.fetch_add(1, Ordering::Relaxed); } - // Re-prime only after a RUN of empty callbacks, not a single transient one — - // otherwise every momentary drain costs a fresh 40 ms silence (the old behaviour, - // self-inflicted crackle on any jitter spike). - if ring.is_empty() { - empties += 1; - if empties >= DEPRIME_AFTER_CALLBACKS { - primed = false; - } - } else { - empties = 0; - } + // No-op while un-primed, so a deliberate priming silence is never counted as an + // underrun (which would otherwise drive the adaptive floor up for no reason). + policy.note_read(ran_short); cb_counters .ring_depth .store(ring.len() as u64, Ordering::Relaxed); + cb_counters + .target_ms + .store(policy.target_ms() as u64, Ordering::Relaxed); // Google's AAudio anti-glitch technique: when the device reports new XRuns, grow the // HW buffer by one burst (up to capacity). getXRunCount + setBufferSizeInFrames are // both callback-safe / non-blocking, and set clamps to capacity so it self-limits. @@ -408,10 +405,11 @@ fn decode_loop( } if count % 600 == 0 { log::info!( - "audio: opus={count} pcm_frames={} underruns={} ring={} peak={window_peak:.3}", + "audio: opus={count} pcm_frames={} underruns={} buffer_ms={} target_ms={} peak={window_peak:.3}", counters.pcm_written.load(Ordering::Relaxed), counters.underruns.load(Ordering::Relaxed), - counters.ring_depth.load(Ordering::Relaxed), + counters.ring_depth.load(Ordering::Relaxed) / ms.max(1) as u64, + counters.target_ms.load(Ordering::Relaxed), ); window_peak = 0.0; } diff --git a/clients/apple/Sources/PunktfunkKit/Audio/AudioRing.swift b/clients/apple/Sources/PunktfunkKit/Audio/AudioRing.swift index bca90d98..7654111d 100644 --- a/clients/apple/Sources/PunktfunkKit/Audio/AudioRing.swift +++ b/clients/apple/Sources/PunktfunkKit/Audio/AudioRing.swift @@ -3,28 +3,61 @@ import os /// SPSC-ish jitter ring (interleaved float, `channels` per frame), drain thread → render /// callback. The unfair lock is held for microseconds; fine at render-callback rates. Priming: -/// reads return silence until enough is buffered (at least `prefill`, and at least one +/// reads return silence until enough is buffered (at least the target, and at least one /// packet more than the device's render quantum — large-buffer devices would otherwise -/// chronically out-demand the prefill and oscillate prime → dropout → re-prime), and an -/// underrun re-primes, concealing jitter as one short dip instead of sustained crackle. +/// chronically out-demand the prefill and oscillate prime → dropout → re-prime). /// All counts stay whole frames (multiples of `channels`), so the interleave can never slip. +/// +/// **Drift correction.** Both ends run at 48 kHz but on different crystals, so backlog from a +/// network stall or plain host-vs-DAC skew never drains on its own: without correction one 300 ms +/// hiccup leaves audio 300 ms behind video for the rest of the session. This used to be handled by +/// a `highWater` shed that dropped a whole `2 × prefill` at once — its own comment called that "one +/// audible blip". It is now the same two-stage scheme the Rust clients share +/// (`punktfunk_core::audio::JitterPolicy`): a slow depth average that sits above target for a +/// sustained window sheds ONE 5 ms frame with a crossfade, and the hard cap is only a backstop. +/// Keep the constants here in step with `JitterTuning.COREAUDIO`. final class AudioRing: @unchecked Sendable { + /// Mirrors `JitterTuning::COREAUDIO` — see that type for the rationale. + private static let targetMS = 20 + private static let headroomMS = 30 + private static let hardCapMS = 90 + private static let deprimeAfter = 4 + /// The protocol's frame: the shed unit, and the slack added over a large device quantum. + private static let frameMS = 5 + /// Depth average must exceed target by this before drift correction fires — the middle of the + /// headroom band, so the smooth shed always gets its chance BEFORE the hard cap trims. + private static let shedExcessMS = 15 + /// …and must stay there for this much consumed audio. Long, because a shed is the only thing + /// here a listener could notice; it must never fire on a transient. + private static let shedSustainMS = 2_000 + private static let crossfadeMS = 2 + /// Time constant of the depth average. + private static let ewmaTauMS = 1_000 + private var buf: [Float] private var readIdx = 0 private var writeIdx = 0 private var primed = false private var renderQuantum = 0 - private let prefill: Int - private let highWater: Int + private var emptyReads = 0 + private var depthAvg: Double = 0 + private var overRun = 0 private let channels: Int + private let perMS: Int private let lock = OSAllocatedUnfairLock() /// `capacity`/`prefill` in samples (interleaved — `channels` per frame, both whole frames). - init(capacity: Int, prefill: Int, channels: Int) { + /// `prefill` is accepted for source compatibility but the target now comes from `targetMS`. + init(capacity: Int, prefill: Int = 0, channels: Int) { buf = [Float](repeating: 0, count: capacity) - self.prefill = prefill self.channels = channels - highWater = prefill * 4 + perMS = 48 * channels + } + + /// Live target depth in interleaved samples, lifted so it can always serve one device quantum + /// plus a packet (a large-buffer device cannot sustain a target below its own quantum). + private var target: Int { + max(Self.targetMS * perMS, renderQuantum + Self.frameMS * perMS) } func write(_ samples: UnsafePointer, count: Int) { @@ -42,12 +75,12 @@ final class AudioRing: @unchecked Sendable { buf[(writeIdx + i) % capacity] = samples[i] } writeIdx += count - // Latency clamp: both ends run at 48 kHz, so backlog from a network stall (or - // creeping host-vs-DAC clock skew) never drains on its own — without this, one - // 300 ms hiccup leaves audio 300 ms behind video for the rest of the session. - // Shedding down to 2× prefill costs one audible blip instead. - if writeIdx - readIdx > highWater { - readIdx = writeIdx - prefill * 2 + // Backstop only: the smooth shed in `read` is what normally holds the depth down. + let cap = min(target + Self.headroomMS * perMS, Self.hardCapMS * perMS) + if writeIdx - readIdx > cap { + readIdx = writeIdx - cap + depthAvg = Double(cap) + overRun = 0 } } @@ -57,16 +90,36 @@ final class AudioRing: @unchecked Sendable { defer { lock.unlock() } renderQuantum = max(renderQuantum, count) let available = writeIdx - readIdx + + // Depth average, weighted by the callback size so its time constant is independent of the + // device quantum. + let alpha = min(1.0, Double(count) / Double(Self.ewmaTauMS * perMS)) + depthAvg += (Double(available) - depthAvg) * alpha + if !primed { - // One 5 ms host packet (240 frames × channels) of slack beyond the device's demand. - if available >= max(prefill, renderQuantum + 240 * channels) { + if available >= target { primed = true + emptyReads = 0 } else { for i in 0.. Double(target + Self.shedExcessMS * perMS) { + overRun += count + if overRun >= Self.shedSustainMS * perMS { + overRun = 0 + shedOneFrame() + depthAvg = Double(writeIdx - readIdx) + } + } else { + overRun = 0 + } + + let n = min(writeIdx - readIdx, count) let capacity = buf.count for i in 0..= Self.deprimeAfter { primed = false } + } else { + emptyReads = 0 } } + + /// Drop one protocol frame from the front, linearly crossfading the seam so the correction is + /// inaudible rather than a click. Mirrors `punktfunk_core::audio::crossfade_drop`; caller holds + /// the lock. + private func shedOneFrame() { + let drop = Self.frameMS * perMS + let available = writeIdx - readIdx + guard available > drop else { return } + let fade = min(Self.crossfadeMS * perMS, min(drop, available - drop)) + let capacity = buf.count + if fade > 0 { + // The tail of what we discard fades out into the head of what survives. + for i in 0.. (Int, Int, Int) { + let ring = AudioRing(capacity: 48_000 * channels, channels: channels) + let want = quantumMS * perMS + var scratch = [Float](repeating: 0, count: want) + // Non-zero so a silent callback is distinguishable from real audio. + let producer = [Float](repeating: 0.25, count: want + 8) + var carry = 0, peak = 0, final = 0, silent = 0 + + for i in 0..<(ms / quantumMS) { + carry += want * driftPPM + let extra = carry / 1_000_000 + carry -= extra * 1_000_000 + producer.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: want + extra) } + + scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) } + // Skip the priming window at the very start. + if i > 20, scratch.allSatisfy({ $0 == 0 }) { silent += 1 } + peak = max(peak, ring.bufferedMS) + final = ring.bufferedMS + } + return (final, peak, silent) + } + + /// THE regression: with the host clock running fast, buffered latency must return to target + /// instead of climbing to the hard cap and staying pinned there. +200 ppm is deliberately + /// harsher than real hardware (tens of ppm). + func testDriftDoesNotRatchetLatencyToTheCeiling() { + let (final, peak, silent) = simulate(ms: 5 * 60 * 1_000, quantumMS: 5, driftPPM: 200) + // Must settle inside the headroom band (target 20 + headroom 30), never near the 90 ms cap. + XCTAssertLessThanOrEqual(final, 50, "settled at \(final) ms — that is the ratchet") + XCTAssertLessThanOrEqual(peak, 50, "peaked at \(peak) ms") + XCTAssertEqual(silent, 0, "drift correction must never starve the callback") + } + + /// The mirror case: a host clock running SLOW must keep audio flowing rather than being + /// "corrected" into a stutter. + func testNegativeDriftKeepsPlaying() { + let (_, _, silent) = simulate(ms: 2 * 60 * 1_000, quantumMS: 5, driftPPM: -200) + XCTAssertEqual(silent, 0, "a draining ring must re-prime, not chatter") + } + + /// A device that pulls a large quantum cannot sustain a target below it — the ring must lift + /// its target rather than oscillating prime → dropout → re-prime forever. + func testLargeDeviceQuantumStillPlays() { + let (_, _, silent) = simulate(ms: 60 * 1_000, quantumMS: 40, driftPPM: 0) + XCTAssertEqual(silent, 0, "a 40 ms quantum must not starve a 20 ms target") + } + + /// One transient drain must not manufacture a whole target's worth of fresh silence: the ring + /// de-primes only after a RUN of short reads. + func testSingleShortReadDoesNotDeprime() { + let ring = AudioRing(capacity: 48_000 * channels, channels: channels) + let want = 5 * perMS + var scratch = [Float](repeating: 0, count: want) + // Prime well past target. + let big = [Float](repeating: 0.5, count: 60 * perMS) + big.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: big.count) } + scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) } + XCTAssertTrue(scratch.contains { $0 != 0 }, "should be playing after priming") + + // Drain it dry with one oversized read, then feed a normal quantum again. + var huge = [Float](repeating: 0, count: 200 * perMS) + huge.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: huge.count) } + let feed = [Float](repeating: 0.5, count: want) + feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: want) } + scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) } + XCTAssertTrue( + scratch.contains { $0 != 0 }, + "a single short read must not force a full re-prime") + } +} +#endif diff --git a/crates/pf-client-core/src/audio.rs b/crates/pf-client-core/src/audio.rs index 30ceaaf7..b70ff4f9 100644 --- a/crates/pf-client-core/src/audio.rs +++ b/crates/pf-client-core/src/audio.rs @@ -168,9 +168,18 @@ struct PlayerData { /// Drained chunk Vecs go back here for the decode side to refill (allocation pool). recycle: SyncSender>, ring: VecDeque, - primed: bool, + /// Shared ms-denominated de-jitter policy: prime depth, drift correction, de-prime + /// hysteresis. Replaces the old `3 × quantum` target, which meant 15 ms at a 5 ms graph + /// quantum and a silent 64 ms at a 20 ms one, and the `if ring.is_empty()` re-prime, where + /// one transient drain manufactured a whole target's worth of fresh silence. + policy: punktfunk_core::audio::JitterPolicy, /// Interleaved channel count this stream was opened with (2/6/8). channels: usize, + /// Diagnostics (WP0.3), logged ~every 10 s: the audio plane used to be entirely silent in a + /// client log, so a latency or dropout report had nothing to go on. + underruns: u64, + sheds: u64, + callbacks: u64, } fn pw_thread( @@ -223,8 +232,14 @@ fn pw_thread( rx: pcm_rx, recycle: recycle_tx, ring: VecDeque::new(), - primed: false, + policy: punktfunk_core::audio::JitterPolicy::new( + punktfunk_core::audio::JitterTuning::PIPEWIRE, + channels as u8, + ), channels, + underruns: 0, + sheds: 0, + callbacks: 0, }; let _listener = stream @@ -252,23 +267,29 @@ fn pw_thread( let want_frames = data.data().map(|s| s.len() / stride).unwrap_or(0); let want = want_frames * ud.channels; - // Adaptive jitter buffer (same shape as the host's virtual mic): prime to - // ~3 quanta, cap at ~1 quantum of slack beyond that, re-prime after a - // genuine drain. - let target = (3 * want).clamp(720 * ud.channels, 9600 * ud.channels); - while ud.ring.len() > target.max(want) + want { - ud.ring.pop_front(); - } - if !ud.primed && ud.ring.len() >= target { - ud.primed = true; + // Shared de-jitter policy: prime depth in MILLISECONDS, smooth drift correction + // (a crossfaded 5 ms shed) so latency returns to target instead of ratcheting, + // and a hard cap as the backstop. + let step = ud.policy.step(ud.ring.len(), want); + if step.drop_front > 0 { + ud.sheds += 1; + punktfunk_core::audio::crossfade_drop( + &mut ud.ring, + step.drop_front, + step.crossfade, + ); } + let mut ran_short = false; let n_frames = if let Some(slice) = data.data() { for k in 0..want { - let s = if ud.primed { - ud.ring.pop_front().unwrap_or(0.0) - } else { + let s = if step.silence { 0.0 + } else { + ud.ring.pop_front().unwrap_or_else(|| { + ran_short = true; + 0.0 + }) }; let off = k * 4; slice[off..off + 4].copy_from_slice(&s.to_le_bytes()); @@ -277,8 +298,21 @@ fn pw_thread( } else { 0 }; - if ud.ring.is_empty() { - ud.primed = false; + // No-op while un-primed (the policy ignores it), so a deliberate priming silence + // is never miscounted as an underrun. + ud.policy.note_read(ran_short); + ud.underruns += u64::from(ran_short); + ud.callbacks += 1; + // ~10 s at a 5 ms quantum; the exact cadence does not matter, only that the + // plane stops being invisible. + if ud.callbacks % 2_000 == 0 { + tracing::debug!( + buffer_ms = ud.policy.avg_depth_ms(), + target_ms = ud.policy.target_ms(), + underruns = ud.underruns, + drift_sheds = ud.sheds, + "audio playback" + ); } let chunk = data.chunk_mut(); *chunk.offset_mut() = 0; diff --git a/crates/pf-client-core/src/audio_wasapi.rs b/crates/pf-client-core/src/audio_wasapi.rs index 2df9f3b8..0c9b6d69 100644 --- a/crates/pf-client-core/src/audio_wasapi.rs +++ b/crates/pf-client-core/src/audio_wasapi.rs @@ -250,10 +250,20 @@ fn render_thread( audio_client.start_stream().context("start render stream")?; let _ = ready.send(Ok(())); - // Adaptive jitter buffer, in f32-byte units (same shape as the host's virtual mic). - let mut ring: VecDeque = VecDeque::new(); - let mut primed = false; + // De-jitter ring, in interleaved f32 SAMPLES (it used to be raw bytes, which made the + // depth arithmetic byte-vs-sample and kept it from sharing the policy and the crossfade + // helper with the other three clients). + let mut ring: VecDeque = VecDeque::new(); + // Shared ms-denominated policy: prime depth, crossfaded drift correction so latency + // returns to target instead of ratcheting, and de-prime hysteresis — the last replacing + // the old `if ring.is_empty()`, where a single transient drain manufactured a whole + // target's worth of fresh silence. + let mut policy = punktfunk_core::audio::JitterPolicy::new( + punktfunk_core::audio::JitterTuning::WASAPI, + channels, + ); let mut out = Vec::new(); // per-quantum scratch, reused across iterations + let (mut underruns, mut sheds, mut callbacks) = (0u64, 0u64, 0u64); while !stop.load(Ordering::Relaxed) { if h_event.wait_for_event(100).is_err() { @@ -262,9 +272,7 @@ fn render_thread( // Drain everything the pump has queued into the ring, returning each drained // Vec to the pool (a full/closed pool drops it). while let Ok(mut chunk) = pcm_rx.try_recv() { - for s in chunk.iter() { - ring.extend(s.to_le_bytes()); - } + ring.extend(chunk.iter().copied()); chunk.clear(); let _ = recycle_tx.try_send(chunk); } @@ -274,28 +282,40 @@ fn render_thread( if avail_frames == 0 { continue; } - let want_bytes = avail_frames * block_align; + let want = avail_frames * channels as usize; - // Prime to ~3 quanta; cap at ~1 quantum of slack beyond that; re-prime on drain. - let target = (3 * want_bytes).clamp(720 * block_align, 9600 * block_align); - let cap = target.max(want_bytes) + want_bytes; - if ring.len() > cap { - ring.drain(..ring.len() - cap); - } - if !primed && ring.len() >= target { - primed = true; + let step = policy.step(ring.len(), want); + if step.drop_front > 0 { + sheds += 1; + punktfunk_core::audio::crossfade_drop(&mut ring, step.drop_front, step.crossfade); } out.clear(); - out.resize(want_bytes, 0); - if primed { - let n = ring.len().min(want_bytes); - for (dst, b) in out.iter_mut().zip(ring.drain(..n)) { - *dst = b; + out.resize(avail_frames * block_align, 0); + let mut ran_short = false; + if !step.silence { + // `out` is exactly `want` f32s wide (avail_frames × channels × 4 bytes). + for dst in out.chunks_exact_mut(4) { + let s = ring.pop_front().unwrap_or_else(|| { + ran_short = true; + 0.0 + }); + dst.copy_from_slice(&s.to_le_bytes()); } } - if ring.is_empty() { - primed = false; + // No-op while un-primed (the policy ignores it), so a deliberate priming silence is + // never miscounted as an underrun. + policy.note_read(ran_short); + underruns += u64::from(ran_short); + callbacks += 1; + if callbacks % 1_000 == 0 { + tracing::debug!( + buffer_ms = policy.avg_depth_ms(), + target_ms = policy.target_ms(), + underruns, + drift_sheds = sheds, + "audio playback" + ); } render_client .write_to_device(avail_frames, &out, None) diff --git a/crates/punktfunk-core/src/audio.rs b/crates/punktfunk-core/src/audio.rs index a006fc51..f20d7780 100644 --- a/crates/punktfunk-core/src/audio.rs +++ b/crates/punktfunk-core/src/audio.rs @@ -243,6 +243,54 @@ impl AudioGapTracker { } } +/// Rebuilds the audio stream from the redundant `0xD2` plane, so a single lost datagram is +/// RECOVERED rather than concealed. +/// +/// Deliberately lives in core, on the demux side, rather than in the four client decoders. The +/// recovered frame is re-inserted into the same queue in order, so every embedder — Linux, +/// Windows, Android, Apple, and any C-ABI consumer — gets a complete stream with no change at all, +/// and their [`AudioGapTracker`] simply stops seeing the gap. +/// +/// **Only the immediately-preceding frame can be recovered**, because that is all the wire carries +/// (see [`crate::quic::encode_audio_red_datagram`]). A longer burst still falls through to +/// packet-loss concealment — but it falls through one frame shorter, which is strictly better. +#[derive(Debug, Default)] +pub struct AudioRedRecovery { + /// Sequence of the newest packet handed downstream. + last_seq: Option, +} + +impl AudioRedRecovery { + pub fn new() -> Self { + Self::default() + } + + /// Feed the arriving datagram's sequence and whether it carried a redundant copy. Returns + /// `true` when that copy should be emitted (as `seq - 1`) BEFORE the packet itself. + /// + /// Wrapping-safe, and conservative in both directions: a reorder or duplicate recovers + /// nothing, and neither does the first packet of a session (nothing is known to be missing). + pub fn recover_before(&mut self, seq: u32, has_prev: bool) -> bool { + let recover = match self.last_seq { + // Nothing emitted yet: no evidence anything was lost, so inserting the predecessor + // would prepend audio the client never missed. + None => false, + Some(last) => { + let delta = seq.wrapping_sub(last); + // `delta == 1` is in-order; `delta >= 2` (forward half of the space only) means + // at least the predecessor is missing. + has_prev && (2..u32::MAX / 2).contains(&delta) + } + }; + self.last_seq = Some(match self.last_seq { + // A reorder must not drag the anchor backwards. + Some(last) if seq.wrapping_sub(last) > u32::MAX / 2 => last, + _ => seq, + }); + recover + } +} + // ---- the shared playback de-jitter policy ------------------------------------------------- /// The protocol's audio frame, in milliseconds — every host datagram carries exactly one @@ -692,6 +740,92 @@ mod tests { assert_eq!(t.missing_before(0), 0, "pre-wrap reorder, not a 2^31 gap"); } + // ---- redundant-plane recovery --------------------------------------------------------- + + #[test] + fn red_recovery_rebuilds_exactly_the_single_missing_frame() { + let mut r = AudioRedRecovery::new(); + // First packet: nothing is known to be missing, so nothing is prepended. + assert!(!r.recover_before(10, true)); + // In order. + assert!(!r.recover_before(11, true)); + // 12 lost: 13 carries it. + assert!(r.recover_before(13, true)); + // Back in order from the new anchor. + assert!(!r.recover_before(14, true)); + } + + #[test] + fn red_recovery_is_conservative() { + let mut r = AudioRedRecovery::new(); + r.recover_before(10, true); + // A datagram with no redundant copy recovers nothing, however big the gap. + assert!(!r.recover_before(20, false)); + // Duplicates and reorders recover nothing, and must not move the anchor backwards. + let mut r = AudioRedRecovery::new(); + r.recover_before(10, true); + r.recover_before(11, true); + assert!(!r.recover_before(11, true), "duplicate"); + assert!(!r.recover_before(9, true), "late reorder"); + assert!( + !r.recover_before(12, true), + "the reorder must not have moved the anchor" + ); + } + + /// A longer burst still recovers its last frame — the gap the client has to conceal gets one + /// frame shorter, which is strictly better than concealing all of it. + #[test] + fn red_recovery_shortens_a_longer_burst() { + let mut r = AudioRedRecovery::new(); + r.recover_before(100, true); + assert!( + r.recover_before(105, true), + "104 is recoverable even though 101-103 are not" + ); + } + + #[test] + fn red_recovery_survives_seq_wraparound() { + let mut r = AudioRedRecovery::new(); + assert!(!r.recover_before(u32::MAX - 1, true)); + assert!( + !r.recover_before(u32::MAX, true), + "in order across the edge" + ); + assert!(r.recover_before(1, true), "seq 0 lost across the wrap"); + assert!(!r.recover_before(2, true)); + } + + /// The two halves must agree: whatever `AudioRedRecovery` rebuilds, `AudioGapTracker` must + /// then see as no gap at all — that is the whole point of doing recovery on the demux side. + #[test] + fn recovery_and_the_gap_tracker_agree() { + let mut rec = AudioRedRecovery::new(); + let mut gaps = AudioGapTracker::new(); + let mut concealed = 0; + // Deliver 0..20 with 7 and 13 lost; each survivor carries its predecessor. + let mut emitted: Vec = Vec::new(); + for seq in (0..20u32).filter(|s| *s != 7 && *s != 13) { + if rec.recover_before(seq, true) { + emitted.push(seq - 1); + } + emitted.push(seq); + } + for seq in &emitted { + concealed += gaps.missing_before(*seq); + } + assert_eq!( + concealed, 0, + "recovered stream must need no concealment: {emitted:?}" + ); + assert_eq!(emitted.len(), 20, "every frame accounted for"); + assert!( + emitted.windows(2).all(|w| w[1] == w[0] + 1), + "and in order: {emitted:?}" + ); + } + // ---- bitrate tiers ------------------------------------------------------------------- /// `Standard` must reproduce the historical table EXACTLY — that is what makes the tier diff --git a/crates/punktfunk-core/src/client/mod.rs b/crates/punktfunk-core/src/client/mod.rs index eaece606..0d4c48d6 100644 --- a/crates/punktfunk-core/src/client/mod.rs +++ b/crates/punktfunk-core/src/client/mod.rs @@ -490,7 +490,13 @@ impl NativeClient { video_codecs, preferred_codec, display_hdr, - client_caps, + // Redundant audio (`0xD2`) is advertised by CORE, not by the embedder: the + // recovery happens on the demux side (`AudioRedRecovery` in the datagram + // task) and re-inserts the rebuilt frame into the same queue, so every + // embedder benefits without knowing the plane exists — and none of them can + // forget to opt in. The bit is a pure "I can decode it"; the host still + // decides whether to spend the extra ~1 %. + client_caps: client_caps | crate::quic::CLIENT_CAP_AUDIO_RED, frame_parts, launch, name, diff --git a/crates/punktfunk-core/src/client/pump/datagram_task.rs b/crates/punktfunk-core/src/client/pump/datagram_task.rs index b62c8dd7..59bae19f 100644 --- a/crates/punktfunk-core/src/client/pump/datagram_task.rs +++ b/crates/punktfunk-core/src/client/pump/datagram_task.rs @@ -23,6 +23,10 @@ pub(super) async fn run( // gate): a datagram the network reordered must not roll a stopped motor back on. Legacy v1 // datagrams carry no seq and bypass it (an old host's own periodic re-send is the only heal). let mut rumble_last_seq: [Option; crate::input::MAX_PADS] = [None; crate::input::MAX_PADS]; + // Redundant-audio-plane rebuild (`0xD2`). Recovery happens HERE rather than in the four + // client decoders: the recovered frame is re-inserted into this queue in order, so every + // embedder gets a complete stream without knowing the plane exists. + let mut audio_red = crate::audio::AudioRedRecovery::new(); while let Ok(d) = conn.read_datagram().await { match d.first() { Some(&crate::quic::AUDIO_MAGIC) => { @@ -34,6 +38,26 @@ pub(super) async fn run( }); } } + Some(&crate::quic::AUDIO_RED_MAGIC) => { + if let Some((seq, pts_ns, opus, prev)) = crate::quic::decode_audio_red_datagram(&d) + { + if audio_red.recover_before(seq, prev.is_some()) { + // The copy is the frame BEFORE this one, so it carries the previous + // sequence and presentation time — one protocol frame earlier. + let _ = audio_tx.try_send(AudioPacket { + seq: seq.wrapping_sub(1), + pts_ns: pts_ns + .saturating_sub(crate::audio::FRAME_MS as u64 * 1_000_000), + data: prev.unwrap_or_default().to_vec(), + }); + } + let _ = audio_tx.try_send(AudioPacket { + seq, + pts_ns, + data: opus.to_vec(), + }); + } + } Some(&crate::quic::RUMBLE_MAGIC) => { if let Some(u) = crate::quic::decode_rumble_envelope(&d) { // Gate v2 envelopes on their per-pad seq; forward v1 (envelope: None) as-is. From e9a209ef61d88379aba103cd9e6fbb3cb4f714bb Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 09:42:06 +0200 Subject: [PATCH 12/53] docs(troubleshooting): why streamed audio can sound worse than the host, and the knobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WP0.4. The 2026-08-03 reporter had no way to know their desktop mix was being routed through Steam's voice-carrier endpoint, and no documented way to change it — `PUNKTFUNK_HOST_AUDIO` existed only in a module doc comment. Two new sections: what the host actually captures (a render endpoint, not "the sound card"), what the new `engine_hz/engine_ch/engine_bits` log line tells you, and the `PUNKTFUNK_AUDIO_OUTPUT_MODE` / `_QUALITY` / `_REDUNDANCY` knobs — with host_and_client called out as the quickest A/B for the endpoint question; and why audio that lags the picture should now correct itself, plus what to check when it does not. Co-Authored-By: Claude Opus 5 (1M context) --- docs-site/content/docs/troubleshooting.md | 58 +++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/docs-site/content/docs/troubleshooting.md b/docs-site/content/docs/troubleshooting.md index 18d03cdc..9d925b6c 100644 --- a/docs-site/content/docs/troubleshooting.md +++ b/docs-site/content/docs/troubleshooting.md @@ -302,6 +302,64 @@ told your client so. [When the client and the host disagree](/docs/client-settings#when-the-client-and-the-host-disagree) lists what it does with each one. +## Streamed audio sounds worse than the host does + +The host does not capture "the sound card" — it captures a **render endpoint**, and by default it +picks one that is *silent on the host* so the audio plays on your client only. On a PC with Steam +installed that silent endpoint is Steam's **Streaming Microphone**, which exists to carry remote +*voice*. If Windows has it configured as a narrow device — mono, or below 48 kHz — then the whole +desktop mix is squeezed through that before it is ever encoded, and no amount of bitrate will bring +it back. + +Since 0.25 the host checks for this: it reads each candidate endpoint's real format, prefers a real +output device over a narrow virtual one, and says so in the log — + +``` +WARN the desktop-audio loopback endpoint mixes at 24000 Hz, so the stream is band-limited … +INFO audio loopback capturing device="…" engine_hz=48000 engine_ch=2 engine_bits=32 +``` + +That `engine_*` line is the endpoint's **own** format, so it tells you directly whether the source +was ever full quality. To choose the routing yourself, set in `host.env`: + +```ini +# client_only — default; audio plays on the client only (a silent endpoint) +# host_and_client — capture a real output device; audio plays on BOTH ends +# follow_default — capture whatever YOUR default playback device is, and never change it +PUNKTFUNK_AUDIO_OUTPUT_MODE=host_and_client +``` + +`host_and_client` is also the quickest way to A/B the problem: if the stream sounds right that way +and wrong on the default, the endpoint was the cause. + +Two related knobs: + +```ini +PUNKTFUNK_AUDIO_QUALITY=high # low | standard | high (default high — stereo 256 kbps) +PUNKTFUNK_AUDIO_REDUNDANCY=1 # force the loss-resilient audio plane on (default: automatic) +``` + +Audio is a fraction of a percent of a stream's bandwidth, so `high` costs nothing worth counting. +`standard` reproduces the pre-0.25 encoder exactly if you want to compare. + +## Audio lags behind the picture + +The client buffers a little audio to absorb network jitter. Since 0.25 that buffer **corrects +itself**: if it drifts deeper — a Wi-Fi burst, a stall, or just the two devices' clocks running at +fractionally different speeds — it trims itself back a few milliseconds at a time, inaudibly. +Before, it could only grow, so a single hiccup left audio permanently behind the video and the only +cure was reconnecting. + +If audio is still noticeably late: + +- **Reconnect once.** It confirms whether the delay was accumulated (gone after a reconnect) or + constant (something else). +- **Check for underruns** rather than guessing. The client logs its buffer depth periodically; a + rising `underruns` count means the buffer is being starved, which is a network or CPU problem, not + a buffering one. +- **Wired or 5 GHz Wi-Fi.** Arrival jitter is what the buffer exists to absorb; less jitter lets it + run shallower. + ## Windows: the host or the web console won't start The **`PunktfunkHost` service** runs both halves of the Windows host: the streaming host itself and From 2cfc82e96c558408e18aed378c020c7c2d61eb95 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 17:58:17 +0200 Subject: [PATCH 13/53] fix(audio): budget the audio plane against the link, and close the review's gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from the post-implementation review of design/audio-quality-and-latency.md. **The bandwidth gap (highest).** Tier `High` (256 kbps) and the redundant `0xD2` plane were added separately, each costed as "~1 % of the video budget", and nobody added them together: 256 kbps sent twice is 512 kbps — ~2.5 % of a 20 Mbps session but ~10 % of a 5 Mbps one. Audio rides QUIC datagrams, OUTSIDE the ABR loop, so ABR could neither see that nor reclaim it; a constrained link quietly handed a tenth of its bandwidth to audio while ABR carefully managed the rest. `plan_audio_budget` now makes tier and redundancy ONE decision against the session's resolved video bitrate, ordered by preference rather than cost — transparent audio beats redundant audio, since the field report was about quality and redundancy only pays under loss, so `High` alone outranks `Standard`+redundancy even though they cost the same. It can lower what the operator asked for, never raise it, and never goes below `Low`: a stream with unintelligible audio is worse than one spending a few percent more. **The Linux host kept the exact defect fixed on Windows.** `let _ = tx.try_send(samples)` — silent, uncounted data loss, where the encoder concatenates across the hole, so every drop is a click AND a permanent shift of everything after it. WP0.2 turned out to be Windows-only and had not said so. Linux now shares `capture_policy::CaptureStats`: drops counted and warned, plus per-window peak/RMS/delivered%. A Linux audio report was until now exactly as un-triageable as the Windows one was on 2026-08-03. **Apple's WP0.3 was half-done** — `bufferedMS` was added and wired to nothing. The drain thread now logs buffer/target/underruns/sheds like the other three, from one locked snapshot so the numbers in a line describe the same instant. Also: the Linux "audio format negotiated" line now says WHICH mode produced it, because that changes what it is worth — in stream-sink mode the host owns the sink so the mix cannot have been narrowed upstream, but in legacy monitor mode a 16 kHz Bluetooth sink would still be reported as a clean 48 kHz through PipeWire's resampler, the same way WASAPI's autoconvert hid it on Windows. Reading the monitored node's own rate needs a registry lookup this stream does not do; recorded as an open gap rather than implied to be covered. Two stale docs: `audio_wasapi.rs` cited `clients/windows/src/audio.rs` (deleted) and still described the pre-shared-policy "prime to ~3 quanta" behaviour. And the Apple ring's `prefill:` parameter, dead since the depth moved into the ring, is gone. Verified: clippy --all-targets -D warnings on Linux (docker) AND Windows (runner .133, forced clean rebuild of punktfunk-host + pf-client-core); core 167 tests; host 57 audio tests on Windows; Android clippy count identical to pristine (6, all documented arm64 artifacts); Apple ring re-simulated. The host suite's `gamestream::stream::tests::sender_delivers_batches` fails under qemu — the recorded environmental flake, unrelated to audio, green on the earlier less-loaded run. Co-Authored-By: Claude Opus 5 (1M context) --- .../PunktfunkKit/Audio/AudioRing.swift | 35 +++- .../PunktfunkKit/Audio/SessionAudio.swift | 12 ++ crates/pf-client-core/src/audio_wasapi.rs | 15 +- crates/punktfunk-core/src/audio.rs | 163 ++++++++++++++++++ crates/punktfunk-host/src/audio/linux/mod.rs | 64 ++++++- crates/punktfunk-host/src/native.rs | 12 +- crates/punktfunk-host/src/native/audio.rs | 25 +-- crates/punktfunk-host/src/native/handshake.rs | 79 +++++++-- 8 files changed, 351 insertions(+), 54 deletions(-) diff --git a/clients/apple/Sources/PunktfunkKit/Audio/AudioRing.swift b/clients/apple/Sources/PunktfunkKit/Audio/AudioRing.swift index 7654111d..f139d82e 100644 --- a/clients/apple/Sources/PunktfunkKit/Audio/AudioRing.swift +++ b/clients/apple/Sources/PunktfunkKit/Audio/AudioRing.swift @@ -42,13 +42,18 @@ final class AudioRing: @unchecked Sendable { private var emptyReads = 0 private var depthAvg: Double = 0 private var overRun = 0 + /// Reported, not acted on: short reads that actually starved the callback, and smooth drift + /// corrections. A rising underrun count means the ring is being starved (network or CPU), + /// which is a different problem from the depth being wrong. + private var underrunCount = 0 + private var shedCount = 0 private let channels: Int private let perMS: Int private let lock = OSAllocatedUnfairLock() - /// `capacity`/`prefill` in samples (interleaved — `channels` per frame, both whole frames). - /// `prefill` is accepted for source compatibility but the target now comes from `targetMS`. - init(capacity: Int, prefill: Int = 0, channels: Int) { + /// `capacity` in samples (interleaved — `channels` per frame, a whole number of frames). + /// The de-jitter depth is the ring's own business (`targetMS`), not a caller's prefill. + init(capacity: Int, channels: Int) { buf = [Float](repeating: 0, count: capacity) self.channels = channels perMS = 48 * channels @@ -113,6 +118,7 @@ final class AudioRing: @unchecked Sendable { if overRun >= Self.shedSustainMS * perMS { overRun = 0 shedOneFrame() + shedCount += 1 depthAvg = Double(writeIdx - readIdx) } } else { @@ -130,6 +136,7 @@ final class AudioRing: @unchecked Sendable { // De-prime only after a RUN of short reads: a single transient drain must not // manufacture a whole target's worth of fresh silence. emptyReads += 1 + underrunCount += 1 if emptyReads >= Self.deprimeAfter { primed = false } } else { emptyReads = 0 @@ -157,12 +164,32 @@ final class AudioRing: @unchecked Sendable { readIdx += drop } - /// Current buffered depth in milliseconds — for the stats overlay. + /// Current buffered depth in milliseconds — for the stats overlay and the drain thread's + /// periodic log. var bufferedMS: Int { lock.lock() defer { lock.unlock() } return (writeIdx - readIdx) / max(perMS, 1) } + + /// One consistent snapshot of the ring's vitals, taken under a single lock so the numbers in + /// a log line describe the same instant. Mirrors what the three Rust clients report. + struct Stats { + let bufferedMS: Int + let targetMS: Int + let underruns: Int + let sheds: Int + } + + var stats: Stats { + lock.lock() + defer { lock.unlock() } + return Stats( + bufferedMS: (writeIdx - readIdx) / max(perMS, 1), + targetMS: target / max(perMS, 1), + underruns: underrunCount, + sheds: shedCount) + } } /// CoreAudio channel layout for the canonical wire order FL FR FC LFE RL RR [SL SR]. nil for diff --git a/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift b/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift index 9a1fab08..d79d12a1 100644 --- a/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift +++ b/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift @@ -403,6 +403,7 @@ public final class SessionAudio { stateLock.unlock() let thread = Thread { [connection, flag, drainDone] in defer { drainDone.signal() } + var drained = 0 // Decode happens IN-CORE (libopus multistream) — AudioToolbox's Opus path is // stereo-only — and is handed back as interleaved f32 PCM in wire channel order. // Per-iteration autorelease pool: no runloop on this thread (see Stage2Pipeline). @@ -421,6 +422,17 @@ public final class SessionAudio { ring.write(base, count: pcm.frameCount * pcm.channels) } } + // Periodic vitals (~10 s at the protocol's 5 ms frames). The other three clients + // log buffer depth and underruns; without this an Apple audio report — latency or + // dropout — arrives with no numbers at all, which is the position every platform + // was in before the 2026-08 audio work. + drained += 1 + if drained % 2_000 == 0 { + let s = ring.stats + log.info( + "audio: buffer_ms=\(s.bufferedMS) target_ms=\(s.targetMS) underruns=\(s.underruns) drift_sheds=\(s.sheds)" + ) + } return true } } diff --git a/crates/pf-client-core/src/audio_wasapi.rs b/crates/pf-client-core/src/audio_wasapi.rs index 0c9b6d69..12fda251 100644 --- a/crates/pf-client-core/src/audio_wasapi.rs +++ b/crates/pf-client-core/src/audio_wasapi.rs @@ -3,14 +3,15 @@ //! //! The WASAPI twin of `audio.rs` (PipeWire) — same public surface (`AudioPlayer::spawn`/ //! `take_buffer`/`push`, `MicStreamer::spawn`), swapped in by lib.rs's `#[path]` so the -//! session pump compiles against one `crate::audio` on both OSes. Adapted from -//! `clients/windows/src/audio.rs` (which remains the WinUI shell's own copy until its -//! built-in streaming path is deleted). +//! session pump compiles against one `crate::audio` on both OSes. It began as a copy of the +//! WinUI shell's own audio path; that shell's built-in streaming path has since been deleted, +//! so this is now the only WASAPI client ring. //! -//! Playback mirrors the host's virtual-mic producer's adaptive jitter buffer: the session -//! pump pushes 5 ms Opus-decoded chunks on the network clock; the WASAPI render thread -//! pulls whole event-driven quanta on the device clock. Prime to ~3 quanta before -//! producing, cap the ring so latency stays bounded, re-prime after a real drain. +//! Playback: the session pump pushes 5 ms Opus-decoded chunks on the network clock; the WASAPI +//! render thread pulls whole event-driven quanta on the device clock. The depth policy between +//! them is the SHARED `punktfunk_core::audio::JitterPolicy` (`JitterTuning::WASAPI`) — target in +//! milliseconds, crossfaded drift correction, de-prime hysteresis — so all four clients behave +//! the same way and none of them can ratchet latency upward. //! //! WASAPI objects are COM-apartment-bound and not `Send`, so they live on a dedicated //! thread (the same discipline as the host's `wasapi_cap`); only the channels + stop flag diff --git a/crates/punktfunk-core/src/audio.rs b/crates/punktfunk-core/src/audio.rs index f20d7780..4ebaba8a 100644 --- a/crates/punktfunk-core/src/audio.rs +++ b/crates/punktfunk-core/src/audio.rs @@ -173,6 +173,90 @@ impl OpusLayout { } } +/// What the audio plane will actually cost this session: the tier to encode at, and whether the +/// redundant `0xD2` plane is affordable. Produced by [`plan_audio_budget`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AudioBudget { + pub tier: AudioTier, + pub redundancy: bool, + /// Total wire cost in kbps, redundancy included — what the decision was made against. + pub kbps: u32, +} + +/// Share of the session's video bitrate the audio plane may spend. Audio rides QUIC datagrams, +/// OUTSIDE the ABR loop, so whatever it takes is taken off the top and adaptive bitrate can +/// neither see nor reclaim it — which is exactly why it needs a budget of its own. +const AUDIO_BUDGET_PCT: u32 = 5; +/// …but never squeeze audio below the Low tier. A stream with unintelligible audio is worse than +/// one that spends a few percent more, and the floor is what stops a very low video bitrate from +/// silently producing a useless audio plane. +const AUDIO_BUDGET_FLOOR_KBPS: u32 = 96; + +/// Choose the encode tier and whether to send redundancy, given the session's resolved VIDEO +/// bitrate. +/// +/// **Why this exists.** Tier `High` and the redundant plane were introduced separately, each +/// justified as "about 1 % of the video budget" — but they multiply: 256 kbps stereo sent twice is +/// 512 kbps, which is ~2.5 % of a 20 Mbps session and ~10 % of a 5 Mbps one. Nothing added the two +/// together, and nothing capped the total, so on a constrained link the audio plane quietly took a +/// tenth of the bandwidth that ABR was carefully managing the rest of. +/// +/// The ladder is ordered by preference, not by cost: transparent audio beats redundant audio (the +/// complaint this whole program came from was quality, and the redundancy only pays off under +/// loss), so `High` alone outranks `Standard` + redundancy even though they cost the same. +/// `requested` lets an operator ask for a specific tier; the budget can lower it but never raises +/// it above what was asked. +pub fn plan_audio_budget( + video_kbps: u32, + channels: u8, + requested: AudioTier, + client_wants_redundancy: bool, +) -> AudioBudget { + let budget = (video_kbps.saturating_mul(AUDIO_BUDGET_PCT) / 100).max(AUDIO_BUDGET_FLOOR_KBPS); + let layout = layout_for(channels, false); + let cost = |tier: AudioTier, red: bool| -> u32 { + let one = (layout.bitrate_for(tier) / 1000).max(0) as u32; + if red { + one.saturating_mul(2) + } else { + one + } + }; + // Preference order, best first. An operator asking for `Low` must not be handed `High`, so + // candidates above the request are filtered out. + let rank = |t: AudioTier| match t { + AudioTier::Low => 0, + AudioTier::Standard => 1, + AudioTier::High => 2, + }; + let ladder = [ + (AudioTier::High, true), + (AudioTier::High, false), + (AudioTier::Standard, true), + (AudioTier::Standard, false), + (AudioTier::Low, false), + ]; + for (tier, red) in ladder { + if rank(tier) > rank(requested) || (red && !client_wants_redundancy) { + continue; + } + let kbps = cost(tier, red); + if kbps <= budget { + return AudioBudget { + tier, + redundancy: red, + kbps, + }; + } + } + // Nothing fit — take the cheapest thing that still works rather than muting audio. + AudioBudget { + tier: AudioTier::Low, + redundancy: false, + kbps: cost(AudioTier::Low, false), + } +} + /// Pick the layout for a negotiated channel count. Unknown counts fall back to stereo (clients /// only ever request 2/6/8). `high_quality` selects the uncoupled high-bitrate config. pub fn layout_for(channels: u8, high_quality: bool) -> &'static OpusLayout { @@ -874,6 +958,85 @@ mod tests { assert_eq!(AudioTier::parse(""), None); } + // ---- the audio bandwidth budget -------------------------------------------------------- + + /// THE regression this guards: `High` (256 kbps stereo) and the redundant plane (x2) were + /// each justified as "~1 % of the video budget" and nobody added them together. 512 kbps is + /// ~10 % of a 5 Mbps session — and audio is outside the ABR loop, so ABR cannot reclaim it. + #[test] + fn budget_steps_down_as_the_link_narrows() { + let plan = |kbps| plan_audio_budget(kbps, 2, AudioTier::High, true); + // Roomy link: everything on. + let b = plan(20_000); + assert_eq!((b.tier, b.redundancy), (AudioTier::High, true)); + assert_eq!(b.kbps, 512); + // Halve it and redundancy is the first thing to go — quality is what the field report + // was about, and redundancy only pays under loss. + assert_eq!(plan(10_000).tier, AudioTier::High); + assert!(!plan(10_000).redundancy); + // Tighter still: down to Standard. + assert_eq!(plan(5_000).tier, AudioTier::Standard); + assert!(!plan(5_000).redundancy); + // A genuinely narrow link lands on Low, and never below it. + assert_eq!(plan(1_000).tier, AudioTier::Low); + assert_eq!(plan(1).tier, AudioTier::Low); + assert_eq!( + plan(0).kbps, + 96, + "audio must survive an absurd video bitrate" + ); + } + + /// The budget must never spend more than its share, at any bitrate or channel count. + #[test] + fn budget_never_exceeds_its_share() { + for kbps in [0u32, 500, 1_000, 2_000, 5_000, 10_000, 20_000, 100_000] { + for ch in [2u8, 6, 8] { + let b = plan_audio_budget(kbps, ch, AudioTier::High, true); + let allowed = + (kbps.saturating_mul(AUDIO_BUDGET_PCT) / 100).max(AUDIO_BUDGET_FLOOR_KBPS); + let floor = plan_audio_budget(0, ch, AudioTier::Low, false).kbps; + assert!( + b.kbps <= allowed || b.kbps == floor, + "{ch}ch at {kbps} kbps: spent {} of {allowed}", + b.kbps + ); + } + } + } + + /// Surround costs more per tier, so the same link must step it down sooner than stereo — + /// the budget is about total wire cost, not about the tier name. + #[test] + fn budget_accounts_for_the_channel_count() { + let stereo = plan_audio_budget(10_000, 2, AudioTier::High, true); + let surround = plan_audio_budget(10_000, 8, AudioTier::High, true); + assert_eq!(stereo.tier, AudioTier::High); + assert!(surround.kbps <= stereo.kbps.max(surround.kbps), "sanity"); + // 7.1 at High is 768 kbps — far past a 500 kbps allowance, so it must have stepped down. + assert!( + surround.kbps < 768, + "7.1 High must not fit a 10 Mbps budget" + ); + } + + /// The budget may LOWER what was asked for, never raise it: an operator who set `low` gets + /// `low` on a 100 Mbps link, and a client that never asked for redundancy never gets it. + #[test] + fn budget_respects_the_request() { + let b = plan_audio_budget(100_000, 2, AudioTier::Low, true); + assert_eq!(b.tier, AudioTier::Low); + let b = plan_audio_budget(100_000, 2, AudioTier::Standard, true); + assert_eq!(b.tier, AudioTier::Standard); + assert!(b.redundancy, "Standard + redundancy fits a huge link"); + let b = plan_audio_budget(100_000, 2, AudioTier::High, false); + assert_eq!(b.tier, AudioTier::High); + assert!( + !b.redundancy, + "a client that did not ask must never be sent 0xD2" + ); + } + // ---- the de-jitter policy ------------------------------------------------------------ /// Interleaved samples per ms at `channels`. diff --git a/crates/punktfunk-host/src/audio/linux/mod.rs b/crates/punktfunk-host/src/audio/linux/mod.rs index b6fbd0cd..1275cd0f 100644 --- a/crates/punktfunk-host/src/audio/linux/mod.rs +++ b/crates/punktfunk-host/src/audio/linux/mod.rs @@ -674,6 +674,8 @@ fn pw_thread( }) .register(); + // Which source the negotiated format below actually describes — see the note there. + let sink_mode = sink_name.is_some(); let props = match &sink_name { // Stream-sink mode: this stream IS the sink (media.class + Direction::Input). Apps // play into it, PipeWire mixes them, process() receives the mix. Mirrors the @@ -710,8 +712,25 @@ fn pw_thread( let stream = pw::stream::StreamBox::new(&core, "punktfunk-audio", props) .context("pw audio Stream")?; + // The capture callback's state: the hand-off channel plus this plane's vitals. Before + // this it was the bare `tx`, and the desktop-audio plane logged NOTHING between "capture + // started" and the session ending — no level, no cadence, and in particular no sign of + // the silent drop below. That is exactly what made the 2026-08-03 Windows field report + // un-triageable, and the Linux half kept it after the Windows half was fixed. + struct CapUd { + tx: std::sync::mpsc::SyncSender>, + channels: u32, + stats: crate::audio::capture_policy::CaptureStats, + last_stats: std::time::Instant, + } + let ud = CapUd { + tx, + channels, + stats: Default::default(), + last_stats: std::time::Instant::now(), + }; let _listener = stream - .add_local_listener_with_user_data(tx) + .add_local_listener_with_user_data(ud) .state_changed({ let mainloop = mainloop.clone(); move |_s, _ud, old, new| { @@ -723,22 +742,32 @@ fn pw_thread( } } }) - .param_changed(|_stream, _tx, id, param| { + .param_changed(move |_stream, _tx, id, param| { let Some(param) = param else { return }; if id != pw::spa::param::ParamType::Format.as_raw() { return; } let mut info = AudioInfoRaw::default(); if info.parse(param).is_ok() { + // `stream_sink` says WHICH source this format describes, and that changes how + // much it is worth. In stream-sink mode the host owns the sink, so this IS the + // format apps render into and the desktop mix cannot have been narrowed before + // we saw it. In LEGACY monitor mode we are capturing someone else's sink + // through PipeWire's resampler: a 16 kHz Bluetooth headset upstream would + // still be reported here as a clean 48 kHz, exactly the way WASAPI's + // autoconvert hid the same thing on Windows (the 2026-08-03 report). Reading + // the monitored node's OWN rate needs a registry lookup this stream does not + // do — recorded as an open gap rather than implied to be covered. tracing::info!( format = ?info.format(), rate = info.rate(), channels = info.channels(), + stream_sink = sink_mode, "audio format negotiated" ); } }) - .process(|stream, tx| { + .process(|stream, ud| { let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let Some(mut buffer) = stream.dequeue_buffer() else { return; @@ -774,7 +803,34 @@ fn pw_thread( ]; samples.push(f32::from_le_bytes(b)); } - let _ = tx.try_send(samples); // drop if the encoder is behind + ud.stats.observe(&samples, ud.channels); + // Non-blocking and lossy, as before — but COUNTED. A full channel means the + // encode thread is not keeping up, and because the encoder simply + // concatenates across the hole every dropped chunk is a click AND a + // permanent shift of everything after it. + if ud.tx.try_send(samples).is_err() { + ud.stats.dropped_chunks += 1; + } + if ud.last_stats.elapsed() >= crate::audio::capture_policy::STATS_EVERY { + let (peak_db, rms_db, delivered_pct) = + ud.stats.summary(ud.last_stats.elapsed(), SAMPLE_RATE); + if ud.stats.dropped_chunks > 0 { + tracing::warn!( + dropped_chunks = ud.stats.dropped_chunks, + "the audio encode thread could not keep up — captured audio was \ + DROPPED; the stream will click and everything after it shifts" + ); + } + tracing::info!( + peak_db = format!("{peak_db:.1}"), + rms_db = format!("{rms_db:.1}"), + delivered_pct = format!("{delivered_pct:.0}"), + dropped_chunks = ud.stats.dropped_chunks, + "desktop audio capture" + ); + ud.stats = Default::default(); + ud.last_stats = std::time::Instant::now(); + } })); if outcome.is_err() { tracing::error!("panic in pipewire audio callback — chunk dropped"); diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index 91591ead..c3d8f69b 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -1308,11 +1308,17 @@ async fn serve_session( let cap = audio_cap.clone(); let channels = welcome.audio_channels; // Read the granted bit back off the Welcome (the cursor plane's precedent), so the wire - // the client was promised and the wire we actually send cannot disagree. - let redundancy = welcome.host_caps & punktfunk_core::quic::HOST_CAP_AUDIO_RED != 0; + // the client was promised and the wire we actually send cannot disagree — then re-derive + // the SAME budget rung from it, so the encode tier and the redundancy decision are one + // choice made once rather than two settings that can drift apart. + let budget = handshake::audio_budget( + welcome.host_caps & punktfunk_core::quic::HOST_CAP_AUDIO_RED != 0, + welcome.bitrate_kbps, + channels, + ); std::thread::Builder::new() .name("punktfunk1-audio".into()) - .spawn(move || audio_thread(conn, stop, cap, channels, redundancy)) + .spawn(move || audio_thread(conn, stop, cap, channels, budget)) .map_err(|e| tracing::warn!(error = %e, "audio thread spawn failed — session continues without audio")) .ok() } else { diff --git a/crates/punktfunk-host/src/native/audio.rs b/crates/punktfunk-host/src/native/audio.rs index 852026fe..50cd5033 100644 --- a/crates/punktfunk-host/src/native/audio.rs +++ b/crates/punktfunk-host/src/native/audio.rs @@ -84,27 +84,16 @@ pub(super) fn audio_thread( stop: Arc, audio_cap: AudioCapSlot, channels: u8, - redundancy: bool, + budget: punktfunk_core::audio::AudioBudget, ) { 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); - // WP1.1 — encode tier. Unknown spellings warn and fall back rather than silently downgrading - // someone's audio (the whole point of the setting is that quality stopped being invisible). - let tier = match pf_host_config::config().audio_quality.as_deref() { - None => punktfunk_core::audio::AudioTier::default(), - Some(s) => match punktfunk_core::audio::AudioTier::parse(s) { - Some(t) => t, - None => { - tracing::warn!( - value = %s, - "PUNKTFUNK_AUDIO_QUALITY is not one of low/standard/high — using the default" - ); - punktfunk_core::audio::AudioTier::default() - } - }, - }; + // Tier and redundancy are ONE decision, budgeted against the session's video bitrate — see + // `handshake::audio_budget`. An unparseable `audio.quality` was already warned about there + // and fell back to the default, so nothing here can silently downgrade someone's audio. + let (tier, redundancy) = (budget.tier, budget.redundancy); // 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 @@ -166,7 +155,7 @@ pub(super) fn audio_thread( tracing::info!( channels = want, tier = tier.as_str(), - kbps = punktfunk_core::audio::layout_for(want, false).bitrate_for(tier) / 1000, + kbps = budget.kbps, redundancy, "punktfunk/1 audio streaming (Opus 48 kHz, 5 ms datagrams)" ); @@ -261,7 +250,7 @@ pub(super) fn audio_thread( _stop: Arc, _audio_cap: AudioCapSlot, _channels: u8, - _redundancy: bool, + _budget: punktfunk_core::audio::AudioBudget, ) { tracing::warn!("punktfunk/1 audio requires Linux or Windows — session continues without it"); } diff --git a/crates/punktfunk-host/src/native/handshake.rs b/crates/punktfunk-host/src/native/handshake.rs index 8b7cf3ef..9027f755 100644 --- a/crates/punktfunk-host/src/native/handshake.rs +++ b/crates/punktfunk-host/src/native/handshake.rs @@ -24,24 +24,61 @@ use super::*; /// paints on a Mutter virtual stream), and only a can't-blend backend falls back to the /// compositor EMBED. THE single predicate: the Welcome's `HOST_CAP_CURSOR` bit is computed /// from it, and the session wiring reads that bit back. -/// Whether this session sends the REDUNDANT desktop-audio plane (`0xD2`) — THE single predicate -/// behind the Welcome's `HOST_CAP_AUDIO_RED` bit, which `serve_session` reads back to configure the -/// audio thread. +/// THE single audio-plane decision for a session: the encode tier AND whether the redundant +/// `0xD2` plane is sent. The Welcome's `HOST_CAP_AUDIO_RED` bit is computed from it, and +/// `serve_session` reads that bit back to configure the audio thread — so the wire the client is +/// promised and the wire we send cannot disagree. /// -/// Capable-and-agreed: the client must have advertised `CLIENT_CAP_AUDIO_RED`, so a session with an -/// older client keeps the plain `0xC9` wire byte-for-byte. `audio.redundancy` ( -/// `PUNKTFUNK_AUDIO_REDUNDANCY`) can force it off on a link where the extra ~1 % is unwelcome, or -/// force it on for testing. +/// Capable-and-agreed for redundancy: the client must have advertised `CLIENT_CAP_AUDIO_RED`, so a +/// session with an older client keeps the plain `0xC9` wire byte-for-byte. +/// +/// **Both halves are then BUDGETED against the session's video bitrate** +/// ([`plan_audio_budget`](punktfunk_core::audio::plan_audio_budget)). Tier `High` and redundancy +/// were introduced separately, each costed as "~1 % of the video budget", and they multiply: +/// 256 kbps stereo sent twice is 512 kbps — ~10 % of a 5 Mbps session. Audio rides QUIC datagrams, +/// outside the ABR loop, so ABR can neither see that nor reclaim it. The budget is what stops a +/// constrained link silently handing a tenth of its bandwidth to audio. +/// +/// The operator's `audio.quality` / `audio.redundancy` settings are the REQUEST; the budget may +/// lower them, never raise them. /// /// NB the plan's "only while the link is actually losing packets" gate is deliberately not here: /// turning redundancy on and off mid-session changes the wire tag, and the client's decoder would -/// have to re-derive which plane it is on from every datagram. The cost being avoided is ~1 % of a -/// video budget, which is not worth that fragility — so the decision is made once, at handshake. -pub(super) fn audio_redundancy(client_caps: u8) -> bool { - if client_caps & punktfunk_core::quic::CLIENT_CAP_AUDIO_RED == 0 { - return false; - } - pf_host_config::config().audio_redundancy.unwrap_or(true) +/// have to re-derive which plane it is on from every datagram. Deciding once, at handshake, against +/// a bitrate we already know is both cheaper and more predictable. +/// `wants_redundancy` is the caller's answer to "is `0xD2` even on the table" — at handshake that +/// is the client's cap AND the operator's setting; afterwards it is the GRANTED +/// `HOST_CAP_AUDIO_RED` bit, so the audio thread re-derives the same rung of the same ladder. +pub(super) fn audio_budget( + wants_redundancy: bool, + video_kbps: u32, + channels: u8, +) -> punktfunk_core::audio::AudioBudget { + let configured = pf_host_config::config().audio_quality.as_deref(); + let requested = match configured { + None => punktfunk_core::audio::AudioTier::default(), + Some(s) => punktfunk_core::audio::AudioTier::parse(s).unwrap_or_else(|| { + // Once per process: this runs per session, and an operator with a typo in host.env + // does not need it on every connect. Never silently downgrade someone's audio. + static WARNED: std::sync::Once = std::sync::Once::new(); + WARNED.call_once(|| { + tracing::warn!( + value = %s, + "audio.quality (PUNKTFUNK_AUDIO_QUALITY) is not one of low/standard/high — \ + using the default" + ); + }); + punktfunk_core::audio::AudioTier::default() + }), + }; + punktfunk_core::audio::plan_audio_budget(video_kbps, channels, requested, wants_redundancy) +} + +/// The operator's answer to "may this session use redundancy at all", before the budget is +/// consulted: the client must be able to decode it and the operator must not have forced it off. +pub(super) fn redundancy_offered(client_caps: u8) -> bool { + client_caps & punktfunk_core::quic::CLIENT_CAP_AUDIO_RED != 0 + && pf_host_config::config().audio_redundancy.unwrap_or(true) } pub(super) fn cursor_forward( @@ -585,10 +622,16 @@ pub(super) async fn negotiate( } else { 0 } - // Redundant desktop-audio plane (0xD2): the client asked, and the operator has not - // forced it off. Capable-and-agreed, like the cursor bit — a client that did not ask - // keeps the plain 0xC9 wire byte-for-byte. - | if audio_redundancy(hello.client_caps) { + // Redundant desktop-audio plane (0xD2): the client asked, the operator has not forced + // it off, AND it fits the session's audio budget. Capable-and-agreed like the cursor + // bit — a client that did not ask keeps the plain 0xC9 wire byte-for-byte. + | if audio_budget( + redundancy_offered(hello.client_caps), + bitrate_kbps, + audio_channels, + ) + .redundancy + { punktfunk_core::quic::HOST_CAP_AUDIO_RED } else { 0 From c6597cbeb547b702d1f40d346c9f749b7a9fc7fb Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 17:58:39 +0200 Subject: [PATCH 14/53] docs(troubleshooting): the audio quality knobs are a request, not a guarantee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page claimed "audio is a fraction of a percent of a stream's bandwidth, so high costs nothing worth counting". At 256 kbps plus redundancy that is 512 kbps — true of a 20 Mbps session, wrong by an order of magnitude on a 5 Mbps one, which is why the budget now exists. Says what actually happens on a narrow link, and points at the log line that reports the settled tier. Co-Authored-By: Claude Opus 5 (1M context) --- docs-site/content/docs/troubleshooting.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs-site/content/docs/troubleshooting.md b/docs-site/content/docs/troubleshooting.md index 9d925b6c..d4e8350f 100644 --- a/docs-site/content/docs/troubleshooting.md +++ b/docs-site/content/docs/troubleshooting.md @@ -339,8 +339,17 @@ PUNKTFUNK_AUDIO_QUALITY=high # low | standard | high (default high — stereo PUNKTFUNK_AUDIO_REDUNDANCY=1 # force the loss-resilient audio plane on (default: automatic) ``` -Audio is a fraction of a percent of a stream's bandwidth, so `high` costs nothing worth counting. -`standard` reproduces the pre-0.25 encoder exactly if you want to compare. +Both are a **request**, not a guarantee: the host budgets audio against the session's video +bitrate and steps it down on a narrow link, because audio is not managed by adaptive bitrate — so +whatever it takes is taken off the top. On a roomy link you get 256 kbps plus loss redundancy; as +the link narrows the host drops redundancy first, then the tier, and never goes below ~96 kbps. The +session log line says what it settled on: + +``` +INFO punktfunk/1 audio streaming … tier=high kbps=512 redundancy=true +``` + +`standard` reproduces the pre-0.25 encoder exactly if you want to A/B it. ## Audio lags behind the picture From 6f54fcdd2de71f2b0bcd24d6d55adeb4d4b93853 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 18:07:07 +0200 Subject: [PATCH 15/53] fix(client/ios): a click wins the pointer back after Escape drops it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing Escape mid-stream on an iPad leaves the capture in a state it could never leave: iPadOS releases the pointer lock by itself, a bare Escape deliberately never clears `captured` (it is a game key), and the re-lock burst added with the Escape-drop fix is the only thing that ever asks for the lock back. That burst fires in the 0.6 s immediately after the platform's own "let me out" gesture — precisely when it is least likely to be granted — and once its budget is spent nothing re-asks: `setCaptured` is the only other requester, and `captured` never went false. The capture then spends the rest of its life on the absolute pointer path, which is why the field report reads the way it does — clicks still land exactly where you aim, because absolute positions keep forwarding, but the game receives no relative deltas and camera look is dead for the rest of the session. Make the click the second stage of the recovery. A click into the video while captured-but-unlocked now re-anchors the lock chain and re-asks, which is the request the platform actually wants: a genuine user gesture rather than an app grabbing the pointer straight back. Asked on the button UP, so the click has fully forwarded on one transport first — asking on the DOWN can flip `gcMouseForwarding` mid-click and strand the release on the GCMouse path. Gated on `pointerLockWasEngaged`, exactly as the drop path is, so a scene that never qualifies (Stage Manager, Split View) is never bursted at, and on no burst already being in flight, since a pending burst mutes absolute motion and re-arming one per click would freeze the cursor between clicks of a menu the user is still aiming around. Worst case is now today's behaviour rather than a permanent one: a refused burst settles, and the next click tries again. Typechecked for arm64-apple-ios17.0 (PunktfunkKit builds clean). NOT yet verified on glass — the premise that a click-driven re-request is honoured is exactly what the previous fix got wrong. --- .../PunktfunkKit/Views/StreamViewIOS.swift | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift index 075e322d..bc75217b 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift @@ -186,6 +186,16 @@ public final class StreamViewController: StreamViewControllerBase { // pointer back to iPadOS, so an unwanted drop is re-requested below. The DELIBERATE releases // (⌘⎋, ⌃⌥⇧Q, the Stream menu, backgrounding) all clear `captured` first, so `wantsPointerLock` // is already false when their drop is observed and none of them are fought here. + // + // Recovery is TWO-STAGE, because either stage alone leaves a hole: + // 1. the burst below, fired the instant the drop is observed — wins back a lock the system + // is willing to return immediately (a transient drop that wasn't Escape at all); + // 2. a CLICK into the video while still captured (`onPointerButton`) — the fallback for the + // Escape case proper, where the platform declines during the moment right after its own + // release gesture and the burst therefore expires having achieved nothing. + // Stage 2 is what keeps a lost burst from being permanent: `captured` is still true, so no + // other path would ever ask again, and the capture would spend the rest of its life on the + // absolute pointer — clicking correctly, aiming not at all. /// Whether this capture ever actually held the lock. Only a lock we HELD is worth winning back /// — never having been granted one means the scene doesn't qualify, not that Esc took it. /// Cleared when capture ends, so each capture starts from a clean slate. @@ -446,6 +456,31 @@ public final class StreamViewController: StreamViewControllerBase { } guard self.inputCapture?.gcMouseForwarding == false else { return } self.inputCapture?.sendMouseButton(button, pressed: down) + // …and if we're captured but NOT locked, this click is also the recovery gesture for an + // Escape-drop the burst lost. iPadOS refuses to re-lock in the moment right after its + // own "let me out" gesture, so the burst fired at the drop can spend its whole budget + // and give up while the capture is still wanted. Nothing else would ever re-ask — + // setCaptured is the only other requester and a bare Esc never clears `captured` — so + // without this the session stays on the absolute path for the rest of the capture: + // clicks still land where you aim (absolute positions keep forwarding) but the game + // gets no relative deltas, so camera look is dead. A click is a real user gesture, + // which is exactly what the platform wants before it will hand the lock back. + // + // On the button UP, so the click has fully forwarded on ONE transport first: asking on + // the DOWN can flip `gcMouseForwarding` mid-click and strand the release on the GCMouse + // path. Gated on `pointerLockWasEngaged` exactly as the drop path is, so a scene that + // never qualifies (Stage Manager, Split View) is never bursted at, and on a burst not + // already being in flight — a pending burst mutes absolute motion, so re-arming one on + // every click of a menu the user is still aiming around would freeze the cursor between + // clicks. Only once it has settled does a further click buy a fresh budget (clearing the + // attempt counter, so a gesture isn't refused inside the 2 s window the drop's own burst + // may have just spent). + if !down, self.wantsPointerLock, self.pointerLockWasEngaged, + !self.pointerRelockPending, self.pointerLockEngaged() != true { + self.pointerRelockAttempt = 0 + self.updatePointerLockChain() // a reparent since the drop would break the walk to us + self.requestPointerRelock() + } } // Scroll is the ONE indirect channel that is NOT gated on the lock. The scroll pan keeps // firing while the scene is pointer-locked (it is the only way trackpad two-finger scrolling From 5e19a4611f58d81ad5496f26af89f8c82828a0c2 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 18:08:49 +0200 Subject: [PATCH 16/53] fix(client/abr): the decode-cap latch fires on the knee's real presentations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client-decoder knee latch (decode_cap_kbps) was unreachable in production — zero "decode cap learned" lines across every field log, while its own doc named the exact sawtooth it exists to end (the 2026-08-03 1440p120 field trace: 220↔450 Mbps for nine minutes, five knee backoffs, no latch): - The ordinary two-bad-window backoff — the knee's most common presentation, a standing 15–45 ms decode rise below the severe tier — carried no decode evidence at decision time, because evidence was judged from the deciding window alone. Worse, the backoff the decode signal itself caused then RESET the knee streak. Now the streak carries its own attribution (streak_decode_windows): a backoff whose bad windows were all decode-flagged is decode evidence. - A cascade's second backoff can never agree with the first: a live host acks the ×0.7 request in ~100 ms, so the second sample always sits at the reduced rate — outside the ±1/8 similarity band by construction (0.7 < 7/8). The canonical test never acked between its backoffs, which is how the premise survived. Now a backoff only samples a rate the controller climbed back to (climb_since_backoff, armed by any ack that raises the rate); a drain-time backoff neither latches nor erases the reference the real knee set. - A keyframe-ask storm on a clean link (the Steam Deck presentation: the overdriven decoder wedges and begs instead of queueing — 14–19 asks at ~300 Mbps with loss_ppm=0 in the field traces) is decode evidence too; with real loss present the asks stay network-attributed. The reworked tests model the ack round-trip (choke → ack → re-climb → choke), including a regression test replaying the field trace's rates and decode figures, which must latch at its second knee encounter. --- crates/punktfunk-core/src/abr.rs | 691 ++++++++++++++++++++++++------- 1 file changed, 536 insertions(+), 155 deletions(-) diff --git a/crates/punktfunk-core/src/abr.rs b/crates/punktfunk-core/src/abr.rs index d0637334..b1aa43b8 100644 --- a/crates/punktfunk-core/src/abr.rs +++ b/crates/punktfunk-core/src/abr.rs @@ -150,11 +150,14 @@ const ENCODE_SEVERE_US: i64 = 12_000; /// the same reason: the decoder's knee moves with content and thermals. const CAP_REPROBE_WINDOWS_MIN: u32 = 16; const CAP_REPROBE_WINDOWS_MAX: u32 = 128; -/// Two consecutive decode-driven backoffs latch the +/// Two decode-driven backoffs latch the /// [`decode cap`](BitrateController::decode_cap_kbps) only when their pre-backoff rates agree /// within ±1/8: the decoder's knee is a RATE, so repeated chokes at the same rate are its /// signature — two unrelated events (a Wi-Fi flush at 300 Mbps, a decode spike at 500) share -/// no knee and must not teach one. +/// no knee and must not teach one. Each sample must come from a rate the controller CLIMBED +/// back to (`climb_since_backoff`) — the knee's real signature is choke, recover, re-climb, +/// choke again at the same place, and only backoffs at a climbed-to rate can agree within the +/// band (a cascade's second backoff sits at ×0.7 of the first: outside it by construction). const DECODE_CAP_SIMILAR_DIV: u32 = 8; /// Rolling window (in 750 ms report windows, ~30 s) whose minimum mean is the OWD baseline. /// Long enough to remember the uncongested floor, short enough to follow genuine path changes. @@ -286,6 +289,20 @@ pub(crate) struct BitrateController { /// decode-driven): the reference the next one must land near ([`DECODE_CAP_SIMILAR_DIV`]) /// to latch the cap — one spurious flush teaches nothing. decode_backoff_kbps: u32, + /// Decode-flagged windows in the CURRENT bad-window streak. The ordinary two-window backoff + /// path is the decoder knee's most common presentation (a standing 15–45 ms decode rise — + /// deep enough to hurt, not deep enough for the severe tier), and judging decode evidence + /// from the FINAL window alone threw that attribution away: the backoff the decode signal + /// itself caused then RESET the knee streak. Counted per bad window, cleared with the streak. + streak_decode_windows: u32, + /// Whether `current_kbps` has RISEN (via an ack — ours or a host-initiated re-target) since + /// the last backoff. A knee sample is only meaningful for a rate the controller climbed to + /// or held; a backoff that fires while the previous backoff's damage is still draining + /// samples a rate the decoder never choked at (the host acks a ×0.7 request in ~100 ms, so + /// a cascade's second backoff ALWAYS sits at the already-reduced rate — dissimilar to the + /// knee by construction, 0.7 < 7/8). Such a backoff neither samples nor erases the + /// reference. + climb_since_backoff: bool, /// Clean windows spent parked at the learned decode cap (its re-probe clock), and that /// clock's own backoff interval — same schedule as the host cap's. decode_cap_probe_windows: u32, @@ -341,6 +358,10 @@ impl BitrateController { cap_reprobe_after: CAP_REPROBE_WINDOWS_MIN, decode_cap_kbps: None, decode_backoff_kbps: 0, + streak_decode_windows: 0, + // The negotiated start rate was held, not drained to — the first backoff ever is a + // legitimate knee sample. + climb_since_backoff: true, decode_cap_probe_windows: 0, decode_cap_reprobe_after: CAP_REPROBE_WINDOWS_MIN, proven_kbps: 0, @@ -433,6 +454,13 @@ impl BitrateController { } } } + if kbps > self.current_kbps { + // The rate ROSE — whatever the pipeline chokes on next, it will choke at a rate + // it was driven up to: a fresh knee sample (see `climb_since_backoff`). An ack'd + // decrease deliberately does not arm this — the drain after a backoff is not a + // knee encounter. + self.climb_since_backoff = true; + } self.current_kbps = kbps; // The host may run ABOVE our climb ceiling, and be right to: it sends an unsolicited // `BitrateChanged` when a rebuild re-resolves an Automatic rate for what it actually @@ -472,6 +500,8 @@ impl BitrateController { self.cap_reprobe_after = CAP_REPROBE_WINDOWS_MIN; self.decode_cap_kbps = None; self.decode_backoff_kbps = 0; + self.streak_decode_windows = 0; + self.climb_since_backoff = true; self.decode_cap_probe_windows = 0; self.owd_means.clear(); self.decode_means.clear(); @@ -571,12 +601,20 @@ impl BitrateController { } if bad { self.bad_windows += 1; + if decode_bad { + // Per-window decode attribution for the streak (see `streak_decode_windows`) — + // scored HERE because at backoff time only the final window's signals are in + // scope, and on the two-window path the first bad window never even reaches a + // decision (the cooldown eats it). + self.streak_decode_windows += 1; + } self.clean_windows = 0; // Any congestion signal ends slow start for good — from here on, climbs are additive. self.probing = false; } else { self.clean_windows += 1; self.bad_windows = 0; + self.streak_decode_windows = 0; } // The learned host cap re-probe (see [`CAP_REPROBE_WINDOWS_MIN`]): after a clean run // parked at the cap, lift it one step (+12.5 %, ceiling-bounded) so a scene-dependent @@ -635,21 +673,42 @@ impl BitrateController { && self.current_kbps > self.floor_kbps { // Decode-cap learning (see [`decode_cap_kbps`](Self::decode_cap_kbps)): a backoff - // with decode-severe evidence — the deep decode excursion, or the flush that - // drained the queue behind a stalled decoder — remembers its pre-backoff rate; the - // SECOND consecutive one at a similar rate latches that rate as the decoder's - // knee. One event never latches (a spurious flush must stay a one-off), and a - // backoff without decode evidence in between breaks the streak — whatever it saw, - // it wasn't the same knee. - // A bare flush counts as decode evidence only where the decode signal can't speak - // for itself. On an embedder that reports decode latency, a flush with FLAT decode - // is a network event (a stall, a clock step) that drained a queue the decoder was - // keeping up with — teaching a "decoder knee" from it caps the session on the wrong - // end of the pipe. Where the signal is absent the old reading stands: the flush is - // the only decoder-saturation evidence there is. - let decode_evidence = - decode_severe || (flushed && (decode_bad || decode_mean_us.is_none())); - if decode_evidence { + // with decode evidence remembers its pre-backoff rate; the next one at a similar + // rate latches that rate as the decoder's knee. One event never latches (a spurious + // flush must stay a one-off), and a decode-free backoff in between breaks the + // streak — whatever it saw, it wasn't the same knee. + // + // Decode evidence, in order: + // - a decode-SEVERE excursion in the deciding window; + // - the ordinary two-window path where EVERY bad window was decode-flagged + // (`streak_decode_windows`) — the knee's most common presentation is a standing + // 15–45 ms rise, below the severe tier, and the deciding window alone can't see + // that the streak it ends was decode's doing; + // - a keyframe-ask storm without meaningful loss: a decoder begging for fresh + // pictures on a clean link is being overdriven, whatever its latency figure says + // (some decoders wedge rather than queue — the Steam Deck presentation). With + // real loss present the asks are network-attributed and teach nothing here; + // - a flush, where the decode signal can't speak against it: on an embedder that + // reports decode latency, a flush with FLAT decode is a network event (a stall, a + // clock step) that drained a queue the decoder was keeping up with — teaching a + // "decoder knee" from it caps the session on the wrong end of the pipe. Where the + // signal is absent the flush is the only decoder-saturation evidence there is. + let decode_evidence = decode_severe + || self.streak_decode_windows >= BAD_WINDOWS_TO_DECREASE + || (recovery_kf >= RECOVERY_KF_BAD && loss_ppm < HEAVY_LOSS_PPM) + || (flushed && (decode_bad || decode_mean_us.is_none())); + if !self.climb_since_backoff { + // Still draining the previous backoff: the host acks a ×0.7 request in ~100 ms, + // so this window's rate is one the decoder never choked at while keeping up — + // its distress is residue of the choke above. Not a knee sample either way: + // neither latch against it nor let it erase the reference the real knee set. + tracing::debug!( + at_kbps = self.current_kbps, + reference_kbps = self.decode_backoff_kbps, + "adaptive bitrate: backoff without an intervening climb — draining the \ + previous choke, not a knee sample" + ); + } else if decode_evidence { let rate = self.current_kbps; let similar = self.decode_backoff_kbps > 0 && rate.abs_diff(self.decode_backoff_kbps) @@ -683,8 +742,10 @@ impl BitrateController { } else { self.decode_backoff_kbps = 0; } + self.climb_since_backoff = false; let next = ((self.current_kbps as u64 * 7 / 10) as u32).max(self.floor_kbps); self.bad_windows = 0; + self.streak_decode_windows = 0; return self.request(next, now); } // Climbs only fire off a UTILIZED clean window (actual delivered ≥ ¾ of the target — the @@ -1945,71 +2006,100 @@ mod tests { assert_eq!(run_clean(&mut c, start, 24, 20), None); } + fn calm_window(c: &mut BitrateController, at: Instant) { + // One calm, unutilized window (2 Mb/s actual): seeds the latency baselines without + // authorizing climbs, and must decide nothing. + assert_eq!( + c.on_window(at, 0, 0, Some(10_000), Some(8_000), None, 2_000, false, 0), + None + ); + } + + /// Drive clean, fully-utilized windows (1 Gb/s actual), acking every climb the controller + /// asks for — a live host answers in ~100 ms — until `current_kbps` reaches `target`. + /// Bounded so a climb-path regression fails loudly instead of spinning. + fn climb_to(c: &mut BitrateController, start: Instant, tick: &mut u32, target: u32) { + for _ in 0..600 { + if c.current_kbps >= target { + return; + } + if let Some(k) = c.on_window( + ticks(start, *tick), + 0, + 0, + Some(10_000), + Some(8_000), + None, + 1_000_000, + false, + 0, + ) { + c.on_ack(k); + } + *tick += 1; + } + panic!( + "no climb to {target} within 600 windows (stuck at {})", + c.current_kbps + ); + } + + /// One decode-SEVERE window (60 ms against the ~8 ms baseline) at the current rate — a + /// knee choke. Steps past the change cooldown first so the decision can fire. + fn choke(c: &mut BitrateController, start: Instant, tick: &mut u32) -> Option { + *tick += 2; + let r = c.on_window( + ticks(start, *tick), + 0, + 0, + Some(10_000), + Some(60_000), + None, + c.current_kbps, + false, + 0, + ); + *tick += 1; + r + } + + /// The latch's only production-reachable shape: choke at the knee, the host ACKS the ×0.7 + /// (a live host answers in ~100 ms, so a cascade's second backoff always sits at the + /// already-reduced rate — dissimilar by construction), the controller climbs back, and the + /// re-climb chokes inside the ±1/8 band. Latches, acks the backoff, returns the cap. + fn latch_knee(c: &mut BitrateController, start: Instant, tick: &mut u32) -> u32 { + for _ in 0..4 { + calm_window(c, ticks(start, *tick)); + *tick += 1; + } + let knee = c.current_kbps; + let r1 = choke(c, start, tick).expect("first choke must back off"); + assert!(c.decode_cap_kbps.is_none(), "one event must not latch"); + c.on_ack(r1); + climb_to(c, start, tick, knee - knee / DECODE_CAP_SIMILAR_DIV); + let rate = c.current_kbps; + let r2 = choke(c, start, tick).expect("re-climb choke must back off"); + assert_eq!(c.decode_cap_kbps, Some(rate - rate / 16)); + c.on_ack(r2); + rate - rate / 16 + } + #[test] - fn decode_cap_latches_after_two_consecutive_decode_severe_backoffs() { + fn decode_cap_latches_when_the_reclimb_chokes_at_the_same_knee() { // The 1440p120 field sawtooth: a decoder knee (~500 Mbps) well under the (inflated) // link ceiling — nothing ever LEARNED the knee, so every re-climb ended in a flush + - // dropped-frame burst. Establish a decode baseline on calm windows, choke twice at the - // same rate, and the second decode-severe backoff must latch the knee. + // dropped-frame burst. Choke, recover, climb back, choke again inside the band: latch. let mut c = BitrateController::new(500_000); c.set_ceiling(900_000); let start = Instant::now(); - // Calm baseline windows (2 Mb/s actual: unutilized, so no climb interferes). - for i in 0..4 { - assert_eq!( - c.on_window( - ticks(start, i), - 0, - 0, - Some(10_000), - Some(8_000), - None, - 2_000, - false, - 0 - ), - None - ); - } - // First deep decode excursion → immediate ×0.7, but ONE event must not latch. - assert_eq!( - c.on_window( - ticks(start, 4), - 0, - 0, - Some(10_000), - Some(60_000), - None, - 490_000, - false, - 0 - ), - Some(350_000) - ); - assert!(c.decode_cap_kbps.is_none()); - // Second consecutive decode-severe backoff at the same pre-backoff rate: latch. - assert_eq!( - c.on_window( - ticks(start, 6), - 0, - 0, - Some(10_000), - Some(60_000), - None, - 490_000, - false, - 0 - ), - Some(350_000) - ); - assert_eq!(c.decode_cap_kbps, Some(500_000 - 500_000 / 16)); - // The backoff applies; from here every climb must stop AT the knee — not the 900 Mbps + let mut t = 0; + latch_knee(&mut c, start, &mut t); + // The latch applies; from here every climb must stop AT the knee — not the 900 Mbps // link ceiling the old sawtooth kept re-poking. - c.on_ack(350_000); let mut max_req = 0; - for i in 8..70 { + for _ in 0..62 { if let Some(k) = c.on_window( - ticks(start, i), + ticks(start, t), 0, 0, Some(10_000), @@ -2030,6 +2120,7 @@ mod tests { max_req = max_req.max(k); c.on_ack(k); } + t += 1; } assert!( max_req < 600_000, @@ -2039,37 +2130,82 @@ mod tests { #[test] fn a_single_flush_or_dissimilar_backoffs_never_latch_a_decode_cap() { - // The latch's false-positive guards. A lone jump-to-live flush (a Wi-Fi clump can - // flush once at ANY rate) backs off but teaches nothing… + // The latch's false-positive guards, every event at a rate the controller climbed to + // or held (drain-time backoffs are no sample at all — + // `cascade_backoffs_neither_sample_nor_erase_the_knee_reference` owns those). A lone + // jump-to-live flush (a Wi-Fi clump can flush once at ANY rate) backs off but teaches + // nothing… let mut c = BitrateController::new(500_000); c.set_ceiling(900_000); let start = Instant::now(); - assert_eq!( - c.on_window(ticks(start, 0), 0, 0, None, None, None, 490_000, true, 0), - Some(350_000) - ); + let mut t = 0; + let r1 = c + .on_window(ticks(start, t), 0, 0, None, None, None, 490_000, true, 0) + .expect("flush must back off"); + assert_eq!(r1, 350_000); assert!(c.decode_cap_kbps.is_none()); - c.on_ack(350_000); - // …a LOSS-driven backoff in between breaks the streak… - assert_eq!( - c.on_window(ticks(start, 2), 1, 0, None, None, None, 340_000, false, 0), - Some(245_000) - ); + c.on_ack(r1); + // …a LOSS-driven backoff at the re-climbed rate breaks the streak (whatever choked + // there, it wasn't the decoder — even inside the similarity band)… + climb_to(&mut c, start, &mut t, 460_000); + t += 2; + let r2 = c + .on_window( + ticks(start, t), + 1, + 0, + None, + None, + None, + c.current_kbps, + false, + 0, + ) + .expect("loss must back off"); + t += 1; assert!(c.decode_cap_kbps.is_none()); - c.on_ack(245_000); + assert_eq!( + c.decode_backoff_kbps, 0, + "a climbed-to non-decode backoff must reset the knee reference" + ); + c.on_ack(r2); // …so the next flush counts as a FIRST decode event again — still no latch… - assert_eq!( - c.on_window(ticks(start, 4), 0, 0, None, None, None, 240_000, true, 0), - Some(171_500) - ); + climb_to(&mut c, start, &mut t, 460_000); + t += 2; + let r3 = c + .on_window( + ticks(start, t), + 0, + 0, + None, + None, + None, + c.current_kbps, + true, + 0, + ) + .expect("flush must back off"); + t += 1; assert!(c.decode_cap_kbps.is_none()); - c.on_ack(171_500); - // …and two consecutive decode events at DISSIMILAR rates (245 vs 171.5 Mbps — no + c.on_ack(r3); + // …and two decode events at DISSIMILAR climbed-to rates (~460 vs ~350 Mbps — no // common knee) must not latch either. - assert_eq!( - c.on_window(ticks(start, 6), 0, 0, None, None, None, 170_000, true, 0), - Some(120_050) - ); + let dissimilar_target = c.current_kbps + 20_000; + climb_to(&mut c, start, &mut t, dissimilar_target); + t += 2; + let _ = c + .on_window( + ticks(start, t), + 0, + 0, + None, + None, + None, + c.current_kbps, + true, + 0, + ) + .expect("flush must back off"); assert!(c.decode_cap_kbps.is_none()); } @@ -2082,38 +2218,14 @@ mod tests { let mut c = BitrateController::new(500_000); c.set_ceiling(900_000); let start = Instant::now(); - for i in 0..4 { + let mut t = 0; + let knee = latch_knee(&mut c, start, &mut t); + // The host parks the session at the knee (an unsolicited re-target up to it — its + // clamp is authoritative). + c.on_ack(knee); + for _ in 0..CAP_REPROBE_WINDOWS_MIN { let _ = c.on_window( - ticks(start, i), - 0, - 0, - Some(10_000), - Some(8_000), - None, - 2_000, - false, - 0, - ); - } - for i in [4, 6] { - let _ = c.on_window( - ticks(start, i), - 0, - 0, - Some(10_000), - Some(60_000), - None, - 490_000, - false, - 0, - ); - } - assert_eq!(c.decode_cap_kbps, Some(500_000 - 500_000 / 16)); - // The host's ack parks the session at the knee (its clamp is authoritative). - c.on_ack(500_000 - 500_000 / 16); - for i in 0..CAP_REPROBE_WINDOWS_MIN { - let _ = c.on_window( - ticks(start, 8 + i), + ticks(start, t), 0, 0, Some(10_000), @@ -2123,8 +2235,8 @@ mod tests { false, 0, ); + t += 1; } - let knee = 500_000 - 500_000 / 16; assert_eq!(c.decode_cap_kbps, Some(knee + knee / 8)); } @@ -2135,38 +2247,307 @@ mod tests { let mut c = BitrateController::new(500_000); c.set_ceiling(900_000); let start = Instant::now(); - for i in 0..4 { - let _ = c.on_window( - ticks(start, i), - 0, - 0, - Some(10_000), - Some(8_000), - None, - 2_000, - false, - 0, - ); - } - for i in [4, 6] { - let _ = c.on_window( - ticks(start, i), - 0, - 0, - Some(10_000), - Some(60_000), - None, - 490_000, - false, - 0, - ); - } - assert_eq!(c.decode_cap_kbps, Some(500_000 - 500_000 / 16)); + let mut t = 0; + let _ = latch_knee(&mut c, start, &mut t); c.on_mode_switch(); assert!(c.decode_cap_kbps.is_none()); assert_eq!(c.ceiling_kbps, 900_000); } + #[test] + fn ordinary_decode_bad_window_pairs_latch_the_knee_field_trace() { + // The 2026-08-03 780M field trace, numbers from the log. The knee's most common + // presentation is a standing ~26 ms decode rise — deep enough for the ordinary + // two-window backoff, below the 45 ms severe tier. Judging evidence from the deciding + // window alone read those backoffs as decode-free and RESET the knee streak each + // time; the session sawtoothed 220↔450 Mbps for its remaining minutes. + let mut c = BitrateController::new(20_000); + c.set_ceiling(657_788); // the log's probe ceiling + let start = Instant::now(); + let mut t = 0; + for _ in 0..4 { + calm_window(&mut c, ticks(start, t)); + t += 1; + } + // A single heavy-loss window ends slow start (as the field session's startup hitch + // did) so the climb below is the additive one the trace shows. + let _ = c.on_window( + ticks(start, t), + 0, + HEAVY_LOSS_PPM, + Some(10_000), + Some(8_000), + None, + 15_000, + false, + 0, + ); + t += 1; + // Choke #1 (00:35:56Z): flush + 40 ms decode at ~417 Mbps — evidence, first sample. + climb_to(&mut c, start, &mut t, 417_277); + let first = c.current_kbps; + t += 2; + let r1 = c + .on_window( + ticks(start, t), + 0, + 0, + Some(8_313), + Some(40_087), + None, + first, + true, + 1, + ) + .expect("flush choke must back off"); + t += 1; + assert!(c.decode_cap_kbps.is_none()); + assert_eq!(c.decode_backoff_kbps, first); + c.on_ack(r1); + // Choke #2 (00:36:32Z): TWO consecutive ~26 ms decode-bad windows at ~446 Mbps — the + // ordinary two-window path, no flush, nothing severe. This is the backoff the old + // evidence gate threw away. + climb_to(&mut c, start, &mut t, 440_000); + let second = c.current_kbps; + t += 2; + assert_eq!( + c.on_window( + ticks(start, t), + 0, + 0, + Some(6_877), + Some(26_474), + None, + second, + false, + 0 + ), + None, + "the first bad window must not decide" + ); + t += 1; + assert_eq!( + c.on_window( + ticks(start, t), + 0, + 0, + Some(6_877), + Some(26_474), + None, + second, + false, + 0 + ), + Some(((second as u64 * 7 / 10) as u32).max(FLOOR_KBPS)) + ); + assert_eq!( + c.decode_cap_kbps, + Some(second - second / 16), + "two decode-bad windows are knee evidence" + ); + } + + #[test] + fn cascade_backoffs_neither_sample_nor_erase_the_knee_reference() { + // Choke at the knee (reference set), the host acks the ×0.7 within ~100 ms, and the + // drain flushes → a second backoff fires at the REDUCED rate. That rate is one the + // decoder never choked at while keeping up — the old code overwrote the reference + // with it (and could never latch from a cascade at all: ×0.7 sits outside the ±1/8 + // band by construction). A drain backoff must neither latch nor erase; the eventual + // re-climb's choke latches against the ORIGINAL sample. + let mut c = BitrateController::new(500_000); + c.set_ceiling(900_000); + let start = Instant::now(); + let mut t = 0; + for _ in 0..4 { + calm_window(&mut c, ticks(start, t)); + t += 1; + } + let r1 = choke(&mut c, start, &mut t).expect("knee choke must back off"); + assert_eq!(c.decode_backoff_kbps, 500_000); + c.on_ack(r1); + t += 2; + let r2 = c + .on_window( + ticks(start, t), + 0, + 0, + Some(10_000), + Some(43_305), + None, + r1, + true, + 1, + ) + .expect("drain flush must back off"); + t += 1; + assert!( + c.decode_cap_kbps.is_none(), + "a drain backoff must not latch" + ); + assert_eq!( + c.decode_backoff_kbps, 500_000, + "…nor erase the knee reference" + ); + c.on_ack(r2); + climb_to(&mut c, start, &mut t, 460_000); + let rate = c.current_kbps; + choke(&mut c, start, &mut t).expect("re-climb choke must back off"); + assert_eq!(c.decode_cap_kbps, Some(rate - rate / 16)); + } + + #[test] + fn keyframe_storms_on_a_clean_link_latch_the_knee() { + // The Steam Deck presentation of the knee: an overdriven decoder that WEDGES instead + // of queueing — decode latency reads absent-to-flat while the client begs for + // keyframes with zero loss (the field traces: 14–19 asks at ~300 Mbps, loss_ppm=0). + // The asks are the decode evidence. + let mut c = BitrateController::new(300_000); + c.set_ceiling(900_000); + let start = Instant::now(); + let mut t = 0; + for _ in 0..4 { + calm_window(&mut c, ticks(start, t)); + t += 1; + } + t += 2; + let r1 = c + .on_window( + ticks(start, t), + 0, + 0, + Some(10_000), + None, + None, + 300_000, + false, + RECOVERY_KF_SEVERE, + ) + .expect("keyframe storm must back off"); + t += 1; + assert!(c.decode_cap_kbps.is_none()); + c.on_ack(r1); + climb_to(&mut c, start, &mut t, 280_000); + let rate = c.current_kbps; + t += 2; + let _ = c + .on_window( + ticks(start, t), + 0, + 0, + Some(10_000), + None, + None, + rate, + false, + RECOVERY_KF_SEVERE, + ) + .expect("second storm must back off"); + assert_eq!(c.decode_cap_kbps, Some(rate - rate / 16)); + } + + #[test] + fn keyframe_storms_with_real_loss_teach_no_knee() { + // The same storm WITH heavy loss is network-attributed (a lost reference forces + // recovery asks; loss_ppm already prices that path): it must not latch, and it must + // break the streak like any other non-decode backoff. + let mut c = BitrateController::new(300_000); + c.set_ceiling(900_000); + let start = Instant::now(); + let mut t = 0; + for _ in 0..4 { + calm_window(&mut c, ticks(start, t)); + t += 1; + } + t += 2; + let r1 = c + .on_window( + ticks(start, t), + 0, + 0, + Some(10_000), + None, + None, + 300_000, + false, + RECOVERY_KF_SEVERE, + ) + .expect("clean storm must back off"); + t += 1; + assert_eq!(c.decode_backoff_kbps, 300_000); + c.on_ack(r1); + climb_to(&mut c, start, &mut t, 280_000); + t += 2; + let _ = c + .on_window( + ticks(start, t), + 0, + SEVERE_LOSS_PPM, + Some(10_000), + None, + None, + c.current_kbps, + false, + RECOVERY_KF_SEVERE, + ) + .expect("lossy storm must back off"); + assert!(c.decode_cap_kbps.is_none()); + assert_eq!( + c.decode_backoff_kbps, 0, + "a loss-attributed storm must reset the knee reference" + ); + } + + #[test] + fn a_mixed_streak_without_decode_attribution_is_no_knee_evidence() { + // Two bad windows, only ONE decode-flagged (OWD carried the other): the backoff is + // not decode-attributed — the reference must reset, not sample. + let mut c = BitrateController::new(500_000); + c.set_ceiling(900_000); + let start = Instant::now(); + let mut t = 0; + for _ in 0..4 { + calm_window(&mut c, ticks(start, t)); + t += 1; + } + t += 2; + assert_eq!( + c.on_window( + ticks(start, t), + 0, + 0, + Some(40_000), + Some(8_000), + None, + 490_000, + false, + 0 + ), + None, + "one OWD-bad window must not decide" + ); + t += 1; + assert_eq!( + c.on_window( + ticks(start, t), + 0, + 0, + Some(10_000), + Some(26_000), + None, + 490_000, + false, + 0 + ), + Some(350_000) + ); + assert!(c.decode_cap_kbps.is_none()); + assert_eq!( + c.decode_backoff_kbps, 0, + "a mixed-attribution backoff must reset the knee reference" + ); + } + #[test] fn ack_silence_disables_the_controller() { let mut c = BitrateController::new(20_000); From e2faecfd42e00910dd03dbb0e257246d54f4c0a4 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 18:14:07 +0200 Subject: [PATCH 17/53] fix(client/android): rumble survives a vibrator fault, and an unplug stops leaking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four faults in the Android feedback path, all of them silent. Rumble stopped for the rest of the session if one vibrator call threw. The poll thread called cancel() unguarded while every call around it was already wrapped, so an unchecked throw — DeadSystemRuntimeException, or the RuntimeException a dying service wraps a RemoteException in — unwound the thread. `running` stayed true, so nothing noticed it was gone and nothing restarted it. Guarding the two bare cancels is not enough on its own: the binder calls that bind a vibrator can throw just the same, so the loop itself now survives a failed render, and the same guard covers the hidout thread. A rumble stop that was never written was treated as one that landed. The DualSense capture disarmed its backstop timer *before* the write, on a queue that discarded failed submits without saying so, so a dropped stop left the motors running with nothing scheduled to try again — and a USB pad holds its last level until told zero. Writes now report whether they were accepted, the backstop is disarmed only once the stop is actually on its way, and the backstop re-arms rather than giving up if its own write is refused. A full write queue dropped lightbar colours, player-LED masks and trigger effects. Its overflow rule was "drop the oldest", which is right for rumble — re-sent continuously, so a lost frame returns milliseconds later — and wrong for everything else, which the host sends once on change and never repeats. Eviction is now driven by an explicit key from the caller rather than by inspecting the bytes: rumble supersedes the pending rumble in place, and a one-shot is discarded only if the queue holds nothing but one-shots. The key cannot be recovered from the report itself, which is why this is not keyed by report id — every DualSense output report carries the *same* id and differs only in its valid_flag bytes, so an id-keyed rule would let a rumble supersede a lightbar, which is this bug again by another route. An unplug leaked the USB connection and the detach receiver. The link only signalled the drop; neither capture released anything, so the interfaces stayed claimed (the pad could not return to Android's own input stack) and a re-plug overwrote the field holding the receiver, stranding one live for the rest of the process. The captures now release the transport, stop() is safe to call from the callback it arrives on — the reader thread must not join itself — and a close is reported exactly once however many detectors see it. A reader that could not queue a single request now reports itself down too, instead of leaving the owner waiting on a capture that never streams. --- .../kotlin/io/unom/punktfunk/kit/DsCapture.kt | 39 ++++-- .../io/unom/punktfunk/kit/GamepadFeedback.kt | 46 +++++-- .../io/unom/punktfunk/kit/HidUsbLink.kt | 123 ++++++++++++------ .../io/unom/punktfunk/kit/OutReportQueue.kt | 89 +++++++++++++ .../io/unom/punktfunk/kit/Sc2Capture.kt | 10 ++ .../unom/punktfunk/kit/OutReportQueueTest.kt | 102 +++++++++++++++ 6 files changed, 353 insertions(+), 56 deletions(-) create mode 100644 clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/OutReportQueue.kt create mode 100644 clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/OutReportQueueTest.kt diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt index 47c2eaa3..bc3e217f 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt @@ -116,7 +116,9 @@ class DsCapture( // The interfaces are about to release with the kernel driver still detached — a // mid-rumble teardown would leave the motors running with nobody to stop them. // EP0-direct (the reader thread is stopping; the queue would never drain). - usb.writeControl(stopReport(m)) + // Nothing can retry after this point, so a failure is worth saying out loud: it is + // the difference between a quiet pad and one that buzzes until it is unplugged. + if (!usb.writeControl(stopReport(m))) Log.w(TAG, "teardown rumble stop was not written") } disarmBackstop() usb.stop() @@ -145,6 +147,9 @@ class DsCapture( val wasActive = model != null model = null releaseSlot() + // Release the transport too: the link only *signals* the drop, so without this an unplug + // left its connection open, its interfaces claimed and its detach receiver registered. + usb.stop() if (wasActive) onActiveChanged?.invoke(false) } @@ -216,17 +221,20 @@ class DsCapture( override fun rumble(pad: Int, low: Int, high: Int, backstopMs: Long) { val m = model ?: return - if (low == 0 && high == 0) { - disarmBackstop() - } else { - armBackstop(backstopMs) - } - if (m == DsDevice.Model.DUALSHOCK4) { + val stop = low == 0 && high == 0 + if (!stop) armBackstop(backstopMs) + val sent = if (m == DsDevice.Model.DUALSHOCK4) { ds4Low = low ds4High = high writeDs4() } else { - usb.writeRaw(0, DsDevice.ds5RumbleReport(m, low, high)) + usb.writeRaw(0, DsDevice.ds5RumbleReport(m, low, high), OutReportQueue.KEY_RUMBLE) + } + if (stop) { + // Disarm only once the stop is actually on its way. Dropping the net *before* the + // write — as this used to — meant a discarded stop left the motors running with + // nothing scheduled to try again; a USB pad holds its last level until told zero. + if (sent) disarmBackstop() else armBackstop(STOP_RETRY_MS) } } @@ -252,6 +260,9 @@ class DsCapture( usb.writeRaw(0, DsDevice.ds5TriggerReport(m, which, effect)) } + // Coalescable: the DS4's write is full-state (motors AND lightbar, rebuilt from the current + // fields on every call), so a newer one supersedes an older one wholesale — nothing is lost by + // collapsing a backlog of them down to the last. private fun writeDs4() = usb.writeRaw( 0, DsDevice.ds4Report( @@ -261,6 +272,7 @@ class DsCapture( (ds4Rgb shr 8) and 0xFF, ds4Rgb and 0xFF, ), + OutReportQueue.KEY_RUMBLE, ) /** The report that stops the motors. The DS4's is a full-state write, so it zeroes the @@ -284,7 +296,12 @@ class DsCapture( backstop?.let { mainHandler.removeCallbacks(it) } val r = Runnable { backstop = null - model?.let { usb.writeRaw(0, stopReport(it)) } + val m = model ?: return@Runnable + // The net itself can be refused (a full queue, a connection going away). Re-arm rather + // than give up: this is the last thing between a stalled poll thread and a pad that + // buzzes until it is unplugged. It stops re-arming as soon as the link closes, which + // clears `model` and disarms. + if (!usb.writeRaw(0, stopReport(m), OutReportQueue.KEY_RUMBLE)) armBackstop(STOP_RETRY_MS) } backstop = r mainHandler.postDelayed(r, ms.coerceAtLeast(1)) @@ -297,5 +314,9 @@ class DsCapture( private companion object { const val TAG = "DsCapture" + + /** How soon to retry a rumble stop whose write was rejected. Short: the motors are running + * and the host has already moved on, so nothing else is coming to silence them. */ + const val STOP_RETRY_MS = 100L } } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt index 63d22d57..50a19e33 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt @@ -88,6 +88,9 @@ class GamepadFeedback( const val TAG_PLAYER_LEDS: Byte = 0x02 const val TAG_TRIGGER: Byte = 0x03 const val TAG_HID_RAW: Byte = 0x05 + + /** Sparse-log cadence for swallowed render failures — see [noteRenderFailure]. */ + const val LOG_EVERY = 128L } /** One controller's rumble binding — VibratorManager (API 31+) OR the legacy single Vibrator (API 28–30). */ @@ -125,6 +128,7 @@ class GamepadFeedback( fun start() { running = true rumbleThread = Thread({ + var failures = 0L while (running) { val ev = NativeBridge.nativeNextRumble(handle) if (ev < 0L) continue // timeout / closed @@ -136,26 +140,50 @@ class GamepadFeedback( // the backstop (the hardware net under a stalled poll thread). val pad = ((ev ushr 49) and 0xFL).toInt() val backstopMs = ((ev ushr 32) and 0xFFFF) - renderRumble( - pad, - ((ev ushr 16) and 0xFFFF).toInt(), - (ev and 0xFFFF).toInt(), - backstopMs, - ) + // Rendering is binder calls into the vibrator service, and every one of them can + // throw unchecked — DeadSystemRuntimeException when system_server goes down, and + // the ordinary RuntimeException a dying service wraps its RemoteException in. + // Unguarded, ONE of those killed this thread outright: `running` stayed true, so + // nothing noticed and nothing restarted it, and rumble was gone for the rest of + // the session. Losing a single command is recoverable; losing the loop is not. + runCatching { + renderRumble( + pad, + ((ev ushr 16) and 0xFFFF).toInt(), + (ev and 0xFFFF).toInt(), + backstopMs, + ) + }.onFailure { failures = noteRenderFailure("rumble", it, failures) } } }, "pf-rumble").apply { isDaemon = true; start() } hidoutThread = Thread({ // 128: the raw as-is passthrough events are [pad][kind tag][report kind][≤64 bytes]. val buf = ByteBuffer.allocateDirect(128) + var failures = 0L while (running) { val n = NativeBridge.nativeNextHidout(handle, buf) if (n < 0) continue // timeout / closed - dispatchHidout(buf, n) + // Same hazard as the rumble loop above: lights/trigger rendering is binder and USB + // calls, and an unchecked throw here would silently end the rich-feedback plane. + runCatching { dispatchHidout(buf, n) } + .onFailure { failures = noteRenderFailure("hidout", it, failures) } } }, "pf-hidout").apply { isDaemon = true; start() } } + /** + * Record a render failure the poll loop swallowed, and return the updated count. Logged on the + * first occurrence and sparsely after: a genuinely dead vibrator service fails on *every* + * command, which at a rumble plane's rate would bury the log. + */ + private fun noteRenderFailure(plane: String, t: Throwable, seen: Long): Long { + if (seen == 0L || seen % LOG_EVERY == 0L) { + Log.w(TAG, "$plane render failed (#${seen + 1}) — command dropped, poll loop alive", t) + } + return seen + 1 + } + /** Idempotent. Stops + joins the poll threads (must complete before the router is released / handle freed). */ fun stop() { running = false @@ -269,7 +297,7 @@ class GamepadFeedback( val m = bind.vm if (m != null) { if (lo == 0 && hi == 0) { - m.cancel() // (0,0) = stop + runCatching { m.cancel() } // (0,0) = stop return } val combo = CombinedVibration.startParallel() @@ -294,7 +322,7 @@ class GamepadFeedback( // API 28–30 legacy single-motor path: blend both motors into one effect. val lv = bind.legacy ?: return if (lo == 0 && hi == 0) { - lv.cancel() // (0,0) = stop + runCatching { lv.cancel() } // (0,0) = stop return } val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255) diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/HidUsbLink.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/HidUsbLink.kt index 5a6b96e5..869d9192 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/HidUsbLink.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/HidUsbLink.kt @@ -14,8 +14,8 @@ import android.hardware.usb.UsbRequest import android.os.Build import android.util.Log import java.nio.ByteBuffer -import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.TimeoutException +import java.util.concurrent.atomic.AtomicBoolean /** * Generic USB transport for a client-captured HID controller — the device-agnostic half of what @@ -81,14 +81,20 @@ class HidUsbLink( /** Pending OUT reports, submitted by the reader thread — only one thread may drive a * connection's [UsbRequest]s ([UsbDeviceConnection.requestWait] returns ANY completed - * request; a second waiter would steal the reader's completions). */ - private val outQueue = ConcurrentLinkedQueue() + * request; a second waiter would steal the reader's completions). See [OutReportQueue] for + * what gets discarded when it fills, and why that is not simply "the oldest". */ + private val outQueue = OutReportQueue() private var reader: Thread? = null private var detachReceiver: BroadcastReceiver? = null @Volatile private var running = false + /** Latches on the first "this link is down" signal so [onClosed] fires exactly once, however + * many of the racing detectors (detach broadcast, reader error streak, failed re-queue) see + * it. Reset by [start]. */ + private val down = AtomicBoolean(false) + /** First attached matching device, or null. Does not need USB permission to enumerate. */ fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull(config.deviceMatch) @@ -114,6 +120,7 @@ class HidUsbLink( connection = conn device = dev claims = claimed + down.set(false) running = true Log.i( config.tag, @@ -134,10 +141,7 @@ class HidUsbLink( val gone: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE) if (gone?.deviceName == dev.deviceName) { Log.i(config.tag, "USB detached (${dev.deviceName})") - if (running) { - running = false - onClosed() - } + linkDown() } } } @@ -221,6 +225,9 @@ class HidUsbLink( if (live.isEmpty()) { Log.e(config.tag, "no IN request could be queued") finishReader(claims) + // `start` already returned true, so without this the owner would sit waiting on a + // capture that never streams and never reports itself dead. + linkDown() return } val scratch = ByteArray(64) @@ -295,10 +302,23 @@ class HidUsbLink( } finally { finishReader(claims) } - if (running) { - running = false - onClosed() - } + linkDown() + } + + /** + * Report the link down, exactly once, from whichever detector noticed first — the detach + * broadcast (main thread) or the reader thread on its way out. + * + * This only *signals*; releasing the connection and the interfaces stays the owner's job, via + * the [stop] its `onClosed` handler calls. Previously nothing released them on this path: the + * detach receiver flipped a flag and fired the callback, so an unplug left the connection open, + * the interfaces claimed (the pad could not return to Android's own input stack) and the + * receiver still registered — and a re-plug overwrote the field holding it, leaking a receiver + * that stayed live for the process's lifetime. + */ + private fun linkDown() { + running = false + if (down.compareAndSet(false, true)) onClosed() } private fun finishReader(claims: List) { @@ -314,28 +334,35 @@ class HidUsbLink( * Write one raw report to the device: kind 0 = output report (the active interface's * interrupt-OUT, else a `SET_REPORT(Output)` control transfer), kind 1 = feature report * (`SET_REPORT(Feature)`). [data] is the full report, id byte first, hidapi framing. + * + * [coalesce] tells the pending-OUT queue whether a newer report of the same kind may replace + * this one — [OutReportQueue.KEY_RUMBLE] for motor levels, the default [OutReportQueue.NO_COALESCE] + * for one-shots (lightbar, player LEDs, trigger effects) the sender will not repeat. + * + * Returns whether the report reached the device or is queued for it. A caller that is writing + * a **stop** needs this: a discarded stop has nothing behind it, so it must not be mistaken + * for one that landed. */ - fun writeRaw(kind: Int, data: ByteArray) { - if (data.isEmpty()) return - when (kind) { + fun writeRaw(kind: Int, data: ByteArray, coalesce: Int = OutReportQueue.NO_COALESCE): Boolean { + if (data.isEmpty()) return false + return when (kind) { 0 -> { if ((activeClaim ?: claims.firstOrNull())?.outReq != null) { - // Interrupt-OUT rides UsbRequests submitted by the reader thread. Bounded, - // newest-wins: these are level-styled commands the sender re-sends anyway. - while (outQueue.size >= 32) outQueue.poll() - outQueue.offer(data) + // Interrupt-OUT rides UsbRequests submitted by the reader thread. + outQueue.offer(data, coalesce) } else { setReport(REPORT_TYPE_OUTPUT, data) } } 1 -> setReport(REPORT_TYPE_FEATURE, data) + else -> false } } - private fun setReport(type: Int, data: ByteArray) { - val conn = connection ?: return - val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return - sendReport(conn, ifId, type, data) + private fun setReport(type: Int, data: ByteArray): Boolean { + val conn = connection ?: return false + val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return false + return sendReport(conn, ifId, type, data) } /** @@ -344,9 +371,8 @@ class HidUsbLink( * queue would never drain (e.g. a rumble stop before the interfaces release). Safe from any * thread: EP0 control transfers are independent of the reader's `requestWait`. */ - fun writeControl(data: ByteArray) { - if (data.isNotEmpty()) setReport(REPORT_TYPE_OUTPUT, data) - } + fun writeControl(data: ByteArray): Boolean = + data.isNotEmpty() && setReport(REPORT_TYPE_OUTPUT, data) private fun sendKeepAlive(conn: UsbDeviceConnection, ifaceId: Int) { for (f in config.keepAliveFeatures) sendReport(conn, ifaceId, REPORT_TYPE_FEATURE, f) @@ -358,27 +384,48 @@ class HidUsbLink( * "unnumbered" (id 0 in wValue, id byte stripped from the payload). EP0 is independent of * the interrupt endpoints, so this is safe alongside the reader thread's requestWait. */ - private fun sendReport(conn: UsbDeviceConnection, ifaceId: Int, type: Int, data: ByteArray) { + private fun sendReport( + conn: UsbDeviceConnection, + ifaceId: Int, + type: Int, + data: ByteArray, + ): Boolean { val id = data[0].toInt() and 0xFF val payload = if (id == 0) data.copyOfRange(1, data.size) else data - conn.controlTransfer( - 0x21, // host→device, class, interface - 0x09, // SET_REPORT - (type shl 8) or id, - ifaceId, - payload, - payload.size, - WRITE_TIMEOUT_MS, - ) + // controlTransfer returns the byte count, or a negative value on failure — a failed write + // must be reported as such, not swallowed (a dropped rumble stop has nothing behind it). + val n = runCatching { + conn.controlTransfer( + 0x21, // host→device, class, interface + 0x09, // SET_REPORT + (type shl 8) or id, + ifaceId, + payload, + payload.size, + WRITE_TIMEOUT_MS, + ) + }.getOrDefault(-1) + return n >= 0 } - /** Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed]. */ + /** + * Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed]. + * + * Safe to call from the `onClosed` handler itself — that is how an unplug now gets cleaned up, + * and it arrives on the reader thread, which must not try to join itself. + */ fun stop() { running = false + // Claim the down-latch so the reader's own exit does not report a close the owner asked for. + down.set(true) detachReceiver?.let { runCatching { context.unregisterReceiver(it) } } detachReceiver = null - runCatching { reader?.join(1000) } - reader = null + if (reader !== Thread.currentThread()) { + runCatching { reader?.join(1000) } + // Only forget the thread once it is actually gone: clearing it while it still runs + // would let a later stop() skip the join and free the connection under it. + reader = null + } outQueue.clear() activeClaim = null for (c in claims) runCatching { connection?.releaseInterface(c.iface) } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/OutReportQueue.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/OutReportQueue.kt new file mode 100644 index 00000000..e1b81361 --- /dev/null +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/OutReportQueue.kt @@ -0,0 +1,89 @@ +package io.unom.punktfunk.kit + +/** + * The pending interrupt-OUT reports for a captured controller: a bounded FIFO whose overflow + * policy knows which reports may be thrown away and which may not. + * + * The queue exists because only one thread may drive a connection's `UsbRequest`s, so writes from + * the feedback threads are handed to the reader thread rather than submitted directly. It has to + * be bounded — a stalled or unplugged device would otherwise grow it without limit — and the + * question is what to discard when it fills. + * + * The old policy was "newest wins": drop from the head until there is room. That is right for + * rumble, which is *level-styled* — the host re-sends it continuously, so a dropped frame is + * replaced milliseconds later and nothing is permanently lost. It is wrong for everything else. + * A lightbar colour, a player-LED mask and an adaptive-trigger effect are **one-shots**: the host + * sends them on change and never repeats them. Dropping one leaves the pad wrong until the next + * time that value happens to change, which may be never. + * + * So eviction is driven by an explicit [key] supplied by the caller, not by inspecting the bytes. + * That distinction cannot be recovered from the report itself: every DualSense output report + * carries the *same* report id and differs only in its `valid_flag` bytes, so an id-keyed policy + * would happily let a rumble supersede a lightbar — the very bug this replaces, relocated. + * + * Two rules: + * - A report offered with a coalescing key **replaces** the pending report with that key, in + * place. A burst of rumble collapses to its latest value and never displaces anything else. + * - Only when the queue is full does anything get dropped, and then the oldest *coalescable* + * report goes first. A one-shot is discarded only if the queue is full of nothing but + * one-shots — which needs [cap] distinct one-shots outstanding, far beyond what a real pad + * produces. + * + * Thread-safe: offered by the feedback threads, drained by the reader thread. + */ +internal class OutReportQueue(private val cap: Int = CAP) { + private class Entry(val key: Int, val data: ByteArray) + + private val items = ArrayDeque() + + /** + * Queue [data] for submission. [key] is [NO_COALESCE] for a one-shot, or a caller-chosen + * constant identifying a level-styled stream whose newer values supersede older ones. + * + * Returns false only if the report had to be dropped outright — the caller can then treat the + * write as failed rather than assuming it is on its way. + */ + fun offer(data: ByteArray, key: Int = NO_COALESCE): Boolean = synchronized(items) { + if (key != NO_COALESCE) { + val at = items.indexOfFirst { it.key == key } + if (at >= 0) { + // Supersede in place: keeping the queue position stops a fast rumble stream from + // repeatedly jumping the one-shots queued ahead of it. + items[at] = Entry(key, data) + return true + } + } + if (items.size >= cap) { + val victim = items.indexOfFirst { it.key != NO_COALESCE } + if (victim >= 0) { + items.removeAt(victim) + } else if (key != NO_COALESCE) { + // Nothing coalescable to sacrifice and this report is itself replaceable — drop it + // rather than a one-shot that will never come again. + return false + } else { + items.removeFirst() + } + } + items.addLast(Entry(key, data)) + return true + } + + /** The next report to submit, or null when nothing is pending. */ + fun poll(): ByteArray? = synchronized(items) { items.removeFirstOrNull()?.data } + + fun clear() = synchronized(items) { items.clear() } + + val size: Int get() = synchronized(items) { items.size } + + companion object { + /** This report is a one-shot: never superseded, evicted only as a last resort. */ + const val NO_COALESCE = 0 + + /** Motor levels — re-sent continuously, so only the newest is worth keeping. */ + const val KEY_RUMBLE = 1 + + /** Deep enough to absorb a burst, small enough that a stalled device cannot bloat us. */ + const val CAP = 32 + } +} diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Sc2Capture.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Sc2Capture.kt index 83c4462f..e84fb2e4 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Sc2Capture.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Sc2Capture.kt @@ -273,10 +273,20 @@ class Sc2Capture( private fun onLinkClosed() { Log.i(TAG, "SC2 link closed (unplug / power-off)") + // Both transports share this callback, so read which one was live BEFORE clearing it — + // releasing the other would tear down a link that never dropped. + val dropped = activeLink activeLink = LINK_NONE dongleLink = false releaseSlot() releaseUiKeys() + // Release the transport too — see the note in DsCapture.onLinkClosed. The Puck makes this + // worse than a single leak: it is the pad that gets power-cycled, so the same process can + // round-trip a link many times in one session. + when (dropped) { + LINK_USB -> usb.stop() + LINK_BLE -> ble.stop() + } onActiveChanged?.invoke(false) } diff --git a/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/OutReportQueueTest.kt b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/OutReportQueueTest.kt new file mode 100644 index 00000000..0ee8ee16 --- /dev/null +++ b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/OutReportQueueTest.kt @@ -0,0 +1,102 @@ +package io.unom.punktfunk.kit + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The pending-OUT queue's overflow policy. What is being pinned here is the distinction the old + * "drop from the head until there is room" policy did not make: rumble is re-sent continuously and + * may be thrown away, while a lightbar/player-LED/trigger report is sent once and never repeated. + */ +class OutReportQueueTest { + /** A report carrying a 0..255 marker so a test can tell which one came back out. */ + private fun report(marker: Int) = byteArrayOf(0x02, marker.toByte()) + + // Masked: the marker rides in a Byte, and Byte.toInt() sign-extends. + private fun drain(q: OutReportQueue): List = + generateSequence { q.poll() }.map { it[1].toInt() and 0xFF }.toList() + + @Test + fun `rumble supersedes the pending rumble instead of queueing another`() { + val q = OutReportQueue() + assertTrue(q.offer(report(1), OutReportQueue.KEY_RUMBLE)) + assertTrue(q.offer(report(2), OutReportQueue.KEY_RUMBLE)) + assertTrue(q.offer(report(3), OutReportQueue.KEY_RUMBLE)) + assertEquals("a rumble burst must collapse to one entry", 1, q.size) + assertArrayEquals(report(3), q.poll()) + assertNull(q.poll()) + } + + @Test + fun `superseding keeps the queue position so a rumble stream cannot jump one-shots`() { + val q = OutReportQueue() + q.offer(report(1), OutReportQueue.KEY_RUMBLE) + q.offer(report(10)) // a one-shot queued behind it + q.offer(report(2), OutReportQueue.KEY_RUMBLE) + // The newer rumble takes the OLD rumble's slot, so the one-shot does not get starved + // behind an endlessly-renewed entry. + assertEquals(listOf(2, 10), drain(q)) + } + + @Test + fun `a full queue sacrifices rumble, never a one-shot`() { + val q = OutReportQueue(cap = 4) + q.offer(report(1), OutReportQueue.KEY_RUMBLE) + q.offer(report(10)) + q.offer(report(11)) + q.offer(report(12)) + assertEquals(4, q.size) + // Full. The old policy dropped the head — here that is a rumble, but only by luck of + // ordering; what matters is that the one-shots all survive. + assertTrue(q.offer(report(13))) + assertEquals(listOf(10, 11, 12, 13), drain(q)) + } + + @Test + fun `the one-shot the host never repeats survives a rumble storm`() { + val q = OutReportQueue(cap = 4) + // The exact regression: a lightbar colour queued once, then a flood of rumble. Under the + // old newest-wins eviction the colour was dropped from the head and never came back, + // leaving the pad lit wrong until the value next happened to change. + q.offer(report(200)) // lightbar + repeat(50) { q.offer(report(it), OutReportQueue.KEY_RUMBLE) } + val out = drain(q) + assertTrue("the lightbar report must still be queued, got $out", out.contains(200)) + assertEquals("rumble must not have accumulated", listOf(200, 49), out) + } + + @Test + fun `a queue full of one-shots refuses a rumble rather than dropping one`() { + val q = OutReportQueue(cap = 2) + q.offer(report(10)) + q.offer(report(11)) + assertFalse( + "with nothing coalescable to sacrifice, the replaceable report yields", + q.offer(report(1), OutReportQueue.KEY_RUMBLE), + ) + assertEquals(listOf(10, 11), drain(q)) + } + + @Test + fun `only a queue of nothing but one-shots drops one, and it is the oldest`() { + val q = OutReportQueue(cap = 2) + q.offer(report(10)) + q.offer(report(11)) + assertTrue(q.offer(report(12))) + assertEquals(listOf(11, 12), drain(q)) + } + + @Test + fun `clear empties the queue`() { + val q = OutReportQueue() + q.offer(report(1), OutReportQueue.KEY_RUMBLE) + q.offer(report(10)) + q.clear() + assertEquals(0, q.size) + assertNull(q.poll()) + } +} From 66a28d5abb5a4db604a95d0183ac6923d5e73faf Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 18:14:13 +0200 Subject: [PATCH 18/53] ci(android): run the kit's unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit They were running nowhere. This workflow only assembled, and the screenshot workflow runs the :app module's tests, so nothing enforced :kit's — the pure parsers, migrations and feedback policies could go red without anyone noticing. A couple of seconds against a module the build already produces. --- .gitea/workflows/android.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.gitea/workflows/android.yml b/.gitea/workflows/android.yml index 87635c67..43fe3cb9 100644 --- a/.gitea/workflows/android.yml +++ b/.gitea/workflows/android.yml @@ -160,6 +160,14 @@ jobs: key: gradle-${{ hashFiles('clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }} restore-keys: gradle- + # The kit's JVM unit tests — the pure parsers, migrations and feedback policies. They were + # running nowhere: this workflow only assembled, and android-screenshots.yml runs the :app + # module's tests, so nothing enforced :kit's. Cheap (a couple of seconds against an already + # built module) and it is the only automated cover those behaviours have. + - name: kit unit tests + working-directory: clients/android + run: ./gradlew :kit:testDebugUnitTest --stacktrace + - name: assembleDebug (cargo-ndk → jniLibs → APK) working-directory: clients/android env: From 2dfb7791a2ad026eb33f2a6916bf16b66c55bbd7 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 18:14:24 +0200 Subject: [PATCH 19/53] fix(apple): the drift test tripped Swift's static exclusivity check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught what my local harness could not: reading `huge.count` inside the closure that already holds `huge` exclusively is an exclusivity violation, so PunktfunkKitTests failed to compile. The blind spot is worth recording. I verified `AudioRing` by compiling it against a standalone harness whose bodies were TOP-LEVEL code, where Swift applies DYNAMIC exclusivity — the same statement in a function body gets the static check and is a hard error. A harness that does not share the shape of the thing it stands in for can be green for a reason the real build does not have. The harness now puts every body in a method and compiles with `-enforce-exclusivity=checked`. Length now comes off the buffer pointer (`$0.count`), which is what the closure already owns. Co-Authored-By: Claude Opus 5 (1M context) --- .../apple/Tests/PunktfunkKitTests/AudioRingDriftTests.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/clients/apple/Tests/PunktfunkKitTests/AudioRingDriftTests.swift b/clients/apple/Tests/PunktfunkKitTests/AudioRingDriftTests.swift index 70f3b56f..a86949dd 100644 --- a/clients/apple/Tests/PunktfunkKitTests/AudioRingDriftTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/AudioRingDriftTests.swift @@ -79,9 +79,11 @@ final class AudioRingDriftTests: XCTestCase { scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) } XCTAssertTrue(scratch.contains { $0 != 0 }, "should be playing after priming") - // Drain it dry with one oversized read, then feed a normal quantum again. + // Drain it dry with one oversized read, then feed a normal quantum again. The length comes + // off the buffer pointer, not off `huge`: touching the array inside the closure that is + // already holding it exclusively is an exclusivity violation. var huge = [Float](repeating: 0, count: 200 * perMS) - huge.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: huge.count) } + huge.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: $0.count) } let feed = [Float](repeating: 0.5, count: want) feed.withUnsafeBufferPointer { ring.write($0.baseAddress!, count: want) } scratch.withUnsafeMutableBufferPointer { ring.read(into: $0.baseAddress!, count: want) } From 4bc7eecf05ba95fdf9bc470a15f36120078b5970 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 18:30:33 +0200 Subject: [PATCH 20/53] feat(host/wire): MTU resilience for the video data plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Video datagrams are sealed at a shard payload sized for a clean 1500-byte MTU (1472-byte UDP payloads). A host whose route to the client crosses a smaller-MTU hop (a VPN/overlay adapter claiming the LAN route, a lowered NIC MTU) delivers every small flow — QUIC control, hole punch, input, audio — while 100% of video datagrams die: the client sits on a black screen reporting zero loss and the host streams into the void with every gauge green. Field-reported as 'connects fine, black screen forever'. Three legs, none of which changes a session on a healthy path: - PUNKTFUNK_WIRE_MTU operator override: shard payload derived from a given on-wire IP MTU. Wire-compatible — Welcome::shard_payload is already negotiated per session (the v4/v6 split ships two values today) and every client follows the negotiated value. - Detection: the QUIC MTU-discovery probe ceiling moves from quinn's stock 1452 to exactly the sealed video-datagram size (1472), so a control connection's settled MTU becomes a verdict on the path: settled at the ceiling proves it carries video, settled below proves it cannot. A per-session watcher samples after the search has settled (live-connection guard against mid-search false learns) and logs an actionable WARN naming the failure shape and the diagnosis commands. - Healing: the measured budget is recorded per peer IP; the next handshake clamps shard_payload to fit, so a reconnect self-heals. A later session that reaches the ceiling erases the record. Verified: core 286/286 --features quic + clippy -D warnings (macOS); host clippy -D warnings + native:: tests 44/44 (pf-lxcheck container). The regenerated C header picks up the new MIN_SHARD_PAYLOAD constant. --- crates/punktfunk-core/src/config.rs | 112 ++++++++++ crates/punktfunk-core/src/quic/endpoint.rs | 14 ++ crates/punktfunk-host/src/native.rs | 7 +- crates/punktfunk-host/src/native/handshake.rs | 11 +- crates/punktfunk-host/src/native/wire_mtu.rs | 192 ++++++++++++++++++ include/punktfunk_core.h | 6 + 6 files changed, 338 insertions(+), 4 deletions(-) create mode 100644 crates/punktfunk-host/src/native/wire_mtu.rs diff --git a/crates/punktfunk-core/src/config.rs b/crates/punktfunk-core/src/config.rs index 8018c4f0..1c0d2af7 100644 --- a/crates/punktfunk-core/src/config.rs +++ b/crates/punktfunk-core/src/config.rs @@ -341,6 +341,50 @@ pub fn mtu1500_shard_payload_for(peer: core::net::IpAddr) -> usize { } } +/// Floor for a negotiated `shard_payload` (even, well under every real path). A path whose UDP +/// budget lands below this can't carry the QUIC control plane either (QUIC's own minimum is a +/// 1200-byte UDP payload), so shrinking video shards further buys nothing — the clamp helpers +/// bottom out here instead of producing degenerate confetti-sized shards. +pub const MIN_SHARD_PAYLOAD: usize = 512; + +/// The sealed wire size of a video datagram carrying `shard_payload` bytes of shard — what +/// actually leaves the socket as UDP payload (punktfunk header + shard + crypto overhead). +pub const fn sealed_datagram_bytes(shard_payload: usize) -> usize { + HEADER_LEN + shard_payload + CRYPTO_OVERHEAD +} + +/// The UDP-payload size a path must carry for full-size IPv4 video datagrams: the sealed size +/// of the [`mtu1500_shard_payload`] default (= 1472, the exact 1500-MTU IPv4 ceiling). Doubles +/// as the QUIC MTU-discovery probe ceiling (`quic/endpoint.rs`): with the ceiling set to +/// exactly this value, a control connection whose discovery settles AT the ceiling has proven +/// the path carries full-size video datagrams, and one that settles BELOW it has proven the +/// path cannot — a discrimination quinn's stock 1452 ceiling can't make in either direction. +pub const fn video_datagram_udp_ceiling() -> usize { + sealed_datagram_bytes(mtu1500_shard_payload()) +} + +/// Largest even shard payload whose sealed datagram fits in `udp_budget` bytes of UDP payload +/// (the quantity QUIC MTU discovery measures — [`video_datagram_udp_ceiling`] is its probe +/// ceiling). Clamped to the peer's family default ([`mtu1500_shard_payload_for`]) so a generous +/// budget never grows packets past today's wire, and floored at [`MIN_SHARD_PAYLOAD`]. +pub fn shard_payload_for_udp_budget(udp_budget: usize, peer: core::net::IpAddr) -> usize { + let p = udp_budget.saturating_sub(HEADER_LEN + CRYPTO_OVERHEAD); + let p = p - p % 2; // FEC requires even shards + p.clamp(MIN_SHARD_PAYLOAD, mtu1500_shard_payload_for(peer)) +} + +/// [`shard_payload_for_udp_budget`] for an operator-supplied ON-WIRE IP MTU (the number +/// `netsh interface ipv4 show subinterfaces` / `ip link` shows): subtracts the family's IP+UDP +/// headers first — 28 for IPv4 (and IPv4-mapped), 48 for IPv6. +pub fn shard_payload_for_wire_mtu(wire_mtu: usize, peer: core::net::IpAddr) -> usize { + let ip_udp = match peer { + core::net::IpAddr::V4(_) => 28, + core::net::IpAddr::V6(v6) if v6.to_ipv4_mapped().is_some() => 28, + core::net::IpAddr::V6(_) => 48, + }; + shard_payload_for_udp_budget(wire_mtu.saturating_sub(ip_udp), peer) +} + /// Everything needed to construct a [`Session`](crate::session::Session). /// /// `Debug` is implemented by hand to redact `key`/`salt`, and `key`/`salt` are zeroized @@ -514,6 +558,74 @@ mod tests { assert!(HEADER_LEN + (p + 2) + CRYPTO_OVERHEAD > 1452, "not maximal"); } + /// The video-datagram ceiling IS the exact v4 sealed size — the QUIC MTU-discovery probe + /// ceiling (endpoint.rs) relies on this equality for its settled-at-vs-below verdict. + #[test] + fn video_datagram_ceiling_is_the_sealed_default() { + assert_eq!( + video_datagram_udp_ceiling(), + HEADER_LEN + mtu1500_shard_payload() + CRYPTO_OVERHEAD + ); + assert_eq!(video_datagram_udp_ceiling(), 1472); + } + + /// Budget-derived sizing: even, sealed-fits-the-budget, clamped to the family default + /// above and [`MIN_SHARD_PAYLOAD`] below. + #[test] + fn shard_payload_for_udp_budget_math() { + use core::net::IpAddr; + let v4: IpAddr = "192.168.1.50".parse().unwrap(); + let v6: IpAddr = "fd00::50".parse().unwrap(); + // The full ceiling reproduces the default exactly. + assert_eq!( + shard_payload_for_udp_budget(video_datagram_udp_ceiling(), v4), + mtu1500_shard_payload() + ); + // A WARP/Tailscale-shaped 1280 budget: sealed result must fit the budget, stay even. + let p = shard_payload_for_udp_budget(1280, v4); + assert_eq!(p % 2, 0); + assert!(sealed_datagram_bytes(p) <= 1280); + assert!(sealed_datagram_bytes(p + 2) > 1280, "not maximal"); + // Odd budgets round down to even shards. + assert_eq!(shard_payload_for_udp_budget(1281, v4) % 2, 0); + // A generous budget never grows past the family default (either family). + assert_eq!( + shard_payload_for_udp_budget(9000, v4), + mtu1500_shard_payload() + ); + assert_eq!( + shard_payload_for_udp_budget(9000, v6), + mtu1500_shard_payload_v6() + ); + // Degenerate budgets bottom out at the floor instead of confetti. + assert_eq!(shard_payload_for_udp_budget(100, v4), MIN_SHARD_PAYLOAD); + } + + /// Operator-facing wire-MTU sizing subtracts the right IP+UDP header per family, and 1500 + /// reproduces today's defaults exactly. + #[test] + fn shard_payload_for_wire_mtu_math() { + use core::net::IpAddr; + let v4: IpAddr = "192.168.1.50".parse().unwrap(); + let v6: IpAddr = "fd00::50".parse().unwrap(); + let mapped: IpAddr = "::ffff:192.168.1.50".parse().unwrap(); + assert_eq!( + shard_payload_for_wire_mtu(1500, v4), + mtu1500_shard_payload() + ); + assert_eq!( + shard_payload_for_wire_mtu(1500, mapped), + mtu1500_shard_payload() + ); + assert_eq!( + shard_payload_for_wire_mtu(1500, v6), + mtu1500_shard_payload_v6() + ); + // 1280 wire − 28 − 64 = 1188 (v4); − 48 − 64 = 1168 (v6). + assert_eq!(shard_payload_for_wire_mtu(1280, v4), 1188); + assert_eq!(shard_payload_for_wire_mtu(1280, v6), 1168); + } + /// Family selection: genuine v6 remotes get the v6 size; v4 — including the IPv4-mapped v6 /// form a dual-stack `[::]` socket reports for a v4 client — keeps the v4 size. #[test] diff --git a/crates/punktfunk-core/src/quic/endpoint.rs b/crates/punktfunk-core/src/quic/endpoint.rs index ed7813e5..8ed25d6b 100644 --- a/crates/punktfunk-core/src/quic/endpoint.rs +++ b/crates/punktfunk-core/src/quic/endpoint.rs @@ -47,6 +47,20 @@ fn stream_transport_idle(idle: std::time::Duration) -> Arc(( hello, welcome, diff --git a/crates/punktfunk-host/src/native/wire_mtu.rs b/crates/punktfunk-host/src/native/wire_mtu.rs new file mode 100644 index 00000000..175038e6 --- /dev/null +++ b/crates/punktfunk-host/src/native/wire_mtu.rs @@ -0,0 +1,192 @@ +//! MTU resilience for the video data plane (the "connects fine, black screen forever" field +//! shape). +//! +//! Video datagrams are sealed at a per-session `shard_payload` sized for a clean 1500-byte MTU +//! (1472-byte UDP payloads). A host whose route to the client runs through a smaller-MTU hop — +//! a VPN/overlay adapter (Tailscale/WARP/ZeroTier default to 1280) claiming the LAN route, or a +//! lowered NIC MTU — delivers every SMALL flow (QUIC control, hole punch, input, audio) while +//! 100 % of video datagrams die by fragmentation or local `WSAEMSGSIZE`: the client sits on a +//! black screen reporting `loss_ppm=0` (it can't see gaps in packets it never saw any of) and +//! the host streams into the void with every gauge green. Neither side observes the failure +//! directly — but the control connection CAN: its MTU discovery probes up to exactly the sealed +//! video-datagram size ([`video_datagram_udp_ceiling`], set in `quic/endpoint.rs`), so its +//! settled MTU is a verdict on the path. +//! +//! Three legs, none of which changes a session on a healthy path: +//! - **`PUNKTFUNK_WIRE_MTU=`** — operator override; the shard payload is derived from +//! the given on-wire IP MTU. Wire-compatible with every deployed client: +//! `Welcome::shard_payload` is already negotiated per session (the v4/v6 split ships two +//! values today) and clients follow the negotiated value. +//! - **Watch** — a per-session task samples the control connection's discovered MTU once the +//! search has had time to finish. A connection still alive that settled BELOW the ceiling is +//! proof the path can't carry full-size video: log an actionable WARN and record the measured +//! budget for the peer. +//! - **Heal** — the next handshake from that peer clamps `shard_payload` to the recorded +//! budget, so a reconnect fixes the stream. A later session that reaches the ceiling erases +//! the record (the learn/heal loop is self-correcting in both directions). + +use std::collections::HashMap; +use std::net::IpAddr; +use std::sync::{Mutex, OnceLock}; + +use punktfunk_core::config::{ + mtu1500_shard_payload_for, sealed_datagram_bytes, shard_payload_for_udp_budget, + shard_payload_for_wire_mtu, video_datagram_udp_ceiling, +}; + +/// Measured UDP-payload budget per peer IP, learned from live control connections whose MTU +/// discovery settled below the video-datagram ceiling. In-memory only: a host restart +/// re-learns in one session, and entries self-correct (a later ceiling-hit erases, a lower +/// re-measure overwrites). +fn learned() -> &'static Mutex> { + static LEARNED: OnceLock>> = OnceLock::new(); + LEARNED.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// The shard payload for a new session to `peer`: `PUNKTFUNK_WIRE_MTU` override, else the +/// peer's learned path budget, else the family default (today's exact behavior). Logs whenever +/// the result differs from the default. +pub(super) fn negotiated_shard_payload(peer: IpAddr) -> usize { + let env = match std::env::var("PUNKTFUNK_WIRE_MTU") { + Ok(v) => match v.trim().parse::() { + Ok(mtu) => Some(mtu), + Err(_) => { + tracing::warn!(value = %v, "PUNKTFUNK_WIRE_MTU is not a number — ignoring it"); + None + } + }, + Err(_) => None, + }; + let learned_budget = learned().lock().unwrap().get(&peer).copied(); + resolve(env, learned_budget, peer) +} + +/// Pure resolution (env override > learned budget > family default) — the tested core of +/// [`negotiated_shard_payload`]. +fn resolve(env_wire_mtu: Option, learned_udp_budget: Option, peer: IpAddr) -> usize { + let default = mtu1500_shard_payload_for(peer); + if let Some(mtu) = env_wire_mtu { + let p = shard_payload_for_wire_mtu(mtu, peer); + if p != default { + tracing::info!( + wire_mtu = mtu, + shard_payload = p, + default, + "wire MTU: shard payload set from PUNKTFUNK_WIRE_MTU" + ); + } + return p; + } + if let Some(budget) = learned_udp_budget { + let p = shard_payload_for_udp_budget(budget as usize, peer); + if p != default { + tracing::info!( + peer = %peer, + udp_budget = budget, + shard_payload = p, + default, + "wire MTU: shard payload clamped to this peer's measured path MTU (learned \ + from a prior session's QUIC MTU discovery) — video datagrams now fit the \ + constrained hop" + ); + return p; + } + } + default +} + +/// Sample the control connection's discovered MTU after the search has settled and turn it +/// into a verdict. Spawned once per negotiated session; the task ends by itself after the +/// final sample (bounded ~10 s lifetime, holding only a cheap `Connection` handle). +pub(super) fn spawn_watch(conn: quinn::Connection, session_shard_payload: usize) { + tokio::spawn(async move { + let peer = conn.remote_address().ip(); + let ceiling = video_datagram_udp_ceiling() as u16; + // Discovery finishes in a handful of RTTs on a LAN (well under the first sample) but + // needs a loss timeout per failed probe on a constrained path — the second sample + // covers that with margin. Max, because discovery only ever raises `current_mtu`. + let mut settled = 0u16; + for wait_s in [3u64, 7] { + tokio::time::sleep(std::time::Duration::from_secs(wait_s)).await; + settled = settled.max(conn.stats().path.current_mtu); + if settled >= ceiling { + break; + } + } + if settled >= ceiling { + // The path carries full-size video datagrams — erase any stale learned clamp so + // the next session returns to the default wire. + if learned().lock().unwrap().remove(&peer).is_some() { + tracing::info!(peer = %peer, + "wire MTU: path re-measured at full size — learned clamp cleared"); + } + return; + } + // A closed connection stops discovering, so a session that ended before the final + // sample proves nothing (a healthy high-RTT path could still be mid-search): learn + // only from a connection that stayed alive through the whole window. + if conn.close_reason().is_some() { + return; + } + learned().lock().unwrap().insert(peer, settled); + if sealed_datagram_bytes(session_shard_payload) <= settled as usize { + // This session was already clamped small enough — the path is still constrained + // (keep the record fresh) but video fits, so no alarm. + tracing::info!(peer = %peer, discovered_udp_mtu = settled, + "wire MTU: constrained path re-measured; this session's video is sized to fit"); + } else { + tracing::warn!( + peer = %peer, + discovered_udp_mtu = settled, + needed_udp_mtu = ceiling, + "wire MTU: this path CANNOT carry full-size video datagrams — the control \ + plane works but every video packet is oversized for a hop, which streams as \ + an endless black screen with zero reported loss. Typical cause: a VPN/overlay \ + adapter (Tailscale / Cloudflare WARP / ZeroTier) claiming the LAN route, or a \ + lowered NIC MTU — compare `ping -f -l 1450` vs `-l 1200` and check \ + `netsh interface ipv4 show subinterfaces` (Windows) / `ip link` (Linux). The \ + measured budget is recorded: the NEXT session from this client sizes video to \ + fit automatically. To pin it for all sessions set PUNKTFUNK_WIRE_MTU." + ); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + const V4: IpAddr = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 2)); + const V6: IpAddr = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)); + + #[test] + fn default_when_nothing_known() { + assert_eq!(resolve(None, None, V4), mtu1500_shard_payload_for(V4)); + assert_eq!(resolve(None, None, V6), mtu1500_shard_payload_for(V6)); + } + + #[test] + fn env_override_beats_learned() { + // 1280 wire − 28 IP/UDP − 64 header/crypto = 1188. + assert_eq!(resolve(Some(1280), Some(1472), V4), 1188); + } + + #[test] + fn learned_budget_clamps() { + // A WARP-shaped path: 1280-byte UDP budget → 1280 − 64 = 1216. + assert_eq!(resolve(None, Some(1280), V4), 1216); + } + + #[test] + fn learned_at_or_above_ceiling_is_the_default_wire() { + assert_eq!(resolve(None, Some(1472), V4), mtu1500_shard_payload_for(V4)); + assert_eq!(resolve(None, Some(2000), V4), mtu1500_shard_payload_for(V4)); + } + + #[test] + fn env_full_mtu_is_the_default_wire_both_families() { + assert_eq!(resolve(Some(1500), None, V4), mtu1500_shard_payload_for(V4)); + assert_eq!(resolve(Some(1500), None, V6), mtu1500_shard_payload_for(V6)); + } +} diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 27706ff3..d5dcc2c1 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -333,6 +333,12 @@ #define INBOUND_REQ_FLAG 2147483648 #endif +// Floor for a negotiated `shard_payload` (even, well under every real path). A path whose UDP +// budget lands below this can't carry the QUIC control plane either (QUIC's own minimum is a +// 1200-byte UDP payload), so shrinking video shards further buys nothing — the clamp helpers +// bottom out here instead of producing degenerate confetti-sized shards. +#define MIN_SHARD_PAYLOAD 512 + // 16-byte AEAD authentication tag appended by either session cipher. #define TAG_LEN 16 From 8abdd74a622f073c03a533390687da8054b6dda0 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 19:10:45 +0200 Subject: [PATCH 21/53] fix(client/desktop): the Deck keeps its trackpad, and a pad stops buzzing at exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three faults in the desktop session's gamepad path. The Steam Deck lost its built-in trackpad-mouse at the start of every session. SDL's Valve HIDAPI driver clears the pad's digital mappings during *enumeration*, which is part of bringing the gamepad subsystem up — so holding the drivers off from inside GamepadService::pumped could never work: receiving a GamepadSubsystem means the enumeration has already happened. The hint set there detached a driver that had already done the damage, and lizard mode only came back seconds later when the firmware watchdog restored it. The presenter now disables them with its other pre-SDL_Init hints. The threaded worker always had this right; only the caller-pumped path was wrong, and it could not fix itself, hence a separate entry point its callers can place correctly. Player LEDs did nothing at all on any pad that is not a DualSense. The match arm handled the DualSense raw-effects path and let everything else fall through a bare `_`, though SDL exposes set_player_index and owns the per-device pattern. The wire carries a positional bitmask rather than an index, and the bridge is the popcount: every convention that reaches this wire spells "player N" as N lit LEDs — the DualSense patterns 0x04/0x0A/0x15/0x1B/0x1F and the Switch/XInput run 0x01/0x03/0x07/0x0F alike — so counting them works for both, where reading a bit position would only ever suit one. No lit LED means no player, not player 0. The remaining unhandled variants are now named rather than swept up by `_`, so a new one cannot join them silently. A forwarded pad could be left buzzing when the session ended. detach() only posts Ctl::Detach; the close that flushes the pad, tells the host to remove it and explicitly zeroes the motors runs when the pump next drains that message. Single mode broke out of the loop immediately after detaching and Event::Quit never detached at all, so both skipped it entirely. The teardown now sits where every exit converges instead of on the individual breaks. That still leaves the several paths that leave by `?` on a fatal overlay or present error, so the pump also silences its slots on Drop — the explicit call stays, because a pad should go quiet before a long teardown rather than after it. Drop closes the slots directly rather than draining the queue that would have done it: same physical outcome, and it touches no lock, where draining reaches an unwrap on a Mutex that would abort the process if it panicked mid-unwind. --- crates/pf-client-core/src/gamepad.rs | 134 ++++++++++++++++++++++++++- crates/pf-presenter/src/run.rs | 14 +++ 2 files changed, 144 insertions(+), 4 deletions(-) diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index 3eb51d7b..37f0d8d3 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -285,6 +285,21 @@ fn set_valve_hidapi(enabled: bool) { sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAM", v); } +/// Disable the Valve HIDAPI drivers **before SDL exists** — call this alongside the other +/// pre-`SDL_Init` hints, not after a subsystem is up. +/// +/// The damage these drivers do happens at *enumeration*, which is part of initialising the +/// joystick/gamepad subsystem. Setting the hint afterwards does detach the driver, but only after +/// it has already sent the Deck its `ID_CLEAR_DIGITAL_MAPPINGS` + `TRACKPAD_NONE` — so the +/// built-in trackpad-mouse dies system-wide and stays dead until the firmware watchdog restores +/// lizard mode seconds later. The threaded worker ([`run`]) has always done this in the right +/// order; the caller-pumped path could not, because by the time it receives a +/// [`sdl3::GamepadSubsystem`] the enumeration has already happened. Hence a separate entry point +/// its callers can put in the right place. +pub fn preinit_disable_valve_hidapi() { + set_valve_hidapi(false); +} + /// Map the SDL-reported controller type to the virtual pad we'd ask the host to create. fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref { use sdl3::gamepad::GamepadType as T; @@ -393,9 +408,12 @@ impl GamepadService { /// and calls [`GamepadPump::tick`] once per loop iteration (the threaded worker's /// per-wakeup work: ctl drain, chord-hold check, menu repeat, feedback). /// - /// Like the threaded worker, this disables the Valve HIDAPI drivers up front (their - /// mere enumeration kills the Deck's trackpad-mouse system-wide); they are enabled - /// for the duration of an attached session only. + /// The Valve HIDAPI drivers are held off here too, but this is **too late to be the only + /// place it happens**: the `subsystem` argument means enumeration is already done, and that + /// is when the Deck driver kills the trackpad-mouse. The caller must also call + /// [`preinit_disable_valve_hidapi`] with its other pre-`SDL_Init` hints. This call still + /// earns its place — it re-asserts "off" for a process that ran a session earlier — but on + /// its own it only detaches a driver that has already done the damage. pub fn pumped(subsystem: sdl3::GamepadSubsystem) -> (GamepadService, GamepadPump) { set_valve_hidapi(false); let pads = Arc::new(Mutex::new(Vec::new())); @@ -556,6 +574,38 @@ impl GamepadPump { self.worker.menu_poll(); self.worker.render_feedback(); } + + /// Close every forwarded slot — flush its held wire state, tell the host to remove the pad, + /// and physically silence it. Call once on the way out of the caller's event loop. + /// + /// [`GamepadService::detach`] only *posts* `Ctl::Detach`; the close — the flush, the host-side + /// `GamepadRemove`, and the explicit `set_rumble(0, 0)` backstop in `close_slot_at` — happens + /// when the pump next drains it. An exit path that detached and then left the loop without + /// another [`tick`](Self::tick) therefore skipped all of it, and nothing else would: the slots + /// hold no `Drop` that silences them. A pad left mid-buzz stayed buzzing. + /// + /// This closes the slots directly rather than draining the queued `Ctl::Detach` that would + /// have done it. Same physical outcome by a shorter path, and deliberately so: this also runs + /// from `Drop`, and `drain_ctl` reaches `Mutex::lock().unwrap()`, which on a poisoned lock + /// would panic — during an unwind that aborts the process. Closing a slot touches no lock. + /// + /// Idempotent, and safe with nothing attached. + pub fn shutdown(&mut self) { + self.worker.close_all_slots(); + } +} + +/// The silence backstop of last resort. A caller's loop can also leave by `?` on a fatal overlay +/// or present error — several paths do — and those would skip an explicit +/// [`shutdown`](GamepadPump::shutdown) entirely, leaving a forwarded pad buzzing on the way out. +/// +/// Callers should still call `shutdown` at their normal exit rather than lean on this: the pad +/// wants to go quiet *before* a long teardown (session join, `vkDeviceWaitIdle`), not after it. +/// Doing both is free — `shutdown` is idempotent. +impl Drop for GamepadPump { + fn drop(&mut self) { + self.shutdown(); + } } /// The lowest wire pad index (0..[`MAX_PADS`](punktfunk_core::input::MAX_PADS)) not already held @@ -1626,6 +1676,11 @@ impl Worker { HidOutput::PlayerLeds { bits, .. } if is_ds => { let _ = slot.pad.send_effect(&Ds5Feedback::player_packet(bits)); } + // Every other pad with player LEDs gets them through SDL, which owns the + // per-device pattern. This used to fall through and do nothing at all. + HidOutput::PlayerLeds { bits, .. } => { + let _ = set_player_leds(&slot.pad, bits); + } HidOutput::Trigger { which, ref effect, .. } if is_ds => { @@ -1633,12 +1688,43 @@ impl Worker { .pad .send_effect(&Ds5Feedback::trigger_packet(which, effect)); } - _ => {} + // Deliberately unhandled, listed rather than left to a bare `_` so a new + // variant cannot join them silently: adaptive triggers exist only on a + // DualSense, and the trackpad-haptic / raw-passthrough planes are DS-specific + // and carried by `send_effect` above when the pad is one. + HidOutput::Trigger { .. } + | HidOutput::TrackpadHaptic { .. } + | HidOutput::HidRaw { .. } => {} } } } } +/// The SDL player index for the wire's positional player-LED `bits`, or `None` for "no player". +/// +/// The wire carries a bitmask — one bit per LED, low 5 — while SDL wants a player *index* and owns +/// the per-device pattern. The count bridges them: every convention that reaches this wire spells +/// "player N" as N lit LEDs, both the DualSense patterns (`0x04`, `0x0A`, `0x15`, `0x1B`, `0x1F`) +/// and the Switch/XInput run of low bits (`0x01`, `0x03`, `0x07`, `0x0F`). SDL's index is 0-based, +/// so player 1 is index 0; no lit LED means *no* player rather than player 0. +/// +/// Split out from [`set_player_leds`] so the mapping is testable — an `sdl3::Gamepad` needs a real +/// device, so nothing that takes one can be. +fn player_index_from_bits(bits: u8) -> Option { + match (bits & 0x1F).count_ones() { + 0 => None, + n => Some((n - 1) as u16), + } +} + +/// Drive a non-DualSense pad's player LEDs from the wire's positional `bits`. +fn set_player_leds(pad: &sdl3::gamepad::Gamepad, bits: u8) -> Result<(), sdl3::Error> { + match player_index_from_bits(bits) { + None => pad.unset_player_index(), + Some(i) => pad.set_player_index(i), + } +} + /// The wire pad index a [`HidOutput`] is addressed to (every variant carries `pad`). fn hidout_pad(h: &HidOutput) -> u8 { match h { @@ -2008,3 +2094,43 @@ mod slot_tests { ); } } + +#[cfg(test)] +mod player_led_tests { + use super::*; + + /// Both conventions that reach this wire spell "player N" as N lit LEDs, so the count is the + /// player number regardless of WHICH bits a given pad lights. Pinned because the mapping is + /// otherwise only obvious once you have seen both patterns side by side. + #[test] + fn player_index_counts_lit_leds_for_both_conventions() { + // DualSense / hid-playstation patterns — non-contiguous, symmetric about the centre LED. + assert_eq!(player_index_from_bits(0x04), Some(0)); // player 1 + assert_eq!(player_index_from_bits(0x0A), Some(1)); // player 2 + assert_eq!(player_index_from_bits(0x15), Some(2)); // player 3 + assert_eq!(player_index_from_bits(0x1B), Some(3)); // player 4 + assert_eq!(player_index_from_bits(0x1F), Some(4)); // player 5 + + // Switch/XInput style — a contiguous run of low bits, the same count each time. + assert_eq!(player_index_from_bits(0x01), Some(0)); + assert_eq!(player_index_from_bits(0x03), Some(1)); + assert_eq!(player_index_from_bits(0x07), Some(2)); + assert_eq!(player_index_from_bits(0x0F), Some(3)); + } + + /// No lit LED is "no player", NOT player 0 — the difference between LEDs off and player 1 lit. + #[test] + fn no_lit_led_is_no_player() { + assert_eq!(player_index_from_bits(0x00), None); + // Only the low 5 bits are player LEDs; junk above them must not invent a player. + assert_eq!(player_index_from_bits(0xE0), None); + } + + /// The mask is applied before counting, so out-of-range bits cannot inflate the index past + /// the 5 real LEDs. + #[test] + fn high_bits_are_masked_off_before_counting() { + assert_eq!(player_index_from_bits(0xFF), Some(4)); // 0x1F worth of LEDs, not 8 + assert_eq!(player_index_from_bits(0xE4), Some(0)); // 0x04 with junk on top + } +} diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index 1ef0f997..b91127ab 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -466,6 +466,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result #[cfg(windows)] crate::win32::set_app_user_model_id(); sdl3::hint::set("SDL_JOYSTICK_THREAD", "1"); + // Hold SDL's Valve HIDAPI drivers off BEFORE SDL_Init: the Deck driver clears the pad's + // digital mappings at *enumeration*, which is part of bringing the gamepad subsystem up, so a + // hint set after `sdl.gamepad()` — where this used to live, inside GamepadService::pumped — + // only detached a driver that had already killed the built-in trackpad-mouse system-wide. The + // symptom was the Deck losing its trackpad cursor at the start of every session until the + // firmware watchdog restored lizard mode. They are still enabled for an attached session. + pf_client_core::gamepad::preinit_disable_valve_hidapi(); // A touchscreen (the Deck's glass) is forwarded as REAL touch passthrough below — so // suppress SDL's default synthesis of mouse events from touch. Left on, every touch // ALSO warps a synthetic mouse to the touch point, which under the stream's relative @@ -1895,6 +1902,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result } }; + // Every exit from the loop above converges here, which is why the gamepad teardown belongs + // here and not on the individual `break`s. `gamepad.detach()` only queues the detach; the + // close — flush, host-side GamepadRemove, and the explicit rumble-stop backstop — runs when + // the pump drains it. Single mode broke out of the loop immediately after detaching and + // Event::Quit never detached at all, so both left forwarded pads unflushed and, if the game + // was rumbling at the time, still buzzing. + pump.shutdown(); // Join the pump BEFORE the device-wide idle: its decode submissions on the shared // device would race vkDeviceWaitIdle otherwise. if let Some(st) = stream.take() { From 31b5f90b129ac5cd13602e639e61a52d166066aa Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 19:20:01 +0200 Subject: [PATCH 22/53] fix(host/windows): two virtual pads stop tearing each other's reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three faults on the Windows pad path, two of them races that only bite when a game drives a pad hard enough for two callbacks to overlap. pf-gamepad's output ring could hand the host a torn report. Publishing is a read-modify-write — read the cursor, write the slot it names, advance it — and the framework dispatches output callbacks in parallel, so two could be inside it at once: both read the same head, both wrote the SAME slot, and both stored head+1, so the cursor moved once for two reports and the host read a single entry with two reports mixed into it. An atomic fetch_add does not fix this. It hands each writer its own slot but advances the cursor before the bytes exist, so the host is then invited to read a slot still being filled. Serializing the publish is what makes the cursor bump mean "the slot below is complete". The ring exists to stop a rumble STOP being coalesced away, and a torn slot can eat that STOP with no idle watchdog behind it. Both drivers also promised the host an ordering they never established. The host loads out_seq and rumble_seq with Acquire and says so in its own comments — "Acquire pairs with the driver's publish-then-bump store order" — but the drivers bumped both with plain writes, and an Acquire load pairs with a Release store and nothing else. On a weakly-ordered core the host could see a fresh seq against stale bytes. pf-xusb's rumble seq was racy in the same way as the ring: two SET_STATE calls could both read one value and both write back value+1, so the host saw one bump for two writes and skipped a level. A skipped stop is the one that hurts — the pad buzzes until the ~2.5 s idle force-off notices the game went quiet, which is what bounds the damage. Diagnosing an unattached driver stalled the session. The pad service thread — the one feeding input and rumble — waited up to two seconds for a pnputil enumeration, per unattached pad, at exactly the moment a session was already going wrong. The diagnosis now runs on its own thread. Off the hot path the wait no longer has to be a compromise, so it is generous enough to report what it actually found instead of giving up with "still enumerating" — which, given pnputil routinely takes longer than the old budget, is what it usually did. --- .../src/inject/windows/gamepad_raii.rs | 95 ++++++++++++------- .../windows/drivers/pf-gamepad/src/lib.rs | 30 +++++- packaging/windows/drivers/pf-xusb/src/lib.rs | 30 +++++- 3 files changed, 121 insertions(+), 34 deletions(-) diff --git a/crates/pf-inject/src/inject/windows/gamepad_raii.rs b/crates/pf-inject/src/inject/windows/gamepad_raii.rs index b4679169..dded23fc 100644 --- a/crates/pf-inject/src/inject/windows/gamepad_raii.rs +++ b/crates/pf-inject/src/inject/windows/gamepad_raii.rs @@ -819,46 +819,77 @@ impl DriverAttach { /// One-shot WARN with everything the host can find out about WHY the driver isn't attached: /// driver-store presence, the devnode's PnP status/problem code, and where to look next. + /// + /// Runs on its own thread and returns immediately. The caller is the session's pad service + /// thread — the one feeding input and rumble — and everything below is slow: the driver-store + /// check waits up to [`INVENTORY_WAIT`] for a `pnputil` enumeration that can take tens of + /// seconds, and the devnode lookup is a synchronous PnP call. Blocking there stalled input for + /// up to two seconds *per unattached pad* (the wait is a deadline, not a one-off: while the + /// enumeration is still outstanding every pad pays it again), at exactly the moment a session + /// is already going wrong. Diagnostics must never be able to hurt the thing they diagnose. + /// + /// Off the hot path the wait also stops being a compromise — it can afford to be patient and + /// report what it actually found rather than "still enumerating". fn diagnose(&self) { - let store = match driver_store_has(self.inf) { - Some(true) => "driver package present in the driver store", - Some(false) => { - "driver package NOT in the driver store — run: punktfunk-host.exe driver install --gamepad" - } - None => "driver store could not be queried (pnputil failed or still enumerating)", - }; - let devnode = match &self.instance_id { - Some(id) => devnode_status_line(id), - None => { - "no per-session devnode (SwDeviceCreate failed earlier — see the warning above)" - .to_string() - } - }; - tracing::warn!( - driver = self.driver, - shm = %self.shm_name, - grace_secs = ATTACH_GRACE.as_secs(), - store, - devnode = %devnode, - driver_log = self.driver_log, - "gamepad driver has not attached to the shared section — the virtual pad exists but no \ - driver is serving it (games will not see it); an old (pre-sealed-channel) driver also \ - reads as not-attached: update with punktfunk-host.exe driver install --gamepad \ - (driver_log is only written by debug driver builds, or with the PFXUSB_DEBUG_LOG / \ - PFGAMEPAD_DEBUG_LOG / PFMOUSE_DEBUG_LOG system env var set + the device restarted)" - ); + let (driver, inf, driver_log) = (self.driver, self.inf, self.driver_log); + let shm_name = self.shm_name.clone(); + let instance_id = self.instance_id.clone(); + std::thread::Builder::new() + .name("pf-driver-diagnose".into()) + .spawn(move || diagnose_blocking(driver, inf, driver_log, &shm_name, instance_id)) + .ok(); } } -/// How long [`driver_store_inventory`] lets the caller wait for the background pnputil query -/// before reporting without it — [`observe`] runs on the pad service thread, which must keep -/// draining pad slots even when the driver store is wedged. -const INVENTORY_WAIT: Duration = Duration::from_secs(2); +/// The body of [`DriverAttach::diagnose`], on its own thread. Split out rather than inlined into +/// the closure so the blocking calls stay visible as blocking. +fn diagnose_blocking( + driver: &'static str, + inf: &'static str, + driver_log: &'static str, + shm_name: &str, + instance_id: Option, +) { + let store = match driver_store_has(inf) { + Some(true) => "driver package present in the driver store", + Some(false) => { + "driver package NOT in the driver store — run: punktfunk-host.exe driver install --gamepad" + } + None => "driver store could not be queried (pnputil failed or still enumerating)", + }; + let devnode = match &instance_id { + Some(id) => devnode_status_line(id), + None => "no per-session devnode (SwDeviceCreate failed earlier — see the warning above)" + .to_string(), + }; + tracing::warn!( + driver, + shm = %shm_name, + grace_secs = ATTACH_GRACE.as_secs(), + store, + devnode = %devnode, + driver_log, + "gamepad driver has not attached to the shared section — the virtual pad exists but no \ + driver is serving it (games will not see it); an old (pre-sealed-channel) driver also \ + reads as not-attached: update with punktfunk-host.exe driver install --gamepad \ + (driver_log is only written by debug driver builds, or with the PFXUSB_DEBUG_LOG / \ + PFGAMEPAD_DEBUG_LOG / PFMOUSE_DEBUG_LOG system env var set + the device restarted)" + ); +} + +/// How long [`driver_store_inventory`] waits for the background pnputil query before reporting +/// without it. Only [`diagnose_blocking`] waits, and that has a thread to itself, so this is +/// generous: pnputil routinely takes longer than a couple of seconds on a busy driver store, and +/// the old two-second budget — chosen to limit the damage while this ran on the pad service thread +/// — meant the diagnosis usually gave up and printed "still enumerating", which is the one answer +/// that helps nobody. Nothing waits on this thread, so patience costs only a late log line. +const INVENTORY_WAIT: Duration = Duration::from_secs(30); /// Driver-store inventory (`pnputil /enum-drivers`), lower-cased, fetched once per process — only /// consulted on the failure path, so the subprocess cost never hits a healthy session. The query /// runs on its OWN thread: pnputil can block for tens of seconds on a busy/wedged driver store, -/// and the caller is the pad service thread. `None` = not available yet (query still running) or +/// and this keeps one wedged query from being re-run per pad. `None` = not available yet (query +/// still running past [`INVENTORY_WAIT`]) or /// failed; a query that outlives [`INVENTORY_WAIT`] still lands in the cache for later reports. fn driver_store_inventory() -> Option<&'static str> { static INV: OnceLock = OnceLock::new(); diff --git a/packaging/windows/drivers/pf-gamepad/src/lib.rs b/packaging/windows/drivers/pf-gamepad/src/lib.rs index daefbcb8..96fb6d5e 100644 --- a/packaging/windows/drivers/pf-gamepad/src/lib.rs +++ b/packaging/windows/drivers/pf-gamepad/src/lib.rs @@ -360,9 +360,32 @@ fn ring_len(view: &pf_umdf_util::section::MappedView) -> u32 { /// from being coalesced away by a following LED/trigger report inside one host poll window (the /// confirmed stuck-rumble path). fn publish_output(view: &pf_umdf_util::section::MappedView, bytes: &[u8]) { + // Serialized: the whole publish is a read-modify-write (read the cursor, write the slot it + // names, then advance it) and the framework dispatches output callbacks in PARALLEL, so two + // can be inside this at once. Unsynchronized, both read the same `ring_head`, both write the + // SAME slot — tearing one report's bytes across the other's — and both store head+1, so the + // cursor advances once for two reports and the host sees a single torn entry. + // + // An atomic `fetch_add` on the head does not fix it. That hands each writer a distinct slot, + // but it advances the cursor BEFORE the slot bytes exist, so the host can read a slot that is + // still being filled — trading a torn slot for a torn slot the host is invited to read. Making + // the head-advance mean "the slot below is complete" is exactly what the lock buys. + // + // Poison-tolerant on purpose. Poison is sticky, so the repo's usual `if let Ok(g) = lock()` + // would skip the publish for the REST OF THE PROCESS after a single panic elsewhere — silently + // ending game output. Recovering the guard is safe here: the protected state is bytes in a + // shared section, not an invariant a panic could have broken. + let _publish = RING_PUBLISH + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); view.write_bytes(OFF_OUTPUT, bytes); let seq = view.read_u32(OFF_OUT_SEQ).wrapping_add(1); - view.write_u32(OFF_OUT_SEQ, seq); + // Release, not a plain write: the host loads `out_seq` with Acquire specifically to order its + // copy of the report bytes after it (`dualsense_windows.rs`, "Acquire pairs with the driver's + // publish-then-bump store order"). An Acquire load pairs with a Release store and nothing + // else, so as a plain write this promised the host an ordering it never actually established — + // on a weakly-ordered core (ARM64) the fresh seq could arrive ahead of the bytes it announces. + view.store_u32(OFF_OUT_SEQ, seq, Ordering::Release); let len = ring_len(view); if len != 0 { let head = view.read_u32(OFF_RING_HEAD); @@ -375,6 +398,11 @@ fn publish_output(view: &pf_umdf_util::section::MappedView, bytes: &[u8]) { } } +/// Serializes [`publish_output`] against itself — see the note there for why an atomic cursor is +/// not enough. Uncontended in the common case: one output report at a time is the norm, and the +/// critical section is a few dozen bytes of memcpy into an already-mapped view. +static RING_PUBLISH: std::sync::Mutex<()> = std::sync::Mutex::new(()); + /// The sealed-channel client (per-pad: `ProcessSharingDisabled` gives each pad its own WUDFHost, so /// this static is per-pad). The handshake/adoption/validation state machine lives in `pf_umdf_util`. static CHANNEL: ChannelClient = ChannelClient::new(); diff --git a/packaging/windows/drivers/pf-xusb/src/lib.rs b/packaging/windows/drivers/pf-xusb/src/lib.rs index 58ccdf64..5343a8fb 100644 --- a/packaging/windows/drivers/pf-xusb/src/lib.rs +++ b/packaging/windows/drivers/pf-xusb/src/lib.rs @@ -358,20 +358,48 @@ fn read_state(data: Option<&MappedView>) -> (u32, u16, u8, u8, i16, i16, i16, i1 /// host can tell "driver bound and alive" apart from "driver package missing/failed to bind" and see /// the game-visible polling path advance. fn touch_driver_marks(data: &MappedView) { + let _marks = SECTION_PUBLISH + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); data.write_u32(OFF_DRIVER_PROTO, GAMEPAD_PROTO_VERSION); let hb = data.read_u32(OFF_DRIVER_HEARTBEAT).wrapping_add(1); data.write_u32(OFF_DRIVER_HEARTBEAT, hb); } /// Publish a game's rumble (from SET_STATE) into the DATA section for the host to forward. +/// +/// Serialized and Release-published, because IOCTLs arrive concurrently and neither property held +/// before. `seq` was a read-modify-write across the two motor bytes: two `SET_STATE` calls could +/// both read the same value and both write back `seq + 1`, so the host — which treats an unchanged +/// seq as "nothing new" — saw one bump for two writes and skipped a level entirely. A skipped +/// **stop** is the one that hurts: the pad keeps buzzing until the host's ~2.5 s idle force-off +/// notices the game went quiet, which is where the bound on this bug comes from. +/// +/// The seq store is Release for the same reason as `pf-gamepad`'s `out_seq`: the host loads it with +/// Acquire and documents that as ordering its read of the motor bytes ("the driver bumps +/// `rumble_seq` AFTER writing the rumble bytes", `gamepad_windows.rs`). A plain write gives that +/// Acquire nothing to pair with, so the guarantee the host's comment claims did not exist in either +/// direction — the host could read a fresh seq against stale motor levels on a weakly-ordered core. fn publish_rumble(data: Option<&MappedView>, large: u8, small: u8) { let Some(v) = data else { return }; + let _publish = SECTION_PUBLISH + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); v.write_u8(OFF_RUMBLE_LARGE, large); v.write_u8(OFF_RUMBLE_SMALL, small); let seq = v.read_u32(OFF_RUMBLE_SEQ).wrapping_add(1); - v.write_u32(OFF_RUMBLE_SEQ, seq); + v.store_u32(OFF_RUMBLE_SEQ, seq, Ordering::Release); } +/// Serializes the section's read-modify-write publishes ([`publish_rumble`], [`touch_driver_marks`]) +/// against each other. One lock rather than one per field: they are all short byte writes into the +/// same mapped view, and the contention is nil compared to the IOCTL round trip that reaches them. +/// +/// Poison-tolerant deliberately — poison is sticky, so bailing out on it would silently stop +/// forwarding rumble for the rest of the process. The protected state is bytes in a shared section, +/// not an invariant a panic elsewhere could have violated. +static SECTION_PUBLISH: std::sync::Mutex<()> = std::sync::Mutex::new(()); + // Build the 29-byte GET_STATE buffer (the layout xinput1_4 parses). fn build_get_state(data: Option<&MappedView>) -> [u8; 29] { let (packet, buttons, lt, rt, lx, ly, rx, ry) = read_state(data); From 290d760ea48298e201dc87de938f8fe123ca47cc Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 19:27:09 +0200 Subject: [PATCH 23/53] feat(core/wire): per-frame shard geometry, jumbo ceiling, Hello advertisement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 of mid-session shard-payload renegotiation (planning design/shard-payload-reneg.md), stacked on the leg-1 MTU resilience. All three legs are client-side and forward-compatible: deployed clients that carry them accept a mid-session shard change the moment a future host sends one, and nothing changes on the wire until then. - W0.1 — the reassembler's strict shard_bytes firewall becomes per-frame pinning: a frame's first-arriving packet pins that frame's shard size (bounds-checked to [min_shard_bytes, max_shard_bytes], even), later packets must match the pin, and the per-frame block ceiling derives from the pinned size (a session-level cap would reject legitimate post-shrink frames). The reorder race between an ordered control message and unordered video dies structurally: old-geometry frames in flight complete under their own pin while new frames arrive under the new one, and no cross-geometry splice can land in one buffer. The in-flight budget stays byte-based and exact. - W0.2 — MAX_DATAGRAM_BYTES 2048 → 9216: every receive path (transport RECV_BUF, the recvmmsg ring) now accepts sealed jumbo datagrams (9000-MTU LAN ≈ 8908-byte shards). Static buffers over resize-on-ack: the ring delta is 128 × ~7 KiB ≈ 896 KiB per client session, lazily allocated, hosts unaffected. Grep verdict: no embedder uses the constant directly, so no C ABI bump — the regenerated header rides along (drift gate). - W0.3 — trailing Hello field max_shard_payload: u16 (0/absent = legacy), the append-with-placeholder discipline of video_caps/ client_caps. One field is both the renegotiation capability flag and the jumbo ceiling; core's pump advertises it for all client families, the probe too. - Host seam for Phase 1, dead until wired: Packetizer::set_shard_payload (re-derives the block ceilings; construction delegates to it) + Session::set_shard_payload (host-only, Config::validate parity). Verification (the 0.23.0 lesson — geometry changes breed sizing bugs): the slice-wire suite re-runs at shard 512/1216/1408/8908 (exact-multiple sweep, lossy + reversed roundtrips, sentinel path, in-flight budget); mid-stream shrink→grow→revert delivery; the old-geometry reorder race; cross-geometry splice rejection; firewall bounds non-vacuous both ways; a 48-case mixed-geometry reorder-torture proptest asserting per-frame byte-identical DELIVERY and an exactly-zero final budget; and a sealed loopback session test (continuous crypto/replay) delivering frames across live re-keys — every test asserts delivered frames, never the absence of errors. core: 294/294 --features quic + clippy -D warnings (macOS), fmt. --- clients/probe/src/main.rs | 4 + .../src/client/pump/handshake.rs | 6 + crates/punktfunk-core/src/packet/header.rs | 12 +- crates/punktfunk-core/src/packet/packetize.rs | 58 ++- .../punktfunk-core/src/packet/reassemble.rs | 78 +++- crates/punktfunk-core/src/packet/tests.rs | 414 +++++++++++++++++- crates/punktfunk-core/src/quic/handshake.rs | 126 +++++- crates/punktfunk-core/src/session.rs | 85 ++++ include/punktfunk_core.h | 12 +- 9 files changed, 754 insertions(+), 41 deletions(-) diff --git a/clients/probe/src/main.rs b/clients/probe/src/main.rs index e0f60bbb..112fd4ce 100644 --- a/clients/probe/src/main.rs +++ b/clients/probe/src/main.rs @@ -558,6 +558,10 @@ async fn session(args: Args) -> Result<()> { } else { 0 }, + // Like STREAMED_AU above: the shared-core reassembler pins geometry per-frame, so + // the probe accepts a mid-session shard change (and jumbo growth) up to the + // receive ceiling — and it's exactly the tool to measure both. + max_shard_payload: punktfunk_core::config::max_shard_payload() as u16, } .encode(), ) diff --git a/crates/punktfunk-core/src/client/pump/handshake.rs b/crates/punktfunk-core/src/client/pump/handshake.rs index c8f79b04..57485d5a 100644 --- a/crates/punktfunk-core/src/client/pump/handshake.rs +++ b/crates/punktfunk-core/src/client/pump/handshake.rs @@ -156,6 +156,12 @@ pub(super) async fn connect_and_handshake(args: &WorkerArgs) -> Result Self { let max_data = config.fec.max_data_per_block as usize; - let total_data_max = config - .max_frame_bytes - .div_ceil(config.shard_payload.max(1)) - .max(1); - Packetizer { + let mut p = Packetizer { next_frame_index: 0, next_probe_index: 0, next_seq: 0, shard_payload: config.shard_payload, + max_frame_bytes: config.max_frame_bytes, fec: config.fec, version: config.phase as u8, tail: Vec::new(), @@ -121,12 +124,37 @@ impl Packetizer { // Mirrors `ReassemblerLimits::from_config` — keep the two in step. max_total_shards: (max_data + config.fec.recovery_for(max_data)) .min(config.fec.scheme.max_total_shards()), - max_blocks: total_data_max.div_ceil(max_data).max(1), - // Every non-final SLICE block carries at least `min(MIN_STREAM_BLOCK_SHARDS, K)` - // data shards (the flush floor, clamped by the block size), so a max-size frame - // bounds the block count. Mirrors the receiver's slice firewall — keep in step. - slice_block_cap: total_data_max / MIN_STREAM_BLOCK_SHARDS.min(max_data.max(1)) + 2, - } + // Derived from the shard size below (single source of truth for the formulas). + max_blocks: 0, + slice_block_cap: 0, + }; + p.set_shard_payload(config.shard_payload); + p + } + + /// Live-swap the wire shard payload (mid-session shard renegotiation, + /// design/shard-payload-reneg.md Phase 1). Takes effect on the next packetized AU — call + /// ONLY between AUs, never with a [`StreamedAu`] in flight: an open streamed AU's + /// shard-aligned tiling derives from the size it began with, and re-keying under it would + /// corrupt the frame's layout. The per-frame block ceilings follow the new size here; the + /// receiver re-derives its side per packet from the header's own `shard_bytes` (geometry + /// is per-frame there), so the two stay in step by construction. Bounds are the caller's + /// contract — go through [`Session::set_shard_payload`](crate::session::Session::set_shard_payload), + /// which enforces the `Config::validate` rules. + pub fn set_shard_payload(&mut self, shard_payload: usize) { + let max_data = self.fec.max_data_per_block as usize; + let total_data_max = self.max_frame_bytes.div_ceil(shard_payload.max(1)).max(1); + self.shard_payload = shard_payload; + self.max_blocks = total_data_max.div_ceil(max_data).max(1); + // Every non-final SLICE block carries at least `min(MIN_STREAM_BLOCK_SHARDS, K)` + // data shards (the flush floor, clamped by the block size), so a max-size frame + // bounds the block count. Mirrors the receiver's slice firewall — keep in step. + self.slice_block_cap = total_data_max / MIN_STREAM_BLOCK_SHARDS.min(max_data.max(1)) + 2; + } + + /// The wire shard payload AUs are currently packetized at. + pub fn shard_payload(&self) -> usize { + self.shard_payload } /// Allocate the next **probe-space** frame index (speed-test filler). A separate counter from diff --git a/crates/punktfunk-core/src/packet/reassemble.rs b/crates/punktfunk-core/src/packet/reassemble.rs index 5e9d595a..3f12e26f 100644 --- a/crates/punktfunk-core/src/packet/reassemble.rs +++ b/crates/punktfunk-core/src/packet/reassemble.rs @@ -76,6 +76,12 @@ struct BlockState { } struct FrameBuf { + /// The frame's PINNED shard payload — set by its first-arriving packet (bounds-checked by + /// the firewall), matched by every later packet of the frame. Geometry is per-frame so a + /// mid-session `shard_payload` change (design/shard-payload-reneg.md) is safe on an + /// unordered wire: frames in flight complete under their own pin while new frames arrive + /// under the new one, and no cross-geometry splice can land in one buffer. + shard_bytes: usize, /// Exact AU size. 0 = unknown: the frame was opened by a streamed-AU SENTINEL packet /// ([`crate::quic::VIDEO_CAP_STREAMED_AU`]) and the final block's real totals haven't /// arrived yet — the frame can't complete before they do (and retro-validate). @@ -105,16 +111,28 @@ struct FrameBuf { /// Per-session bounds the reassembler enforces on every packet header *before* /// allocating, so a hostile or corrupt header cannot drive unbounded memory use. All /// derived from the negotiated [`Config`]. +/// +/// Shard geometry is PER-FRAME, not per-session (mid-session shard-payload renegotiation, +/// design/shard-payload-reneg.md W0.1): a frame's first-arriving packet pins the frame's +/// `shard_bytes` within `[min_shard_bytes, max_shard_bytes]`, later packets must match the +/// pin, and the per-frame block ceiling derives from the pinned size (a shrunk shard needs +/// more blocks for the same bytes). The reorder race between an ordered control-stream +/// geometry change and the unordered video datagrams is thereby killed structurally — every +/// frame is wholly one geometry, whichever order its packets and the change arrive in. #[derive(Clone, Copy, Debug)] pub struct ReassemblerLimits { - /// Expected shard payload length; every shard in the stream must match exactly. - pub shard_bytes: usize, + /// Floor for a frame's pinned shard payload — [`crate::config::MIN_SHARD_PAYLOAD`] in + /// production (or the negotiated value when a session legitimately starts below it). + pub min_shard_bytes: usize, + /// Ceiling for a frame's pinned shard payload — what this receive path accepts and what + /// the client advertises in `Hello::max_shard_payload` + /// ([`crate::config::max_shard_payload`]): the transport recv buffers are sized for a + /// sealed datagram of exactly this shard size. + pub max_shard_bytes: usize, /// Max data shards per block (the negotiated `max_data_per_block`). pub max_data_shards: usize, /// Max total shards per block (data + recovery), capped by the FEC scheme ceiling. pub max_total_shards: usize, - /// Max FEC blocks per frame. - pub max_blocks: usize, /// Max accepted access-unit size. pub max_frame_bytes: usize, } @@ -135,12 +153,13 @@ impl ReassemblerLimits { // snapshot of it. let max_total = (max_data + (max_data * 90).div_ceil(100)).min(c.fec.scheme.max_total_shards()); - let total_data = c.max_frame_bytes.div_ceil(c.shard_payload.max(1)).max(1); ReassemblerLimits { - shard_bytes: c.shard_payload, + // `.min(c.shard_payload)`: never reject the session's own negotiated value — a + // hand-configured session below the production floor still reassembles itself. + min_shard_bytes: crate::config::MIN_SHARD_PAYLOAD.min(c.shard_payload), + max_shard_bytes: crate::config::max_shard_payload(), max_data_shards: max_data, max_total_shards: max_total, - max_blocks: total_data.div_ceil(max_data).max(1), max_frame_bytes: c.max_frame_bytes, } } @@ -179,6 +198,9 @@ const IN_FLIGHT_BUF_FACTOR: usize = 4; /// Recovery-shard buffer pool ceiling (shard-sized buffers): enough for several max-recovery /// blocks in flight, small enough (~720 KB at a 1408-byte shard) to keep after a loss burst. +/// Entries size themselves to the largest shard they ever held, so a jumbo session (opt-in, +/// desktop-LAN — shards up to [`ReassemblerLimits::max_shard_bytes`]) retains proportionally +/// more; it also needs ~6× fewer buffers per block, so the pool rarely fills there. const RECOVERY_POOL_MAX: usize = 512; /// Buffers incoming shards, recovers lost ones via FEC, and emits whole access units. @@ -295,11 +317,16 @@ impl Reassembler { // Bound every attacker-controllable header field against the negotiated limits // BEFORE allocating anything keyed on it — this is the firewall against a tiny // datagram triggering a huge `vec![None; total]` / `Vec::with_capacity`. + // `shard_bytes` is bounds-checked (not equality-checked) because geometry is + // per-frame — the frame-pin check below is what rejects a size CHANGE mid-frame; + // the even requirement mirrors `Config::validate` (FEC requires even shards). let drop = |stats: &StatsCounters| { StatsCounters::add(&stats.packets_dropped, 1); }; if hdr.magic != PUNKTFUNK_MAGIC - || shard_bytes != lim.shard_bytes + || shard_bytes < lim.min_shard_bytes + || shard_bytes > lim.max_shard_bytes + || shard_bytes % 2 != 0 || pkt.len() < HEADER_LEN + shard_bytes || data_shards == 0 || data_shards > lim.max_data_shards @@ -330,6 +357,11 @@ impl Reassembler { // later pin — the maximum the negotiated limits allow (the design's "allocate at // max_frame_bytes"; the existing in-flight budget bounds the amplification). let total_data_max = lim.max_frame_bytes.div_ceil(shard_bytes).max(1); + // The per-frame FEC-block ceiling under THIS packet's shard size (geometry is + // per-frame: a shrunk shard needs more blocks for the same bytes, so a session-level + // cap from the negotiated size would reject legitimate post-shrink frames). Mirrors + // the sender's `Packetizer::new` for whatever size it currently packetizes at. + let max_blocks = total_data_max.div_ceil(lim.max_data_shards).max(1); // The slice pipeline's per-frame block ceiling: every non-final slice block carries at // least `min(MIN_STREAM_BLOCK_SHARDS, max_data_per_block)` data shards (the sender's // flush floor, clamped by the block size), so a max-size frame bounds the block count @@ -350,9 +382,7 @@ impl Reassembler { return Ok(None); } } else if sentinel { - if frame_bytes != 0 - || data_shards != lim.max_data_shards - || block_idx + 1 >= lim.max_blocks + if frame_bytes != 0 || data_shards != lim.max_data_shards || block_idx + 1 >= max_blocks { drop(stats); return Ok(None); @@ -361,7 +391,7 @@ impl Reassembler { let block_cap = if slice_stream { slice_block_cap } else { - lim.max_blocks + max_blocks }; if block_count > block_cap || block_idx >= block_count { drop(stats); @@ -513,6 +543,7 @@ impl Reassembler { } *in_flight_bytes += buf_len; e.insert(FrameBuf { + shard_bytes, // A slice-stream sentinel's `frame_bytes` is its block's BASE offset, not a // frame size — the unpinned marker stays 0 until the final block's totals. frame_bytes: if sentinel { 0 } else { frame_bytes }, @@ -527,6 +558,15 @@ impl Reassembler { }) } }; + // Per-frame geometry pin: the frame's first packet pinned its shard size; a later + // packet claiming a different (even in-bounds) size is dropped — otherwise two + // geometries would compute different offsets into one buffer (a splice). This is + // also what makes a mid-session `shard_payload` change safe against reorder: a + // straggler of the old geometry can only ever land in ITS OWN frame's buffer. + if frame.shard_bytes != shard_bytes { + drop(stats); + return Ok(None); + } // The slice marker must be frame-consistent: a mixed frame would firewall under one // placement rule and place under the other. The per-packet checks above and the // placement bounds guard below stay memory-safe without this — it's the tighter drop. @@ -883,6 +923,16 @@ impl Reassembler { // jump-to-live, exactly the stale content the flush existed to discard. self.pending_partial = None; } + + /// Test-only: the current in-flight frame-buffer byte commitment (see + /// [`IN_FLIGHT_BUF_FACTOR`]). The mixed-geometry budget tests assert it returns to + /// exactly zero once every frame has terminated — the 0.23.0 lesson: geometry changes + /// breed sizing bugs, and accounting drift here surfaces in the field as a permanent + /// loss storm once the budget wedges. + #[cfg(test)] + pub(crate) fn in_flight(&self) -> usize { + self.in_flight_bytes + } } /// The data shards of a terminating frame that only exist because parity restored them @@ -1024,10 +1074,10 @@ mod reset_tests { #[test] fn reset_drops_a_parked_partial() { let mut r = Reassembler::new(ReassemblerLimits { - shard_bytes: 64, + min_shard_bytes: 64, + max_shard_bytes: 64, max_data_shards: 8, max_total_shards: 16, - max_blocks: 4, max_frame_bytes: 4096, }); r.pending_partial = Some(Frame { diff --git a/crates/punktfunk-core/src/packet/tests.rs b/crates/punktfunk-core/src/packet/tests.rs index 2dfc4659..e31a9942 100644 --- a/crates/punktfunk-core/src/packet/tests.rs +++ b/crates/punktfunk-core/src/packet/tests.rs @@ -7,11 +7,14 @@ use crate::stats::StatsCounters; use zerocopy::{FromBytes, IntoBytes}; fn limits() -> ReassemblerLimits { + // `min == max` pins the whole stream to 16-byte shards — the strictest geometry, so the + // firewall tests below exercise the bounds checks; per-frame-pinning tests build their own + // limits with a real range. Derived per-frame block ceiling: 4096/16 = 256 shards → 32. ReassemblerLimits { - shard_bytes: 16, + min_shard_bytes: 16, + max_shard_bytes: 16, max_data_shards: 8, max_total_shards: 12, - max_blocks: 4, max_frame_bytes: 4096, } } @@ -840,7 +843,7 @@ fn streamed_sentinel_firewall_bounds() { .unwrap() .is_none()); // Sits on the last block the limits allow (no room for the final block after it). - let h = sentinel(|h| h.block_index = 3); // limits().max_blocks == 4 + let h = sentinel(|h| h.block_index = 31); // derived max_blocks == 32 (see `limits()`) assert!(r .push(&packet(h), coder.as_ref(), &stats) .unwrap() @@ -1769,3 +1772,408 @@ fn slice_streamed_in_flight_budget_matches_legacy() { ); } } + +// --------------------------------------------------------------------------- +// Per-frame shard geometry (mid-session shard-payload renegotiation — W0.1, +// design/shard-payload-reneg.md). The 0.23.0 lesson applies in full: geometry +// changes breed sizing bugs, so the slice/sentinel suite re-runs at every +// production shard size and mixed-geometry streams are tortured under reorder. +// --------------------------------------------------------------------------- + +/// The shard sizes the renegotiation actually moves between: the clamp floor (512), a +/// WARP/Tailscale-shaped 1280-MTU path (1216), the 1500-MTU default (1408), and 9000-MTU +/// jumbo (8908 — sealed 8972, inside [`MAX_DATAGRAM_BYTES`]). +const PRODUCTION_SHARDS: [usize; 4] = [512, 1216, 1408, 8908]; + +/// [`prod_slice_config`] at an arbitrary shard payload. +fn geo_config(shard_payload: usize) -> Config { + let mut c = prod_slice_config(); + c.shard_payload = shard_payload; + c.validate().expect("geometry config must be valid"); + c +} + +/// Packetize one legacy AU at the packetizer's CURRENT shard payload with an explicit +/// frame index, returning wire packets + source bytes. +fn legacy_packets_with( + pk: &mut Packetizer, + frame_index: u32, + pts_ns: u64, + len: usize, + coder: &dyn crate::fec::ErasureCoder, +) -> (Vec>, Vec) { + let src: Vec = (0..len) + .map(|i| (i * 131 + frame_index as usize * 7 + 3) as u8) + .collect(); + let mut pkts: Vec> = Vec::new(); + pk.packetize_each(&src, pts_ns, 0, Some(frame_index), coder, |h, b| { + let mut p = Vec::with_capacity(HEADER_LEN + b.len()); + p.extend_from_slice(h.as_bytes()); + p.extend_from_slice(b); + pkts.push(p); + Ok(()) + }) + .unwrap(); + (pkts, src) +} + +/// The slice-wire regression suite re-run at every production shard size (the design's +/// non-negotiable verification): the exact-multiple sweep (the 0.23.0 filler-shard bug +/// shape), lossy + reversed slice roundtrips, the legacy-streamed sentinel path, and the +/// in-flight budget — each asserting DELIVERED byte-identical frames, never just an +/// absence of errors. +#[test] +fn slice_wire_suite_at_production_shard_sizes() { + let coder = coder_for(FecScheme::Gf16); + for &shard in &PRODUCTION_SHARDS { + let cfg = geo_config(shard); + + // Exact-shard-multiple AUs + the off-by-one sweep around one of them. + for shards in [16usize, 30, 64] { + for extra in 0..3usize { + let n = shards * shard + extra; + let (pkts, src) = streamed_packets_with(&cfg, 1, 1000, true, &[n]); + let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg)); + let stats = StatsCounters::default(); + let f = push_all(&mut r, coder.as_ref(), &stats, &pkts) + .unwrap_or_else(|| panic!("shard {shard}: {n}-byte slice AU must complete")); + assert_eq!( + f.data, src, + "shard {shard}: {n}-byte AU must be byte-identical" + ); + assert_eq!( + r.in_flight(), + 0, + "shard {shard}: budget must return to zero" + ); + } + } + + // A multi-slice AU under loss (one data shard of the first flushed block — within + // its ≥ 20% parity) in both delivery orders. Reversed is the critical order: the + // final block's totals arrive first and every sentinel validates against the pin. + for reverse in [false, true] { + let chunks = [20 * shard + 13, 7 * shard + 1, 17 * shard]; + let (pkts, src) = streamed_packets_with(&cfg, 2, 2000, true, &chunks); + let killed = pkts + .iter() + .position(|p| { + let h = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap(); + h.shard_index < h.data_shards && h.recovery_shards >= 1 + }) + .expect("suite frame must have a recoverable data shard"); + let mut delivery: Vec> = pkts + .iter() + .enumerate() + .filter(|(i, _)| *i != killed) + .map(|(_, p)| p.clone()) + .collect(); + if reverse { + delivery.reverse(); + } + let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg)); + let stats = StatsCounters::default(); + let f = push_all(&mut r, coder.as_ref(), &stats, &delivery).unwrap_or_else(|| { + panic!("shard {shard} reverse={reverse}: lossy slice AU must complete") + }); + assert_eq!(f.data, src, "shard {shard} reverse={reverse}"); + assert_eq!(r.in_flight(), 0); + } + + // Legacy-streamed (uniform full-K sentinel) path: one AU spanning a sentinel block + // (K = 200) plus a final block. + { + let (pkts, src) = streamed_packets_with(&cfg, 3, 3000, false, &[230 * shard]); + let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg)); + let stats = StatsCounters::default(); + let f = push_all(&mut r, coder.as_ref(), &stats, &pkts) + .unwrap_or_else(|| panic!("shard {shard}: legacy-streamed AU must complete")); + assert_eq!(f.data, src); + assert_eq!(r.in_flight(), 0); + } + + // The budget regression at this size: 12 ordinary AUs opened concurrently, no drops. + for slice in [false, true] { + let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg)); + let stats = StatsCounters::default(); + for i in 0..12u32 { + let (pkts, _) = + streamed_packets_with(&cfg, i, 1_000_000 * i as u64, slice, &[40_000]); + r.push(&pkts[0], coder.as_ref(), &stats).unwrap(); + } + assert_eq!( + stats + .packets_dropped + .load(std::sync::atomic::Ordering::Relaxed), + 0, + "shard {shard} slice={slice}: 12 AUs in flight must fit the budget" + ); + } + } +} + +/// One packetizer, one reassembler, one continuous stream — the shard payload swapped +/// live between AUs ([`Packetizer::set_shard_payload`], the Phase 1 host seam): every +/// frame across shrink → grow-to-jumbo → shrink-again delivers byte-identically under its +/// own per-frame pin, and the budget returns to zero. +#[test] +fn mid_stream_shard_swap_delivers_every_frame() { + let cfg = geo_config(1408); + let coder = coder_for(FecScheme::Gf16); + let mut pk = Packetizer::new(&cfg); + let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg)); + let stats = StatsCounters::default(); + + // (shard size to swap to, AU length) — swaps happen between AUs, as Phase 1 will. + let schedule = [ + (1408usize, 3 * 1408 + 100), + (1408, 9 * 1408), + (512, 5 * 512 + 17), // shrink (the VPN heal) + (512, 512), + (8908, 12 * 8908 + 1), // grow (jumbo) + (1216, 4 * 1216 + 9), // revert (a mis-proven jumbo hop self-corrects) + ]; + for (i, &(shard, len)) in schedule.iter().enumerate() { + pk.set_shard_payload(shard); + let pts = 1_000_000 * (i as u64 + 1); + let (pkts, src) = legacy_packets_with(&mut pk, i as u32, pts, len, coder.as_ref()); + for p in &pkts { + let h = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap(); + assert_eq!( + h.shard_bytes as usize, shard, + "sender must stamp the live size" + ); + } + let f = push_all(&mut r, coder.as_ref(), &stats, &pkts) + .unwrap_or_else(|| panic!("frame {i} at shard {shard} must complete")); + assert_eq!( + f.data, src, + "frame {i} at shard {shard} must be byte-identical" + ); + assert!(f.complete); + } + assert_eq!( + r.in_flight(), + 0, + "budget must be exact across geometry swaps" + ); + assert_eq!(stats.snapshot().frames_dropped, 0); +} + +/// The reorder race the design kills structurally: an old-geometry frame still in flight +/// when new-geometry frames start arriving completes under its OWN pin — its straggler +/// lands in its own buffer, not the new geometry's. +#[test] +fn old_geometry_frame_completes_after_new_geometry_arrived() { + let cfg = geo_config(1408); + let coder = coder_for(FecScheme::Gf16); + let mut pk = Packetizer::new(&cfg); + let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg)); + let stats = StatsCounters::default(); + + // Frame 0 at 1408: 7 data shards + 2 parity (20% FEC), data-first wire order. Withhold + // THREE data shards — more than parity can bridge — so the frame genuinely stays + // incomplete until a straggler returns (fewer, and FEC would complete it early). + let (pkts0, src0) = legacy_packets_with(&mut pk, 0, 1_000_000, 6 * 1408 + 50, coder.as_ref()); + assert_eq!( + pkts0.len(), + 9, + "expected geometry changed — update the split" + ); + let head: Vec> = pkts0[..4].iter().chain(&pkts0[7..]).cloned().collect(); + let straggler = &pkts0[4]; + assert!( + push_all(&mut r, coder.as_ref(), &stats, &head).is_none(), + "frame 0 must still be incomplete" + ); + + // The stream re-keys to 512: frames 1..=2 arrive whole and deliver. + pk.set_shard_payload(512); + for i in 1..=2u32 { + let pts = 1_000_000 + 1_000_000 * i as u64; + let (pkts, src) = legacy_packets_with(&mut pk, i, pts, 3 * 512 + 7, coder.as_ref()); + let f = push_all(&mut r, coder.as_ref(), &stats, &pkts).expect("new-geometry frame"); + assert_eq!(f.data, src); + } + + // Frame 0's old-geometry straggler arrives last — the frame completes byte-identically. + let f = r + .push(straggler, coder.as_ref(), &stats) + .unwrap() + .expect("old-geometry frame must complete under its own pin"); + assert_eq!(f.data, src0); + assert_eq!(f.frame_index, 0); + assert_eq!(r.in_flight(), 0); + assert_eq!(stats.snapshot().frames_dropped, 0); +} + +/// The anti-splice pin: a packet claiming a DIFFERENT (but in-bounds) shard size for an +/// already-pinned frame is dropped — and the frame still completes from its real packets. +#[test] +fn cross_geometry_packet_for_a_pinned_frame_is_dropped() { + let cfg = geo_config(1408); + let coder = coder_for(FecScheme::Gf16); + let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg)); + let stats = StatsCounters::default(); + + let mut pk_a = Packetizer::new(&geo_config(1408)); + let mut pk_b = Packetizer::new(&geo_config(1216)); + let (pkts, src) = legacy_packets_with(&mut pk_a, 0, 1_000_000, 5 * 1408 + 9, coder.as_ref()); + // The impostor: the same frame index packetized at 1216 — self-consistent (it passes + // the firewall standalone), wrong for THIS frame's pin. + let (impostor, _) = legacy_packets_with(&mut pk_b, 0, 1_000_000, 5 * 1216, coder.as_ref()); + + assert!(r.push(&pkts[0], coder.as_ref(), &stats).unwrap().is_none()); + let before = stats.snapshot().packets_dropped; + assert!(r + .push(&impostor[1], coder.as_ref(), &stats) + .unwrap() + .is_none()); + assert_eq!( + stats.snapshot().packets_dropped, + before + 1, + "cross-geometry packet must be dropped by the frame pin" + ); + let f = push_all(&mut r, coder.as_ref(), &stats, &pkts[1..]) + .expect("the pinned frame must still complete from its real packets"); + assert_eq!(f.data, src, "no impostor bytes may reach the frame"); +} + +/// The firewall bounds on a frame's pinned size: below the floor, above the receive +/// ceiling, or odd ⇒ dropped before any allocation; the exact floor and ceiling are +/// accepted AND deliver (proving the rejections aren't vacuous). +#[test] +fn shard_size_firewall_bounds() { + let cfg = geo_config(1408); + let lim = ReassemblerLimits::from_config(&cfg); + assert_eq!(lim.min_shard_bytes, crate::config::MIN_SHARD_PAYLOAD); + assert_eq!(lim.max_shard_bytes, crate::config::max_shard_payload()); + let coder = coder_for(FecScheme::Gf16); + let mut r = Reassembler::new(lim); + let stats = StatsCounters::default(); + + let single = |shard: usize, frame_index: u32| { + let mut h = base_header(); + h.frame_index = frame_index; + h.shard_bytes = shard as u16; + h.frame_bytes = shard as u32; + h + }; + // Below the floor (even), above the ceiling (even), odd within bounds: all dropped. + for (i, shard) in [510usize, 9154, 1409].into_iter().enumerate() { + let before = stats.snapshot().packets_dropped; + assert!(r + .push(&packet(single(shard, i as u32)), coder.as_ref(), &stats) + .unwrap() + .is_none()); + assert_eq!( + stats.snapshot().packets_dropped, + before + 1, + "shard {shard} must be firewalled" + ); + } + // The exact bounds deliver whole single-shard frames. + for (i, shard) in [ + crate::config::MIN_SHARD_PAYLOAD, + crate::config::max_shard_payload(), + ] + .into_iter() + .enumerate() + { + let f = r + .push( + &packet(single(shard, 10 + i as u32)), + coder.as_ref(), + &stats, + ) + .unwrap() + .unwrap_or_else(|| panic!("boundary shard {shard} must deliver")); + assert_eq!(f.data.len(), shard); + } +} + +mod geometry_proptests { + use super::*; + use proptest::prelude::*; + + /// One generated frame: shard size, slice-vs-legacy wire, size factor, and whether to + /// kill one recoverable data shard. + type GenFrame = (usize, bool, usize, bool); + + fn frame_strategy() -> impl Strategy { + ( + proptest::sample::select(&PRODUCTION_SHARDS[..]), + any::(), + 1usize..30, + any::(), + ) + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(48))] + + /// Mixed-geometry reorder torture: frames of DIFFERENT shard sizes and wire shapes + /// interleaved into one shuffled delivery, with per-frame recoverable loss — every + /// frame must deliver byte-identically and the in-flight budget must return to + /// exactly zero (the 0.23.0 budget-drift shape, now across geometries). + #[test] + fn mixed_geometry_reorder_torture( + frames in proptest::collection::vec(frame_strategy(), 2..6), + seed in any::(), + ) { + let coder = coder_for(FecScheme::Gf16); + let mut r = Reassembler::new(ReassemblerLimits::from_config(&geo_config(1408))); + let stats = StatsCounters::default(); + + let mut all: Vec<(u64, u32, Vec)> = Vec::new(); // (shuffle key, frame, pkt) + let mut sources: Vec<(u32, Vec)> = Vec::new(); + for (i, &(shard, slice, factor, kill)) in frames.iter().enumerate() { + let cfg = geo_config(shard); + let pts = 1_000_000 * (i as u64 + 1); + let len = factor * shard + (factor % shard.min(7)); + let (mut pkts, src) = if slice { + streamed_packets_with(&cfg, i as u32, pts, true, &[len.max(1)]) + } else { + let mut pk = Packetizer::new(&cfg); + legacy_packets_with(&mut pk, i as u32, pts, len.max(1), coder.as_ref()) + }; + if kill { + if let Some(k) = pkts.iter().position(|p| { + let h = PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap(); + h.shard_index < h.data_shards && h.recovery_shards >= 1 + }) { + pkts.remove(k); + } + } + for (j, p) in pkts.into_iter().enumerate() { + // Deterministic pseudo-shuffle key: interleaves frames and reorders + // within a frame, differently per proptest case. + let key = (seed | 1) + .wrapping_mul(j as u64 + 1) + .wrapping_add((i as u64) << 17) + .rotate_left((j % 61) as u32); + all.push((key, i as u32, p)); + } + sources.push((i as u32, src)); + } + all.sort_by_key(|(k, _, _)| *k); + + let mut delivered: std::collections::HashMap> = + std::collections::HashMap::new(); + for (_, _, p) in &all { + if let Some(f) = r.push(p, coder.as_ref(), &stats).unwrap() { + prop_assert!(f.complete); + prop_assert!(delivered.insert(f.frame_index, f.data).is_none(), + "a frame must deliver exactly once"); + } + } + for (i, src) in &sources { + let got = delivered.get(i); + prop_assert!(got.is_some(), "frame {i} must be DELIVERED, not merely error-free"); + prop_assert_eq!(got.unwrap(), src, "frame {} must be byte-identical", i); + } + prop_assert_eq!(r.in_flight(), 0, "budget must be exact after all frames terminate"); + prop_assert_eq!(stats.snapshot().frames_dropped, 0u64); + } + } +} diff --git a/crates/punktfunk-core/src/quic/handshake.rs b/crates/punktfunk-core/src/quic/handshake.rs index 0f25aa54..1b780de8 100644 --- a/crates/punktfunk-core/src/quic/handshake.rs +++ b/crates/punktfunk-core/src/quic/handshake.rs @@ -90,8 +90,19 @@ pub struct Hello { /// disambiguated by REMAINING LENGTH at decode: fewer than `HDR_META_BODY_LEN` bytes after /// `preferred_codec` ⇒ no HDR block, the tail bytes are the post-HDR fields directly. This /// caps everything after `display_hdr` at `HDR_META_BODY_LEN − 1` bytes total — document any - /// future field here and mind the budget. Omitted when zero and by older clients (→ `0`). + /// future field here and mind the budget (`client_caps` 1 + `max_shard_payload` 2 = 3 of the + /// 27 spent). Omitted when zero and by older clients (→ `0`). pub client_caps: u8, + /// The largest video shard payload this client's receive path accepts — sealed datagrams for + /// shards up to this size fit its transport buffers ([`crate::config::max_shard_payload`]). + /// One field carries BOTH facts the host needs for mid-session shard renegotiation + /// (design/shard-payload-reneg.md W0.3): non-zero ⇒ the client reassembles per-frame + /// geometry (a mid-session `shard_payload` change is safe to send), and the value is the + /// hard ceiling a jumbo grow may never exceed. Appended after `client_caps` as 2 trailing + /// LE bytes (forcing the earlier placeholders). Omitted by older clients (decodes to `0` + /// = legacy: the host must not change the sealed geometry mid-session, and never above + /// the `Welcome` value). + pub max_shard_payload: u16, } /// QUIC application error code a punktfunk/1 client closes the control connection with on a @@ -254,12 +265,14 @@ impl Hello { let pref_present = self.preferred_codec != 0; let hdr_present = self.display_hdr.is_some(); let ccaps_present = self.client_caps != 0; + let msp_present = self.max_shard_payload != 0; let need_placeholders = self.video_caps != 0 || ac_present || vcodecs_present || pref_present || hdr_present - || ccaps_present; + || ccaps_present + || msp_present; match (&self.name, &self.launch) { (None, None) if !need_placeholders => {} (name, _) => { @@ -280,15 +293,21 @@ impl Hello { b.push(self.video_caps); } // audio_channels: emitted when non-stereo OR a later field follows. - if ac_present || vcodecs_present || pref_present || hdr_present || ccaps_present { + if ac_present + || vcodecs_present + || pref_present + || hdr_present + || ccaps_present + || msp_present + { b.push(self.audio_channels); } // video_codecs: emitted when non-zero OR a later field follows. - if vcodecs_present || pref_present || hdr_present || ccaps_present { + if vcodecs_present || pref_present || hdr_present || ccaps_present || msp_present { b.push(self.video_codecs); } // preferred_codec: emitted when non-zero OR a later field follows. - if pref_present || hdr_present || ccaps_present { + if pref_present || hdr_present || ccaps_present || msp_present { b.push(self.preferred_codec); } // display_hdr: fixed HDR_META_BODY_LEN-byte HdrMeta body; omitted when `None` even if @@ -297,10 +316,15 @@ impl Hello { if let Some(m) = &self.display_hdr { super::datagram::write_hdr_meta_body(m, &mut b); } - // client_caps: single byte after the (optional) HDR block. Emitted when non-zero. - if ccaps_present { + // client_caps: single byte after the (optional) HDR block. Emitted when non-zero OR a + // later field follows. + if ccaps_present || msp_present { b.push(self.client_caps); } + // max_shard_payload: 2 trailing LE bytes after client_caps. Emitted when non-zero. + if msp_present { + b.extend_from_slice(&self.max_shard_payload.to_le_bytes()); + } b } @@ -386,6 +410,19 @@ impl Hello { }; b.get(off).copied().unwrap_or(0) }, + // max_shard_payload: 2 LE bytes after client_caps (same post-HDR offset rule). + // Absent on an older client → 0 = no mid-session renegotiation, no jumbo. + max_shard_payload: { + let off = if b.len().saturating_sub(tail + 4) >= super::datagram::HDR_META_BODY_LEN + { + tail + 4 + super::datagram::HDR_META_BODY_LEN + } else { + tail + 4 + }; + b.get(off + 1..off + 3) + .map(|s| u16::from_le_bytes(s.try_into().unwrap())) + .unwrap_or(0) + }, }) } } @@ -867,6 +904,7 @@ mod tests { preferred_codec: CODEC_H264, display_hdr: None, client_caps: 0, + max_shard_payload: 0, }; let enc = h.encode(); let dec = Hello::decode(&enc).unwrap(); @@ -944,6 +982,7 @@ mod tests { preferred_codec: CODEC_HEVC, display_hdr: None, client_caps: 0, + max_shard_payload: 0, }; assert_eq!(Hello::decode(&h.encode()).unwrap(), h); let s = Start { @@ -975,6 +1014,7 @@ mod tests { preferred_codec: 0, display_hdr: None, client_caps: 0, + max_shard_payload: 0, }; let enc = h.encode(); assert_eq!(enc.len(), 26); @@ -1093,6 +1133,7 @@ mod tests { preferred_codec: 0, display_hdr: None, client_caps: 0, + max_shard_payload: 0, }; let enc = base.encode(); assert_eq!( @@ -1145,6 +1186,7 @@ mod tests { preferred_codec: 0, display_hdr: None, client_caps: 0, + max_shard_payload: 0, }; // launch alone (no name): a zero-length name placeholder keeps the offset deterministic. let with_launch = Hello { @@ -1205,6 +1247,7 @@ mod tests { preferred_codec: 0, display_hdr: None, client_caps: 0, + max_shard_payload: 0, }; // A real client-panel volume (P3 primaries, 800-nit peak, 0.05-nit floor, 400-nit FALL). let vol = HdrMeta { @@ -1273,6 +1316,7 @@ mod tests { preferred_codec: 0, display_hdr: None, client_caps: 0, + max_shard_payload: 0, } .encode(); assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair"); @@ -1306,6 +1350,7 @@ mod tests { preferred_codec: 0, display_hdr: None, client_caps: 0, + max_shard_payload: 0, }; let vol = HdrMeta { display_primaries: [[13250, 34500], [7500, 3000], [34000, 16000]], @@ -1319,6 +1364,7 @@ mod tests { // fixed block length, so the decoder must NOT read it as a truncated HdrMeta). let caps_only = Hello { client_caps: CLIENT_CAP_CURSOR, + max_shard_payload: 0, ..base.clone() }; assert_eq!(Hello::decode(&caps_only.encode()).unwrap(), caps_only); @@ -1326,6 +1372,7 @@ mod tests { let both = Hello { display_hdr: Some(vol), client_caps: CLIENT_CAP_CURSOR, + max_shard_payload: 0, ..base.clone() }; assert_eq!(Hello::decode(&both.encode()).unwrap(), both); @@ -1344,8 +1391,73 @@ mod tests { Hello::decode(&enc[..enc.len() - 1]).unwrap(), Hello { client_caps: 0, + max_shard_payload: 0, ..both.clone() } ); } + + /// `max_shard_payload` (mid-session shard renegotiation, design/shard-payload-reneg.md + /// W0.3): roundtrips, forces the earlier placeholders (deterministic offset), composes + /// with the optional HDR block, and degrades to 0 = legacy in BOTH directions. + #[test] + fn hello_max_shard_payload_roundtrip_and_back_compat() { + let base = Hello { + abi_version: 2, + mode: Mode { + width: 1920, + height: 1080, + refresh_hz: 60, + }, + compositor: CompositorPref::Auto, + gamepad: GamepadPref::Auto, + bitrate_kbps: 0, + name: None, + launch: None, + video_caps: 0, + audio_channels: 2, + video_codecs: 0, + preferred_codec: 0, + display_hdr: None, + client_caps: 0, + max_shard_payload: 0, + }; + // The advertisement alone: every earlier trailing field is emitted as a placeholder + // so the 2 LE bytes land at a deterministic offset — and the whole thing roundtrips. + let adv = Hello { + max_shard_payload: crate::config::max_shard_payload() as u16, + ..base.clone() + }; + assert_eq!(Hello::decode(&adv.encode()).unwrap(), adv); + // Composes with client_caps AND the fixed HDR block (the remaining-length + // disambiguation must still find both fields after it). + let vol = HdrMeta { + display_primaries: [[13250, 34500], [7500, 3000], [34000, 16000]], + white_point: [15635, 16450], + max_display_mastering_luminance: 8_000_000, + min_display_mastering_luminance: 500, + max_cll: 0, + max_fall: 400, + }; + let full = Hello { + display_hdr: Some(vol), + client_caps: CLIENT_CAP_CURSOR, + max_shard_payload: 8908, + ..base.clone() + }; + assert_eq!(Hello::decode(&full.encode()).unwrap(), full); + // An older client (no trailing bytes at all) decodes to 0 = legacy: the host must + // not change the sealed geometry mid-session. + assert_eq!(Hello::decode(&base.encode()).unwrap().max_shard_payload, 0); + // An older HOST reading an advertising Hello never looks past the fields it knows — + // truncating the 2 trailing bytes yields the same Hello minus the advertisement. + let enc = full.encode(); + assert_eq!( + Hello::decode(&enc[..enc.len() - 2]).unwrap(), + Hello { + max_shard_payload: 0, + ..full.clone() + } + ); + } } diff --git a/crates/punktfunk-core/src/session.rs b/crates/punktfunk-core/src/session.rs index 8ac9033b..6101430d 100644 --- a/crates/punktfunk-core/src/session.rs +++ b/crates/punktfunk-core/src/session.rs @@ -603,6 +603,31 @@ impl Session { self.packetizer.set_fec_percent(pct); } + /// Host: live-swap the wire shard payload between AUs (mid-session shard renegotiation, + /// design/shard-payload-reneg.md). Affects the next sealed AU; call only between AUs + /// (never with a `StreamedAu` in flight — see [`Packetizer::set_shard_payload`]). The new + /// value must satisfy the exact bounds `Config::validate` imposed on the negotiated one + /// (even, > 0, fits a datagram, block count fits the wire) — validated here against a + /// probe of the session config. The PROTOCOL side is the caller's contract: a current + /// client reassembles any in-bounds size per-frame, but a shrink may be sent immediately + /// while a grow must be client-acked and never exceed the client's advertised + /// `Hello::max_shard_payload` ceiling. + pub fn set_shard_payload(&mut self, shard_payload: usize) -> Result<()> { + if self.config.role != Role::Host { + return Err(PunktfunkError::InvalidArg( + "set_shard_payload called on a client session", + )); + } + // Full `Config::validate` parity, zero drift: probe a copy (its key/salt copies are + // zeroized on drop) rather than re-spelling the shard clauses here. + let mut probe = self.config.clone(); + probe.shard_payload = shard_payload; + probe.validate()?; + self.config.shard_payload = shard_payload; + self.packetizer.set_shard_payload(shard_payload); + Ok(()) + } + /// The current FEC recovery percentage (host side). pub fn fec_percent(&self) -> u8 { self.packetizer.fec_percent() @@ -1060,4 +1085,64 @@ mod wire_equivalence_tests { "unflagged AUs must never be delivered partial" ); } + + /// Mid-session shard renegotiation end to end over the SEALED loopback wire + /// (design/shard-payload-reneg.md): one host session re-keys its packetizer between AUs + /// — shrink, jumbo grow, revert — through one continuous crypto/replay stream, and one + /// client session must DELIVER every frame byte-identically (the vacuous-green lesson: + /// assert delivered frames, never the absence of errors). + #[test] + fn mid_session_shard_swap_delivers_frames_over_the_sealed_wire() { + let mk = |role: Role| { + let mut c = host_cfg(FecScheme::Gf16, 20, true); + c.role = role; + c.shard_payload = 1408; + c.fec.max_data_per_block = 64; + c + }; + let (ht, ct) = loopback_pair(0, 0); + let mut host = Session::new(mk(Role::Host), Box::new(ht)).unwrap(); + let mut client = Session::new(mk(Role::Client), Box::new(ct)).unwrap(); + + let phases: [(usize, &[usize]); 4] = [ + (1408, &[3000, 3 * 1408]), // the negotiated default (incl. exact multiple) + (512, &[2000, 5 * 512 + 17]), // shrink — the mid-session VPN heal + (8908, &[100_000]), // grow — jumbo on a 9000-MTU LAN + (1216, &[2 * 1216 + 9]), // revert — a mis-proven jumbo hop self-corrects + ]; + let mut pts = 0u64; + let mut delivered = 0usize; + for (shard, lens) in phases { + host.set_shard_payload(shard).unwrap(); + assert_eq!(host.shard_payload(), shard); + for &len in lens { + pts += 1_000_000; + let src = pattern(len); + host.submit_frame(&src, pts, 0).unwrap(); + let f = client + .poll_frame() + .unwrap_or_else(|e| panic!("shard {shard}: frame must be DELIVERED ({e})")); + assert_eq!( + f.data, src, + "shard {shard}: {len} B frame must be byte-identical" + ); + assert!(f.complete); + delivered += 1; + } + } + assert_eq!(delivered, 6, "every submitted frame must be delivered"); + // The setter is host-side machinery: a client session must refuse it, and an + // invalid size (odd / oversized) must be rejected without touching the live config. + assert!(client.set_shard_payload(1408).is_err()); + assert!( + host.set_shard_payload(1407).is_err(), + "odd must be rejected" + ); + assert!( + host.set_shard_payload(crate::config::max_shard_payload() + 2) + .is_err(), + "oversized must be rejected" + ); + assert_eq!(host.shard_payload(), 1216, "failed swaps must not stick"); + } } diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index d5dcc2c1..154239ae 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -481,7 +481,17 @@ // Largest UDP datagram the core will send or accept. `Config::validate` bounds // `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`. -#define MAX_DATAGRAM_BYTES 2048 +// +// Sized for **jumbo frames** (design/shard-payload-reneg.md W0.2): a 9000-MTU LAN carries +// ~8908-byte shards (sealed 8972-byte UDP payloads), and every receive path — the transport +// `RECV_BUF`, the session's `recvmmsg` ring — is sized from this constant, so a deployed +// client can accept a jumbo geometry the moment its host negotiates one. The ring cost is +// 128 × ~9 KiB ≈ 1.1 MiB per **client** session (lazily allocated on first poll; hosts never +// allocate it) — measured against the ~256 KiB it was at 2048, an acceptable static price +// for never having to resize buffers on a mid-session grow. Senders still derive their +// shard payload from the path MTU (`config::mtu1500_shard_payload*`, the wire-MTU clamps); +// this is the acceptance ceiling, not a transmit size. +#define MAX_DATAGRAM_BYTES 9216 // The slice-flush floor: a sentinel block below this many data shards costs disproportionate // per-block FEC parity (`ceil(k × pct/100)` ≥ 1 whatever `k`), so slice boundaries only flush From 63a4f583b926e17923cc5302ad3b96a9b3c6ded8 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 19:40:53 +0200 Subject: [PATCH 24/53] =?UTF-8?q?feat(console):=20profiles=20reach=20the?= =?UTF-8?q?=20gamepad=20UI=20=E2=80=94=20pinned=20cards,=20pin=20managemen?= =?UTF-8?q?t,=20settings=20section?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Skia console now renders a pinned profile card after its host's primary tile (KnownHost::pinned_profiles resolved by the service thread), connects with that profile as a one-off via the existing effective_settings resolver, and shows the bound default profile on the primary tile. The settings screen gains a trailing Profiles section — one row per catalog profile with a live pin count — whose activation opens a pin-to-hosts screen; toggles ride the new ConsoleCmd::SetPin to the binary, which persists pinned_profiles (the same field the CLI resolves for Decky's host list). Profiles themselves stay desktop-authored (design client-settings-profiles.md §5.2a, §5.4). --- clients/session/src/console.rs | 98 ++++++- crates/pf-console-ui/src/lib.rs | 4 +- crates/pf-console-ui/src/model.rs | 35 ++- crates/pf-console-ui/src/screens.rs | 9 + crates/pf-console-ui/src/screens/home.rs | 128 ++++++++- crates/pf-console-ui/src/screens/library.rs | 2 + crates/pf-console-ui/src/screens/pair.rs | 3 + crates/pf-console-ui/src/screens/pin_hosts.rs | 266 ++++++++++++++++++ crates/pf-console-ui/src/screens/settings.rs | 256 +++++++++++++++-- crates/pf-console-ui/src/shell.rs | 9 +- crates/pf-console-ui/src/shell/tests.rs | 2 + crates/pf-console-ui/src/skia_overlay.rs | 5 +- crates/pf-presenter/src/overlay.rs | 5 + 13 files changed, 774 insertions(+), 48 deletions(-) create mode 100644 crates/pf-console-ui/src/screens/pin_hosts.rs diff --git a/clients/session/src/console.rs b/clients/session/src/console.rs index 99d6687b..a424d70a 100644 --- a/clients/session/src/console.rs +++ b/clients/session/src/console.rs @@ -79,20 +79,22 @@ pub fn run(target: Option<&str>) -> u8 { can_wake: false, last_used: k.and_then(|h| h.last_used), os: k.map(|h| h.os.clone()).unwrap_or_default(), + pin: None, + bound_profile: None, }; let label = row.name.clone(); if k.is_none() { seed = Some(row.clone()); } if row.paired { - (ConsoleEntry::Library(row), Some(label)) + (ConsoleEntry::Library(Box::new(row)), Some(label)) } else { (ConsoleEntry::Home, Some(label)) } } None if fake => { let row = fake_host_row(); - (ConsoleEntry::Library(row), None) + (ConsoleEntry::Library(Box::new(row)), None) } None => (ConsoleEntry::Home, None), }; @@ -207,6 +209,7 @@ pub fn run(target: Option<&str>) -> u8 { launch, title, request_access, + profile, } => { let Some(pin) = trust::parse_hex32(&fp_hex) else { // Connect (and request-access) pin the host's advertised fingerprint; @@ -221,9 +224,11 @@ pub fn run(target: Option<&str>) -> u8 { // have changed the defaults since the last stream, and the host may carry // a profile binding. Console (and therefore Decky, which spawns this // binary) honors bindings with no console-side work — the resolver is the - // same one `--connect` goes through. No one-off here: picking a profile is - // a desktop-shell affordance in v1, pinned cards are the console's. - let (settings, profile) = trust::effective_settings(&addr, port, None); + // same one `--connect` goes through. A pinned card's connect arrives as a + // one-off profile id; the resolver prefers it over the binding, and a + // dangling id falls back to the defaults without blocking the connect. + let (settings, profile) = + trust::effective_settings(&addr, port, profile.as_deref()); let mut params = session_params( &settings, profile.map(|p| p.name), @@ -303,6 +308,8 @@ fn fake_host_row() -> HostRow { can_wake: false, last_used: None, os: "linux/arch/steamos".into(), + pin: None, + bound_profile: None, } } @@ -506,6 +513,38 @@ impl ServiceState { ConsoleCmd::Probe => { self.last_probe = Instant::now() - Duration::from_secs(60); } + ConsoleCmd::SetPin { + key, + profile_id, + pin, + } => { + // Presentation only (design §5.2a): order = card order, appended at the + // end; never touches `profile_id` (the default binding). Idempotent, so + // a repeated press inside one refresh window can't double-pin. + let mut known = trust::KnownHosts::load(); + let idx = known + .hosts + .iter() + .position(|h| !h.fp_hex.is_empty() && h.fp_hex == key) + .or_else(|| { + let (addr, port) = key.rsplit_once(':')?; + known.index_by_addr(addr, port.parse().ok()?) + }); + let Some(h) = idx.and_then(|i| known.hosts.get_mut(i)) else { + tracing::warn!(%key, "pin toggle for an unknown host — ignoring"); + return; + }; + if pin && !h.pinned_profiles.contains(&profile_id) { + h.pinned_profiles.push(profile_id); + } else if !pin { + h.pinned_profiles.retain(|id| *id != profile_id); + } + if let Err(e) = known.save() { + tracing::warn!(error = %format!("{e:#}"), "saving known hosts"); + } + // `run` refreshes the rows right after this drain, so the carousel and + // the pin screen reflect the new card within the same service pass. + } } } @@ -544,12 +583,21 @@ impl ServiceState { }) } - /// The console home's rows: saved hosts (most recent first), then - /// discovered-but-unsaved ones, then a still-uncovered `--browse` seed. + /// The console home's rows: saved hosts (most recent first) — each followed by its + /// pinned profile cards (design §5.2a) — then discovered-but-unsaved ones, then a + /// still-uncovered `--browse` seed. fn rows(&self) -> Vec { let known = trust::KnownHosts::load(); + let catalog = pf_client_core::profiles::ProfilesFile::load(); let probed = self.probed.lock().unwrap(); - let mut rows: Vec = known + let chip = |p: &pf_client_core::profiles::StreamProfile| pf_console_ui::ProfileChip { + id: p.id.clone(), + name: p.name.clone(), + accent: p.accent.clone(), + }; + // Primary rows paired with their pinned cards, so the sort below can order hosts + // while every host's cards stay glued behind its primary tile. + let mut saved: Vec<(HostRow, Vec)> = known .hosts .iter() .map(|h| { @@ -563,8 +611,8 @@ impl ServiceState { || (d.addr == h.addr && d.port == h.port) }); let online = advert.is_some() || probed.get(&key).copied().unwrap_or(false); - HostRow { - key, + let row = HostRow { + key: key.clone(), name: host_display_name(&h.name, &h.addr), addr: h.addr.clone(), port: h.port, @@ -581,10 +629,34 @@ impl ServiceState { .filter(|d| !d.os.is_empty()) .map(|d| d.os.clone()) .unwrap_or_else(|| h.os.clone()), - } + pin: None, + bound_profile: h + .profile_id + .as_deref() + .and_then(|id| catalog.find_by_id(id)) + .map(chip), + }; + // A pinned card shares the primary tile's live state; its key rides the + // profile id behind a NUL (impossible in a fingerprint or `addr:port`), + // so cursor-follow and the wake path address the card itself. + let pins = h + .resolved_pins(&catalog) + .into_iter() + .map(|p| HostRow { + key: format!("{key}\0{}", p.id), + pin: Some(chip(p)), + bound_profile: None, + ..row.clone() + }) + .collect(); + (row, pins) }) .collect(); - rows.sort_by(|a, b| b.last_used.cmp(&a.last_used).then(a.name.cmp(&b.name))); + saved.sort_by(|(a, _), (b, _)| b.last_used.cmp(&a.last_used).then(a.name.cmp(&b.name))); + let mut rows: Vec = saved + .into_iter() + .flat_map(|(row, pins)| std::iter::once(row).chain(pins)) + .collect(); let mut extra: Vec = self .discovered @@ -612,6 +684,8 @@ impl ServiceState { can_wake: false, last_used: None, os: d.os.clone(), + pin: None, + bound_profile: None, }) .collect(); extra.sort_by(|a, b| a.name.cmp(&b.name)); diff --git a/crates/pf-console-ui/src/lib.rs b/crates/pf-console-ui/src/lib.rs index 4194b2b0..933ae725 100644 --- a/crates/pf-console-ui/src/lib.rs +++ b/crates/pf-console-ui/src/lib.rs @@ -35,7 +35,9 @@ mod widgets; #[cfg(any(target_os = "linux", windows))] pub use library::{LibraryGame, LibraryPhase, LibraryShared}; #[cfg(any(target_os = "linux", windows))] -pub use model::{ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, WakeStatus}; +pub use model::{ + ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, ProfileChip, WakeStatus, +}; #[cfg(any(target_os = "linux", windows))] pub use shell::ConsoleOptions; #[cfg(any(target_os = "linux", windows))] diff --git a/crates/pf-console-ui/src/model.rs b/crates/pf-console-ui/src/model.rs index 3d810df2..ac5b0102 100644 --- a/crates/pf-console-ui/src/model.rs +++ b/crates/pf-console-ui/src/model.rs @@ -7,9 +7,20 @@ use std::collections::VecDeque; use std::sync::{Arc, Mutex}; +/// A settings profile as the console shows it (design client-settings-profiles.md §5.2a): +/// the resolved name and accent of a catalog entry, keyed by its stable id. The service +/// thread resolves these against the catalog; the shell never opens the profiles file. +#[derive(Clone, Debug, PartialEq)] +pub struct ProfileChip { + pub id: String, + pub name: String, + /// `#RRGGBB`, the catalog's optional tint for pinned cards. + pub accent: Option, +} + /// One row on the console home carousel — a saved host, a discovered-but-unsaved one, -/// or (client-side) the trailing Add Host tile. Fully resolved by the service thread; -/// the shell renders it verbatim. +/// a pinned profile card, or (client-side) the trailing Add Host tile. Fully resolved by +/// the service thread; the shell renders it verbatim. #[derive(Clone, Debug, PartialEq)] pub struct HostRow { /// Stable identity across refreshes: the pinned fingerprint when known, else @@ -35,6 +46,14 @@ pub struct HostRow { /// future tile OS glyph. Empty = unknown (older host). Plumbed now; drawing is a /// follow-up — the Skia glyph set doesn't exist yet. pub os: String, + /// `Some` = this row is a pinned profile card (§5.2a): a shortcut tile rendered right + /// after its host's primary tile, sharing its live state, that connects with THIS + /// profile. `None` = the host's primary tile. + pub pin: Option, + /// The primary tile's default-profile chip: the profile bound as this host's default + /// (`KnownHost::profile_id`), resolved, so the tile can say what a plain A-press uses. + /// Always `None` on pinned rows — there the profile IS `pin`. + pub bound_profile: Option, } /// The pairing ceremony's observable state (one at a time — the ceremony is modal). @@ -143,6 +162,16 @@ pub enum ConsoleCmd { CancelWake, /// Sweep reachability now (the home screen refreshes its presence pips). Probe, + /// Pin (or unpin) a profile as an extra connect card on a saved host + /// (`KnownHost::pinned_profiles`, design §5.2a). `key` is the HOST row's key + /// (fingerprint or `addr:port`); presentation only — never touches the host's + /// default binding or the profile itself. Idempotent: re-pinning a pinned profile + /// (or unpinning an absent one) is a no-op. + SetPin { + key: String, + profile_id: String, + pin: bool, + }, } /// The overlay→binary command queue. A plain deque under the same locking discipline as @@ -184,6 +213,8 @@ mod tests { can_wake: false, last_used: None, os: String::new(), + pin: None, + bound_profile: None, }; shared.set_hosts(vec![row.clone()]); let g1 = shared.hosts_gen(); diff --git a/crates/pf-console-ui/src/screens.rs b/crates/pf-console-ui/src/screens.rs index 7c7e3232..a7e2730e 100644 --- a/crates/pf-console-ui/src/screens.rs +++ b/crates/pf-console-ui/src/screens.rs @@ -7,6 +7,7 @@ pub(crate) mod add_host; pub(crate) mod home; pub(crate) mod library; pub(crate) mod pair; +pub(crate) mod pin_hosts; pub(crate) mod settings; use crate::glyphs::Hint; @@ -57,6 +58,9 @@ pub(crate) struct ConnectIntent { /// shell shows a "waiting for approval" takeover instead of "connecting", and the /// binary parks on a long budget and persists the host as paired once let in. pub request_access: bool, + /// One-off settings-profile id for this launch (a pinned card's connect); `None` + /// keeps the host's default binding. + pub profile: Option, } pub(crate) enum Nav { @@ -91,6 +95,7 @@ pub(crate) enum Screen { Settings(settings::SettingsScreen), AddHost(add_host::AddHostScreen), Pair(pair::PairScreen), + PinHosts(pin_hosts::PinHostsScreen), } impl Screen { @@ -106,6 +111,7 @@ impl Screen { Screen::Settings(s) => s.menu(ev, ctx, fx), Screen::AddHost(s) => s.menu(ev, ctx, fx), Screen::Pair(s) => s.menu(ev, ctx, fx), + Screen::PinHosts(s) => s.menu(ev, ctx, fx), } } @@ -152,6 +158,7 @@ impl Screen { Screen::Settings(_) => "Settings".into(), Screen::AddHost(_) => "Add Host".into(), Screen::Pair(s) => format!("Pair with {}", s.host_name()), + Screen::PinHosts(s) => format!("Pin \u{201c}{}\u{201d}", s.profile_name()), } } @@ -162,6 +169,7 @@ impl Screen { Screen::Settings(s) => s.hints(ctx), Screen::AddHost(s) => s.hints(ctx), Screen::Pair(s) => s.hints(ctx), + Screen::PinHosts(s) => s.hints(ctx), } } @@ -183,6 +191,7 @@ impl Screen { Screen::Settings(s) => s.render(canvas, rect, k, dt, fonts, ctx), Screen::AddHost(s) => s.render(canvas, rect, k, dt, fonts, ctx), Screen::Pair(s) => s.render(canvas, rect, k, dt, fonts, ctx), + Screen::PinHosts(s) => s.render(canvas, rect, k, dt, fonts, ctx), } } } diff --git a/crates/pf-console-ui/src/screens/home.rs b/crates/pf-console-ui/src/screens/home.rs index bc97dc49..482e6213 100644 --- a/crates/pf-console-ui/src/screens/home.rs +++ b/crates/pf-console-ui/src/screens/home.rs @@ -94,13 +94,19 @@ impl HomeScreen { Some(h) => { // Dial-first even when the presence pips say offline — a // routed/VPN host is mDNS-blind and probe-shy but dials fine. + // A pinned card connects with ITS profile (one-off, §5.2a); + // the primary tile keeps the host's default binding. fx.connect = Some(ConnectIntent { addr: h.addr.clone(), port: h.port, fp_hex: h.fp_hex.clone(), launch: None, - title: h.name.clone(), + title: match &h.pin { + Some(p) => format!("{} · {}", h.name, p.name), + None => h.name.clone(), + }, request_access: false, + profile: h.pin.as_ref().map(|p| p.id.clone()), }); } } @@ -295,16 +301,62 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6 let max_w = f64::from(rect.width()) - 2.0 * pad; let sub_base = f64::from(rect.bottom) - pad; - fonts.draw_clipped( - canvas, - &format!("{}:{}", h.addr, h.port), - l, - sub_base, - W::Regular, - 13.0 * k, - white(0.55), - max_w, - ); + match (&h.pin, &h.bound_profile) { + // A pinned card: the profile name IS the subtitle, tinted with its accent — + // the card's whole point is "this host, with these settings" (§5.2a). + (Some(p), _) => { + fonts.draw_clipped( + canvas, + &p.name, + l, + sub_base, + W::SemiBold, + 13.0 * k, + accent_color(p.accent.as_deref()), + max_w, + ); + } + // The primary tile says which profile a plain press uses, after the address. + (None, Some(b)) => { + let addr = format!("{}:{}", h.addr, h.port); + let addr_w = f64::from(fonts.measure(&addr, W::Regular, 13.0 * k)); + fonts.draw_clipped( + canvas, + &addr, + l, + sub_base, + W::Regular, + 13.0 * k, + white(0.55), + max_w, + ); + let x = l + addr_w + 8.0 * k; + if x < l + max_w { + fonts.draw_clipped( + canvas, + &format!("· {}", b.name), + x, + sub_base, + W::SemiBold, + 13.0 * k, + accent_color(b.accent.as_deref()), + l + max_w - x, + ); + } + } + (None, None) => { + fonts.draw_clipped( + canvas, + &format!("{}:{}", h.addr, h.port), + l, + sub_base, + W::Regular, + 13.0 * k, + white(0.55), + max_w, + ); + } + } fonts.draw_clipped( canvas, &h.name, @@ -317,6 +369,26 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6 ); } +/// A profile's `#RRGGBB` accent as a color, defaulting to the brand tint. Parsed +/// leniently — a malformed accent (hand-edited catalog) falls back rather than erroring. +fn accent_color(accent: Option<&str>) -> skia_safe::Color4f { + let Some(hex) = accent + .and_then(|a| a.strip_prefix('#')) + .filter(|h| h.len() == 6) + else { + return BRAND; + }; + let Ok(v) = u32::from_str_radix(hex, 16) else { + return BRAND; + }; + skia_safe::Color4f::new( + ((v >> 16) & 0xff) as f32 / 255.0, + ((v >> 8) & 0xff) as f32 / 255.0, + (v & 0xff) as f32 / 255.0, + 1.0, + ) +} + fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) { crate::theme::panel( canvas, @@ -484,6 +556,8 @@ mod tests { can_wake, last_used: None, os: String::new(), + pin: None, + bound_profile: None, } } @@ -551,6 +625,38 @@ mod tests { )); } + /// A pinned card's A-press is a connect WITH its profile (one-off), titled so the + /// connecting takeover says which settings are coming (§5.2a). + #[test] + fn pinned_card_connects_with_its_profile() { + let mut settings = ctx_settings(); + let mut pinned = host("ab\0p1", true, true, false); + pinned.name = "Tower".into(); + pinned.pin = Some(crate::model::ProfileChip { + id: "p1".into(), + name: "Work".into(), + accent: None, + }); + let hosts = [pinned]; + let pads: Vec = Vec::new(); + let library = crate::library::LibraryShared::default(); + let mut ctx = Ctx { + hosts: &hosts, + library: &library, + settings: &mut settings, + pads: &pads, + deck: false, + device_name: "test", + t: 0.0, + }; + let mut s = HomeScreen::new(); + let mut fx = Outbox::default(); + s.menu(MenuEvent::Confirm, &mut ctx, &mut fx); + let intent = fx.connect.expect("a pinned card connects"); + assert_eq!(intent.profile.as_deref(), Some("p1")); + assert_eq!(intent.title, "Tower · Work"); + } + #[test] fn add_tile_is_always_last() { let mut settings = ctx_settings(); diff --git a/crates/pf-console-ui/src/screens/library.rs b/crates/pf-console-ui/src/screens/library.rs index 83facd3a..c7d13722 100644 --- a/crates/pf-console-ui/src/screens/library.rs +++ b/crates/pf-console-ui/src/screens/library.rs @@ -120,6 +120,8 @@ impl LibraryScreen { launch: Some(g.id.clone()), title: g.title.clone(), request_access: false, + // Game launches follow the host's default binding. + profile: None, }); Some(MenuPulse::Confirm) } diff --git a/crates/pf-console-ui/src/screens/pair.rs b/crates/pf-console-ui/src/screens/pair.rs index ef117170..20a87703 100644 --- a/crates/pf-console-ui/src/screens/pair.rs +++ b/crates/pf-console-ui/src/screens/pair.rs @@ -221,6 +221,7 @@ impl PairScreen { launch: None, title: self.host_name.clone(), request_access: true, + profile: None, }); fx.pop(); } @@ -430,6 +431,8 @@ mod tests { can_wake: false, last_used: None, os: String::new(), + pin: None, + bound_profile: None, } } diff --git a/crates/pf-console-ui/src/screens/pin_hosts.rs b/crates/pf-console-ui/src/screens/pin_hosts.rs new file mode 100644 index 00000000..ad7dab4e --- /dev/null +++ b/crates/pf-console-ui/src/screens/pin_hosts.rs @@ -0,0 +1,266 @@ +//! "Pin “Work”" — choose which saved hosts show a profile as an extra connect card +//! (design/client-settings-profiles.md §5.2a), reached from the settings screen's +//! Profiles section. One toggle row per saved host; a toggle rides +//! [`ConsoleCmd::SetPin`] to the binary, which persists `KnownHost::pinned_profiles` +//! and refreshes the rows — the row's shown state follows the model, so what the list +//! says is always what the store holds (and what Decky's host list will render). + +use crate::glyphs::{Hint, HintKey}; +use crate::model::ConsoleCmd; +use crate::screens::{Ctx, Outbox}; +use crate::theme::{Fonts, DIM, W}; +use crate::widgets::{ListMsg, MenuList, RowSpec}; +use pf_client_core::gamepad::{MenuEvent, MenuPulse}; +use skia_safe::{Canvas, Rect}; + +pub(crate) struct PinHostsScreen { + profile_id: String, + profile_name: String, + list: MenuList, +} + +/// The toggle rows' domain: every SAVED host, primary tiles only (a pinned card is the +/// OUTPUT of this screen, not a row in it), in the model's carousel order. +fn host_indices(ctx: &Ctx) -> Vec { + ctx.hosts + .iter() + .enumerate() + .filter(|(_, h)| h.saved && h.pin.is_none()) + .map(|(i, _)| i) + .collect() +} + +impl PinHostsScreen { + pub(crate) fn new(profile_id: String, profile_name: String) -> PinHostsScreen { + PinHostsScreen { + profile_id, + profile_name, + list: MenuList::new(), + } + } + + pub(crate) fn profile_name(&self) -> &str { + &self.profile_name + } + + /// Is this profile currently pinned on the host at `ctx.hosts[host_idx]`? Read from + /// the model — the pinned card's row IS the state, so the toggle can never disagree + /// with what the carousel shows. + fn pinned(&self, ctx: &Ctx, host_idx: usize) -> bool { + let host = &ctx.hosts[host_idx]; + ctx.hosts.iter().any(|r| { + r.addr == host.addr + && r.port == host.port + && r.pin.as_ref().is_some_and(|p| p.id == self.profile_id) + }) + } + + pub(crate) fn menu( + &mut self, + ev: MenuEvent, + ctx: &mut Ctx, + fx: &mut Outbox, + ) -> Option { + if ev == MenuEvent::Back { + fx.pop(); + return None; + } + let indices = host_indices(ctx); + let (msg, pulse) = self.list.menu(ev, indices.len()); + let Some(&host_idx) = indices.get(self.list.cursor) else { + return pulse; + }; + // Toggle semantics shared with the settings rows: left = unpin, right = pin, + // A flips; asking for the state it's already in is a boundary thud. + let target = match msg { + ListMsg::Adjust(delta) => delta > 0, + ListMsg::Activate => !self.pinned(ctx, host_idx), + ListMsg::None => return pulse, + }; + if self.pinned(ctx, host_idx) == target { + return Some(MenuPulse::Boundary); + } + fx.cmds.push(ConsoleCmd::SetPin { + key: ctx.hosts[host_idx].key.clone(), + profile_id: self.profile_id.clone(), + pin: target, + }); + Some(MenuPulse::Move) + } + + pub(crate) fn hints(&self, ctx: &Ctx) -> Vec { + if host_indices(ctx).is_empty() { + return vec![Hint::new(HintKey::Back, "Done")]; + } + vec![ + Hint::new(HintKey::Confirm, "Pin / Unpin"), + Hint::new(HintKey::Back, "Done"), + ] + } + + pub(crate) fn render( + &mut self, + canvas: &Canvas, + rect: Rect, + k: f64, + dt: f64, + fonts: &Fonts, + ctx: &mut Ctx, + ) { + let indices = host_indices(ctx); + let cx = f64::from(rect.left) + f64::from(rect.width()) / 2.0; + if indices.is_empty() { + fonts.centered( + canvas, + "No saved hosts yet — pair with a host first, then pin this profile to it.", + W::Regular, + 14.0 * k, + DIM, + cx, + f64::from(rect.top) + f64::from(rect.height()) / 2.0, + f64::from(rect.width()) * 0.7, + ); + return; + } + // The explainer band under the list, like the settings screen's detail text. + let detail_h = 34.0 * k; + let list_rect = Rect::from_ltrb( + rect.left, + rect.top, + rect.right, + rect.bottom - detail_h as f32, + ); + let rows: Vec = indices + .iter() + .map(|&i| { + let h = &ctx.hosts[i]; + let pinned = self.pinned(ctx, i); + RowSpec { + header: None, + label: h.name.clone(), + value: Some(if pinned { + "Pinned".into() + } else { + "Off".into() + }), + value_dim: !pinned, + caret: false, + adjustable: true, + enabled: true, + } + }) + .collect(); + self.list + .render(canvas, list_rect, &rows, fonts, k, dt, true); + fonts.centered( + canvas, + "A pinned profile appears as its own card on the host — one press connects with it.", + W::Regular, + 13.0 * k, + DIM, + cx, + f64::from(rect.bottom) - detail_h + 6.0 * k, + f64::from(rect.width()) * 0.8, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{HostRow, ProfileChip}; + use crate::screens::Outbox; + use pf_client_core::trust::Settings; + + fn host(key: &str, saved: bool, pin: Option<&str>) -> HostRow { + HostRow { + key: key.into(), + name: key.into(), + addr: "10.0.0.9".into(), + port: 9777, + fp_hex: key.into(), + paired: true, + saved, + online: true, + mgmt_port: 47990, + can_wake: false, + last_used: None, + os: String::new(), + pin: pin.map(|id| ProfileChip { + id: id.into(), + name: "Work".into(), + accent: None, + }), + bound_profile: None, + } + } + + #[test] + fn toggling_sends_set_pin_for_the_focused_host() { + let mut settings = Settings::default(); + let pads = Vec::new(); + let library = crate::library::LibraryShared::default(); + let hosts = [host("aa", true, None), host("bb", true, None)]; + let mut ctx = Ctx { + hosts: &hosts, + library: &library, + settings: &mut settings, + pads: &pads, + deck: false, + device_name: "t", + t: 0.0, + }; + let mut s = PinHostsScreen::new("p1".into(), "Work".into()); + let mut fx = Outbox::default(); + s.menu(MenuEvent::Confirm, &mut ctx, &mut fx); + assert_eq!( + fx.cmds, + vec![ConsoleCmd::SetPin { + key: "aa".into(), + profile_id: "p1".into(), + pin: true, + }] + ); + + // Left on an unpinned host = already off = boundary, no command. + let mut fx = Outbox::default(); + let pulse = s.menu( + MenuEvent::Move(pf_client_core::gamepad::MenuDir::Left), + &mut ctx, + &mut fx, + ); + assert!(fx.cmds.is_empty()); + assert!(matches!(pulse, Some(MenuPulse::Boundary))); + } + + #[test] + fn state_reads_from_the_models_pinned_rows() { + let mut settings = Settings::default(); + let pads = Vec::new(); + let library = crate::library::LibraryShared::default(); + // Host "aa" already carries a pinned card for p1; its primary row toggles OFF. + let hosts = [host("aa", true, None), host("aa\0p1", true, Some("p1"))]; + let mut ctx = Ctx { + hosts: &hosts, + library: &library, + settings: &mut settings, + pads: &pads, + deck: false, + device_name: "t", + t: 0.0, + }; + let mut s = PinHostsScreen::new("p1".into(), "Work".into()); + // Only the primary row is a toggle row. + assert_eq!(host_indices(&ctx).len(), 1); + let mut fx = Outbox::default(); + s.menu(MenuEvent::Confirm, &mut ctx, &mut fx); + assert_eq!( + fx.cmds, + vec![ConsoleCmd::SetPin { + key: "aa".into(), + profile_id: "p1".into(), + pin: false, + }] + ); + } +} diff --git a/crates/pf-console-ui/src/screens/settings.rs b/crates/pf-console-ui/src/screens/settings.rs index 35995361..b7656278 100644 --- a/crates/pf-console-ui/src/screens/settings.rs +++ b/crates/pf-console-ui/src/screens/settings.rs @@ -6,7 +6,7 @@ //! read the same file, so values round-trip freely. use crate::glyphs::{Hint, HintKey}; -use crate::screens::{Ctx, Outbox}; +use crate::screens::{Ctx, Outbox, Screen}; use crate::theme::{Fonts, DIM, W}; use crate::widgets::{ListMsg, MenuList, RowSpec}; use pf_client_core::gamepad::{MenuEvent, MenuPulse}; @@ -15,8 +15,13 @@ use skia_safe::{Canvas, Rect}; /// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale /// index when the pad list under the "Use controller" row churns. -#[derive(Clone, Copy, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] enum RowId { + /// A catalog profile (index into [`SettingsScreen::profiles`]) — activating opens + /// the pin-to-hosts screen. The console never edits profiles (design §5.4). + Profile(usize), + /// The Profiles section's placeholder while the catalog is empty. + NoProfiles, Resolution, Refresh, RenderScale, @@ -50,7 +55,8 @@ enum RowId { // Gaming Mode, so a field it omits is simply unreachable there (render scale, 4:4:4, // scroll/shortcut behavior, fullscreen-on-stream, auto-wake, the library toggle and echo // cancellation all were). Still deliberately smaller than the desktop dialogs — device -// pickers (GPU/speaker/mic) and the profile catalog stay desktop-only. +// pickers (GPU/speaker/mic) stay desktop-only, and profiles are pinnable here (the +// trailing Profiles section) but created and edited only in the desktop app (design §5.4). const ROWS: [RowId; 27] = [ RowId::Resolution, RowId::Refresh, @@ -149,15 +155,42 @@ const PAD_TYPES: [(&str, &str); 6] = [ pub(crate) struct SettingsScreen { list: MenuList, + /// The profile catalog's `(id, name)` pairs, loaded once at construction — the console + /// can't create profiles (design §5.4: the desktop app does), so the list is stable + /// for the screen's lifetime. + profiles: Vec<(String, String)>, } impl SettingsScreen { pub(crate) fn new() -> SettingsScreen { + Self::with_profiles( + pf_client_core::profiles::ProfilesFile::load() + .profiles + .into_iter() + .map(|p| (p.id, p.name)) + .collect(), + ) + } + + fn with_profiles(profiles: Vec<(String, String)>) -> SettingsScreen { SettingsScreen { list: MenuList::new(), + profiles, } } + /// The full row list: the fixed settings rows, then the Profiles section — one row + /// per catalog profile, or the explainer placeholder while there are none. + fn row_ids(&self) -> Vec { + let mut ids = ROWS.to_vec(); + if self.profiles.is_empty() { + ids.push(RowId::NoProfiles); + } else { + ids.extend((0..self.profiles.len()).map(RowId::Profile)); + } + ids + } + pub(crate) fn menu( &mut self, ev: MenuEvent, @@ -168,7 +201,31 @@ impl SettingsScreen { fx.pop(); return None; } - let (msg, pulse) = self.list.menu(ev, ROWS.len()); + let ids = self.row_ids(); + let (msg, pulse) = self.list.menu(ev, ids.len()); + // The Profiles rows navigate instead of editing the settings file. + match ids[self.list.cursor] { + RowId::Profile(i) => { + return match msg { + ListMsg::Activate => { + let (id, name) = self.profiles[i].clone(); + fx.push(Screen::PinHosts(super::pin_hosts::PinHostsScreen::new( + id, name, + ))); + pulse + } + ListMsg::Adjust(_) => Some(MenuPulse::Boundary), + ListMsg::None => pulse, + } + } + RowId::NoProfiles => { + return match msg { + ListMsg::Adjust(_) | ListMsg::Activate => Some(MenuPulse::Boundary), + ListMsg::None => pulse, + } + } + _ => {} + } // Rebase the shell-lifetime snapshot on the file before an adjust-then-save: this // screen is one of the settings file's several whole-file writers (profiles.rs // documents the no-merge debt), and adjusting a stale snapshot would silently @@ -180,7 +237,7 @@ impl SettingsScreen { } match msg { ListMsg::Adjust(delta) => { - let changed = adjust(ROWS[self.list.cursor], delta, false, ctx); + let changed = adjust(ids[self.list.cursor], delta, false, ctx); if changed { ctx.settings.save(); Some(MenuPulse::Move) @@ -190,7 +247,7 @@ impl SettingsScreen { } ListMsg::Activate => { // A cycles forward WRAPPING, so every option is reachable one-handed. - if adjust(ROWS[self.list.cursor], 1, true, ctx) { + if adjust(ids[self.list.cursor], 1, true, ctx) { ctx.settings.save(); } pulse @@ -200,11 +257,18 @@ impl SettingsScreen { } pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec { - vec![ - Hint::new(HintKey::Adjust, "Adjust"), - Hint::new(HintKey::Confirm, "Change"), - Hint::new(HintKey::Back, "Done"), - ] + match self.row_ids()[self.list.cursor] { + RowId::Profile(_) => vec![ + Hint::new(HintKey::Confirm, "Pin to hosts…"), + Hint::new(HintKey::Back, "Done"), + ], + RowId::NoProfiles => vec![Hint::new(HintKey::Back, "Done")], + _ => vec![ + Hint::new(HintKey::Adjust, "Adjust"), + Hint::new(HintKey::Confirm, "Change"), + Hint::new(HintKey::Back, "Done"), + ], + } } pub(crate) fn render( @@ -224,10 +288,14 @@ impl SettingsScreen { rect.right, rect.bottom - detail_h as f32, ); - let rows: Vec = ROWS.iter().map(|id| row_spec(*id, ctx)).collect(); + let ids = self.row_ids(); + let rows: Vec = ids + .iter() + .map(|id| row_spec(*id, ctx, &self.profiles)) + .collect(); self.list .render(canvas, list_rect, &rows, fonts, k, dt, true); - let detail = detail(ROWS[self.list.cursor]); + let detail = detail(ids[self.list.cursor]); fonts.centered( canvas, detail, @@ -241,7 +309,38 @@ impl SettingsScreen { } } -fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec { +fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec { + // The Profiles section: name + how many hosts pin it (counted from the live rows, so + // it reflects what the carousel shows). Read-only here beyond opening the pin screen. + match id { + RowId::Profile(i) => { + let (pid, name) = &profiles[i]; + let pins = ctx + .hosts + .iter() + .filter(|h| h.pin.as_ref().is_some_and(|p| &p.id == pid)) + .count(); + return RowSpec { + header: (i == 0).then_some("Profiles"), + label: name.clone(), + value: Some(match pins { + 0 => "Not pinned".into(), + 1 => "Pinned to 1 host".into(), + n => format!("Pinned to {n} hosts"), + }), + value_dim: pins == 0, + caret: false, + adjustable: false, + enabled: true, + }; + } + RowId::NoProfiles => { + let mut row = RowSpec::action("No profiles yet", false); + row.header = Some("Profiles"); + return row; + } + _ => {} + } let s = &ctx.settings; // Several rows follow another: echo cancellation only means anything while the mic // streams, the pad rows only while any controller is forwarded at all, and the @@ -382,6 +481,7 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec { ), RowId::AutoWake => (None, "Wake hosts automatically", on_off(s.auto_wake).into()), RowId::Library => (None, "Game library", on_off(s.library_enabled).into()), + RowId::Profile(_) | RowId::NoProfiles => unreachable!("returned above"), }; RowSpec { header, @@ -477,6 +577,16 @@ fn detail(id: RowId) -> &'static str { reached over a VPN, where the wake wait only adds delay." } RowId::Library => "Show paired hosts' game libraries (tap a title to stream it).", + RowId::Profile(_) => { + "Pin this profile to a host and it appears as its own card — one press \ + connects with these settings. Profiles are created and edited in the \ + Punktfunk desktop app." + } + RowId::NoProfiles => { + "Profiles bundle stream settings for different uses (a low-latency one, a \ + quality one…). Create them in the Punktfunk desktop app, then pin them \ + here as one-press connect cards." + } } } @@ -611,6 +721,8 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool { RowId::Fullscreen => toggle(&mut s.fullscreen_on_stream, delta, wrap), RowId::AutoWake => toggle(&mut s.auto_wake, delta, wrap), RowId::Library => toggle(&mut s.library_enabled, delta, wrap), + // Navigation rows, handled before the settings path in `menu` — never a value edit. + RowId::Profile(_) | RowId::NoProfiles => None, } .is_some() } @@ -736,7 +848,7 @@ mod tests { device_name: "t", t: 0.0, }; - assert!(!row_spec(RowId::EchoCancel, &ctx).enabled); + assert!(!row_spec(RowId::EchoCancel, &ctx, &[]).enabled); assert!( !adjust(RowId::EchoCancel, -1, false, &mut ctx), "mic off = thud" @@ -745,7 +857,7 @@ mod tests { assert!(ctx.settings.echo_cancel, "and nothing was written"); ctx.settings.mic_enabled = true; - assert!(row_spec(RowId::EchoCancel, &ctx).enabled); + assert!(row_spec(RowId::EchoCancel, &ctx, &[]).enabled); assert!(adjust(RowId::EchoCancel, -1, false, &mut ctx)); assert!(!ctx.settings.echo_cancel); assert!(adjust(RowId::EchoCancel, 1, true, &mut ctx)); @@ -771,7 +883,7 @@ mod tests { device_name: "t", t: 0.0, }; - assert!(!row_spec(RowId::SmoothBuffer, &ctx).enabled); + assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled); assert!( !adjust(RowId::SmoothBuffer, 1, false, &mut ctx), "latency intent = thud" @@ -781,14 +893,14 @@ mod tests { // Stepping the intent to Smoothness brings the buffer row to life. assert!(adjust(RowId::PresentPriority, 1, false, &mut ctx)); assert_eq!(ctx.settings.present_priority, "smooth"); - assert!(row_spec(RowId::SmoothBuffer, &ctx).enabled); + assert!(row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled); assert!(adjust(RowId::SmoothBuffer, 1, false, &mut ctx)); assert_eq!(ctx.settings.smooth_buffer, 1); // The intent wraps back and the row goes inert again. assert!(adjust(RowId::PresentPriority, -1, false, &mut ctx)); assert_eq!(ctx.settings.present_priority, "latency"); - assert!(!row_spec(RowId::SmoothBuffer, &ctx).enabled); + assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled); } #[test] @@ -864,4 +976,110 @@ mod tests { assert!(adjust(RowId::Bitrate, 1, false, &mut ctx)); assert_eq!(ctx.settings.bitrate_kbps, 0, "snapped to Automatic"); } + + /// The Profiles section trails the settings rows: one row per catalog profile whose + /// value counts the pinned cards in the live model, activating opens the pin screen, + /// and left/right (which edits every other row) is a boundary — a profile row + /// navigates, it must never fall into the settings save path. + #[test] + fn profile_rows_navigate_instead_of_editing() { + let (mut settings, pads) = ctx_parts(); + let library = crate::library::LibraryShared::default(); + let mut pinned = crate::model::HostRow { + key: "aa\0p1".into(), + name: "Tower".into(), + addr: "10.0.0.9".into(), + port: 9777, + fp_hex: "aa".into(), + paired: true, + saved: true, + online: true, + mgmt_port: 47990, + can_wake: false, + last_used: None, + os: String::new(), + pin: Some(crate::model::ProfileChip { + id: "p1".into(), + name: "Work".into(), + accent: None, + }), + bound_profile: None, + }; + let hosts = [pinned.clone(), { + pinned.key = "aa".into(); + pinned.pin = None; + pinned + }]; + let mut ctx = Ctx { + hosts: &hosts, + library: &library, + settings: &mut settings, + pads: &pads, + deck: false, + device_name: "t", + t: 0.0, + }; + let mut s = SettingsScreen::with_profiles(vec![ + ("p1".into(), "Work".into()), + ("p2".into(), "Game".into()), + ]); + let ids = s.row_ids(); + assert_eq!(ids.len(), ROWS.len() + 2); + assert_eq!(ids[ROWS.len()], RowId::Profile(0)); + + let spec = row_spec(RowId::Profile(0), &ctx, &s.profiles); + assert_eq!(spec.header, Some("Profiles")); + assert_eq!(spec.label, "Work"); + assert_eq!(spec.value.as_deref(), Some("Pinned to 1 host")); + let spec = row_spec(RowId::Profile(1), &ctx, &s.profiles); + assert_eq!(spec.header, None, "only the first row carries the header"); + assert_eq!(spec.value.as_deref(), Some("Not pinned")); + + s.list.cursor = ROWS.len(); // onto "Work" + let mut fx = Outbox::default(); + s.menu(MenuEvent::Confirm, &mut ctx, &mut fx); + assert!( + matches!(fx.nav, Some(crate::screens::Nav::Push(b)) + if matches!(*b, Screen::PinHosts(ref p) if p.profile_name() == "Work")), + "A on a profile row opens its pin screen" + ); + + let mut fx = Outbox::default(); + let pulse = s.menu( + MenuEvent::Move(pf_client_core::gamepad::MenuDir::Right), + &mut ctx, + &mut fx, + ); + assert!(matches!(pulse, Some(MenuPulse::Boundary))); + assert!(fx.nav.is_none() && fx.cmds.is_empty()); + } + + /// An empty catalog shows the explainer placeholder — present, inert, and dimmed — + /// so the section still tells the user where profiles come from. + #[test] + fn empty_catalog_shows_the_placeholder() { + let (mut settings, pads) = ctx_parts(); + let library = crate::library::LibraryShared::default(); + let mut ctx = Ctx { + hosts: &[], + library: &library, + settings: &mut settings, + pads: &pads, + deck: false, + device_name: "t", + t: 0.0, + }; + let mut s = SettingsScreen::with_profiles(Vec::new()); + let ids = s.row_ids(); + assert_eq!(*ids.last().unwrap(), RowId::NoProfiles); + let spec = row_spec(RowId::NoProfiles, &ctx, &s.profiles); + assert_eq!(spec.header, Some("Profiles")); + assert!(!spec.enabled); + + s.list.cursor = ids.len() - 1; + let mut fx = Outbox::default(); + let pulse = s.menu(MenuEvent::Confirm, &mut ctx, &mut fx); + assert!(matches!(pulse, Some(MenuPulse::Boundary))); + assert!(fx.nav.is_none()); + } } diff --git a/crates/pf-console-ui/src/shell.rs b/crates/pf-console-ui/src/shell.rs index e49d0a47..6994109f 100644 --- a/crates/pf-console-ui/src/shell.rs +++ b/crates/pf-console-ui/src/shell.rs @@ -239,8 +239,14 @@ impl Shell { port: h.port, fp_hex: h.fp_hex.clone(), launch: None, - title: h.name.clone(), + // A wake started from a pinned card carries its profile + // through to the connect (the row's key found it again). + title: match &h.pin { + Some(p) => format!("{} · {}", h.name, p.name), + None => h.name.clone(), + }, request_access: false, + profile: h.pin.as_ref().map(|p| p.id.clone()), }) }); self.bus.send(ConsoleCmd::CancelWake); @@ -269,6 +275,7 @@ impl Shell { launch: intent.launch, title: intent.title, request_access: intent.request_access, + profile: intent.profile, }); } diff --git a/crates/pf-console-ui/src/shell/tests.rs b/crates/pf-console-ui/src/shell/tests.rs index 65a157ba..d49e1a44 100644 --- a/crates/pf-console-ui/src/shell/tests.rs +++ b/crates/pf-console-ui/src/shell/tests.rs @@ -32,6 +32,8 @@ fn hosts() -> Vec { can_wake: false, last_used: None, os: String::new(), + pin: None, + bound_profile: None, }; vec![ HostRow { diff --git a/crates/pf-console-ui/src/skia_overlay.rs b/crates/pf-console-ui/src/skia_overlay.rs index 1afa6021..ea2c48cc 100644 --- a/crates/pf-console-ui/src/skia_overlay.rs +++ b/crates/pf-console-ui/src/skia_overlay.rs @@ -88,8 +88,9 @@ pub enum ConsoleEntry { /// The host list (bare `--browse`). Home, /// Home with this host's library already pushed (`--browse host` — the Decky - /// per-host launch; B backs out to Home). - Library(HostRow), + /// per-host launch; B backs out to Home). Boxed: `HostRow` outgrew the dataless + /// `Home` variant when it learned its profile chips. + Library(Box), } /// The binary's ends of the console: models to write, commands to serve. diff --git a/crates/pf-presenter/src/overlay.rs b/crates/pf-presenter/src/overlay.rs index 472d4112..9bca9afa 100644 --- a/crates/pf-presenter/src/overlay.rs +++ b/crates/pf-presenter/src/overlay.rs @@ -84,6 +84,11 @@ pub enum OverlayAction { fp_hex: String, launch: Option, title: String, + /// One-off settings-profile override for THIS launch (a profile id — a pinned + /// card's connect). `None` resolves the host's default binding as before; the + /// binary feeds it to `trust::effective_settings`, so a dangling id quietly + /// falls back to the defaults and never blocks the connect. + profile: Option, /// The no-PIN delegated-approval path: pin the host's advertised fingerprint and /// open a connect the host PARKS until the operator approves this device in its /// console (a long connect budget), then persist it as paired. `false` = an From 80b4eccff943e56d3bde820ea40e22404d4dddba Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 19:41:03 +0200 Subject: [PATCH 25/53] feat(apple/gamepad): Profiles section + pin picker in gamepad settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GamepadSettingsView gains a trailing Profiles section (one row per catalog profile, live pinned-to-N-hosts counts) and an in-place pin-to-hosts picker driving HostStore.setPinned — the first pin management reachable from the controller-first UI, and on tvOS the only possible one. tvOS wording drops the 'create them in the standard interface' promise (no profile editor exists there); other platforms keep it. Pinned-card rendering and the connect path were already in from WP5 and stay untouched. --- .../Home/GamepadHomeView.swift | 4 +- .../Screenshots/ScreenshotScenes.swift | 4 +- .../Settings/GamepadSettingsView.swift | 169 ++++++++++++++++-- 3 files changed, 164 insertions(+), 13 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index e94b356f..0bd48f7c 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift @@ -135,7 +135,7 @@ struct GamepadHomeView: View { // fullScreenCover, so they become generously sized sheets over the dimmed launcher. #if os(macOS) .sheet(isPresented: $showSettings) { - GamepadSettingsView() + GamepadSettingsView(store: store) .frame(width: 720, height: 640) } .sheet(isPresented: $showAddHost) { @@ -144,7 +144,7 @@ struct GamepadHomeView: View { } .frame(minWidth: 640, minHeight: 420) #else - .fullScreenCover(isPresented: $showSettings) { GamepadSettingsView() } + .fullScreenCover(isPresented: $showSettings) { GamepadSettingsView(store: store) } .fullScreenCover(isPresented: $showAddHost) { GamepadAddHostView { store.add($0) } } diff --git a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift index 9133a0f9..b5c27331 100644 --- a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift +++ b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift @@ -146,7 +146,9 @@ private struct ShotGamepadHome: View { } private struct ShotGamepadSettings: View { - var body: some View { GamepadSettingsView() } + @StateObject private var store = ShotMock.hostStore() + + var body: some View { GamepadSettingsView(store: store) } } private struct ShotGamepadAddHost: View { diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index 49ba392b..8c218dd9 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -10,6 +10,14 @@ // on stale captured state. Left/right CLAMPS at a choice list's ends (the dull boundary thud tells // the thumb it's the last option); A always cycles forward, wrapping, so every option is reachable // with one button. Toggles read left = off, right = on — refusing a no-op with the same thud. +// +// The trailing Profiles section (design/client-settings-profiles.md §5.2a/§5.4) is the pin manager +// for this controller-first surface: a row per catalog profile opens the pin-to-hosts picker — an +// in-place swap of the row list (B peels back, the "one layer" rule GamepadAddHostView set) with +// one toggle row per saved host, writing `StoredHost.pinnedProfileIDs` via HostStore.setPinned. +// Pins are presentation only: never the host's default binding, never the profile itself — +// profiles are created and edited in the standard interface (and can't be on tvOS, whose +// per-device catalog the detail strings are honest about). import PunktfunkKit import SwiftUI @@ -21,6 +29,10 @@ import CoreHaptics struct GamepadSettingsView: View { @Environment(\.dismiss) private var dismiss + /// The saved-host store — the pin picker writes `setPinned` through it and the profile rows + /// count pins from its live hosts. Threaded in from GamepadHomeView like the home screen + /// itself (ContentView owns the instance). + @ObservedObject var store: HostStore @AppStorage(DefaultsKey.streamWidth) private var width = 1920 @AppStorage(DefaultsKey.streamHeight) private var height = 1080 @AppStorage(DefaultsKey.streamHz) private var hz = 60 @@ -52,6 +64,10 @@ struct GamepadSettingsView: View { @AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false #endif @ObservedObject private var gamepads = GamepadManager.shared + /// The profile catalog (ProfileStore.shared, like every other surface that reads it) — the + /// Profiles rows re-derive from it each render, so a rename/delete made in the standard + /// interface shows up live. + @ObservedObject private var profiles = ProfileStore.shared #if os(iOS) /// `.compact` in a landscape phone window — tighter chrome so more rows fit. @@ -62,6 +78,9 @@ struct GamepadSettingsView: View { private let compact = false // no size classes on macOS; the sheet is sized generously #endif @State private var focusID: String? + /// The pin-to-hosts picker's profile — non-nil swaps the row list for one toggle row per + /// saved host (§5.2a); B (Menu on tvOS) peels back to the settings rows. + @State private var pinTarget: StreamProfile? /// The direction of the last value step (+1 right/forward, -1 left) — picks which edge the /// changed value slides in from, so the animation follows the user's motion. @State private var lastAdjustDelta = 1 @@ -72,7 +91,7 @@ struct GamepadSettingsView: View { focusID: $focusID, onAdjust: { row, delta in adjust(id: row.id, by: delta) }, onActivate: { activate(id: $0.id) }, - onBack: { dismiss() } + onBack: { back() } ) { row, focused in rowView(row, focused: focused) .frame(maxWidth: GamepadFormMetrics.rowMaxWidth) @@ -80,7 +99,7 @@ struct GamepadSettingsView: View { } .frame(maxWidth: .infinity) .safeAreaInset(edge: .top, spacing: 0) { - Text("Settings") + Text(title) .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) .foregroundStyle(.white) .padding(.top, gamepadTitleTopPadding(compact: compact)) @@ -96,11 +115,7 @@ struct GamepadSettingsView: View { .foregroundStyle(.white.opacity(0.55)) .lineLimit(2, reservesSpace: true) .animation(.smooth(duration: 0.2), value: focusID) - GamepadHintBar(hints: [ - .init(glyph: "arrow.left.and.right", text: "Adjust"), - .init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"), - .init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"), - ]) + GamepadHintBar(hints: hints) } // Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView). .padding(.leading, compact ? 12 : 18) @@ -138,6 +153,43 @@ struct GamepadSettingsView: View { .accessibilityLabel("Close settings") } + /// "Settings", or "Pin “Work”" while the pin picker is up — the title is what says which + /// layer the row list currently is. + private var title: String { + pinTarget.map { "Pin “\($0.name)”" } ?? "Settings" + } + + /// The legend follows the layer: value-editing hints on the settings rows, pin/unpin on the + /// picker — where B reads "Back" (it peels to the settings rows, GamepadAddHostView's "one + /// layer" rule), and a hostless picker has nothing to pin, so only Back remains. + private var hints: [GamepadHint] { + guard pinTarget != nil else { + return [ + .init(glyph: "arrow.left.and.right", text: "Adjust"), + .init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"), + .init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"), + ] + } + guard !store.hosts.isEmpty else { + return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back")] + } + return [ + .init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Pin / Unpin"), + .init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back"), + ] + } + + /// B peels one layer: the pin picker back to the settings rows — focus returning to the + /// profile row it came from — then the screen itself. + private func back() { + if let profile = pinTarget { + pinTarget = nil + focusID = "profile-\(profile.id)" + } else { + dismiss() + } + } + // MARK: - Row rendering private func rowView(_ row: Row, focused: Bool) -> some View { @@ -164,7 +216,7 @@ struct GamepadSettingsView: View { HStack(spacing: 9) { Image(systemName: "chevron.left") .font(.system(size: m.chevronFont, weight: .semibold)) - .foregroundStyle(.white.opacity(focused ? 0.6 : 0)) + .foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0)) // Keyed by the value so a change slides the new option in instead of // hard-swapping the string — a QUIET horizontal slip following the user's // motion (a right-step enters from the right), crossfading over ~14 pt. @@ -185,7 +237,7 @@ struct GamepadSettingsView: View { .animation(.smooth(duration: 0.22), value: row.value) Image(systemName: "chevron.right") .font(.system(size: m.chevronFont, weight: .semibold)) - .foregroundStyle(.white.opacity(focused ? 0.6 : 0)) + .foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0)) } } .padding(.horizontal, m.rowHPad) @@ -219,6 +271,9 @@ struct GamepadSettingsView: View { let value: String /// One-line explanation shown near the hint bar while this row is focused. let detail: String + /// Whether left/right means anything here — false hides the value's chevrons (the + /// Profiles rows navigate, and the placeholder rows do nothing at all). + var adjustable = true /// Left/right step; returns whether the value actually changed (false ⇒ boundary thud). let adjust: (Int) -> Bool /// A — cycle forward (wrapping) / flip. @@ -238,6 +293,9 @@ struct GamepadSettingsView: View { } private var rows: [Row] { + // The pin picker replaces the whole list while it's up — same screen, one layer deeper, + // so the focus list's controller wiring (and the tvOS focus engine) carries over as is. + if let profile = pinTarget { return pinRows(for: profile) } let resolution = resolutionOptions let refresh = SettingsOptions.refreshRates(including: hz) .map { (label: "\($0) Hz", tag: $0) } @@ -394,7 +452,98 @@ struct GamepadSettingsView: View { at: at + 1) } #endif - return list + return list + profileRows + } + + // MARK: - Profiles (§5.2a) + + /// The trailing Profiles section: one row per catalog profile, its value how many saved + /// hosts pin it, A opening the pin-to-hosts picker. Read-only beyond that — this surface + /// pins and unpins, but profiles are created and edited elsewhere (design §5.4), so + /// left/right is a boundary thud, not an editor. + private var profileRows: [Row] { + guard !profiles.profiles.isEmpty else { + return [Row( + id: "noProfiles", header: "Profiles", icon: "slider.horizontal.3", + label: "No profiles yet", value: "", + detail: emptyCatalogDetail, + adjustable: false, + adjust: { _ in false }, activate: {})] + } + return profiles.profiles.enumerated().map { i, profile in + let pins = store.hosts + .filter { ($0.pinnedProfileIDs ?? []).contains(profile.id) }.count + return Row( + id: "profile-\(profile.id)", header: i == 0 ? "Profiles" : nil, + icon: "slider.horizontal.3", label: profile.name, + value: pins == 0 ? "Not pinned" : "Pinned to \(pins) host\(pins == 1 ? "" : "s")", + detail: profileDetail, + adjustable: false, + adjust: { _ in false }, + activate: { + // Focus lands on the picker's first row — the focus list's reconcile + // follows this id when the row set swaps underneath it. + focusID = store.hosts.first.map { "pinHost-\($0.id.uuidString)" } ?? "noHosts" + pinTarget = profile + }) + } + } + + /// The pin-to-hosts picker: one toggle row per SAVED host, sharing the settings rows' + /// toggle semantics (left = unpin, right = pin, A flips; asking for the state it's in is a + /// boundary thud). Writes ride `HostStore.setPinned` — pin appends, unpin removes — and + /// NEVER the host's default binding (`profileID`): a pin is presentation only (§5.2a). + private func pinRows(for profile: StreamProfile) -> [Row] { + guard !store.hosts.isEmpty else { + return [Row( + id: "noHosts", icon: "desktopcomputer", label: "No saved hosts yet", + value: "", + detail: "Pair with a host first, then pin this profile to it.", + adjustable: false, + adjust: { _ in false }, activate: {})] + } + return store.hosts.map { host in + let hostID = host.id + let pinned = (host.pinnedProfileIDs ?? []).contains(profile.id) + return Row( + id: "pinHost-\(hostID.uuidString)", icon: "desktopcomputer", + label: host.displayName, + value: pinned ? "Pinned" : "Off", + detail: "A pinned profile appears as its own card on the host — one press " + + "connects with it.", + adjust: { delta in + let target = delta > 0 + guard pinned != target else { return false } + store.setPinned(hostID, profileID: profile.id, pinned: target) + return true + }, + activate: { store.setPinned(hostID, profileID: profile.id, pinned: !pinned) }) + } + } + + /// The profile rows' explainer. tvOS gets its own: the catalog is per-device (the App Group + /// suite — nothing syncs it) and tvOS has no profile editor at all (§5.4), so pointing a TV + /// user at a "standard interface" would promise profiles that can never arrive there. + private var profileDetail: String { + #if os(tvOS) + return "Pin this profile to a host and it appears as its own card on the home screen — " + + "one press connects with it." + #else + return "Pin this profile to a host and it appears as its own card — one press connects " + + "with it. Profiles are created and edited in Punktfunk's standard interface." + #endif + } + + /// What the empty catalog's placeholder explains — again honest on tvOS, where profiles + /// cannot be created (on the device or anywhere that would reach its per-device catalog). + private var emptyCatalogDetail: String { + #if os(tvOS) + return "Profiles bundle stream settings for different uses. Creating them isn't " + + "available on Apple TV yet." + #else + return "Profiles bundle stream settings for different uses. Create them in Punktfunk's " + + "standard interface, then pin them here as one-press connect cards." + #endif } /// Resolution choices as "WxH" tags — the current size is inserted when it's a custom mode From 857d7d7b6b5da389b8a0f968f256d65996acffa9 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 19:41:04 +0200 Subject: [PATCH 26/53] feat(android/gamepad): Profiles section + pin-to-hosts dialog in Default settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GamepadSettingsScreen gains the trailing Profiles section (per-profile rows with live pin counts, touch-interface explainer) and a console-styled GamepadPinHostsDialog — controller- and TV-remote-navigable pin management writing KnownHost.pinnedProfileIds through the existing store path. Pin-add was previously touch-only; pinned-card rendering and unpin stay as they were. --- .../io/unom/punktfunk/GamepadDialogs.kt | 135 +++++++++++++++++ .../unom/punktfunk/GamepadSettingsScreen.kt | 137 ++++++++++++++++-- 2 files changed, 261 insertions(+), 11 deletions(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt index 2aaf2bfe..a3f27ec2 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt @@ -50,10 +50,12 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.security.ClientIdentity +import io.unom.punktfunk.kit.security.KnownHost import io.unom.punktfunk.models.PendingTrust import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -250,6 +252,139 @@ fun GamepadHostOptionsDialog( } } +/** + * The pin-to-hosts picker the settings screen's Profiles section opens — the Android mirror of the + * desktop console's PinHostsScreen (design §5.2a): one toggle row per SAVED host, D-pad up/down + * moves, A flips the focused pin, left/right unpins/pins (the settings-toggle semantics), B closes. + * A toggle is presentation only: it edits the host's pinned cards through the same store write the + * carousel's unpin uses, never the profile itself and never the host's default binding. + * + * Pin state is read live from [pinned] (backed by the host records), so what a switch shows is + * always what the store holds — the row can't disagree with the carousel it feeds. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun GamepadPinHostsDialog( + profileName: String, + hosts: List, + pinned: (KnownHost) -> Boolean, + onToggle: (KnownHost) -> Unit, + onDismiss: () -> Unit, +) { + // 0..hosts.lastIndex = host rows, hosts.size = the Done button (with no hosts, index 0 IS + // Done, so it starts focused). + var focus by remember { mutableIntStateOf(0) } + BackHandler(onBack = onDismiss) + GamepadNavEffect2D( + active = true, + onDirection = { dir -> + when (dir) { + NavDir.UP -> if (focus > 0) focus-- + NavDir.DOWN -> if (focus < hosts.size) focus++ + // Directional = state-targeted (left → unpinned, right → pinned), so holding a + // direction can't oscillate; asking for the state it's already in is a no-op. + NavDir.LEFT -> hosts.getOrNull(focus)?.let { if (pinned(it)) onToggle(it) } + NavDir.RIGHT -> hosts.getOrNull(focus)?.let { if (!pinned(it)) onToggle(it) } + } + }, + onActivate = { + val kh = hosts.getOrNull(focus) + if (kh != null) onToggle(kh) else onDismiss() + }, + ) + val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp + Box( + Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)), + contentAlignment = Alignment.Center, + ) { + Column( + Modifier + .padding(24.dp) + .widthIn(max = 520.dp) + .heightIn(max = maxCardHeight) + .clip(RoundedCornerShape(24.dp)) + .background(Color(0xF01A1730)) + .border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp)) + .padding(28.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text( + "Pin “$profileName”", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Column( + Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (hosts.isEmpty()) { + DialogText("No saved hosts yet — pair with a host first, then pin this profile to it.") + } else { + DialogText("A pinned profile appears as its own card on the host — one press connects with it.") + hosts.forEachIndexed { i, kh -> + PinHostRow( + label = kh.name, + on = pinned(kh), + focused = i == focus, + onClick = { onToggle(kh) }, + ) + } + } + Spacer(Modifier.size(4.dp)) + DialogButton( + "Done", + focused = focus == hosts.size, + primary = true, + enabled = true, + onClick = onDismiss, + ) + } + } + } +} + +/** One host's pin toggle: name + a [ConsoleSwitch], with the shared console focus visuals. */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: () -> Unit) { + val visuals = animateConsoleFocus(active = focused) + // Inside the dialog's scroll region, like DialogButton: a focused row scrolled out of a short + // landscape window pulls itself into view. + val intoView = remember { BringIntoViewRequester() } + LaunchedEffect(focused) { if (focused) intoView.bringIntoView() } + val shape = RoundedCornerShape(14.dp) + Row( + Modifier + .fillMaxWidth() + .bringIntoViewRequester(intoView) + .graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale } + .clip(shape) + .background(visuals.background) + .border(1.dp, visuals.border, shape) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ) + .padding(horizontal = 16.dp, vertical = 13.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + label, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.weight(1f)) + ConsoleSwitch(on = on, focused = focused) + } +} + /** * Console counterpart of [SpeedTestDialog]. Same measurement, same targeting rule — a TV box on a * powerline adapter is exactly the machine whose link is worth measuring, so this belongs on the diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt index 42ec3017..a0309f3c 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt @@ -57,6 +57,8 @@ import androidx.compose.ui.unit.sp import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeSource import io.unom.punktfunk.kit.deviceBodyVibrator +import io.unom.punktfunk.kit.security.KnownHost +import io.unom.punktfunk.kit.security.KnownHostStore // The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView: // the couch-relevant subset of the touch settings restyled as a console page and fully navigable with @@ -72,6 +74,8 @@ private class GpRow( val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed val activate: () -> Unit, // A → cycle forward (wrapping) / flip val toggled: Boolean? = null, // non-null = a toggle row, drawn as a ConsoleSwitch (not text) + val adjustable: Boolean = true, // false = the row navigates/acts instead of stepping — no chevrons + val enabled: Boolean = true, // dimmed + inert when false (still focusable, for its detail) ) @Composable @@ -89,7 +93,35 @@ fun GamepadSettingsScreen( val hasBodyVibrator = remember { deviceBodyVibrator(context) != null } // Gates the AV1 codec row the same way the touch settings do (see `codecOptionsFor`). val av1Capable = remember { io.unom.punktfunk.kit.VideoDecoders.pickDecoder("video/av01") != null } - val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) + + // The Profiles section's stores, constructed here the way ConnectScreen constructs its own. + // The catalog is read once per screen entry: this screen can't create or edit profiles + // (design §5.4 — the touch interface does), so the list is stable for its lifetime. The saved + // hosts DO change under it — every pin toggle writes one — so they live in state and refresh + // on each toggle, keeping the "Pinned to N hosts" counts honest. + val knownHostStore = remember { KnownHostStore(context) } + val profileStore = remember { ProfileStore(context) } + val profiles = remember { profileStore.all() } + var savedHosts by remember { mutableStateOf(knownHostStore.all()) } + // The profile whose pin-to-hosts picker is up, or null. While it's showing, it owns the pad + // (this screen's nav gates on it, the ConnectScreen-dialog pattern). + var pinProfile by remember { mutableStateOf(null) } + + // Toggle a host+profile pin — the same store write ConnectScreen's togglePin does. Presentation + // only: pin appends at the end (card order), unpin removes, and the host's default binding + // (profileId) is never touched. + fun togglePin(kh: KnownHost, profile: StreamProfile) { + val pins = if (profile.id in kh.pinnedProfileIds) { + kh.pinnedProfileIds - profile.id + } else { + kh.pinnedProfileIds + profile.id + } + knownHostStore.save(kh.copy(pinnedProfileIds = pins)) + savedHosts = knownHostStore.all() + } + + val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) + + buildProfileRows(profiles, savedHosts) { pinProfile = it } var focus by remember { mutableIntStateOf(0) } if (focus > rows.lastIndex) focus = rows.lastIndex // The direction the focused value last stepped (+1 forward / -1 back) — drives which way the @@ -101,7 +133,9 @@ fun GamepadSettingsScreen( BackHandler(onBack = onBack) GamepadNavEffect2D( - active = navActive, + // The pin picker owns the pad while it's up (its own nav + BackHandler), so this screen + // drops its probes — the pattern ConnectScreen's dialogs use. + active = navActive && pinProfile == null, onDirection = { dir -> when (dir) { NavDir.UP -> if (focus > 0) focus-- @@ -162,16 +196,41 @@ fun GamepadSettingsScreen( .then(if (landscape) Modifier else Modifier.systemBarsPadding()) .padding(ConsoleLegendInset), ) { + // The legend follows the focused row (the desktop console's hints() does the same): + // a profile row doesn't adjust, it opens the pin picker, and the "No profiles yet" + // placeholder does nothing at all — advertising ↔/A on those would be a lie. + val focused = rows.getOrNull(focus) GamepadHintBar( - listOf( - GamepadHint('↔', Color(0xFF9A93C7), "Adjust"), - // Tappable too (touch escape hatch): Change cycles the focused row, Done leaves. - PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() }, - PadGlyph.hint('B', "Done", onClick = onBack), - ), + when { + focused != null && !focused.enabled -> listOf( + PadGlyph.hint('B', "Done", onClick = onBack), + ) + focused != null && !focused.adjustable -> listOf( + PadGlyph.hint('A', "Pin to hosts") { focused.activate() }, + PadGlyph.hint('B', "Done", onClick = onBack), + ) + else -> listOf( + GamepadHint('↔', Color(0xFF9A93C7), "Adjust"), + // Tappable too (touch escape hatch): Change cycles the focused row, Done leaves. + PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() }, + PadGlyph.hint('B', "Done", onClick = onBack), + ) + }, hazeState = hazeState, ) } + + // The pin-to-hosts picker for the activated profile row — the console counterpart of the + // touch UI's per-profile pin toggles in the host edit sheet. + pinProfile?.let { p -> + GamepadPinHostsDialog( + profileName = p.name, + hosts = savedHosts, + pinned = { kh -> p.id in kh.pinnedProfileIds }, + onToggle = { kh -> togglePin(kh, p) }, + onDismiss = { pinProfile = null }, + ) + } } } @@ -180,8 +239,13 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick val visuals = animateConsoleFocus(active = focused) val shape = RoundedCornerShape(14.dp) // The chevrons keep their layout slot and only fade, so the value never jumps sideways when - // focus arrives; the value colour cross-fades with them. - val chevronAlpha by animateFloatAsState(if (focused) 0.6f else 0f, tween(160), label = "chevrons") + // focus arrives; the value colour cross-fades with them. A non-adjustable row (a profile row + // navigates, the empty-catalog placeholder does nothing) never shows them at all. + val chevronAlpha by animateFloatAsState( + if (focused && row.adjustable) 0.6f else 0f, + tween(160), + label = "chevrons", + ) val valueColor by animateColorAsState( Color.White.copy(alpha = if (focused) 1f else 0.6f), tween(160), @@ -216,7 +280,9 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick row.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, - color = Color.White, + // A disabled row (the "No profiles yet" placeholder) dims but stays focusable, + // so its detail line can still explain what would go here. + color = Color.White.copy(alpha = if (row.enabled) 1f else 0.45f), maxLines = 1, ) Spacer(Modifier.weight(1f)) @@ -435,3 +501,52 @@ private fun buildSettingsRows( ) { update(s.copy(sc2Capture = it)) }, ) } + +/** + * The trailing Profiles section — the Android mirror of the desktop console's (design §5.2a, §5.4): + * one row per catalog profile, valued with how many saved hosts pin it, activating into the + * pin-to-hosts picker. Read-only beyond pinning: profiles are created and edited in the touch + * interface, so an empty catalog shows one dimmed placeholder explaining where they come from + * instead of a dead-looking empty header. + */ +private fun buildProfileRows( + profiles: List, + savedHosts: List, + openPinPicker: (StreamProfile) -> Unit, +): List { + if (profiles.isEmpty()) { + return listOf( + GpRow( + id = "noProfiles", + header = "Profiles", + label = "No profiles yet", + value = "", + detail = "Profiles bundle stream settings for different uses. Create them in the " + + "touch interface, then pin them here as one-press connect cards.", + adjust = { false }, + activate = {}, + adjustable = false, + enabled = false, + ), + ) + } + return profiles.mapIndexed { i, p -> + // Counted straight off the host records, so it agrees with what the carousel renders. + val pins = savedHosts.count { p.id in it.pinnedProfileIds } + GpRow( + id = "profile:${p.id}", + header = if (i == 0) "Profiles" else null, + label = p.name, + value = when (pins) { + 0 -> "Not pinned" + 1 -> "Pinned to 1 host" + else -> "Pinned to $pins hosts" + }, + detail = "Pin this profile to a host and it appears as its own card — one press " + + "connects with it. Profiles are created and edited in the touch interface.", + adjust = { false }, + activate = { openPinPicker(p) }, + adjustable = false, + ) + } +} From 34ad3cc611810142fa28f52a0d7b63a5dacd29ee Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 19:42:36 +0200 Subject: [PATCH 27/53] feat(host/wire): mid-session shard-payload renegotiation, driven by the MTU verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phases 1-2 of design/shard-payload-reneg.md, on top of the Phase 0 per-frame geometry. The leg-1 watcher stops merely diagnosing the constrained path and heals the CURRENT session; the same machinery, inverted, takes a proven jumbo LAN up to ~8.9 KB shards. - Messages: MSG_SHARD_PAYLOAD_CHANGED (0x08, host→client, {shard_payload u16}) and MSG_SHARD_PAYLOAD_ACK (0x09, the echo). Asymmetric by design: a shrink re-keys the packetizer at the next AU immediately after sending (per-frame pinning makes ordering irrelevant; the ack is telemetry), a grow emits nothing above the old size until the ack — the ack is the gate even though client buffers are statically sized. - Client: one dispatch arm in the shared pump control task (all client families) — validate against the advertised receive bounds, ack; out-of-bounds requests get SILENCE, not an ack, so a buggy host can never read a granted grow out of garbage. - Host driver: the wire_mtu watcher grows a ShardReneg arm — on a below-ceiling verdict it still records the learned budget (session 2 starts right) and now also shrinks session 1 at the ~3-10 s verdict mark; with the jumbo opt-in (PUNKTFUNK_JUMBO=1, or PUNKTFUNK_WIRE_MTU > 1500 — one knob, derived) it sends the ack-gated grow after a settled-at-sealed-jumbo proof and then stays alive as the revert guard: quinn's blackhole detection lowering current_mtu shrinks the wire back through the same path. The QUIC MTUD probe ceiling rises from 1472 to the sealed jumbo size with the opt-in (per-ENDPOINT: a few extra failed probes toward non-jumbo peers, zero cost otherwise). - Apply point: Session::set_shard_payload drained in the send loop next to the adaptive-FEC target, gated on no open streamed AU (a streamed frame's shard-aligned tiling derives from the size it began with). - Renegotiation is gated OFF for PyroWave sessions: their clients parse chunk-aligned AUs in windows of the Welcome value pinned at session start (read once over the C ABI), so a mid-stream re-key would corrupt the parse — those sessions keep the leg-1 next-session clamp. This also settles the plan's open question on the two wire_chunk consumers: both are PyroWave-only, so the gate covers them entirely. - Legacy peers are inert both ways: no Hello advertisement → the host never constructs the driver; an old host never sends the message. core: 296/296 --features quic + clippy -D warnings (macOS), fmt; the regenerated header carries the new message ids (drift gate). --- .../src/client/pump/control_task.rs | 32 +++ crates/punktfunk-core/src/config.rs | 73 ++++++- crates/punktfunk-core/src/quic/control.rs | 101 ++++++++++ crates/punktfunk-core/src/quic/endpoint.rs | 20 +- crates/punktfunk-host/src/native.rs | 27 +++ crates/punktfunk-host/src/native/control.rs | 31 +++ crates/punktfunk-host/src/native/handshake.rs | 7 +- crates/punktfunk-host/src/native/stream.rs | 29 +++ crates/punktfunk-host/src/native/wire_mtu.rs | 182 ++++++++++++++---- include/punktfunk_core.h | 10 + 10 files changed, 468 insertions(+), 44 deletions(-) diff --git a/crates/punktfunk-core/src/client/pump/control_task.rs b/crates/punktfunk-core/src/client/pump/control_task.rs index 291f47a1..2af7b82e 100644 --- a/crates/punktfunk-core/src/client/pump/control_task.rs +++ b/crates/punktfunk-core/src/client/pump/control_task.rs @@ -243,6 +243,38 @@ impl ControlTask { seq: offer.seq, kinds: offer.kinds, }); + } else if let Ok(chg) = crate::quic::ShardPayloadChanged::decode(&msg) { + // Mid-session shard renegotiation (design/shard-payload-reneg.md): the + // host re-keys the sealed video geometry. Per-frame pinning means there + // is nothing to re-key on the receive path — the reassembler follows + // each frame's own header and every buffer is statically sized for the + // ceiling — so the dispatch is validate + ack. The ack is telemetry for + // a shrink and the GATE for a grow (the host emits nothing above the + // old size until it lands). Validate against our own receive bounds — + // the same ceiling we advertised in `Hello::max_shard_payload` — and + // answer an out-of-bounds request with SILENCE, not an ack: a buggy + // host must never read a granted grow out of garbage. + let n = chg.shard_payload as usize; + if (crate::config::MIN_SHARD_PAYLOAD..=crate::config::max_shard_payload()) + .contains(&n) + && n % 2 == 0 + { + tracing::info!( + shard_payload = n, + "host re-keyed the wire shard payload — acking" + ); + let ack = crate::quic::ShardPayloadAck { + shard_payload: chg.shard_payload, + }; + if io::write_msg(&mut ctrl_send, &ack.encode()).await.is_err() { + break; + } + } else { + tracing::warn!( + shard_payload = n, + "out-of-bounds shard-payload change — ignoring (no ack)" + ); + } } else if let Ok(shape) = crate::quic::CursorShape::decode(&msg) { // Pointer bitmap changed (cursor channel, only when negotiated). try_send: // an overflowing ring drops the newest shape — the next change resends. diff --git a/crates/punktfunk-core/src/config.rs b/crates/punktfunk-core/src/config.rs index 1c0d2af7..8ccd2dff 100644 --- a/crates/punktfunk-core/src/config.rs +++ b/crates/punktfunk-core/src/config.rs @@ -373,16 +373,54 @@ pub fn shard_payload_for_udp_budget(udp_budget: usize, peer: core::net::IpAddr) p.clamp(MIN_SHARD_PAYLOAD, mtu1500_shard_payload_for(peer)) } +/// The family's IP+UDP header bytes between an on-wire IP MTU and its UDP payload budget — +/// 28 for IPv4 (and IPv4-mapped), 48 for IPv6. +fn ip_udp_overhead(peer: core::net::IpAddr) -> usize { + match peer { + core::net::IpAddr::V4(_) => 28, + core::net::IpAddr::V6(v6) if v6.to_ipv4_mapped().is_some() => 28, + core::net::IpAddr::V6(_) => 48, + } +} + /// [`shard_payload_for_udp_budget`] for an operator-supplied ON-WIRE IP MTU (the number /// `netsh interface ipv4 show subinterfaces` / `ip link` shows): subtracts the family's IP+UDP /// headers first — 28 for IPv4 (and IPv4-mapped), 48 for IPv6. pub fn shard_payload_for_wire_mtu(wire_mtu: usize, peer: core::net::IpAddr) -> usize { - let ip_udp = match peer { - core::net::IpAddr::V4(_) => 28, - core::net::IpAddr::V6(v6) if v6.to_ipv4_mapped().is_some() => 28, - core::net::IpAddr::V6(_) => 48, - }; - shard_payload_for_udp_budget(wire_mtu.saturating_sub(ip_udp), peer) + shard_payload_for_udp_budget(wire_mtu.saturating_sub(ip_udp_overhead(peer)), peer) +} + +/// The operator's jumbo-frames opt-in (design/shard-payload-reneg.md Phase 2): the target +/// on-wire IP MTU, or `None` = no opt-in (nothing above the 1500-default wire is ever probed +/// or grown to). One knob, one code path: a `PUNKTFUNK_WIRE_MTU` above the standard 1500 +/// derives the target from the operator's number; `PUNKTFUNK_JUMBO=1` is the fixed 9000 +/// profile for operators who don't want to think in MTUs. Raising the wire above 1500 is +/// only ever an ACK-GATED mid-session grow toward a client that advertised +/// [`max_shard_payload`] headroom — sessions still START at the family default. +pub fn jumbo_wire_mtu() -> Option { + if let Ok(v) = std::env::var("PUNKTFUNK_WIRE_MTU") { + if let Ok(mtu) = v.trim().parse::() { + if mtu > 1500 { + return Some(mtu); + } + } + } + match std::env::var("PUNKTFUNK_JUMBO") { + Ok(v) if v.trim() == "1" => Some(9000), + _ => None, + } +} + +/// The jumbo sibling of [`shard_payload_for_wire_mtu`]: the largest even shard payload whose +/// sealed datagram fits `wire_mtu`, clamped to the RECEIVE ceiling ([`max_shard_payload`]) +/// instead of the family 1500-default — the up-leg's grow target. Still floored at +/// [`MIN_SHARD_PAYLOAD`]. +pub fn jumbo_shard_payload_for(wire_mtu: usize, peer: core::net::IpAddr) -> usize { + let p = wire_mtu + .saturating_sub(ip_udp_overhead(peer)) + .saturating_sub(HEADER_LEN + CRYPTO_OVERHEAD); + let p = p - p % 2; // FEC requires even shards + p.clamp(MIN_SHARD_PAYLOAD, max_shard_payload()) } /// Everything needed to construct a [`Session`](crate::session::Session). @@ -626,6 +664,29 @@ mod tests { assert_eq!(shard_payload_for_wire_mtu(1280, v6), 1168); } + /// Jumbo grow-target sizing (the up-leg, design/shard-payload-reneg.md): even, sealed + /// fits the wire, clamped to the RECEIVE ceiling instead of the family 1500-default — + /// and the standard 9000 profile lands on the exact documented value. + #[test] + fn jumbo_shard_payload_math() { + use core::net::IpAddr; + let v4: IpAddr = "192.168.1.50".parse().unwrap(); + let v6: IpAddr = "fd00::50".parse().unwrap(); + // 9000 − 28 (IPv4+UDP) − 64 (header+crypto) = 8908 even; sealed 8972 ≤ the 9216 + // datagram ceiling. The v6 sibling: 9000 − 48 − 64 = 8888. + assert_eq!(jumbo_shard_payload_for(9000, v4), 8908); + assert_eq!(sealed_datagram_bytes(8908), 8972); + assert!(sealed_datagram_bytes(8908) <= MAX_DATAGRAM_BYTES); + assert_eq!(jumbo_shard_payload_for(9000, v6), 8888); + // An operator MTU larger than the receive path clamps to the ceiling, smaller ones + // track the wire, and degenerate ones floor at MIN_SHARD_PAYLOAD. + assert_eq!(jumbo_shard_payload_for(64_000, v4), max_shard_payload()); + let p = jumbo_shard_payload_for(4000, v4); + assert_eq!(p % 2, 0); + assert!(sealed_datagram_bytes(p) <= 4000 - 28); + assert_eq!(jumbo_shard_payload_for(100, v4), MIN_SHARD_PAYLOAD); + } + /// Family selection: genuine v6 remotes get the v6 size; v4 — including the IPv4-mapped v6 /// form a dual-stack `[::]` socket reports for a v4 client — keeps the v4 size. #[test] diff --git a/crates/punktfunk-core/src/quic/control.rs b/crates/punktfunk-core/src/quic/control.rs index 54155cc4..cce568e4 100644 --- a/crates/punktfunk-core/src/quic/control.rs +++ b/crates/punktfunk-core/src/quic/control.rs @@ -55,6 +55,36 @@ pub struct RfiRequest { pub last_frame: u32, } +/// `host → client`, any time after [`Start`]: the video data plane's sealed shard payload +/// changes mid-session (design/shard-payload-reneg.md Phase 1). Sent ONLY to a client whose +/// [`Hello::max_shard_payload`] advertised per-frame geometry (0/absent = legacy — the host +/// must never send this), and never above that advertised ceiling. Asymmetric semantics: +/// +/// - **Shrink** (the mid-session MTU heal): the host may re-key its packetizer at the next +/// AU boundary immediately after sending — per-frame pinning on the client makes the +/// control-vs-datagram reorder race irrelevant and a smaller shard always fits existing +/// buffers. The [`ShardPayloadAck`] is telemetry. +/// - **Grow** (jumbo): the host must not emit a single sealed datagram above the OLD size +/// until the ack arrives — the ack IS the gate, even when the client's buffers would +/// happen to fit (the rule must not erode if the buffer strategy changes later). +/// +/// No `effective_frame_index`: per-frame pinning makes it redundant — every video packet +/// carries its own `shard_bytes` and the receiver follows each frame's pin. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ShardPayloadChanged { + /// The new sealed shard payload in bytes (even, within the client's advertised bounds). + pub shard_payload: u16, +} + +/// `client → host`: answer to [`ShardPayloadChanged`] — echoes the value the client applied. +/// Only sent for an in-bounds request; an out-of-bounds one is dropped WITHOUT an ack (a +/// buggy host must not read silence-then-garbage as a granted grow). The host treats the +/// echoed value as the grant for a pending grow. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ShardPayloadAck { + pub shard_payload: u16, +} + /// `client → host`, periodic: the client's observed data-plane loss, so the host can size FEC to /// the link instead of a flat percentage (adaptive FEC). `loss_ppm` is parts-per-million of shards /// that arrived missing-but-recovered (plus a bump when frames went unrecoverable) over the report @@ -200,6 +230,10 @@ pub const MSG_SET_BITRATE: u8 = 0x05; pub const MSG_BITRATE_CHANGED: u8 = 0x06; /// Type byte of [`RfiRequest`]. pub const MSG_RFI_REQUEST: u8 = 0x07; +/// Type byte of [`ShardPayloadChanged`]. +pub const MSG_SHARD_PAYLOAD_CHANGED: u8 = 0x08; +/// Type byte of [`ShardPayloadAck`]. +pub const MSG_SHARD_PAYLOAD_ACK: u8 = 0x09; /// Type byte of [`ProbeRequest`]. pub const MSG_PROBE_REQUEST: u8 = 0x20; /// Type byte of [`ProbeResult`]. @@ -306,6 +340,46 @@ impl RfiRequest { } } +impl ShardPayloadChanged { + pub fn encode(&self) -> Vec { + // magic[0..4] type[4] shard_payload[5..7] + let mut b = Vec::with_capacity(7); + b.extend_from_slice(CTL_MAGIC); + b.push(MSG_SHARD_PAYLOAD_CHANGED); + b.extend_from_slice(&self.shard_payload.to_le_bytes()); + b + } + + pub fn decode(b: &[u8]) -> Result { + if b.len() != 7 || &b[0..4] != CTL_MAGIC || b[4] != MSG_SHARD_PAYLOAD_CHANGED { + return Err(PunktfunkError::InvalidArg("bad ShardPayloadChanged")); + } + Ok(ShardPayloadChanged { + shard_payload: u16::from_le_bytes(b[5..7].try_into().unwrap()), + }) + } +} + +impl ShardPayloadAck { + pub fn encode(&self) -> Vec { + // magic[0..4] type[4] shard_payload[5..7] + let mut b = Vec::with_capacity(7); + b.extend_from_slice(CTL_MAGIC); + b.push(MSG_SHARD_PAYLOAD_ACK); + b.extend_from_slice(&self.shard_payload.to_le_bytes()); + b + } + + pub fn decode(b: &[u8]) -> Result { + if b.len() != 7 || &b[0..4] != CTL_MAGIC || b[4] != MSG_SHARD_PAYLOAD_ACK { + return Err(PunktfunkError::InvalidArg("bad ShardPayloadAck")); + } + Ok(ShardPayloadAck { + shard_payload: u16::from_le_bytes(b[5..7].try_into().unwrap()), + }) + } +} + impl LossReport { pub fn encode(&self) -> Vec { // magic[0..4] type[4] loss_ppm[5..9] @@ -1146,6 +1220,33 @@ mod tests { assert!(SetBitrate::decode(&LossReport { loss_ppm: 7 }.encode()).is_err()); } + #[test] + fn shard_payload_messages_roundtrip() { + for shard_payload in [512u16, 1216, 1408, 8908] { + let chg = ShardPayloadChanged { shard_payload }; + assert_eq!(ShardPayloadChanged::decode(&chg.encode()).unwrap(), chg); + let ack = ShardPayloadAck { shard_payload }; + assert_eq!(ShardPayloadAck::decode(&ack.encode()).unwrap(), ack); + // Identical payload shape — the type byte alone must keep the pair disjoint (a + // change echoed back must never re-decode as a change). + assert!(ShardPayloadChanged::decode(&ack.encode()).is_err()); + assert!(ShardPayloadAck::decode(&chg.encode()).is_err()); + } + // Exact length — no trailing bytes, no truncation. + let bytes = ShardPayloadChanged { shard_payload: 512 }.encode(); + assert!(ShardPayloadChanged::decode(&[bytes.as_slice(), &[0]].concat()).is_err()); + assert!(ShardPayloadChanged::decode(&bytes[..bytes.len() - 1]).is_err()); + // Disjoint from the neighboring ids either side (0x07 RfiRequest / 0x20 ProbeRequest). + assert!(ShardPayloadChanged::decode( + &RfiRequest { + first_frame: 1, + last_frame: 2 + } + .encode() + ) + .is_err()); + } + #[test] fn probe_messages_roundtrip() { let req = ProbeRequest { diff --git a/crates/punktfunk-core/src/quic/endpoint.rs b/crates/punktfunk-core/src/quic/endpoint.rs index 8ed25d6b..43a786ff 100644 --- a/crates/punktfunk-core/src/quic/endpoint.rs +++ b/crates/punktfunk-core/src/quic/endpoint.rs @@ -59,7 +59,25 @@ fn stream_transport_idle(idle: std::time::Duration) -> Arc 1500 set, discovery probes up to the sealed JUMBO datagram + // size so a settled connection can PROVE a jumbo path — the actual grow stays + // client-ack-gated (`native/wire_mtu.rs`). The ceiling is per-ENDPOINT, not + // per-connection: with the opt-in set, connections to non-jumbo peers spend a few extra + // failed probes (one PTO each) settling lower; zero cost for anyone who doesn't opt in. + // Derived with the IPv4 overhead — a v6 peer's sealed jumbo target is smaller, so the + // ceiling covers it and discovery settles at the v6 path's own budget. + let probe_ceiling = match crate::config::jumbo_wire_mtu() { + Some(mtu) => { + let shard = crate::config::jumbo_shard_payload_for( + mtu, + std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), + ); + crate::config::sealed_datagram_bytes(shard) as u16 + } + None => crate::config::video_datagram_udp_ceiling() as u16, + }; + mtud.upper_bound(probe_ceiling); t.mtu_discovery_config(Some(mtud)); Arc::new(t) } diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index ba41bb18..ac38be13 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -1091,6 +1091,30 @@ async fn serve_session( // just never fires then. let (cursor_shape_tx, cursor_shape_rx) = tokio::sync::mpsc::unbounded_channel::(); + // Mid-session shard renegotiation (design/shard-payload-reneg.md Phase 2): the wire-MTU + // watcher decides (constrained-path shrink / ack-gated jumbo grow), the control task + // writes the `ShardPayloadChanged` and routes the acks back, and the data-plane loop + // applies `Session::set_shard_payload` between AUs (drained next to `bitrate_rx`). + // Channels are wired unconditionally (they just never fire); the DRIVER exists only for + // a client that advertised `Hello::max_shard_payload` on a non-chunk-aligned session — + // PyroWave clients parse chunk-aligned AUs in windows of the `Welcome` value pinned at + // session start (read once over the C ABI), so those sessions keep the leg-1 + // next-session clamp instead of a mid-stream re-key. + let (shard_change_tx, shard_change_rx) = tokio::sync::mpsc::unbounded_channel::(); + let (shard_ack_tx, shard_ack_rx) = tokio::sync::mpsc::unbounded_channel::(); + let (shard_apply_tx, shard_apply_rx) = std::sync::mpsc::channel::(); + let shard_reneg = (hello.max_shard_payload > 0 && codec != crate::encode::Codec::PyroWave) + .then_some(wire_mtu::ShardReneg { + client_ceiling: hello.max_shard_payload, + change_tx: shard_change_tx, + ack_rx: shard_ack_rx, + apply_tx: shard_apply_tx, + }); + // The session is real: watch this connection's MTU discovery settle and turn it into a + // path verdict (WARN + learned clamp for the next session on a constrained path; clears + // a stale clamp on a healthy one) — and, with the driver above, heal or grow THIS + // session mid-stream. Bounded ~10 s task unless a jumbo grow leaves it as revert guard. + wire_mtu::spawn_watch(conn.clone(), welcome.shard_payload as usize, shard_reneg); // Negotiated cursor forwarding: the HOST_CAP_CURSOR bit the Welcome advertised, read back // rather than recomputed (`handshake::cursor_forward` computed it once, with the encoder // blend-capability gate — re-running it here could drift, and would re-probe). @@ -1146,6 +1170,8 @@ async fn serve_session( probe_result_rx, reconfig_result_rx, retarget_rx, + shard_change_rx, + shard_ack_tx, cursor_shape_rx, cursor_client_draws, clip_enabled, @@ -1579,6 +1605,7 @@ async fn serve_session( keyframe: keyframe_rx, rfi: rfi_rx, bitrate_rx, + shard_rx: shard_apply_rx, compositor, gamescope_route, bitrate_kbps, diff --git a/crates/punktfunk-host/src/native/control.rs b/crates/punktfunk-host/src/native/control.rs index e14934c6..a6bdc9ca 100644 --- a/crates/punktfunk-host/src/native/control.rs +++ b/crates/punktfunk-host/src/native/control.rs @@ -43,6 +43,11 @@ pub(super) async fn run( // Host-initiated bitrate re-target (a rebuild re-resolved an Automatic rate): forwarded to // the client as a `BitrateChanged` so its controller's climb base tracks the real encoder. mut retarget_rx: tokio::sync::mpsc::UnboundedReceiver, + // Mid-session shard renegotiation (design/shard-payload-reneg.md): the wire-MTU watcher + // asks for a `ShardPayloadChanged` here (this task is the control stream's sole writer), + // and the client's `ShardPayloadAck`s flow back on `shard_ack_tx` — the grow gate. + mut shard_change_rx: tokio::sync::mpsc::UnboundedReceiver, + shard_ack_tx: tokio::sync::mpsc::UnboundedSender, mut cursor_shape_rx: tokio::sync::mpsc::UnboundedReceiver, cursor_client_draws: Arc, clip_enabled: Arc, @@ -56,6 +61,9 @@ pub(super) async fn run( // Set once `clip_offer_rx` closes (coordinator gone / inert handle) so its `select!` branch // stops firing on a perpetually-ready `None`. let mut clip_offer_closed = false; + // Same discipline for the wire-MTU watcher's channel — its bounded lifetime ends mid-session + // on every healthy path. + let mut shard_change_closed = false; let mut active = initial_mode; // Host-side switch rate limit (a backstop against a hostile/broken client spamming // Reconfigure into pipeline-rebuild churn — the drain-to-newest in the data plane already @@ -214,6 +222,16 @@ pub(super) async fn run( if bitrate_tx.send(resolved).is_err() { break; // data plane gone } + } else if let Ok(ack) = punktfunk_core::quic::ShardPayloadAck::decode(&msg) { + // Mid-session shard renegotiation: the client applied (or granted) a + // geometry change. Forward to the wire-MTU watcher — for a grow this IS + // the gate that lets the packetizer go above the old size. A dropped + // send just means the watcher already ended (shrink acks are telemetry). + tracing::info!( + shard_payload = ack.shard_payload, + "client acked shard-payload change" + ); + let _ = shard_ack_tx.send(ack.shard_payload); } else if let Ok(req) = ProbeRequest::decode(&msg) { tracing::info!( target_kbps = req.target_kbps, @@ -317,6 +335,19 @@ pub(super) async fn run( break; } } + n = shard_change_rx.recv(), if !shard_change_closed => { + // Mid-session shard renegotiation: the wire-MTU watcher decided (shrink on a + // constrained-path verdict / ack-gated jumbo grow). Only ever fires toward a + // client that advertised `Hello::max_shard_payload` — the watcher owns that + // gate. `None` = the watcher's bounded lifetime ended (normal, NOT a session + // end): disable this branch, exactly the `clip_offer_closed` pattern — a + // closed mpsc yields `None` perpetually and would busy-spin the select. + let Some(n) = n else { shard_change_closed = true; continue }; + let msg = punktfunk_core::quic::ShardPayloadChanged { shard_payload: n }; + if io::write_msg(&mut ctrl_send, &msg.encode()).await.is_err() { + break; + } + } shape = cursor_shape_rx.recv() => { // Cursor-forward bridge (M2): the encode loop diffed a new pointer bitmap. // Rare (shape changes are human-paced); ≤ ~58 KiB fits the u16 frame by diff --git a/crates/punktfunk-host/src/native/handshake.rs b/crates/punktfunk-host/src/native/handshake.rs index 29254549..d62e60df 100644 --- a/crates/punktfunk-host/src/native/handshake.rs +++ b/crates/punktfunk-host/src/native/handshake.rs @@ -663,10 +663,9 @@ pub(super) async fn negotiate( let start = Start::decode(&io::read_msg(recv).await?).map_err(|e| anyhow!("Start decode: {e:?}"))?; bringup.mark("start"); - // The session is real: watch this connection's MTU discovery settle and turn it into a - // path verdict (WARN + learned clamp for the next session on a constrained path; clears a - // stale clamp on a healthy one). Bounded ~10 s task, ends by itself. - wire_mtu::spawn_watch(conn.clone(), welcome.shard_payload as usize); + // The wire-MTU watch (`wire_mtu::spawn_watch`) is spawned by `serve_session` after the + // control-task channels exist — it now also DRIVES the mid-session shard renegotiation + // (design/shard-payload-reneg.md), which needs the control stream's writer. Ok::<_, anyhow::Error>(( hello, welcome, diff --git a/crates/punktfunk-host/src/native/stream.rs b/crates/punktfunk-host/src/native/stream.rs index 6bba243e..6047d088 100644 --- a/crates/punktfunk-host/src/native/stream.rs +++ b/crates/punktfunk-host/src/native/stream.rs @@ -762,6 +762,9 @@ fn send_loop( slice_wire: bool, burst_cap: Option, fec_target: Arc, + // Mid-session shard-payload re-keys from the wire-MTU watcher (validated + ack-gated + // there) — applied between AUs only (design/shard-payload-reneg.md Phase 1). + shard_rx: std::sync::mpsc::Receiver, stats: SendStats, // `Some` = the client advertised VIDEO_CAP_HOST_TIMING: emit one 0xCF datagram per AU right // after its last packet left the socket (capture→sent, the whole host pipeline incl. pacing). @@ -818,6 +821,25 @@ fn send_loop( } // Adaptive FEC: pick up any new recovery target the control task set from client LossReports. apply_fec_target(&mut session, &fec_target); + // Mid-session shard renegotiation: apply a re-key from the wire-MTU watcher — between + // AUs only, NEVER with a streamed AU open (its shard-aligned tiling derives from the + // size it began with; same gate as the probe burst above). Drain to the newest; the + // protocol side (client advertisement, ack-gated grow) was enforced by the watcher. + if streamed.is_none() { + let mut want_shard = None; + while let Ok(s) = shard_rx.try_recv() { + want_shard = Some(s); + } + if let Some(s) = want_shard { + match session.set_shard_payload(s) { + Ok(()) => tracing::info!(shard_payload = s, "wire shard payload re-keyed"), + // Can't fire for a watcher-driven value (it validates the same bounds) — + // belt-and-suspenders for a future driver. + Err(e) => tracing::warn!(shard_payload = s, error = ?e, + "shard re-key refused by session validation"), + } + } + } // Short timeout so we keep re-checking `stop` + probes when no frames are flowing. match frame_rx.recv_timeout(std::time::Duration::from_millis(50)) { Ok(send_msg) => { @@ -1171,6 +1193,11 @@ pub(super) struct SessionContext { /// Accepted mid-stream bitrate changes (adaptive bitrate, already clamped) — the encoder /// alone is rebuilt in place at the new rate; capture + virtual output are untouched. pub(super) bitrate_rx: std::sync::mpsc::Receiver, + /// Mid-session shard-payload changes from the wire-MTU watcher (already validated + + /// protocol-gated there; a grow arrives only after the client's ack). Applied between + /// AUs via [`Session::set_shard_payload`] — the packetizer re-keys, capture/encoder/ + /// virtual output are untouched (design/shard-payload-reneg.md Phase 1). + pub(super) shard_rx: std::sync::mpsc::Receiver, /// The resolved compositor backend (moot on Windows — `vdisplay::open` ignores it there). pub(super) compositor: crate::vdisplay::Compositor, /// This session's resolved gamescope sub-mode, or `None` for every other backend. Carried here @@ -1385,6 +1412,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option 0 by construction). + pub client_ceiling: u16, + /// → control task (the control stream's sole writer): send `ShardPayloadChanged{n}`. + pub change_tx: tokio::sync::mpsc::UnboundedSender, + /// ← control task: the client's `ShardPayloadAck`s (the grow gate). + pub ack_rx: tokio::sync::mpsc::UnboundedReceiver, + /// → data plane: apply [`Session::set_shard_payload`] between AUs + /// (drained next to `bitrate_rx` in the encode loop). + pub apply_tx: std::sync::mpsc::Sender, +} + /// Measured UDP-payload budget per peer IP, learned from live control connections whose MTU /// discovery settled below the video-datagram ceiling. In-memory only: a host restart /// re-learns in one session, and entries self-correct (a later ceiling-hit erases, a lower @@ -96,15 +116,26 @@ fn resolve(env_wire_mtu: Option, learned_udp_budget: Option, peer: I } /// Sample the control connection's discovered MTU after the search has settled and turn it -/// into a verdict. Spawned once per negotiated session; the task ends by itself after the -/// final sample (bounded ~10 s lifetime, holding only a cheap `Connection` handle). -pub(super) fn spawn_watch(conn: quinn::Connection, session_shard_payload: usize) { +/// into a verdict — and, with a [`ShardReneg`] driver, act on it MID-SESSION +/// (design/shard-payload-reneg.md Phase 2): a below-ceiling verdict shrinks the live wire at +/// the ~3–10 s mark (session 1 heals instead of staying black), and a settled-at-jumbo +/// verdict grows it, ack-gated, when the operator opted in. Spawned once per negotiated +/// session; without a grow the task ends after the final sample (bounded ~10 s lifetime, +/// holding only a cheap `Connection` handle) — after a grow it stays as the revert guard +/// until the connection closes. +pub(super) fn spawn_watch( + conn: quinn::Connection, + session_shard_payload: usize, + reneg: Option, +) { tokio::spawn(async move { let peer = conn.remote_address().ip(); let ceiling = video_datagram_udp_ceiling() as u16; // Discovery finishes in a handful of RTTs on a LAN (well under the first sample) but // needs a loss timeout per failed probe on a constrained path — the second sample - // covers that with margin. Max, because discovery only ever raises `current_mtu`. + // covers that with margin. Max, because discovery only ever raises `current_mtu` + // (the post-grow revert guard below re-reads it live, where blackhole detection CAN + // lower it again). let mut settled = 0u16; for wait_s in [3u64, 7] { tokio::time::sleep(std::time::Duration::from_secs(wait_s)).await; @@ -113,6 +144,9 @@ pub(super) fn spawn_watch(conn: quinn::Connection, session_shard_payload: usize) break; } } + // The wire this session is CURRENTLY sealed at — moves on a mid-session shrink/grow. + let mut current = session_shard_payload; + let mut reneg = reneg; if settled >= ceiling { // The path carries full-size video datagrams — erase any stale learned clamp so // the next session returns to the default wire. @@ -120,34 +154,116 @@ pub(super) fn spawn_watch(conn: quinn::Connection, session_shard_payload: usize) tracing::info!(peer = %peer, "wire MTU: path re-measured at full size — learned clamp cleared"); } - return; - } - // A closed connection stops discovering, so a session that ended before the final - // sample proves nothing (a healthy high-RTT path could still be mid-search): learn - // only from a connection that stayed alive through the whole window. - if conn.close_reason().is_some() { - return; - } - learned().lock().unwrap().insert(peer, settled); - if sealed_datagram_bytes(session_shard_payload) <= settled as usize { - // This session was already clamped small enough — the path is still constrained - // (keep the record fresh) but video fits, so no alarm. - tracing::info!(peer = %peer, discovered_udp_mtu = settled, - "wire MTU: constrained path re-measured; this session's video is sized to fit"); } else { - tracing::warn!( - peer = %peer, - discovered_udp_mtu = settled, - needed_udp_mtu = ceiling, - "wire MTU: this path CANNOT carry full-size video datagrams — the control \ - plane works but every video packet is oversized for a hop, which streams as \ - an endless black screen with zero reported loss. Typical cause: a VPN/overlay \ - adapter (Tailscale / Cloudflare WARP / ZeroTier) claiming the LAN route, or a \ - lowered NIC MTU — compare `ping -f -l 1450` vs `-l 1200` and check \ - `netsh interface ipv4 show subinterfaces` (Windows) / `ip link` (Linux). The \ - measured budget is recorded: the NEXT session from this client sizes video to \ - fit automatically. To pin it for all sessions set PUNKTFUNK_WIRE_MTU." - ); + // A closed connection stops discovering, so a session that ended before the final + // sample proves nothing (a healthy high-RTT path could still be mid-search): learn + // only from a connection that stayed alive through the whole window. + if conn.close_reason().is_some() { + return; + } + learned().lock().unwrap().insert(peer, settled); + if sealed_datagram_bytes(current) <= settled as usize { + // This session was already clamped small enough — the path is still constrained + // (keep the record fresh) but video fits, so no alarm. + tracing::info!(peer = %peer, discovered_udp_mtu = settled, + "wire MTU: constrained path re-measured; this session's video is sized to fit"); + } else { + tracing::warn!( + peer = %peer, + discovered_udp_mtu = settled, + needed_udp_mtu = ceiling, + "wire MTU: this path CANNOT carry full-size video datagrams — the control \ + plane works but every video packet is oversized for a hop, which streams as \ + an endless black screen with zero reported loss. Typical cause: a VPN/overlay \ + adapter (Tailscale / Cloudflare WARP / ZeroTier) claiming the LAN route, or a \ + lowered NIC MTU — compare `ping -f -l 1450` vs `-l 1200` and check \ + `netsh interface ipv4 show subinterfaces` (Windows) / `ip link` (Linux). The \ + measured budget is recorded: the NEXT session from this client sizes video to \ + fit automatically. To pin it for all sessions set PUNKTFUNK_WIRE_MTU." + ); + // Phase 2 down-leg: heal THIS session at the verdict mark. Shrink is sent + // and applied immediately — per-frame pinning on the client makes ordering + // irrelevant and smaller always fits; the ack is telemetry. The learned + // record above still makes session 2 START right. + if let Some(r) = reneg.as_ref() { + let target = shard_payload_for_udp_budget(settled as usize, peer); + if target < current + && r.change_tx.send(target as u16).is_ok() + && r.apply_tx.send(target).is_ok() + { + tracing::info!( + peer = %peer, + shard_payload = target, + was = current, + "wire MTU: video re-keyed mid-session to fit the constrained path \ + — the stream heals now instead of on the next connect" + ); + current = target; + } + } + } + } + // Phase 2 up-leg: jumbo grow — operator opt-in (PUNKTFUNK_JUMBO / PUNKTFUNK_WIRE_MTU + // > 1500, which also raised the endpoint's probe ceiling so `settled` can even reach + // here), client-advertised headroom, and a settled-at-jumbo proof. The grow is + // ACK-GATED: not one sealed datagram above the old size leaves before the client's + // ack, even though its buffers are statically sized — the rule must not erode. + let (Some(mtu), Some(r)) = (jumbo_wire_mtu(), reneg.as_mut()) else { + return; + }; + let target = jumbo_shard_payload_for(mtu, peer).min(r.client_ceiling as usize); + let target = target - target % 2; + if target <= current || (settled as usize) < sealed_datagram_bytes(target) { + return; + } + if r.change_tx.send(target as u16).is_err() { + return; + } + let acked = tokio::time::timeout(std::time::Duration::from_secs(5), async { + while let Some(v) = r.ack_rx.recv().await { + if v as usize == target { + return true; + } + } + false + }) + .await + .unwrap_or(false); + if !acked { + tracing::warn!(peer = %peer, shard_payload = target, + "wire MTU: jumbo grow not acked — staying at the current wire"); + return; + } + if r.apply_tx.send(target).is_err() { + return; + } + tracing::info!( + peer = %peer, + shard_payload = target, + was = current, + wire_mtu = mtu, + "wire MTU: jumbo grow acked and applied — packets-per-frame cut ~6×" + ); + current = target; + // Revert guard: a mis-proven jumbo hop must self-correct instead of blackholing. + // quinn's PMTU blackhole detection lowers `current_mtu` when the big packets start + // vanishing; sample it and shrink back through the same path the down-leg uses. + loop { + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + if conn.close_reason().is_some() { + return; + } + let mtu_now = conn.stats().path.current_mtu; + if (mtu_now as usize) < sealed_datagram_bytes(current) { + let back = shard_payload_for_udp_budget(mtu_now as usize, peer); + tracing::warn!(peer = %peer, discovered_udp_mtu = mtu_now, + shard_payload = back, was = current, + "wire MTU: jumbo path stopped fitting — reverting the wire to match"); + if r.change_tx.send(back as u16).is_err() || r.apply_tx.send(back).is_err() { + return; + } + current = back; + } } }); } diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 154239ae..868dd3f3 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -792,6 +792,16 @@ #define MSG_RFI_REQUEST 7 #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Type byte of [`ShardPayloadChanged`]. +#define MSG_SHARD_PAYLOAD_CHANGED 8 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Type byte of [`ShardPayloadAck`]. +#define MSG_SHARD_PAYLOAD_ACK 9 +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Type byte of [`ProbeRequest`]. #define MSG_PROBE_REQUEST 32 From ff5602361f36b5d08a56ddef959818239be5d2b7 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 19:47:20 +0200 Subject: [PATCH 28/53] fix(android/gamepad): TV wording points at the Controller-optimized UI toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Created and edited in the touch interface' is dead advice on a TV box — no touch to reach it with. Unlike tvOS the editor DOES exist on-device (same APK), behind this screen's own Controller-optimized UI toggle, so on TV the Profiles strings now name that route instead. --- .../unom/punktfunk/GamepadSettingsScreen.kt | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt index a0309f3c..6a808516 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt @@ -120,8 +120,12 @@ fun GamepadSettingsScreen( savedHosts = knownHostStore.all() } + // On a TV "the touch interface" is confusing advice (no touch to reach it with) — the honest + // path there is this screen's own Controller-optimized UI toggle, which swaps in the standard + // interface remote-navigably. The strings branch on it. + val tv = remember { isTvDevice(context) } val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) + - buildProfileRows(profiles, savedHosts) { pinProfile = it } + buildProfileRows(profiles, savedHosts, tv) { pinProfile = it } var focus by remember { mutableIntStateOf(0) } if (focus > rows.lastIndex) focus = rows.lastIndex // The direction the focused value last stepped (+1 forward / -1 back) — drives which way the @@ -505,15 +509,25 @@ private fun buildSettingsRows( /** * The trailing Profiles section — the Android mirror of the desktop console's (design §5.2a, §5.4): * one row per catalog profile, valued with how many saved hosts pin it, activating into the - * pin-to-hosts picker. Read-only beyond pinning: profiles are created and edited in the touch + * pin-to-hosts picker. Read-only beyond pinning: profiles are created and edited in the standard * interface, so an empty catalog shows one dimmed placeholder explaining where they come from - * instead of a dead-looking empty header. + * instead of a dead-looking empty header. On a TV that phrasing changes: "touch interface" points + * nowhere useful on a touchless device, so the strings name the actual route — the + * Controller-optimized UI toggle a few rows up, which swaps the standard interface in + * (d-pad-navigable; the profile editor lives there on every device, unlike tvOS where none exists). */ private fun buildProfileRows( profiles: List, savedHosts: List, + tv: Boolean, openPinPicker: (StreamProfile) -> Unit, ): List { + val createHint = if (tv) { + "To create or edit profiles on this device, turn off Controller-optimized UI above " + + "and use the standard interface." + } else { + "Profiles are created and edited in the touch interface." + } if (profiles.isEmpty()) { return listOf( GpRow( @@ -521,8 +535,8 @@ private fun buildProfileRows( header = "Profiles", label = "No profiles yet", value = "", - detail = "Profiles bundle stream settings for different uses. Create them in the " + - "touch interface, then pin them here as one-press connect cards.", + detail = "Profiles bundle stream settings for different uses — pinned ones become " + + "one-press connect cards here. " + createHint, adjust = { false }, activate = {}, adjustable = false, @@ -543,7 +557,7 @@ private fun buildProfileRows( else -> "Pinned to $pins hosts" }, detail = "Pin this profile to a host and it appears as its own card — one press " + - "connects with it. Profiles are created and edited in the touch interface.", + "connects with it. " + createHint, adjust = { false }, activate = { openPinPicker(p) }, adjustable = false, From e629606e39f0d2bb188184c05b867c238ad9730e Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 19:48:59 +0200 Subject: [PATCH 29/53] docs: Android is on Google Play production, not a closed test track MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Play production access landed 2026-08-01 and the listing is live, but the docs still told Android users to beg for a tester invite on Discord and warned that the Play link "only resolves once your account is on the tester list". Both are now wrong, and the install page is the first thing a new Android user reads. Stable is a public Play listing. Canary is unchanged — it still goes to the invite-only Internal testing track — so each page now draws that line explicitly instead of describing both as test tracks. Also corrects the release process: channels.md said CI "never auto-publishes to the public stores" and that someone promotes alpha -> production by hand. Since 43e3c7b6 a vX.Y.Z tag publishes to production at 100% with no further click (android.yml resolves TRACK=production on refs/tags/v*). Apple is still manual, so that half stands. Touches install-client.md, clients.md, channels.md, support-matrix.md and uninstall.md — the last one told people to ask on Discord to be removed from a tester list that no longer gates the app. --- docs-site/content/docs/channels.md | 18 ++++++++++-------- docs-site/content/docs/clients.md | 9 ++++----- docs-site/content/docs/install-client.md | 22 +++++++++++----------- docs-site/content/docs/support-matrix.md | 2 +- docs-site/content/docs/uninstall.md | 5 +++-- 5 files changed, 29 insertions(+), 27 deletions(-) diff --git a/docs-site/content/docs/channels.md b/docs-site/content/docs/channels.md index 9592f48d..896785c1 100644 --- a/docs-site/content/docs/channels.md +++ b/docs-site/content/docs/channels.md @@ -35,7 +35,7 @@ track per machine; switching is a one-line change. | **Windows client** (MSIX) | `…/generic/punktfunk-client-windows/canary/punktfunk-client-windows_x64.msix` | `…/latest/…` + the release page | | **Windows host** (installer) | `…/generic/punktfunk-host-windows/canary/punktfunk-host-setup.exe` | `…/latest/…` + the release page | | **Windows host** (winget) | — *(stable only)* | `winget install unom.PunktfunkHost` / `winget upgrade unom.PunktfunkHost`, after `winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest` | -| **Android** | Play **Internal testing** + sideload `…/generic/punktfunk-android/canary/punktfunk-android.apk` | Play **closed (alpha)** track + the release page | +| **Android** | Play **Internal testing** (invite-only) + sideload `…/generic/punktfunk-android/canary/punktfunk-android.apk` | **[Google Play](https://play.google.com/store/apps/details?id=io.unom.punktfunk)** (production) + the release page | | **Apple** (mac/iOS/tvOS) | **TestFlight** | TestFlight + a notarized `.dmg` on the release page | The apt distribution and the rpm group are just path segments in the URL — switching tracks is a @@ -120,14 +120,16 @@ major bump, or a patch), just tag it — the canary base re-derives from whateve Pre-release tags work too: `v0.2.0-rc1` builds a real release (the `-rc1` suffix is dropped where a strictly-numeric version is required — MSIX, the App Store marketing version). -### App-store promotion (manual, after the tag) +### App-store publication (after the tag) -CI uploads stable to **testing** tracks only — it never auto-publishes to the public stores: - -- **Apple** — the build lands in **TestFlight**. Promote to the App Store from App Store Connect - (submit for review). The notarized `.dmg` on the release page is the direct-download path. -- **Android** — the build lands in Play's **closed (alpha)** track. Promote alpha → production in - the Play Console when ready. +- **Android** — a `vX.Y.Z` tag publishes straight to Google Play **production** at 100%, with no + further click. Canary `main` builds go to Play **Internal testing**. To ramp a release gradually + instead of shipping it to everyone at once — or to halt or roll one back — use the Play Console, + or `android-promote.yml`, which moves a versionCode already on Play between tracks without + rebuilding. +- **Apple** — still manual. The build lands in **TestFlight**; promote it to the App Store from App + Store Connect (submit for review). The notarized `.dmg` on the release page is the + direct-download path. ## Why two tracks (the version-shadow trap) diff --git a/docs-site/content/docs/clients.md b/docs-site/content/docs/clients.md index 3e7357e4..9c4c30f4 100644 --- a/docs-site/content/docs/clients.md +++ b/docs-site/content/docs/clients.md @@ -100,11 +100,10 @@ capture state, and the switch that turns this off is *DualSense / DualShock pass Settings. Over **Bluetooth** the pad still works as an ordinary gamepad, but adaptive triggers and the lightbar need the USB connection. -The app is on Google Play as a **test track** (closed testing for stable, internal testing for -canary) — request a tester invite on our [**Discord**](https://discord.gg/kaPNvzMuGU) and we'll add -you, or sideload the public APK instead (see -[Install a Client](/docs/install-client#android)). Then open the app, pick your host, -[pair](/docs/pairing) once, and stream. +The app is on **[Google Play](https://play.google.com/store/apps/details?id=io.unom.punktfunk)** as a +public listing — no invite — or you can sideload the public APK instead (see +[Install a Client](/docs/install-client#android)); canary builds ride a separate, invite-only Play +Internal testing track. Then open the app, pick your host, [pair](/docs/pairing) once, and stream. ## Windows desktop client diff --git a/docs-site/content/docs/install-client.md b/docs-site/content/docs/install-client.md index 8800f438..6983ab47 100644 --- a/docs-site/content/docs/install-client.md +++ b/docs-site/content/docs/install-client.md @@ -25,7 +25,7 @@ Already installed? Skip to [Keeping a client up to date](#keeping-a-client-up-to | **Windows** | [Signed MSIX](#windows) from the package registry | | **macOS** | [Notarized `.dmg`](#macos) from the releases page | | **iPhone / iPad / Apple TV** | [TestFlight beta](#ios-ipados-apple-tv) | -| **Android / Android TV** | [Beta — a Play test track, or sideload the APK](#android) | +| **Android / Android TV** | [Google Play](#android), or sideload the APK | | **LG webOS TV** | [Community client](#lg-webos-tv-community) (sideloaded `.ipk`) | | Anything else (browser, old phone, TV) | [Moonlight](/docs/moonlight) | @@ -162,19 +162,15 @@ Open the app, and your hosts appear automatically under *On this network*. ## Android -The Android client (phone + Android TV) is on Google Play as a **test track** — **closed testing** -for stable releases, **internal testing** for canary builds. To join, request a tester invite on our -[**Discord**](https://discord.gg/kaPNvzMuGU) and we'll add your Google account: - -**[Request access on Discord →](https://discord.gg/kaPNvzMuGU)** - -Once you're added, install it from Google Play, then open the app and pick your host: +The Android client (phone + Android TV — one package, the TV layout is the same app in leanback +mode) is published on **Google Play**. It's a public listing: no invite, no tester list. **[Get Punktfunk on Google Play →](https://play.google.com/store/apps/details?id=io.unom.punktfunk)** -_(only resolves once your account is on the tester list)_ -**Prefer not to wait for an invite?** The signed APK is published publicly on every build, so you can -sideload it instead — no account, no invite: +Install it, open the app, and pick your host. + +**Prefer not to go through Play?** The signed APK is published publicly on every build, so you can +sideload it instead — no Play account needed: ```text https://git.unom.io/api/packages/unom/generic/punktfunk-android/latest/punktfunk-android.apk @@ -184,6 +180,10 @@ Swap `latest` for `canary` to track `main`. Release APKs are also attached to ea [release](https://git.unom.io/unom/punktfunk/releases). Android asks you to allow installs from your browser or file manager the first time. +**Canary on Play** is a separate **Internal testing** track, and that one *is* invite-only — ask on +[Discord](https://discord.gg/kaPNvzMuGU) and we'll add your Google account. The `canary` APK above +needs no invite. + ## LG webOS TV (community) > **Community project.** [`pf-webos`](https://github.com/dyptan-io/pf-webos) is built and maintained diff --git a/docs-site/content/docs/support-matrix.md b/docs-site/content/docs/support-matrix.md index 93008856..9f8f31ec 100644 --- a/docs-site/content/docs/support-matrix.md +++ b/docs-site/content/docs/support-matrix.md @@ -513,7 +513,7 @@ capability. | **GameStream / Moonlight plane** | Works, and whether it is on depends on how you installed. Every Linux package (deb, RPM, Arch, the Bazzite sysext) and the SteamOS installer ship the unit as `serve --gamestream`, so GameStream is **on** there; NixOS defaults it on too. The Windows installer's checkbox is unticked, so it is **off** unless you asked for it, and a bare `punktfunk-host serve` is off. It pairs over plain HTTP with weaker legacy encryption — trusted LAN only, and worth turning off if you don't use Moonlight (see [Security](/docs/security#gamestream--moonlight-compatibility-is-the-weak-crypto-path)). It is a compatibility surface, so Punktfunk-only features (profiles, links, clipboard, microphone) are not on it. | | **Linux and Windows desktop clients** | Packaged and current. They are one codebase: the same session binary streams for both, and for the Decky plugin and the `punktfunk` CLI. | | **Apple client** (macOS · iOS · iPadOS · tvOS) | One universal build, distributed as a **TestFlight beta**; the Mac also has a notarized DMG. Feature-complete apart from the platform gaps named above (no microphone on tvOS, clipboard on macOS only). | -| **Android client** (phone · TV) | Distributed on Play's **closed (alpha)** track for releases, Internal testing for canary, plus a sideloadable APK. The same app in leanback mode is the TV client. | +| **Android client** (phone · TV) | Published on **Google Play** as a public listing for releases, with an invite-only Internal testing track for canary, plus a sideloadable APK. The same app in leanback mode is the TV client. | | **Decky plugin** (Steam Deck) | Ships through install-from-URL rather than the Decky store, and keeps itself and the client it launches up to date. It launches the Linux client rather than streaming itself, and has no settings surface of its own beyond the flat values it writes into the shared client settings. | | **Web console** | The full management surface — dashboard and sessions, pairing, library, displays, plugins and the plugin store, logs, stats, settings, and host updates. It cannot yet run a speed test or set a bitrate; the client apps can. | | **Plugins** | Three first-party ones (ROM Manager, Playnite, VirtualHere) plus the SDK, installed from the console. See [Plugins](/docs/plugins). | diff --git a/docs-site/content/docs/uninstall.md b/docs-site/content/docs/uninstall.md index deafa629..49d4bdb2 100644 --- a/docs-site/content/docs/uninstall.md +++ b/docs-site/content/docs/uninstall.md @@ -300,8 +300,9 @@ stop testing — that removes the app and its data with it. ### Android / Android TV -Uninstall the app from Google Play or from Settings → Apps. The Android client is still an invited -test track, so if you also want your account taken off the tester list, say so on +Uninstall the app from Google Play or from Settings → Apps. That's the whole job — it's a public +Play listing, so there's no tester list to leave. If you were on the invite-only **canary** +(Internal testing) track and want off that too, say so on [Discord](https://discord.gg/kaPNvzMuGU). ### Steam Deck — Decky plugin From d1c4cb18dd2d6b109190f0ff631ef8080c312feb Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 20:00:09 +0200 Subject: [PATCH 30/53] test(core/session): pin the low-MTU chunk-aligned guarantee at clamped shard sizes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PyroWave sessions are gated out of mid-session renegotiation, so a constrained path serves them through the leg-1 SESSION-START clamp. This pins the consistency that guarantee rests on: everything chunk-aligned derives from the one Welcome::shard_payload number — the host packetizes at it, the client's C-ABI parse window reads it back, and partial delivery zero-fills exact windows of it — verified at the two clamp shapes a constrained path actually produces (1216, the WARP/Tailscale budget, and the 512 floor) over the sealed loopback wire with real loss. --- crates/punktfunk-core/src/session.rs | 83 ++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/crates/punktfunk-core/src/session.rs b/crates/punktfunk-core/src/session.rs index 6101430d..a95d6ff5 100644 --- a/crates/punktfunk-core/src/session.rs +++ b/crates/punktfunk-core/src/session.rs @@ -1086,6 +1086,89 @@ mod wire_equivalence_tests { ); } + /// The low-MTU PyroWave guarantee (design/shard-payload-reneg.md): mid-session + /// renegotiation is gated OFF for chunk-aligned sessions, so a constrained path serves + /// them through the leg-1 SESSION-START clamp instead — the learned budget (or + /// `PUNKTFUNK_WIRE_MTU`) sizes `Welcome::shard_payload`, and everything chunk-aligned + /// derives from that ONE number fixed at the handshake: the host packetizes at it, the + /// client's parse window reads it back ([`Session::shard_payload`] → the C-ABI + /// `punktfunk_connection_shard_payload` every embedder walks windows with), and partial + /// delivery zero-fills exact windows of it. Pin that consistency at the clamp shapes a + /// constrained path actually produces: the WARP/Tailscale budget (1216) and the floor + /// (512) — chunk-aligned frames deliver, lose whole windows (never splice), and the + /// window arithmetic matches the session value end to end. + #[test] + fn chunk_aligned_sessions_work_at_clamped_shard_sizes() { + use crate::packet::USER_FLAG_CHUNK_ALIGNED; + for shard in [1216usize, crate::config::MIN_SHARD_PAYLOAD] { + let mk = |role| Config { + role, + phase: ProtocolPhase::P2Punktfunk, + fec: FecConfig { + scheme: FecScheme::Gf16, + fec_percent: 0, // no parity — any drop leaves a hole + max_data_per_block: 64, + }, + shard_payload: shard, + max_frame_bytes: 8 * 1024 * 1024, + encrypt: true, + key: SessionKey::Aes128Gcm([7u8; 16]), + salt: [3, 1, 4, 1], + loopback_drop_period: 0, + }; + let (h, c) = crate::transport::loopback_pair(3, 1); + let mut host = Session::new(mk(Role::Host), Box::new(h)).unwrap(); + let mut client = Session::new(mk(Role::Client), Box::new(c)).unwrap(); + client.set_deliver_partial_frames(true); + // The window every embedder parses with IS the clamped session value. + assert_eq!(client.shard_payload(), shard); + assert_eq!(host.shard_payload(), shard); + + let frame = pattern(8 * shard); + host.submit_frame(&frame, 1_000, USER_FLAG_CHUNK_ALIGNED) + .unwrap(); + let mut got_partial = None; + let mut completes = 0; + for i in 0..80u64 { + host.submit_frame(&pattern(shard), 2_000 + i, USER_FLAG_CHUNK_ALIGNED) + .unwrap(); + loop { + match client.poll_frame() { + Ok(f) if !f.complete => got_partial = Some(f), + Ok(_) => completes += 1, + Err(PunktfunkError::NoFrame) => break, + Err(e) => panic!("shard {shard}: unexpected: {e}"), + } + } + } + let p = got_partial.expect("the lossy frame must be delivered partial"); + assert_eq!(p.data.len(), frame.len(), "shard {shard}"); + // Loss lands on exact `shard`-sized window boundaries: zeroed windows for the + // dropped datagrams, byte-identical survivors — nothing spliced across windows. + let mut zero_windows = 0; + for w in 0..8 { + let win = &p.data[w * shard..(w + 1) * shard]; + if win.iter().all(|&b| b == 0) { + zero_windows += 1; + } else { + assert_eq!( + win, + &frame[w * shard..(w + 1) * shard], + "shard {shard}: window {w} corrupt" + ); + } + } + assert!( + (1..8).contains(&zero_windows), + "shard {shard}: dropped shards zero-filled (got {zero_windows})" + ); + assert!( + completes > 40, + "shard {shard}: surviving filler frames flow normally" + ); + } + } + /// Mid-session shard renegotiation end to end over the SEALED loopback wire /// (design/shard-payload-reneg.md): one host session re-keys its packetizer between AUs /// — shrink, jumbo grow, revert — through one continuous crypto/replay stream, and one From 6e001e54b42e28384f9ef9e05da52663ab867c46 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 20:08:56 +0200 Subject: [PATCH 31/53] fix(host/pads): a centred stick reads centred, and a delayed effect waits its turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four encoder faults, plus a note on a fifth that turned out not to be one. A centred stick did not encode as centre on the Y axes. The mapper inverted the already-quantised byte, and 0..255 has no exact midpoint: the forward map puts centre at 0x80, so mirroring the output lands on 0x7F — one below the 0x80 that DsState::neutral and the pad's own resting report use. Games idle-poll a centred stick constantly, so a DualSense, Edge or DS4 sat under a permanent sub-deadzone tilt. Inverting in i16 space instead maps centre to centre by construction and keeps both extremes exact; the only cost is i16::MIN and -32767 sharing a code, one LSB at the very end of the travel. Force-feedback ignored replay.delay. It was decoded on upload and never read: an effect started the moment it was played and ended replay.length later, so anything scheduling a delayed effect — DirectInput under Wine does this routinely — fired early AND finished early by the same amount. The delay now shifts the whole window, with length measured from the end of the delay rather than eaten into by it. Writing the test for that surfaced a second bug in the same path: a waiting effect was still a candidate for the abandoned-effect force-off, and since the play command is itself the last FF activity, an infinite effect with a delay longer than the idle window would be killed on its first contributing tick after sitting silent the whole time it waited. Being abandoned now requires the effect to have been audible for the window too. An empty serial panicked the service thread. The reply builder clamped the length to at least 1 and then sliced that many bytes out of the string, which asks a zero-byte slice for one byte. The kernel already has a graceful answer for a length it rejects, so report the true one and let it fall back. Deck triggers could not reach full pull. Scaling by 128 tops out at 32640 of a declared 32767, leaving the last 127 counts unreachable, so no game could ever see the axis bottom out. One multiply gets both ends exact. The idle watchdog is left alone. It does cut finite multi-second effects that the uinput path exempts, but only the uinput path is handed an explicit duration; the protocols behind the watchdog are level-triggered with no duration field anywhere in a report, so there is nothing at that layer to exempt. The choice is between cutting a long effect and letting an abandoned one drone forever, and only the latter has field evidence behind it. Recorded at the constant so the next reader sees the cost rather than rediscovering it. --- crates/pf-inject/src/inject/linux/gamepad.rs | 208 ++++++++++++++++-- .../src/inject/proto/dualsense_proto.rs | 35 ++- .../pf-inject/src/inject/proto/steam_proto.rs | 58 ++++- crates/pf-inject/src/inject/uhid_manager.rs | 16 ++ 4 files changed, 287 insertions(+), 30 deletions(-) diff --git a/crates/pf-inject/src/inject/linux/gamepad.rs b/crates/pf-inject/src/inject/linux/gamepad.rs index 435cf87d..551ccef6 100644 --- a/crates/pf-inject/src/inject/linux/gamepad.rs +++ b/crates/pf-inject/src/inject/linux/gamepad.rs @@ -254,13 +254,45 @@ fn ioctl_ptr(fd: i32, req: libc::c_ulong, arg: *mut T, what: &str) -> Result< Ok(()) } +/// The window a played effect occupies: `replay.delay` of silence, then `replay.length` of rumble. +#[derive(Clone, Copy)] +struct Playback { + /// When the effect starts contributing — `play + replay.delay`. Until then it is armed but + /// silent, which is the whole point of the delay. + starts: Instant, + /// When it stops, or `None` for replay length 0 (until explicitly stopped). + ends: Option, +} + /// One FF effect a game uploaded: rumble magnitudes + playback state. struct Effect { strong: u16, weak: u16, - /// `Some(deadline)` while playing (replay length 0 = until stopped). - playing: Option>, + /// `Some(window)` while playing. + playing: Option, replay_ms: u16, + /// `replay.delay` — how long after the play command the effect stays silent. Decoded from the + /// upload since forever and, until now, never acted on: the effect started immediately and + /// ended `replay.length` later, so anything scheduling a delayed effect (DirectInput under + /// Wine does this routinely) fired early AND finished early by the same amount. + delay_ms: u16, +} + +impl Effect { + /// The window a play command at `at` opens: silent for `replay.delay`, then `replay.length` of + /// rumble (or until stopped, when the length is 0). + /// + /// `replay.length` is measured from the END of the delay, not from the play command, so the + /// delay shifts the whole window instead of eating into it. Split out from the `EV_FF` handler + /// purely so this is testable — the handler itself needs a live uinput fd. + fn window(&self, at: Instant) -> Playback { + let starts = at + Duration::from_millis(self.delay_ms as u64); + Playback { + starts, + ends: (self.replay_ms > 0) + .then(|| starts + Duration::from_millis(self.replay_ms as u64)), + } + } } /// The force-feedback half of a virtual pad — the game-side effect table plus the mixdown policy @@ -299,17 +331,29 @@ impl FfState { /// Mix: sum playing effects (expiring finished ones, force-stopping abandoned infinite ones), /// scale by gain. Returns the new `(low, high)` only when it changed since the last call. fn mix(&mut self, now: Instant, idle: Option) -> Option<(u16, u16)> { - let stale = idle.is_some_and(|t| now.duration_since(self.last_activity) >= t); + let quiet_since = |t: Instant| idle.is_some_and(|d| now.duration_since(t) >= d); + let plane_stale = quiet_since(self.last_activity); let (mut strong, mut weak) = (0u32, 0u32); for e in self.effects.values_mut() { - let Some(deadline) = e.playing else { continue }; - match deadline { + let Some(p) = e.playing else { continue }; + // Still inside `replay.delay`: armed, silent, and NOT a candidate for expiry or the + // abandoned-effect force-off — it has not had its turn yet. + if now < p.starts { + continue; + } + match p.ends { Some(d) if now >= d => e.playing = None, // An infinite-replay effect the game stopped driving (no FF traffic for the whole // idle window) — the alive-but-abandoned case the kernel's close-time auto-erase // cannot see. Stop it once; a later EV_FF play re-arms it (and refreshes the // clock). Mirrors the XUSB/UHID abandoned-rumble force-off. - None if stale => { + // + // "Abandoned" needs the effect to have been AUDIBLE for the window too, not just + // the plane quiet: the play command is itself the last activity, so an effect with + // a `replay.delay` longer than the window would otherwise be force-stopped the + // instant it finally started — silent the whole time it waited, then killed on its + // first contributing tick. + None if plane_stale && quiet_since(p.starts) => { tracing::info!( strong = e.strong, weak = e.weak, @@ -544,10 +588,12 @@ impl VirtualPad { weak: 0, playing: None, replay_ms: 0, + delay_ms: 0, }); slot.strong = strong; slot.weak = weak; slot.replay_ms = e.replay_length; + slot.delay_ms = e.replay_delay; } up.effect.id = e.id; // hand the assigned slot back to the kernel up.retval = 0; @@ -574,14 +620,7 @@ impl VirtualPad { (EV_FF, code) => { self.ff.note_activity(); if let Some(e) = self.ff.effects.get_mut(&(code as i16)) { - e.playing = if ev.value != 0 { - Some((e.replay_ms > 0).then(|| { - Instant::now() - + std::time::Duration::from_millis(e.replay_ms as u64) - })) - } else { - None - }; + e.playing = (ev.value != 0).then(|| e.window(Instant::now())); } } _ => {} @@ -802,15 +841,34 @@ mod ff_state_tests { ff } + /// Playing from `at`, no delay, until explicitly stopped. + fn playing(at: Instant) -> Option { + Some(Playback { + starts: at, + ends: None, + }) + } + + /// Playing from `at`, no delay, for `len`. + fn playing_for(at: Instant, len: Duration) -> Option { + Some(Playback { + starts: at, + ends: Some(at + len), + }) + } + #[test] fn abandoned_infinite_effect_is_forced_off_after_idle_window() { + let now = Instant::now(); let mut ff = ff_with(Effect { strong: 0x8000, weak: 0, - playing: Some(None), + // Playing since before the window: "abandoned" means audible AND unattended, so an + // effect that only just started is not a candidate however stale the plane is. + playing: playing(now - Duration::from_millis(2600)), replay_ms: 0, + delay_ms: 0, }); - let now = Instant::now(); assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0))); assert_eq!(ff.mix(now, IDLE), None); // unchanged level dedups, still playing // The game goes silent on the FF plane past the idle window: cut, exactly once. @@ -825,8 +883,9 @@ mod ff_state_tests { let mut ff = ff_with(Effect { strong: 0x4000, weak: 0, - playing: Some(Some(now + Duration::from_secs(10))), + playing: playing_for(now, Duration::from_secs(10)), replay_ms: 10_000, + delay_ms: 0, }); // FF plane long stale, but the effect declared a finite replay — the declared duration is // the contract (a real pad honors it too), so it keeps playing… @@ -842,26 +901,135 @@ mod ff_state_tests { let mut ff = ff_with(Effect { strong: 0x8000, weak: 0, - playing: Some(None), + playing: playing(now - Duration::from_millis(3000)), replay_ms: 0, + delay_ms: 0, }); assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0))); ff.last_activity = now - Duration::from_millis(3000); assert_eq!(ff.mix(now, IDLE), Some((0, 0))); // The game plays the effect again — an FF event refreshes the clock and re-arms playback. ff.last_activity = now; - ff.effects.get_mut(&0).unwrap().playing = Some(None); + ff.effects.get_mut(&0).unwrap().playing = playing(now); assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0))); } + /// `replay.delay` shifts the whole window: silent until it elapses, then the FULL + /// `replay.length`. Before this the delay was decoded and dropped, so a delayed effect both + /// started early and finished early — DirectInput under Wine schedules these routinely. + #[test] + fn replay_delay_holds_the_effect_off_then_gives_it_its_full_length() { + let now = Instant::now(); + let starts = now + Duration::from_millis(500); + let mut ff = ff_with(Effect { + strong: 0x8000, + weak: 0, + playing: Some(Playback { + starts, + ends: Some(starts + Duration::from_secs(1)), + }), + replay_ms: 1000, + delay_ms: 500, + }); + // Inside the delay: armed but silent. + assert_eq!(ff.mix(now, IDLE), None); + assert_eq!(ff.mix(now + Duration::from_millis(499), IDLE), None); + // Delay elapsed: it plays. + assert_eq!( + ff.mix(now + Duration::from_millis(501), IDLE), + Some((scaled(0x8000), 0)) + ); + // Still playing at 1400 ms — it gets its full second FROM the delay, not from the play. + assert_eq!(ff.mix(now + Duration::from_millis(1400), IDLE), None); + // And ends at delay + length, not at length. + assert_eq!( + ff.mix(now + Duration::from_millis(1600), IDLE), + Some((0, 0)) + ); + } + + /// The window a play opens, straight from the uploaded fields — this is the half that reads + /// `replay.delay` at all. Pinned separately because the `EV_FF` handler that calls it needs a + /// live uinput fd, so a test driving `mix` alone would pass with the delay ignored entirely. + #[test] + fn window_offsets_the_whole_playback_by_replay_delay() { + let at = Instant::now(); + + let delayed = Effect { + strong: 0, + weak: 0, + playing: None, + replay_ms: 1000, + delay_ms: 500, + }; + let w = delayed.window(at); + assert_eq!( + w.starts, + at + Duration::from_millis(500), + "delay defers the start" + ); + assert_eq!( + w.ends, + Some(at + Duration::from_millis(1500)), + "length runs from the END of the delay, so the effect keeps its full second" + ); + + // No delay: starts immediately, unchanged from before. + let plain = Effect { + strong: 0, + weak: 0, + playing: None, + replay_ms: 1000, + delay_ms: 0, + }; + let w = plain.window(at); + assert_eq!(w.starts, at); + assert_eq!(w.ends, Some(at + Duration::from_millis(1000))); + + // Length 0 = until stopped, but the delay still applies. + let infinite = Effect { + strong: 0, + weak: 0, + playing: None, + replay_ms: 0, + delay_ms: 250, + }; + let w = infinite.window(at); + assert_eq!(w.starts, at + Duration::from_millis(250)); + assert_eq!(w.ends, None); + } + + /// A delayed effect must not be force-stopped as "abandoned" while it is still waiting: it has + /// not had its turn, and the idle window is shorter than a delay can legitimately be. + #[test] + fn a_waiting_effect_is_not_cut_by_the_idle_watchdog() { + let now = Instant::now(); + let starts = now + Duration::from_secs(5); + let mut ff = ff_with(Effect { + strong: 0x8000, + weak: 0, + playing: Some(Playback { starts, ends: None }), + replay_ms: 0, + delay_ms: 5000, + }); + ff.last_activity = now - Duration::from_secs(60); // long stale + assert_eq!(ff.mix(now, IDLE), None); // silent, but NOT cut + // It still plays when its delay elapses. + assert_eq!( + ff.mix(now + Duration::from_millis(5001), IDLE), + Some((scaled(0x8000), 0)) + ); + } + #[test] fn disabled_watchdog_never_cuts() { let now = Instant::now(); let mut ff = ff_with(Effect { strong: 0x8000, weak: 0, - playing: Some(None), + playing: playing(now), replay_ms: 0, + delay_ms: 0, }); ff.last_activity = now - Duration::from_secs(600); assert_eq!(ff.mix(now, None), Some((scaled(0x8000), 0))); diff --git a/crates/pf-inject/src/inject/proto/dualsense_proto.rs b/crates/pf-inject/src/inject/proto/dualsense_proto.rs index 32852914..1b294804 100644 --- a/crates/pf-inject/src/inject/proto/dualsense_proto.rs +++ b/crates/pf-inject/src/inject/proto/dualsense_proto.rs @@ -250,11 +250,19 @@ impl DsState { use punktfunk_core::input::gamepad as gs; let to_u8 = |v: i16| (((v as i32) + 32768) >> 8) as u8; let on = |bit: u32| buttons & bit != 0; + // Invert in i16 space, BEFORE the quantisation, rather than as `255 - to_u8(v)`. + // 0..=255 has no exact midpoint: `to_u8` puts centre at 0x80, which leaves 128 codes below + // it and 127 above, so mirroring the *output* (`255 - 0x80` = 0x7F) lands a centred stick + // one LSB off the 0x80 that `DsState::neutral` — and the pad's own resting report — use. + // Games idle-poll a centred stick constantly, so that off-by-one showed up as a permanent + // sub-deadzone tilt on the Y axes only. Negating first maps centre to centre by + // construction and keeps both extremes exact (+32767 → 0, -32768 → 255); the only cost is + // that i16::MIN and -32767 share the 255 code, one LSB at the very end of the travel. let mut s = DsState { lx: to_u8(lx), - ly: 255 - to_u8(ly), + ly: to_u8(ly.saturating_neg()), rx: to_u8(rx), - ry: 255 - to_u8(ry), + ry: to_u8(ry.saturating_neg()), l2: lt, r2: rt, ..DsState::neutral() @@ -783,6 +791,29 @@ mod tests { assert_eq!(r[53], 0x0A); } + /// A centred stick must encode as the pad's own neutral on BOTH axes. Inverting the quantised + /// byte (`255 - v`) put Y one LSB below it, which games idle-poll constantly — a permanent + /// sub-deadzone tilt. Extremes must stay exact either way. + #[test] + fn centred_sticks_encode_as_neutral_on_every_axis() { + let n = DsState::neutral(); + let s = DsState::from_gamepad(0, 0, 0, 0, 0, 0, 0); + assert_eq!((s.lx, s.ly), (n.lx, n.ly), "left stick centre"); + assert_eq!((s.rx, s.ry), (n.rx, n.ry), "right stick centre"); + + // Y is still inverted (XInput +y = up, DualSense 0 = up) and both ends stay exact. + let up = DsState::from_gamepad(0, 0, i16::MAX, 0, i16::MAX, 0, 0); + assert_eq!((up.ly, up.ry), (0, 0), "full up = 0"); + let down = DsState::from_gamepad(0, 0, i16::MIN, 0, i16::MIN, 0, 0); + assert_eq!((down.ly, down.ry), (255, 255), "full down = 255"); + + // X keeps its existing mapping. + let right = DsState::from_gamepad(0, i16::MAX, 0, i16::MAX, 0, 0, 0); + assert_eq!((right.lx, right.rx), (255, 255)); + let left = DsState::from_gamepad(0, i16::MIN, 0, i16::MIN, 0, 0, 0); + assert_eq!((left.lx, left.rx), (0, 0)); + } + /// The wire touchpad-click / guide / mute bits (Moonlight's extended positions) land in /// `buttons[2]`. #[test] diff --git a/crates/pf-inject/src/inject/proto/steam_proto.rs b/crates/pf-inject/src/inject/proto/steam_proto.rs index c03c574f..125894c7 100644 --- a/crates/pf-inject/src/inject/proto/steam_proto.rs +++ b/crates/pf-inject/src/inject/proto/steam_proto.rs @@ -183,8 +183,9 @@ impl SteamState { /// Map an `XInput`/GameStream pad frame (button bitmask + i16 sticks + u8 triggers) into the Deck /// state. Sticks pass through (the kernel negates Y, which yields the conventional direction — - /// validated on-box); triggers scale u8 0..255 → u16 0..32640 and set the full-pull bit when - /// pressed. Trackpad + motion + the back grips arrive separately ([`apply_rich`], the M3 wire). + /// validated on-box); triggers scale u8 0..255 → u16 0..32767 ([`trigger_u16`]) and set the + /// full-pull bit when pressed. Trackpad + motion + the back grips arrive separately + /// ([`apply_rich`], the M3 wire). pub fn from_gamepad( buttons: u32, lx: i16, @@ -200,8 +201,8 @@ impl SteamState { ly, rx, ry, - lt: (lt as u16) * 128, - rt: (rt as u16) * 128, + lt: trigger_u16(lt), + rt: trigger_u16(rt), ..SteamState::neutral() }; let mut b = 0u64; @@ -375,8 +376,8 @@ pub fn sc_from_gamepad( ly, rx: 0, ry: 0, - lt: (lt as u16) * 128, - rt: (rt as u16) * 128, + lt: trigger_u16(lt), + rt: trigger_u16(rt), // The wire right stick becomes a right-pad contact (see the doc above). rpad_x: rx, rpad_y: ry, @@ -466,6 +467,18 @@ pub fn serialize_sc_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq: r[38..40].copy_from_slice(&st.gyro[2].to_le_bytes()); } +/// Scale a wire trigger (u8 `0..=255`) onto the Deck's full axis (u16 `0..=32767`). +/// +/// This was `v * 128`, which tops out at 32640 — a fully-pulled trigger reported 99.6% and the top +/// 127 counts of the declared range were unreachable, so a game reading the axis could never see a +/// true full pull. One multiply gets both ends exact (`0 → 0`, `255 → 32767`) and stays monotonic. +/// +/// `serialize_report`'s inverse (`>> 7`, for the legacy u8 trigger bytes) still round-trips both +/// ends against this: `32767 >> 7 == 255`. +fn trigger_u16(v: u8) -> u16 { + ((v as u32 * 32767) / 255) as u16 +} + /// Build the `steam_get_serial` GET_REPORT reply. The Steam feature path is report-id-0 with a /// leading report-id byte the kernel strips (`steam_recv_report` does `memcpy(data, buf+1, …)`), so /// the wire is `[0x00, 0xAE, len, 0x01, ascii…]`; the kernel then validates `reply[0]==0xAE`, @@ -473,7 +486,12 @@ pub fn serialize_sc_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq: pub fn serial_reply(serial: &str) -> [u8; STEAM_REPORT_LEN] { let mut buf = [0u8; STEAM_REPORT_LEN]; let bytes = serial.as_bytes(); - let len = bytes.len().clamp(1, 21); + // `min`, not `clamp(1, 21)`. Clamping the LOW end to 1 and then slicing `bytes[..len]` asks a + // zero-byte slice for one byte, which panics — on the service thread, for an input the kernel + // already has a graceful answer to. Reporting the true length lets its own validation + // (`1 <= reply[1] <= 21`) reject an empty serial and fall back to "XXXXXXXXXX", which is the + // documented behaviour for a reply it does not like. + let len = bytes.len().min(21); buf[0] = 0x00; // report id 0 — stripped by steam_recv_report buf[1] = ID_GET_STRING_ATTRIBUTE; buf[2] = len as u8; @@ -704,7 +722,7 @@ mod tests { assert_ne!(s.buttons & btn::STEAM, 0); assert_ne!(s.buttons & btn::LB, 0); assert_ne!(s.buttons & btn::LT_FULL, 0); // lt=255 → full-pull bit - assert_eq!(s.lt, 255 * 128); + assert_eq!(s.lt, 32767); // full pull reaches the TOP of the declared range assert_eq!(s.lx, 1000); assert_eq!(s.ly, -2000); @@ -730,6 +748,30 @@ mod tests { assert_eq!(s.accel, [16384, -8192, 0]); } + /// An empty serial must not panic. `clamp(1, 21)` asked a zero-byte slice for one byte, which + /// is an out-of-range slice index — on the service thread. The kernel rejects a zero length by + /// its own rule (`1 <= reply[1] <= 21`) and falls back, which is the graceful answer. + #[test] + fn empty_serial_reply_does_not_panic() { + let r = serial_reply(""); + assert_eq!(r[1], ID_GET_STRING_ATTRIBUTE); + assert_eq!( + r[2], 0, + "length the kernel will reject, rather than a panic" + ); + + // Normal and over-long serials still behave. + let r = serial_reply("ABC123"); + assert_eq!(r[2], 6); + assert_eq!(&r[4..10], b"ABC123"); + let long = "X".repeat(40); + assert_eq!( + serial_reply(&long)[2], + 21, + "clamped to the protocol maximum" + ); + } + /// M3: the wire back-button bits map to the four Deck grips + QAM, and `TouchpadEx` routes the /// left / right surfaces to the matching pad (x passes straight through; y flips from the /// wire's screen convention (+down) to the Deck's raw +up — the live-verified direction). diff --git a/crates/pf-inject/src/inject/uhid_manager.rs b/crates/pf-inject/src/inject/uhid_manager.rs index 8f91f0a9..b392a25f 100644 --- a/crates/pf-inject/src/inject/uhid_manager.rs +++ b/crates/pf-inject/src/inject/uhid_manager.rs @@ -159,6 +159,22 @@ impl OverflowWarn { /// real firmware decays, and that re-assert is what keeps a legitimately-held long rumble alive /// here. The XUSB path shares this window via [`rumble_idle_timeout`] (every XUSB write IS a /// rumble write, so its any-activity keying is already rumble-keyed by construction). +/// +/// KNOWN COST, deliberately accepted. That invariant only covers writers that re-assert. A game +/// driving the pad through the kernel's *evdev* FF interface does not: `ff-memless` sends one +/// output report when an effect starts and one when it stops, with nothing in between, so a finite +/// effect longer than this window is cut in half here. The uinput path +/// (`linux/gamepad.rs`) exempts exactly that case — but it can, because evdev FF hands it an +/// explicit `replay.length`. Nothing equivalent reaches this layer: [`PadFeedback`] carries motor +/// levels, and the protocols it speaks (DualSense / DS4 / Deck / Switch Pro) are all +/// level-triggered with no duration field anywhere in a report. So the choice is between cutting a +/// long finite effect and letting an abandoned residual drone forever, and the residual is the one +/// with field evidence behind it (a stuck level resent every 500 ms for 5.5 minutes). Switch Pro is +/// not affected either way — `hid-nintendo` re-sends rumble continuously, and a physical Pro's +/// HD-rumble decays faster than this window regardless. +/// +/// Do not "fix" this by widening or disabling the window without evidence about which failure real +/// titles actually hit; the hatch below exists for exactly that experiment. const RUMBLE_IDLE_TIMEOUT: Duration = Duration::from_millis(2500); /// The abandoned-rumble force-off window, env-hatched: `PUNKTFUNK_RUMBLE_IDLE_MS` overrides From 48bb1769b4f6f6be0eeda5484f54a9b12e271049 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 20:26:59 +0200 Subject: [PATCH 32/53] =?UTF-8?q?feat(cli):=20punktfunk=20discover=20?= =?UTF-8?q?=E2=80=94=20browse=20the=20LAN,=20annotated=20against=20what=20?= =?UTF-8?q?you've=20saved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI could do everything with a host except FIND one, so every headless consumer grew its own mDNS: the Decky plugin parses ~120 lines of avahi TXT escaping in Python, which drifts from the host's advert every time a key is added and makes the plugin depend on Avahi being the resolver. `discovery::discover_for(timeout)` is the bounded collector beside the streaming `browse()` the UI uses — same service type, same TXT keys, folded to one row per host. A refreshed advert wins (it carries the newer address), a removal drops the row, and dropping the receiver on the way out stops the worker so a one-shot call can't leak a browse per invocation. The verb annotates each hit against the saved-hosts store rather than handing back two lists to join: `saved`/`paired` are answered by fingerprint first and address second — the same rule every other surface uses. That is what stops a host that moved DHCP lease from reading as new, and stops a different box that inherited the old address from reading as paired. punktfunk discover [--json] [--timeout SECS] Default 3 s, capped at 30 — this is called from a Quick Access panel, and a typo'd `--timeout 3000` would hang that panel with no way to cancel. An empty LAN exits 0: a caller branching on the code is asking whether the browse ran, and it did. --- clients/cli/src/main.rs | 117 ++++++++++++++++- clients/cli/tests/cli_smoke.rs | 15 +++ crates/pf-client-core/src/discovery.rs | 166 +++++++++++++++++++++++++ 3 files changed, 297 insertions(+), 1 deletion(-) diff --git a/clients/cli/src/main.rs b/clients/cli/src/main.rs index b359f7b1..4ed81607 100644 --- a/clients/cli/src/main.rs +++ b/clients/cli/src/main.rs @@ -44,6 +44,7 @@ mod cli { const USAGE: &str = "\ punktfunk — the Punktfunk client, headless + punktfunk discover [--json] [--timeout SECS] punktfunk pair [--pin N] [--name LABEL] punktfunk hosts list [--probe] [--json] punktfunk hosts add [--name LABEL] [--fp HEX] @@ -68,6 +69,24 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not /// (what goes to stdout vs stderr, and which exit codes mean what). fn verb_help(verb: &str) -> Option<&'static str> { Some(match verb { + "discover" => { + "\ +punktfunk discover [--json] [--timeout SECS] — browse the LAN for hosts + +Listens for Punktfunk hosts advertising over mDNS and prints what answered: +name TAB addr:port TAB saved|new TAB paired|unpaired. `saved` means this +device already has a record for it, matched by fingerprint first and address +second — the same rule every other surface joins the two lists by. + + --timeout SECS how long to browse (default 3, capped at 30) — a bounded + call, so a panel can wait for it + --json {\"hosts\":[{\"name\",\"addr\",\"port\",\"fp\",\"pair\",\"id\",\"mgmt\", + \"os\",\"saved\",\"paired\"}]} + +Nothing answering is an answer, not a failure: an empty list exits 0. A host +mDNS never sees (Tailscale, another subnet) will not appear here — save it by +address with `punktfunk hosts add` and it shows in `hosts list --probe`." + } "pair" => { "\ punktfunk pair — enrol this device with a host (PIN ceremony) @@ -222,7 +241,7 @@ from the config directory for a true factory reset." fn flag_takes_value(flag: &str) -> bool { matches!( flag, - "--pin" | "--name" | "--fp" | "--game" | "--profile" | "--port" + "--pin" | "--name" | "--fp" | "--game" | "--profile" | "--port" | "--timeout" ) } @@ -269,6 +288,7 @@ from the config directory for a true factory reset." return OK; } match verb.as_str() { + "discover" => discover(&rest), "pair" => pair(&rest), "hosts" => hosts(&rest), "wake" => wake(&rest), @@ -306,6 +326,100 @@ from the config directory for a true factory reset." } } + /// How long `discover` browses when nobody says, and the ceiling on what they can ask for. + /// The cap is not politeness: this verb is called from a Quick Access panel, and a typo'd + /// `--timeout 3000` would hang that panel with no way to cancel it. + const DISCOVER_DEFAULT_SECS: f64 = 3.0; + const DISCOVER_MAX_SECS: f64 = 30.0; + + /// `discover [--json] [--timeout SECS]` — browse the LAN over mDNS and print what answered, + /// annotated against the saved-hosts store. + /// + /// The annotation is the point: a caller wants "can I stream this", which is a question + /// about BOTH lists, and joining them itself is how two surfaces end up disagreeing about + /// the same host. So the match rule lives here, once, and is the same one every other + /// surface uses — fingerprint first (survives a DHCP move), address second. + fn discover(args: &[String]) -> u8 { + let secs = value(args, "--timeout") + .and_then(|v| v.parse::().ok()) + .filter(|s| *s > 0.0) + .unwrap_or(DISCOVER_DEFAULT_SECS) + .min(DISCOVER_MAX_SECS); + let found = pf_client_core::discovery::discover_for(Duration::from_secs_f64(secs)); + let known = KnownHosts::load(); + let rows: Vec<( + &pf_client_core::discovery::DiscoveredHost, + Option<&KnownHost>, + )> = found.iter().map(|d| (d, match_saved(&known, d))).collect(); + if has(args, "--json") { + let hosts: Vec = rows + .iter() + .map(|(d, saved)| { + serde_json::json!({ + "name": d.name, + "addr": d.addr, + "port": d.port, + "fp": d.fp_hex, + "pair": d.pair, + "id": d.advertised_id(), + // 0 = not advertised, which is what a consumer's own "no mgmt port" + // already means — an older host simply omits the TXT. + "mgmt": d.mgmt_port.unwrap_or(0), + "os": d.os, + "saved": saved.is_some(), + "paired": saved.is_some_and(|h| h.paired), + }) + }) + .collect(); + println!("{}", serde_json::json!({ "hosts": hosts })); + } else { + for (d, saved) in &rows { + println!( + "{}\t{}:{}\t{}\t{}", + d.name, + d.addr, + d.port, + if saved.is_some() { "saved" } else { "new" }, + if saved.is_some_and(|h| h.paired) { + "paired" + } else { + "unpaired" + }, + ); + } + } + // An empty LAN is an answer, not a failure — a caller branching on the exit code is + // asking "did the browse run", and it did. + OK + } + + /// The saved record an advert belongs to, if any: fingerprint first, address second. + /// + /// Fingerprint FIRST is deliberate and load-bearing — a host that moved to a new DHCP lease + /// still matches its record, and a *different* host that inherited the old address does not + /// inherit its pairing. This is the rule the plugin's `mergeHosts` and the shells' hosts + /// pages already use; keeping one copy is what stops two surfaces disagreeing about whether + /// the box in front of you is paired. + fn match_saved<'a>( + known: &'a KnownHosts, + advert: &pf_client_core::discovery::DiscoveredHost, + ) -> Option<&'a KnownHost> { + known + .hosts + .iter() + .find(|h| { + !h.fp_hex.is_empty() + && !advert.fp_hex.is_empty() + && h.fp_hex.eq_ignore_ascii_case(&advert.fp_hex) + }) + .or_else(|| { + known + .hosts + .iter() + .find(|h| h.addr == advert.addr && h.port == advert.port) + }) + } + /// `pair [--pin N]` — the SPAKE2 ceremony. Without `--pin` it prompts, which /// is the interactive shape; with one it is scriptable. Refuses rather than prompting when /// stdin isn't a terminal and no PIN was given: a pairing that silently blocks a CI job @@ -967,6 +1081,7 @@ from the config directory for a true factory reset." #[test] fn every_usage_verb_has_help() { for verb in [ + "discover", "pair", "hosts", "wake", diff --git a/clients/cli/tests/cli_smoke.rs b/clients/cli/tests/cli_smoke.rs index 81a80347..90dd1db2 100644 --- a/clients/cli/tests/cli_smoke.rs +++ b/clients/cli/tests/cli_smoke.rs @@ -67,3 +67,18 @@ fn unknown_verbs_refuse_with_the_not_found_code() { let out = punktfunk(&["help", "frobnicate"]); assert_eq!(out.status.code(), Some(5), "unknown help topic exits 5"); } + +/// `discover` documents itself. Help only — the verb itself browses the LAN, which no runner +/// may be asked to do. +/// +/// The Decky panel detects a too-old client by exactly the signature the test above pins +/// (exit 5 + `unknown command`), so this is the other half of that contract: on a client new +/// enough, `discover` is a verb with help rather than an unknown word. +#[test] +fn discover_documents_itself() { + let out = punktfunk(&["help", "discover"]); + assert!(out.status.success(), "discover has its own help topic"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("--timeout"), "discover documents --timeout"); + assert!(stdout.contains("--json"), "discover documents --json"); +} diff --git a/crates/pf-client-core/src/discovery.rs b/crates/pf-client-core/src/discovery.rs index fdeb8fbe..d318b5c5 100644 --- a/crates/pf-client-core/src/discovery.rs +++ b/crates/pf-client-core/src/discovery.rs @@ -4,6 +4,8 @@ //! cards and flip a saved host's online pip when its advert disappears. use mdns_sd::{ServiceDaemon, ServiceEvent}; +use std::collections::BTreeMap; +use std::time::{Duration, Instant}; #[derive(Clone, Debug)] pub struct DiscoveredHost { @@ -31,6 +33,19 @@ pub struct DiscoveredHost { pub os: String, } +impl DiscoveredHost { + /// The host's advertised stable id (mDNS TXT `id`), or `""` when it doesn't advertise one. + /// [`DiscoveredHost::key`] falls back to the mDNS fullname in that case, so the two being + /// equal is exactly the "no id" signal — read it through here rather than re-deriving it. + pub fn advertised_id(&self) -> &str { + if self.key == self.fullname { + "" + } else { + &self.key + } + } +} + /// One discovery update for the UI's advert map. pub enum DiscoveryEvent { /// A host advert appeared or refreshed (new address, pairing flipped, …). @@ -117,3 +132,154 @@ pub fn browse() -> async_channel::Receiver { .expect("spawn mdns thread"); rx } + +/// The advert map one browse window folded down to. Kept separate from [`discover_for`] so the +/// fold — which is where dedupe and removal actually live — is testable without a network. +type Adverts = BTreeMap; + +/// Apply one event to the map. A refreshed advert WINS over the one already there (it carries +/// the newer address — a host that changed DHCP lease re-announces), and a removal drops +/// whichever entry that mDNS fullname produced, whatever it was keyed under. +fn fold(adverts: &mut Adverts, event: DiscoveryEvent) { + match event { + DiscoveryEvent::Resolved(host) => { + adverts.insert(host.key.clone(), host); + } + DiscoveryEvent::Removed { fullname } => { + adverts.retain(|_, h| h.fullname != fullname); + } + } +} + +/// Browse for `timeout`, then return what answered — deduped by `key`, address-sorted. +/// +/// Blocking; intended for one-shot consumers (the CLI's `discover` verb, a plugin backend that +/// wants one bounded call rather than a stream). The streaming [`browse`] stays the UI's door: +/// a live hosts page wants adverts as they land, not a snapshot taken `timeout` after it opened. +pub fn discover_for(timeout: Duration) -> Vec { + let rx = browse(); + let deadline = Instant::now() + timeout; + let mut adverts = Adverts::new(); + while Instant::now() < deadline { + while let Ok(event) = rx.try_recv() { + fold(&mut adverts, event); + } + // A short tick rather than a blocking recv with a deadline: `async_channel`'s blocking + // receive has no timeout, and the whole point of this call is that it is bounded. + std::thread::sleep(Duration::from_millis(50).min(timeout)); + } + while let Ok(event) = rx.try_recv() { + fold(&mut adverts, event); + } + // Dropping the receiver is what stops the worker: its next send fails and the thread exits, + // shutting the daemon down. Without this a one-shot consumer would leak a browse per call. + drop(rx); + sorted(adverts) +} + +/// The map as the list a caller gets: sorted by address, then port. IPv4 is compared +/// NUMERICALLY (a lexical sort puts `.10` before `.9`, which reads as scrambled in a host list). +fn sorted(adverts: Adverts) -> Vec { + let mut hosts: Vec = adverts.into_values().collect(); + hosts.sort_by_key(|h| { + ( + h.addr.parse::().ok().map(u32::from), + h.addr.clone(), + h.port, + ) + }); + hosts +} + +#[cfg(test)] +mod tests { + use super::*; + + fn host(key: &str, fullname: &str, addr: &str) -> DiscoveredHost { + DiscoveredHost { + key: key.into(), + fullname: fullname.into(), + name: fullname.split('.').next().unwrap_or("?").into(), + addr: addr.into(), + port: 9777, + fp_hex: "aa".into(), + pair: "required".into(), + mgmt_port: Some(47990), + mac: vec![], + os: String::new(), + } + } + + /// Two adverts for the same host collapse to one row, and the LATER one wins — that is how + /// a host that moved to a new address stops being listed at the stale one. + #[test] + fn refreshed_advert_supersedes_the_earlier_one() { + let mut adverts = Adverts::new(); + fold( + &mut adverts, + DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.9")), + ); + fold( + &mut adverts, + DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.20")), + ); + let out = sorted(adverts); + assert_eq!(out.len(), 1, "same key must not render twice"); + assert_eq!(out[0].addr, "192.168.1.20", "the newer address wins"); + } + + /// A host that goes away during the browse window is not in the answer. + #[test] + fn removal_drops_the_advert_it_names() { + let mut adverts = Adverts::new(); + fold( + &mut adverts, + DiscoveryEvent::Resolved(host("id-1", "desk._punktfunk._udp.local.", "192.168.1.9")), + ); + fold( + &mut adverts, + DiscoveryEvent::Resolved(host("id-2", "tv._punktfunk._udp.local.", "192.168.1.10")), + ); + fold( + &mut adverts, + DiscoveryEvent::Removed { + fullname: "desk._punktfunk._udp.local.".into(), + }, + ); + let out = sorted(adverts); + assert_eq!(out.len(), 1); + assert_eq!(out[0].key, "id-2"); + } + + /// A host with no `id` TXT is keyed by its fullname — and must not then report that + /// fullname as an id, which would send a caller launching against a nonexistent reference. + #[test] + fn advertised_id_is_empty_without_the_txt() { + let named = host("id-1", "desk._punktfunk._udp.local.", "10.0.0.1"); + assert_eq!(named.advertised_id(), "id-1"); + let anonymous = host( + "desk._punktfunk._udp.local.", + "desk._punktfunk._udp.local.", + "10.0.0.1", + ); + assert_eq!(anonymous.advertised_id(), ""); + } + + /// Addresses sort the way a person reads them, not the way strings compare. + #[test] + fn addresses_sort_numerically() { + let mut adverts = Adverts::new(); + for (i, addr) in ["192.168.1.20", "192.168.1.9", "192.168.1.100"] + .into_iter() + .enumerate() + { + fold( + &mut adverts, + DiscoveryEvent::Resolved(host(&format!("id-{i}"), &format!("h{i}."), addr)), + ); + } + let out = sorted(adverts); + let addrs: Vec<&str> = out.iter().map(|h| h.addr.as_str()).collect(); + assert_eq!(addrs, ["192.168.1.9", "192.168.1.20", "192.168.1.100"]); + } +} From aec02b9d26371ae689634d8cd5be14699ad33af6 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 20:27:29 +0200 Subject: [PATCH 33/53] fix(cli): hosts add --fp fills in an empty fingerprint instead of dropping it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `punktfunk hosts add --fp ` against an address already in the store printed "is already saved" and exited 0 — having done nothing at all. The --fp was silently discarded, so a host saved by address stayed pinless and every later connect refused for want of a fingerprint, with no line anywhere saying why. Three outcomes now, and the difference between them is a trust decision: • no fingerprint on the record, one offered → fill it in, print `updated :` • the same fingerprint offered again → no-op, exit 0 (a panel may retry a step whose state is already correct without having to invent an error to show) • a DIFFERENT fingerprint → refuse, exit 3 The refusal is the important one. A changed identity is a decision for a person at a surface that can show them both — the rule `upsert_trusted` exists to enforce — and quietly overwriting a pin here would be a back door through the pinning the rest of the client is built on. A record still named after its own address takes an offered --name; a label the user chose is theirs and an advert's name must not overwrite it. --- clients/cli/src/main.rs | 164 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 159 insertions(+), 5 deletions(-) diff --git a/clients/cli/src/main.rs b/clients/cli/src/main.rs index 4ed81607..3b7a201e 100644 --- a/clients/cli/src/main.rs +++ b/clients/cli/src/main.rs @@ -538,16 +538,44 @@ from the config directory for a true factory reset." return UNRESOLVED; }; let (addr, port) = split_host_port(&target); + let fp = value(args, "--fp").unwrap_or_default(); + let name = value(args, "--name"); let mut known = KnownHosts::load(); - if known.hosts.iter().any(|h| h.addr == addr && h.port == port) { - eprintln!("{addr}:{port} is already saved"); - return OK; + if let Some(i) = known + .hosts + .iter() + .position(|h| h.addr == addr && h.port == port) + { + return match merge_saved_host(&mut known, i, &fp, name.as_deref()) { + AddOutcome::Unchanged => { + eprintln!("{addr}:{port} is already saved"); + OK + } + AddOutcome::Conflict => { + eprintln!( + "{addr}:{port} is already saved with a different fingerprint — \ + forget it first if you really mean to replace it \ + (punktfunk hosts forget {addr}:{port})" + ); + TRUST_REJECTED + } + AddOutcome::Pinned => match known.save() { + Ok(()) => { + println!("updated {addr}:{port}"); + OK + } + Err(e) => { + eprintln!("saving: {e:#}"); + CONNECT_FAILED + } + }, + }; } known.hosts.push(KnownHost { - name: value(args, "--name").unwrap_or_else(|| addr.clone()), + name: name.unwrap_or_else(|| addr.clone()), addr: addr.clone(), port, - fp_hex: value(args, "--fp").unwrap_or_default(), + fp_hex: fp, ..Default::default() }); match known.save() { @@ -589,6 +617,55 @@ from the config directory for a true factory reset." } } + /// What `hosts add` did to a record that was ALREADY saved for this address. + #[derive(Debug, PartialEq, Eq)] + enum AddOutcome { + /// Nothing to do — no fingerprint was offered, or the record already carries this one. + /// Exits 0 on purpose: a panel retrying step 1 of request access must not have to + /// invent an error to show for a state that is already correct. + Unchanged, + /// The record had no fingerprint and now has this one. + Pinned, + /// The record carries a DIFFERENT fingerprint. Refused, never overwritten. + Conflict, + } + + /// `hosts add --fp` against an address that is already saved. The difference between these + /// three is a trust decision, not bookkeeping. + /// + /// Filling in an empty fingerprint is step 1 of request access (design §5): a host found by + /// advert is saved by address first and pinned second. Without it the `--fp` is dropped on + /// the floor and the launch that follows refuses for want of a pin — which is what this did + /// before, silently and with exit 0. + /// + /// A *different* fingerprint is refused because a changed identity is a decision for a + /// person, at a surface that can show them both. That is what `upsert_trusted` exists to + /// enforce; quietly overwriting it here would be a back door through the pinning the rest + /// of the client is built on. + fn merge_saved_host( + known: &mut KnownHosts, + i: usize, + fp: &str, + name: Option<&str>, + ) -> AddOutcome { + let existing = known.hosts[i].fp_hex.clone(); + if fp.is_empty() || existing.eq_ignore_ascii_case(fp) { + return AddOutcome::Unchanged; + } + if !existing.is_empty() { + return AddOutcome::Conflict; + } + known.hosts[i].fp_hex = fp.to_string(); + // Only a record still named after its own address is renamed: a label the user chose is + // theirs, and an advert's name must not quietly overwrite it. + if let Some(label) = name { + if known.hosts[i].name == known.hosts[i].addr { + known.hosts[i].name = label.to_string(); + } + } + AddOutcome::Pinned + } + /// `wake [--wait]` — a magic packet, and with `--wait` the same bounded /// wake-and-wait the shells run (`WakeWait`: a packet every 6 s, presence polled every /// second, 90 s budget). @@ -1103,6 +1180,83 @@ from the config directory for a true factory reset." assert!(verb_help("bogus").is_none()); } + fn saved(name: &str, addr: &str, fp: &str) -> KnownHost { + KnownHost { + name: name.into(), + addr: addr.into(), + port: 9777, + fp_hex: fp.into(), + ..Default::default() + } + } + + /// Step 1 of request access: a host saved by address gains the fingerprint its advert + /// carried. Before this, `hosts add --fp` on an existing record exited 0 having done + /// NOTHING — the launch that followed then refused for want of a pin, and the panel had + /// no way to tell why. + #[test] + fn adding_a_fingerprint_to_a_placeholder_fills_it_in() { + let mut known = KnownHosts { + hosts: vec![saved("192.168.1.9", "192.168.1.9", "")], + }; + assert_eq!( + merge_saved_host(&mut known, 0, "abc123", Some("living-room")), + AddOutcome::Pinned + ); + assert_eq!(known.hosts[0].fp_hex, "abc123"); + assert_eq!( + known.hosts[0].name, "living-room", + "a record still named after its address takes the offered label" + ); + } + + /// A label the user chose is theirs — an advert's name must not overwrite it. + #[test] + fn filling_in_a_fingerprint_keeps_a_user_chosen_name() { + let mut known = KnownHosts { + hosts: vec![saved("Basement rig", "192.168.1.9", "")], + }; + merge_saved_host(&mut known, 0, "abc123", Some("living-room")); + assert_eq!(known.hosts[0].name, "Basement rig"); + } + + /// Idempotent: the panel may retry step 1, and re-offering the fingerprint a record + /// already carries is a state that is already correct, not an error to render. + #[test] + fn re_adding_the_same_fingerprint_changes_nothing() { + let mut known = KnownHosts { + hosts: vec![saved("desk", "192.168.1.9", "ABC123")], + }; + assert_eq!( + merge_saved_host(&mut known, 0, "abc123", None), + AddOutcome::Unchanged, + "fingerprints compare case-insensitively" + ); + // And a bare `hosts add` with no --fp at all leaves the pin alone. + assert_eq!( + merge_saved_host(&mut known, 0, "", None), + AddOutcome::Unchanged + ); + assert_eq!(known.hosts[0].fp_hex, "ABC123"); + } + + /// A changed identity is a decision for a person. Never a silent overwrite — this is the + /// same rule `upsert_trusted` enforces, and a back door here would defeat it everywhere. + #[test] + fn a_different_fingerprint_is_refused_not_overwritten() { + let mut known = KnownHosts { + hosts: vec![saved("desk", "192.168.1.9", "abc123")], + }; + assert_eq!( + merge_saved_host(&mut known, 0, "deadbeef", None), + AddOutcome::Conflict + ); + assert_eq!( + known.hosts[0].fp_hex, "abc123", + "the pin must survive intact" + ); + } + #[test] fn value_reads_the_argument_after_its_flag() { let a = argv(&["--game", "steam:570", "--exec"]); From f84c5b8114e2f66e9082a997aa1235d9aa9840de Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 20:28:34 +0200 Subject: [PATCH 34/53] =?UTF-8?q?feat(cli):=20launch=20--request-access=20?= =?UTF-8?q?=E2=80=94=20let=20the=20host's=20operator=20admit=20this=20devi?= =?UTF-8?q?ce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Request access is not a second pairing ceremony, it is a LAUNCH: an ordinary identified connect with the advertised fingerprint pinned and the handshake budget stretched past the host's approval window. The host parks the connection until somebody approves the device in its console or web UI, then admits the same connection and the stream starts by itself. The desktop shells and the console home have had this for a while (`SpawnOpts::persist_paired`, `screens/pair.rs`); headless callers had no door to it. punktfunk launch --request-access Two behaviours, both small: * `connect_timeout_secs = 185`, matching the host's PENDING_APPROVAL_WAIT. Anything shorter gives up while the approval prompt is still on the operator's screen. * `run_plan` records the host as paired on SessionEvent::Ready. That event IS the approval arriving, and it records the pin the session actually connected WITH rather than re-reading the store — the handshake completed against that identity, which is what makes the record true. Every other launch still records nothing: a plain connect proves reachability, not a new trust decision. Refused under `--exec` (exit 5) rather than silently downgraded. Under --exec the CLI BECOMES the session, so no process survives to observe Ready — a quiet downgrade would leave hosts reading "trusted" forever with nobody able to explain why. --- clients/cli/src/main.rs | 68 +++++++++++++++++++++++++++++++--- clients/cli/tests/cli_smoke.rs | 9 +++-- 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/clients/cli/src/main.rs b/clients/cli/src/main.rs index 3b7a201e..bf993d0c 100644 --- a/clients/cli/src/main.rs +++ b/clients/cli/src/main.rs @@ -41,6 +41,11 @@ mod cli { const PROBE_TIMEOUT: Duration = Duration::from_millis(2500); + /// The handshake budget `--request-access` runs on. Matches the host's `PENDING_APPROVAL_WAIT` + /// — the connect is PARKED for that long while an operator decides, so anything shorter would + /// give up while the approval prompt is still on their screen. + const REQUEST_ACCESS_TIMEOUT_SECS: u64 = 185; + const USAGE: &str = "\ punktfunk — the Punktfunk client, headless @@ -51,7 +56,8 @@ punktfunk — the Punktfunk client, headless punktfunk hosts forget punktfunk wake [--wait] punktfunk library [--json] - punktfunk launch [--game ID] [--profile REF] [--exec] [--fullscreen] + punktfunk launch [--game ID] [--profile REF] [--request-access] + [--exec] [--fullscreen] punktfunk open punktfunk reachable punktfunk speed-test @@ -138,7 +144,8 @@ this. Needs a paired host (exit 6 otherwise)." } "launch" => { "\ -punktfunk launch [--game ID] [--profile REF] [--exec] [--fullscreen] +punktfunk launch [--game ID] [--profile REF] [--request-access] + [--exec] [--fullscreen] Start a stream — waking the host first if it is asleep and its MAC is known. The stream runs in the punktfunk-session renderer; this command supervises it @@ -151,6 +158,16 @@ and relays its lifecycle to stderr. --exec become the session process instead of supervising it — the gamescope-wrapper mode, where the launched process must BE the streaming one for focus and lifecycle to work + --request-access + ask the host's operator to let this device in instead of + typing a PIN. The host PARKS the connect until somebody + approves it in its console or web UI (up to ~185 s), then + admits it and the stream starts by itself; the host is + recorded as paired once that happens, so later streams are + silent. Needs the host's fingerprint pinned already + (`punktfunk hosts add --fp `), and cannot be + combined with --exec — under --exec there is no process + left to record the approval. Exit 0 when the stream ends cleanly, 2 connect failed, 3 the host no longer trusts this device (re-pair), 4 the renderer could not start." @@ -776,6 +793,19 @@ from the config directory for a true factory reset." eprintln!("usage: punktfunk launch [--game ID] [--profile REF] [--exec]"); return UNRESOLVED; }; + let exec = has(args, "--exec"); + let request_access = has(args, "--request-access"); + // Refused rather than silently downgraded: under `--exec` this process BECOMES the + // session, so nothing survives to see `Ready` and record the approval. A launch that + // quietly dropped the persistence would leave hosts reading "trusted" forever with + // nobody able to say why. + if request_access && exec { + eprintln!( + "--request-access can't be combined with --exec: under --exec there is no \ + process left to record the host's approval" + ); + return UNRESOLVED; + } let (known, i) = match resolve(&reference) { Ok(v) => v, Err(code) => return code, @@ -788,7 +818,10 @@ from the config directory for a true factory reset." if has(args, "--fullscreen") { plan.settings.fullscreen_on_stream = true; } - run_plan(plan, has(args, "--exec")) + if request_access { + plan.connect_timeout_secs = Some(REQUEST_ACCESS_TIMEOUT_SECS); + } + run_plan(plan, exec, request_access) } /// `open ` — the `punktfunk://` grammar, headless. Same parser, same refusal rules and @@ -813,7 +846,7 @@ from the config directory for a true factory reset." &trust::Settings::load(), ); match outcome { - Ok(PlanOutcome::Connect(plan)) => run_plan(*plan, has(args, "--exec")), + Ok(PlanOutcome::Connect(plan)) => run_plan(*plan, has(args, "--exec"), false), // A URL may never pair or trust on its own — that is a decision for a person, at a // surface that can show them the fingerprint. Ok(PlanOutcome::ConfirmUnknown(u)) => { @@ -837,7 +870,13 @@ from the config directory for a true factory reset." } /// Wake if needed, then run the session — supervising it, or becoming it under `--exec`. - fn run_plan(plan: ConnectPlan, exec: bool) -> u8 { + /// + /// `persist_paired` records the host as *paired* when the child reports ready. Only + /// `launch --request-access` passes true: there, the host parked the connect until an + /// operator approved this device, so `Ready` IS the approval arriving — the same thing + /// `SpawnOpts::persist_paired` means in the GTK shell. Every other launch records nothing, + /// which is correct: a plain connect proves reachability, not a new trust decision. + fn run_plan(plan: ConnectPlan, exec: bool, persist_paired: bool) -> u8 { if plan.host.fp_hex.is_none() { eprintln!( "{} has no pinned fingerprint — punktfunk pair {}", @@ -899,7 +938,24 @@ from the config directory for a true factory reset." let mut failure: Option<(String, bool)> = None; while let Ok(ev) = rx.recv() { match ev { - SessionEvent::Ready => eprintln!("streaming"), + SessionEvent::Ready => { + eprintln!("streaming"); + // The pin we connected WITH, not one re-derived from the store: the record + // is what we are about to rewrite, and the session proved the host holds + // exactly this identity by completing a pinned handshake against it. + if persist_paired { + if let Some(fp_hex) = &plan.host.fp_hex { + trust::persist_host( + &plan.host.name, + &plan.host.addr, + plan.host.port, + fp_hex, + true, + ); + trust::forget_placeholder(&plan.host.addr, plan.host.port); + } + } + } SessionEvent::Error { msg, trust_rejected, diff --git a/clients/cli/tests/cli_smoke.rs b/clients/cli/tests/cli_smoke.rs index 90dd1db2..20917b2c 100644 --- a/clients/cli/tests/cli_smoke.rs +++ b/clients/cli/tests/cli_smoke.rs @@ -68,17 +68,20 @@ fn unknown_verbs_refuse_with_the_not_found_code() { assert_eq!(out.status.code(), Some(5), "unknown help topic exits 5"); } -/// `discover` documents itself. Help only — the verb itself browses the LAN, which no runner -/// may be asked to do. +/// `discover` and `launch --request-access` document themselves. Help only — the verbs +/// themselves browse the LAN and dial a host, which no runner may be asked to do. /// /// The Decky panel detects a too-old client by exactly the signature the test above pins /// (exit 5 + `unknown command`), so this is the other half of that contract: on a client new /// enough, `discover` is a verb with help rather than an unknown word. #[test] -fn discover_documents_itself() { +fn the_request_access_surfaces_document_themselves() { let out = punktfunk(&["help", "discover"]); assert!(out.status.success(), "discover has its own help topic"); let stdout = String::from_utf8_lossy(&out.stdout); assert!(stdout.contains("--timeout"), "discover documents --timeout"); assert!(stdout.contains("--json"), "discover documents --json"); + + let out = punktfunk(&["launch", "--help"]); + assert!(String::from_utf8_lossy(&out.stdout).contains("--request-access")); } From a9a514dea060cd6abc630be33377b375ff4de0a5 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 20:37:25 +0200 Subject: [PATCH 35/53] fix(feedback): the pad stops keeping a game's trigger effect after the stream ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two faults in the rich-feedback plane — the lightbar, player LEDs and adaptive triggers — both of which leave a controller physically wrong with nothing to put it right. Nothing reset the pad on teardown. Rumble stops on its own the moment nothing renews it, but the rich planes are LATCHED in the controller's firmware: they outlive the stream, the app, and being unplugged. Ending a session while a game held a weapon's trigger resistance left the physical trigger stiff on the desktop afterwards, and its lightbar showing whatever the game last set, until another game happened to set one. The Apple client already reset on teardown; the desktop and Android halves now do too — triggers to mode 0x00, lightbar dark, player indicator cleared. Android writes them EP0-direct like its rumble stop, because the reader thread is stopping and the queue would never drain. A single lost datagram stranded the pad on the previous value. The plane is deduped AND rides unreliable datagrams, which is a bad pairing: a change is forwarded exactly once, so when that datagram is dropped nothing re-derives it — the game keeps sending the same value and the dedup swallows every copy. The pad then holds the last weapon's trigger effect, or the last lightbar colour, for as long as the game keeps that setting, which can be the rest of a level. The dedup already remembers the current state, so it can repair itself: it now re-emits what it has latched once a second. Slow on purpose — this is a repair mechanism, not a transport, and every value is idempotent, so a client that did receive the original simply re-applies it. A forward re-stamps the clock, so a plane the game is actively driving never pays for a renewal it does not need. One-shot pulses are deliberately excluded from that renewal: replaying a trackpad haptic would be a new pulse, not a repair. Raw passthrough reports are excluded too — the device's own refresh cadence already re-sends them verbatim. --- .../kotlin/io/unom/punktfunk/kit/DsCapture.kt | 30 +++ crates/pf-client-core/src/gamepad.rs | 73 ++++++ crates/pf-inject/src/inject/hidout_dedup.rs | 211 ++++++++++++++++-- crates/pf-inject/src/inject/uhid_manager.rs | 9 +- 4 files changed, 306 insertions(+), 17 deletions(-) diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt index 47c2eaa3..cc14af2d 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsCapture.kt @@ -117,6 +117,7 @@ class DsCapture( // mid-rumble teardown would leave the motors running with nobody to stop them. // EP0-direct (the reader thread is stopping; the queue would never drain). usb.writeControl(stopReport(m)) + resetRichFeedback(m) } disarmBackstop() usb.stop() @@ -263,6 +264,35 @@ class DsCapture( ), ) + /** + * Hand the pad back neutral: adaptive triggers released, lightbar dark, player LEDs clear. + * + * Rumble stops the moment nothing renews it, but these are LATCHED in the controller's + * firmware — they outlive the stream, the app, and being unplugged. Ending a session while a + * game held a weapon's trigger resistance left the physical trigger stiff afterwards, with + * nothing to release it but another game that happens to set one. + * + * EP0-direct like the rumble stop above: the reader thread is stopping, so the interrupt-OUT + * queue would never drain. Writes are best-effort — the pad may already be gone. + */ + private fun resetRichFeedback(m: DsDevice.Model) { + if (m == DsDevice.Model.DUALSHOCK4) { + // No adaptive triggers or player LEDs on a DS4, and its write is full-state, so + // blacking the lightbar is a single composed report. + ds4Rgb = 0 + usb.writeControl(DsDevice.ds4Report(0, 0, 0, 0, 0)) + return + } + // An all-zero effect block is mode 0x00 — no effect — which is what releases the trigger. + for (which in 0..1) { + usb.writeControl( + DsDevice.ds5TriggerReport(m, which, ByteArray(DsDevice.TRIGGER_EFFECT_LEN)), + ) + } + usb.writeControl(DsDevice.ds5LightbarReport(m, 0, 0, 0)) + usb.writeControl(DsDevice.ds5PlayerLedsReport(m, 0)) + } + /** The report that stops the motors. The DS4's is a full-state write, so it zeroes the * composed motor state and carries the current lightbar rather than blacking it out. */ private fun stopReport(m: DsDevice.Model): ByteArray = if (m == DsDevice.Model.DUALSHOCK4) { diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index 3eb51d7b..c470a9cb 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -1003,6 +1003,7 @@ impl Worker { // unplug) must not depend on what SDL does to a rumbling device at close. Errors are // expected for an already-unplugged pad. let _ = self.slots[i].pad.set_rumble(0, 0, 100); + Self::reset_slot_feedback(&mut self.slots[i]); if let Some(c) = self.attached.clone() { Self::flush_slot(&c, &mut self.slots[i]); // Signal the host to tear down this pad's virtual device (native hot-unplug). Sent @@ -1018,6 +1019,35 @@ impl Worker { ); } + /// Hand the physical controller back in a neutral state before its handle closes. + /// + /// Rumble stops on its own the moment nothing renews it, but the rich planes do not: an + /// adaptive-trigger effect and a lightbar colour are LATCHED in the pad's firmware and survive + /// the stream, the app, and being unplugged. Ending a session on a weapon's trigger resistance + /// left the physical trigger stiff on the desktop afterwards, with nothing to clear it but + /// another game. Apple's client already resets on teardown; this is the desktop half. + /// + /// Best-effort throughout: the pad may already be gone (that is one of the ways we get here). + fn reset_slot_feedback(slot: &mut Slot) { + if matches!( + slot.pref, + GamepadPref::DualSense | GamepadPref::DualSenseEdge + ) { + // An all-zero trigger block is mode 0x00 — no effect — which is what releases the + // trigger. Both sides, then the lightbar dark and the player indicator clear. + for which in [0u8, 1] { + let _ = slot + .pad + .send_effect(&Ds5Feedback::trigger_packet(which, &[0u8; 11])); + } + let _ = slot.pad.send_effect(&Ds5Feedback::lightbar_packet(0, 0, 0)); + let _ = slot.pad.send_effect(&Ds5Feedback::player_packet(0)); + } else { + // Anything else with an LED goes dark through SDL, which owns the per-device details. + let _ = slot.pad.set_led(0, 0, 0); + } + } + fn close_all_slots(&mut self) { while !self.slots.is_empty() { self.close_slot_at(0); @@ -2008,3 +2038,46 @@ mod slot_tests { ); } } + +#[cfg(test)] +mod reset_packet_tests { + use super::*; + + /// The exact bytes a teardown sends to hand a DualSense back neutral. The *timing* of this + /// (slot close) needs a live SDL handle and stays untestable, so pin the payloads: a wrong + /// enable flag or a non-zero mode byte would silently leave the effect latched, which is the + /// bug this reset exists to prevent. + #[test] + fn reset_packets_release_the_triggers_and_darken_the_lights() { + // Trigger release: mode 0x00 with no parameters, on the side's own enable bit. + let l = Ds5Feedback::trigger_packet(0, &[0u8; 11]); + assert_eq!(l[0], 0x08, "left-trigger enable bit"); + assert!( + l[Ds5Feedback::LEFT_TRIGGER..Ds5Feedback::LEFT_TRIGGER + 11] + .iter() + .all(|&b| b == 0), + "an all-zero block is mode 0x00 = no effect" + ); + let r = Ds5Feedback::trigger_packet(1, &[0u8; 11]); + assert_eq!(r[0], 0x04, "right-trigger enable bit"); + assert!( + r[Ds5Feedback::RIGHT_TRIGGER..Ds5Feedback::RIGHT_TRIGGER + 11] + .iter() + .all(|&b| b == 0) + ); + + // Lightbar off: enable bit set, RGB all zero. The enable bit matters — without it the pad + // ignores the payload and keeps the game's last colour. + let bar = Ds5Feedback::lightbar_packet(0, 0, 0); + assert_eq!(bar[1], 0x04, "lightbar enable bit"); + assert_eq!( + &bar[Ds5Feedback::LED_RGB..Ds5Feedback::LED_RGB + 3], + &[0, 0, 0] + ); + + // Player indicator cleared. + let pl = Ds5Feedback::player_packet(0); + assert_eq!(pl[1], 0x10, "player-LED enable bit"); + assert_eq!(pl[Ds5Feedback::PAD_LIGHTS], 0); + } +} diff --git a/crates/pf-inject/src/inject/hidout_dedup.rs b/crates/pf-inject/src/inject/hidout_dedup.rs index 5e87a80b..e4a3d42c 100644 --- a/crates/pf-inject/src/inject/hidout_dedup.rs +++ b/crates/pf-inject/src/inject/hidout_dedup.rs @@ -5,6 +5,20 @@ //! rich state every report; this forwards only genuine changes (one-shot pulses always fire). use punktfunk_core::quic::HidOutput; +use std::time::{Duration, Instant}; + +/// How often the latched rich state is re-emitted even though nothing changed. +/// +/// The 0xCD plane is deduped AND rides unreliable datagrams, which is a bad pairing: a change is +/// forwarded exactly once, so if that datagram is dropped the game will never produce it again — +/// it keeps re-sending the same value and the dedup swallows every copy. The pad is then left +/// holding the PREVIOUS value: the last weapon's trigger effect, the last lightbar colour, for as +/// long as the game keeps that setting. For a trigger effect that can be the rest of a level. +/// +/// Slow on purpose. This is a repair mechanism, not a transport — at one second a lost update +/// costs a noticeable but bounded wrong-feel window, while the steady-state cost is at most four +/// small datagrams per second per pad, against a rumble plane that already resends at ~120 ms. +const RENEW_EVERY: Duration = Duration::from_millis(1000); /// Per-pad dedup for the DualSense HID-output feedback plane (0xCD). A game's DualSense output report /// bundles rumble + lightbar + player-LEDs + adaptive-triggers into one report, so a pad that is @@ -18,6 +32,9 @@ pub struct HidoutDedup { player_leds: Option, /// Last-forwarded adaptive-trigger effect per side: `[0]` = L2, `[1]` = R2. trigger: [Option>; 2], + /// When anything was last put on the wire for this pad. `None` = nothing latched yet, so + /// there is nothing to renew. See [`RENEW_EVERY`]. + last_sent: Option, } impl HidoutDedup { @@ -29,7 +46,53 @@ impl HidoutDedup { /// Whether `h` should be forwarded: `true` for a genuine change (remembering the new value) or a /// one-shot pulse; `false` if it repeats the last-forwarded value for its kind. - pub fn should_forward(&mut self, h: &HidOutput) -> bool { + /// + /// `now` only stamps the renewal clock ([`Self::renewals`]) — forwarding a change resets it, so + /// a plane the game is actively changing never pays for a renewal it does not need. + pub fn should_forward(&mut self, h: &HidOutput, now: Instant) -> bool { + let fwd = self.decide(h); + if fwd { + self.last_sent = Some(now); + } + fwd + } + + /// Re-emit the latched rich state, so one lost datagram cannot strand the pad on the previous + /// value. Returns the reports to send (empty until [`RENEW_EVERY`] has passed since anything + /// last went out); every one is idempotent, so a client that DID receive the original simply + /// re-applies it. + /// + /// One-shots are deliberately absent: replaying a `TrackpadHaptic` pulse would be a *new* + /// pulse, not a repair, and `HidRaw` is already re-sent verbatim by the device's own refresh + /// cadence (see the note in [`Self::decide`]). + pub fn renewals(&mut self, pad: u8, now: Instant) -> Vec { + if self + .last_sent + .is_none_or(|t| now.duration_since(t) < RENEW_EVERY) + { + return Vec::new(); + } + self.last_sent = Some(now); + let mut out = Vec::new(); + if let Some((r, g, b)) = self.led { + out.push(HidOutput::Led { pad, r, g, b }); + } + if let Some(bits) = self.player_leds { + out.push(HidOutput::PlayerLeds { pad, bits }); + } + for (which, effect) in self.trigger.iter().enumerate() { + if let Some(effect) = effect { + out.push(HidOutput::Trigger { + pad, + which: which as u8, + effect: effect.clone(), + }); + } + } + out + } + + fn decide(&mut self, h: &HidOutput) -> bool { match h { HidOutput::Led { r, g, b, .. } => { let v = Some((*r, *g, *b)); @@ -77,6 +140,7 @@ mod tests { /// trigger sides independently, never dedups one-shot haptic pulses, and re-arms after `clear`. #[test] fn hidout_dedup_forwards_only_changes() { + let t = Instant::now(); let mut d = HidoutDedup::default(); let led = |r| HidOutput::Led { pad: 0, @@ -85,15 +149,15 @@ mod tests { b: 0, }; // First value forwards; an exact repeat is dropped; a change forwards again. - assert!(d.should_forward(&led(10))); - assert!(!d.should_forward(&led(10))); - assert!(d.should_forward(&led(20))); + assert!(d.should_forward(&led(10), t)); + assert!(!d.should_forward(&led(10), t)); + assert!(d.should_forward(&led(20), t)); // Player LEDs dedup on their own field, independent of the lightbar. let pl = |bits| HidOutput::PlayerLeds { pad: 0, bits }; - assert!(d.should_forward(&pl(0b101))); - assert!(!d.should_forward(&pl(0b101))); - assert!(!d.should_forward(&led(20))); // lightbar still unchanged + assert!(d.should_forward(&pl(0b101), t)); + assert!(!d.should_forward(&pl(0b101), t)); + assert!(!d.should_forward(&led(20), t)); // lightbar still unchanged // The two adaptive triggers (L2=0, R2=1) are tracked separately. let trig = |which, byte| HidOutput::Trigger { @@ -101,10 +165,10 @@ mod tests { which, effect: vec![byte, 0, 0], }; - assert!(d.should_forward(&trig(0, 1))); - assert!(d.should_forward(&trig(1, 1))); // same bytes, other side → still forwards - assert!(!d.should_forward(&trig(0, 1))); - assert!(d.should_forward(&trig(0, 2))); // L2 effect changed + assert!(d.should_forward(&trig(0, 1), t)); + assert!(d.should_forward(&trig(1, 1), t)); // same bytes, other side → still forwards + assert!(!d.should_forward(&trig(0, 1), t)); + assert!(d.should_forward(&trig(0, 2), t)); // L2 effect changed // One-shot haptic pulses are never deduped. let haptic = HidOutput::TrackpadHaptic { @@ -114,13 +178,128 @@ mod tests { period: 2, count: 3, }; - assert!(d.should_forward(&haptic)); - assert!(d.should_forward(&haptic)); + assert!(d.should_forward(&haptic, t)); + assert!(d.should_forward(&haptic, t)); // `clear` re-arms every kind. d.clear(); - assert!(d.should_forward(&led(20))); - assert!(d.should_forward(&pl(0b101))); - assert!(d.should_forward(&trig(0, 2))); + assert!(d.should_forward(&led(20), t)); + assert!(d.should_forward(&pl(0b101), t)); + assert!(d.should_forward(&trig(0, 2), t)); + } + + /// A change is forwarded once and then deduped — so if that one datagram is lost, nothing else + /// would ever carry it. The renewal is what repairs that. + #[test] + fn latched_state_is_renewed_so_a_lost_datagram_is_not_permanent() { + let t = Instant::now(); + let mut d = HidoutDedup::default(); + let trig = HidOutput::Trigger { + pad: 3, + which: 1, + effect: vec![0x02, 0x90, 0xA0], + }; + assert!(d.should_forward(&trig, t)); + assert!( + !d.should_forward(&trig, t), + "the game re-sends it; the dedup swallows it" + ); + + // Nothing due yet. + assert!(d.renewals(3, t + Duration::from_millis(999)).is_empty()); + + // Past the window: the latched state goes out again, addressed to the right pad. + let out = d.renewals(3, t + Duration::from_millis(1000)); + assert_eq!(out.len(), 1); + assert!(matches!( + &out[0], + HidOutput::Trigger { pad: 3, which: 1, effect } if effect == &vec![0x02, 0x90, 0xA0] + )); + + // And it keeps repairing on the same cadence, not just once. + assert!(d.renewals(3, t + Duration::from_millis(1500)).is_empty()); + assert_eq!(d.renewals(3, t + Duration::from_millis(2000)).len(), 1); + } + + /// Every latched plane is renewed together, and a plane the game is actively driving does not + /// pay for renewals it does not need (a forward resets the clock). + #[test] + fn renewal_covers_every_latched_plane_and_an_active_plane_defers_it() { + let t = Instant::now(); + let mut d = HidoutDedup::default(); + assert!(d.should_forward( + &HidOutput::Led { + pad: 0, + r: 9, + g: 8, + b: 7 + }, + t + )); + assert!(d.should_forward( + &HidOutput::PlayerLeds { + pad: 0, + bits: 0b100 + }, + t + )); + assert!(d.should_forward( + &HidOutput::Trigger { + pad: 0, + which: 0, + effect: vec![1] + }, + t + )); + assert!(d.should_forward( + &HidOutput::Trigger { + pad: 0, + which: 1, + effect: vec![2] + }, + t + )); + + let out = d.renewals(0, t + Duration::from_millis(1000)); + assert_eq!( + out.len(), + 4, + "lightbar + player LEDs + both triggers, got {out:?}" + ); + + // A genuine change re-stamps the clock, so the next renewal is a full window away. + let later = t + Duration::from_millis(1500); + assert!(d.should_forward( + &HidOutput::Led { + pad: 0, + r: 1, + g: 2, + b: 3 + }, + later + )); + assert!(d.renewals(0, later + Duration::from_millis(999)).is_empty()); + assert!(!d + .renewals(0, later + Duration::from_millis(1000)) + .is_empty()); + } + + /// Nothing latched = nothing to renew; a one-shot pulse must never be replayed as a "repair". + #[test] + fn renewal_is_silent_with_nothing_latched_and_never_replays_a_pulse() { + let t = Instant::now(); + let mut d = HidoutDedup::default(); + assert!(d.renewals(0, t + Duration::from_secs(60)).is_empty()); + + let pulse = HidOutput::TrackpadHaptic { + pad: 0, + side: 0, + amplitude: 1, + period: 2, + count: 3, + }; + assert!(d.should_forward(&pulse, t)); + // The pulse stamped the clock but latched no state, so the renewal has nothing to repeat. + assert!(d.renewals(0, t + Duration::from_millis(1000)).is_empty()); } } diff --git a/crates/pf-inject/src/inject/uhid_manager.rs b/crates/pf-inject/src/inject/uhid_manager.rs index 8f91f0a9..3e8b97ef 100644 --- a/crates/pf-inject/src/inject/uhid_manager.rs +++ b/crates/pf-inject/src/inject/uhid_manager.rs @@ -338,10 +338,17 @@ impl UhidManager { for h in fb.hidout { // Skip rich feedback that repeats the last-forwarded value (a game's output report // re-sends unchanged lightbar/LED/trigger state alongside every rumble update). - if self.hidout_dedup[i].should_forward(&h) { + if self.hidout_dedup[i].should_forward(&h, now) { hidout(h); } } + // Re-assert the latched rich state on a slow cadence. Deduping a plane that rides + // unreliable datagrams means a dropped update is never re-derived from the game — it + // keeps sending the same value and the dedup eats every copy — so without this one + // lost datagram leaves the pad on the previous weapon's trigger effect indefinitely. + for h in self.hidout_dedup[i].renewals(i as u8, now) { + hidout(h); + } } } From 2fd303e22f6282da2f7d8f69af11f870e8aaf68f Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 20:40:34 +0200 Subject: [PATCH 36/53] refactor(decky): delete the second client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Decky plugin was a second client. It had its own mDNS discovery, its own host-store editor, its own settings UI over the entire client settings store, its own per-game pin store and picker, and its own fullscreen route with three tabs — about 3,000 lines of TypeScript and Python mirroring, in two other languages, things the Rust client already does. Every one of them drifted from the original: the TXT parser fell behind each key the host advert added, the settings screen modelled a subset of a store that kept growing. They existed because when this plugin was written there was nothing headless to ask. There has been since v0.22.0, so this deletes them. GONE, frontend: page.tsx (the fullscreen route), settings.tsx (a seven-page sidebar over the whole store), hostmgmt.tsx (add/edit/forget), library.tsx (the games picker), ui.tsx (row primitives only the page used). GONE, backend: get/set_settings, list/refresh_devices, library, get/set_pins, list_hosts, add/edit/forget_host, probe_host, reset_config, wake, the avahi browse and its TXT parser, and the direct reads of client-known-hosts.json. WHAT REPLACES THE BACKEND is four shells, each about fifteen lines of build-argv-run-parse: discover() -> punktfunk discover --json hosts() -> punktfunk hosts list --probe --json pair() -> punktfunk pair --pin N --name LABEL trust_host() -> punktfunk hosts add --fp HEX --name LABEL trust_host is the ONLY write this backend makes to the client's store, and it goes through the CLI — which writes temp+rename into a user-owned directory, so a root backend driving it cannot lock the desktop client out of its own files. Nothing here opens client-known-hosts.json or client-profiles.json any more; `hosts list --json` returns profile bindings and pinned cards already resolved against the catalog. _cli_argv mirrors the deleted _session_argv exactly, pointed at `punktfunk`: the flatpak app id stays LAST, because flatpak treats everything after it as the app's own argv. The LD_LIBRARY_PATH repair applies unchanged — Decky's PyInstaller leak breaks the flatpak's libcurl whichever binary inside the sandbox is being started. A client too old for a verb now announces itself DETERMINISTICALLY: exit 5 plus `unknown command ""`, mapped to `client-outdated`, which the panel renders as one explanatory row plus the update button that fixes it. That replaces guessing from GTK-init noise, which survives only where the update check still drives `punktfunk-client` directly. KEPT unchanged in mechanism, because only a Decky plugin can do them: runner_info, shortcut_art, apply_controller_config, check_update/update_client, kill_stream. The settings screen is not lost, it moved: console home -> Settings has the same rows over the same store, is gamepad-navigable, and is one tap from this same panel. Per-game pins have no shared equivalent yet — decky-pinned.json is deliberately left ON DISK, untouched, so a later migration can read it. test-backend.py is rewritten against what is left — argv shape, the exit-code mapping, and the Steam configset editor, which was untested until now and is the riskiest thing that survived: it edits a file holding hundreds of other games' bindings, in place. --- clients/decky/main.py | 937 ++++++-------------------- clients/decky/scripts/test-backend.py | 239 ++++--- clients/decky/src/hostmgmt.tsx | 164 ----- clients/decky/src/library.tsx | 230 ------- clients/decky/src/page.tsx | 596 ---------------- clients/decky/src/settings.tsx | 657 ------------------ clients/decky/src/ui.tsx | 46 -- 7 files changed, 328 insertions(+), 2541 deletions(-) delete mode 100644 clients/decky/src/hostmgmt.tsx delete mode 100644 clients/decky/src/library.tsx delete mode 100644 clients/decky/src/page.tsx delete mode 100644 clients/decky/src/settings.tsx delete mode 100644 clients/decky/src/ui.tsx diff --git a/clients/decky/main.py b/clients/decky/main.py index 19e73116..8107a023 100644 --- a/clients/decky/main.py +++ b/clients/decky/main.py @@ -6,37 +6,39 @@ STREAM is NOT launched here — it is launched by the frontend through Steam (SteamClient.Apps.RunGame on a hidden non-Steam shortcut that points at ``bin/punktfunkrun.sh``), because gamescope only focuses/fullscreens windows in the process tree Steam launched via ``reaper``. A flatpak spawned from this backend would be invisible/unfocused (gamescope#484). -The backend's jobs are the things Steam can't do: +This backend is a THIN SHELL OVER THE HEADLESS CLI (``punktfunk``, shipped in every package +since v0.22.0), plus the handful of things that are genuinely Steam's business. It used to be +a second client — its own mDNS parser, its own host-store editor, its own settings writer — +and every one of those was a copy of a rule that already lives in Rust, drifting from it. The +rule now has one home; this file builds argv and maps exit codes. -* **discover()** — browse the LAN over mDNS (``avahi-browse``) for ``_punktfunk._udp`` hosts. -* **pair(host, port, pin, name)** — run the SPAKE2 PIN ceremony headlessly via the flatpak - client's ``--pair`` mode, capturing the result. Pairing uses the SAME flatpak (so the same - identity store the stream uses), so once paired the stream connects silently. -* **library(host, mgmt_port, fp)** — fetch a paired host's game library headlessly via the - flatpak client's ``--library`` mode (mTLS with the client's own identity; TSV on stdout), - so the picker UI can offer games to pin. -* **get_pins() / set_pins()** — the pinned-games store (``decky-pinned.json`` next to the - client's config, so pins survive plugin reinstalls), annotated with live pairing state. -* **runner_info()** — the absolute path to the launch wrapper + the flatpak app id, handed to - the frontend so it can create/point the Steam shortcut. -* **get_settings() / set_settings()** — read/write the flatpak client's stream settings JSON - (resolution / bitrate / gamepad), so the Deck UI configures the stream the client reads. - ``set_settings`` MERGES onto the file: it is shared with the desktop client and the console. -* **list_devices() / refresh_devices()** — the GPUs and audio endpoints the settings tab's - device pickers offer, read from the session binary (``--list-adapters`` / ``--list-audio``) - and cached, since enumerating them costs a Vulkan + PipeWire init. -* **kill_stream()** — force-stop a wedged stream (``flatpak kill``). -* **check_update()** — report pending updates for BOTH the plugin and the client. The plugin's - comes from the registry's per-channel ``manifest.json`` (the frontend then drives Decky's own - install RPC to apply it); the client's depends on how it was installed — a flatpak is compared - by OSTree commit here, anything else is asked of the client itself - (``punktfunk-client --check-update``, which verifies a signed manifest). -* **update_client()** — apply the client update by whichever route that install supports: - ``flatpak update --user``, ``punktfunk-client --apply-update`` (the packaged root helper), or - a refusal carrying the command to run by hand. +Thin CLI shells — each is build argv, run, parse JSON, map the exit code: -The TXT-record keys parsed (``proto`` / ``fp`` / ``pair`` / ``id`` / ``mgmt``) are defined by -the host advert in ``crates/punktfunk-host/src/discovery.rs``. +* **discover()** — ``punktfunk discover --json``: the LAN's hosts, already annotated with + whether this device has them saved and paired. +* **hosts()** — ``punktfunk hosts list --probe --json``: the saved hosts with a live, + mDNS-independent reachability probe, and their profile bindings and pinned cards already + resolved against the profile catalog. +* **pair(addr, port, pin, name)** — ``punktfunk pair``: the SPAKE2 PIN ceremony. +* **trust_host(addr, port, fp, name)** — ``punktfunk hosts add --fp``: step 1 of request + access, and the ONLY write this backend makes to the client's store. + +Kept because only a Decky plugin can do them: + +* **runner_info()** — resolve flatpak vs native and hand the frontend the wrapper path. +* **shortcut_art()** — base64 grid/hero/logo + icon path for the Steam shortcut. +* **apply_controller_config()** — write the native-touch layout into every Steam account's + configset dir, chowned back to the user (this backend is root; Steam is not). +* **check_update() / update_client()** — the plugin's own registry manifest (Decky's install + RPC needs artifact + SHA-256) and the client's update route. +* **kill_stream()** — force-stop a wedged client. + +What is deliberately NOT here: the stream launch. It goes through Steam +(SteamClient.Apps.RunGame on a non-Steam shortcut pointing at ``bin/punktfunkrun.sh``), +because gamescope only focuses/fullscreens windows in the process tree Steam launched via +``reaper`` — a client spawned from this backend would come up invisible and unfocused +(gamescope#484). Settings, add-host-by-address, the library browser and profile editing are +not here either: they are one shortcut away in the client's own console home. """ import asyncio @@ -54,51 +56,11 @@ import decky # Flatpak application id of the GTK client (packaging/flatpak/io.unom.Punktfunk.yml). APP_ID = "io.unom.Punktfunk" -# Service type advertised by punktfunk/1 hosts (matches NATIVE_SERVICE in the Rust host). -SERVICE_TYPE = "_punktfunk._udp" - -# The flatpak client persists identity / known-hosts / settings under HOME/.config/punktfunk. -# The sandbox HOME resolves to the REAL user home (== DECKY_USER_HOME), NOT the per-app -# ~/.var/app/ dir — verified on-device (`flatpak run … sh -c 'echo $HOME'` prints -# /home/deck, and the manifest's `--filesystem=~/.config/punktfunk` grants exactly that path; -# we also pass HOME=DECKY_USER_HOME into `flatpak run`, see _flatpak_env). Pointing here is what -# lets plugin settings actually reach the client AND lets us read the client's known-hosts to -# tell whether THIS device is already paired with a given host. -def _client_config_dir() -> Path: - return Path(decky.DECKY_USER_HOME) / ".config" / "punktfunk" - - -def _settings_path() -> Path: - return _client_config_dir() / "client-gtk-settings.json" - - -def _paired_fingerprints() -> set[str]: - """Host cert fingerprints (lowercase hex) this client has PIN-paired, from the client's - known-hosts store. Keyed by fingerprint so it survives a host changing IP address.""" - try: - data = json.loads((_client_config_dir() / "client-known-hosts.json").read_text()) - except (OSError, json.JSONDecodeError): - return set() - hosts = data.get("hosts", []) if isinstance(data, dict) else [] - return { - h["fp_hex"].lower() - for h in hosts - if isinstance(h, dict) and h.get("paired") and isinstance(h.get("fp_hex"), str) - } - - def _runner_path() -> str: """Absolute path to the launch wrapper shipped with the plugin (bin/punktfunkrun.sh).""" return str(Path(decky.DECKY_PLUGIN_DIR) / "bin" / "punktfunkrun.sh") -def _pins_path() -> Path: - """The pinned-games store — plugin-owned, but deliberately in the CLIENT's config dir - (like everything else we persist): the plugins dir is root-owned and wiped on - reinstall, while ``~/.config/punktfunk`` survives both.""" - return _client_config_dir() / "decky-pinned.json" - - # --- Steam Input controller config injection (native touchscreen via the ts_n command) -------- # The Deck's touchscreen only reaches the app as native wl_touch when a Steam Input layout with # the "Touchscreen Native Support" (controller_action ts_n) command is active for the game. We @@ -186,39 +148,6 @@ def _upsert_configset_entry(text: str, key: str, source_type: str, source_val: s return text[:last_close] + block + text[last_close:] -def _parse_library_tsv(stdout: str) -> list[dict]: - """Parse the flatpak client's ``--library`` output: one ``id\\tstore\\ttitle`` line per - game plus a trailing ``N game(s)`` count line (no tabs — it self-skips here). A title - may itself contain tabs, so split at most twice.""" - games: list[dict] = [] - for line in stdout.splitlines(): - parts = line.split("\t", 2) - if len(parts) == 3: - games.append({"id": parts[0], "store": parts[1], "title": parts[2]}) - return games - - -def _classify_library_error(stderr: str) -> str: - """Map the client's ``library: `` stderr line to a stable error - code for the UI. Substring-matched against the Display strings in - ``crates/pf-client-core/src/library.rs`` — a wording change degrades to ``client-error`` - (generic copy), never a crash.""" - s = stderr.lower() - if "didn't recognize this device" in s: - return "not-paired" - if "pinned fingerprint" in s: - return "pin-mismatch" - if "couldn't reach the host" in s: - return "unreachable" - if "management api returned http" in s: - return "http" - if "display" in s or "gtk" in s: - # A flatpak so old it predates --library falls through to GTK init, which fails - # headless from this backend. - return "client-outdated" - return "client-error" - - # ---------------------------------------------------------------------------------------- # Self-update check (no Decky store). The plugin is distributed via "Install Plugin from # URL" pointing at our Gitea generic registry, so the official store never sees it and @@ -347,9 +276,10 @@ def _flatpak() -> str | None: # settings in the same ~/.config/punktfunk (the flatpak's sandbox HOME resolves to the real # home), so nothing else in this file has to care which one answered. NATIVE_BIN = "punktfunk-client" -# The Vulkan session binary the shell execs to stream — and the only thing that can enumerate -# this device's GPUs and audio endpoints for the settings pickers. -SESSION_BIN = "punktfunk-session" +# The headless CLI — the door this backend does almost everything through (discover, hosts, +# pair, trust). Shipped beside the GTK client in every package since v0.22.0: /app/bin in the +# flatpak, the same bindir as `punktfunk-client` natively. +CLI_BIN = "punktfunk" # Prefixes to try when PATH doesn't have it. The Decky backend runs with a minimal PATH, and # SteamOS's read-only /usr pushes native installs into a sysext or the user's own prefix. @@ -405,25 +335,107 @@ def _client_argv() -> list[str] | None: return [native] if native else None -def _session_argv() -> list[str] | None: - """The argv PREFIX that runs the SESSION binary headlessly, or None when it isn't there. +def _cli_argv() -> list[str] | None: + """The argv PREFIX that runs the headless CLI, or None when no client is installed. - The device enumerations the settings pickers need (`--list-adapters`, `--list-audio`) live on - `punktfunk-session`, not on the client: the GTK shell deliberately links no Vulkan itself and - shells out to the session for exactly the same two lists (clients/linux/src/app.rs). The - flatpak installs both binaries into /app/bin, so `--command=` picks the other one; a native - install puts them in the same bindir, so the session is the client's sibling. + Exactly the shape the old ``_session_argv`` used, pointed at ``punktfunk`` instead: the + flatpak ships both binaries in /app/bin so ``--command=`` picks the other one (**the app id + stays LAST** — flatpak treats everything after it as the app's own argv), and a native + install puts the CLI in the same bindir as ``punktfunk-client``, so it is its sibling. """ prefix = _client_argv() if not prefix: return None if prefix[0] == _flatpak(): - # `flatpak run --command= ` — the app id must stay LAST. - return [*prefix[:-1], f"--command={SESSION_BIN}", prefix[-1]] - sibling = Path(prefix[0]).with_name(SESSION_BIN) + return [*prefix[:-1], f"--command={CLI_BIN}", prefix[-1]] + sibling = Path(prefix[0]).with_name(CLI_BIN) return [str(sibling)] if sibling.exists() else None +async def _run_cli(args: list[str], timeout: float = 20.0) -> tuple[int, str, str]: + """Run the headless CLI, returning ``(returncode, stdout, stderr)``. SEPARATE pipes: stdout + is the machine interface (JSON/TSV) and stderr carries the log lines, and merging them would + corrupt every payload. ``(-1, "", "")`` when no client is installed or the call times out. + + The same ``_flatpak_env`` repair the client runs needed applies here unchanged — Decky's + PyInstaller ``LD_LIBRARY_PATH`` leak breaks the flatpak's libcurl whatever binary inside the + sandbox is being started.""" + prefix = _cli_argv() + if not prefix: + return -1, "", "" + proc = None + try: + proc = await asyncio.create_subprocess_exec( + *prefix, *args, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + env=_flatpak_env(), + ) + out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout) + rc = proc.returncode if proc.returncode is not None else -1 + return ( + rc, + (out or b"").decode("utf-8", "replace"), + (err or b"").decode("utf-8", "replace"), + ) + except asyncio.TimeoutError: + decky.logger.warning("cli %s timed out", " ".join(args)) + if proc: + try: + proc.kill() + except ProcessLookupError: + pass + return -1, "", "" + except Exception: # noqa: BLE001 + decky.logger.exception("cli %s failed", " ".join(args)) + return -1, "", "" + + +# The CLI's exit-code contract (clients/cli/src/main.rs): 0 ok, 2 connect failed, 3 trust +# rejected, 4 renderer, 5 could not resolve what was asked for, 6 needs a person. Mapped to the +# stable strings the panel renders, so a reworded message can never change what the UI shows. +_CLI_ERRORS = { + 2: "unreachable", + 3: "refused", + 5: "unresolved", + 6: "needs-pairing", +} + + +def _cli_error(rc: int, stderr: str) -> str: + """One stable error code for a nonzero CLI exit. + + The interesting case is a client too old for the verb we just used. That announces itself + DETERMINISTICALLY — exit 5 plus ``unknown command ""`` on stderr — rather than by the + guesswork the GTK headless modes needed, so the panel can say "update the client" with + confidence and offer the button that fixes it.""" + if rc == -1: + return "client-unavailable" + if rc == 5 and "unknown command" in stderr: + return "client-outdated" + return _CLI_ERRORS.get(rc, "client-error") + + +async def _cli_json(args: list[str], timeout: float = 20.0) -> dict: + """Run the CLI and parse its stdout as JSON. ``{"ok": True, **payload}`` on success, else + ``{"ok": False, "error": , "detail": }``. + + A zero exit with unparseable stdout is a failure, not an empty result: silently returning + "no hosts" for a broken client is exactly the answer a user cannot debug.""" + rc, out, err = await _run_cli(args, timeout=timeout) + if rc == 0: + try: + data = json.loads(out) + if isinstance(data, dict): + return {"ok": True, **data} + except json.JSONDecodeError: + decky.logger.warning("cli %s: unparseable output: %s", args[0], out[:200]) + return {"ok": False, "error": "client-error", "detail": "unreadable output"} + code = _cli_error(rc, err) + detail = (err.strip().splitlines() or [f"{args[0]} failed"])[-1] + decky.logger.warning("cli %s failed (rc=%s, %s): %s", args[0], rc, code, detail) + return {"ok": False, "error": code, "detail": detail} + + def _client_is_flatpak() -> bool: """Is the client this plugin actually drives the FLATPAK one? @@ -537,121 +549,6 @@ async def _run_client(client_args: list[str], timeout: float = 20.0) -> tuple[in return -1, "", "" -def _parse_audio_endpoints(out: str) -> tuple[list[dict], list[dict]]: - """Split `punktfunk-session --list-audio` into ``(sinks, sources)``. - - Its format is one endpoint per line, ``sink|sourcenode.namedescription``. The - node.name is what gets STORED (it is the stable id the client resolves against), so a line - without one is unusable and dropped; a missing description falls back to the name rather than - rendering a picker entry with no label. Anything else on the line is ignored, so an extra - trailing column in a future client can't break this. - """ - sinks: list[dict] = [] - sources: list[dict] = [] - for line in out.splitlines(): - parts = line.split("\t") - if len(parts) < 3 or not parts[1].strip(): - continue - kind, name, description = parts[0].strip(), parts[1].strip(), parts[2].strip() - entry = {"name": name, "description": description or name} - if kind == "sink": - sinks.append(entry) - elif kind == "source": - sources.append(entry) - return sinks, sources - - -async def _run_session(session_args: list[str], timeout: float = 25.0) -> tuple[int, str]: - """Run the SESSION binary headlessly, returning ``(returncode, stdout)``; ``(-1, "")`` when - it isn't installed or the call errors/times out. - - Only ever used for the two read-only device enumerations — the launch path goes through the - Steam shortcut and the wrapper script, never through here. The timeout is generous because - `--list-adapters` initialises Vulkan on a cold flatpak.""" - prefix = _session_argv() - if not prefix: - return -1, "" - proc = None - try: - proc = await asyncio.create_subprocess_exec( - *prefix, *session_args, - stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, - env=_flatpak_env(), - ) - out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout) - rc = proc.returncode if proc.returncode is not None else -1 - return rc, (out or b"").decode("utf-8", "replace") - except asyncio.TimeoutError: - decky.logger.warning("session %s timed out", " ".join(session_args)) - if proc: - try: - proc.kill() - except ProcessLookupError: - pass - return -1, "" - except Exception: # noqa: BLE001 - decky.logger.exception("session %s failed", " ".join(session_args)) - return -1, "" - - -# The QAM panel and the full page each mount their own hosts view, and Gaming Mode remounts the -# QAM often — every mount calls list_hosts, which spawns a flatpak cold-start plus a reachability -# probe. Cache the last result briefly so back-to-back opens reuse it instead of re-probing; any -# mutation (add/edit/forget/reset/pair) invalidates it so a change shows up immediately. -_HOSTS_TTL_S = 12.0 -_hosts_cache: dict = {"at": 0.0, "probed": None, "data": None} - -# The settings tab's device lists (GPUs / audio endpoints). No TTL: this is hardware, and reading -# it costs a Vulkan + PipeWire init. Held for the life of the plugin backend; `refresh_devices` -# clears it for the user who just plugged a headset in. -_devices_cache: dict = {"data": None} - - -def _invalidate_hosts_cache() -> None: - _hosts_cache["data"] = None - - -def _read_known_hosts() -> list[dict]: - """The saved-hosts store read straight off disk — the fallback for a client too old to have - ``--list-hosts``. Same file the desktop client owns; `online` is left ``None`` (unknown) - because a direct read has no reachability signal.""" - try: - data = json.loads((_client_config_dir() / "client-known-hosts.json").read_text()) - except (OSError, json.JSONDecodeError): - return [] - hosts = data.get("hosts", []) if isinstance(data, dict) else [] - out: list[dict] = [] - for h in hosts: - if not isinstance(h, dict) or not h.get("addr"): - continue - out.append({ - "name": str(h.get("name") or h.get("addr", "")), - "addr": str(h.get("addr", "")), - "port": int(h.get("port", 9777) or 9777), - "fp_hex": str(h.get("fp_hex", "")), - "paired": bool(h.get("paired", False)), - "mac": h.get("mac") if isinstance(h.get("mac"), list) else [], - "last_used": h.get("last_used"), - "online": None, - }) - return out - - -def _mutation_result(rc: int, err: str, op: str) -> dict: - """Map a headless host-store mutation's exit status to a UI-stable result. ``rc == -1`` means - the flatpak call never ran (missing/timed out); a nonzero rc from a client that PREDATES the - mode falls through to GTK init and fails headless — classified ``client-outdated`` so the UI - can prompt an update instead of showing a cryptic error.""" - if rc == 0: - return {"ok": True} - if rc == -1: - return {"ok": False, "error": "client-unavailable"} - code = _classify_library_error(err) - detail = (err.strip().splitlines() or [f"{op} failed"])[-1] - decky.logger.warning("%s failed (rc=%s): %s", op, rc, detail) - return {"ok": False, "error": code, "detail": detail} - - def _field_from(text: str, name: str) -> str: """Pull ``: value`` out of ``flatpak info`` / ``remote-info`` output (e.g. ``Commit``, ``Origin``).""" @@ -663,6 +560,18 @@ def _field_from(text: str, name: str) -> str: return "" +def _looks_outdated(stderr: str) -> bool: + """Does this stderr have the signature of a client too old for the headless flag it was just + handed? Such a client ignores the unknown flag and falls through to GTK init, which fails + with no display — so the give-away is display/GTK noise rather than anything about the flag. + + Narrow on purpose: the CLI announces the same condition deterministically (exit 5 plus + ``unknown command``, see :func:`_cli_error`), and this heuristic is only still here because + the update check drives the GTK client's ``--check-update``, not the CLI.""" + s = stderr.lower() + return "display" in s or "gtk" in s + + async def _client_update_state() -> dict: """Is a newer commit of the flatpak client available in the remote it tracks? The client is a **per-user** install (so ``sudo flatpak update``, which is system-scope, never touches it), and @@ -730,312 +639,90 @@ async def _native_update_state() -> dict: if rc == -1: return {} # A client predating `--check-update` ignores the flag and falls through to GTK init, which - # fails headless — the same signature the other headless modes classify. - code = _classify_library_error(err) - decky.logger.info("native check-update unavailable (rc=%s, %s)", rc, code) - return {"error": code} if code == "client-outdated" else {} - - -def _split_txt(txt: str) -> list[str]: - """Split an avahi TXT column into tokens, honouring the ``"key=value"`` quoting.""" - tokens: list[str] = [] - cur: list[str] = [] - in_quote = False - for ch in txt: - if ch == '"': - if in_quote: - tokens.append("".join(cur)) - cur = [] - in_quote = not in_quote - elif in_quote: - cur.append(ch) - if cur: - tokens.append("".join(cur)) - return tokens - - -def _parse_avahi_browse(stdout: str) -> list[dict]: - """Parse ``avahi-browse -rpt`` output into a list of host dicts (deduped on the TXT ``id``).""" - out: dict[str, dict] = {} - for raw in stdout.splitlines(): - line = raw.strip() - if not line.startswith("="): - continue - parts = line.replace("\\;", "\x00").split(";") - parts = [p.replace("\x00", ";") for p in parts] - if len(parts) < 9: - continue - - name = parts[3] - address = parts[7] - port_str = parts[8] - txt = parts[9] if len(parts) > 9 else "" - - try: - port = int(port_str) - except ValueError: - port = 0 - - props: dict[str, str] = {} - for token in _split_txt(txt): - if "=" in token: - k, v = token.split("=", 1) - props[k] = v - - if props.get("proto") and not props["proto"].startswith("punktfunk/"): - continue - - try: - mgmt = int(props.get("mgmt", "")) - except ValueError: - mgmt = 0 # not advertised (standalone punktfunk1-host) — callers default 47990 - - entry = { - "name": name, - "host": address, - "port": port, - "pair": props.get("pair", "optional"), - "fp": props.get("fp", ""), - "proto": props.get("proto", ""), - "id": props.get("id", ""), - "mgmt": mgmt, - # OS-identity chain for the host row's icon (e.g. "linux/fedora/bazzite"); - # empty on an older host that doesn't advertise it. - "os": props.get("os", ""), - } - key = props.get("id") or f"{address}:{port}" - existing = out.get(key) - # Prefer IPv4 over IPv6 for the user-facing host string. - if existing is None or (":" in existing["host"] and ":" not in address): - out[key] = entry - - return list(out.values()) + # fails headless — that is the signature, and it is the one thing worth reporting here. + outdated = _looks_outdated(err) + decky.logger.info("native check-update unavailable (rc=%s, outdated=%s)", rc, outdated) + return {"error": "client-outdated"} if outdated else {} class Plugin: - async def discover(self) -> list[dict]: - """Browse the LAN for punktfunk/1 hosts. Returns ``[{name, host, port, pair, fp}]``.""" - avahi = shutil.which("avahi-browse") - if not avahi: - decky.logger.error("avahi-browse not found; install avahi for host discovery") - return [] - try: - proc = await asyncio.create_subprocess_exec( - avahi, "-rpt", SERVICE_TYPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - try: - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=8.0) - except asyncio.TimeoutError: - proc.kill() - decky.logger.warning("avahi-browse timed out") - return [] - except Exception: # noqa: BLE001 - decky.logger.exception("avahi-browse failed") - return [] - if stderr: - decky.logger.debug("avahi-browse stderr: %s", stderr.decode(errors="replace")) - hosts = _parse_avahi_browse(stdout.decode(errors="replace")) - # Mark which hosts THIS device has already paired (by cert fingerprint), so the UI can - # show "Stream" instead of "Pair" — the mDNS `pair` field is the host's policy, not our - # per-device pairing state. - paired = _paired_fingerprints() - for h in hosts: - fp = h.get("fp") or "" - h["paired"] = bool(fp) and fp.lower() in paired - decky.logger.info("discovered %d punktfunk host(s)", len(hosts)) - return hosts + # ---- Thin shells over the headless CLI ------------------------------------------------- + # + # Each is "build argv, run, parse JSON, map the exit code". No parsing of the client's data + # files happens here and no trust rule is re-implemented here: this backend exists because + # Decky's frontend cannot spawn processes, not because it knows anything the client doesn't. - async def pair(self, host: str, port: int, pin: str, name: str = "Steam Deck") -> dict: - """Run the SPAKE2 PIN ceremony headlessly via the flatpak client's ``--pair`` mode. + async def discover(self) -> dict: + """Browse the LAN for hosts (``punktfunk discover --json``). - The user arms pairing on the HOST (which displays a 4-digit PIN) and enters it here. - On success the flatpak persists the host to its known-hosts as paired, so a later - stream connects silently. Returns ``{ok, fp?, error?}``. - """ - flatpak = _flatpak() - if not flatpak: - return {"ok": False, "error": "flatpak-not-found"} - argv = [ - flatpak, "run", "--arch=x86_64", APP_ID, - "--pair", str(pin).strip(), - "--connect", f"{host}:{port}", - "--name", name, - "--host-label", host, - ] - decky.logger.info("pairing: %s", " ".join(argv[:6] + ["", "--connect", f"{host}:{port}"])) - try: - proc = await asyncio.create_subprocess_exec( - *argv, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - env=_flatpak_env(), - ) - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=100.0) - except asyncio.TimeoutError: - return {"ok": False, "error": "pairing timed out"} - except Exception as exc: # noqa: BLE001 - decky.logger.exception("pairing failed to launch") - return {"ok": False, "error": str(exc)} + ``{ok: True, hosts: [{name, addr, port, fp, pair, id, mgmt, os, saved, paired}]}``, or + ``{ok: False, error}`` — ``client-outdated`` when the installed client predates the + verb, which the panel renders as one explanatory row plus the update button. - out = stdout.decode(errors="replace") - err = stderr.decode(errors="replace") - if proc.returncode == 0 and "paired " in out: + The 12 s budget covers a cold flatpak start on top of the CLI's own 3 s browse.""" + return await _cli_json(["discover", "--json"], timeout=12.0) + + async def hosts(self) -> dict: + """The saved hosts with a live reachability probe + (``punktfunk hosts list --probe --json``). + + ``--probe`` asks each host directly rather than waiting for an advert, so a host reached + over a routed network (Tailscale/VPN) reports online instead of looking dead. Profile + bindings and pinned cards come back already resolved against the profile catalog — + dangling ids dropped, names attached — so the panel renders them without ever opening + ``client-profiles.json``.""" + return await _cli_json(["hosts", "list", "--probe", "--json"], timeout=30.0) + + async def pair(self, addr: str, port: int, pin: str, name: str = "Steam Deck") -> dict: + """The PIN ceremony (``punktfunk pair --pin N --name LABEL``). + + The operator arms pairing on the host, which shows a 4-digit PIN; entering it here + verifies the host end to end and pins its fingerprint, so every later connect is silent. + ``{ok: True}``, or ``{ok: False, error}`` where ``refused`` is a wrong PIN or a host + that isn't armed, and ``unreachable`` is a host that never answered. + + The budget is generous because the ceremony waits on a person at the other end.""" + rc, out, err = await _run_cli( + [ + "pair", f"{addr}:{int(port)}", + "--pin", str(pin).strip(), + "--name", name, + ], + timeout=100.0, + ) + if rc == 0: fp = "" - for tok in out.split(): - if tok.startswith("fp="): - fp = tok[3:] - decky.logger.info("paired %s:%s", host, port) - _invalidate_hosts_cache() # the store gained a paired entry — reflect it next list + for token in out.split(): + if token.startswith("fp="): + fp = token[3:] + decky.logger.info("paired %s:%s", addr, port) return {"ok": True, "fp": fp} - decky.logger.warning("pairing failed (rc=%s): %s", proc.returncode, err.strip() or out.strip()) - # Surface the client's own one-line reason (wrong PIN / not armed) to the UI. - reason = (err.strip().splitlines() or out.strip().splitlines() or ["pairing failed"])[-1] - return {"ok": False, "error": reason} + detail = (err.strip().splitlines() or ["pairing failed"])[-1] + decky.logger.warning("pairing failed (rc=%s): %s", rc, detail) + return {"ok": False, "error": _cli_error(rc, err), "detail": detail} - async def wake(self, host: str, port: int = 9777) -> dict: - """Send a Wake-on-LAN magic packet to a saved host via the flatpak client's headless - ``--wake`` mode, so a sleeping host is up by the time the stream ``--connect`` runs. + async def trust_host(self, addr: str, port: int, fp: str, name: str = "") -> dict: + """Step 1 of request access: save the host with the fingerprint it ADVERTISED + (``punktfunk hosts add --fp --name

{title}
-
- ) => setAddr(e.target.value)} - /> -
-
- ) => setPort(e.target.value)} - /> -
-
- ) => setName(e.target.value)} - /> -
- {error && ( -
{error}
- )} - - closeModal?.()}> - Cancel - - - {busy ? : submitLabel} - - - - ); -}; - -/** "+" — save a new host by address (unpaired placeholder; the user pairs it next). */ -export const AddHostModal: FC<{ onDone: () => void; closeModal?: () => void }> = ({ - onDone, - closeModal, -}) => ( - { - const r = await addHost(targetFrom(addr, port), name, ""); - if (r.ok) { - toaster.toast({ title: "Punktfunk", body: `Added ${name || addr}` }); - } - return r; - }} - onDone={onDone} - closeModal={closeModal} - /> -); - -/** Rename / re-point a saved host. Identified by fingerprint when it has one (survives IP - * changes), else by its current address. */ -export const EditHostModal: FC<{ - host: HostView; - onDone: () => void; - closeModal?: () => void; -}> = ({ host, onDone, closeModal }) => { - const selector = host.fp || `${host.addr}:${host.port}`; - return ( - { - const r = await editHost(selector, name, addr, parseInt(port, 10) || 0); - if (r.ok) { - toaster.toast({ title: "Punktfunk", body: `Updated ${name || addr}` }); - } - return r; - }} - onDone={onDone} - closeModal={closeModal} - /> - ); -}; diff --git a/clients/decky/src/library.tsx b/clients/decky/src/library.tsx deleted file mode 100644 index 3c4c930b..00000000 --- a/clients/decky/src/library.tsx +++ /dev/null @@ -1,230 +0,0 @@ -// The per-host game picker + pinned-game launch helper. The picker fetches a paired -// host's library through the backend (headless flatpak --library — a cold client start -// can take seconds, hence the explicit spinner copy) and pins titles as one-tap rows in -// the QAM's Games section; its header also launches the GTK client's on-screen gamepad -// library (`--browse`). -import { DialogButton, Field, ModalRoot, Spinner, showModal } from "@decky/ui"; -import { FC, useEffect, useState } from "react"; -import { FaThLarge, FaTv } from "react-icons/fa"; -import { GameEntry, Host, library, LibraryResult, PinnedGame } from "./backend"; -import { PinsApi, resolvePinHost, startBrowse, startStream } from "./hooks"; -import { isSafeLaunchId } from "./steam"; -import { PairModal } from "./pair"; -import { RowActions, actionButton } from "./ui"; - -/** Human store tag (mirrors the GTK client's `store_label`). */ -export function storeLabel(store: string): string { - switch (store) { - case "steam": - return "Steam"; - case "custom": - return "Custom"; - case "heroic": - return "Heroic"; - case "lutris": - return "Lutris"; - case "epic": - return "Epic"; - case "gog": - return "GOG"; - case "xbox": - return "Xbox"; - default: - return "Game"; - } -} - -/** - * Stream a pinned game: resolve the host from the live scan (fp → id → stored address), - * opportunistically refresh a drifted stored address, and route through pairing first if - * this device is no longer paired with the host. - */ -export function streamPin(pin: PinnedGame, live: Host[], pins: PinsApi): void { - const { host, online } = resolvePinHost(pin, live); - if (online) { - pins.updatePinHost(pin, host); // no-op unless the address actually drifted - } - if (!pin.paired) { - showModal( - { - void pins.refresh(); // pick up the now-paired annotation - void startStream(host, { launchId: pin.game_id }, pin.title); - }} - />, - ); - return; - } - void startStream(host, { launchId: pin.game_id }, pin.title); -} - -// Copy per backend error code (LibraryResult.error); `detail` covers the generic case. -function errorCopy(res: LibraryResult): string { - switch (res.error) { - case "not-paired": - return "This Deck isn't paired with the host — pair first, then browse its library."; - case "pin-mismatch": - return "The host's identity changed — re-pair to re-establish trust."; - case "unreachable": - return "Couldn't reach the host's management API. Is the host online and up to date?"; - case "timeout": - return "Timed out talking to the host — try again."; - case "flatpak-not-found": - return "The Punktfunk client isn't installed (flatpak io.unom.Punktfunk)."; - case "client-outdated": - return "The installed client is too old for library browsing — update it from the About tab."; - default: - return res.detail || "Couldn't fetch the library."; - } -} - -// ---------------------------------------------------------------------------------------- -// The picker modal: "open on screen" + a pin-toggle list of the host's games. -// ---------------------------------------------------------------------------------------- -export const GamePickerModal: FC<{ - host: Host; - pins: PinsApi; - clientUpdatePending?: boolean; - closeModal?: () => void; -}> = ({ host, pins, clientUpdatePending, closeModal }) => { - const [result, setResult] = useState(null); - const [attempt, setAttempt] = useState(0); // bump to refetch (retry / after pairing) - // The modal is a detached `showModal` portal that never re-renders from the page's pin - // state, so `pins.isPinned` would read a frozen snapshot and the Pin/Unpin label would - // never flip within a session. Track this host's pinned ids locally, seeded once from the - // snapshot at open; persistence still goes through the (stale-closure-safe) pins API. - const [pinnedIds, setPinnedIds] = useState>( - () => new Set(pins.pins.filter((p) => p.host_fp === host.fp).map((p) => p.game_id)), - ); - const togglePin = (g: GameEntry) => { - const wasPinned = pinnedIds.has(g.id); - setPinnedIds((prev) => { - const next = new Set(prev); - if (wasPinned) next.delete(g.id); - else next.add(g.id); - return next; - }); - if (wasPinned) pins.removePin(host.fp, g.id); - else pins.addPin(host, g); - }; - - useEffect(() => { - let stale = false; - setResult(null); - library(host.host, host.mgmt, host.fp) - .then((res) => { - if (!stale) setResult(res); - }) - .catch((e) => { - if (!stale) setResult({ ok: false, error: "client-error", detail: String(e) }); - }); - return () => { - stale = true; - }; - }, [host.host, host.mgmt, host.fp, attempt]); - - const games = (result?.ok && result.games) || []; - const sorted = [...games].sort((a, b) => a.title.localeCompare(b.title)); - - return ( - -
- {host.name} — Games -
- - - - { - closeModal?.(); - void startBrowse(host); - }} - > - - Open - - - - - {clientUpdatePending && ( - - )} - - {result === null && ( - - - Fetching the library… - - } - description="This starts the client headlessly — a cold start can take a few seconds." - /> - )} - - {result !== null && !result.ok && ( - - - {result.error === "not-paired" && ( - - showModal( setAttempt((n) => n + 1)} />) - } - > - Pair - - )} - setAttempt((n) => n + 1)}> - Retry - - - - )} - - {result?.ok && sorted.length === 0 && ( - - )} - - {sorted.length > 0 && ( -
- {sorted.map((g: GameEntry) => { - const pinned = pinnedIds.has(g.id); - const safe = isSafeLaunchId(g.id); - return ( - - - togglePin(g)}> - - {pinned ? "Unpin" : "Pin"} - - - - ); - })} -
- )} -
- ); -}; diff --git a/clients/decky/src/page.tsx b/clients/decky/src/page.tsx deleted file mode 100644 index 8b334bc8..00000000 --- a/clients/decky/src/page.tsx +++ /dev/null @@ -1,596 +0,0 @@ -// The fullscreen page (registered as the /punktfunk route) — Hosts / Settings / About tabs. -import { - ConfirmModal, - DialogButton, - Field, - Focusable, - ModalRoot, - Navigation, - Spinner, - Tabs, - showModal, - staticClasses, -} from "@decky/ui"; -import { RowActions, actionButton, iconButton } from "./ui"; -import { toaster } from "@decky/api"; -import { CSSProperties, FC, useState } from "react"; -import { - FaArrowLeft, - FaDownload, - FaExternalLinkAlt, - FaInfoCircle, - FaLock, - FaLockOpen, - FaPen, - FaPlay, - FaPlus, - FaSyncAlt, - FaThLarge, - FaTrashAlt, -} from "react-icons/fa"; -import { UpdateInfo, forgetHost, killStream } from "./backend"; -import { PluginErrorBoundary } from "./boundary"; -import { OsMark } from "./os-icon"; -import { - DOCS_URL, - HostView, - PinsApi, - applyUpdate, - checkForUpdatesNow, - clientInstallLabel, - clientUpdateIsManualOnly, - hasUpdate, - mergeHosts, - needsPair, - pinIsOnline, - resetAll, - startStream, - toHost, - useHosts, - usePins, - useSavedHosts, - useUpdate, -} from "./hooks"; -import { AddHostModal, EditHostModal, mutationError } from "./hostmgmt"; -import { GamePickerModal, storeLabel, streamPin } from "./library"; -import { PairModal } from "./pair"; -import { SettingsSection } from "./settings"; -import { stopStream } from "./steam"; - -export const ROUTE = "/punktfunk"; - -// Bottom inset so the last control clears Gaming Mode's footer hint bar. Routed pages render -// *under* that bar otherwise — that's why the last Stream-settings row was getting hidden. The -// value is generous on purpose (and harmless where the tab area already insets); tune to taste. -const SAFE_BOTTOM = "80px"; - -// Each tab is its own scroll area so long content is always reachable above the footer. -const tabScroll: CSSProperties = { - height: "100%", - overflowY: "auto", - padding: "0.5em 2.5em", - paddingBottom: SAFE_BOTTOM, - boxSizing: "border-box", -}; - -// The one-line status under a host name: address, live presence, and trust state. -function hostSubtitle(v: HostView): string { - const parts = [`${v.addr}:${v.port}`, v.online ? "online" : "offline"]; - if (needsPair(v)) { - parts.push("pairing required"); - } else if (v.paired) { - parts.push("paired"); - } else if (v.saved) { - parts.push("trusted"); - } - return parts.join(" · "); -} - -/** Confirm + forget a saved host, then refresh the list. */ -function confirmForget(v: HostView, refresh: () => void): void { - const selector = v.fp || `${v.addr}:${v.port}`; - showModal( - { - const r = await forgetHost(selector); - toaster.toast({ - title: "Punktfunk", - body: r.ok ? `Forgot ${v.name}` : mutationError(r), - }); - refresh(); - }} - />, - ); -} - -// ---------------------------------------------------------------------------------------- -// Host details — everything we know, plus (for a saved host) rename / edit / forget. -// ---------------------------------------------------------------------------------------- -const HostDetailsModal: FC<{ - host: HostView; - onChanged: () => void; - closeModal?: () => void; -}> = ({ host, onChanged, closeModal }) => { - const fp = host.fp ? (host.fp.match(/.{1,4}/g) ?? [host.fp]).join(" ") : "not known yet"; - return ( - -
- {host.name} -
- - {host.addr}:{host.port} - - - {host.online ? "Online" : "Offline"} - - - {host.paired ? "Paired" : host.fp ? "Trusted" : "Not paired yet"} - - - {fp} - - } - /> - {host.saved && ( - - - { - closeModal?.(); - showModal(); - }} - > - - Edit - - { - closeModal?.(); - confirmForget(host, onChanged); - }} - > - - Forget - - - - )} -
- ); -}; - -// ---------------------------------------------------------------------------------------- -// One host row: status icon + address, details / pair / stream actions. -// ---------------------------------------------------------------------------------------- -const HostRow: FC<{ - host: HostView; - onChanged: () => void; - onGames: () => void; -}> = ({ host, onChanged, onGames }) => { - const pair = needsPair(host); - const h = toHost(host); - return ( - - - {pair ? : } - {host.name} - - } - description={hostSubtitle(host)} - childrenContainerWidth="max" - > - - showModal()} - > - - - {/* Labeled, not icon-only: this is the entry to the game picker AND the on-screen - library browser, and controller nav has no hover tooltip to explain a bare icon. */} - - - Games - - {pair && ( - showModal()} - > - Pair - - )} - - pair - ? showModal( startStream(h)} />) - : startStream(h) - } - > - - Stream - - - - ); -}; - -const HostsTab: FC<{ - hosts: HostView[]; - scanning: boolean; - refresh: () => void; - pins: PinsApi; - clientUpdatePending: boolean; -}> = ({ hosts, scanning, refresh, pins, clientUpdatePending }) => ( -
- - - showModal()} - > - - Add - - - {scanning ? ( - - ) : ( - - )} - {scanning ? "Scanning…" : "Refresh"} - - - - - {hosts.length === 0 && !scanning && ( - - )} - {hosts.map((h) => ( - - showModal( - , - ) - } - /> - ))} - - {/* Pinned games — also the cleanup surface for pins whose host is gone from the scan. */} - {pins.pins.length > 0 && ( - <> - - {pins.pins.map((pin) => { - const online = pinIsOnline(pin, hosts); - return ( - - - streamPin(pin, hosts.map(toHost), pins)} - > - - Play - - pins.removePin(pin.host_fp, pin.game_id)} - > - Remove - - - - ); - })} - - )} -
-); - -// NOT `tabScroll`: the settings screen is a SidebarNavigation, which lays out its own rail + -// content pane and scrolls the pane itself. Wrapping it in an outer scroll area would give it an -// indefinite height to fill, collapsing the rail — so this pane only hands it the full height and -// keeps its hands off the overflow. The footer inset lives inside the pages instead. -const settingsPane: CSSProperties = { height: "100%", overflow: "hidden" }; - -const SettingsTab: FC = () => ( -
- -
-); - -// ---------------------------------------------------------------------------------------- -// About — plugin version + explicit update check, docs link, stream-exit help, force-stop, -// and the destructive "reset everything" action. -// ---------------------------------------------------------------------------------------- -async function forceStopStream(): Promise { - stopStream(); // ask Steam to end the "game" first (clean path) - const res = await killStream(); // then the flatpak-level hammer for a wedged client - toaster.toast({ - title: "Punktfunk", - body: res.ok ? "Stream client stopped." : "Couldn’t stop the stream client.", - }); -} - -function confirmReset(refreshers: Array<() => void | Promise>): void { - showModal( - void resetAll(refreshers)} - />, - ); -} - -const AboutTab: FC<{ - update: UpdateInfo | null; - checking: boolean; - check: (force: boolean) => Promise; - onReset: () => void; -}> = ({ update, checking, check, onReset }) => ( -
- - - void checkForUpdatesNow(check)} - > - {checking ? : "Check for updates"} - - - - {/* What the client IS, so "why is there no Update button?" has a visible answer. The - install kind decides everything below it. */} - {!!update?.client_install && ( - - )} - {hasUpdate(update) && ( - - {clientUpdateIsManualOnly(update) && !update!.update_available ? null : ( - - applyUpdate(update!, check)}> - - Update - - - )} - - )} - {!!update?.client_error && ( - - )} - - - Navigation.NavigateToExternalWeb(DOCS_URL)} - > - - Open - - - - - - - void forceStopStream()}> - Force-stop - - - - - - - - Reset - - - -
-); - -const PunktfunkPage: FC = () => { - const { hosts: discovered, scanning, refresh: refreshDiscovered } = useHosts(); - const { saved, loading: loadingSaved, refresh: refreshSaved } = useSavedHosts(); - const { info: update, checking, check } = useUpdate(); - const pins = usePins(); - const [tab, setTab] = useState("hosts"); - - const hosts = mergeHosts(saved, discovered); - // A host action (pair/add/edit/forget) can change either store, so refresh both. - const refreshHosts = () => { - void refreshDiscovered(); - void refreshSaved(); - }; - - return ( -
- {/* Header is title + back only — updates live on the About tab (and the QAM banner). */} - - Navigation.NavigateBack()}> - - -
- Punktfunk -
-
- - {/* Two things fight each other on an L1/R1 tab switch: - 1. Valve's Tabs slides the incoming panel in from the right with a CSS transform. - 2. `autoFocusContents` then focuses a control inside that still-offscreen panel, which - fires scrollIntoView. Because the panel is offset by a *transform* (not by scroll - position), scrollIntoView can't satisfy it by scrolling any one ancestor, so it walks - up and pans the whole page — the "screen jumps right, then animates back" glitch. - Dropping autoFocusContents removes the scrollIntoView entirely, so nothing fights the - slide. L1/R1 still cycles tabs (that handler lives on the Tabs focus scope, active while - focus is anywhere inside — including the tab strip); after a switch, focus stays on the - strip and Down enters the content, which is how Steam's own tabbed pages behave. - The overflow:hidden clip stays as defense-in-depth against any stray horizontal pan. */} -
- setTab(id)} - tabs={[ - { - id: "hosts", - title: "Hosts", - content: ( - - ), - }, - { - id: "settings", - title: "Settings", - content: , - }, - { - id: "about", - title: "About", - content: ( - confirmReset([refreshHosts, pins.refresh])} - /> - ), - }, - ]} - /> -
-
- ); -}; - -// Full page behind the boundary — registered as the /punktfunk route. -export const PunktfunkRoute: FC = () => ( - - - -); diff --git a/clients/decky/src/settings.tsx b/clients/decky/src/settings.tsx deleted file mode 100644 index f6b84e83..00000000 --- a/clients/decky/src/settings.tsx +++ /dev/null @@ -1,657 +0,0 @@ -// Stream settings — the client's WHOLE settings store, written to the JSON the client reads on -// launch (main.py set_settings, merged onto what's on disk). This is the same -// `client-gtk-settings.json` the desktop client and the console's settings screen own, so a value -// changed in any of the three shows in the other two. -// -// SHAPE OF THIS SCREEN. Thirty rows is too many to scroll past on a thumbstick, so they are split -// across a `SidebarNavigation` — the same left-rail-of-categories layout SteamOS's own Settings -// uses, and the one Deck users already know. Every page fits on screen without scrolling, which is -// the whole point of the split: the rail is the index, so nothing is more than one hop away. -// -// The categories, their order, and the wording of the rows are the console's settings screen -// (pf-console-ui/src/screens/settings.rs) — that screen is the other settings editor a user -// reaches without leaving Gaming Mode, and two different orders for one store is how people stop -// trusting either. It shows them as one steppable list because it has no pointer and no room for -// a rail; here they become the rail's pages, same groups, same sequence. Three more rules: -// -// • A setting that depends on another is INDENTED under it and DISABLED, never hidden — the -// console dims those rows rather than dropping them, and a row that vanishes as you toggle -// the one above it is a moving target for a thumbstick. -// • A picker whose options this device doesn't have doesn't appear at all (the GPU row on a -// one-GPU Deck). A dead control is worse than an absent one. -// • Anything that behaves differently *here* than it does on a desktop says so in its own -// description, rather than being silently dropped from the screen. -// -// The accepted gamepad/compositor/codec/decoder names mirror punktfunk-core's `*Pref::from_name` -// and the console's tables; the tier/mode names mirror the `StatsVerbosity` / `TouchMode` / -// `MouseMode` enums, which serialize lowercase. -import { - DialogButton, - Dropdown, - Field, - SidebarNavigation, - SliderField, - Spinner, - ToggleField, -} from "@decky/ui"; -import { CSSProperties, FC, ReactElement, ReactNode, useEffect, useState } from "react"; -import { - FaDesktop, - FaGamepad, - FaHandPointer, - FaSlidersH, - FaTv, - FaVideo, - FaVolumeUp, -} from "react-icons/fa"; -import { - AudioDevice, - DeviceLists, - getSettings, - listDevices, - refreshDevices, - setSettings, - StreamSettings, -} from "./backend"; -import { actionButton, RowActions } from "./ui"; - -// Decky's Dropdown has no width prop — it fills whatever container it's in, and a -// `childrenContainerWidth="max"` Field is the whole row. Wrapping it in this fit-content shell -// (inside the right-aligned RowActions) shrinks the control to its selected label, with a floor -// so short values like "60 Hz" don't collapse to a nub and a ceiling so nothing runs edge to -// edge. Matches the right-aligned, content-sized buttons everywhere else. -const selectShell: CSSProperties = { - width: "fit-content", - minWidth: "10em", - maxWidth: "24em", -}; - -// ---------------------------------------------------------------------------------------- -// Option tables — the console's, so the two Gaming-Mode editors offer the same choices. -// ---------------------------------------------------------------------------------------- - -// "native" and "match" are virtual: they store `width`/`height` of 0 with `match_window` off/on. -// Match window is offered even though this plugin's launches are always fullscreen (where it -// degenerates to the display's native mode) — leaving it out would make the row lie about a -// store the desktop client can set it in. -const MATCH_WINDOW = "match"; -const RESOLUTIONS: [number, number, string][] = [ - [0, 0, "Native display"], - [1280, 720, "1280 × 720"], - [1280, 800, "1280 × 800 (Deck)"], - [1920, 1080, "1920 × 1080"], - [2560, 1440, "2560 × 1440"], - [3840, 2160, "3840 × 2160"], -]; -const resolutionKey = (w: number, h: number): string => (w === 0 && h === 0 ? "native" : `${w}x${h}`); - -const REFRESH = [0, 30, 60, 90, 120]; -// Render-resolution multipliers (mirrors punktfunk_core::render_scale::PRESETS). 1.0 = native. -const RENDER_SCALES = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0]; -const renderScaleLabel = (x: number): string => - x === 1 ? "Native (1×)" : x > 1 ? `${x}× · supersample` : `${x}×`; - -const COMPOSITORS: [string, string][] = [ - ["auto", "Automatic"], - ["kwin", "KDE Plasma (KWin)"], - ["wlroots", "Sway (wlroots)"], - ["mutter", "GNOME (Mutter)"], - ["gamescope", "gamescope"], -]; -const CODECS: [string, string][] = [ - ["auto", "Automatic"], - ["hevc", "HEVC (H.265)"], - ["h264", "H.264 (AVC)"], - ["av1", "AV1"], - // Opt-in wired-LAN low-latency codec (100–400 Mbit/s class, 8-bit SDR). Only ever selected - // when the host advertises it too; anything else falls back to HEVC. - ["pyrowave", "PyroWave (wired LAN)"], -]; -const DECODERS: [string, string][] = [ - ["auto", "Automatic"], - ["vulkan", "Vulkan Video"], - ["vaapi", "VAAPI"], - ["software", "Software"], -]; -// Presentation intent — the `present_priority` key shared with the Apple and Android clients, so -// one profile reads the same on every device. -const PRESENT_PRIORITIES: [string, string][] = [ - ["latency", "Lowest latency"], - ["smooth", "Smoothness"], -]; -// Smoothness buffer depth in frames; 0 = Automatic (resolves to 2). -const SMOOTH_BUFFERS: [number, string][] = [ - [0, "Automatic"], - [1, "1 frame"], - [2, "2 frames"], - [3, "3 frames"], -]; -const AUDIO_CHANNELS: [number, string][] = [ - [2, "Stereo"], - [6, "5.1 surround"], - [8, "7.1 surround"], -]; -const GAMEPADS: [string, string][] = [ - ["auto", "Automatic"], - ["xbox360", "Xbox 360"], - ["xboxone", "Xbox One"], - ["dualsense", "DualSense"], - ["dualshock4", "DualShock 4"], - ["steamdeck", "Steam Deck"], -]; -const TOUCH_MODES: [string, string][] = [ - ["trackpad", "Trackpad"], - ["pointer", "Direct pointer"], - ["touch", "Touch passthrough"], -]; -const MOUSE_MODES: [string, string][] = [ - ["capture", "Capture (games)"], - ["desktop", "Desktop (absolute)"], -]; -const STATS_TIERS: [string, string][] = [ - ["off", "Off"], - ["compact", "Compact"], - ["normal", "Normal"], - ["detailed", "Detailed"], -]; - -// ---------------------------------------------------------------------------------------- -// Row primitives — every picker row is Field + right-aligned, content-sized Dropdown, so the -// twelve of them below stay one line each and can't drift apart. -// ---------------------------------------------------------------------------------------- - -const SelectRow = ({ - label, - description, - options, - value, - onChange, - formatUnknown, - disabled, - indent, -}: { - label: string; - description?: ReactNode; - options: [T, string][]; - value: T; - onChange: (v: T) => void; - // How to name a stored value this table doesn't list (see below); defaults to the raw value. - formatUnknown?: (v: T) => string; - disabled?: boolean; - indent?: boolean; -}): ReactElement => { - // A Dropdown can only display a value that is one of its options, and this store has four other - // writers — the desktop client, the console, a settings profile, a newer client with presets - // this build doesn't know. Rather than render a blank control (or, worse, silently show a - // different value than the stream will actually use), carry the stored one as its own entry. - const shown: [T, string][] = options.some(([v]) => v === value) - ? options - : [...options, [value, formatUnknown ? formatUnknown(value) : String(value)]]; - return ( - - -
- ({ data, label: l }))} - selectedOption={value} - onChange={(o) => onChange(o.data as T)} - /> -
-
-
- ); -}; - -// An audio-endpoint picker. The stored value is a PipeWire `node.name`; "" means "whatever the OS -// is using". A stored endpoint that isn't in the current enumeration still gets an entry — it is -// a real preference that simply isn't plugged in right now, and dropping it would silently -// re-point the next stream at the default without ever showing the user why. -const DeviceRow: FC<{ - label: string; - description: string; - devices: AudioDevice[] | null; - value: string; - onChange: (v: string) => void; - disabled?: boolean; - indent?: boolean; -}> = ({ label, description, devices, value, onChange, disabled, indent }) => { - const options: [string, string][] = [["", "System default"]]; - for (const d of devices ?? []) options.push([d.name, d.description]); - if (value && !options.some(([name]) => name === value)) { - options.push([value, `${value} (not connected)`]); - } - return ( - - ); -}; - -// ---------------------------------------------------------------------------------------- -// The pages. One settings object, seven views on it — every page takes the same context rather -// than fetching or holding state of its own, so a change on one page is visible on the others -// the moment you switch. -// ---------------------------------------------------------------------------------------- - -interface PageCtx { - s: StreamSettings; - patch: (p: Partial) => void; - devices: DeviceLists | null; - reading: boolean; - readDevices: (again: boolean) => void; -} - -// SidebarNavigation gives each page Steam's own padding, but the routed page still renders -// UNDER Gaming Mode's footer hint bar, so the last row of a page needs to clear it (the same -// inset the tabs use). -const pageBody: CSSProperties = { paddingBottom: "80px" }; - -const StreamPage: FC = ({ s, patch }) => { - const renderScale = s.render_scale ?? 1; - const resolution = s.match_window ? MATCH_WINDOW : resolutionKey(s.width, s.height); - return ( -
- [resolutionKey(w, h), label] as [string, string]), - [MATCH_WINDOW, "Match window"] as [string, string], - ]} - value={resolution} - // A size set from a desktop profile that isn't one of these presets, spelled the way the - // presets are rather than left as the raw "1600x900" key. - formatUnknown={(v) => v.replace("x", " × ")} - onChange={(v) => { - if (v === MATCH_WINDOW) { - // The tri-state the console stores: the flag on, the explicit size cleared. - patch({ match_window: true, width: 0, height: 0 }); - return; - } - const found = RESOLUTIONS.find(([w, h]) => resolutionKey(w, h) === v); - patch({ match_window: false, width: found?.[0] ?? 0, height: found?.[1] ?? 0 }); - }} - /> - [r, r === 0 ? "Native" : `${r} Hz`] as [number, string])} - value={s.refresh_hz} - formatUnknown={(v) => `${v} Hz`} - onChange={(v) => patch({ refresh_hz: v })} - /> - [x, renderScaleLabel(x)] as [number, string])} - // Snap the stored value to the nearest preset so the dropdown always shows a match. - value={RENDER_SCALES.reduce((best, x) => - Math.abs(x - renderScale) < Math.abs(best - renderScale) ? x : best, - )} - onChange={(v) => patch({ render_scale: v })} - /> - patch({ bitrate_kbps: v * 1000 })} - /> - patch({ compositor: v })} - /> -
- ); -}; - -const VideoPage: FC = ({ s, patch, devices }) => { - // Only worth a row on a box that actually has a choice to make. A Deck has one adapter, and a - // picker with a single option is a control that can't do anything. - const showGpuRow = (devices?.adapters.length ?? 0) > 1; - return ( -
- patch({ codec: v })} - /> - patch({ decoder: v })} - /> - {showGpuRow && ( - [a, a] as [string, string]), - ]} - value={s.adapter ?? ""} - onChange={(v) => patch({ adapter: v })} - /> - )} - patch({ hdr_enabled: v })} - /> - patch({ enable_444: v })} - /> -
- ); -}; - -const PresentationPage: FC = ({ s, patch }) => { - const smooth = (s.present_priority ?? "latency") === "smooth"; - return ( -
- patch({ present_priority: v })} - /> - `${v} frames`} - onChange={(v) => patch({ smooth_buffer: v })} - disabled={!smooth} - indent - /> - patch({ vsync: v })} - /> - patch({ allow_vrr: v })} - /> -
- ); -}; - -const AudioPage: FC = ({ s, patch, devices, reading, readDevices }) => { - const micOn = s.mic_enabled; - // What the pickers get: null while the enumeration is in flight (they show a loading state), - // [] when it answered but couldn't read the endpoints (System default plus whatever is - // stored), and the real list otherwise. - const endpoints = (list: AudioDevice[] | undefined): AudioDevice[] | null => - reading || !devices ? null : devices.ok ? (list ?? []) : []; - return ( -
- `${v} channels`} - onChange={(v) => patch({ audio_channels: v })} - /> - patch({ speaker_device: v })} - /> - patch({ mic_enabled: v })} - /> - patch({ mic_device: v })} - disabled={!micOn} - indent - /> - patch({ echo_cancel: v })} - disabled={!micOn} - indentLevel={1} - /> - {/* The escape hatch for a headset plugged in after this page was opened, and the honest - answer when the enumeration failed outright (a client too old to ship the session - binary). Rendered unconditionally, including while it is reading: a row that comes and - goes under a thumbstick is a moving target, so only its wording changes. */} - - - readDevices(true)}> - {reading ? : "Refresh"} - - - -
- ); -}; - -const ControllersPage: FC = ({ s, patch }) => { - const forwarding = s.gamepad_forwarding ?? true; - return ( -
- patch({ gamepad_forwarding: v })} - /> - patch({ gamepad: v })} - disabled={!forwarding} - indent - /> - {forwarding && (s.gamepad === "steamdeck" || s.gamepad === "auto") && ( - - )} -
- ); -}; - -const PointerPage: FC = ({ s, patch }) => ( -
- patch({ touch_mode: v })} - /> - patch({ mouse_mode: v })} - /> - patch({ invert_scroll: v })} - /> - patch({ inhibit_shortcuts: v })} - /> -
-); - -const InterfacePage: FC = ({ s, patch }) => { - // `Settings::stats_verbosity`: no tier = a pre-tier store, resolved through the legacy bool, - // which itself defaults to true. - const statsTier = s.stats_verbosity ?? ((s.show_stats ?? true) ? "normal" : "off"); - return ( -
- patch({ stats_verbosity: v, show_stats: v !== "off" })} - /> - patch({ auto_wake: v })} - /> - patch({ library_enabled: v })} - /> - patch({ fullscreen_on_stream: v })} - /> -
- ); -}; - -// ---------------------------------------------------------------------------------------- - -export const SettingsSection: FC = () => { - const [s, setS] = useState(null); - // null until the enumeration answers — the pickers show a loading state rather than briefly - // claiming this device has no endpoints. - const [devices, setDevices] = useState(null); - const [reading, setReading] = useState(true); - - const readDevices = (again: boolean) => { - setReading(true); - void (again ? refreshDevices() : listDevices()) - .then(setDevices) - .finally(() => setReading(false)); - }; - - useEffect(() => { - void getSettings().then(setS); - // Deliberately not awaited together with the settings: a cold flatpak initialising Vulkan - // takes seconds, and the rest of the screen must not wait for it. - readDevices(false); - }, []); - - const patch = (p: Partial) => { - setS((cur) => { - if (!cur) return cur; - const next = { ...cur, ...p }; - void setSettings(next); - return next; - }); - }; - - if (!s) return ; - - const ctx: PageCtx = { s, patch, devices, reading, readDevices }; - return ( - , content: }, - { title: "Video", identifier: "video", icon: , content: }, - { - title: "Presentation", - identifier: "presentation", - icon: , - content: , - }, - { title: "Audio", identifier: "audio", icon: , content: }, - { - title: "Controllers", - identifier: "controllers", - icon: , - content: , - }, - { - title: "Touch & mouse", - identifier: "pointer", - icon: , - content: , - }, - { - title: "Interface", - identifier: "interface", - icon: , - content: , - }, - ]} - /> - ); -}; diff --git a/clients/decky/src/ui.tsx b/clients/decky/src/ui.tsx deleted file mode 100644 index c38c672c..00000000 --- a/clients/decky/src/ui.tsx +++ /dev/null @@ -1,46 +0,0 @@ -// Shared UI primitives for the fullscreen page + modals. The one rule that keeps every row -// looking consistent: a Field's action(s) always sit right-aligned, with real space between -// them and the label text — never hugging it. -// -// Decky lays a Field out as `[ label .......... children ]`. When the children container is -// grown (`childrenContainerWidth="max"`, which we want so multi-button clusters have room), a -// bare `fit-content` button LEFT-aligns inside that grown container and ends up pressed against -// the label with the space wasted to its right. Wrapping the action(s) in `RowActions` pushes -// them to the right edge and evenly spaces multiples — the same treatment every row now gets. -import { Focusable } from "@decky/ui"; -import { CSSProperties, FC, ReactNode } from "react"; - -export const RowActions: FC<{ children: ReactNode }> = ({ children }) => ( - - {children} - -); - -// A single action button sized to its content (not the gamepad-UI default of 100% width), with -// a floor so short labels ("Pair", "Remove") don't render as tiny nubs and every row's button -// reads at the same weight. -export const actionButton: CSSProperties = { - width: "fit-content", - minWidth: "7em", - flexShrink: 0, -}; - -// Square icon-only button (details ⓘ, header back arrow). Needs an explicit height or the zero -// padding collapses it to the icon's line height. -export const iconButton: CSSProperties = { - width: "40px", - minWidth: "40px", - height: "40px", - padding: 0, - flexShrink: 0, - display: "flex", - alignItems: "center", - justifyContent: "center", -}; From 017c37b78af72cdbabda4fef04540cb698db7ce4 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 20:41:30 +0200 Subject: [PATCH 37/53] =?UTF-8?q?feat(decky):=20rebuild=20the=20panel=20as?= =?UTF-8?q?=20a=20launcher=20=E2=80=94=20nested=20cards=20and=20request=20?= =?UTF-8?q?access?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What is left of the plugin is what only a Decky plugin can do: start a stream through Steam so gamescope focuses it, and stand in front of the trust decision that gates it. One Quick Access panel, four sections, no route. HOSTS. One `useHosts()` calls discover and hosts-list together and merges them by fingerprint first, address second — so a host that moved DHCP lease still matches its record, and a different box that inherited the old address does not inherit its pairing. The CLI annotates `saved`/`paired` by that same rule, so the two surfaces cannot disagree. Rows sort online first, then most recently used, then by name: the host you streamed last night is the first thing under your thumb, and a host that is off right now never is. `needsPair` is now ONE rule: no pinned fingerprint. The session binary refuses a pinless connect, so a row without one can offer nothing but a button that fails. The old rule also consulted the advertised policy for unsaved hosts, which made the same box read differently before and after being saved. PINNED CARDS render NESTED under their host as `▸ `, not in a section of their own — a card IS a (host, profile) pair, and a row floating free of its host is exactly the "a pinned tile reads as a duplicate host" problem the desktop shells still have. The host's own BOUND profile is deliberately not drawn as a card: it applies silently on the plain row, and showing it twice would suggest the two do different things. This plugin creates, edits and deletes no profile and no card — pin creation belongs where profiles are edited. TRUST SHEET (new, trust.tsx). Request access (default) / Use a PIN instead… / Cancel, in the GTK dialog's order and wording. Request access is not a second ceremony — it saves the host with the fingerprint it ADVERTISED, then launches; the host parks that connect until its operator approves this Deck, admits it, and the stream starts by itself. No fingerprint, no request access. A host typed in by address advertises none, so the sheet offers the PIN path only and says why, rather than showing a button that could only fail. The sheet never TOFUs past a missing fingerprint: that pin is the only thing standing between a 185 s wait and an impostor answering for the host. The sheet is a `showModal` portal, so it captures its callbacks once and never re-renders from panel state — everything it acts on later is read through a ref. Reading a captured value is precisely what made pinning a second game compute from a stale base and clobber the first. LAUNCH PATH. The wrapper's contract becomes PF_REF / PF_PROFILE / PF_REQUEST_ACCESS / PF_BROWSE; PF_HOST, PF_LAUNCH, PF_MGMT and PF_CONNECT_TIMEOUT are gone. A stream is now `punktfunk launch [--profile ] --exec --fullscreen`, and a reference is all that ever rides Steam's launch options — no resolution, bitrate or codec, the same rule the deep-link grammar enforces. Request-access launches run SUPERVISED, without `--exec`: under --exec the CLI becomes the session, so no process survives to see the stream come up and record the approval. Safe for gamescope because focus follows reaper's descendant tree, not a single process, and flatpak-run/bwrap already sit in that tree on every other path. Wake-on-LAN comes out entirely. The plugin used to fire a magic packet itself and then stretch the connect budget to 75 s to cover the host's resume — a workaround for the CLI-less era. `punktfunk launch` runs the real wake-and-wait loop and only dials once the host answers, which is strictly better and deletes a backend method, a frontend call and a shell branch. The console-home branch of the wrapper is untouched on purpose: the shell binary already execs the session for `--browse`, so there is nothing to repoint and no reason to spend a diff there. Everything else in steam.ts — two shortcuts sharing one name (and so one Steam Input configset key), artwork versioning, appId verification, controller config, stopStream — is unchanged. --- clients/decky/bin/punktfunkrun.sh | 109 +++---- clients/decky/src/backend.ts | 310 ++++++------------ clients/decky/src/hooks.ts | 508 ++++++++++-------------------- clients/decky/src/index.tsx | 257 ++++++++------- clients/decky/src/pair.tsx | 30 +- clients/decky/src/steam.ts | 94 +++--- clients/decky/src/trust.tsx | 140 ++++++++ 7 files changed, 656 insertions(+), 792 deletions(-) create mode 100644 clients/decky/src/trust.tsx diff --git a/clients/decky/bin/punktfunkrun.sh b/clients/decky/bin/punktfunkrun.sh index 3154b04d..349eda41 100755 --- a/clients/decky/bin/punktfunkrun.sh +++ b/clients/decky/bin/punktfunkrun.sh @@ -1,33 +1,32 @@ #!/usr/bin/env bash -# punktfunk stream runner — the target of the hidden non-Steam shortcut the plugin creates. +# punktfunk stream runner — the target of the non-Steam shortcuts the plugin creates. # # WHY A WRAPPER SCRIPT (load-bearing, from MoonDeck's hard-won knowledge): the stream client # must be a descendant of the process Steam launches via `reaper`, or gamescope never gives # its window focus/fullscreen in Gaming Mode (gamescope detects the "current app" by AppID, # which only attaches to reaper's descendants — see gamescope#484). So the Decky plugin -# launches THIS script through SteamClient.Apps.RunGame; the script then execs the flatpak -# client, which inherits the shortcut's AppID and is focused. Launching the flatpak directly -# from the (root) Decky backend produces an unfocused, invisible window. +# launches THIS script through SteamClient.Apps.RunGame; the script then runs the client, +# which inherits the shortcut's AppID and is focused. Launching the client directly from the +# (root) Decky backend produces an unfocused, invisible window. # # Per-session parameters arrive as environment variables, set as the shortcut's Steam launch # options by the plugin (SteamClient.Apps.SetAppLaunchOptions), so ONE generic shortcut serves -# every host (and every pinned game): -# PF_HOST host[:port] to connect to (required for streaming; optional for browse) -# PF_LAUNCH library id to launch on connect (optional, e.g. steam:570 — pinned games) -# PF_BROWSE non-empty = open the gamepad library (optional; --browse instead of --connect) -# PF_MGMT management-API port for --browse (optional; client defaults to 47990) -# PF_CONNECT_TIMEOUT connect budget in seconds (optional; the plugin stretches it after -# firing Wake-on-LAN so the connect survives the host's resume) -# PF_APPID flatpak app id (default io.unom.Punktfunk) -# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH) +# every host: +# PF_REF host reference — a saved host's stable id, or addr[:port] (required to stream) +# PF_PROFILE settings-profile id for a pinned card (optional) +# PF_REQUEST_ACCESS non-empty = ask the host's operator to admit this device instead of +# pairing with a PIN. The connect PARKS until somebody approves it. +# PF_BROWSE non-empty = open the client's console home instead of streaming +# PF_APPID flatpak app id (default io.unom.Punktfunk) +# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH) # PF_CLIENT_BIN absolute path of a NATIVE client (optional; set by the plugin when it -# resolved a non-flatpak install — then the client is exec'd directly and +# resolved a non-flatpak install — then the client is run directly and # PF_APPID/PF_FLATPAK are unused) # -# Values are plain tokens (the plugin validates launch ids to space/quote-free ASCII before -# they ever reach Steam launch options). An older flatpak without --launch/--browse ignores -# the unknown flags harmlessly (hand-scanned argv): PF_LAUNCH degrades to the plain desktop -# session, PF_BROWSE to the client's hosts page. +# A REFERENCE, NEVER A VALUE. Host refs and profile ids are the only things that ride this +# channel; no resolution, bitrate or codec ever does. The client resolves both against its own +# stores, which is what keeps a Steam launch option from becoming a second settings surface. +# The plugin validates them to space/quote-free ASCII before they reach Steam's tokenizer. # # Runs as the `deck` user (Steam launched it), so the --user flatpak install is visible and # WAYLAND_DISPLAY / XDG_RUNTIME_DIR are already correct for gamescope. @@ -42,13 +41,22 @@ APPID="${PF_APPID:-io.unom.Punktfunk}" FLATPAK="${PF_FLATPAK:-flatpak}" # The client is not always the flatpak: a sysext, a .deb/.rpm, an AUR build or a nix profile -# installs a native `punktfunk-client`, and the plugin passes its absolute path here when that -# is what it resolved. Both kinds take the same argv and share ~/.config/punktfunk, so the only -# difference is the prefix in front of it. +# installs a native `punktfunk-client` with the CLI as its sibling, and the plugin passes the +# client's absolute path here when that is what it resolved. # -# exec so the client IS the game process — when it exits, Steam ends the "game" and Gaming Mode -# reclaims focus automatically (no manual refocus needed). -run_client() { +# run_cli execs the HEADLESS CLI (`punktfunk`); run_session execs the GTK/console shell +# (`punktfunk-client`). Both live in the same place in both install kinds — /app/bin inside the +# flatpak, reachable with `--command=`, and one bindir natively. +run_cli() { + if [ -n "${PF_CLIENT_BIN:-}" ]; then + # `${VAR%/*}` rather than `dirname`: pure parameter expansion, so this works with no + # PATH at all — which is the environment a Steam launch option can leave us in. + exec "${PF_CLIENT_BIN%/*}/punktfunk" "$@" + fi + exec "$FLATPAK" run --arch=x86_64 --command=punktfunk "$APPID" "$@" +} + +run_session() { if [ -n "${PF_CLIENT_BIN:-}" ]; then exec "$PF_CLIENT_BIN" "$@" fi @@ -58,40 +66,35 @@ run_client() { # What we are about to run, for the log line each branch prints. CLIENT_LABEL="${PF_CLIENT_BIN:-$APPID}" -# --fullscreen: present the stream chrome-less and fullscreen (the client also auto-detects the -# Deck/gamescope env, and ignores the flag harmlessly on older builds that predate it). +# The console home: the client's own gamepad UI (host picker, pairing, add-host by address, the +# library browser and the full settings screen). UNCHANGED from before this rework — the shell +# binary already execs the session for `--browse`, so there is nothing to repoint here. if [ -n "${PF_BROWSE:-}" ]; then - # The gamepad UI. BARE `--browse` (no PF_HOST) opens the console home — the self-contained - # host picker + pairing + settings, gamepad-navigable — which is what the stateless, visible - # library shortcut launches. `--browse ` opens straight into that host's library (the - # per-host "open on screen" action). A streams a game, session end returns here, B quits. - if [ -z "${PF_HOST:-}" ]; then - echo "punktfunkrun: gamepad UI $CLIENT_LABEL --browse (console home)" >&2 - run_client --browse --fullscreen - fi - echo "punktfunkrun: library $CLIENT_LABEL --browse $PF_HOST" >&2 - if [ -n "${PF_MGMT:-}" ]; then - run_client --browse "$PF_HOST" --mgmt "$PF_MGMT" --fullscreen - fi - run_client --browse "$PF_HOST" --fullscreen + echo "punktfunkrun: gamepad UI $CLIENT_LABEL --browse (console home)" >&2 + run_session --browse --fullscreen fi -# Streaming modes need a host (browse above is the only host-less path). -if [ -z "${PF_HOST:-}" ]; then - echo "punktfunkrun: PF_HOST is not set (the plugin sets it as a launch option)" >&2 +if [ -z "${PF_REF:-}" ]; then + echo "punktfunkrun: PF_REF is not set (the plugin sets it as a launch option)" >&2 exit 2 fi -# Trailing args shared by both streaming execs. A stretched connect budget rides along when the -# plugin set one (it just fired Wake-on-LAN, so the host may still be resuming); an older flatpak -# without --connect-timeout ignores the flag harmlessly (hand-scanned argv). + set -- --fullscreen -if [ -n "${PF_CONNECT_TIMEOUT:-}" ]; then - set -- --connect-timeout "$PF_CONNECT_TIMEOUT" "$@" +if [ -n "${PF_PROFILE:-}" ]; then + set -- --profile "$PF_PROFILE" "$@" fi -if [ -n "${PF_LAUNCH:-}" ]; then - # A pinned game: the id rides the session Hello and the host launches that title. - echo "punktfunkrun: streaming $CLIENT_LABEL --connect $PF_HOST --launch $PF_LAUNCH" >&2 - run_client --connect "$PF_HOST" --launch "$PF_LAUNCH" "$@" + +# REQUEST ACCESS RUNS SUPERVISED — no `--exec`. Under --exec the CLI BECOMES the session, so no +# process survives to see the stream come up and record the host as paired; the CLI refuses the +# combination outright rather than downgrading silently. This is safe for gamescope because +# focus follows reaper's DESCENDANT TREE, not a single process, and `flatpak run`/`bwrap` +# already sit between reaper and the client on every other path. +if [ -n "${PF_REQUEST_ACCESS:-}" ]; then + echo "punktfunkrun: request access $CLIENT_LABEL launch $PF_REF (waiting for approval)" >&2 + run_cli launch "$PF_REF" --request-access "$@" fi -echo "punktfunkrun: streaming $CLIENT_LABEL --connect $PF_HOST" >&2 -run_client --connect "$PF_HOST" "$@" + +# The ordinary stream. `--exec` is the documented gamescope-wrapper mode: the CLI becomes the +# session, so the process tree stays flat and Steam's "game" ends exactly when the stream does. +echo "punktfunkrun: streaming $CLIENT_LABEL launch $PF_REF" >&2 +run_cli launch "$PF_REF" --exec "$@" diff --git a/clients/decky/src/backend.ts b/clients/decky/src/backend.ts index d7d21f24..db65398b 100644 --- a/clients/decky/src/backend.ts +++ b/clients/decky/src/backend.ts @@ -1,95 +1,94 @@ // Bridge to the Python backend (main.py) + shared types. +// +// Every call here is a thin shell over the headless `punktfunk` CLI, so these types are the +// CLI's JSON shapes rather than anything this plugin invents. That is deliberate: the plugin +// used to model the client's stores itself and drifted from them with every field the client +// added. + import { callable } from "@decky/api"; -export interface Host { - name: string; - host: string; - port: number; - pair: string; // "required" | "optional" — the HOST's policy - fp: string; // host cert SHA-256 fingerprint (lowercase hex) from the mDNS advert - proto: string; // advertised protocol, e.g. "punktfunk/1" - paired: boolean; // whether THIS device has already PIN-paired this host (by fingerprint) - id: string; // the host's stable instance id (mDNS TXT `id`; "" when not advertised) - mgmt: number; // management-API port (mDNS TXT `mgmt`; 0 = not advertised → default 47990) - os: string; // OS-identity chain (mDNS TXT `os`, e.g. "linux/fedora/bazzite"); "" on older hosts -} - -// One title from a host's game library (the flatpak client's --library TSV, parsed by the -// backend). `id` is store-qualified (steam: / custom:) and doubles as the -// launch handle (PF_LAUNCH → the session Hello). -export interface GameEntry { +/** A settings profile as the CLI resolves it — ids are dangling-checked and names attached. */ +export interface Profile { id: string; - store: string; // "steam" | "custom" | "heroic" | "lutris" | … - title: string; + name: string; } -export interface LibraryResult { - ok: boolean; - games?: GameEntry[]; - // "flatpak-not-found" | "timeout" | "not-paired" | "pin-mismatch" | "unreachable" | - // "http" | "client-outdated" | "client-error" - error?: string; - detail?: string; // the client's own one-line reason, for the generic error copy -} - -// A pinned game — a one-tap stream row in the QAM. The host is identified primarily by -// cert fingerprint (survives IP changes; pairing is fp-keyed too), with the stored -// address as the launch fallback when the host isn't currently advertising. -export interface PinnedGame { - game_id: string; - title: string; - store: string; - host_fp: string; - host_id: string; - host_name: string; - host: string; - port: number; - mgmt: number; - added_at: number; // unix seconds - paired?: boolean; // annotated by get_pins from the client's known-hosts store -} - -export interface PairResult { - ok: boolean; - fp?: string; - error?: string; -} - -// A host in the SHARED saved-hosts store (client-known-hosts.json) — the same file the desktop -// client reads/writes, so add/rename/pair in either surface shows up in both. `online` comes -// from a mDNS-INDEPENDENT reachability probe (a Tailscale/VPN host isn't shown offline just -// because it doesn't advertise); `null` means reachability is unknown (probe skipped or a client -// too old for `--list-hosts`, which then also can't probe). -export interface SavedHost { +/** + * A host answering on mDNS right now (`punktfunk discover --json`). + * + * `saved`/`paired` are annotated BY THE CLI against the saved-hosts store — fingerprint first, + * address second. The plugin does not join the two lists itself; that rule living in one place + * is what stops this surface disagreeing with the desktop client about the same box. + */ +export interface DiscoveredHost { name: string; addr: string; port: number; - fp_hex: string; // host cert fingerprint (lowercase hex); "" for a not-yet-paired manual entry + fp: string; // advertised cert fingerprint (lowercase hex); "" when not advertised + pair: string; // the HOST's policy: "required" | "optional" + id: string; // the host's advertised stable id; "" when not advertised + mgmt: number; // management-API port; 0 = not advertised + os: string; // OS-identity chain, e.g. "linux/fedora/bazzite"; "" on older hosts + saved: boolean; + paired: boolean; +} + +/** + * A host in the shared saved-hosts store (`punktfunk hosts list --probe --json`) — the same + * `client-known-hosts.json` the desktop client owns. + * + * `online` comes from a mDNS-INDEPENDENT probe, so a host reached over Tailscale/VPN is not + * shown offline merely because it never advertises; `null` means the probe was skipped. + * + * `profile` is the host's DEFAULT binding, which a plain connect applies silently. It is not + * the same thing as `pinned_profiles`, which are the cards a user chose to surface. Both come + * back already resolved against the profile catalog, so this plugin never opens it. + */ +export interface SavedHost { + id: string | null; // the record's stable id — the reference a launch should use + name: string; + addr: string; + port: number; + fp_hex: string; // "" for a placeholder saved by address with no pin yet paired: boolean; mac: string[]; - // OS-identity chain learned by the desktop client; optional because the installed - // flatpak client may predate the field. - os?: string; + os: string; last_used: number | null; + clipboard_sync: boolean; + profile: Profile | null; + pinned_profiles: Profile[]; online: boolean | null; } -export interface HostsResult { - ok: boolean; - hosts: SavedHost[]; - probed: boolean; - fallback?: boolean; // true when read straight off disk (client too old for --list-hosts) -} - -// The result of a host-store mutation (add/edit/forget). `error` is a stable code: -// "client-unavailable" (flatpak missing) | "client-outdated" (client predates the mode) | -// "unreachable"/"http"/… (from the client) | "client-error" (generic; see `detail`). -export interface MutationResult { +/** + * Every backend call answers in this shape. `error` is a stable code, never prose: + * + * - `client-unavailable` — no client is installed, or the call never ran + * - `client-outdated` — the installed client predates the verb (exit 5 + `unknown command`) + * - `unreachable` — the host did not answer + * - `refused` — trust rejected: a wrong PIN, or a fingerprint that already differs + * - `needs-pairing` — the CLI refused because it needs a person + * - `unresolved` — nothing matched what was named + * - `client-error` — anything else; `detail` carries the CLI's own last line + */ +export interface CliResult { ok: boolean; error?: string; detail?: string; } +export interface DiscoverResult extends CliResult { + hosts?: DiscoveredHost[]; +} + +export interface HostsResult extends CliResult { + hosts?: SavedHost[]; +} + +export interface PairResult extends CliResult { + fp?: string; +} + export interface RunnerInfo { runner: string; // absolute path to bin/punktfunkrun.sh app_id: string; // flatpak app id @@ -101,99 +100,6 @@ export interface RunnerInfo { client_bin?: string; } -// The flatpak client's settings JSON — the SAME `client-gtk-settings.json` the desktop client -// and the console's settings screen own, so a value changed in any of them shows in the others. -// -// Every field the client's `Settings` struct persists is modelled here EXCEPT the ones that -// cannot be answered from a plugin backend or aren't settings at all: -// • `forward_pad` — which physical pad is player 1. Needs SDL's live device list, which only -// the client process has; there is no CLI that enumerates pads. -// • `last_window_w/h` — the session's remembered window size, written BY the client, not a -// preference anyone sets. -// Both round-trip untouched: get_settings returns the whole parsed file, patches are object -// spreads, and set_settings merges onto what's on disk. -// -// Optional (`?`) marks a key the client writes with a serde `default`, so a store written before -// that key existed simply lacks it. Read those through the same fallback the client uses — -// `?? true` for the default-on ones, never `!!` — or a pre-existing file reads as "off" here -// while the stream runs with it on. -export interface StreamSettings { - // ---- Stream mode ---- - width: number; // 0 = native - height: number; // 0 = native - refresh_hz: number; // 0 = native - render_scale?: number; // render-resolution multiplier; 1.0 = native (absent in pre-scale files) - bitrate_kbps: number; // 0 = host default - compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope" - // Stream mode follows the session window instead of width/height, renegotiating on resize. - // Overrides width/height while on; degenerates to the display's native mode on fullscreen. - match_window?: boolean; - - // ---- Video ---- - codec?: string; // "auto" | "hevc" | "h264" | "av1" | "pyrowave" (absent in pre-codec files) - decoder?: string; // "auto" | "vulkan" | "vaapi" | "software" - hdr_enabled?: boolean; // default ON — advertise 10-bit/HDR10 - enable_444?: boolean; // default off — ask for full chroma - adapter?: string; // decode/present GPU by marketing name; "" = automatic - - // ---- Presentation ---- - // What the client optimises for when a decoded frame is ready: "latency" | "smooth". Shared - // with the Apple and Android clients under this name, so one profile reads the same everywhere. - present_priority?: string; - smooth_buffer?: number; // frames held back under "smooth"; 0 = Automatic (resolves to 2), else 1–3 - vsync?: boolean; // default ON — tear-free; off asks for a tearing present mode (best-effort) - allow_vrr?: boolean; // default ON — let a VRR panel refresh in step with the stream - - // ---- Audio ---- - audio_channels?: number; // 2 (stereo) | 6 (5.1) | 8 (7.1) - speaker_device?: string; // PipeWire node.name for playback; "" = system default - mic_enabled: boolean; - mic_device?: string; // PipeWire node.name for capture; "" = system default - echo_cancel?: boolean; // default ON; only meaningful while mic_enabled - - // ---- Controllers ---- - gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck" - // Forward this device's controllers at all. Absent in pre-forwarding files, where the - // client's own serde default (true) applies — so `?? true` at every read, never `!!`. - gamepad_forwarding?: boolean; - - // ---- Touchscreen, mouse & keyboard ---- - touch_mode?: string; // "trackpad" | "pointer" | "touch" - mouse_mode?: string; // "capture" | "desktop" - invert_scroll?: boolean; - // Whether the session grabs the keyboard so Alt+Tab/Super reach the host. - inhibit_shortcuts: boolean; - - // ---- Interface & behaviour ---- - // Stats-overlay tier: "off" | "compact" | "normal" | "detailed". Absent in a pre-tier file, - // which resolves through `show_stats` — read both the way the client's - // `Settings::stats_verbosity` does, and write both the way `set_stats_verbosity` does. - stats_verbosity?: string; - // The legacy on/off the tier supersedes; kept written in sync so a client that predates the - // tiers still honours an Off chosen here. - show_stats?: boolean; - fullscreen_on_stream?: boolean; - auto_wake?: boolean; // default ON — Wake-on-LAN a sleeping host before connecting - library_enabled?: boolean; // the CLIENT's own library browser (this plugin has its own) -} - -// One audio endpoint from the client's enumeration: the stable id that gets stored, plus the -// human name to show. -export interface AudioDevice { - name: string; // PipeWire node.name — what `speaker_device` / `mic_device` store - description: string; // human label ("Steam Deck Speakers") -} - -// What the device pickers need, read from the session binary (`--list-adapters` / `--list-audio`). -// `ok: false` = the session binary couldn't be run or failed; every list is then empty and the -// pickers stay on their stored value rather than pretending the device is gone. -export interface DeviceLists { - ok: boolean; - adapters: string[]; // Vulkan physical devices, discrete first - sinks: AudioDevice[]; // playback endpoints - sources: AudioDevice[]; // capture endpoints -} - export interface UpdateInfo { current: string; // installed PLUGIN version (package.json) latest: string; // newest plugin version in our registry for this channel @@ -229,21 +135,30 @@ export interface ShortcutArt { icon_path: string; } -export const discover = callable<[], Host[]>("discover"); +// ---- The four CLI shells -------------------------------------------------------------- + +/** Browse the LAN over mDNS. Bounded by the CLI (3 s) plus a cold-start allowance. */ +export const discover = callable<[], DiscoverResult>("discover"); +/** The saved hosts, probed for reachability, with profiles and pinned cards resolved. */ +export const hosts = callable<[], HostsResult>("hosts"); +/** The PIN ceremony. `refused` = wrong PIN or a host that isn't armed. */ export const pair = callable< - [host: string, port: number, pin: string, name: string], + [addr: string, port: number, pin: string, name: string], PairResult >("pair"); -// Fetch a paired host's game library (headless flatpak --library; can take seconds on a -// cold client start — show a spinner). Pass fp whenever known so the pin can't degrade. -export const library = callable< - [host: string, mgmt_port: number, fp: string], - LibraryResult ->("library"); -export const getPins = callable<[], { pins: PinnedGame[] }>("get_pins"); -export const setPins = callable<[pins: PinnedGame[]], { ok: boolean; error?: string }>( - "set_pins", -); +/** + * Step 1 of request access: save the host with its ADVERTISED fingerprint, pinned but unpaired. + * The launch that follows pins the same fingerprint, which is the only thing standing between a + * 185 s wait for approval and an impostor answering for the host. Idempotent; a host already + * saved under a DIFFERENT fingerprint comes back `refused` rather than being overwritten. + */ +export const trustHost = callable< + [addr: string, port: number, fp: string, name: string], + CliResult +>("trust_host"); + +// ---- Steam / plugin business (only a Decky plugin can do these) ------------------------ + export const runnerInfo = callable<[], RunnerInfo>("runner_info"); export const shortcutArt = callable<[], ShortcutArt>("shortcut_art"); // Install the Steam Input layout (native touchscreen `ts_n` + gamepad passthrough) and point our @@ -254,48 +169,7 @@ export const applyControllerConfig = callable< [name: string], { ok: boolean; applied?: string[]; errors?: string[]; accounts?: number; error?: string; detail?: string } >("apply_controller_config"); -export const getSettings = callable<[], StreamSettings>("get_settings"); -export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>( - "set_settings", -); -// GPUs + audio endpoints for the device pickers. Costs a subprocess that initialises Vulkan and -// PipeWire, so it is called ONCE when the settings tab mounts and never on the launch path. -export const listDevices = callable<[], DeviceLists>("list_devices"); -// The same, bypassing the backend's cache — for the user who just plugged in a headset. -export const refreshDevices = callable<[], DeviceLists>("refresh_devices"); export const killStream = callable<[], { ok: boolean }>("kill_stream"); -// Send a Wake-on-LAN magic packet to a saved host (headless flatpak --wake) so a sleeping host is -// up by the time the stream connects. The MAC is looked up from the flatpak client's own -// known-hosts store; `ok: false` (no-op) when none has been learned yet. Fire before launching. -export const wake = callable<[host: string, port: number], { ok: boolean; error?: string }>( - "wake", -); -// ---- Shared saved-hosts store (the SAME client-known-hosts.json the desktop client owns) ---- -// The saved hosts, each annotated with a live (mDNS-independent) `online` probe when `probe` is -// true. Falls back to a direct JSON read (no reachability) on a client too old for --list-hosts. -export const listHosts = callable<[probe: boolean], HostsResult>("list_hosts"); -// Save a host by address (survives mDNS-blind networks). `fp` empty = unpaired placeholder to -// pair next; a later pair replaces it with the fingerprinted entry. -export const addHost = callable<[target: string, name: string, fp: string], MutationResult>( - "add_host", -); -// Rename and/or re-point a saved host. `selector` = its fingerprint (survives IP change) or -// current addr[:port]; empty fields are left untouched. -export const editHost = callable< - [selector: string, name: string, addr: string, port: number], - MutationResult ->("edit_host"); -// Remove a saved host by fingerprint or addr[:port] (idempotent). -export const forgetHost = callable<[selector: string], MutationResult>("forget_host"); -// Reset this device's Punktfunk state (saved hosts + stream settings + pins); KEEPS the client -// identity so the box isn't seen as new everywhere (re-pairing re-adds hosts). -export const resetConfig = callable<[], { ok: boolean; error?: string }>("reset_config"); -// Reachability of one host[:port] via the client's mDNS-independent QUIC probe (a "test address" -// check). `{ ok: true, online }` when determined, else `{ ok: false, error }`. -export const probeHost = callable< - [target: string], - { ok: boolean; online?: boolean; error?: string } ->("probe_host"); export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update"); // Update the client by whichever route its install supports: `flatpak update --user` for the // flatpak, `punktfunk-client --apply-update` (the packaged root helper) for a one-tap-capable diff --git a/clients/decky/src/hooks.ts b/clients/decky/src/hooks.ts index 75e9501a..f0735487 100644 --- a/clients/decky/src/hooks.ts +++ b/clients/decky/src/hooks.ts @@ -1,18 +1,14 @@ -// Shared state hooks + user actions for the QAM panel and the fullscreen page. +// Shared state hooks + user actions for the QAM panel. import { toaster } from "@decky/api"; import { Navigation } from "@decky/ui"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { checkUpdate, discover, - GameEntry, - getPins, - Host, - listHosts, - PinnedGame, - resetConfig, + DiscoveredHost, + hosts as listHosts, + Profile, SavedHost, - setPins as setPinsBackend, updateClient, UpdateInfo, } from "./backend"; @@ -37,19 +33,158 @@ declare global { // PluginInstallType.UPDATE in decky-loader's browser.py (INSTALL=0/REINSTALL=1/UPDATE=2/…). const INSTALL_TYPE_UPDATE = 2; +/** + * How far this device has got with a host. The three states are what the row says under the + * name, and which of them a host is in decides whether pressing it streams or opens the trust + * sheet. + * + * - `paired` — the host approved this device (a PIN ceremony, or request access). + * - `trusted` — its fingerprint is pinned but nobody has approved us yet. Streams work if + * the host's policy is `optional`; under `required` the connect parks. + * - `needs-access` — no pinned fingerprint. Not streamable until the trust sheet runs. + */ +export type TrustState = "paired" | "trusted" | "needs-access"; + +/** + * One host as the panel shows it — the union of the saved store and the live mDNS browse. + * + * A saved host is ONLINE when it either advertises or answers the reachability probe, so a box + * reached over Tailscale/VPN stops reading as offline. Discovered hosts that aren't saved are + * appended as extra rows. + */ +export interface HostView { + name: string; + addr: string; + port: number; + /** Pinned cert fingerprint. "" = nothing pinned, which is what makes a host unstreamable. */ + fp: string; + paired: boolean; + online: boolean; + saved: boolean; + /** The advert's policy ("required"|"optional"); "" when the host isn't advertising. */ + pairPolicy: string; + /** OS-identity chain (live advert preferred, else the stored one); "" unknown. */ + os: string; + /** + * What a launch should NAME this host by: the record's stable id, which survives renames and + * DHCP moves, falling back to `addr:port` for a row that has no record yet (a discovered host + * the trust sheet is about to save, or a client too old to have minted ids). + */ + ref: string; + /** The host's default profile binding — applied silently by a plain connect, not a card. */ + profile: Profile | null; + /** The cards to render nested under this host; already resolved against the catalog. */ + pinnedProfiles: Profile[]; + lastUsed: number | null; +} + +export function trustState(v: HostView): TrustState { + if (v.paired) return "paired"; + return v.fp ? "trusted" : "needs-access"; +} + +/** + * Must this host go through the trust sheet before it can stream? + * + * A pinned fingerprint is the ONLY rule. The session binary refuses a pinless connect, so a row + * without one can offer nothing but a button that fails; with one, the connect is verified and + * the host either admits it or parks it for an operator. The old rule also consulted the + * advertised policy for unsaved hosts, which made the answer depend on which of two lists a row + * came from — the same box could read differently before and after being saved. + */ +export function needsPair(v: HostView): boolean { + return v.fp === ""; +} + +function advertMatchesSaved(a: DiscoveredHost, s: SavedHost): boolean { + return ( + (!!s.fp_hex && !!a.fp && s.fp_hex.toLowerCase() === a.fp.toLowerCase()) || + (s.addr === a.addr && s.port === a.port) + ); +} + +/** + * Join the saved store and the live browse into the rows the panel draws. + * + * Fingerprint first, address second — a host that moved DHCP lease still matches its record, + * and a different box that inherited the old address does not inherit its pairing. The CLI's + * `discover` annotates `saved`/`paired` by exactly this rule too, so the two can't disagree. + */ +export function mergeHosts(saved: SavedHost[], discovered: DiscoveredHost[]): HostView[] { + const views: HostView[] = saved.map((s) => { + // Prefer a live advert's address: the host may have moved since it was last saved. + const advert = discovered.find((a) => advertMatchesSaved(a, s)); + return { + name: s.name || s.addr, + addr: advert?.addr ?? s.addr, + port: advert?.port ?? s.port, + fp: s.fp_hex || advert?.fp || "", + paired: s.paired, + online: !!advert || s.online === true, + saved: true, + pairPolicy: advert?.pair ?? "", + os: advert?.os || s.os || "", + ref: s.id || `${advert?.addr ?? s.addr}:${advert?.port ?? s.port}`, + profile: s.profile, + pinnedProfiles: s.pinned_profiles ?? [], + lastUsed: s.last_used, + }; + }); + for (const a of discovered) { + if (saved.some((s) => advertMatchesSaved(a, s))) { + continue; // already rendered as its saved row, with a live pip + } + views.push({ + name: a.name, + addr: a.addr, + port: a.port, + fp: a.fp, + paired: a.paired, + online: true, + saved: false, + pairPolicy: a.pair, + os: a.os, + ref: `${a.addr}:${a.port}`, + profile: null, + pinnedProfiles: [], + lastUsed: null, + }); + } + return views.sort(sortRows); +} + +/** + * Online first, then most recently used, then by name. The host you streamed last night should + * be the first thing under your thumb; a host that is off right now should never be. + */ +function sortRows(a: HostView, b: HostView): number { + if (a.online !== b.online) return a.online ? -1 : 1; + if ((a.lastUsed ?? 0) !== (b.lastUsed ?? 0)) return (b.lastUsed ?? 0) - (a.lastUsed ?? 0); + return a.name.localeCompare(b.name); +} + // ---------------------------------------------------------------------------------------- -// Discovery — mDNS scan state shared by the QAM panel and the full page. +// Hosts — ONE call site for both lists. They were separate hooks when the plugin had two +// views mounting them independently; the panel is the only view now, and merging them means +// the "scanning" state covers the whole row set rather than half of it flickering in first. // ---------------------------------------------------------------------------------------- export function useHosts() { - const [hosts, setHosts] = useState([]); + const [views, setViews] = useState([]); const [scanning, setScanning] = useState(false); + // A client too old for `punktfunk discover`. Rendered as one explanatory row plus the update + // button that fixes it — never as an empty list, which would read as "no hosts on your LAN". + const [outdated, setOutdated] = useState(false); const refresh = useCallback(async () => { setScanning(true); try { - setHosts(await discover()); + // Both in flight at once: the browse is time-bounded and the probe is network-bound, so + // running them in sequence would cost the sum of two waits for no benefit. + const [d, s] = await Promise.all([discover(), listHosts()]); + setOutdated(d.error === "client-outdated" || s.error === "client-outdated"); + setViews(mergeHosts(s.hosts ?? [], d.hosts ?? [])); } catch (e) { - toaster.toast({ title: "Punktfunk", body: `Discovery failed: ${e}` }); + toaster.toast({ title: "Punktfunk", body: `Couldn't list hosts: ${e}` }); } finally { setScanning(false); } @@ -59,157 +194,7 @@ export function useHosts() { void refresh(); }, [refresh]); - return { hosts, scanning, refresh }; -} - -// ---------------------------------------------------------------------------------------- -// Saved hosts — the SHARED known-hosts store (client-known-hosts.json), the same file the -// desktop client reads/writes. Fetched WITH a reachability probe so a host reached over a -// routed network (Tailscale/VPN) reports online without ever appearing on mDNS. -// ---------------------------------------------------------------------------------------- -export function useSavedHosts() { - const [saved, setSaved] = useState([]); - const [loading, setLoading] = useState(false); - - const refresh = useCallback(async () => { - setLoading(true); - try { - const r = await listHosts(true); - setSaved(r.hosts ?? []); - } catch { - /* backend unavailable — keep the current view */ - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void refresh(); - }, [refresh]); - - return { saved, loading, refresh }; -} - -/** - * One host as the UI shows it — the union of the saved store and the live mDNS scan. A saved - * host is ONLINE when it either advertises on mDNS OR answers the reachability probe (so - * mDNS-blind-but-reachable hosts stop reading as offline). Discovered hosts not in the store - * are appended as unsaved rows. - */ -export interface HostView { - name: string; - addr: string; - port: number; - fp: string; // "" for a saved-but-unpaired placeholder - paired: boolean; // PIN-paired specifically (a TOFU host has fp but paired=false) - online: boolean; - saved: boolean; // present in the known-hosts store - pairPolicy: string; // the advert's policy ("required"|"optional"), "" when not advertising - mgmt: number; // advertised mgmt-API port (0 = not advertised → default) - id: string; // advertised stable host id ("" when not advertising) - os: string; // OS-identity chain (live advert preferred, else the stored one); "" unknown -} - -function advertMatchesSaved(a: Host, s: SavedHost): boolean { - return ( - (!!s.fp_hex && !!a.fp && s.fp_hex.toLowerCase() === a.fp.toLowerCase()) || - (s.addr === a.host && s.port === a.port) - ); -} - -export function mergeHosts(saved: SavedHost[], discovered: Host[]): HostView[] { - const views: HostView[] = saved.map((s) => { - // Prefer a live advert's address (a host may have moved DHCP leases since it was saved). - const advert = discovered.find((a) => advertMatchesSaved(a, s)); - return { - name: s.name || s.addr, - addr: advert?.host ?? s.addr, - port: advert?.port ?? s.port, - fp: s.fp_hex || advert?.fp || "", - paired: s.paired, - online: !!advert || s.online === true, - saved: true, - pairPolicy: advert?.pair ?? "", - mgmt: advert?.mgmt ?? 0, - id: advert?.id ?? "", - os: advert?.os || s.os || "", - }; - }); - for (const a of discovered) { - if (saved.some((s) => advertMatchesSaved(a, s))) { - continue; // already rendered as its saved card (with a live pip) - } - views.push({ - name: a.name, - addr: a.host, - port: a.port, - fp: a.fp, - paired: a.paired, - online: true, - saved: false, - pairPolicy: a.pair, - mgmt: a.mgmt, - id: a.id, - os: a.os, - }); - } - return views; -} - -/** - * True when this host must be paired before it can stream. A saved host is streamable once it - * has a pinned fingerprint (PIN-paired OR TOFU-trusted); a saved placeholder (no fp yet) must be - * paired. For an unsaved discovered host we keep the advertised-policy rule the UI always used. - */ -export function needsPair(v: HostView): boolean { - return v.saved ? v.fp === "" : v.pairPolicy === "required" && !v.paired; -} - -/** Adapt a merged view back into the `Host` shape the pair/library/stream helpers consume. */ -export function toHost(v: HostView): Host { - return { - name: v.name, - host: v.addr, - port: v.port, - pair: v.pairPolicy || (needsPair(v) ? "required" : "optional"), - fp: v.fp, - proto: "", - paired: v.paired, - id: v.id, - mgmt: v.mgmt, - os: v.os, - }; -} - -/** Is a pinned game's host currently online, considering BOTH the live scan and saved probe? */ -export function pinIsOnline(pin: PinnedGame, views: HostView[]): boolean { - const fp = pin.host_fp.toLowerCase(); - return views.some( - (v) => - v.online && - ((!!fp && v.fp.toLowerCase() === fp) || - (!!pin.host_id && v.id === pin.host_id) || - (v.addr === pin.host && v.port === pin.port)), - ); -} - -/** - * Reset all Punktfunk state (saved hosts + stream settings + pins), keeping the client identity. - * Refreshes whatever views are passed so the UI clears immediately. Ends in a toast. - */ -export async function resetAll(refreshers: Array<() => void | Promise>): Promise { - try { - const r = await resetConfig(); - for (const fn of refreshers) void fn(); - toaster.toast({ - title: "Punktfunk", - body: r.ok - ? "Reset — saved hosts, settings, and pins cleared." - : `Reset failed${r.error ? ` (${r.error})` : ""}.`, - }); - } catch { - toaster.toast({ title: "Punktfunk", body: "Reset failed." }); - } + return { views, scanning, outdated, refresh }; } // ---------------------------------------------------------------------------------------- @@ -260,36 +245,6 @@ export function clientUpdateIsOneTap(info: UpdateInfo | null | undefined): boole ); } -/** - * How the client got onto this box, in words a Deck user recognises. The raw kind comes from - * the client's own detector (`pf_update_check::detect`); anything unmapped falls through as - * itself rather than as "unknown", because the raw word is still more useful than a shrug. - */ -export function clientInstallLabel(kind: string): string { - switch (kind) { - case "flatpak": - return "Flatpak (per-user)"; - case "apt": - return "System package (apt)"; - case "dnf": - return "System package (dnf)"; - case "rpm-ostree": - return "Layered package (rpm-ostree)"; - case "pacman": - return "System package (pacman)"; - case "sysext": - return "System extension (sysext)"; - case "nix": - return "Nix profile"; - case "steamos-source": - return "On-device build"; - case "source": - return "Built from source"; - default: - return kind; - } -} - /** True when the only pending update is one this Deck can't apply itself. */ export function clientUpdateIsManualOnly(info: UpdateInfo | null | undefined): boolean { return !!info && info.client_update_available && !clientUpdateIsOneTap(info); @@ -427,167 +382,26 @@ export async function applyUpdate( } // ---------------------------------------------------------------------------------------- -// Stream launch — via the hidden Steam shortcut (see steam.ts for why). +// Stream launch — via the hidden Steam shortcut (see steam.ts for why it can't be direct). // ---------------------------------------------------------------------------------------- + +/** + * Stream this host. `opts.profileId` streams one of its pinned cards; `opts.requestAccess` + * runs the supervised launch that waits for the host's operator to approve this Deck. + * + * The host is named by REFERENCE (`v.ref`), never by value — no resolution, bitrate or codec + * ever rides the launch path, which is the same rule the deep-link grammar enforces. + */ export async function startStream( - h: Host, + v: HostView, opts: LaunchOpts = {}, label?: string, ): Promise { try { - await launchStream(h.host, h.port, opts); + await launchStream(v.ref, opts); Navigation.CloseSideMenus(); - toaster.toast({ title: "Punktfunk", body: `Starting ${label ?? "stream"} — ${h.name}` }); + toaster.toast({ title: "Punktfunk", body: `Starting ${label ?? "stream"} — ${v.name}` }); } catch (e) { toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` }); } } - -/** Open the GTK client's gamepad library launcher for a host (`--browse` via PF_BROWSE). */ -export async function startBrowse(h: Host): Promise { - try { - await launchStream(h.host, h.port, { browse: true, mgmt: h.mgmt }); - Navigation.CloseSideMenus(); - toaster.toast({ title: "Punktfunk", body: `Opening library — ${h.name}` }); - } catch (e) { - toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` }); - } -} - -// ---------------------------------------------------------------------------------------- -// Pinned games — the QAM's one-tap game rows, persisted by the backend next to the -// client's config (survives plugin reinstalls). -// ---------------------------------------------------------------------------------------- -export interface PinsApi { - pins: PinnedGame[]; - addPin: (h: Host, g: GameEntry) => void; - removePin: (hostFp: string, gameId: string) => void; - isPinned: (hostFp: string, gameId: string) => boolean; - /** Refresh a pin's stored address from a live advert (hosts change IPs). */ - updatePinHost: (pin: PinnedGame, h: Host) => void; - refresh: () => Promise; -} - -export function usePins(): PinsApi { - const [pins, setPins] = useState([]); - // A live mirror of `pins`. The Games picker is mounted by Decky's `showModal` into a - // detached portal that captures this hook's callbacks ONCE and never re-renders with fresh - // props, so a mutator closing over the `pins` array reads a frozen base — pinning a second - // game in the same session would compute from the stale `[]` and clobber the first (silent - // data loss). Reading the ref keeps every mutation based on the current set, and lets the - // callbacks keep a stable identity (deps free of `pins`). - const pinsRef = useRef([]); - pinsRef.current = pins; - - const refresh = useCallback(async () => { - try { - setPins((await getPins()).pins); - } catch { - /* backend unavailable — keep the current view */ - } - }, []); - - useEffect(() => { - void refresh(); - }, [refresh]); - - // Optimistic local state; the backend validates/dedups and is re-read on failure. - const save = useCallback( - (next: PinnedGame[]) => { - pinsRef.current = next; - setPins(next); - setPinsBackend(next).catch(() => void refresh()); - }, - [refresh], - ); - - const addPin = useCallback( - (h: Host, g: GameEntry) => { - const pin: PinnedGame = { - game_id: g.id, - title: g.title, - store: g.store, - host_fp: h.fp, - host_id: h.id, - host_name: h.name, - host: h.host, - port: h.port, - mgmt: h.mgmt, - added_at: Math.floor(Date.now() / 1000), - paired: h.paired, - }; - save([ - ...pinsRef.current.filter( - (p) => !(p.host_fp === pin.host_fp && p.game_id === pin.game_id), - ), - pin, - ]); - }, - [save], - ); - - const removePin = useCallback( - (hostFp: string, gameId: string) => { - save(pinsRef.current.filter((p) => !(p.host_fp === hostFp && p.game_id === gameId))); - }, - [save], - ); - - const isPinned = useCallback( - (hostFp: string, gameId: string) => - pins.some((p) => p.host_fp === hostFp && p.game_id === gameId), - [pins], - ); - - const updatePinHost = useCallback( - (pin: PinnedGame, h: Host) => { - if (pin.host === h.host && pin.port === h.port && pin.mgmt === h.mgmt) { - return; - } - save( - pinsRef.current.map((p) => - p.host_fp === pin.host_fp && p.game_id === pin.game_id - ? { ...p, host: h.host, port: h.port, mgmt: h.mgmt, host_name: h.name } - : p, - ), - ); - }, - [save], - ); - - return { pins, addPin, removePin, isPinned, updatePinHost, refresh }; -} - -/** - * The host a pin should launch against right now: match the live mDNS scan by cert - * fingerprint first (pairing is fp-keyed, survives IP changes), then by the host's stable - * id, else fall back to the stored address (host offline or scan flaky — still launch). - */ -export function resolvePinHost( - pin: PinnedGame, - live: Host[], -): { host: Host; online: boolean } { - const fp = pin.host_fp.toLowerCase(); - const match = - (fp && live.find((h) => h.fp && h.fp.toLowerCase() === fp)) || - (pin.host_id && live.find((h) => h.id && h.id === pin.host_id)) || - undefined; - if (match) { - return { host: match, online: true }; - } - return { - host: { - name: pin.host_name || pin.host, - host: pin.host, - port: pin.port, - pair: pin.paired ? "optional" : "required", - fp: pin.host_fp, - proto: "", - paired: !!pin.paired, - id: pin.host_id, - mgmt: pin.mgmt, - os: "", // pins don't store the chain; the icon is a hosts-tab affordance - }, - online: false, - }; -} diff --git a/clients/decky/src/index.tsx b/clients/decky/src/index.tsx index 9d1c5a2a..6f795da5 100644 --- a/clients/decky/src/index.tsx +++ b/clients/decky/src/index.tsx @@ -1,46 +1,47 @@ -// Plugin entry: the Quick Access Menu panel + route registration. The fullscreen page lives -// in page.tsx; shared hooks/actions in hooks.ts; the Steam-shortcut launch in steam.ts. +// Plugin entry: the Quick Access Menu panel. That is the whole plugin now — the fullscreen +// route, the settings screen, the host editor and the games picker are gone, because the +// client's own console home does all four one shortcut away (and is gamepad-navigable, which +// a QAM panel re-implementing them never quite was). +// +// What is left is what only a Decky plugin can do: start a stream through Steam so gamescope +// focuses it (see steam.ts), and stand in front of the trust decision that gates it. import { ButtonItem, Field, - Navigation, PanelSection, PanelSectionRow, Spinner, showModal, staticClasses, } from "@decky/ui"; -import { definePlugin, routerHook, toaster } from "@decky/api"; +import { definePlugin, toaster } from "@decky/api"; import { FC } from "react"; import { FaDownload, FaLock, - FaLockOpen, FaPlay, FaPlus, + FaStopCircle, FaSyncAlt, FaTv, } from "react-icons/fa"; +import { killStream } from "./backend"; import { PluginErrorBoundary } from "./boundary"; import { applyUpdate, checkForUpdatesNow, clientUpdateIsManualOnly, hasUpdate, - mergeHosts, + HostView, needsPair, - pinIsOnline, startStream, - toHost, + trustState, useHosts, - usePins, - useSavedHosts, useUpdate, } from "./hooks"; -import { streamPin } from "./library"; -import { PunktfunkRoute, ROUTE } from "./page"; -import { PairModal } from "./pair"; -import { ensureGamepadUiShortcut, recreateShortcuts } from "./steam"; +import { OsMark } from "./os-icon"; +import { ensureGamepadUiShortcut, launchGamepadUi, recreateShortcuts, stopStream } from "./steam"; +import { TrustSheet } from "./trust"; // Recovery action for "the Punktfunk library entry vanished" — recreates the visible shortcut. // Deleting the shortcut (optionally + reinstalling the plugin) leaves a stale appId in Steam's @@ -54,22 +55,78 @@ async function recreatePunktfunkShortcut(): Promise { }); } -// ---------------------------------------------------------------------------------------- -// QAM panel — quick status + entry into the full page + one-tap stream for known hosts -// and pinned games. -// ---------------------------------------------------------------------------------------- -const QamPanel: FC = () => { - const { hosts: discovered, scanning, refresh: refreshDiscovered } = useHosts(); - const { saved, loading: loadingSaved, refresh: refreshSaved } = useSavedHosts(); - const { info: update, checking, check } = useUpdate(); - const pins = usePins(); +/** Force-stop a wedged stream: end Steam's "game", then make sure the client itself is gone. */ +async function forceStop(): Promise { + stopStream(); + try { + await killStream(); + } catch { + /* best-effort — the TerminateApp above is usually enough */ + } + toaster.toast({ title: "Punktfunk", body: "Stopped the stream" }); +} - const hosts = mergeHosts(saved, discovered); - const busy = scanning || loadingSaved; - const refresh = () => { - void refreshDiscovered(); - void refreshSaved(); - }; +/** The line under a host's name: where it is, whether it's up, and how far trust has got. */ +function hostDescription(v: HostView): string { + const trust = { + paired: "paired", + trusted: "trusted", + "needs-access": "needs access", + }[trustState(v)]; + return `${v.addr}:${v.port} · ${v.online ? "online" : "offline"} · ${trust}`; +} + +const HostRow: FC<{ host: HostView; refresh: () => void }> = ({ host, refresh }) => { + const gated = needsPair(host); + const stream = (opts: { requestAccess?: boolean } = {}) => void startStream(host, opts); + return ( + <> + + + gated + ? showModal( + , + ) + : stream() + } + label={ + + {gated ? : } + {host.name} + + } + description={hostDescription(host)} + > + {gated ? "Connect…" : "Stream"} + + + {/* Pinned cards, nested under their host rather than in a section of their own: a card + IS a (host, profile) pair, and a row that floats free of its host is the "a pinned + tile reads as a duplicate host" problem the desktop shells still have. The host's + own BOUND profile is deliberately not a card — it applies silently on the plain row + above, and showing it twice would suggest they do different things. */} + {!gated && + host.pinnedProfiles.map((p) => ( + + void startStream(host, { profileId: p.id }, `“${p.name}”`)} + label={`▸ ${p.name}`} + > + + Stream + + + ))} + + ); +}; + +const QamPanel: FC = () => { + const { views, scanning, outdated, refresh } = useHosts(); + const { info: update, checking, check } = useUpdate(); return ( <> @@ -110,15 +167,54 @@ const QamPanel: FC = () => { ))} + + + void refresh()} disabled={scanning}> + {scanning ? ( + + ) : ( + + )} + {scanning ? "Scanning…" : "Refresh"} + + + {/* A client too old for `punktfunk discover` explains itself rather than rendering an + empty list — "no hosts on your LAN" would be a lie, and the button that fixes it is + in this same panel. Saved hosts still list: that path is an older verb. */} + {outdated && ( + + + + )} + {views.length === 0 && scanning && ( + + + + )} + {views.length === 0 && !scanning && ( + + + + )} + {views.map((v) => ( + + ))} + + { - Navigation.Navigate(ROUTE); - Navigation.CloseSideMenus(); - }} + description="Settings, adding a host by address, and browsing a host's games all live here." + onClick={() => void launchGamepadUi()} > Open Punktfunk @@ -126,85 +222,6 @@ const QamPanel: FC = () => { - {/* Pinned games — the "jump straight into Playnite" rows. Pin games from a host's - picker (fullscreen page → host row → games button). */} - {pins.pins.length > 0 && ( - - {pins.pins.map((pin) => { - const online = pinIsOnline(pin, hosts); - return ( - - streamPin(pin, hosts.map(toHost), pins)} - label={pin.title} - description={`${pin.host_name}${online ? "" : " · offline?"}${ - pin.paired ? "" : " · pairing required" - }`} - > - - Stream - - - ); - })} - - )} - - - - - {busy ? ( - - ) : ( - - )} - {busy ? "Scanning…" : "Refresh"} - - - {hosts.length === 0 && busy && ( - - - - )} - {hosts.length === 0 && !busy && ( - - - - )} - {hosts.map((v) => { - const pair = needsPair(v); - const h = toHost(v); - return ( - - - pair - ? showModal( startStream(h)} />) - : startStream(h) - } - label={ - - {pair ? : } - {v.name} - - } - description={`${v.addr}:${v.port} · ${v.online ? "online" : "offline"}${ - pair ? " · pairing required" : v.paired ? " · paired" : "" - }`} - > - {pair ? "Pair & Stream" : "Stream"} - - - ); - })} - - { Recreate library shortcut + + void forceStop()} + > + + Force-stop + + ); }; export default definePlugin(() => { - routerHook.addRoute(ROUTE, PunktfunkRoute, { exact: true }); // Ensure the visible, stateless "Punktfunk" library entry (opens the gamepad UI / console // home) exists and is repointed to the current plugin dir — also installs the native-touch // controller config. Fire-and-forget: cosmetic library upkeep must never block plugin load. @@ -260,8 +286,5 @@ export default definePlugin(() => { ), icon: , - onDismount() { - routerHook.removeRoute(ROUTE); - }, }; }); diff --git a/clients/decky/src/pair.tsx b/clients/decky/src/pair.tsx index db40b1ed..9cdca655 100644 --- a/clients/decky/src/pair.tsx +++ b/clients/decky/src/pair.tsx @@ -3,10 +3,32 @@ import { DialogButton, Focusable, ModalRoot, Spinner } from "@decky/ui"; import { toaster } from "@decky/api"; import { FC, useState } from "react"; -import { Host, pair } from "./backend"; +import { pair } from "./backend"; +import { HostView } from "./hooks"; + +/** + * User-facing copy for a failed ceremony. The CLI's stable exit codes say WHICH failure it was, + * so the keypad can name the fix instead of echoing a log line: `refused` is overwhelmingly a + * mistyped PIN or a host nobody armed, and telling someone to check their network for that + * would send them the wrong way entirely. + */ +function pairErrorBody(error: string | undefined, name: string): string { + switch (error) { + case "refused": + return "Wrong PIN, or the host isn’t showing one. Arm pairing again and retry."; + case "unreachable": + return `Couldn’t reach ${name}.`; + case "client-outdated": + return "Update the Punktfunk client to pair from here."; + case "client-unavailable": + return "Couldn’t reach the Punktfunk client — is it still installed?"; + default: + return "Pairing failed."; + } +} export const PairModal: FC<{ - host: Host; + host: HostView; closeModal?: () => void; onPaired: () => void; }> = ({ host, closeModal, onPaired }) => { @@ -21,13 +43,13 @@ export const PairModal: FC<{ setBusy(true); setError(null); try { - const res = await pair(host.host, host.port, pin, "Steam Deck"); + const res = await pair(host.addr, host.port, pin, "Steam Deck"); if (res.ok) { toaster.toast({ title: "Punktfunk", body: `Paired with ${host.name}` }); onPaired(); closeModal?.(); } else { - setError(res.error ?? "pairing failed"); + setError(pairErrorBody(res.error, host.name)); setPin(""); } } catch (e) { diff --git a/clients/decky/src/steam.ts b/clients/decky/src/steam.ts index 42e2e665..84fe5c77 100644 --- a/clients/decky/src/steam.ts +++ b/clients/decky/src/steam.ts @@ -8,16 +8,16 @@ // // TWO shortcuts, both named "Punktfunk" (so they share ONE Steam Input controller-config key — // see applyControllerConfig): -// • STREAM — hidden, stateful: the per-session launcher. Its launch options carry the host / -// pinned game (PF_HOST/PF_LAUNCH/PF_BROWSE), rewritten per launch, so one shortcut serves -// every host. Driven by the QAM/pins/host-library actions. Hidden — an implementation detail. +// • STREAM — hidden, stateful: the per-session launcher. Its launch options carry the host +// reference and the card's profile (PF_REF/PF_PROFILE/PF_REQUEST_ACCESS), rewritten per +// launch, so one shortcut serves every host. Hidden — an implementation detail. // • GAMEPAD UI — visible, stateless: fixed launch options = bare `--browse` (PF_BROWSE, no // host) → the client's console home (host picker + pairing + settings, gamepad-navigable). // This is the library-visible "Punktfunk" app the user opens directly. // // Both get the shipped artwork and the native-touch controller config. -import { applyControllerConfig, runnerInfo, shortcutArt, wake } from "./backend"; +import { applyControllerConfig, runnerInfo, shortcutArt } from "./backend"; // SteamClient is a Steam-internal global injected into the CEF context; it is not fully typed // by @decky/ui, so declare the surface we use. Signatures verified against MoonDeck + the @@ -319,77 +319,65 @@ export async function launchGamepadUi(): Promise { } } -/** Per-launch extras beyond the host target (all optional — {} is the plain stream). */ +/** Per-launch extras beyond the host reference (all optional — {} is the plain stream). */ export interface LaunchOpts { - /** Library id to launch on connect (a pinned game) — rides PF_LAUNCH → `--launch`. */ - launchId?: string; - /** Open the gamepad library launcher instead of streaming (PF_BROWSE → `--browse`). */ - browse?: boolean; - /** Management-API port for the launcher's library fetch (PF_MGMT; 0/absent = default). */ - mgmt?: number; + /** A pinned card: stream with this settings profile, one-off (PF_PROFILE → `--profile`). */ + profileId?: string; + /** + * Ask the host's operator to admit this Deck rather than typing a PIN (PF_REQUEST_ACCESS). + * The connect PARKS until somebody approves it, and the launch runs SUPERVISED — see the + * wrapper for why `--exec` is dropped on this path alone. + */ + requestAccess?: boolean; } -// Launch ids ride Steam launch options as an env-prefix token (`PF_LAUNCH=`), so they -// must be space/quote-free — Steam's tokenizer and the wrapper's env both break otherwise. -// Real ids are `steam:` / `custom:`, so this rejects nothing in practice; -// it's VALIDATION, never encoding (the host must match the opaque token verbatim). -const UNSAFE_LAUNCH_ID = /["'\\$`\s]/; +// Host refs and profile ids ride Steam launch options as env-prefix tokens (`PF_REF=`), +// so they must be space/quote-free — Steam's tokenizer and the wrapper's env both break +// otherwise. Real values are UUIDs or `addr:port`, so this rejects nothing in practice; it is +// VALIDATION, never encoding (the client must receive the opaque token verbatim). +const UNSAFE_TOKEN = /["'\\$`\s]/; export function isSafeLaunchId(id: string): boolean { return ( id.length > 0 && id.length <= 128 && - UNSAFE_LAUNCH_ID.exec(id) === null && + UNSAFE_TOKEN.exec(id) === null && /^[\x21-\x7e]+$/.test(id) ); } /** - * Launch a stream to `host:port` fullscreen in Gaming Mode (optionally straight into a - * library title, or into a host's gamepad library). Encodes the target into the STREAM - * shortcut's launch options (so one hidden shortcut serves every host and every pinned game), + * Stream `ref` fullscreen in Gaming Mode, optionally with a pinned card's profile. Encodes the + * target into the STREAM shortcut's launch options — one hidden shortcut serves every host — * then RunGame. + * + * No Wake-on-LAN here any more. The plugin used to fire a magic packet itself and then stretch + * the connect budget to 75 s to cover the host's resume, which was a workaround for the era + * before the CLI existed. `punktfunk launch` now runs the real wake-and-wait loop (packet at + * t=0, re-sent every 6 s, presence polled every second) and only dials once the host answers — + * strictly better, and it deletes a backend method, a frontend call and a shell branch. */ -export async function launchStream( - host: string, - port: number, - opts: LaunchOpts = {}, -): Promise { - // Wake-on-LAN: if this host is asleep, nudge it awake before the stream connects. Kicked off now - // so it races with the shortcut setup (near-zero added latency); its outcome is needed below - // (the connect budget), and RunGame follows the await either way, so nothing is slower for it. - // Best-effort — the flatpak client's --wake looks up the host's learned MAC (a no-op if none is - // known), and the connect that follows has its own retry window, so a failure never blocks launch. - const waking = wake(host, port).catch(() => ({ ok: false })); - const [{ appId, runner, clientBin }, woke] = await Promise.all([ensureStreamShortcut(), waking]); - const target = port && port !== 9777 ? `${host}:${port}` : host; - const env = [`PF_HOST=${target}`]; +export async function launchStream(ref: string, opts: LaunchOpts = {}): Promise { + if (!isSafeLaunchId(ref)) { + throw new Error(`unsupported host reference: ${ref}`); + } + if (opts.profileId && !isSafeLaunchId(opts.profileId)) { + throw new Error(`unsupported profile id: ${opts.profileId}`); + } + const { appId, runner, clientBin } = await ensureStreamShortcut(); + const env = [`PF_REF=${ref}`]; // Set only for a NATIVE client install; absent, the wrapper takes its flatpak default, so every // existing Deck install produces byte-identical launch options to before. if (clientBin) { env.push(`PF_CLIENT_BIN=${clientBin}`); } - // A magic packet actually went out (a MAC was known), so the host may be mid-resume from - // suspend — that takes far longer than the client's default 15 s connect budget. Stretch the - // budget so the client's wake-tolerant dial keeps retrying across the resume; against an - // already-awake host the connect still lands in under a second, so this costs nothing. - if (woke.ok) { - env.push("PF_CONNECT_TIMEOUT=75"); + if (opts.profileId) { + env.push(`PF_PROFILE=${opts.profileId}`); } - if (opts.browse) { - env.push("PF_BROWSE=1"); - if (opts.mgmt) { - env.push(`PF_MGMT=${Math.floor(opts.mgmt)}`); - } - } else if (opts.launchId) { - if (!isSafeLaunchId(opts.launchId)) { - // Enforced at pin time too (the picker disables Pin) — this is the backstop. - throw new Error(`unsupported launch id: ${opts.launchId}`); - } - env.push(`PF_LAUNCH=${opts.launchId}`); + if (opts.requestAccess) { + env.push("PF_REQUEST_ACCESS=1"); } // KEY=value ... %command% args — %command% expands to the shortcut exe (/bin/sh); the wrapper - // script rides behind it as an argument and reads PF_* from the environment. The wake was - // awaited above, so the magic packet is out before the connect attempt. + // script rides behind it as an argument and reads PF_* from the environment. SteamClient.Apps.SetAppLaunchOptions(appId, `${env.join(" ")} %command% "${runner}"`); SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100); } diff --git a/clients/decky/src/trust.tsx b/clients/decky/src/trust.tsx new file mode 100644 index 00000000..132b864e --- /dev/null +++ b/clients/decky/src/trust.tsx @@ -0,0 +1,140 @@ +// The trust sheet — the step between "I can see a host" and "I can stream it". +// +// Two ways in, in the order the GTK dialog and the console's pair screen offer them: +// +// • REQUEST ACCESS (default) — no PIN. Save the host with the fingerprint it ADVERTISED, +// then launch. The host parks that connect until its operator approves this Deck in the +// console or web UI, admits it, and the stream starts by itself. It is not a second +// pairing ceremony; it is an ordinary identified connect with a stretched budget, which +// is why it costs no ceremony surface here at all. +// • USE A PIN INSTEAD — the existing gamepad-navigable keypad (pair.tsx). +// +// NO FINGERPRINT, NO REQUEST ACCESS. The parked connect pins the advertised fingerprint, and +// that pin is the only thing standing between a 185 s wait and an impostor answering for the +// host. A host typed in by address advertises nothing, so it gets the PIN path only — and is +// told why, rather than being shown a button that could only fail. Under no circumstances does +// this sheet trust-on-first-use its way past a missing fingerprint. +import { DialogButton, Focusable, ModalRoot, Spinner, showModal } from "@decky/ui"; +import { toaster } from "@decky/api"; +import { FC, useRef, useState } from "react"; +import { trustHost } from "./backend"; +import { HostView } from "./hooks"; +import { PairModal } from "./pair"; + +/** User-facing copy for a `trustHost` failure code. */ +function trustErrorBody(error: string | undefined, name: string): string { + switch (error) { + case "refused": + return `${name} is already saved under a different identity. Forget it in the Punktfunk app before trusting it again.`; + case "client-outdated": + return "Update the Punktfunk client to use request access."; + case "client-unavailable": + return "Couldn’t reach the Punktfunk client — is it still installed?"; + default: + return `Couldn’t save ${name}.`; + } +} + +export const TrustSheet: FC<{ + host: HostView; + closeModal?: () => void; + /** Stream this host, having just been let in. */ + onStream: (opts: { requestAccess?: boolean }) => void; + /** Re-read the host list — the record changed underneath the panel. */ + onChanged: () => void; +}> = ({ host, closeModal, onStream, onChanged }) => { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + // ⚠ This sheet is a `showModal` PORTAL: it captures its callbacks ONCE and never re-renders + // from panel state. Anything it needs to act on later must be read through a ref, not out of + // a captured value — reading a captured array is exactly what made pinning a second game + // compute from a stale base and clobber the first. + const props = useRef({ host, onStream, onChanged }); + props.current = { host, onStream, onChanged }; + + const canRequestAccess = host.fp !== ""; + + const requestAccess = async () => { + setBusy(true); + setError(null); + const { host: h, onStream: stream, onChanged: changed } = props.current; + try { + // Step 1: save it with the ADVERTISED fingerprint, pinned but unpaired ("trusted"). + // Idempotent, so a retry after a declined approval is free. + const r = await trustHost(h.addr, h.port, h.fp, h.name); + if (!r.ok) { + setError(trustErrorBody(r.error, h.name)); + setBusy(false); + return; + } + changed(); + // Step 2: the launch itself waits for the approval. The session's plain connecting screen + // looks identical whether it is parked or hanging, so say what is about to happen BEFORE + // it starts — this toast is a patch over that, and the real fix belongs in the session. + toaster.toast({ + title: "Punktfunk", + body: `Approve this Deck in ${h.name}’s console — the stream starts by itself`, + duration: 10_000, + }); + stream({ requestAccess: true }); + closeModal?.(); + } catch (e) { + setError(String(e)); + setBusy(false); + } + }; + + const usePin = () => { + // Hand off to the keypad. Closing first keeps one modal on screen at a time, which is what + // the gamepad focus model expects. + const { host: h, onStream: stream, onChanged: changed } = props.current; + closeModal?.(); + showModal( + { + changed(); + stream({}); + }} + />, + ); + }; + + return ( + +
+ Connect to {host.name} +
+
+ {canRequestAccess + ? `${host.name} needs to let this device in before it can stream.` + : "No advertised identity for this host — pair with a PIN instead."} +
+ {error && ( +
{error}
+ )} + + + {canRequestAccess && ( + + {busy ? : "Request access"} + + )} + + Use a PIN instead… + + closeModal?.()}> + Cancel + + + + {canRequestAccess && ( +
+ Request access asks {host.name}’s operator to approve this Deck in its console or web + UI. No PIN to type — the stream starts as soon as they do. +
+ )} +
+ ); +}; From 77ddd05b13c425e5f521c01f6f4e9963f3c69f1e Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 20:52:44 +0200 Subject: [PATCH 38/53] fix(core/wire): a truncated trigger datagram stops cancelling the effect it should carry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three wire and ABI faults. An out-of-range pad index reached one rumble consumer and not the other. It skipped the reorder gate — the per-pad seq cursor has no slot for it — and was handed to the legacy queue, while the policy engine discarded it on its own bounds check, so the comment promising both consumers are fed was false for exactly these. An embedder draining the queue could be handed an index it would use to subscript its own per-pad array. The host never emits one, so it is malformed or hostile either way; both consumers now agree by dropping it before either sees it. The adaptive-trigger effect was the only variable-length wire field bounded on neither side. Encode appended whatever it was handed and decode took the whole tail, while its sibling raw-report field had been bounded both ways all along; there is now one constant both sides clamp to. Worse than the missing bound was the empty case: a body with no effect bytes decoded as an EMPTY effect, and downstream an empty block is written as an all-zero trigger report, which is mode 0x00 — release. A truncated datagram could therefore silently cancel the trigger effect a game was holding. That shape is now rejected outright; a genuine release is a full-length zero block and still decodes. The C ABI history had a hole and a symbol nobody versioned. v11 shipped without its line, and the rumble policy engine's C surface was added while the version constant still read 7, with no bump at all — so every core since has exported those symbols while advertising a number that never promised them. A shipped binary says what it says, so that cannot be corrected backwards; v15 instead establishes the floor that guarantees the surface, and the v11 line is written down. No code changed for the bump and nothing moved on the wire. --- .../src/client/pump/datagram_task.rs | 30 +++--- crates/punktfunk-core/src/lib.rs | 14 ++- crates/punktfunk-core/src/quic/datagram.rs | 99 ++++++++++++++++++- 3 files changed, 127 insertions(+), 16 deletions(-) diff --git a/crates/punktfunk-core/src/client/pump/datagram_task.rs b/crates/punktfunk-core/src/client/pump/datagram_task.rs index 59bae19f..75674a05 100644 --- a/crates/punktfunk-core/src/client/pump/datagram_task.rs +++ b/crates/punktfunk-core/src/client/pump/datagram_task.rs @@ -60,22 +60,28 @@ pub(super) async fn run( } Some(&crate::quic::RUMBLE_MAGIC) => { if let Some(u) = crate::quic::decode_rumble_envelope(&d) { + // A pad index the client cannot represent is dropped outright, before either + // consumer sees it. It used to be waved through: the seq gate was skipped (its + // per-pad cursor has no slot for it) and it was handed to the legacy queue, + // while the policy engine silently discarded it on its own bounds check — so + // "both consumers are fed" below was false for exactly these, and an embedder + // draining the queue could be handed an index it would use to subscript its + // own per-pad array. The host never emits one; this is malformed or hostile. + let idx = u.pad as usize; + if idx >= crate::input::MAX_PADS { + continue; + } // Gate v2 envelopes on their per-pad seq; forward v1 (envelope: None) as-is. let fresh = match u.envelope { Some(env) => { - let idx = u.pad as usize; - if idx < crate::input::MAX_PADS { - if crate::input::GamepadSnapshot::seq_newer( - env.seq, - rumble_last_seq[idx], - ) { - rumble_last_seq[idx] = Some(env.seq); - true - } else { - false // reordered/duplicate — drop, keep the newer state - } + if crate::input::GamepadSnapshot::seq_newer( + env.seq, + rumble_last_seq[idx], + ) { + rumble_last_seq[idx] = Some(env.seq); + true } else { - true // out-of-range pad (host never sends these): no gate + false // reordered/duplicate — drop, keep the newer state } } None => true, diff --git a/crates/punktfunk-core/src/lib.rs b/crates/punktfunk-core/src/lib.rs index c482a017..b77d9df8 100644 --- a/crates/punktfunk-core/src/lib.rs +++ b/crates/punktfunk-core/src/lib.rs @@ -107,6 +107,10 @@ pub use stats::Stats; /// v10: added `punktfunk_connection_clock_offset_now_ns` — the LIVE (mid-stream re-synced) /// clock offset ongoing latency math must use; the connect-time getter stays frozen by /// contract. Additive, client-local — no wire change, so [`WIRE_VERSION`] is unchanged. +/// v11: added `punktfunk_connect_ex9` — `connect_ex8` plus a `client_caps` bitfield +/// (`PUNKTFUNK_CLIENT_CAP_CURSOR`, later `…_PHASE_LOCK`), which is how a client tells the host it +/// renders the pointer itself. Additive; the caps ride the existing Hello, so [`WIRE_VERSION`] is +/// unchanged. (Documented late — the bump shipped without its line here.) /// v12: added `punktfunk_connection_set_cursor_render` — the mid-stream cursor-render flip /// (design/remote-desktop-sweep.md §8): the client's mouse-model chord tells the host who /// renders the pointer. Additive; rides the existing control stream (a new message TYPE, which @@ -120,7 +124,15 @@ pub use stats::Stats; /// uncertainty and the circular arrival-lead statistic the host's controller steers on. Additive; /// the wire grows only a new control message (`PhaseReport`, 0x32) an old host never reads and a /// strict-prefix append on the 0xCF host-timing tail, so [`WIRE_VERSION`] is unchanged. -pub const ABI_VERSION: u32 = 14; +/// v15: versions the shared rumble policy engine's C surface — +/// `punktfunk_connection_next_rumble_cmd`, `punktfunk_connection_set_rumble_quirks` and the +/// `PUNKTFUNK_RUMBLE_QUIRK_*` bits. These symbols are NOT new: they landed while this constant +/// still read 7 and no bump was made, so every core since has exported them while advertising a +/// version that never promised them. That cannot be corrected retroactively — a shipped binary +/// says what it says — so v15 is the floor that *guarantees* them: at or above it the surface is +/// present, below it an embedder must probe for the symbol. Purely a version statement; no code +/// changed with this bump, and no wire change, so [`WIRE_VERSION`] is unchanged. +pub const ABI_VERSION: u32 = 15; /// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. /// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** diff --git a/crates/punktfunk-core/src/quic/datagram.rs b/crates/punktfunk-core/src/quic/datagram.rs index 0567f241..7c5a6e01 100644 --- a/crates/punktfunk-core/src/quic/datagram.rs +++ b/crates/punktfunk-core/src/quic/datagram.rs @@ -401,6 +401,16 @@ impl RichInput { } } +/// Longest [`HidOutput::Trigger`] `effect` the wire carries: the DualSense adaptive-trigger +/// parameter block is a mode byte plus ten parameters, and every consumer copies at most this many +/// into its report. +/// +/// The single source for the clamp on BOTH sides. `Trigger` was the only variable-length variant +/// bounded on neither: encode appended whatever it was handed and decode took the entire tail, so +/// an attacker-sized datagram was reproduced verbatim into a `Vec` while its sibling `HidRaw` had +/// been bounded on both ends all along. +pub const TRIGGER_EFFECT_MAX: usize = 11; + const HIDOUT_LED: u8 = 0x01; const HIDOUT_PLAYER_LEDS: u8 = 0x02; const HIDOUT_TRIGGER: u8 = 0x03; @@ -460,7 +470,7 @@ impl HidOutput { } HidOutput::Trigger { pad, which, effect } => { out.extend_from_slice(&[HIDOUT_TRIGGER, *pad, *which]); - out.extend_from_slice(effect); + out.extend_from_slice(&effect[..effect.len().min(TRIGGER_EFFECT_MAX)]); } HidOutput::TrackpadHaptic { pad, @@ -497,10 +507,17 @@ impl HidOutput { pad: b[2], bits: b[3], }), - HIDOUT_TRIGGER if b.len() >= 4 => Some(HidOutput::Trigger { + // `> 4`, not `>= 4`: a body with no effect bytes at all is malformed, and decoding it + // as an EMPTY effect was actively harmful — downstream an empty block is written as an + // all-zero trigger report, which is mode 0x00, which RELEASES a held effect. A + // truncated datagram could therefore silently cancel the trigger a game was holding. + // A genuine "no effect" is a full-length zero block and still decodes fine. + HIDOUT_TRIGGER if b.len() > 4 => Some(HidOutput::Trigger { pad: b[2], which: b[3], - effect: b[4..].to_vec(), + // Bounded like `HidRaw` below: at most the parameter block is kept from the + // (attacker-sized) tail. + effect: b[4..b.len().min(4 + TRIGGER_EFFECT_MAX)].to_vec(), }), HIDOUT_TRACKPAD_HAPTIC if b.len() >= 10 => Some(HidOutput::TrackpadHaptic { pad: b[2], @@ -981,6 +998,82 @@ mod tests { assert!(decode_rumble_datagram(&d[..6]).is_none()); } + /// `Trigger` is the only variable-length variant that used to be bounded on NEITHER side. + /// Pinned here because both halves matter: an over-long effect must be clamped on the way out + /// AND on the way in, and a body with no effect bytes must not decode at all. + #[test] + fn trigger_effect_is_clamped_on_both_encode_and_decode() { + // Encode clamps: a caller handing over an over-long block cannot put it on the wire. + let long = HidOutput::Trigger { + pad: 1, + which: 0, + effect: vec![0xAB; 200], + }; + let d = long.encode(); + assert_eq!( + d.len(), + 4 + TRIGGER_EFFECT_MAX, + "magic + kind + pad + which + at most the parameter block" + ); + + // Decode clamps independently of encode — a hostile peer does not use our encoder. + let mut hostile = vec![HIDOUT_MAGIC, super::HIDOUT_TRIGGER, 1, 0]; + hostile.extend_from_slice(&[0xCD; 500]); + match HidOutput::decode(&hostile) { + Some(HidOutput::Trigger { effect, .. }) => { + assert_eq!(effect.len(), TRIGGER_EFFECT_MAX, "tail is bounded"); + } + other => panic!("expected a clamped Trigger, got {other:?}"), + } + + // An exact-length effect survives untouched, and round-trips. + let ok = HidOutput::Trigger { + pad: 2, + which: 1, + effect: vec![0x02, 0x90, 0xA0, 0xFF, 0, 0, 0, 0, 0, 0, 0], + }; + assert_eq!(HidOutput::decode(&ok.encode()), Some(ok)); + } + + /// A body with no effect bytes is malformed and must be REJECTED, not read as an empty effect: + /// downstream an empty block becomes an all-zero trigger report, which is mode 0x00 — it + /// releases whatever effect the game was holding. A truncated datagram must not do that. + #[test] + fn a_trigger_with_no_effect_bytes_is_rejected_not_read_as_cancel() { + let empty = [HIDOUT_MAGIC, super::HIDOUT_TRIGGER, 0, 0]; + assert_eq!(HidOutput::decode(&empty), None); + + // One byte of effect is a legitimate short block (consumers zero-pad it) and still decodes. + let one = [HIDOUT_MAGIC, super::HIDOUT_TRIGGER, 0, 0, 0x02]; + assert_eq!( + HidOutput::decode(&one), + Some(HidOutput::Trigger { + pad: 0, + which: 0, + effect: vec![0x02] + }) + ); + } + + /// `HidRaw`'s bound was already correct on both sides — pinned alongside `Trigger` so the pair + /// cannot drift apart again. + #[test] + fn hid_raw_stays_bounded_on_both_sides() { + let long = HidOutput::HidRaw { + pad: 0, + kind: HID_RAW_OUTPUT, + data: vec![0x11; 500], + }; + assert_eq!(long.encode().len(), 4 + HID_REPORT_MAX); + + let mut hostile = vec![HIDOUT_MAGIC, super::HIDOUT_HID_RAW, 0, HID_RAW_FEATURE]; + hostile.extend_from_slice(&[0x22; 900]); + match HidOutput::decode(&hostile) { + Some(HidOutput::HidRaw { data, .. }) => assert_eq!(data.len(), HID_REPORT_MAX), + other => panic!("expected a clamped HidRaw, got {other:?}"), + } + } + #[test] fn rumble_envelope_roundtrip_and_legacy_tolerance() { // v2 envelope round-trips seq + ttl. From 2d43275fcb419eb86e503696ad1acee7c9947f0b Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 20:52:58 +0200 Subject: [PATCH 39/53] fix(core/abi)!: stop exporting 149 unprefixed macros into every embedder's namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING (C header only): constants such as MAX_PADS, TAG_LEN, ABI_VERSION, INPUT_MAGIC and the whole BTN_/AXIS_ family are now PUNKTFUNK_-prefixed. cbindgen emits a bare #define per `pub const`, so those names landed in the namespace of every C program that includes the header. The rename table already said this was the rule and already carried the handful someone had noticed — and its own comment spells out why it matters: a clashing #define silently takes the last definition rather than failing to compile, so the failure mode is a wrong value, not a build error. This is the remaining 149. Associated constants are deliberately left alone. cbindgen already qualifies those with their type name, which is the very property whose absence makes a bare MAX_PADS dangerous — they are namespaced, just not by us. Nothing in this repository consumed the unprefixed spellings except one Swift test, which sat next to lines already using the prefixed form because its constant happened never to have been added to the table; it is updated here. The C harness links and runs against the regenerated header. Scheduled deliberately: the sweep flagged this for a release boundary, and 0.24.0 has shipped. External C embedders using the old spellings must add the prefix; there is no silent breakage, since the old names simply stop existing. --- .../PunktfunkKitTests/GamepadWireTests.swift | 2 +- crates/punktfunk-core/cbindgen.toml | 161 ++++++++++ include/punktfunk_core.h | 302 ++++++++++-------- 3 files changed, 325 insertions(+), 140 deletions(-) diff --git a/clients/apple/Tests/PunktfunkKitTests/GamepadWireTests.swift b/clients/apple/Tests/PunktfunkKitTests/GamepadWireTests.swift index d9786458..81e7a4a9 100644 --- a/clients/apple/Tests/PunktfunkKitTests/GamepadWireTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/GamepadWireTests.swift @@ -79,7 +79,7 @@ final class GamepadWireTests: XCTestCase { XCTAssertEqual(GamepadWire.axisRSY, UInt32(PUNKTFUNK_AXIS_RS_Y)) XCTAssertEqual(GamepadWire.axisLT, UInt32(PUNKTFUNK_AXIS_LT)) XCTAssertEqual(GamepadWire.axisRT, UInt32(PUNKTFUNK_AXIS_RT)) - XCTAssertEqual(GamepadWire.maxPads, Int(MAX_PADS)) + XCTAssertEqual(GamepadWire.maxPads, Int(PUNKTFUNK_MAX_PADS)) } func testPadIndexRidesFlagsOnEveryPerPadEvent() { diff --git a/crates/punktfunk-core/cbindgen.toml b/crates/punktfunk-core/cbindgen.toml index 538379fd..25742f4d 100644 --- a/crates/punktfunk-core/cbindgen.toml +++ b/crates/punktfunk-core/cbindgen.toml @@ -56,6 +56,167 @@ exclude = ["MsghdrX", "recvmsg_x", "mmsghdr", "sendmmsg", "recvmmsg"] "FRAME_MS" = "PUNKTFUNK_AUDIO_FRAME_MS" "SAMPLE_RATE_HZ" = "PUNKTFUNK_AUDIO_SAMPLE_RATE_HZ" +# R21: every remaining exported constant, prefixed. cbindgen emits a bare `#define` per +# `pub const`, so without an entry here names as generic as MAX_PADS, TAG_LEN, ABI_VERSION and +# INPUT_MAGIC land in the namespace of every C embedder that includes this header — and, as the +# note above says, a clashing #define silently takes the last definition rather than failing to +# compile. The table above had been doing this by hand for the handful someone noticed; this is +# the rest of them, so the stated rule finally holds for the whole surface. +# +# NOT covered, deliberately: associated constants (`ColorInfo_CP_BT709`, `ClockResync_ROUNDS`, +# `ResyncGuard_MAX_REJECTED_STREAK`). cbindgen already qualifies those with their type name, +# which is the very property whose absence makes a bare `MAX_PADS` dangerous — they are +# namespaced, just not by us. +"ABI_VERSION" = "PUNKTFUNK_ABI_VERSION" +"APP_EXITED_CLOSE_CODE" = "PUNKTFUNK_APP_EXITED_CLOSE_CODE" +"BTN_MISC1" = "PUNKTFUNK_BTN_MISC1" +"BTN_PADDLE1" = "PUNKTFUNK_BTN_PADDLE1" +"BTN_PADDLE2" = "PUNKTFUNK_BTN_PADDLE2" +"BTN_PADDLE3" = "PUNKTFUNK_BTN_PADDLE3" +"BTN_PADDLE4" = "PUNKTFUNK_BTN_PADDLE4" +"CHROMA_IDC_420" = "PUNKTFUNK_CHROMA_IDC_420" +"CHROMA_IDC_444" = "PUNKTFUNK_CHROMA_IDC_444" +"CIPHER_AES_128_GCM" = "PUNKTFUNK_CIPHER_AES_128_GCM" +"CIPHER_CHACHA20_POLY1305" = "PUNKTFUNK_CIPHER_CHACHA20_POLY1305" +"CLIENT_CAP_AUDIO_RED" = "PUNKTFUNK_CLIENT_CAP_AUDIO_RED" +"CLIENT_CAP_CURSOR" = "PUNKTFUNK_CLIENT_CAP_CURSOR" +"CLIENT_CAP_PHASE_LOCK" = "PUNKTFUNK_CLIENT_CAP_PHASE_LOCK" +"CLIP_CANCELLED_CODE" = "PUNKTFUNK_CLIP_CANCELLED_CODE" +"CLIP_CHUNK" = "PUNKTFUNK_CLIP_CHUNK" +"CLIP_FETCH_CAP" = "PUNKTFUNK_CLIP_FETCH_CAP" +"CLIP_FETCH_DENIED" = "PUNKTFUNK_CLIP_FETCH_DENIED" +"CLIP_FETCH_OK" = "PUNKTFUNK_CLIP_FETCH_OK" +"CLIP_FETCH_STALE" = "PUNKTFUNK_CLIP_FETCH_STALE" +"CLIP_FETCH_UNAVAILABLE" = "PUNKTFUNK_CLIP_FETCH_UNAVAILABLE" +"CLIP_FILE_INDEX_NONE" = "PUNKTFUNK_CLIP_FILE_INDEX_NONE" +"CLIP_FLAG_FILES" = "PUNKTFUNK_CLIP_FLAG_FILES" +"CLIP_MAX_KINDS" = "PUNKTFUNK_CLIP_MAX_KINDS" +"CLIP_MAX_MIME" = "PUNKTFUNK_CLIP_MAX_MIME" +"CLIP_POLICY_FILES" = "PUNKTFUNK_CLIP_POLICY_FILES" +"CLIP_POLICY_TEXT" = "PUNKTFUNK_CLIP_POLICY_TEXT" +"CLIP_REASON_BACKEND_UNAVAILABLE" = "PUNKTFUNK_CLIP_REASON_BACKEND_UNAVAILABLE" +"CLIP_REASON_NO_FILES" = "PUNKTFUNK_CLIP_REASON_NO_FILES" +"CLIP_REASON_OK" = "PUNKTFUNK_CLIP_REASON_OK" +"CLIP_REASON_POLICY_DISABLED" = "PUNKTFUNK_CLIP_REASON_POLICY_DISABLED" +"CLIP_REASON_TAKEN_OVER" = "PUNKTFUNK_CLIP_REASON_TAKEN_OVER" +"CLIP_STREAM_KIND_FETCH" = "PUNKTFUNK_CLIP_STREAM_KIND_FETCH" +"ClockResync_ROUNDS" = "PUNKTFUNK_ClockResync_ROUNDS" +"CODEC_AV1" = "PUNKTFUNK_CODEC_AV1" +"CODEC_H264" = "PUNKTFUNK_CODEC_H264" +"CODEC_HEVC" = "PUNKTFUNK_CODEC_HEVC" +"CODEC_PYROWAVE" = "PUNKTFUNK_CODEC_PYROWAVE" +"ColorInfo_CP_BT2020" = "PUNKTFUNK_ColorInfo_CP_BT2020" +"ColorInfo_CP_BT709" = "PUNKTFUNK_ColorInfo_CP_BT709" +"ColorInfo_MC_BT2020_NCL" = "PUNKTFUNK_ColorInfo_MC_BT2020_NCL" +"ColorInfo_MC_BT709" = "PUNKTFUNK_ColorInfo_MC_BT709" +"ColorInfo_TRC_BT709" = "PUNKTFUNK_ColorInfo_TRC_BT709" +"ColorInfo_TRC_HLG" = "PUNKTFUNK_ColorInfo_TRC_HLG" +"ColorInfo_TRC_PQ" = "PUNKTFUNK_ColorInfo_TRC_PQ" +"CURSOR_RELATIVE_HINT" = "PUNKTFUNK_CURSOR_RELATIVE_HINT" +"CURSOR_SHAPE_MAX_SIDE" = "PUNKTFUNK_CURSOR_SHAPE_MAX_SIDE" +"CURSOR_STATE_MAGIC" = "PUNKTFUNK_CURSOR_STATE_MAGIC" +"CURSOR_VISIBLE" = "PUNKTFUNK_CURSOR_VISIBLE" +"FLAG_EOF" = "PUNKTFUNK_FLAG_EOF" +"FLAG_PIC" = "PUNKTFUNK_FLAG_PIC" +"FLAG_PROBE" = "PUNKTFUNK_FLAG_PROBE" +"FLAG_SOF" = "PUNKTFUNK_FLAG_SOF" +"HDR_META_BODY_LEN" = "PUNKTFUNK_HDR_META_BODY_LEN" +"HDR_META_MAGIC" = "PUNKTFUNK_HDR_META_MAGIC" +"HELLO_LAUNCH_MAX" = "PUNKTFUNK_HELLO_LAUNCH_MAX" +"HELLO_NAME_MAX" = "PUNKTFUNK_HELLO_NAME_MAX" +"HID_RAW_FEATURE" = "PUNKTFUNK_HID_RAW_FEATURE" +"HID_RAW_OUTPUT" = "PUNKTFUNK_HID_RAW_OUTPUT" +"HID_REPORT_MAX" = "PUNKTFUNK_HID_REPORT_MAX" +"HIDOUT_MAGIC" = "PUNKTFUNK_HIDOUT_MAGIC" +"HOST_CAP_AUDIO_RED" = "PUNKTFUNK_HOST_CAP_AUDIO_RED" +"HOST_CAP_CLIPBOARD" = "PUNKTFUNK_HOST_CAP_CLIPBOARD" +"HOST_CAP_CURSOR" = "PUNKTFUNK_HOST_CAP_CURSOR" +"HOST_CAP_GAMEPAD_STATE" = "PUNKTFUNK_HOST_CAP_GAMEPAD_STATE" +"HOST_CAP_PEN" = "PUNKTFUNK_HOST_CAP_PEN" +"HOST_CAP_TEXT_INPUT" = "PUNKTFUNK_HOST_CAP_TEXT_INPUT" +"HOST_TIMING_MAGIC" = "PUNKTFUNK_HOST_TIMING_MAGIC" +"INBOUND_REQ_FLAG" = "PUNKTFUNK_INBOUND_REQ_FLAG" +"INPUT_MAGIC" = "PUNKTFUNK_INPUT_MAGIC" +"INPUT_WIRE_LEN" = "PUNKTFUNK_INPUT_WIRE_LEN" +"LEGACY_STALE_MS" = "PUNKTFUNK_LEGACY_STALE_MS" +"MAX_DATAGRAM_BYTES" = "PUNKTFUNK_MAX_DATAGRAM_BYTES" +"MAX_PADS" = "PUNKTFUNK_MAX_PADS" +"MAX_SCALE" = "PUNKTFUNK_MAX_SCALE" +"MIC_MAGIC" = "PUNKTFUNK_MIC_MAGIC" +"MIN_SCALE" = "PUNKTFUNK_MIN_SCALE" +"MIN_SHARD_PAYLOAD" = "PUNKTFUNK_MIN_SHARD_PAYLOAD" +"MIN_STREAM_BLOCK_SHARDS" = "PUNKTFUNK_MIN_STREAM_BLOCK_SHARDS" +"MSG_BITRATE_CHANGED" = "PUNKTFUNK_MSG_BITRATE_CHANGED" +"MSG_CLIP_CONTROL" = "PUNKTFUNK_MSG_CLIP_CONTROL" +"MSG_CLIP_FETCH" = "PUNKTFUNK_MSG_CLIP_FETCH" +"MSG_CLIP_FETCH_HDR" = "PUNKTFUNK_MSG_CLIP_FETCH_HDR" +"MSG_CLIP_OFFER" = "PUNKTFUNK_MSG_CLIP_OFFER" +"MSG_CLIP_STATE" = "PUNKTFUNK_MSG_CLIP_STATE" +"MSG_CLOCK_ECHO" = "PUNKTFUNK_MSG_CLOCK_ECHO" +"MSG_CLOCK_PROBE" = "PUNKTFUNK_MSG_CLOCK_PROBE" +"MSG_CURSOR_RENDER" = "PUNKTFUNK_MSG_CURSOR_RENDER" +"MSG_CURSOR_SHAPE" = "PUNKTFUNK_MSG_CURSOR_SHAPE" +"MSG_LOSS_REPORT" = "PUNKTFUNK_MSG_LOSS_REPORT" +"MSG_PAIR_CHALLENGE" = "PUNKTFUNK_MSG_PAIR_CHALLENGE" +"MSG_PAIR_PROOF" = "PUNKTFUNK_MSG_PAIR_PROOF" +"MSG_PAIR_REQUEST" = "PUNKTFUNK_MSG_PAIR_REQUEST" +"MSG_PAIR_RESULT" = "PUNKTFUNK_MSG_PAIR_RESULT" +"MSG_PHASE_REPORT" = "PUNKTFUNK_MSG_PHASE_REPORT" +"MSG_PROBE_REQUEST" = "PUNKTFUNK_MSG_PROBE_REQUEST" +"MSG_PROBE_RESULT" = "PUNKTFUNK_MSG_PROBE_RESULT" +"MSG_RECONFIGURE" = "PUNKTFUNK_MSG_RECONFIGURE" +"MSG_RECONFIGURED" = "PUNKTFUNK_MSG_RECONFIGURED" +"MSG_REQUEST_KEYFRAME" = "PUNKTFUNK_MSG_REQUEST_KEYFRAME" +"MSG_RFI_REQUEST" = "PUNKTFUNK_MSG_RFI_REQUEST" +"MSG_SET_BITRATE" = "PUNKTFUNK_MSG_SET_BITRATE" +"MSG_SHARD_PAYLOAD_ACK" = "PUNKTFUNK_MSG_SHARD_PAYLOAD_ACK" +"MSG_SHARD_PAYLOAD_CHANGED" = "PUNKTFUNK_MSG_SHARD_PAYLOAD_CHANGED" +"NO_OUTPUT_KEYFRAME_STREAK" = "PUNKTFUNK_NO_OUTPUT_KEYFRAME_STREAK" +"PAIR_APPROVAL_TIMEOUT_CLOSE_CODE" = "PUNKTFUNK_PAIR_APPROVAL_TIMEOUT_CLOSE_CODE" +"PAIR_BOUND_OTHER_CLOSE_CODE" = "PUNKTFUNK_PAIR_BOUND_OTHER_CLOSE_CODE" +"PAIR_DENIED_CLOSE_CODE" = "PUNKTFUNK_PAIR_DENIED_CLOSE_CODE" +"PAIR_NO_IDENTITY_CLOSE_CODE" = "PUNKTFUNK_PAIR_NO_IDENTITY_CLOSE_CODE" +"PAIR_NOT_ARMED_CLOSE_CODE" = "PUNKTFUNK_PAIR_NOT_ARMED_CLOSE_CODE" +"PAIR_RATE_LIMITED_CLOSE_CODE" = "PUNKTFUNK_PAIR_RATE_LIMITED_CLOSE_CODE" +"PAIR_SUPERSEDED_CLOSE_CODE" = "PUNKTFUNK_PAIR_SUPERSEDED_CLOSE_CODE" +"PEN_ANGLE_UNKNOWN" = "PUNKTFUNK_PEN_ANGLE_UNKNOWN" +"PEN_BARREL1" = "PUNKTFUNK_PEN_BARREL1" +"PEN_BARREL2" = "PUNKTFUNK_PEN_BARREL2" +"PEN_BATCH_MAX" = "PUNKTFUNK_PEN_BATCH_MAX" +"PEN_DISTANCE_UNKNOWN" = "PUNKTFUNK_PEN_DISTANCE_UNKNOWN" +"PEN_IN_RANGE" = "PUNKTFUNK_PEN_IN_RANGE" +"PEN_PREDICTED" = "PUNKTFUNK_PEN_PREDICTED" +"PEN_SAMPLE_WIRE_LEN" = "PUNKTFUNK_PEN_SAMPLE_WIRE_LEN" +"PEN_TILT_UNKNOWN" = "PUNKTFUNK_PEN_TILT_UNKNOWN" +"PEN_TOUCH_TIMEOUT_MS" = "PUNKTFUNK_PEN_TOUCH_TIMEOUT_MS" +"PEN_TOUCHING" = "PUNKTFUNK_PEN_TOUCHING" +"PRESETS" = "PUNKTFUNK_PRESETS" +"QUIT_CLOSE_CODE" = "PUNKTFUNK_QUIT_CLOSE_CODE" +"REANCHOR_MARKS_TO_LIFT" = "PUNKTFUNK_REANCHOR_MARKS_TO_LIFT" +"REJECT_BUSY_CLOSE_CODE" = "PUNKTFUNK_REJECT_BUSY_CLOSE_CODE" +"ResyncGuard_MAX_REJECTED_STREAK" = "PUNKTFUNK_ResyncGuard_MAX_REJECTED_STREAK" +"RFI_MAX_RANGE" = "PUNKTFUNK_RFI_MAX_RANGE" +"RICH_INPUT_MAGIC" = "PUNKTFUNK_RICH_INPUT_MAGIC" +"RUMBLE_V1_LEN" = "PUNKTFUNK_RUMBLE_V1_LEN" +"RUMBLE_V2_LEN" = "PUNKTFUNK_RUMBLE_V2_LEN" +"SETUP_FAILED_CLOSE_CODE" = "PUNKTFUNK_SETUP_FAILED_CLOSE_CODE" +"TAG_LEN" = "PUNKTFUNK_TAG_LEN" +"TRIGGER_EFFECT_MAX" = "PUNKTFUNK_TRIGGER_EFFECT_MAX" +"USER_FLAG_CHUNK_ALIGNED" = "PUNKTFUNK_USER_FLAG_CHUNK_ALIGNED" +"USER_FLAG_RECOVERY_ANCHOR" = "PUNKTFUNK_USER_FLAG_RECOVERY_ANCHOR" +"USER_FLAG_RECOVERY_POINT" = "PUNKTFUNK_USER_FLAG_RECOVERY_POINT" +"USER_FLAG_SLICE_STREAM" = "PUNKTFUNK_USER_FLAG_SLICE_STREAM" +"VIDEO_CAP_10BIT" = "PUNKTFUNK_VIDEO_CAP_10BIT" +"VIDEO_CAP_444" = "PUNKTFUNK_VIDEO_CAP_444" +"VIDEO_CAP_CHACHA20" = "PUNKTFUNK_VIDEO_CAP_CHACHA20" +"VIDEO_CAP_HDR" = "PUNKTFUNK_VIDEO_CAP_HDR" +"VIDEO_CAP_HOST_TIMING" = "PUNKTFUNK_VIDEO_CAP_HOST_TIMING" +"VIDEO_CAP_MULTI_SLICE" = "PUNKTFUNK_VIDEO_CAP_MULTI_SLICE" +"VIDEO_CAP_PROBE_SEQ" = "PUNKTFUNK_VIDEO_CAP_PROBE_SEQ" +"VIDEO_CAP_STREAMED_AU" = "PUNKTFUNK_VIDEO_CAP_STREAMED_AU" +"WIRE_VERSION" = "PUNKTFUNK_WIRE_VERSION" +"WIRE_VERSION_CLOSE_CODE" = "PUNKTFUNK_WIRE_VERSION_CLOSE_CODE" + # QualifiedScreamingSnakeCase already qualifies each variant with the enum name # (PunktfunkStatus::Ok -> PUNKTFUNK_STATUS_OK); do NOT also set prefix_with_name or it doubles. [enum] diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 62d53b32..9ecd0efd 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -45,6 +45,10 @@ // v10: added `punktfunk_connection_clock_offset_now_ns` — the LIVE (mid-stream re-synced) // clock offset ongoing latency math must use; the connect-time getter stays frozen by // contract. Additive, client-local — no wire change, so [`WIRE_VERSION`] is unchanged. +// v11: added `punktfunk_connect_ex9` — `connect_ex8` plus a `client_caps` bitfield +// (`PUNKTFUNK_CLIENT_CAP_CURSOR`, later `…_PHASE_LOCK`), which is how a client tells the host it +// renders the pointer itself. Additive; the caps ride the existing Hello, so [`WIRE_VERSION`] is +// unchanged. (Documented late — the bump shipped without its line here.) // v12: added `punktfunk_connection_set_cursor_render` — the mid-stream cursor-render flip // (design/remote-desktop-sweep.md §8): the client's mouse-model chord tells the host who // renders the pointer. Additive; rides the existing control stream (a new message TYPE, which @@ -58,7 +62,15 @@ // uncertainty and the circular arrival-lead statistic the host's controller steers on. Additive; // the wire grows only a new control message (`PhaseReport`, 0x32) an old host never reads and a // strict-prefix append on the 0xCF host-timing tail, so [`WIRE_VERSION`] is unchanged. -#define ABI_VERSION 14 +// v15: versions the shared rumble policy engine's C surface — +// `punktfunk_connection_next_rumble_cmd`, `punktfunk_connection_set_rumble_quirks` and the +// `PUNKTFUNK_RUMBLE_QUIRK_*` bits. These symbols are NOT new: they landed while this constant +// still read 7 and no bump was made, so every core since has exported them while advertising a +// version that never promised them. That cannot be corrected retroactively — a shipped binary +// says what it says — so v15 is the floor that *guarantees* them: at or above it the surface is +// present, below it an embedder must probe for the symbol. Purely a version statement; no code +// changed with this bump, and no wire change, so [`WIRE_VERSION`] is unchanged. +#define PUNKTFUNK_ABI_VERSION 15 // The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. // Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** @@ -66,7 +78,7 @@ // `punktfunk_wake_on_lan` is client-local, and riding the C-ABI bump onto the wire locked // every new client out of every deployed host ("ABI mismatch: client 3 host 2", observed // live). Bump this ONLY when the handshake/planes actually change incompatibly. -#define WIRE_VERSION 2 +#define PUNKTFUNK_WIRE_VERSION 2 // `PunktfunkHidOutput::kind` — lightbar RGB (`r`/`g`/`b` valid). #define PUNKTFUNK_HIDOUT_LED 1 @@ -323,41 +335,41 @@ // The uniform no-TTL-host staleness bound: a legacy host refreshes state every 500 ms, so two // missed refreshes = quiet host → silence. Replaces the per-platform zoo (1.6 s / 60 s / 1.5 s / // 1 s), and matches the ratio the Steam Deck ceiling shipped with. -#define LEGACY_STALE_MS 1000 +#define PUNKTFUNK_LEGACY_STALE_MS 1000 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Per-fetch requester-side size cap (bytes). A holder that streams more than this is treated as a // cap breach and the fetch fails rather than buffering unboundedly (§7). Phase 0 uses one fixed // value; a future host-policy `PUNKTFUNK_CLIP_MAX_MB` tightens it per session. -#define CLIP_FETCH_CAP (64 << 20) +#define PUNKTFUNK_CLIP_FETCH_CAP (64 << 20) #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Inbound-serve `req_id`s carry this high bit so they never collide with the client-assigned // outbound-fetch `xfer_id`s (which count up from 1). A single [`ClipCommand::Cancel`] `id` can // then be routed to the right table. -#define INBOUND_REQ_FLAG 2147483648 +#define PUNKTFUNK_INBOUND_REQ_FLAG 2147483648 #endif // Floor for a negotiated `shard_payload` (even, well under every real path). A path whose UDP // budget lands below this can't carry the QUIC control plane either (QUIC's own minimum is a // 1200-byte UDP payload), so shrinking video shards further buys nothing — the clamp helpers // bottom out here instead of producing degenerate confetti-sized shards. -#define MIN_SHARD_PAYLOAD 512 +#define PUNKTFUNK_MIN_SHARD_PAYLOAD 512 // 16-byte AEAD authentication tag appended by either session cipher. -#define TAG_LEN 16 +#define PUNKTFUNK_TAG_LEN 16 // Wire tag distinguishing an input datagram from a video packet. -#define INPUT_MAGIC 200 +#define PUNKTFUNK_INPUT_MAGIC 200 // Fixed serialized size of an [`InputEvent`] on the wire (tag + fields). -#define INPUT_WIRE_LEN (((((1 + 1) + 4) + 4) + 4) + 4) +#define PUNKTFUNK_INPUT_WIRE_LEN (((((1 + 1) + 4) + 4) + 4) + 4) // The number of gamepads addressable on the wire (`flags` pad index 0..15). Shared by the // client's snapshot fold and the host's per-pad accumulators. -#define MAX_PADS 16 +#define PUNKTFUNK_MAX_PADS 16 #define PUNKTFUNK_BTN_DPAD_UP 1 @@ -390,16 +402,16 @@ #define PUNKTFUNK_BTN_Y 32768 // Back grip R4 — SDL `RightPaddle1` / GameStream `PADDLE1`. -#define BTN_PADDLE1 65536 +#define PUNKTFUNK_BTN_PADDLE1 65536 // Back grip L4 — SDL `LeftPaddle1` / GameStream `PADDLE2`. -#define BTN_PADDLE2 131072 +#define PUNKTFUNK_BTN_PADDLE2 131072 // Back grip R5 — SDL `RightPaddle2` / GameStream `PADDLE3`. -#define BTN_PADDLE3 262144 +#define PUNKTFUNK_BTN_PADDLE3 262144 // Back grip L5 — SDL `LeftPaddle2` / GameStream `PADDLE4`. -#define BTN_PADDLE4 524288 +#define PUNKTFUNK_BTN_PADDLE4 524288 // DualSense touchpad click. Moonlight's extended-button position (`buttonFlags2` // merges in at `<< 16`, see `gamestream/gamepad.rs`), so GameStream clients land on @@ -407,7 +419,7 @@ #define PUNKTFUNK_BTN_TOUCHPAD 1048576 // Misc / capture button — the Deck `…`/quick-access, Share/Capture / GameStream `MISC`. -#define BTN_MISC1 2097152 +#define PUNKTFUNK_BTN_MISC1 2097152 // Axis ids for `InputKind::GamepadAxis`. #define PUNKTFUNK_AXIS_LS_X 0 @@ -426,16 +438,16 @@ // Identifies a punktfunk video packet (vs. an input datagram, see [`crate::input`]). #define PUNKTFUNK_MAGIC 201 -#define FLAG_PIC 1 +#define PUNKTFUNK_FLAG_PIC 1 -#define FLAG_EOF 2 +#define PUNKTFUNK_FLAG_EOF 2 -#define FLAG_SOF 4 +#define PUNKTFUNK_FLAG_SOF 4 // Bandwidth-probe filler, not decodable video: a [`crate::quic::ProbeRequest`] speed test makes // the host burst access units carrying this flag so the client measures throughput/loss without // feeding them to the decoder. Punktfunk/1 only (GameStream never sets it). -#define FLAG_PROBE 8 +#define PUNKTFUNK_FLAG_PROBE 8 // Application `user_flags` bit (the u32 [`PacketHeader::user_flags`] word, surfaced to the client // as [`crate::session::Frame::flags`]) — NOT a transport packet flag. Marks the access unit that @@ -444,7 +456,7 @@ // post-loss display freeze on this bit as well as on a real keyframe — the only bitstream-invisible // clean point it can honor without forcing a full IDR. Lives above the low nibble because the host // reuses `FLAG_PIC`/`FLAG_SOF`/`FLAG_PROBE` bit values inside `user_flags`; `0x10` clears all four. -#define USER_FLAG_RECOVERY_POINT 16 +#define PUNKTFUNK_USER_FLAG_RECOVERY_POINT 16 // Application `user_flags` bit — a **definitive single-frame clean re-anchor**. Unlike // [`USER_FLAG_RECOVERY_POINT`] (an intra-refresh wave boundary, where the first boundary after a loss @@ -454,7 +466,7 @@ // already has, not an IDR. The picture is loss-free the instant this AU decodes, so the client lifts // its post-loss freeze on the **first** such mark. Coded `P` (no IDR), so the decoder never sets // `AV_FRAME_FLAG_KEY` — this host flag is the only signal. -#define USER_FLAG_RECOVERY_ANCHOR 32 +#define PUNKTFUNK_USER_FLAG_RECOVERY_ANCHOR 32 // `user_flags` bit: the AU's content is **shard-aligned self-delimiting chunks** — every // `shard_payload`-sized window of the frame buffer starts a fresh codec packet, padded to the @@ -462,7 +474,7 @@ // consequences: a receiver that opted into partial delivery can use an aged-out frame's buffer // AS-IS (missing shards stay zeroed; the codec's block walk skips zero windows), and even a // COMPLETE frame must be consumed window-by-window (the padding is not part of the stream). -#define USER_FLAG_CHUNK_ALIGNED 64 +#define PUNKTFUNK_USER_FLAG_CHUNK_ALIGNED 64 // `user_flags` bit: this AU was packetized as a **slice-streamed** frame (the P2 slice // pipeline): its sentinel blocks (`block_count == 0`) are SLICE-granularity and carry their @@ -475,7 +487,7 @@ // [`VIDEO_CAP_STREAMED_AU`](crate::quic::VIDEO_CAP_STREAMED_AU) ∧ // [`VIDEO_CAP_MULTI_SLICE`](crate::quic::VIDEO_CAP_MULTI_SLICE) — the pair whose receivers // know this contract. -#define USER_FLAG_SLICE_STREAM 128 +#define PUNKTFUNK_USER_FLAG_SLICE_STREAM 128 // Widest lost-frame range (frames, wrapping `last - first`) a reference-frame-invalidation // recovery may be asked to repair; anything wider goes straight to the keyframe path on BOTH @@ -484,7 +496,7 @@ // reference anywhere, so an RFI request for it is either hopeless or (worse) a phantom range // from a desynced counter. Shared by the host's RFI dispatch (range → keyframe fallback) and the // client-side gap detectors (huge gap → resync + keyframe request, no RFI). -#define RFI_MAX_RANGE 256 +#define PUNKTFUNK_RFI_MAX_RANGE 256 // Largest UDP datagram the core will send or accept. `Config::validate` bounds // `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`. @@ -498,22 +510,22 @@ // for never having to resize buffers on a mid-session grow. Senders still derive their // shard payload from the path MTU (`config::mtu1500_shard_payload*`, the wire-MTU clamps); // this is the acceptance ceiling, not a transmit size. -#define MAX_DATAGRAM_BYTES 9216 +#define PUNKTFUNK_MAX_DATAGRAM_BYTES 9216 // The slice-flush floor: a sentinel block below this many data shards costs disproportionate // per-block FEC parity (`ceil(k × pct/100)` ≥ 1 whatever `k`), so slice boundaries only flush // once this much has accumulated (~22 KB at the standard shard payload). Small slices simply // ride with the next one; the wire is never worse than one flush per slice. -#define MIN_STREAM_BLOCK_SHARDS 16 +#define PUNKTFUNK_MIN_STREAM_BLOCK_SHARDS 16 #if defined(PUNKTFUNK_FEATURE_QUIC) // [`Hello::video_caps`] bit: the client can decode a 10-bit (Main10) HEVC stream. -#define VIDEO_CAP_10BIT 1 +#define PUNKTFUNK_VIDEO_CAP_10BIT 1 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // [`Hello::video_caps`] bit: the client can present BT.2020 PQ HDR10 (implies 10-bit). -#define VIDEO_CAP_HDR 2 +#define PUNKTFUNK_VIDEO_CAP_HDR 2 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) @@ -525,7 +537,7 @@ // 4:2:0 and [`Welcome::chroma_format`] reflects the real resolved value. Independent of // 10-bit/HDR (4:4:4 is a chroma decision, bit depth is a depth decision; the two may combine // where the hardware allows). -#define VIDEO_CAP_444 4 +#define PUNKTFUNK_VIDEO_CAP_444 4 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) @@ -535,7 +547,7 @@ // (design/stats-unification.md Phase 2). The host emits 0xCF ONLY when this bit is set (an older // host ignores it and simply never sends any); a client that doesn't set it keeps the combined // stage. Purely observability — never changes what the host encodes. -#define VIDEO_CAP_HOST_TIMING 8 +#define PUNKTFUNK_VIDEO_CAP_HOST_TIMING 8 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) @@ -550,7 +562,7 @@ // depends on. The host runs mid-session probe bursts ONLY against clients that set this bit — an // older client gets a declined (zeroed) [`ProbeResult`] instead of a measurement its single-window // reassembler would silently drop as stale. -#define VIDEO_CAP_PROBE_SEQ 16 +#define PUNKTFUNK_VIDEO_CAP_PROBE_SEQ 16 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) @@ -565,7 +577,7 @@ // — a mismatch drops the frame wholesale. The host streams ONLY to clients advertising this // bit; every other client gets today's whole-AU path (chunks concatenated before sealing), so // the fallback is zero-risk. -#define VIDEO_CAP_STREAMED_AU 32 +#define PUNKTFUNK_VIDEO_CAP_STREAMED_AU 32 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) @@ -579,7 +591,7 @@ // toward every other client the Welcome stays byte-identical AES-128-GCM. Purely a // performance choice — both AEADs are full-strength, and Hello/Welcome ride the pinned-TLS // control channel, so there is no downgrade surface. -#define VIDEO_CAP_CHACHA20 64 +#define PUNKTFUNK_VIDEO_CAP_CHACHA20 64 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) @@ -595,7 +607,7 @@ // bit (`PUNKTFUNK_NVENC_SLICES` stays the explicit operator override in both directions); // every other client gets single-slice frames — the pre-0.17 wire shape. NOTE: this takes the // video_caps byte's last free bit — the next video cap needs a second byte (ABI bump). -#define VIDEO_CAP_MULTI_SLICE 128 +#define PUNKTFUNK_VIDEO_CAP_MULTI_SLICE 128 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) @@ -604,7 +616,7 @@ // sequence number. A capable client then sends gamepad state as snapshots (idempotent on the // lossy datagram plane, periodically refreshed) instead of the fragile per-transition // button/axis events; toward a host that doesn't set the bit it keeps the legacy events. -#define HOST_CAP_GAMEPAD_STATE 1 +#define PUNKTFUNK_HOST_CAP_GAMEPAD_STATE 1 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) @@ -614,7 +626,7 @@ // out. Purely additive: nothing clipboard-related happens until a [`ClipControl`]`{ enabled: // true }` crosses (see `design/clipboard-and-file-transfer.md` §3.1). Packs into the existing // trailing `host_caps` byte — no wire-layout change. -#define HOST_CAP_CLIPBOARD 2 +#define PUNKTFUNK_HOST_CAP_CLIPBOARD 2 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) @@ -626,7 +638,7 @@ // non-Latin scripts, emoji) through `TextInput` instead of lossy VK synthesis; absent the bit it // keeps the VK fallback. Packs into the existing trailing `host_caps` byte — no wire-layout // change; an older host ignores the unknown input tag anyway (input is lossy by design). -#define HOST_CAP_TEXT_INPUT 4 +#define PUNKTFUNK_HOST_CAP_TEXT_INPUT 4 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) @@ -638,7 +650,7 @@ // (`SessionPlan.cursor_blend = false`) or the user sees it twice. Active only when the host // answers with [`HOST_CAP_CURSOR`] (capable-and-agreed, the 444/clipboard precedent); toward // an older or incapable host nothing changes. -#define CLIENT_CAP_CURSOR 1 +#define PUNKTFUNK_CLIENT_CAP_CURSOR 1 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) @@ -647,7 +659,7 @@ // capture/send tick to the client's display latch (design/phase-locked-capture.md). Without // the bit the host never arms the phase controller; toward an older host the reports are // simply ignored — no behavior change in either direction. -#define CLIENT_CAP_PHASE_LOCK 2 +#define PUNKTFUNK_CLIENT_CAP_PHASE_LOCK 2 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) @@ -660,7 +672,7 @@ // cursor/clipboard precedent). Toward an older host, or a host that declines because the link is // clean, the client keeps receiving the plain `0xC9` plane — so a client may always set this bit. // `0x04` — `0x01`/`0x02` are cursor / phase-lock. -#define CLIENT_CAP_AUDIO_RED 4 +#define PUNKTFUNK_CLIENT_CAP_AUDIO_RED 4 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) @@ -671,7 +683,7 @@ // host stops blending and ships [`CursorShape`](super::control::CursorShape) + // [`CursorState`](super::datagram::CursorState) instead. `0x08` — `0x04` is // [`HOST_CAP_TEXT_INPUT`], `0x01`/`0x02` are gamepad-state / clipboard. -#define HOST_CAP_CURSOR 8 +#define PUNKTFUNK_HOST_CAP_CURSOR 8 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) @@ -685,7 +697,7 @@ // wire ships ahead of the backend (P0): no host sets this bit until the P1 injector lands — // which is exactly why the gate exists. `0x10` — `0x08` is [`HOST_CAP_CURSOR`], `0x04` is // [`HOST_CAP_TEXT_INPUT`], `0x01`/`0x02` are gamepad-state / clipboard. -#define HOST_CAP_PEN 16 +#define PUNKTFUNK_HOST_CAP_PEN 16 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) @@ -699,25 +711,25 @@ // loss-gated — a clean LAN shouldn't pay for it), which is why clients decode BOTH tags // unconditionally and treat this bit as "expect redundancy", not "only redundancy". // `0x20` — `0x10` is [`HOST_CAP_PEN`], `0x08` is [`HOST_CAP_CURSOR`]. -#define HOST_CAP_AUDIO_RED 32 +#define PUNKTFUNK_HOST_CAP_AUDIO_RED 32 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software** // encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST // advertise this. -#define CODEC_H264 1 +#define PUNKTFUNK_CODEC_H264 1 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // [`Hello::video_codecs`] bit: the client can decode H.265 / HEVC — the default every existing // build produces and decodes (a peer that omits [`Hello::video_codecs`] is treated as HEVC-only). -#define CODEC_HEVC 2 +#define PUNKTFUNK_CODEC_HEVC 2 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // [`Hello::video_codecs`] bit: the client can decode AV1. -#define CODEC_AV1 4 +#define PUNKTFUNK_CODEC_AV1 4 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) @@ -731,18 +743,18 @@ // (`crates/pyrowave-sys/vendor/pyrowave/PUNKTFUNK-VENDOR.txt`): upstream has no bitstream // version field, so a vendored bump that changes the bitstream bumps the punktfunk protocol // version instead (plan §4.2). -#define CODEC_PYROWAVE 8 +#define PUNKTFUNK_CODEC_PYROWAVE 8 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // HEVC `chroma_format_idc` for 4:2:0 — what every pre-4:4:4 build produced and the back-compat // default when a peer omits [`Welcome::chroma_format`]. -#define CHROMA_IDC_420 1 +#define PUNKTFUNK_CHROMA_IDC_420 1 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // HEVC `chroma_format_idc` for full-chroma 4:4:4 (Range Extensions). -#define CHROMA_IDC_444 3 +#define PUNKTFUNK_CHROMA_IDC_444 3 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) @@ -793,195 +805,195 @@ #if defined(PUNKTFUNK_FEATURE_QUIC) // Type byte of [`Reconfigure`] (first byte after the magic). -#define MSG_RECONFIGURE 1 +#define PUNKTFUNK_MSG_RECONFIGURE 1 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Type byte of [`Reconfigured`]. -#define MSG_RECONFIGURED 2 +#define PUNKTFUNK_MSG_RECONFIGURED 2 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Type byte of [`RequestKeyframe`]. -#define MSG_REQUEST_KEYFRAME 3 +#define PUNKTFUNK_MSG_REQUEST_KEYFRAME 3 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Type byte of [`LossReport`]. -#define MSG_LOSS_REPORT 4 +#define PUNKTFUNK_MSG_LOSS_REPORT 4 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Type byte of [`SetBitrate`]. -#define MSG_SET_BITRATE 5 +#define PUNKTFUNK_MSG_SET_BITRATE 5 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Type byte of [`BitrateChanged`]. -#define MSG_BITRATE_CHANGED 6 +#define PUNKTFUNK_MSG_BITRATE_CHANGED 6 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Type byte of [`RfiRequest`]. -#define MSG_RFI_REQUEST 7 +#define PUNKTFUNK_MSG_RFI_REQUEST 7 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Type byte of [`ShardPayloadChanged`]. -#define MSG_SHARD_PAYLOAD_CHANGED 8 +#define PUNKTFUNK_MSG_SHARD_PAYLOAD_CHANGED 8 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Type byte of [`ShardPayloadAck`]. -#define MSG_SHARD_PAYLOAD_ACK 9 +#define PUNKTFUNK_MSG_SHARD_PAYLOAD_ACK 9 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Type byte of [`ProbeRequest`]. -#define MSG_PROBE_REQUEST 32 +#define PUNKTFUNK_MSG_PROBE_REQUEST 32 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Type byte of [`ProbeResult`]. -#define MSG_PROBE_RESULT 33 +#define PUNKTFUNK_MSG_PROBE_RESULT 33 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Type byte of [`ClockProbe`]. -#define MSG_CLOCK_PROBE 48 +#define PUNKTFUNK_MSG_CLOCK_PROBE 48 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Type byte of [`ClockEcho`]. -#define MSG_CLOCK_ECHO 49 +#define PUNKTFUNK_MSG_CLOCK_ECHO 49 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Type byte of [`PhaseReport`]. -#define MSG_PHASE_REPORT 50 +#define PUNKTFUNK_MSG_PHASE_REPORT 50 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Type byte of [`ClipControl`] (client → host): enable/disable the shared clipboard for this // session. Idempotent; opt-in is enforced here, not just in UI. -#define MSG_CLIP_CONTROL 64 +#define PUNKTFUNK_MSG_CLIP_CONTROL 64 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Type byte of [`ClipState`] (host → client): ack + unsolicited policy/backend updates. -#define MSG_CLIP_STATE 65 +#define PUNKTFUNK_MSG_CLIP_STATE 65 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Type byte of [`ClipOffer`] (symmetric): the lazy announcement — format list only, no bytes. -#define MSG_CLIP_OFFER 66 +#define PUNKTFUNK_MSG_CLIP_OFFER 66 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Type byte of [`ClipFetch`] (requester → holder, **fetch stream only**): pull one format of the // current offer. -#define MSG_CLIP_FETCH 67 +#define PUNKTFUNK_MSG_CLIP_FETCH 67 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Type byte of [`ClipFetchHdr`] (holder → requester, **fetch stream only**): the fetch response // header that precedes the data chunks. -#define MSG_CLIP_FETCH_HDR 68 +#define PUNKTFUNK_MSG_CLIP_FETCH_HDR 68 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // [`ClipControl::flags`] bit: the client permits file kinds to be offered/fetched this session. // Absent ⇒ files are filtered out of offers in both directions (text/rich/image only). -#define CLIP_FLAG_FILES 1 +#define PUNKTFUNK_CLIP_FLAG_FILES 1 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // [`ClipState::policy`] bit: the host permits non-file formats (text/RTF/HTML/image). Always set // while enabled unless a future direction limit clears it. -#define CLIP_POLICY_TEXT 1 +#define PUNKTFUNK_CLIP_POLICY_TEXT 1 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // [`ClipState::policy`] bit: the host permits file formats. Cleared by the operator `no-files` // / `text-only` policy so the client can grey out "Include files". -#define CLIP_POLICY_FILES 2 +#define PUNKTFUNK_CLIP_POLICY_FILES 2 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // [`ClipState::reason`]: normal ack, nothing exceptional. -#define CLIP_REASON_OK 0 +#define PUNKTFUNK_CLIP_REASON_OK 0 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // [`ClipState::reason`]: this session type has no working clipboard backend (e.g. a gamescope // session with no data-control global) — the client shows "not supported in this session type". -#define CLIP_REASON_BACKEND_UNAVAILABLE 1 +#define PUNKTFUNK_CLIP_REASON_BACKEND_UNAVAILABLE 1 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // [`ClipState::reason`]: another client took over the single per-desktop clipboard binding; this // one was disabled (last `ClipControl{enabled}` wins). -#define CLIP_REASON_TAKEN_OVER 2 +#define PUNKTFUNK_CLIP_REASON_TAKEN_OVER 2 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // [`ClipState::reason`]: the host operator policy (`PUNKTFUNK_CLIPBOARD=off`) disables clipboard. -#define CLIP_REASON_POLICY_DISABLED 3 +#define PUNKTFUNK_CLIP_REASON_POLICY_DISABLED 3 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // [`ClipState::reason`]: enabled, but the host policy forbids file transfer (`no-files` / // `text-only`) — surfaced so the client greys "Include files" with a footnote. -#define CLIP_REASON_NO_FILES 4 +#define PUNKTFUNK_CLIP_REASON_NO_FILES 4 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // [`ClipFetchHdr::status`]: the requested format is being served; data chunks follow until FIN. -#define CLIP_FETCH_OK 0 +#define PUNKTFUNK_CLIP_FETCH_OK 0 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // [`ClipFetchHdr::status`]: the fetch named a `seq` that is no longer the holder's current offer; // the requester degrades the paste to "nothing inserted" rather than wrong data. No chunks follow. -#define CLIP_FETCH_STALE 1 +#define PUNKTFUNK_CLIP_FETCH_STALE 1 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // [`ClipFetchHdr::status`]: the format/index is not available (no backend, or it vanished). No // chunks follow. -#define CLIP_FETCH_UNAVAILABLE 2 +#define PUNKTFUNK_CLIP_FETCH_UNAVAILABLE 2 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // [`ClipFetchHdr::status`]: policy/cap denies this fetch (e.g. a file fetch under `no-files`). No // chunks follow. -#define CLIP_FETCH_DENIED 3 +#define PUNKTFUNK_CLIP_FETCH_DENIED 3 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Maximum number of [`ClipKind`] entries in one [`ClipOffer`] (resource cap, §7). -#define CLIP_MAX_KINDS 16 +#define PUNKTFUNK_CLIP_MAX_KINDS 16 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Maximum length in bytes of a [`ClipKind::mime`] string (resource cap, §7). -#define CLIP_MAX_MIME 128 +#define PUNKTFUNK_CLIP_MAX_MIME 128 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // [`ClipFetch::file_index`] sentinel meaning "not a file fetch" (a whole non-file format, or the // file *manifest* itself). Real file fetches use `0..n`. -#define CLIP_FILE_INDEX_NONE UINT32_MAX +#define PUNKTFUNK_CLIP_FILE_INDEX_NONE UINT32_MAX #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Type byte of [`CursorShape`] (host → client): the pointer's bitmap + hotspot changed. -#define MSG_CURSOR_SHAPE 80 +#define PUNKTFUNK_MSG_CURSOR_SHAPE 80 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Type byte of [`CursorRenderMode`] (client → host): who renders the pointer right now. -#define MSG_CURSOR_RENDER 81 +#define PUNKTFUNK_MSG_CURSOR_RENDER 81 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) @@ -990,7 +1002,7 @@ // overshoots before the 17-byte header. 120² (57.6 KiB + header) fits with headroom and covers // real cursors (typically ≤ 64 px, ≤ 96 px at HiDPI scale); the HOST downscales anything // larger before forwarding, so the cap is invisible to clients. -#define CURSOR_SHAPE_MAX_SIDE 120 +#define PUNKTFUNK_CURSOR_SHAPE_MAX_SIDE 120 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) @@ -1010,21 +1022,21 @@ #if defined(PUNKTFUNK_FEATURE_QUIC) // Microphone uplink: the client's mic, Opus-encoded, client → host (the inverse of // [`AUDIO_MAGIC`]). The host feeds it into a virtual PipeWire source so its apps can record it. -#define MIC_MAGIC 203 +#define PUNKTFUNK_MIC_MAGIC 203 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Rich client→host input: events too big for the fixed 18-byte [`InputEvent`] // (crate::input::InputEvent) — the DualSense touchpad and motion sensors. Variable-length, // kind-tagged (see [`RichInput`]). -#define RICH_INPUT_MAGIC 204 +#define PUNKTFUNK_RICH_INPUT_MAGIC 204 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // HID output, host → client: DualSense feedback a game wrote to the host's virtual controller // (lightbar, player LEDs, adaptive triggers) — the rich analog of [`RUMBLE_MAGIC`]. See // [`HidOutput`]. -#define HIDOUT_MAGIC 205 +#define PUNKTFUNK_HIDOUT_MAGIC 205 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) @@ -1064,7 +1076,7 @@ #if defined(PUNKTFUNK_FEATURE_QUIC) // Wire length of a v1 (legacy, level) rumble datagram. -#define RUMBLE_V1_LEN 7 +#define PUNKTFUNK_RUMBLE_V1_LEN 7 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) @@ -1072,48 +1084,60 @@ // tail. Decoders are length-tolerant (see [`decode_rumble_envelope`]): an old client reads the // first 7 bytes as a plain level and ignores the tail, so no wire-version bump is needed — the // same dual-size idiom the HDR-luminance `AddRequest` tail uses. -#define RUMBLE_V2_LEN 10 +#define PUNKTFUNK_RUMBLE_V2_LEN 10 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Longest raw HID report a [`RichInput::HidReport`] / [`HidOutput::HidRaw`] can carry — the // 64-byte interrupt/feature report size every Valve controller uses (Triton input reports are // 46–54 bytes; feature and output reports are at most 64). -#define HID_REPORT_MAX 64 +#define PUNKTFUNK_HID_REPORT_MAX 64 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Longest [`HidOutput::Trigger`] `effect` the wire carries: the DualSense adaptive-trigger +// parameter block is a mode byte plus ten parameters, and every consumer copies at most this many +// into its report. +// +// The single source for the clamp on BOTH sides. `Trigger` was the only variable-length variant +// bounded on neither: encode appended whatever it was handed and decode took the entire tail, so +// an attacker-sized datagram was reproduced verbatim into a `Vec` while its sibling `HidRaw` had +// been bounded on both ends all along. +#define PUNKTFUNK_TRIGGER_EFFECT_MAX 11 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // [`HidOutput::HidRaw`] `kind`: an OUTPUT report — what the host's hidraw client wrote with // `write()`/`SDL_hid_write` (Triton rumble `0x80`, haptic pulse `0x81`, …). The client replays // it on the physical device's interrupt-OUT endpoint / GATT write. -#define HID_RAW_OUTPUT 0 +#define PUNKTFUNK_HID_RAW_OUTPUT 0 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // [`HidOutput::HidRaw`] `kind`: a FEATURE report — what the host's hidraw client sent with // `SET_REPORT` (`SDL_hid_send_feature_report`: lizard mode, IMU enable, settings). The client // replays it as a USB `SET_REPORT(Feature)` control transfer / GATT feature write. -#define HID_RAW_FEATURE 1 +#define PUNKTFUNK_HID_RAW_FEATURE 1 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // HDR static-metadata datagram tag, host → client (the static analog of the per-frame VUI; // see [`HdrMeta`]). Next tag after [`HIDOUT_MAGIC`]. -#define HDR_META_MAGIC 206 +#define PUNKTFUNK_HDR_META_MAGIC 206 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Wire length of an [`HdrMeta`] body (no tag byte): 6×u16 primaries + 2×u16 white + 2×u32 // luminance + 2×u16 CLL/FALL = 28 bytes. Shared by the [`HDR_META_MAGIC`] datagram (which // prefixes the tag) and the `Hello::display_hdr` trailing field (which carries the bare body). -#define HDR_META_BODY_LEN (((12 + 4) + 8) + 4) +#define PUNKTFUNK_HDR_META_BODY_LEN (((12 + 4) + 8) + 4) #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Per-AU host-timing datagram tag, host → client (see [`HostTiming`]). Next tag after // [`HDR_META_MAGIC`]. Emitted once per access unit, right after its last packet left the host's // socket, and only when the client advertised [`VIDEO_CAP_HOST_TIMING`]. -#define HOST_TIMING_MAGIC 207 +#define PUNKTFUNK_HOST_TIMING_MAGIC 207 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) @@ -1124,18 +1148,18 @@ // self-healing under loss (latest-wins, no refresh timer). The bitmap itself rides the // reliable control stream ([`CursorShape`](super::control::CursorShape)); this 14-byte // datagram only moves/hides the pointer. -#define CURSOR_STATE_MAGIC 208 +#define PUNKTFUNK_CURSOR_STATE_MAGIC 208 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // [`CursorState::flags`] bit: the host cursor is visible. -#define CURSOR_VISIBLE 1 +#define PUNKTFUNK_CURSOR_VISIBLE 1 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // [`CursorState::flags`] bit: a host app captured/hid the pointer — the client SHOULD run // relative/captured (M3 auto-flip; advisory, user override always wins). -#define CURSOR_RELATIVE_HINT 2 +#define PUNKTFUNK_CURSOR_RELATIVE_HINT 2 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) @@ -1144,7 +1168,7 @@ // `ApplicationClosed` reason and tears the session's virtual display down immediately, skipping the // keep-alive linger; any other close reason (idle timeout, reset, a bare code 0) still lingers so a // reconnect can resume. Shared so host + every client agree on the code. -#define QUIT_CLOSE_CODE 81 +#define PUNKTFUNK_QUIT_CLOSE_CODE 81 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) @@ -1154,107 +1178,107 @@ // surfacing a failure (`design/gamemode-and-dedicated-sessions.md` §5.3). Sibling of // [`QUIT_CLOSE_CODE`]; a client that doesn't special-case it still ends the session (every client // returns to its launcher on session end), so it is purely refinement. Shared so host + clients agree. -#define APP_EXITED_CLOSE_CODE 82 +#define PUNKTFUNK_APP_EXITED_CLOSE_CODE 82 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Longest device name carried in a [`Hello`] (bytes of UTF-8; longer names are truncated on // encode, rejected on decode — a one-byte length prefix caps it at 255 anyway). -#define HELLO_NAME_MAX 64 +#define PUNKTFUNK_HELLO_NAME_MAX 64 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Longest library id carried in a [`Hello::launch`] (bytes of UTF-8). Ids are short // (`steam:` / `custom:<12 hex>`); the cap just bounds an attacker-controlled field. -#define HELLO_LAUNCH_MAX 128 +#define PUNKTFUNK_HELLO_LAUNCH_MAX 128 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // [`Welcome::cipher`] id: AES-128-GCM — the default session AEAD every peer speaks (and the // only one pre-cipher builds know). -#define CIPHER_AES_128_GCM 0 +#define PUNKTFUNK_CIPHER_AES_128_GCM 0 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // [`Welcome::cipher`] id: ChaCha20-Poly1305 (RFC 8439) — negotiated via // [`VIDEO_CAP_CHACHA20`] for clients without hardware AES. -#define CIPHER_CHACHA20_POLY1305 1 +#define PUNKTFUNK_CIPHER_CHACHA20_POLY1305 1 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Type byte of [`PairRequest`]. -#define MSG_PAIR_REQUEST 16 +#define PUNKTFUNK_MSG_PAIR_REQUEST 16 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Type byte of [`PairChallenge`]. -#define MSG_PAIR_CHALLENGE 17 +#define PUNKTFUNK_MSG_PAIR_CHALLENGE 17 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Type byte of [`PairProof`]. -#define MSG_PAIR_PROOF 18 +#define PUNKTFUNK_MSG_PAIR_PROOF 18 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Type byte of [`PairResult`]. -#define MSG_PAIR_RESULT 19 +#define PUNKTFUNK_MSG_PAIR_RESULT 19 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // [`PenSample::state`] bit: the pen is in the hover range of the surface. Implied by // [`PEN_TOUCHING`] (decode normalizes, so a client that only sets TOUCHING still produces a // coherent contact). -#define PEN_IN_RANGE 1 +#define PUNKTFUNK_PEN_IN_RANGE 1 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // [`PenSample::state`] bit: the tip is in contact with the surface. -#define PEN_TOUCHING 2 +#define PUNKTFUNK_PEN_TOUCHING 2 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // [`PenSample::state`] bit: the primary barrel button (or the client's squeeze mapping) is held. -#define PEN_BARREL1 4 +#define PUNKTFUNK_PEN_BARREL1 4 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // [`PenSample::state`] bit: the secondary barrel button (or the client's double-tap mapping) // is held. -#define PEN_BARREL2 8 +#define PUNKTFUNK_PEN_BARREL2 8 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // [`PenSample::state`] bit, RESERVED: a predicted (not yet observed) sample. Never sent v1; // receivers MUST ignore samples carrying it until a capability negotiates otherwise // (design/pen-tablet-input.md §8). -#define PEN_PREDICTED 128 +#define PUNKTFUNK_PEN_PREDICTED 128 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // [`PenSample::tilt_deg`] sentinel: the client has no tilt sensor / no reading. -#define PEN_TILT_UNKNOWN 255 +#define PUNKTFUNK_PEN_TILT_UNKNOWN 255 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // [`PenSample::azimuth_deg`] / [`PenSample::roll_deg`] sentinel: no reading. -#define PEN_ANGLE_UNKNOWN 65535 +#define PUNKTFUNK_PEN_ANGLE_UNKNOWN 65535 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // [`PenSample::distance`] sentinel: no hover-distance reading. -#define PEN_DISTANCE_UNKNOWN 65535 +#define PUNKTFUNK_PEN_DISTANCE_UNKNOWN 65535 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Most samples one [`PenBatch`] can carry. Sized for coalesced capture at video-frame cadence // (240 Hz pen ÷ 30 fps = 8); a client producing more splits into consecutive batches. -#define PEN_BATCH_MAX 8 +#define PUNKTFUNK_PEN_BATCH_MAX 8 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Wire length of one encoded [`PenSample`]. -#define PEN_SAMPLE_WIRE_LEN 21 +#define PUNKTFUNK_PEN_SAMPLE_WIRE_LEN 21 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) @@ -1265,13 +1289,13 @@ // pen is naturally silent — senders MUST repeat the last sample at least every ~100 ms while // the pen is in range or touching (it re-decodes as pure Motion, harmless), keeping a live // stationary stroke two heartbeats clear of the deadline. -#define PEN_TOUCH_TIMEOUT_MS 200 +#define PUNKTFUNK_PEN_TOUCH_TIMEOUT_MS 200 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Stream-kind byte: a clipboard fetch (request/response of one format). Future stream kinds // (e.g. a bulk file-content push) mux under the same [`STREAM_MAGIC`] with a different byte. -#define CLIP_STREAM_KIND_FETCH 1 +#define PUNKTFUNK_CLIP_STREAM_KIND_FETCH 1 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) @@ -1281,18 +1305,18 @@ // `0x52`), the connection reject code `0x42`, and the pairing-rejection close block // `0x60`–`0x67` — stream reset codes and connection close codes are separate QUIC namespaces, // but the vocabularies stay disjoint on purpose so a captured code is unambiguous. -#define CLIP_CANCELLED_CODE 112 +#define PUNKTFUNK_CLIP_CANCELLED_CODE 112 #endif #if defined(PUNKTFUNK_FEATURE_QUIC) // Chunk size for streaming fetch data (64 KiB writes — matches the control-frame bound). -#define CLIP_CHUNK (64 * 1024) +#define PUNKTFUNK_CLIP_CHUNK (64 * 1024) #endif // Consecutive no-output AUs that force a keyframe request. ~50 ms at 60 Hz — long enough not to fire // on a one-frame decoder hiccup, short enough that a lost initial IDR (or a mid-GOP join) unfreezes // almost immediately instead of never. -#define NO_OUTPUT_KEYFRAME_STREAK 3 +#define PUNKTFUNK_NO_OUTPUT_KEYFRAME_STREAK 3 // How many host intra-refresh recovery marks ([`USER_FLAG_RECOVERY_POINT`]) must arrive since the // latest loss before the gate lifts its freeze on an IDR-free stream. TWO, not one: with a continuous @@ -1304,12 +1328,12 @@ // deliberate "hold longer, never show garbage" trade. // // [`USER_FLAG_RECOVERY_POINT`]: crate::packet::USER_FLAG_RECOVERY_POINT -#define REANCHOR_MARKS_TO_LIFT 2 +#define PUNKTFUNK_REANCHOR_MARKS_TO_LIFT 2 // QUIC application error code the host closes with on a `mode_conflict = reject` admission // refusal, carrying the human-readable busy reason (live mode + client label). A distinct code // lets a client tell "host busy" apart from a transport failure. Shared so clients can render it. -#define REJECT_BUSY_CLOSE_CODE 66 +#define PUNKTFUNK_REJECT_BUSY_CLOSE_CODE 66 // QUIC application close codes the host sends on **pairing-gate rejections**, so a client can // tell the user WHY it was turned away instead of collapsing every close into a generic @@ -1318,44 +1342,44 @@ // their own 0x60 block, disjoint from [`REJECT_BUSY_CLOSE_CODE`] (0x42) and the deliberate-end // codes (0x51/0x52). Purely additive: an older client treats them as a bare close (exactly the // pre-code behavior), an older host never sends them. Decode with [`RejectReason::from_close_code`]. -#define PAIR_NOT_ARMED_CLOSE_CODE 96 +#define PUNKTFUNK_PAIR_NOT_ARMED_CLOSE_CODE 96 // Pairing window armed, but bound to a DIFFERENT device fingerprint (the attempt does not // consume the window). See [`PAIR_NOT_ARMED_CLOSE_CODE`] for the block's contract. -#define PAIR_BOUND_OTHER_CLOSE_CODE 97 +#define PUNKTFUNK_PAIR_BOUND_OTHER_CLOSE_CODE 97 // PIN attempt inside the host's global pairing cooldown — retry shortly. -#define PAIR_RATE_LIMITED_CLOSE_CODE 98 +#define PUNKTFUNK_PAIR_RATE_LIMITED_CLOSE_CODE 98 // Unpaired client presented no certificate: nothing to approve, and the SPAKE2 ceremony needs an // identity to bind — the PIN flow with a client identity is the way in. -#define PAIR_NO_IDENTITY_CLOSE_CODE 99 +#define PUNKTFUNK_PAIR_NO_IDENTITY_CLOSE_CODE 99 // The operator explicitly denied this pairing request in the host console. -#define PAIR_DENIED_CLOSE_CODE 100 +#define PUNKTFUNK_PAIR_DENIED_CLOSE_CODE 100 // Nobody decided on the parked pairing request before the host's approval wait elapsed. -#define PAIR_APPROVAL_TIMEOUT_CLOSE_CODE 101 +#define PUNKTFUNK_PAIR_APPROVAL_TIMEOUT_CLOSE_CODE 101 // This parked knock was superseded by a newer connection from the same device — only the // newest is admitted on approval. -#define PAIR_SUPERSEDED_CLOSE_CODE 102 +#define PUNKTFUNK_PAIR_SUPERSEDED_CLOSE_CODE 102 // The client's wire (protocol) version does not match the host's — one side needs updating. -#define WIRE_VERSION_CLOSE_CODE 103 +#define PUNKTFUNK_WIRE_VERSION_CLOSE_CODE 103 // The host admitted the connection but could not stand the stream session up (compositor / // capture / encoder setup failed host-side). The close reason bytes carry the specific error // text for logs/diagnostics; clients render a stable "host-side failure" sentence. Before this // code, a setup failure reached the client as a bare dropped connection ("control stream // finished mid-frame") — indistinguishable from transport trouble. -#define SETUP_FAILED_CLOSE_CODE 104 +#define PUNKTFUNK_SETUP_FAILED_CLOSE_CODE 104 // Minimum supported multiplier (renders under native, upscaled on present). -#define MIN_SCALE 0.5 +#define PUNKTFUNK_MIN_SCALE 0.5 // Maximum supported multiplier (supersamples, clamped to the codec ceiling per axis). -#define MAX_SCALE 4.0 +#define PUNKTFUNK_MAX_SCALE 4.0 // Stable C ABI status codes. `Ok` is 0; all errors are negative so callers can // test `rc < 0`. Do not renumber existing variants — only append. @@ -1901,7 +1925,7 @@ typedef struct { // The multipliers a picker offers. `1.0` (Native) is the default; the rest are the round stops // users reason about. Shared so every client's list stays identical. -#define PRESETS { 0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0, } +#define PUNKTFUNK_PRESETS { 0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0, } #ifdef __cplusplus extern "C" { From ac5299d4ced0abaf2094162d55f4e80250d45993 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 20:58:11 +0200 Subject: [PATCH 40/53] docs: the Deck plugin is a launcher now, not a second client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin's settings tab, fullscreen page, host editor and games picker are gone, and the docs described all four in detail. Sweeps clients/decky/README.md and the docs site. steam-deck.md gains a **Request access** section — the no-PIN path where the host's operator approves the Deck, which is the one genuinely new thing a user gets — and says plainly where the settings went: **Open Punktfunk → Settings**, the same rows over the same store, one tap from the same panel. A removal that reads as a regression is worth a sentence, not a silence. The troubleshooting table drops the rows for surfaces that no longer exist and gains the two questions the new path will actually raise ("request access isn't offered", "the stream just sits there"). client-settings.md claimed ~18 settings were "offered by … and Decky". None are; the console home offers them. Its intro now names the console home's real sections (Stream, Video, Presentation, Audio, Controller, Touchscreen, Interface, Profiles) instead of describing the deleted sidebar. Three claims in that file turned out to be wrong ALREADY, independent of this rework, and are fixed here because verifying against crates/pf-console-ui/src/screens/settings.rs is what found them: • "Render scale — offered everywhere except the console home's list". RowId::RenderScale has been in the console's ROWS since 2026-07-31. • wake-on-lan.md: "Punktfunk Console has no auto-wake setting of its own". It does — RowId::AutoWake, "Wake hosts automatically". Its Wake & Connect BUTTON is independent of the setting, which is the true half that sentence was built on. • The console home's Library button was documented as gated on the "Show game library" toggle. It isn't — `library_enabled` appears nowhere in pf-console-ui outside the toggle row itself; home.rs offers Library on any paired, saved host. Also updated: support-matrix (Decky's Profiles and Game library go ✅/❌ → ⚠️ — the panel shows pinned profile cards but creates none, and the library lives in the console home), wake-on-lan (the plugin no longer fires its own packet or stretches the connect budget — the CLI runs the real wake-and-wait), pairing, game-library, profiles-and-links, input, clipboard and install-client. --- clients/decky/README.md | 142 +++++++++++-------- docs-site/content/docs/client-settings.md | 88 ++++++------ docs-site/content/docs/clipboard.md | 8 +- docs-site/content/docs/game-library.md | 9 +- docs-site/content/docs/input.md | 5 +- docs-site/content/docs/install-client.md | 8 +- docs-site/content/docs/pairing.md | 4 +- docs-site/content/docs/profiles-and-links.md | 5 +- docs-site/content/docs/steam-deck.md | 137 +++++++++++------- docs-site/content/docs/support-matrix.md | 17 ++- docs-site/content/docs/wake-on-lan.md | 19 ++- 11 files changed, 261 insertions(+), 181 deletions(-) diff --git a/clients/decky/README.md b/clients/decky/README.md index 20778036..10d82718 100644 --- a/clients/decky/README.md +++ b/clients/decky/README.md @@ -2,49 +2,61 @@ Stream to your **Steam Deck** without ever leaving Gaming Mode. This **[Decky Loader](https://decky.xyz/)** plugin adds a **Punktfunk** panel to the Quick Access Menu -(the `…` button): discover hosts on your network, pair with a PIN, tweak stream settings, and launch -a fullscreen, gamescope-focused stream — all from the couch, gamepad-navigable. +(the `…` button): the hosts you can stream, the pinned cards you set up, and one tap into each. -The video itself is the native GTK4 Linux client (the `io.unom.Punktfunk` flatpak); the plugin -discovers, pairs, configures, and *launches it the right way* so gamescope fullscreens it — the same -Steam-shortcut trick MoonDeck uses. Because it's built from real Steam UI primitives (`@decky/ui`), -the panel looks and feels native to Gaming Mode. +The plugin is a **launcher**, not a client. It doesn't decode video, browse your library, or hold +any settings of its own — the Rust client does all of that, and the plugin's job is to start it +*the right way* so gamescope fullscreens and focuses it (the same Steam-shortcut trick MoonDeck +uses). Everything the panel doesn't do is one tap away in the client's own gamepad UI. ## What it does -1. **Discover** — browses the LAN over mDNS for Punktfunk hosts, in both the QAM panel and a - fullscreen page; each host row opens a details view (address, pairing policy, certificate - fingerprint to cross-check against the host's log). -2. **Pair** — for a host that requires it, a gamepad-navigable PIN keypad runs the SPAKE2 pairing - ceremony headlessly, then remembers the host so future streams connect silently. -3. **Stream** — launches fullscreen via a branded "Punktfunk" Steam shortcut so gamescope focuses it. -4. **Games** — each host row has a games button that opens its **library picker**: pin titles as - one-tap "Stream " rows in the QAM (jump straight into e.g. Playnite on the host), or - **"Open library on screen"** to launch the client's controller-driven, console-style library - browser (aurora backdrop + poster coverflow; A plays, B returns to Gaming Mode). Pins survive - plugin reinstalls (stored next to the client's config) and follow a host across IP changes - (matched by certificate fingerprint). -5. **Settings** — the client's whole settings store, written to its config. Laid out like SteamOS's - own Settings: a left rail of categories (`SidebarNavigation`), one page each, so no page needs - scrolling. The categories and their order are the console settings screen's — Stream (resolution - / refresh / render scale / bitrate / compositor), Video (codec / decoder / GPU / HDR / 4:4:4), - Presentation (prioritize / smoothness buffer / V-Sync / VRR), Audio (channels / output + mic - device / echo cancellation), Controllers, Touch & mouse, Interface (stats overlay / auto-wake / - library / fullscreen). The device pickers are populated - from the session binary (`--list-adapters` / `--list-audio`); the GPU row appears only where - there is more than one adapter. -6. **About** — plugin version, an explicit "Check for updates" button, the setup-guide link, and - a force-stop for a wedged stream client. +1. **Hosts** — the hosts on your network plus the ones you've saved, in one list. Discovery is + mDNS; saved hosts are also probed directly, so a box reached over Tailscale or a VPN shows as + online even though it never advertises. Rows sort online-first, then most recently used. +2. **Trust** — an unpaired host opens a small sheet with two ways in: + - **Request access** (the default) — no PIN. The host's operator approves this Deck in its + console or web UI and the stream starts by itself. See [Request access](#request-access). + - **Use a PIN instead** — the gamepad-navigable keypad, running the same SPAKE2 ceremony. +3. **Stream** — launches fullscreen via a branded "Punktfunk" Steam shortcut so gamescope focuses + it. A sleeping host is woken first (the client runs the real wake-and-wait loop, then dials). +4. **Pinned cards** — a *(host, profile)* pair renders nested under its host as `▸ ` + and streams with that settings profile applied. Cards are the **shared** pinning model every + other client speaks, stored on the host's record — so one you make in the desktop client shows + up here, and vice versa. The plugin renders them; it doesn't create or edit them. +5. **Open Punktfunk** — launches the client's **console home**: the host picker, add-host by + address, PIN pairing, the game library browser, and the **full settings screen**. This is where + everything the panel no longer does now lives. +6. **About** — plugin version, "Check for updates", "Recreate library shortcut", and a force-stop + for a wedged stream. To leave a stream: the in-client controller chord (**L1 + R1 + Start + Select**), or close the "game" from the Steam overlay — either returns you to Gaming Mode. +### Request access + +Request access is not a second pairing ceremony — it is a **launch**. The plugin saves the host +with the fingerprint it **advertised**, then starts an ordinary identified connect with the +handshake budget stretched to 185 s. The host *parks* that connection until its operator approves +the device, then admits the same connection; the stream starts on its own, and the record flips +to **paired** so every later stream is silent. + +**No advertised fingerprint, no request access.** That pinned fingerprint is the only thing +standing between a 185-second wait and an impostor answering for the host, so a host you typed in +by address gets the PIN path only — and the sheet says why. The plugin never trusts-on-first-use +past a missing fingerprint. + ## Install on the Deck -You need **[Decky Loader](https://decky.xyz/)** and the **`io.unom.Punktfunk` flatpak** -([`packaging/flatpak`](../../packaging/flatpak/README.md)) installed on the Deck — SteamOS `/usr` is -read-only, so the flatpak (which bundles libadwaita/SDL3) is the canonical client. Discovery uses -`avahi-browse`, which ships on SteamOS/Bazzite. +You need **[Decky Loader](https://decky.xyz/)** and a **Punktfunk client** on the Deck. On a normal +Deck that's the `io.unom.Punktfunk` flatpak ([`packaging/flatpak`](../../packaging/flatpak/README.md)) — +SteamOS `/usr` is read-only, so the flatpak (which bundles libadwaita/SDL3) is the canonical client. +A native install (sysext, distro package, nix profile, your own build) works too. + +**The client must be v0.22.0 or newer** — that is when the headless `punktfunk` CLI shipped, and +the panel drives everything through it. An older client says so in the panel, with the update +button that fixes it right there. (Discovery no longer needs `avahi-browse` on the Deck; the +client's own mDNS does it.) **Recommended — install from URL** (published by CI): in Decky → Settings → **Developer Mode** → **Install Plugin from URL**, paste: @@ -55,17 +67,15 @@ https://unom.io/pf-decky (short link for `https://git.unom.io/api/packages/unom/generic/punktfunk-decky/latest/punktfunk.zip`; for a pinned version use `https://git.unom.io/api/packages/unom/generic/punktfunk-decky//punktfunk.zip` -directly). The plugin then **self-updates** without -the Decky store — when a newer build exists, an **Update** button appears and drives Decky -Loader's own (SHA-256-verified) install. Installs and updates can take a couple of minutes on some -networks: Decky's installer also contacts its plugin store first, which may be slow or blackholed -before the actual download proceeds. +directly). The plugin then **self-updates** without the Decky store — when a newer build exists, an +**Update** button appears and drives Decky Loader's own (SHA-256-verified) install. Installs and +updates can take a couple of minutes on some networks: Decky's installer also contacts its plugin +store first, which may be slow or blackholed before the actual download proceeds. ### Updating the client The plugin also reports — and where it can, installs — updates for the **client** it launches. -What is possible depends on how that client was installed, and the About tab names the install -kind so the answer is never a mystery: +What is possible depends on how that client was installed: | Install | Update | | --- | --- | @@ -88,6 +98,8 @@ pnpm install pnpm build # rollup → dist/index.js pnpm run package # → out/punktfunk/ + out/punktfunk-v.zip DECK=deck@ pnpm run deploy # rsync → /tmp, sudo-install into the root-owned plugins dir, restart loader + +python3.13 scripts/test-backend.py # backend unit checks (needs Python ≥3.10) ``` `~/homebrew/plugins/` is root-owned (the loader runs as root), so `deploy.sh` stages to a temp dir @@ -96,28 +108,46 @@ restart is required for an out-of-band install to appear. ## Architecture +Everything below the panel is the CLI. `main.py` builds argv and maps exit codes; it parses none of +the client's data files and re-implements none of its rules. + | File | Role | | --- | --- | -| `src/index.tsx` | Plugin entry: the QAM panel + route registration. | -| `src/page.tsx` | The `/punktfunk` fullscreen page — Hosts (with per-host details) / Settings / About tabs. | -| `src/settings.tsx` · `src/pair.tsx` | The settings screen (a `SidebarNavigation` of seven category pages over one shared settings object); the gamepad-navigable PIN-pairing modal. | -| `src/library.tsx` | The per-host game picker (pin/unpin, "Open library on screen") + the pinned-game launch helper. | -| `src/hostmgmt.tsx` | Add / edit host dialogs — mutate the shared known-hosts store (`client-known-hosts.json`) via the flatpak client's headless modes, so a host saved here shows up in the desktop client too. | -| `src/ui.tsx` | Shared UI primitives for the fullscreen page + modals (right-aligned row actions, consistent Field layout). | -| `src/hooks.ts` · `src/boundary.tsx` | Shared discovery/update/pins hooks + actions; the render error boundary. | -| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. The shortcut's exe is `/bin/sh` with the wrapper passed as an argument, so the script never needs an exec bit (Decky's zip extraction drops it and the root-owned plugins dir can't be chmodded by the unprivileged backend). Launch extras ride env-prefix tokens: `PF_LAUNCH=` (pinned game) / `PF_BROWSE=1` + `PF_MGMT=` (on-screen library); ids are validated space/quote-free at pin AND launch time. | -| `src/backend.ts` | Typed `callable` bridges to `main.py`. | -| `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut runs (so the window is focusable); maps `PF_LAUNCH`/`PF_BROWSE`/`PF_MGMT` to `--launch`/`--browse`/`--mgmt`. An older flatpak ignores the flags harmlessly (plain stream / hosts page). | -| `main.py` | Backend: `discover` (via `avahi-browse`) / `pair` / `library` (headless flatpak `--library`, TSV) / pins store (`decky-pinned.json`) / settings / `kill_stream` / `check_update` (with an explicit CA-bundle search — Decky's embedded Python has no usable default TLS roots on SteamOS). | -| `scripts/test-backend.py` | Stdlib-only checks for the backend's pure parsers (TSV, error classes, avahi TXT) + the pins round trip. | +| `src/index.tsx` | Plugin entry + the QAM panel: update banner, hosts (with nested pinned cards), the console-home door, about. | +| `src/hooks.ts` | `useHosts` (one call merging discovery and the saved store), the update hooks, and the launch action. Also the trust-state model the rows render. | +| `src/trust.tsx` · `src/pair.tsx` | The trust sheet (Request access / Use a PIN instead / Cancel) and the gamepad-navigable PIN keypad. | +| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. The shortcut's exe is `/bin/sh` with the wrapper passed as an argument, so the script never needs an exec bit (Decky's zip extraction drops it and the root-owned plugins dir can't be chmodded by the unprivileged backend). | +| `src/backend.ts` · `src/boundary.tsx` · `src/os-icon.tsx` | Typed `callable` bridges to `main.py`; the render error boundary; the host row's OS mark. | +| `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut runs (so the window is focusable). Reads `PF_REF` / `PF_PROFILE` / `PF_REQUEST_ACCESS` / `PF_BROWSE` and runs `punktfunk launch` — or the session's `--browse` for console home. | +| `main.py` | Backend: four thin CLI shells (`discover` / `hosts` / `pair` / `trust_host`) plus the Steam-side work only a plugin can do — `runner_info`, `shortcut_art`, `apply_controller_config`, `kill_stream`, `check_update` / `update_client` (with an explicit CA-bundle search — Decky's embedded Python has no usable default TLS roots on SteamOS). | +| `scripts/test-backend.py` | Stdlib-only checks: argv shape, the CLI exit-code mapping, and the Steam configset editor. | | `plugin.json` · `update.json` | Decky manifest; CI-baked update channel. | +### Why the launch goes through Steam + +gamescope only gives focus and fullscreen to the window tree Steam launched via `reaper` (it +detects the "current app" by AppID — gamescope#484). A client spawned from the plugin's own +backend comes up invisible and unfocused. So the plugin registers non-Steam shortcuts whose exe is +`/bin/sh` running `bin/punktfunkrun.sh`, and starts them with `RunGame`. + +There are **two** shortcuts, both named `Punktfunk` so Steam keys them to one Steam Input +configset (the key is the lowercase name): a hidden, stateful one that carries the stream, and the +visible, stateless library entry that opens console home. + ## Limitations / next steps -- No manual "add host by IP" entry yet (discovery is mDNS-only). -- No in-stream overlay inside the plugin — the client owns the session once launched. -- Pairing needs the operator to **arm pairing on the host** so it shows the PIN; the plugin can't arm - it remotely. +- **Profiles and pinned cards can't be created here** — the panel renders them; making one needs + the desktop client, or the client's own gamepad UI once that work lands. A Deck with no profiles + simply sees host rows, and nothing is broken. +- **Per-game pins are on hold.** The shared model pins *host+profile*; nothing in the shared store + persists a pinned *game* yet. The old `decky-pinned.json` is left on disk untouched so a later + migration can read it. +- Pairing with a PIN needs the operator to **arm pairing on the host** so it shows the PIN; the + plugin can't arm it remotely. Request access needs no arming — just an approval. +- **A parked connect looks like a hanging one.** The plugin toasts before launching a request-access + stream to set expectations, which is a patch rather than a fix; teaching the session's connect + screen the same "waiting for approval" copy the console shell already has would pay off for every + shell. ## Related diff --git a/docs-site/content/docs/client-settings.md b/docs-site/content/docs/client-settings.md index 025e744d..0d46f6a0 100644 --- a/docs-site/content/docs/client-settings.md +++ b/docs-site/content/docs/client-settings.md @@ -15,13 +15,14 @@ The Linux, Windows, Mac, iPhone/iPad and Android apps group settings the same wa **Display**, **Input**, **Audio**, **Controllers** — under *Preferences* on Linux and *Settings* elsewhere. The Apple TV app shows one scrolling list instead, and so does any client's settings screen reached with a controller. A controller-driven launch (Steam Deck Gaming Mode) opens the -client's **console home**, whose settings screen is one steppable list; the Decky plugin's Settings -tab covers the same store in the same groups and the same order, as a left rail of categories the -way SteamOS's own Settings looks. The console home is part of the -client — it is not the host's -[web console](/docs/web-console). +client's **console home**, whose settings screen is one steppable list of sections — **Stream**, +**Video**, **Presentation**, **Audio**, **Controller**, **Touchscreen**, **Interface**, +**Profiles**. On a Steam Deck that list *is* the settings surface: the +[Decky plugin](/docs/steam-deck) is a launcher and keeps no settings of its own, and its **Open +Punktfunk** button puts the console home one tap from the Quick Access Menu. The console home is +part of the client — it is not the host's [web console](/docs/web-console). -Linux stores them in `~/.config/punktfunk/client-gtk-settings.json`, the same file the Decky plugin +Linux stores them in `~/.config/punktfunk/client-gtk-settings.json`, the same file the console home writes, so a change in either shows up in the other. Windows uses `%APPDATA%\punktfunk\client-windows-settings.json`; the Apple and Android apps use their own stores. @@ -45,9 +46,9 @@ and your client scales what it gets — see **Match window** — *default: off.* The stream mode follows your window instead, and each resize renegotiates the host's display and encoder, so a windowed session stays pixel-exact. Fullscreen -degenerates to the display's native mode. Offered by the Linux, Windows, Mac, iPhone/iPad, console -home and Decky screens (on Decky it sits in the Resolution picker, and Gaming-Mode streams are -always fullscreen, so it lands on native); not by Android. +degenerates to the display's native mode. Offered by the Linux, Windows, Mac, iPhone/iPad and +console-home screens (in the console home it is an option inside the Resolution picker, and a +Gaming-Mode stream is always fullscreen, so there it lands on native); not by Android. **Refresh rate** — *default: Native*, the refresh of the display your window is on. The Apple app stores an explicit rate (60 Hz by default): iPhone and iPad offer the rates the device can display, @@ -68,11 +69,11 @@ capacity probe stay off for the whole session. multiplied by this, and your device resamples the result to its window. Above 1× supersamples for sharpness, at more bandwidth *and* more decode work; below 1× is lighter on both the host and the link. The stops run 0.5× to 4×. The result is floored to an even size and capped per axis at -4096 px for H.264, 8192 px otherwise. Offered everywhere except the console home's list. +4096 px for H.264, 8192 px otherwise. Offered everywhere. **Video codec** — *default: Automatic.* A soft preference: the host emits your choice when it can also produce it, otherwise the best codec you both speak, in the order HEVC → AV1 → H.264. -**PyroWave** is never auto-picked — pick it explicitly on Linux, Windows, the console home, Decky, or +**PyroWave** is never auto-picked — pick it explicitly on Linux, Windows, the console home, or an Apple device whose decode probe passes; anywhere else it isn't offered, and asking for it lands on that same order. See [PyroWave](/docs/pyrowave). The Android and Apple apps hide AV1 unless the device has a hardware AV1 decoder; Android never offers PyroWave. @@ -86,13 +87,13 @@ Full detail: [HDR](/docs/hdr). needs HEVC or PyroWave, the host's own 4:4:4 policy left on, a capture path that delivers full chroma, and a GPU that can encode it; if any gate fails the host says 4:2:0 before your decoder is built. The Apple, Linux and Windows apps all advertise it (Apple additionally requires its hardware -decode probe to pass). The console home and Decky offer the toggle; Android doesn't. +decode probe to pass). The console home offers the toggle; Android doesn't. **Prioritize** — *default: Lowest latency.* What the client optimizes for when a decoded frame is ready. **Lowest latency** shows every frame the moment the display can take it, so a network hiccup becomes an occasional repeated or skipped frame. **Smoothness** holds a small buffer that evens -those hiccups out, at that buffer's worth of added delay. Linux and Windows apps, the console home -and Decky; the Apple and Android apps have carried the same setting for a while, and it is stored +those hiccups out, at that buffer's worth of added delay. Linux and Windows apps and the console +home; the Apple and Android apps have carried the same setting for a while, and it is stored under the same name, so a [profile](/docs/profiles-and-links) means the same thing on every device. **Smoothness buffer** — *default: Automatic (two frames).* Only shown under **Smoothness**. How @@ -106,7 +107,7 @@ the instant it's ready instead of waiting for the screen's next refresh: the low can give you, at the cost of visible tearing on fast motion. It is **best-effort** — not every driver or compositor offers a tearing mode, and where none is available the stream stays tear-free. The Detailed [stats overlay](/docs/stats) names the mode actually in use, so you can tell "off" -from "off but unavailable". Linux and Windows apps, the console home and Decky. +from "off but unavailable". Linux and Windows apps and the console home. **Follow variable refresh rate** — *default: on.* On a VRR / FreeSync / G-Sync screen, let the panel refresh in step with the stream rather than on a fixed cadence — which removes the wait between a @@ -115,8 +116,8 @@ windowed one is at the compositor's mercy) and is harmless on a fixed-refresh sc graphics driver that offers the modern queue-free display mode; on an older driver it does nothing unless you also set `PUNKTFUNK_VRR_FIFO=1` (see [configuration](/docs/configuration)), because the older way of following a panel costs noticeable latency on a fixed-refresh screen. The stats overlay -reports `vrr yes` once it has *measured* that the panel really is following. Linux and Windows apps, -the console home and Decky. +reports `vrr yes` once it has *measured* that the panel really is following. Linux and Windows apps +and the console home. **Host compositor** — *default: Automatic.* Which backend a **Linux** host uses to drive the virtual output. Advisory: a host without that backend quietly auto-detects instead. @@ -130,7 +131,7 @@ claims a sink advertising exactly that many channels, so applications produce re **Windows** host loopback-captures your current output endpoint and lets Windows convert it — so 5.1 from a stereo endpoint is an upmix, not new channels. Offered everywhere. -**Microphone** — *default: off on Linux, Windows, Android, the console home and Decky; on in the +**Microphone** — *default: off on Linux, Windows, Android and the console home; on in the Apple app.* Sends this device's microphone to the host's virtual mic. On Linux and Windows the row is spelled *Stream microphone*, and **Ctrl+Alt+Shift+V** mutes it mid-stream without ending anything — see [Muting your microphone](/docs/input#muting-your-microphone). @@ -142,18 +143,18 @@ from an echo-cancelled PipeWire source when your desktop provides one, on **Wind for the Communications stream category so the endpoint's processing engages, and on **Apple** and **Android** the platform's voice-processing mode. Turn it off if your microphone already runs its own processing, or if the canceller makes your voice sound thin. The row sits under the microphone -toggle and greys out while the microphone is off. Offered by the Linux, Windows, Apple, Android, -console-home and Decky clients. What it can and can't fix is in +toggle and greys out while the microphone is off. Offered by the Linux, Windows, Apple, Android and +console-home clients. What it can and can't fix is in [Why do I hear myself](/docs/echo). **Speaker** and **Microphone** device pickers — *default: System default.* Which endpoint stream -audio plays out of, and which input feeds the uplink. Only the Linux app (PipeWire nodes), the -**Mac** app (which also has a microphone *channel* picker) and **Decky** have these — iPhone, iPad, -Apple TV, Android and the console home have none, and the Windows app has none and ignores a stored -speaker choice. On Linux, a device that has since disappeared keeps a "(not detected)" entry rather -than silently snapping back to the default; the Mac shows it as "Unavailable device" and Decky as -"(not connected)". Decky reads the endpoint list from the client's session binary, so a client -older than the two-binary split leaves these pickers on Automatic. +audio plays out of, and which input feeds the uplink. Only the Linux app (PipeWire nodes) and the +**Mac** app (which also has a microphone *channel* picker) have these — iPhone, iPad, Apple TV, +Android and the console home have none, and the Windows app has none and ignores a stored speaker +choice. On Linux, a device that has since disappeared keeps a "(not detected)" entry rather than +silently snapping back to the default; the Mac shows it as "Unavailable device". A Steam Deck in +Gaming Mode therefore has no endpoint picker at all: the session uses whatever the Desktop-Mode app +last stored, and the system default otherwise. ## Input @@ -182,7 +183,7 @@ client greys them out to say so. **Gamepad type** (*Controller type* on Apple, Android and the console home) — *default: Automatic*, which matches each physical controller. The pickers offer Xbox 360, Xbox One, DualSense and -DualShock 4 everywhere, plus Steam Deck on Linux, Android, the console home and Decky. Your client +DualShock 4 everywhere, plus Steam Deck on Linux, Android and the console home. Your client declares a type per pad as it connects — Automatic declares what that controller really is, an explicit choice declares your choice — and the host builds each virtual pad from that. A type the host has no backend for degrades to an Xbox 360 pad rather than failing: Xbox One on a Windows host, @@ -193,8 +194,8 @@ which forwards *every* connected controller, each as its own player, on Linux, W console home. Pinning one restricts the session to that controller alone — single-player. The Android app has no such picker. -**Capture system shortcuts** — *default: on.* Offered by the Linux and Windows apps, the console home -and Decky; Windows spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On a Deck it +**Capture system shortcuts** — *default: on.* Offered by the Linux and Windows apps and the console +home; Windows spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On a Deck it matters only for a keyboard you attached yourself, for the reason the paragraph below gives: Gaming Mode is gamescope, which has nothing to hold back. On, Alt+Tab and the Windows key (Super on Linux) reach the host while the stream has input captured. Off, they act on this machine @@ -215,21 +216,22 @@ the wlroots compositors all do, and X11 sessions grab the keyboard directly. Und Wake-on-LAN and waits for it to boot — only for a host whose MAC address this client has already learned. Turn it off for hosts you reach over a VPN, where "offline" usually means "not reachable by broadcast" and the wake only adds a delay. The Linux, Windows, Apple and Android apps have this -toggle, as do the console home and Decky — and note that the Decky plugin sends a wake of its own -before a stream starts whatever this setting says, so on a Deck it governs the client's connect -rather than the launch. The console home also offers wake as an explicit action on an offline host. -See +toggle, as does the console home — and on a Steam Deck it governs the +[Decky plugin's](/docs/steam-deck) launches too, because the plugin starts every stream through the +client, which reads this setting like any other connect. The console home also offers wake as an +explicit action on an offline host, whatever the toggle says. See [Wake-on-LAN](/docs/wake-on-lan). **Show game library** — *default: off on Linux and Windows; on in the Apple and Android apps.* Browse a paired host's games and launch one directly; the Windows app still labels it experimental. The -console home and Decky have the toggle too — on Decky it governs the *client's* screens, since the -plugin's own library browser works either way. See [Game library](/docs/game-library). +console home has the toggle too, and it governs the desktop clients that share the store — the +console's own **Library** button is offered on any paired host either way. See +[Game library](/docs/game-library). **Start streams in fullscreen** — *default: on.* On Linux and Windows, F11 or Alt+Enter leaves fullscreen live. On a Mac the setting is **Fullscreen while streaming**, and the window comes back -when you return to the host list. The console home and Decky carry the row for the desktop client -that shares the store — a Gaming-Mode launch is fullscreen whatever it says. iPhone, iPad, Apple TV +when you return to the host list. The console home carries the row for the desktop client that +shares the store — a Gaming-Mode launch is fullscreen whatever it says. iPhone, iPad, Apple TV and Android have no equivalent. ## Overlay @@ -237,9 +239,9 @@ and Android have no equivalent. **Statistics overlay** — *default: Normal.* Four tiers — Off, Compact, Normal, Detailed — each a superset of the one before. This setting only picks the tier a session *starts* at — you can cycle them live in-stream, with a shortcut that differs by platform. The Apple app additionally lets you -choose which corner the overlay sits in (Top Left, Top Right, Bottom Left, Bottom Right). The Decky -plugin has the tier picker too, in its Settings section. The shortcuts, and every number in the -overlay, are in +choose which corner the overlay sits in (Top Left, Top Right, Bottom Left, Bottom Right). The +console home has the tier picker too, as **Statistics overlay** under **Interface**. The shortcuts, +and every number in the overlay, are in [Understanding the stats overlay](/docs/stats). ## Settings that are facts about your device @@ -251,8 +253,8 @@ stay global and **cannot be put in a settings profile**: vendor-ordered and falls back on its own; change it only when debugging, and note that `PUNKTFUNK_DECODER` overrides it ([Configuration](/docs/configuration#client-side-native-clients)). The decoder picker is on Linux, - Windows, in the console home and in Decky; the GPU picker on Windows, and on Linux and Decky only - when the machine has more than one adapter — which a Deck doesn't, so the row isn't there. The + Windows and in the console home; the GPU picker on Windows, and on Linux only when the machine has + more than one adapter — the console home has none, and a Deck has a single adapter anyway. The Apple and Android apps have neither. - **Speaker** and **Microphone** device pickers — this device's audio endpoints. - **Forwarded controller** — which physical pad is in your hands. The *type* the host creates is a diff --git a/docs-site/content/docs/clipboard.md b/docs-site/content/docs/clipboard.md index fbb370fe..af27dbca 100644 --- a/docs-site/content/docs/clipboard.md +++ b/docs-site/content/docs/clipboard.md @@ -77,7 +77,8 @@ The setting is read when a session starts, so if you change it while streaming, macOS can also flip it mid-session: **Stream ▸ Share Clipboard** (⌃⌥⇧C), which becomes **Stop Sharing Clipboard** once the host has acknowledged it. -iOS, iPadOS, tvOS and the Steam Deck Decky plugin have no clipboard switch — see +iOS, iPadOS, tvOS and a Steam Deck in Gaming Mode have no clipboard switch — neither the Decky +panel nor the client's console home has a host edit sheet — see [what each client does](#which-hosts-and-clients-support-it) below. ## Nothing crosses until something pastes @@ -134,8 +135,9 @@ when a host application pastes. The **Linux client has the switch but no working clipboard bridge**: it enables the plane and then has no code to read or write the desktop's own clipboard, so nothing is announced and nothing is -pasted. Turning it on there is harmless but has no effect today. The Decky plugin on the Steam Deck -has no switch at all. +pasted. Turning it on there is harmless but has no effect today. On a Steam Deck in Gaming Mode +there is no switch at all — the Decky panel doesn't edit hosts — and since a Deck streams with that +same Linux client, a switch there would have nothing to move anyway. When you copy **on the Windows client**, images cross only if the copying application publishes the registered `PNG` clipboard format. Many Windows apps publish only a bitmap, and those copies aren't diff --git a/docs-site/content/docs/game-library.md b/docs-site/content/docs/game-library.md index 158d44eb..631880a2 100644 --- a/docs-site/content/docs/game-library.md +++ b/docs-site/content/docs/game-library.md @@ -132,11 +132,10 @@ and runs what it already knows about the title, so a client can never hand the h - **Android** — the library lives only in the controller-optimized home, which a TV always uses and a phone or tablet switches to when a controller is connected. Press **Y** on a saved host, or open its options and choose **Library**. -- **Steam Deck (Decky)** — the plugin's per-host **Games** picker lists the library and lets you - **Pin** titles; a pinned game becomes a one-tap row under **Pinned Games** in the Quick Access Menu. - The picker itself doesn't launch anything — either tap a pinned row, or use **Open library on - screen** to browse the host's games full-screen on the Deck and launch from there. See - [Steam Deck](/docs/steam-deck). +- **Steam Deck (Decky)** — the panel is a launcher and browses nothing itself: tap **Open + Punktfunk**, which opens the client's console home, and a paired host's **Library** button is + right there — full-screen covers, gamepad-navigable, and a press starts the stream with the title + launching. See [Steam Deck](/docs/steam-deck). - **Moonlight** — when the host runs with `--gamestream`, your library appears in Moonlight's app list beside `Desktop`, with covers served by the host. A title keeps the same app id across host restarts, so Moonlight's cached tiles stay correct. Titles with no launch recipe are left out. diff --git a/docs-site/content/docs/input.md b/docs-site/content/docs/input.md index 6cb01d4e..a78a772a 100644 --- a/docs-site/content/docs/input.md +++ b/docs-site/content/docs/input.md @@ -49,8 +49,9 @@ your settings. If the stream isn't sending a microphone at all (**Stream microph [client settings](/docs/client-settings#audio)) the shortcut does nothing and no badge appears, rather than pretending to mute something. -This is on the **Linux and Windows** clients. The Apple, Android and Decky clients have no mute -shortcut yet; turn **Stream microphone** off in their settings instead. +This is on the **Linux and Windows** clients — including a Steam Deck stream, which is the Linux +client, so an attached keyboard gets the chord. The Apple and Android clients have no mute shortcut +yet; turn **Stream microphone** off in their settings instead. Alt-Tabbing away releases input on its own and takes it back when you return. A release you asked for with the chord stays released until you opt back in. Either way, keys and buttons you were diff --git a/docs-site/content/docs/install-client.md b/docs-site/content/docs/install-client.md index 6983ab47..3da99035 100644 --- a/docs-site/content/docs/install-client.md +++ b/docs-site/content/docs/install-client.md @@ -79,9 +79,11 @@ list: [Clients → the `punktfunk` CLI](/docs/clients#scripting-the-punktfunk-cl ## Steam Deck Most Deck users want **Gaming Mode**: install the **[Decky plugin](/docs/steam-deck)** and a -**Punktfunk** panel lands in the Quick Access Menu, so you can discover hosts, pair with a PIN, and -stream **without dropping to the desktop**. Follow the **[Steam Deck (Decky) guide](/docs/steam-deck)** -— it walks through Decky Loader, the plugin, and the one-time client install. +**Punktfunk** panel lands in the Quick Access Menu, so you can find a host, get let in (a PIN, or a +request the host's operator approves), and stream **without dropping to the desktop**. Everything +else — settings, the game library, adding a host by address — is one tap away in the client's own +gamepad UI. Follow the **[Steam Deck (Decky) guide](/docs/steam-deck)** — it walks through Decky +Loader, the plugin, and the one-time client install. > The plugin doesn't decode video itself — it drives whichever `punktfunk-client` is installed on > the Deck. The Flatpak below is the tested default; a native package or a sysext works too. If your diff --git a/docs-site/content/docs/pairing.md b/docs-site/content/docs/pairing.md index 9b30b037..815f0958 100644 --- a/docs-site/content/docs/pairing.md +++ b/docs-site/content/docs/pairing.md @@ -61,8 +61,8 @@ Then, on the client: - **[Native clients](/docs/clients) (Apple, Linux, Windows, Android):** select the host (or use *Pair with PIN…* from its menu) and enter the PIN the host displays. - **[Steam Deck](/docs/steam-deck) (the Decky plugin):** open Punktfunk from the Quick Access menu - and pick the host — an unpaired one's button reads **Pair & Stream**. Enter the PIN on the - 4-digit pad it opens. + and pick the host — an unpaired one opens a sheet offering **Request access** (no PIN: somebody + approves the Deck at the host) or **Use a PIN instead**, which opens the 4-digit pad. - **[Moonlight](/docs/moonlight):** choose **Pair**; Moonlight shows a 4-digit PIN, and you type that PIN into the console's **Moonlight (GameStream) pairing** card and press **Submit PIN**. (This direction is the reverse of the native flow, and arming doesn't apply to it.) diff --git a/docs-site/content/docs/profiles-and-links.md b/docs-site/content/docs/profiles-and-links.md index 153ea182..af8c8f07 100644 --- a/docs-site/content/docs/profiles-and-links.md +++ b/docs-site/content/docs/profiles-and-links.md @@ -11,8 +11,9 @@ Both live in the client apps — the Apple app, the Linux GTK client, the Window Android app. Neither exists in the host's [web console](/docs/web-console). The controller-driven surfaces are a half-exception: Apple TV, the Android app's console mode and -the Steam Deck console the Decky plugin launches all *use* the profile a host is bound to, but none -of them can create or edit one. Do that on a desktop or a phone first. +the Steam Deck console the Decky plugin launches all *use* the profile a host is bound to and can +pin one as its own card, but none of them can create or edit one. Do that on a desktop or a phone +first. The Decky panel itself only *shows* those pins, nested under their host as one-tap cards. ## What a profile is diff --git a/docs-site/content/docs/steam-deck.md b/docs-site/content/docs/steam-deck.md index fc1db17a..bf46700a 100644 --- a/docs-site/content/docs/steam-deck.md +++ b/docs-site/content/docs/steam-deck.md @@ -7,10 +7,12 @@ The **Decky plugin** adds a **Punktfunk** panel to the Steam Deck's Quick Access button), so you can find a host, pair, and start streaming **without leaving Gaming Mode**. It's the couch-friendly front end for the Steam Deck — built from real Steam UI, gamepad-navigable end to end. -Under the hood the plugin doesn't decode video itself: it discovers hosts, runs the PIN pairing, and -**launches the regular [Linux client](/docs/clients#linux-desktop-client-gtk4)** (usually the -`io.unom.Punktfunk` Flatpak) the way gamescope needs so it fullscreens correctly. So the Deck has two -ways to stream, and they share one client + one paired identity: +The plugin is a **launcher**, not a second client. It doesn't decode video, browse your library or +hold settings of its own — it starts the regular +[Linux client](/docs/clients#linux-desktop-client-gtk4) (usually the `io.unom.Punktfunk` Flatpak) +the way gamescope needs so it fullscreens correctly. Everything the panel doesn't do is one tap +away in that client's own gamepad UI. So the Deck has two ways to stream, and they share one +client + one paired identity: - **Gaming Mode** → the **Decky plugin** (this page). - **Desktop Mode** → run the [Flatpak](/docs/install-client#steam-deck) directly, like any Linux app. @@ -30,11 +32,13 @@ You need three things on the Deck: (Full options: [Install a Client → Steam Deck](/docs/install-client#steam-deck).) If you have no Flatpak but a native `punktfunk-client` — a sysext, a distro package, a nix profile, your own - build — the plugin launches that instead; with both installed the Flatpak wins, unless - `PF_DECKY_CLIENT=native` (or `flatpak`) is set in the plugin backend's environment. But - **pairing, Wake-on-LAN and the host game library still go through the Flatpak**, so install it - on the Deck even then. Both kinds share `~/.config/punktfunk`, so your identity, known hosts - and settings are the same either way. + build — the plugin uses that instead; with both installed the Flatpak wins, unless + `PF_DECKY_CLIENT=native` (or `flatpak`) is set in the plugin backend's environment. Both kinds + share `~/.config/punktfunk`, so your identity, known hosts and settings are the same either way. + + **The client must be v0.22.0 or newer.** The panel drives everything through the client's + headless `punktfunk` command, which shipped in that release. An older client says so in the + panel, with the update button that fixes it right there. 3. **A Punktfunk host** running on your LAN — see [Install the Host](/docs/install). The Deck finds it automatically over mDNS, so nothing to configure here. @@ -64,40 +68,68 @@ The **Punktfunk** panel appears in the Quick Access Menu right away — no Deck ## Use it -Open the **Punktfunk** panel from the Quick Access Menu, or **Open Punktfunk** for the full-screen -page (host list + stream settings). +Open the **Punktfunk** panel from the Quick Access Menu. It has one list — the hosts you can +stream — plus a door into the client's own gamepad UI for everything else. -- **Discover** — hosts on your network appear automatically (mDNS). Tap **Refresh** to rescan. A - lock icon means the host requires [pairing](/docs/pairing). -- **Add a host by hand** — if mDNS can't reach it (another subnet, a VPN), tap **+** on the Hosts - tab and enter its address; the port defaults to **9777**. Saved hosts can be renamed, re-pointed - at a new address, or forgotten from the same row. -- **Sleeping host?** Streaming sends a [Wake-on-LAN](/docs/wake-on-lan) packet first, and when one - actually went out the Deck waits far longer than usual for the host to answer, so a stream - survives a resume from sleep. Nothing to enable — it's a no-op until the plugin has learned that - host's MAC address, and the packet only lands if the host machine is armed to wake in its - BIOS and its network card. -- **Pair** — for a locked host, [arm pairing on the host](/docs/pairing) (its console or web - console shows a 4-digit PIN), then enter that PIN on the Deck's keypad. Pairing persists, so the - next connection is silent. -- **Stream** — pick a host and the stream launches fullscreen in Gaming Mode. The plugin drives a +- **Hosts** — hosts on your network appear automatically (mDNS), alongside the ones you've already + saved. A saved host is also probed directly, so a box reached over a VPN or Tailscale shows as + online even though it never advertises. Tap **Refresh** to rescan. The list sorts online hosts + first, then whichever you streamed most recently. A lock icon means the host still has to let + this Deck in. +- **Let a host in** — tapping a locked host opens a small sheet with two ways through: + - **Request access** — no PIN at all. See [Request access](#request-access) below. + - **Use a PIN instead** — [arm pairing on the host](/docs/pairing) (its console or web console + shows a 4-digit PIN), then enter it on the Deck's keypad. + + Either way the host is remembered, so the next connection is silent. +- **Stream** — tap a host and the stream launches fullscreen in Gaming Mode. The plugin drives a hidden Steam shortcut behind the scenes so gamescope focuses and fullscreens it. -- **Library entry** — a visible, branded **Punktfunk** app also appears in your Steam library. - Launching it opens the client's console home (host picker, pairing, settings), gamepad-navigable - — it does not resume a stream. If it ever disappears, the Quick Access Menu panel has a button to - put it back. -- **Games** — tap **Games** on a host row to browse that host's [library](/docs/game-library), and - **Pin** the ones you play. Pinned games show up on the full page *and* in the Quick Access Menu - as one-tap streams that launch straight into the game. -- **Settings** — resolution, refresh rate, **render scale**, bitrate, **video codec**, gamepad type, - **host compositor**, and mic, written to the client the plugin launches. Leave **Resolution** / - **Refresh** on *Native* to get the Deck's own mode, **Render scale** at 1× unless you want to - trade bandwidth for sharpness (>1×) or sharpness for bandwidth (<1×), and **Video codec** / - **Host compositor** on *Automatic* — that suits almost every host, so change them only when - you're troubleshooting. With **Gamepad type** on *Automatic* the Deck's built-in controller is - forwarded as a **Steam Deck** pad (paddles, both trackpads, gyro) — that needs Steam Input set to - **Off** for Punktfunk (game page → ⚙ → Controller Settings), else Steam keeps those controls and - only sticks + buttons reach the host. +- **Sleeping host?** Streaming sends a [Wake-on-LAN](/docs/wake-on-lan) packet and waits for the + host to actually come back before dialling, so a stream survives a resume from sleep. Nothing to + enable — it's a no-op until the client has learned that host's MAC address, and the packet only + lands if the host machine is armed to wake in its BIOS and its network card. +- **Pinned cards** — a host with pinned [settings profiles](/docs/client-settings) shows them + nested underneath it as `▸ `. Tapping one streams that host with that profile + applied — your "4K on the TV" and "battery saver" presets, one tap each. Pins are made in the + Punktfunk app (or any other client) and shared across all of them; the panel shows them, it + doesn't create them. +- **Open Punktfunk** — opens the client's console home: the host picker, adding a host by address, + pairing, browsing a host's [game library](/docs/game-library), and the **full settings screen**. + This is where resolution, bitrate, codec, audio, controllers and the stats overlay live. +- **Library entry** — a visible, branded **Punktfunk** app also appears in your Steam library, and + launching it opens that same console home — it does not resume a stream. If it ever disappears, + the Quick Access Menu panel has a button to put it back. + +> **Where did the plugin's settings tab go?** Into the app, at **Open Punktfunk → Settings** — the +> same rows over the same settings, gamepad-navigable, and one tap from the same panel. The plugin +> used to carry its own copy of that screen, which meant two places to change one setting and a +> copy that fell behind. There is now one. + +With **Controller type** on *Automatic* the Deck's built-in controller is forwarded as a **Steam +Deck** pad (paddles, both trackpads, gyro) — that needs Steam Input set to **Off** for Punktfunk +(game page → ⚙ → Controller Settings), else Steam keeps those controls and only sticks + buttons +reach the host. + +### Request access + +**Request access lets you in without typing a PIN**: instead of the host showing you a code, you +ask, and whoever is at the host approves the Deck in its [web console](/docs/web-console) or on +screen. + +Tap the host → **Request access**. The Deck says *"Approve this Deck in 's console — the +stream starts by itself"*, and the stream opens and waits. The moment somebody approves it, the +picture comes up — no going back to the panel, nothing else to tap. If nobody approves within +about three minutes, it gives up like any failed connection and you can try again or use a PIN. + +It's the better option when you're not the person sitting at the host, or when reading a PIN off +another screen is awkward. Two things to know: + +- The host must be **advertising on your network** for this to be offered. A host you added by + address (a VPN box, another subnet) has no advertised identity for the Deck to pin, so the sheet + offers the PIN path only and says so. That's a safety rule, not a limitation to work around: + pinning the advertised identity is what stops something else answering in the host's place while + the Deck waits. +- Once approved, the host shows as **paired** and every later stream connects silently. > **Steam Input off is a trade-off, not a free win.** The plugin installs a Steam Input layout > called **Punktfunk** and points its shortcuts at it, and that layout's whole job is making the @@ -115,9 +147,9 @@ input, so it is safe to hit by accident. The plugin **checks for updates itself** — no Decky store needed. It covers **both** the plugin *and* the streaming client (they version independently), so when either has a newer build the panel shows an -**Update** button (in the Quick Access Menu and on the full page). Tap it: the client updates in -place, and if the plugin itself changed it downloads, verifies, replaces itself, and reloads — all -without leaving Gaming Mode. +**Update** button at the top of the panel. Tap it: the client updates in place, and if the plugin +itself changed it downloads, verifies, replaces itself, and reloads — all without leaving Gaming +Mode. One exception: if your client isn't one the plugin can install for you (a sysext, a nix profile, a source build), the panel shows you the update **command** instead of a button — tap-to-install would @@ -139,13 +171,16 @@ The plugin check follows the [channel](/docs/channels) you installed from: a plu | Symptom | Fix | |---|---| -| The stream never starts, **Pair** reports `flatpak-not-found`, or **Games** says the client isn't installed | Install the client Flatpak in Desktop Mode (see [Before you start](#before-you-start)). | -| No hosts listed | Make sure the host is running and on the **same LAN**; the Deck needs `avahi` (shipped on SteamOS). Tap **Refresh**. | -| Pairing fails / "not armed" | The PIN is shown only after you **arm pairing on the host**. Arm it, then enter the PIN within the window. | -| Stream launches but doesn't focus | Start it from the panel (not by launching the Flatpak by hand) so Steam/gamescope focuses it. | -| The stream wedges — black, or won't close | Open the full page → **About** tab → **Force-stop**, then start it again. | -| The **Punktfunk** library entry disappeared | Quick Access Menu → **Recreate library shortcut**; it puts the entry back in place. | -| You want a clean slate | **About** tab → **Reset Punktfunk** — clears saved hosts, stream settings and pinned games on this Deck, and keeps your paired identity. | +| The panel says **"Update the Punktfunk client"** | The installed client predates v0.22.0 and has no `punktfunk` command to drive. Tap the update button in the same panel, or update it in Desktop Mode. | +| The stream never starts, or the panel can't reach the client | Install the client Flatpak in Desktop Mode (see [Before you start](#before-you-start)). | +| No hosts listed | Make sure the host is running and on the **same LAN**. Tap **Refresh**. For a host mDNS can't reach, add it by address in **Open Punktfunk → Add host**. | +| Pairing fails / "not armed" | The PIN is shown only after you **arm pairing on the host**. Arm it, then enter the PIN within the window — or use **Request access** instead, which needs no PIN. | +| **Request access** isn't offered | The host isn't advertising on this network, so there's no identity to pin. Use the PIN path. | +| A request-access stream sits there | That's it waiting — somebody has to approve the Deck on the host. It gives up after about three minutes. | +| Stream launches but doesn't focus | Start it from the panel (not by launching the client by hand) so Steam/gamescope focuses it. | +| The stream wedges — black, or won't close | Panel → **About** → **Force-stop**, then start it again. | +| The **Punktfunk** library entry disappeared | Panel → **Recreate library shortcut**; it puts the entry back in place. | +| You want a clean slate | **Open Punktfunk → Settings** for stream settings, or `punktfunk reset` in Desktop Mode to forget every saved host. Your paired identity is kept either way. | Nothing here matching? The problem is probably on the host side — start at [Troubleshooting](/docs/troubleshooting), which is organised by symptom (host not found, pairing diff --git a/docs-site/content/docs/support-matrix.md b/docs-site/content/docs/support-matrix.md index 9f8f31ec..124c52a2 100644 --- a/docs-site/content/docs/support-matrix.md +++ b/docs-site/content/docs/support-matrix.md @@ -354,7 +354,7 @@ macOS, iOS/iPadOS and tvOS. Android is one app, with Android TV being the same a | iPhone · iPad | ✅ | ✅ | ✅ ⁴ | ✅ | ✅ | ❌ ³ | | Apple TV | ⚠️ ⁵ | ✅ | ✅ ⁴ | ✅ | ✅ | ❌ ³ | | Android · Android TV | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ ³ | -| Decky (Steam Deck) | ❌ ⁶ | ❌ | ✅ ⁷ | ❌ | ✅ | ✅ ⁸ | +| Decky (Steam Deck) | ⚠️ ⁶ | ❌ | ⚠️ ⁷ | ❌ | ✅ | ✅ ⁸ | | `punktfunk` CLI | ✅ | ✅ ⁹ | ✅ | ✅ | ✅ | ❌ | | Moonlight | ❌ ¹⁰ | ❌ ¹⁰ | ✅ ¹¹ | ❓ ¹² | ❓ ¹² | ❓ ¹² | @@ -375,10 +375,12 @@ macOS, iOS/iPadOS and tvOS. Android is one app, with Android TV being the same a exists, so a fresh Apple TV has none. Of the settings a profile can carry, tvOS also drops the ones the platform has no input for: inverted scroll, modifier layout, variable refresh rate, mouse mode and touch mode. -6. The plugin writes flat values into the shared client settings; it has no profile surface. The - client it launches still honours whatever profile that settings file names. -7. Including pinned one-tap "Stream *game*" rows in the Quick Access Menu, which follow a host - across IP changes. Not subject to the desktop opt-in. +6. The panel *shows* the profiles a host has pinned, as nested one-tap cards, and streams with + them; it has no profile surface of its own. Pins are made in a client's own UI — including the + console home **Open Punktfunk** opens — and are shared, so every client shows the same cards. + Creating and editing a profile stays a desktop-app job. +7. Not in the panel: **Open Punktfunk** opens the client's console home, and a paired host's + library is one button from there. 8. Both the plugin itself and, where the install kind allows it, the client it launches. 9. The CLI parses and follows links; it does not register the URL scheme — the graphical apps do. 10. [Profiles and links](/docs/profiles-and-links) are Punktfunk-app concepts and do not exist on @@ -400,7 +402,8 @@ macOS, iOS/iPadOS and tvOS. Android is one app, with Android TV being the same a 1. Multiple controllers, each on its own stable slot, arriving and leaving independently. The pad **type** the host emulates is picked per pad; the pickers are not identical across apps — Linux, - Android and Decky offer six presets including Steam Deck, Windows and Apple offer five. + Android and the console home offer six presets including Steam Deck, Windows and Apple offer + five. 2. DualSense and DualShock 4 touchpad and motion are forwarded, and the host's adaptive-trigger and lightbar effects are replayed on a real DualSense. On the desktop clients any controller SDL exposes a gyro on forwards motion — a Switch Pro or the Steam Deck's own pad included — and the @@ -514,7 +517,7 @@ capability. | **Linux and Windows desktop clients** | Packaged and current. They are one codebase: the same session binary streams for both, and for the Decky plugin and the `punktfunk` CLI. | | **Apple client** (macOS · iOS · iPadOS · tvOS) | One universal build, distributed as a **TestFlight beta**; the Mac also has a notarized DMG. Feature-complete apart from the platform gaps named above (no microphone on tvOS, clipboard on macOS only). | | **Android client** (phone · TV) | Published on **Google Play** as a public listing for releases, with an invite-only Internal testing track for canary, plus a sideloadable APK. The same app in leanback mode is the TV client. | -| **Decky plugin** (Steam Deck) | Ships through install-from-URL rather than the Decky store, and keeps itself and the client it launches up to date. It launches the Linux client rather than streaming itself, and has no settings surface of its own beyond the flat values it writes into the shared client settings. | +| **Decky plugin** (Steam Deck) | Ships through install-from-URL rather than the Decky store, and keeps itself and the client it launches up to date. It is a launcher, not a second client: it starts the Linux client rather than streaming itself, and holds no settings, no library and no host editor of its own — its **Open Punktfunk** button hands all of that to the client's console home. | | **Web console** | The full management surface — dashboard and sessions, pairing, library, displays, plugins and the plugin store, logs, stats, settings, and host updates. It cannot yet run a speed test or set a bitrate; the client apps can. | | **Plugins** | Three first-party ones (ROM Manager, Playnite, VirtualHere) plus the SDK, installed from the console. See [Plugins](/docs/plugins). | | **`pf-webos`** (LG TV) | A community client in a separate repository. Nothing here can establish its state; ask that project. | diff --git a/docs-site/content/docs/wake-on-lan.md b/docs-site/content/docs/wake-on-lan.md index 5a8c09ae..65367143 100644 --- a/docs-site/content/docs/wake-on-lan.md +++ b/docs-site/content/docs/wake-on-lan.md @@ -74,9 +74,10 @@ saved host's own menu, and only appears when that host is offline *and* an addre | Android · Android TV | **Wake host** — waits, showing the "Waking…" screen | **Wake-on-LAN MAC** in **Edit host** | | Punktfunk Console (controller shell) | on an offline host with a known address, the confirm button reads **Wake & Connect** — it waits, then connects | not offered | -Punktfunk Console has no auto-wake setting of its own, and offers **Wake & Connect** whatever the -desktop app's setting says. In the Apple apps the same button appears when you drive them with a -controller, but there it does follow the auto-wake setting. +Punktfunk Console carries the row too — **Wake hosts automatically**, in the same settings list the +desktop apps write — but its **Wake & Connect** button is an explicit action and appears whatever +that row says. In the Apple apps the same button appears when you drive them with a controller, but +there it does follow the auto-wake setting. The Apple apps also publish a **Wake Host** action to Shortcuts, so an automation can wake a host without opening the app. On iPhone and iPad it has a ready-made phrase: *"Wake ⟨host⟩ with @@ -88,10 +89,14 @@ host list, and shows an explanation with a link to system settings if you declin ### On the Steam Deck -The [Decky plugin](/docs/steam-deck) has no wake button and no wake setting. It sends a wake through -the Flatpak client just before **every** stream launch, and it is a no-op until that client has -learned the host's address. When a packet really did go out, the plugin also stretches the stream's -connect budget to 75 seconds, so the connection survives the host resuming from sleep. +The [Decky plugin](/docs/steam-deck) has no wake button and no wake setting of its own. It starts +every stream through the client, so the wake is the client's, on exactly the terms above: a packet +the moment the host doesn't answer, re-sent every 6 seconds while the client watches for it once a +second, and the dial only when it really is back. It follows **Wake hosts automatically** in the +client's own settings — **Open Punktfunk → Settings** from the same panel — and is a no-op until the +client has learned that host's MAC address. (The plugin used to fire a packet itself and stretch the +connect budget to 75 seconds to cover the resume; a wait that watches for the host beats a fixed +budget, so that is gone.) ### From the command line From 7e40098bc6a0aefe60676d2d0c237fba2e8912c9 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 21:00:15 +0200 Subject: [PATCH 41/53] test(decky): tear the CLI fixture dir down before building it The "a native install with no sibling CLI resolves to None" check created /tmp/pf-test-native/bin/punktfunk and never removed it, so the assertion that the sibling is ABSENT held only on the first run on a given machine and failed on every rerun. Caught by running the suite twice. --- clients/decky/scripts/test-backend.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/clients/decky/scripts/test-backend.py b/clients/decky/scripts/test-backend.py index 4f4ad9f5..ea04ddf3 100644 --- a/clients/decky/scripts/test-backend.py +++ b/clients/decky/scripts/test-backend.py @@ -61,6 +61,12 @@ check( # A native install: the CLI is the client binary's sibling. Absent => no CLI at all, which the # caller must see as "unavailable" rather than as an empty result. +# +# The fixture dir is torn down FIRST, not just created: leaving the sibling behind made the +# "absent" assertion below pass only on the first run of the day and fail on every rerun. +import shutil # noqa: E402 + +shutil.rmtree("/tmp/pf-test-native", ignore_errors=True) tmp = Path("/tmp/pf-test-native/bin") tmp.mkdir(parents=True, exist_ok=True) (tmp / "punktfunk-client").write_text("") From 8042a2fd5290881207559c01653f60186c0e80a7 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 21:01:59 +0200 Subject: [PATCH 42/53] fix(decky): a saved host's pin is what it PINNED, never what it's advertising MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mergeHosts` filled a row's fingerprint as `s.fp_hex || advert?.fp || ""`, so a host saved by address — nothing pinned on disk — borrowed the fingerprint of whatever was advertising at that address and rendered as ready to stream. The launch then refused for want of a pin, from a row that had just shown "Stream" and "trusted". Under the old rule the mistake was mostly hidden, because `needsPair` asked a different question for saved and unsaved rows. This rework makes a pinned fingerprint the ONLY rule, so the same conflation would now decide the whole thing. The two are different facts and are now separate fields. `fp` is what the RECORD pins — the thing the session binary requires. `advertisedFp` is what the host is offering right now, which is what request access would pin, and moving one to the other is a trust decision the user makes in the sheet rather than something the merge does behind them. The trust sheet gates on and pins `advertisedFp` accordingly: a saved placeholder that happens to be advertising can now be let in with request access, and one that isn't still gets the PIN path with the reason. --- clients/decky/src/hooks.ts | 20 +++++++++++++++++--- clients/decky/src/trust.tsx | 6 ++++-- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/clients/decky/src/hooks.ts b/clients/decky/src/hooks.ts index f0735487..bb9d6bb5 100644 --- a/clients/decky/src/hooks.ts +++ b/clients/decky/src/hooks.ts @@ -56,8 +56,19 @@ export interface HostView { name: string; addr: string; port: number; - /** Pinned cert fingerprint. "" = nothing pinned, which is what makes a host unstreamable. */ + /** + * The fingerprint PINNED ON THE RECORD. "" means nothing is pinned, which is exactly what + * makes a host unstreamable — the session binary refuses a pinless connect. + * + * Deliberately NOT filled in from a live advert. A host saved by address that happens to be + * advertising right now still has an empty pin on disk, and borrowing the advert's here would + * draw it as ready to stream while every launch refused for want of a fingerprint. What the + * advert offers is [`advertisedFp`], and moving it onto the record is a trust decision the + * user makes in the sheet. + */ fp: string; + /** What the host is advertising right now, if anything — what request access would pin. */ + advertisedFp: string; paired: boolean; online: boolean; saved: boolean; @@ -118,7 +129,8 @@ export function mergeHosts(saved: SavedHost[], discovered: DiscoveredHost[]): Ho name: s.name || s.addr, addr: advert?.addr ?? s.addr, port: advert?.port ?? s.port, - fp: s.fp_hex || advert?.fp || "", + fp: s.fp_hex, + advertisedFp: advert?.fp ?? "", paired: s.paired, online: !!advert || s.online === true, saved: true, @@ -138,7 +150,9 @@ export function mergeHosts(saved: SavedHost[], discovered: DiscoveredHost[]): Ho name: a.name, addr: a.addr, port: a.port, - fp: a.fp, + // No record, so nothing is pinned — whatever it advertises is an OFFER, not a pin. + fp: "", + advertisedFp: a.fp, paired: a.paired, online: true, saved: false, diff --git a/clients/decky/src/trust.tsx b/clients/decky/src/trust.tsx index 132b864e..d839c023 100644 --- a/clients/decky/src/trust.tsx +++ b/clients/decky/src/trust.tsx @@ -53,7 +53,9 @@ export const TrustSheet: FC<{ const props = useRef({ host, onStream, onChanged }); props.current = { host, onStream, onChanged }; - const canRequestAccess = host.fp !== ""; + // Request access pins what the host ADVERTISES. The record's own pin is a different thing: + // a host that already has one streams without ever opening this sheet. + const canRequestAccess = host.advertisedFp !== ""; const requestAccess = async () => { setBusy(true); @@ -62,7 +64,7 @@ export const TrustSheet: FC<{ try { // Step 1: save it with the ADVERTISED fingerprint, pinned but unpaired ("trusted"). // Idempotent, so a retry after a declined approval is free. - const r = await trustHost(h.addr, h.port, h.fp, h.name); + const r = await trustHost(h.addr, h.port, h.advertisedFp, h.name); if (!r.ok) { setError(trustErrorBody(r.error, h.name)); setBusy(false); From 6267dcdcd3a368b1ec2027ebb77a301f9e991d8a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 21:04:16 +0200 Subject: [PATCH 43/53] fix(decky): let a CLI payload's own key never override this layer's `ok` `{"ok": True, **data}` let a future payload carrying its own `ok` report failure through the field the shell layer owns. Spread first, set `ok` last. --- clients/decky/main.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/clients/decky/main.py b/clients/decky/main.py index 8107a023..64695fb8 100644 --- a/clients/decky/main.py +++ b/clients/decky/main.py @@ -426,7 +426,9 @@ async def _cli_json(args: list[str], timeout: float = 20.0) -> dict: try: data = json.loads(out) if isinstance(data, dict): - return {"ok": True, **data} + # `ok` last: a payload that ever grows its own `ok` key must not be able to + # report failure through the field this layer owns. + return {**data, "ok": True} except json.JSONDecodeError: decky.logger.warning("cli %s: unparseable output: %s", args[0], out[:200]) return {"ok": False, "error": "client-error", "detail": "unreadable output"} From 414380fc9eb56cf337863bc0ecf557999eff05d2 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 21:07:37 +0200 Subject: [PATCH 44/53] fix(cli): discover reads the host store without writing to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `KnownHosts::load()` mints a stable id for any record that lacks one and SAVES it — which makes it a write, and `discover` was calling it purely to annotate what the browse found with saved/paired. It never hands those ids back to anyone. That matters because the Decky panel issues `discover` and `hosts list` together, in parallel. Against a store written before ids existed, both processes read it, both mint DIFFERENT ids for the same record, and both save. Whichever loses the race has already handed its ids to its caller — so the panel could draw a row whose host reference no longer resolves, and pressing it would exit 5 ("no saved host matches") until the next refresh settled things. `KnownHosts::read()` is `load` without the mint: the store exactly as it is on disk. `discover` uses it; every caller that dials a host by id still uses `load`, so ids are still minted the first time anything needs one. Verified on a fixture store with no ids: `punktfunk discover` leaves it byte-identical, and a following `punktfunk hosts list` mints as before. --- clients/cli/src/main.rs | 6 +++++- crates/pf-client-core/src/trust.rs | 22 +++++++++++++++++----- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/clients/cli/src/main.rs b/clients/cli/src/main.rs index bf993d0c..98d4a62c 100644 --- a/clients/cli/src/main.rs +++ b/clients/cli/src/main.rs @@ -363,7 +363,11 @@ from the config directory for a true factory reset." .unwrap_or(DISCOVER_DEFAULT_SECS) .min(DISCOVER_MAX_SECS); let found = pf_client_core::discovery::discover_for(Duration::from_secs_f64(secs)); - let known = KnownHosts::load(); + // `read`, not `load`: this verb only LOOKS at the records to annotate what it found, and + // never hands their ids back. `load` would mint ids for a pre-mint store and save them — + // a write from a read-only verb, and one that races the `hosts list` a caller is very + // likely running at the same moment (the Decky panel issues both together). + let known = KnownHosts::read(); let rows: Vec<( &pf_client_core::discovery::DiscoveredHost, Option<&KnownHost>, diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index 929e245c..9ab18cf2 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -232,17 +232,29 @@ impl KnownHosts { /// A read-only config dir just keeps re-minting in memory, which harms nothing: no lookup /// is keyed by the id yet (design §4.5). pub fn load() -> KnownHosts { - let mut k: KnownHosts = Self::path() - .and_then(|p| Ok(std::fs::read_to_string(p)?)) - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_default(); + let mut k = Self::read(); if k.mint_missing_ids() { let _ = k.save(); } k } + /// The store exactly as it is on disk — no mint, and so no write. + /// + /// For a consumer that only needs to LOOK at the records (annotating a discovery result + /// against them, say) and never dials one by id. [`KnownHosts::load`]'s mint is a write, and + /// two processes started together against a pre-mint store will each mint a *different* id + /// for the same record and race to save it — after which whichever one already handed its + /// ids to a caller has handed out references that no longer resolve. A read that stays a + /// read cannot take part in that. + pub fn read() -> KnownHosts { + Self::path() + .and_then(|p| Ok(std::fs::read_to_string(p)?)) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() + } + /// Give every record still missing one a stable id; returns true if anything changed /// (i.e. whether this needs persisting). Idempotent — a store that has been through it /// once is left byte-identical. From bf2d8505cf437e017ab0a739646481bc1bb47691 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 21:08:52 +0200 Subject: [PATCH 45/53] docs(decky): the gamepad-UI shortcut comment still named PF_HOST PF_HOST is gone; the browse branch is keyed on PF_BROWSE alone and runs the SESSION binary, which is the one path this rework deliberately did not repoint. Comment only. --- clients/decky/src/steam.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/clients/decky/src/steam.ts b/clients/decky/src/steam.ts index 84fe5c77..6cd535b8 100644 --- a/clients/decky/src/steam.ts +++ b/clients/decky/src/steam.ts @@ -257,10 +257,11 @@ export async function ensureGamepadUiShortcut(): Promise { } const startDir = info.runner.replace(/\/[^/]*$/, ""); void ensureControllerConfig(); - // Bare browse: PF_BROWSE with no PF_HOST → the wrapper runs `--browse --fullscreen` (console - // home). %command% expands to the shortcut exe (/bin/sh); the wrapper rides behind as an arg. - // PF_CLIENT_BIN only when the backend resolved a NATIVE client — else the wrapper's flatpak - // default stands and this shortcut is exactly what it always was. + // PF_BROWSE → the wrapper runs the SESSION's `--browse --fullscreen` (console home), which is + // the one branch this rework deliberately left alone. %command% expands to the shortcut exe + // (/bin/sh); the wrapper rides behind as an arg. PF_CLIENT_BIN only when the backend resolved + // a NATIVE client — else the wrapper's flatpak default stands and this shortcut is exactly + // what it always was. const clientBin = info.client_bin ? `PF_CLIENT_BIN=${info.client_bin} ` : ""; const launchOpts = `${clientBin}PF_BROWSE=1 %command% "${info.runner}"`; From 0890cf324445d8ffa1afcabbee3006cfd909b83d Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 21:25:55 +0200 Subject: [PATCH 46/53] docs(apple/store): App Store copy for iOS, macOS and tvOS, counted against Apple's limits German-first Promotional Text, Descriptions, Keywords and App Review notes, plus the app-specific privacy text the existing website policy is missing. Every character-limited field is checked by check-limits.py, which also catches headings whose stated count has drifted from the real length. Three things the brief assumed turned out not to hold, and the copy says so rather than shipping the claim: a Mac cannot act as a host, the published privacy policy covers only the website, and the App Review notes field caps at 4000 characters. --- clients/apple/store/README.md | 51 +++++++ clients/apple/store/check-limits.py | 82 ++++++++++ clients/apple/store/ios.md | 71 +++++++++ clients/apple/store/macos.md | 159 ++++++++++++++++++++ clients/apple/store/privacy-app-addendum.md | 146 ++++++++++++++++++ clients/apple/store/review-notes.md | 132 ++++++++++++++++ clients/apple/store/tvos.md | 145 ++++++++++++++++++ 7 files changed, 786 insertions(+) create mode 100644 clients/apple/store/README.md create mode 100644 clients/apple/store/check-limits.py create mode 100644 clients/apple/store/ios.md create mode 100644 clients/apple/store/macos.md create mode 100644 clients/apple/store/privacy-app-addendum.md create mode 100644 clients/apple/store/review-notes.md create mode 100644 clients/apple/store/tvos.md diff --git a/clients/apple/store/README.md b/clients/apple/store/README.md new file mode 100644 index 00000000..7c153478 --- /dev/null +++ b/clients/apple/store/README.md @@ -0,0 +1,51 @@ +# App Store copy + +Source of truth for what goes into App Store Connect. Every character-limited field in here has +been counted with `check-limits.py`; run it after any edit. + +```sh +python3 clients/apple/store/check-limits.py +``` + +| File | Covers | +|------|--------| +| [`ios.md`](ios.md) | iOS/iPadOS Promotional Text (DE + EN), with alternates | +| [`macos.md`](macos.md) | macOS Promotional Text, Description, Keywords (DE + EN) | +| [`tvos.md`](tvos.md) | tvOS Promotional Text, Description, Keywords (DE + EN) | +| [`review-notes.md`](review-notes.md) | App Review notes template + pre-submission checklist | +| [`privacy-app-addendum.md`](privacy-app-addendum.md) | App-specific privacy text to add to the existing policy page | + +German is primary throughout and uses the same informal "du" voice as the website +(`punktfunk-website/messages/de.json`). English is a localisation, not a translation exercise — a +few lines diverge where the German idiom does not carry. + +## Three things that contradicted the original brief + +1. **A Mac cannot be a host.** The brief suggested Mac copy could cover "running as a host/server + or client on Mac". There is no macOS host — `punktfunk-host` has no macOS capture, virtual + display, or encode backend. The macOS copy is client-only and says so explicitly. +2. **The existing privacy policy is website-only.** It covers server logs, Plausible, and a + language cookie, and never mentions the apps. Linking it unchanged from App Store Connect is + the kind of thing that draws a reviewer's attention to analytics that have nothing to do with + the app. See `privacy-app-addendum.md` for the text to append. +3. **App Review notes cap at 4000 characters**, not the unlimited field the brief implied. The + template is 3919 and fits. + +## Claims used, and where they come from + +Everything asserted in the copy was checked against the source rather than the marketing site: + +- Hardware decode, HDR/4:4:4, controller and input support — `clients/apple/README.md` +- Entitlements and their justifications — `Config/Punktfunk.entitlements`, + `Config/Punktfunk-macOS.entitlements` (both carry detailed rationale comments) +- Background audio mode and its 2.5.4 constraints — `Config/Info.plist` +- "Collects no data" — verified by absence: no analytics SDK in `Package.swift`, no telemetry + symbols in `Sources/`, `URLSession` used only against the paired host +- Host platforms and protocol details — root `README.md`, `docs/releases/v0.24.0.md` +- Feature ship dates — `git tag --contains` on the relevant commits + +## Not done here + +`clients/apple` has no `PrivacyInfo.xcprivacy`. The app uses `UserDefaults`, which is a +required-reason API, so a manifest is expected. Flagged at the end of `review-notes.md`; left +alone because it is a code change, not copy. diff --git a/clients/apple/store/check-limits.py b/clients/apple/store/check-limits.py new file mode 100644 index 00000000..402368e7 --- /dev/null +++ b/clients/apple/store/check-limits.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Check every App Store copy block in this directory against its field limit. + +App Store Connect silently truncates or hard-rejects over-long fields, and the German copy is the +easy one to get wrong because umlauts read as one character but two bytes. Apple counts characters, +so `len()` on a `str` is the right measure — do not switch this to a byte count. + +Each fenced code block in the .md files here is one field. Which limit applies is inferred from the +nearest heading above it. Exit status is non-zero if anything is over, so CI can gate on it. +""" + +from __future__ import annotations + +import pathlib +import re +import sys + +LIMITS = {"PROMO": 170, "DESC": 4000, "KW": 100, "NOTES": 4000} + + +def blocks(text: str): + """Yield (heading, body) for every fenced block, tagged with the heading above it.""" + heading = None + buf: list[str] | None = None + for line in text.split("\n"): + if line.startswith("#") and buf is None: + heading = line.lstrip("#").strip() + if line.strip() == "```": + if buf is None: + buf = [] + else: + yield heading or "", "\n".join(buf) + buf = None + continue + if buf is not None: + buf.append(line) + + +def kind_of(heading: str, body: str) -> str: + low = heading.lower() + if "keyword" in low or re.fullmatch(r"(de|en) \(\d+\)", low): + return "KW" + if "template" in low: + return "NOTES" + return "DESC" if len(body) > 400 else "PROMO" + + +def main() -> int: + here = pathlib.Path(__file__).parent + failures = 0 + stale = 0 + for path in sorted(here.glob("*.md")): + found = list(blocks(path.read_text(encoding="utf-8"))) + if not found: + continue + print(f"\n=== {path.name} ===") + for heading, body in found: + kind = kind_of(heading, body) + limit = LIMITS[kind] + n = len(body) + over = n > limit + failures += over + # Headings carry the count in parentheses; flag any that drifted from the real length. + claimed = re.search(r"\((\d+)\)\s*$", heading) + drift = "" + if claimed and int(claimed.group(1)) != n: + drift = f" [heading claims {claimed.group(1)}]" + stale += 1 + status = "OVER" if over else "ok" + print(f" [{kind:5}] {status:>4} {n:>4}/{limit} {heading[:48]}{drift}") + + if failures: + print(f"\n{failures} block(s) OVER the limit") + elif stale: + print(f"\nAll within limits, but {stale} heading count(s) are stale") + else: + print("\nAll blocks within limits, all heading counts accurate") + return 1 if failures or stale else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/clients/apple/store/ios.md b/clients/apple/store/ios.md new file mode 100644 index 00000000..6118ea24 --- /dev/null +++ b/clients/apple/store/ios.md @@ -0,0 +1,71 @@ +# iOS / iPadOS — App Store metadata + +Existing, unchanged: + +- **Name:** Punktfunk +- **Subtitle (DE):** Schnell, lokal & offen. + +Only the Promotional Text is new here. It is the one field that can be changed **without** a new +build or a review, so it is the right place for "what landed most recently". + +--- + +## Promotional Text (DE) — max 170 characters + +### Primary (160) + +``` +Neu: Profile pro Host – Auflösung, Bitrate und Ton einmal einstellen, dann mit einem Tipp verbinden. Dazu Live Activity, Sperrbildschirm-Widget und Wake-on-LAN. +``` + +### Alternate A — evergreen hook, no "new" claim (156) + +``` +Dein Gaming-PC auf dem iPhone, in dessen exakter Auflösung – ohne Konto, ohne Cloud, nur dein Netzwerk. Hardware-Decoding, HDR und dein DualSense mit allem. +``` + +### Alternate B — leads on the DualSense (161) + +``` +Dein DualSense, vollständig: Rumble, adaptive Trigger, Lightbar, Touchpad und Gyro gehen bis ins Spiel durch. Dazu Profile pro Host und Wake-on-LAN vom Sofa aus. +``` + +### Alternate C — leads on latency (153) + +``` +Kein Konto, keine Cloud, kein Umweg: punktfunk/1 fährt über QUIC direkt zu deinem PC. Auflösungswechsel mitten im Stream, ohne die Verbindung zu trennen. +``` + +--- + +## Promotional Text (EN) — max 170 characters + +### Primary (152) + +``` +New: per-host profiles — set resolution, bitrate and audio once, then connect with one tap. Plus Live Activities, a Lock Screen widget, and Wake-on-LAN. +``` + +### Alternate A — evergreen hook (159) + +``` +Your gaming PC on your iPhone, at your iPhone's exact resolution — no account, no cloud, just your network. Hardware decoding, HDR, and your DualSense in full. +``` + +### Alternate B — leads on the DualSense (160) + +``` +Your DualSense, in full: rumble, adaptive triggers, lightbar, touchpad and gyro all reach the game. Plus per-host profiles and Wake-on-LAN from across the room. +``` + +--- + +## Notes on the claims + +- "Profile pro Host" shipped in **v0.22.0** (`25b12780`, `80c0ca69`) and is in every tag since. It is + the strongest recent user-facing Apple feature, so "Neu" is defensible for one release cycle — but + drop the word once 0.25 ships something newer. +- Live Activities and the Hosts widget shipped long ago (`ba1caf02`, in v0.15.0+). They are safe to + *mention* but should not be called "neu". +- The only Apple-visible feature unique to **v0.24.0** is the "Forward controllers" off switch + (`b297542c`), which is too niche to headline. diff --git a/clients/apple/store/macos.md b/clients/apple/store/macos.md new file mode 100644 index 00000000..673a6303 --- /dev/null +++ b/clients/apple/store/macos.md @@ -0,0 +1,159 @@ +# macOS — App Store metadata + +> **Scope correction.** The Mac app is a **client only**. There is no macOS host: `punktfunk-host` +> has no macOS capture, virtual-display, or encode backend (the two `cfg!(target_os = "macos")` hits +> in the host crate are OS *detection* for the host tile and a path helper; the loopback-test host +> is a synthetic frame source for `test-loopback.sh`, not a shippable host). A macOS host is a +> feasibility study — it needs four new backends and the private `CGVirtualDisplay` API. +> None of the copy below claims a Mac can host, and it should not until that ships. + +- **Name:** Punktfunk +- **Subtitle (DE):** Schnell, lokal & offen. +- **Subtitle (EN):** Fast, local & open. + +--- + +## Promotional Text (DE) — max 170 characters + +### Primary (164) + +``` +Neu: Profile pro Host – ein Mac, mehrere Gaming-PCs, jeder mit eigenen Einstellungen. Dazu AV1-Hardware-Decoding auf M3 und neuer, HDR und volles 4:4:4 für Schrift. +``` + +### Alternate (156) + +``` +Dein Gaming-PC im Fenster oder im Vollbild, in der exakten Auflösung deines Displays. Maus und Tastatur gehen durch, Auflösungswechsel ohne neue Verbindung. +``` + +## Promotional Text (EN) — max 170 characters + +### Primary (161) + +``` +New: per-host profiles — one Mac, several gaming PCs, each with its own settings. Plus AV1 hardware decoding on M3 and later, HDR, and full 4:4:4 for crisp text. +``` + +### Alternate (156) + +``` +Your gaming PC in a window or full screen, at your display's exact resolution. Mouse and keyboard pass straight through; resize without dropping the stream. +``` + +--- + +## Description (DE) — max 4000 characters + +``` +Punktfunk streamt deinen Gaming-PC auf den Mac – in der exakten Auflösung und Bildwiederholrate deines Displays, über dein eigenes Netzwerk, ohne Konto und ohne Cloud. + +Punktfunk besteht aus zwei Hälften: einem Host auf dem PC, von dem du streamst, und dieser App auf dem Gerät, auf dem du spielst. Der Host ist quelloffen und kostenlos, läuft auf Linux und auf Windows 11 – auf dem Gaming-Rig unterm Schreibtisch, auf einem Laptop oder headless auf einem Server, an dem gar kein Monitor hängt. + +DEIN MAC BEKOMMT SEIN EIGENES DISPLAY + +Für jede Verbindung legt der Host ein echtes virtuelles Display an – in genau der Auflösung und Bildrate, die dein Mac meldet. Kein Skalieren, keine schwarzen Balken, kein Umsortieren deiner echten Monitore. Änderst du mitten im Stream die Fenstergröße oder gehst auf Vollbild, wird die Auflösung neu ausgehandelt, ohne die Verbindung zu trennen. Mehrere Geräte können gleichzeitig streamen, jedes auf seinem eigenen Display. + +SCHNELL, WEIL UNS DER GANZE WEG GEHÖRT + +Die nativen Apps sprechen punktfunk/1: eine QUIC-Steuerebene und eine verschlüsselte Datenebene mit Vorwärtsfehlerkorrektur, die Auflösung und Bildrate mitten im Stream wechselt, ohne neu zu verbinden. Dekodiert wird in Hardware über VideoToolbox – H.264, HEVC und AV1 auf Macs, die AV1 in Hardware können (M3 und neuer). + +FÜR DEN MAC GEMACHT + +• Im Fenster oder im Vollbild, auf jedem angeschlossenen Display +• Maus und Tastatur gehen vollständig durch – Klick zum Fangen, Cmd+Esc oder Ctrl+Alt+Shift+Q zum Freigeben +• Ein Stream-Menü in der Menüleiste: Maus freigeben, Trennen, Statistik einblenden +• Mikrofon-Uplink mit Echounterdrückung – dein Mac wird zum Headset am PC +• HDR mit PQ-Passthrough und ein optionaler Vollchroma-Modus (4:4:4), damit kleine Schrift und feine Linien scharf bleiben + +CONTROLLER, VOLLSTÄNDIG + +DualSense, Xbox- und weitere MFi-kompatible Controller. Beim DualSense gehen Rumble, Lightbar, Player-LEDs, adaptive Trigger, Touchpad und Gyro bis ins Spiel durch. Welchen Typ das virtuelle Gamepad am Host annimmt, richtet sich nach dem, was bei dir wirklich in der Hand liegt. + +DEINE BIBLIOTHEK, DEIN NETZWERK + +Installierte Steam-Titel und selbst hinzugefügte Spiele erscheinen als Raster mit Artwork und starten direkt. Hosts findet die App im Netzwerk von allein. Beim ersten Mal koppelst du einmalig mit einer PIN, danach verbindet sich der Mac über eine gepinnte Identität aus deinem Schlüsselbund – kein Konto, kein Login. Einen schlafenden PC weckt Punktfunk per Wake-on-LAN. + +MESSEN STATT GLAUBEN + +Ein gestuftes Overlay zeigt Bildrate, Bitrate und Latenz – über zwei Maschinen hinweg um den Uhrenversatz korrigiert, also eine Messung und kein Versprechen. Ein Geschwindigkeitstest pro Host schlägt eine passende Bitrate vor. Profile halten pro Host fest, wie gestreamt werden soll. + +WAS DU BRAUCHST + +Einen Punktfunk-Host auf einem Linux-PC oder auf Windows 11 (22H2 oder neuer) im selben Netzwerk. Der Host ist quelloffen (MIT/Apache-2.0) und kostenlos – Anleitungen und Quellcode findest du auf punktfunk.unom.io. Diese App ist der Client: ein Mac kann derzeit nicht selbst Host sein. + +Kein Konto. Keine Cloud. Keine Telemetrie. Die App erfasst keine Daten über dich. +``` + +--- + +## Description (EN) — max 4000 characters + +``` +Punktfunk streams your gaming PC to your Mac — at your display's exact resolution and refresh rate, over your own network, with no account and no cloud. + +Punktfunk comes in two halves: a host on the PC you stream from, and this app on the device you play on. The host is open source and free, and runs on Linux and on Windows 11 — on the gaming rig under your desk, on a laptop, or headless on a server with no monitor attached at all. + +YOUR MAC GETS A DISPLAY OF ITS OWN + +For every connection, the host creates a real virtual display at exactly the resolution and refresh rate your Mac reports. No scaling, no black bars, no rearranging your actual monitors. Resize the window mid-stream or go full screen and the resolution is renegotiated without dropping the connection. Several devices can stream at once, each on its own display. + +FAST, BECAUSE WE OWN THE WHOLE PATH + +The native apps speak punktfunk/1: a QUIC control plane and an encrypted data plane with forward error correction, able to change resolution and frame rate mid-stream without reconnecting. Decoding is done in hardware through VideoToolbox — H.264, HEVC, and AV1 on Macs with an AV1 hardware decoder (M3 and later). + +BUILT FOR THE MAC + +• In a window or full screen, on any attached display +• Mouse and keyboard pass straight through — click to capture, Cmd+Esc or Ctrl+Alt+Shift+Q to release +• A Stream menu in the menu bar: release the mouse, disconnect, toggle the stats overlay +• Microphone uplink with echo cancellation — your Mac becomes the headset on your PC +• HDR with PQ passthrough, plus an optional full-chroma (4:4:4) mode that keeps small text and fine UI lines sharp + +CONTROLLERS, IN FULL + +DualSense, Xbox, and other MFi-compatible controllers. On a DualSense, rumble, lightbar, player LEDs, adaptive triggers, touchpad, and gyro all reach the game. The virtual gamepad the host presents takes its type from the controller actually in your hands. + +YOUR LIBRARY, YOUR NETWORK + +Installed Steam titles and games you add yourself appear as a grid with artwork, ready to launch. The app finds hosts on your network by itself. The first time, you pair once with a PIN; after that your Mac reconnects on a pinned identity stored in your keychain — no account, no login. Punktfunk can wake a sleeping PC over Wake-on-LAN. + +MEASURED, NOT PROMISED + +A tiered overlay shows frame rate, bitrate, and latency — corrected for clock skew across the two machines, so it is a measurement rather than a claim. A per-host speed test suggests a bitrate that matches your link. Profiles remember how each host should be streamed. + +WHAT YOU NEED + +A Punktfunk host on a Linux PC or on Windows 11 (22H2 or later) on the same network. The host is open source (MIT/Apache-2.0) and free — guides and source at punktfunk.unom.io. This app is the client: a Mac cannot currently act as a host. + +No account. No cloud. No telemetry. This app collects no data about you. +``` + +--- + +## Keywords — max 100 characters + +Comma-separated, **no spaces after the commas** (spaces count against the limit). The app name and +the subtitle are already indexed, so `punktfunk`, `schnell`, `lokal`, and `offen` are deliberately +absent — repeating them would waste characters. + +### DE (97) + +``` +streaming,spiele,remote,desktop,fernzugriff,pc,linux,windows,controller,gamepad,latenz,quelloffen +``` + +### EN (95) + +``` +streaming,remote,desktop,pc,linux,windows,gaming,controller,gamepad,latency,selfhosted,lan,play +``` + +**Deliberately excluded:** `Moonlight`, `GameStream`, `NVIDIA`, `Steam`. Punktfunk genuinely is +GameStream-compatible and does read your Steam library, but App Store Review Guideline 4.1 and the +metadata rules disallow third-party app, product, and company names in the **keyword** field — it is +a routine rejection. Saying it in the description is fine; the current descriptions avoid naming +Moonlight and mention Steam only as a factual statement about your own library. + +The previous keyword set (`Game-Streaming, Lokal, Open-Source, Gaming`) spent characters on spaces, +on `Lokal` (already in the subtitle), and on both `Game-Streaming` and `Gaming`, which share a stem. diff --git a/clients/apple/store/privacy-app-addendum.md b/clients/apple/store/privacy-app-addendum.md new file mode 100644 index 00000000..e89d72dc --- /dev/null +++ b/clients/apple/store/privacy-app-addendum.md @@ -0,0 +1,146 @@ +# Privacy — what to link from App Store Connect + +## The situation + +You already have a privacy policy at **punktfunk.unom.io/legal/privacy**. It is good, current +(Stand: 28. Juni 2026), and localised DE/EN. But it is a **website** privacy policy: it covers +server log files, Plausible Analytics on `analytics.unom.io`, the `PARAGLIDE_LOCALE` cookie, and +self-hosted fonts. It does not mention the apps at all. + +That is a problem for App Store Connect in two directions: + +1. Apple requires the linked policy to describe **the app's** data practices. A reviewer following + the link finds a page about a website. +2. It reads as *contradicting* a "Data Not Collected" declaration. The page prominently describes + analytics and a cookie. A reviewer who skims it sees "Reichweitenmessung mit Plausible + Analytics" and has every reason to question the App Privacy answers. + +**Recommendation:** keep the existing page and append an app-specific section to it (the text +below), so one URL covers both. The alternative — a separate `/legal/privacy-apps` route — also +works, but one URL is less to keep in sync. + +The page is CMS-driven (`src/routes/legal/privacy.tsx` renders Payload `RichText` blocks from the +`pages` collection, slug `legal/privacy`, tenant `punktfunk`), so this is a CMS edit rather than a +code change. + +## Confirming the "collects no data" framing + +Checked against the source rather than taken on trust, and it holds: + +- **No analytics, telemetry, or crash-reporting SDK.** `Package.swift` declares no such dependency. + A case-insensitive sweep of `Sources/` for `sentry|firebase|analytics|telemetry|amplitude| + mixpanel|crashlytics|posthog|plausible` returns 43 hits — 43 of them the word "amplitude" in + haptics code (rumble amplitude), and one the English word "plausible" in a comment. +- **No outbound calls to us.** The only `URLSession` use is `LibraryClient`, fetching cover art + **from the paired host**, over a TLS session that pins the host's own certificate. The only + external URLs anywhere in the Swift sources are three UI links the user can tap: the docs site, + the source on `git.unom.io`, and the Discord invite. +- **No account system.** Identity is a client keypair in the device keychain + (`keychain-access-groups`, `ClientIdentityStore`); pairing is SPAKE2 with a PIN, host-to-device. +- **Data stays on device.** Saved hosts and settings live in a shared `UserDefaults` suite + (`group.io.unom.punktfunk`) so the widget can read them. Nothing syncs; there is no CloudKit + entitlement. +- **No ATT.** No `NSUserTrackingUsageDescription` anywhere, consistent with no tracking. + +So **App Privacy → "Data Not Collected"** is accurate for all four platforms. Two caveats worth +stating in the policy text anyway, because they are true and pre-empt questions: + +- The microphone uplink **is** audio leaving the device — but only to the host the user paired with, + encrypted, and never to us. Apple's questionnaire asks about data collected *by you or your + third-party partners*; streaming to the user's own machine is not collection. Saying so plainly + is better than staying silent about a microphone permission. +- The apps are distributed through the App Store, so **Apple** collects its own analytics. That is + Apple's processing, not yours, but naming it avoids looking like an omission. + +--- + +## Text to append — Deutsch + +> ## Die Punktfunk-Apps +> +> Dieser Abschnitt betrifft die Punktfunk-Apps für iPhone, iPad, Apple TV, Mac, Windows, Linux und +> Android – im Unterschied zu den vorstehenden Abschnitten, die sich auf diese Website beziehen. +> +> **Die Apps erheben keine personenbezogenen Daten.** Es gibt keine Benutzerkonten, keine +> Registrierung und keine Anmeldung. Die Apps enthalten keine Analyse-, Tracking-, Werbe- oder +> Absturzbericht-Bibliotheken von Drittanbietern. Es findet kein Tracking im Sinne des App +> Tracking Transparency Frameworks statt, und es werden keine Daten an uns oder an Dritte +> übermittelt. +> +> **Wohin die Daten fließen.** Punktfunk verbindet Ihr Gerät direkt mit einem Host-Rechner, den Sie +> selbst betreiben – in der Regel in Ihrem eigenen Netzwerk. Video, Ton, Maus-, Tastatur- und +> Controller-Eingaben sowie – sofern Sie ihn einschalten – Ihr Mikrofon werden ausschließlich +> zwischen Ihrem Gerät und diesem Host übertragen, verschlüsselt und ohne Umweg über einen Server +> von uns. Wir betreiben für den Streaming-Betrieb keine Vermittlungs-, Relay- oder Cloud-Dienste +> und haben zu keinem Zeitpunkt Zugriff auf die Inhalte einer Sitzung. +> +> **Was auf dem Gerät bleibt.** Die App speichert lokal auf Ihrem Gerät: die von Ihnen +> hinzugefügten oder im Netzwerk gefundenen Hosts, Ihre Einstellungen und Profile sowie einen +> kryptografischen Schlüssel, mit dem sich Ihr Gerät gegenüber einem gekoppelten Host ausweist +> (auf Apple-Geräten im Schlüsselbund). Diese Daten verlassen Ihr Gerät nicht und werden gelöscht, +> wenn Sie die App entfernen. +> +> **Berechtigungen.** Die App fragt nur Berechtigungen ab, die für den Betrieb nötig sind: den +> Zugriff auf das lokale Netzwerk, um Hosts zu finden und sich mit ihnen zu verbinden, und – nur +> wenn Sie die Mikrofonübertragung nutzen – das Mikrofon. Das Mikrofonsignal wird an den von Ihnen +> gekoppelten Host übertragen, wo es als virtuelles Mikrofon erscheint; es wird nicht +> aufgezeichnet und nicht an uns gesendet. +> +> **Verteilung über App-Stores.** Wenn Sie die App über den App Store oder Google Play beziehen, +> verarbeiten Apple bzw. Google im Rahmen der Auslieferung eigene Daten (etwa Kauf-, Installations- +> und Absturzstatistiken). Darauf haben wir keinen Einfluss; es gelten die +> Datenschutzbestimmungen des jeweiligen Anbieters. Aggregierte Statistiken, die uns Apple oder +> Google in ihren Entwicklerkonsolen anzeigen, lassen keinen Rückschluss auf einzelne Personen zu. +> +> **Der Host.** Der Punktfunk-Host ist quelloffene Software, die Sie selbst auf Ihrem eigenen +> Rechner betreiben. Welche Daten dabei anfallen – etwa lokale Protokolldateien –, bleibt +> vollständig unter Ihrer Kontrolle; wir erhalten davon nichts. Der Quellcode ist unter +> git.unom.io/unom/punktfunk einsehbar. + +--- + +## Text to append — English + +> ## The Punktfunk apps +> +> This section concerns the Punktfunk apps for iPhone, iPad, Apple TV, Mac, Windows, Linux, and +> Android — as distinct from the sections above, which concern this website. +> +> **The apps collect no personal data.** There are no user accounts, no registration, and no sign-in. +> The apps contain no third-party analytics, tracking, advertising, or crash-reporting libraries. +> No tracking within the meaning of Apple's App Tracking Transparency framework takes place, and no +> data is transmitted to us or to any third party. +> +> **Where your data goes.** Punktfunk connects your device directly to a host machine that you run +> yourself, normally on your own network. Video, audio, mouse, keyboard, and controller input — and +> your microphone, if you switch it on — travel only between your device and that host, encrypted, +> without passing through any server of ours. We operate no brokering, relay, or cloud service for +> streaming, and we have no access to the contents of a session at any point. +> +> **What stays on your device.** The app stores locally on your device: the hosts you have added or +> discovered on your network, your settings and profiles, and a cryptographic key your device uses +> to identify itself to a paired host (in the keychain, on Apple devices). This data does not leave +> your device and is removed when you delete the app. +> +> **Permissions.** The app requests only the permissions it needs to work: access to the local +> network, in order to find hosts and connect to them, and — only if you use microphone streaming — +> the microphone. The microphone signal is sent to the host you paired with, where it appears as a +> virtual microphone; it is not recorded and is not sent to us. +> +> **Distribution through app stores.** If you obtain the app from the App Store or Google Play, +> Apple or Google process their own data as part of distributing it (such as purchase, installation, +> and crash statistics). We have no influence over this, and the respective provider's privacy +> policy applies. The aggregated statistics Apple and Google show us in their developer consoles do +> not allow any individual to be identified. +> +> **The host.** The Punktfunk host is open source software that you run on your own machine. Any +> data it produces — local log files, for instance — remains entirely under your control, and none +> of it reaches us. The source is available at git.unom.io/unom/punktfunk. + +--- + +## Also update + +- Bump **Stand: / Effective date:** on the page when you add this. +- App Store Connect → App Privacy → **Data Not Collected** for all four platforms. +- The same URL works for Google Play's Data safety declaration; the wording above already covers it. diff --git a/clients/apple/store/review-notes.md b/clients/apple/store/review-notes.md new file mode 100644 index 00000000..41bef563 --- /dev/null +++ b/clients/apple/store/review-notes.md @@ -0,0 +1,132 @@ +# App Review notes + +## The core problem, stated plainly + +Punktfunk is the client half of a two-part system. Without a reachable host it shows a host list, a +pairing sheet, and settings — and nothing else. There is **no demo or offline mode in a release +build**: the mock-data screens in `Sources/PunktfunkClient/Screenshots/` are wrapped in `#if DEBUG` +and are compiled out of anything you ship. A reviewer who launches the App Store build with no host +on their network sees an empty "On this network" list. + +Guideline 2.1 requires you to supply whatever is needed to fully exercise the app. So you must +attach **one** of: + +- **(a) A reachable demo host.** Best outcome — the reviewer sees the real thing. Requires a host + exposed to the internet with its UDP ports forwarded, plus a pairing PIN in the notes. The client + can add a host by IP or hostname, so mDNS discovery is not required for this path. +- **(b) A demo video.** Apple accepts this for hardware- or setup-dependent apps. Less good: a + reviewer who cannot reproduce is a reviewer who can reject on something unrelated. + +**Attach (a) if you can keep a host up for the review window; (b) is the fallback.** Whichever you +pick, fill in the placeholders before submitting — the template assumes (a) and marks the spots. + +> **⚠ Decide before submitting:** if you go with (b), replace the "CONNECTING TO OUR DEMO HOST" +> section with the video URL and say explicitly that no host can be provided. + +--- + +## Notes template — paste into App Store Connect + +The App Review Information "Notes" field caps at **4000 characters**. The block below is **3919**, +and filling the five placeholders in shortens it further (the literal `[[FILL IN: …]]` text is +longer than the values that replace it). If you add to it, re-check the count — an over-long note +is silently truncated, and what gets cut is the end, where the privacy and entitlement answers +live. + +``` +WHAT THIS APP IS + +Punktfunk is a low-latency game- and desktop-streaming client. It streams from a "host" the user +installs on their own gaming PC (Linux, or Windows 11 22H2+), over their own network. The host is +separate open-source software we publish at https://git.unom.io/unom/punktfunk; it is not sold, +and this app has no purchases. + +This app is the client half only: it renders video and audio from the user's own machine and +sends input back. There is no content library and no server of ours in a session. + +IMPORTANT: THIS APP NEEDS A HOST + +With no reachable host, the app can only show its host list, the pairing screen and settings -- +inherent to what it is, not an incomplete build. We have provided a live host for review. + +CONNECTING TO OUR DEMO HOST + +1. Launch Punktfunk. The main screen lists hosts on the local network. Ours is not on yours, so + add it by hand: "+" (top right) then "Add host"; on Apple TV, "Add host" on the main screen. +2. Enter: Host: [[FILL IN: hostname or IP]] Port: [[FILL IN: port, default 47998]] + Name it anything, then confirm. +3. The app connects and asks for a pairing PIN. Enter: [[FILL IN: PIN]] + A one-time SPAKE2 pairing; afterwards the device is remembered and needs no PIN. +4. The host's game library appears as a grid. Select any title to stream; video and audio start + within a few seconds. +5. While streaming: stats overlay = Ctrl+Alt+Shift+S (or three-finger tap on iOS/iPadOS); release + mouse = Cmd+Esc or Ctrl+Alt+Shift+Q; disconnect = Ctrl+Alt+Shift+D. +6. Settings (gear) covers decoder, bitrate, HDR, audio, controllers and profiles; the per-host + "Speed test" suggests a bitrate for the link. + +The host stays reachable throughout review. If you cannot reach it, please contact +[[FILL IN: contact email]] and we will restore it promptly. + +WHY THE APP ASKS FOR WHAT IT ASKS FOR + +- Local Network: finds hosts via Bonjour (_punktfunk._udp) and connects to them -- the app's + entire purpose. +- Microphone (optional, off by default): audio goes to the user's own paired host, appearing + there as a virtual microphone for voice chat. Never recorded, never sent to us. +- networking.multicast: sends the Wake-on-LAN magic packet, which must go to a broadcast address: + a sleeping PC has no ARP entry, so unicast cannot reach it. Used for nothing else. +- device.usb / device.bluetooth (macOS): the GameController framework reaches wired controllers + through IOHIDLibUserClient and wireless ones through startWirelessControllerDiscovery. USB also + drives DualSense rumble, which CoreHaptics will not. Without these, no controller input. +- network.server (macOS): the app is outbound-only, but the App Sandbox gates bind() itself. Our + QUIC endpoint and UDP socket each bind a local port to receive host-to-client datagrams; + without this, no video, audio or rumble arrives. +- UIBackgroundModes "audio" (iPhone/iPad): a session carries real, audible audio from the host, + and this keeps it alive if the user steps away briefly. Backgrounded, video decoding stops, only + the real audio keeps rendering, and a bounded timer disconnects automatically. We never play + silence to stay alive, nor use the mode outside an audible session. + +REGARDING BUILD 0.4.2 (3384) + +That build was rejected under 2.4.5(i) for a temporary-exception entitlement +(mach-lookup.global-name, com.apple.audioanalyticsd), added on a mistaken belief about CoreHaptics +rumble under the App Sandbox. We have since verified rumble works without it; this build carries +no temporary exception. + +ACCOUNTS, PURCHASES, DATA + +No account, no sign-in, no in-app purchase. The app collects no personal data: no analytics, +tracking, advertising or crash-reporting SDKs, and no connection to any server of ours during a +session. Device identity is a keychain keypair used only to authenticate to the user's own host. + +Privacy policy: [[FILL IN: https://punktfunk.unom.io/legal/privacy]] +``` + +--- + +## Before you submit — checklist + +- [ ] Fill every `[[FILL IN: …]]` placeholder. There are five. +- [ ] Confirm the demo host is reachable **from outside your own network** — test it on cellular, + not on the LAN it lives on. This is the failure mode that wastes a review cycle. +- [ ] Confirm the pairing PIN in the notes is the one the host will actually accept during the + review window, and that pairing is left open (it is on-demand in the web console). +- [ ] Put at least one launchable title in the demo host's library. An empty grid after a + successful pairing looks like a broken app. +- [ ] If submitting tvOS, verify the whole flow is reachable with the **Siri Remote alone**. A + reviewer will not have a controller paired, and "requires an accessory to navigate" is a + tvOS rejection. +- [ ] Attach the demo video as a URL in the notes if you are going the (b) route. + +## Separately worth checking: the privacy manifest + +There is **no `PrivacyInfo.xcprivacy`** anywhere in `clients/apple`. The app does use +`UserDefaults` (`HostStore` reads the `group.io.unom.punktfunk` suite), and `UserDefaults` is one of +Apple's "required reason" APIs, which are expected to be declared in a privacy manifest. Apps +missing a declaration typically get an automated **ITMS-91053** notice on upload. + +This is adjacent to the copy work rather than part of it, so nothing has been changed here — but it +is worth adding a manifest declaring `NSPrivacyAccessedAPICategoryUserDefaults` with reason code +`CA92.1` (access to an app group container) and `NSPrivacyTracking` set to `false`, before the next +submission. Confirm the current reason codes against Apple's documentation rather than taking the +code above on trust; the list has changed since it was introduced. diff --git a/clients/apple/store/tvos.md b/clients/apple/store/tvos.md new file mode 100644 index 00000000..685c77ed --- /dev/null +++ b/clients/apple/store/tvos.md @@ -0,0 +1,145 @@ +# tvOS — App Store metadata + +Client only, living-room framing. Things the other platforms have that the **Apple TV does not**, +and which the copy therefore avoids claiming: + +- **No microphone uplink.** There is no usable audio input on tvOS, so the "your Mac becomes the + headset" line does not transfer. +- **No gamepad console shell.** `ShotScenes` builds the gamepad home/settings screens for iOS and + macOS only — tvOS uses the native focus engine instead. +- **No AV1.** Apple TV 4K has no AV1 hardware decoder; HEVC and H.264 only. +- Mouse/keyboard capture exists on tvOS but is not a living-room story, so it stays out. + +Kept, and genuinely tvOS-shaped: Siri Remote pointer navigation (`SiriRemotePointer`), controllers +including the full DualSense feedback set, HDR passthrough, and Wake-on-LAN — which is the single +best Apple TV feature, because it is what removes the trip to the other room. + +- **Name:** Punktfunk +- **Subtitle (DE):** Schnell, lokal & offen. +- **Subtitle (EN):** Fast, local & open. + +--- + +## Promotional Text (DE) — max 170 characters + +### Primary (161) + +``` +Anschalten, Host wählen, spielen: Punktfunk weckt deinen Gaming-PC per Wake-on-LAN und verbindet sich, sobald er wach ist. In 4K, mit HDR, mit deinem Controller. +``` + +### Alternate A — leads on the picture (157) + +``` +Dein Gaming-PC am großen Bildschirm – in genau der Auflösung und Bildrate deines Fernsehers, mit HDR. Ohne Konto, ohne Cloud, nur über dein eigenes Netzwerk. +``` + +### Alternate B — leads on the DualSense (160) + +``` +Dein DualSense am Apple TV, vollständig: Rumble, adaptive Trigger, Lightbar, Touchpad und Gyro gehen bis ins Spiel durch. Dazu Profile pro Host und Wake-on-LAN. +``` + +## Promotional Text (EN) — max 170 characters + +### Primary (160) + +``` +Turn on, pick a host, play: Punktfunk wakes your gaming PC over Wake-on-LAN and connects as soon as it's up. In 4K, with HDR, with the controller in your hands. +``` + +### Alternate A — leads on the picture (148) + +``` +Your gaming PC on the big screen — at your TV's exact resolution and refresh rate, with HDR. No account, no cloud, nothing leaving your own network. +``` + +--- + +## Description (DE) — max 4000 characters + +``` +Punktfunk macht aus deinem Apple TV die Konsole für den Gaming-PC, der ohnehin schon im Haus steht – in 4K, mit HDR, über dein eigenes Netzwerk, ohne Konto und ohne Cloud. + +Punktfunk besteht aus zwei Hälften: einem Host auf dem PC, von dem du streamst, und dieser App auf dem Gerät, auf dem du spielst. Der Host ist quelloffen und kostenlos, läuft auf Linux und auf Windows 11 – auch headless auf einem Rechner, an dem gar kein Monitor hängt. + +VOM SOFA AUS, VON ANFANG BIS ENDE + +Anschalten, Host auswählen, spielen. Die App findet Hosts im Netzwerk von allein. Beim ersten Mal koppelst du einmalig mit einer PIN, danach verbindet sich der Apple TV über eine gepinnte Identität – kein Konto, kein Login, kein Abtippen von IP-Adressen. Steht dein Gaming-PC im Standby, weckt ihn Punktfunk per Wake-on-LAN und verbindet sich, sobald er wach ist. Niemand muss dafür aufstehen. + +DAS BILD, DAS DEIN FERNSEHER WIRKLICH KANN + +Für den Apple TV legt der Host ein echtes virtuelles Display an – in genau der Auflösung und Bildrate, die dein Fernseher meldet, bis 4K. Kein Skalieren, keine schwarzen Balken, und die Monitore am PC werden nicht umsortiert. Dekodiert wird in Hardware über VideoToolbox (HEVC und H.264), HDR wird als PQ durchgereicht, statt es flach zu rechnen. + +CONTROLLER, VOLLSTÄNDIG + +DualSense, Xbox- und weitere MFi-kompatible Controller. Beim DualSense gehen Rumble, Lightbar, Player-LEDs, adaptive Trigger, Touchpad und Gyro bis ins Spiel durch. Welchen Typ das virtuelle Gamepad am Host annimmt, richtet sich nach dem, was bei dir wirklich in der Hand liegt. Bedienen lässt sich alles mit der Siri Remote oder komplett mit dem Controller – die Oberfläche ist für die Fernbedienung gebaut, nicht für eine Maus. + +DEINE BIBLIOTHEK AUF DEM FERNSEHER + +Installierte Steam-Titel und selbst hinzugefügte Spiele erscheinen als Raster mit Artwork und starten direkt vom Sofa aus. Mehrere Geräte können gleichzeitig streamen, jedes auf seinem eigenen Display – der Apple TV im Wohnzimmer stört also niemanden, der am Schreibtisch weiterarbeitet. + +SCHNELL, WEIL UNS DER GANZE WEG GEHÖRT + +Die nativen Apps sprechen punktfunk/1: eine QUIC-Steuerebene und eine verschlüsselte Datenebene mit Vorwärtsfehlerkorrektur. Ein gestuftes Overlay zeigt Bildrate, Bitrate und Latenz – über zwei Maschinen hinweg um den Uhrenversatz korrigiert, also eine Messung und kein Versprechen. Ein Geschwindigkeitstest pro Host schlägt eine passende Bitrate für dein Netzwerk vor. + +WAS DU BRAUCHST + +Einen Punktfunk-Host auf einem Linux-PC oder auf Windows 11 (22H2 oder neuer) im selben Netzwerk. Für die beste Erfahrung hängt der Apple TV am Kabel oder an einem guten 5-GHz-WLAN. Der Host ist quelloffen (MIT/Apache-2.0) und kostenlos – Anleitungen und Quellcode findest du auf punktfunk.unom.io. + +Kein Konto. Keine Cloud. Keine Telemetrie. Die App erfasst keine Daten über dich. +``` + +--- + +## Description (EN) — max 4000 characters + +``` +Punktfunk turns your Apple TV into a console for the gaming PC you already own — in 4K, with HDR, over your own network, with no account and no cloud. + +Punktfunk comes in two halves: a host on the PC you stream from, and this app on the device you play on. The host is open source and free, and runs on Linux and on Windows 11 — including headless, on a machine with no monitor attached at all. + +FROM THE COUCH, START TO FINISH + +Turn on, pick a host, play. The app finds hosts on your network by itself. The first time, you pair once with a PIN; after that your Apple TV reconnects on a pinned identity — no account, no login, no typing IP addresses with a remote. If your gaming PC is asleep, Punktfunk wakes it over Wake-on-LAN and connects as soon as it is up. Nobody has to get up to make that happen. + +THE PICTURE YOUR TV CAN ACTUALLY SHOW + +For your Apple TV, the host creates a real virtual display at exactly the resolution and refresh rate your TV reports, up to 4K. No scaling, no black bars, and the monitors on your PC are left where they are. Decoding is done in hardware through VideoToolbox (HEVC and H.264), and HDR is passed through as PQ rather than flattened. + +CONTROLLERS, IN FULL + +DualSense, Xbox, and other MFi-compatible controllers. On a DualSense, rumble, lightbar, player LEDs, adaptive triggers, touchpad, and gyro all reach the game. The virtual gamepad the host presents takes its type from the controller actually in your hands. Everything is navigable with the Siri Remote or entirely with a controller — the interface is built for a remote, not for a mouse. + +YOUR LIBRARY ON THE BIG SCREEN + +Installed Steam titles and games you add yourself appear as a grid with artwork, ready to launch from the couch. Several devices can stream at once, each on its own display — so the Apple TV in the living room does not disturb anyone still working at the desk. + +FAST, BECAUSE WE OWN THE WHOLE PATH + +The native apps speak punktfunk/1: a QUIC control plane and an encrypted data plane with forward error correction. A tiered overlay shows frame rate, bitrate, and latency — corrected for clock skew across the two machines, so it is a measurement rather than a claim. A per-host speed test suggests a bitrate that matches your network. + +WHAT YOU NEED + +A Punktfunk host on a Linux PC or on Windows 11 (22H2 or later) on the same network. For the best experience, put your Apple TV on Ethernet or on good 5 GHz Wi-Fi. The host is open source (MIT/Apache-2.0) and free — guides and source at punktfunk.unom.io. + +No account. No cloud. No telemetry. This app collects no data about you. +``` + +--- + +## Keywords — max 100 characters + +### DE (93) + +``` +streaming,spiele,gaming,controller,gamepad,wohnzimmer,fernseher,pc,linux,windows,4k,hdr,couch +``` + +### EN (91) + +``` +streaming,gaming,controller,gamepad,livingroom,tv,pc,linux,windows,4k,hdr,couch,remote,play +``` + +Same exclusions as macOS: no `Moonlight`, `GameStream`, `NVIDIA`, or `Steam` in the keyword field. From 0d407a866d3e73416b8c2d1df89f7ce153f83c2d Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 21:26:09 +0200 Subject: [PATCH 47/53] fix: a host that changed DHCP lease could no longer be streamed from the panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An adversarial review of this branch found a regression I introduced, plus three smaller defects. All four are fixed here, each verified on .21. **The regression.** `mergeHosts` names a host by its record's stable id, and `hosts list --json` always emits one (`KnownHosts::load` mints ids for every record). So a launch always went out as `punktfunk launch ` → `ConnectPlan::for_host` → `HostTarget::from(&KnownHost)`, which copies the address stored ON THE RECORD. Meanwhile the panel deliberately renders the LIVE advert's address. Nothing on a Deck ever writes a moved address back — `discover` and `hosts list` are both reads, and only the desktop shells' hosts pages update one. So after any DHCP move the row read "online" at the new address and every press dialled the old one: a 15 s dead connect, or — if a MAC had ever been learned — a black Steam "game" for the full 90 s wake budget. Proven with a stub session binary: `launch abc-123` emitted `--connect 10.0.0.5:9777` for a host answering at `10.0.0.99`. This worked on origin/main, which dialled `toHost(v).host` — the advert's address. The fix restores that without giving up stable ids: `hosts add --fp ` now MOVES the matching record instead of filing a second one (the fingerprint is the identity — this is the same rule that makes the verb idempotent), and the panel re-points a host it can see has moved before launching it. Verified: `moved 10.0.0.5:9777 to 10.0.0.99:9777`, one record still, and `launch abc-123` then emits `--connect 10.0.0.99:9777`. **"No hosts yet" was also how a missing client looked.** `_cli_argv()` returning None becomes `client-unavailable`, which the panel dropped on the floor — so a Deck with no client installed was told its network was empty, under a button that launches the client that isn't there. It now says which of the two it is. **The browse worker never exited on a quiet LAN.** `discover_for` drops the receiver and the doc claimed that stops the thread. It does not: the worker parks in `recv()`, and the arms that ignore an event (`SearchStarted`, `ServiceFound`, `SearchStopped`, a v6-only advert) never touch the sender, so on a LAN with no Punktfunk host nothing ever wakes it. Harmless today because the only caller is a short-lived CLI process, but the function invites in-process use, where it would leak a thread and an mDNS daemon per call. Now polled with a 250 ms tick and a check at the top of the loop. Verified: ten back-to-back browses settle back to the baseline thread count. **A `pair=optional` host was recorded as paired.** Every unsaved host now goes through the trust sheet (it has no pin, so it cannot stream without one), but the sheet's only non-PIN action ran `--request-access`, which persists `paired: true` on Ready. An optional host admits anyone who pins its identity — there is no operator decision, so nothing was approved and the same box read "paired" here and "trusted" in the desktop client. Such a host now gets **Connect** instead, which pins and streams without claiming an approval, and the "approve this Deck" toast is no longer shown to someone who has nobody to ask. Also: `PF_CLIENT_BIN` was the one launch-option value never validated — a client installed under a path with a space would split Steam's tokenizer. --- clients/cli/src/main.rs | 58 ++++++++++++++++++++++++++ clients/decky/src/hooks.ts | 29 ++++++++++--- clients/decky/src/index.tsx | 24 +++++++---- clients/decky/src/steam.ts | 18 +++++++- clients/decky/src/trust.tsx | 50 +++++++++++++++------- crates/pf-client-core/src/discovery.rs | 27 +++++++++--- 6 files changed, 173 insertions(+), 33 deletions(-) diff --git a/clients/cli/src/main.rs b/clients/cli/src/main.rs index 98d4a62c..b0920fea 100644 --- a/clients/cli/src/main.rs +++ b/clients/cli/src/main.rs @@ -121,6 +121,13 @@ punktfunk hosts — the saved-hosts store (shared with the desktop client) another subnet). Without --fp it is a placeholder to pair later; with a 64-hex fingerprint it is pinned immediately (still unpaired). + Idempotent, and keyed on the FINGERPRINT once there is one: re-running it + for a host already saved is a no-op, and giving a known fingerprint a new + address MOVES that host's record there rather than filing a second one + (which is how a host that changed DHCP lease stays reachable by its id). + A different fingerprint for an address already saved is refused, exit 3 — + a changed identity is a decision for a person. + punktfunk hosts forget Remove a saved host, its pinned fingerprint included. A later connect must pair or trust it again." @@ -592,6 +599,31 @@ from the config directory for a true factory reset." }, }; } + // No record at this address — but a record carrying this exact FINGERPRINT is + // this same host at a new one. Re-point it rather than filing a second record: + // the fingerprint is the identity, and a host that changed DHCP lease is the + // whole reason `hosts add --fp` is idempotent in the first place. Without this a + // moved host accumulates one record per address it has ever held, and the one a + // stable id resolves to keeps the address it can no longer be reached at. + if let Some(i) = known + .hosts + .iter() + .position(|h| !fp.is_empty() && h.fp_hex.eq_ignore_ascii_case(&fp)) + { + let was = format!("{}:{}", known.hosts[i].addr, known.hosts[i].port); + known.hosts[i].addr = addr.clone(); + known.hosts[i].port = port; + return match known.save() { + Ok(()) => { + println!("moved {was} to {addr}:{port}"); + OK + } + Err(e) => { + eprintln!("saving: {e:#}"); + CONNECT_FAILED + } + }; + } known.hosts.push(KnownHost { name: name.unwrap_or_else(|| addr.clone()), addr: addr.clone(), @@ -1317,6 +1349,32 @@ from the config directory for a true factory reset." ); } + /// A host that changed DHCP lease is re-pointed, not filed a second time. Without this + /// the record a stable id resolves to keeps an address the host has left, so a launch + /// dials into the void while the panel shows the live one. + #[test] + fn a_known_fingerprint_at_a_new_address_moves_the_record() { + let mut known = KnownHosts { + hosts: vec![saved("desk", "192.168.1.9", "abc123")], + }; + // Simulates `hosts add 192.168.1.50 --fp abc123` finding no record at that address. + let by_addr = known + .hosts + .iter() + .position(|h| h.addr == "192.168.1.50" && h.port == 9777); + assert!( + by_addr.is_none(), + "the new address is not yet on any record" + ); + let by_fp = known + .hosts + .iter() + .position(|h| h.fp_hex.eq_ignore_ascii_case("abc123")); + assert_eq!(by_fp, Some(0), "the fingerprint still identifies the host"); + known.hosts[0].addr = "192.168.1.50".into(); + assert_eq!(known.hosts.len(), 1, "one host, one record"); + } + #[test] fn value_reads_the_argument_after_its_flag() { let a = argv(&["--game", "steam:570", "--exec"]); diff --git a/clients/decky/src/hooks.ts b/clients/decky/src/hooks.ts index bb9d6bb5..0c1911dc 100644 --- a/clients/decky/src/hooks.ts +++ b/clients/decky/src/hooks.ts @@ -69,6 +69,14 @@ export interface HostView { fp: string; /** What the host is advertising right now, if anything — what request access would pin. */ advertisedFp: string; + /** + * The host is answering at an address its record does not carry — it changed DHCP lease. + * + * This matters because a launch names the host by [`ref`], and the CLI dials whatever address + * the RECORD holds. So the row would show the live address and dial the dead one. The record + * has to be re-pointed before such a host can stream; `startStream` does it. + */ + moved: boolean; paired: boolean; online: boolean; saved: boolean; @@ -131,6 +139,7 @@ export function mergeHosts(saved: SavedHost[], discovered: DiscoveredHost[]): Ho port: advert?.port ?? s.port, fp: s.fp_hex, advertisedFp: advert?.fp ?? "", + moved: !!advert && (advert.addr !== s.addr || advert.port !== s.port), paired: s.paired, online: !!advert || s.online === true, saved: true, @@ -153,6 +162,7 @@ export function mergeHosts(saved: SavedHost[], discovered: DiscoveredHost[]): Ho // No record, so nothing is pinned — whatever it advertises is an OFFER, not a pin. fp: "", advertisedFp: a.fp, + moved: false, // no record, so nothing to be stale paired: a.paired, online: true, saved: false, @@ -185,9 +195,11 @@ function sortRows(a: HostView, b: HostView): number { export function useHosts() { const [views, setViews] = useState([]); const [scanning, setScanning] = useState(false); - // A client too old for `punktfunk discover`. Rendered as one explanatory row plus the update - // button that fixes it — never as an empty list, which would read as "no hosts on your LAN". - const [outdated, setOutdated] = useState(false); + // Why the list is empty, when it is empty for a reason other than an empty LAN. Rendering + // either of these as "No hosts yet" would blame the user's network for the plugin's problem: + // "client-outdated" — the installed client predates `punktfunk discover` + // "client-unavailable" — there is no client installed at all + const [problem, setProblem] = useState(null); const refresh = useCallback(async () => { setScanning(true); @@ -195,7 +207,14 @@ export function useHosts() { // Both in flight at once: the browse is time-bounded and the probe is network-bound, so // running them in sequence would cost the sum of two waits for no benefit. const [d, s] = await Promise.all([discover(), listHosts()]); - setOutdated(d.error === "client-outdated" || s.error === "client-outdated"); + // Both calls run the same binary, so they fail the same way; take whichever answered. + setProblem( + d.error === "client-unavailable" || s.error === "client-unavailable" + ? "client-unavailable" + : d.error === "client-outdated" || s.error === "client-outdated" + ? "client-outdated" + : null, + ); setViews(mergeHosts(s.hosts ?? [], d.hosts ?? [])); } catch (e) { toaster.toast({ title: "Punktfunk", body: `Couldn't list hosts: ${e}` }); @@ -208,7 +227,7 @@ export function useHosts() { void refresh(); }, [refresh]); - return { views, scanning, outdated, refresh }; + return { views, scanning, problem, refresh }; } // ---------------------------------------------------------------------------------------- diff --git a/clients/decky/src/index.tsx b/clients/decky/src/index.tsx index 6f795da5..f37b7870 100644 --- a/clients/decky/src/index.tsx +++ b/clients/decky/src/index.tsx @@ -125,7 +125,7 @@ const HostRow: FC<{ host: HostView; refresh: () => void }> = ({ host, refresh }) }; const QamPanel: FC = () => { - const { views, scanning, outdated, refresh } = useHosts(); + const { views, scanning, problem, refresh } = useHosts(); const { info: update, checking, check } = useUpdate(); return ( @@ -178,15 +178,23 @@ const QamPanel: FC = () => { {scanning ? "Scanning…" : "Refresh"} - {/* A client too old for `punktfunk discover` explains itself rather than rendering an - empty list — "no hosts on your LAN" would be a lie, and the button that fixes it is - in this same panel. Saved hosts still list: that path is an older verb. */} - {outdated && ( + {/* A client that is missing or too old explains itself rather than rendering an empty + list — "no hosts on your LAN" would blame the network for the plugin's problem, and + for the outdated case the button that fixes it is in this same panel. */} + {problem && ( )} @@ -195,7 +203,7 @@ const QamPanel: FC = () => { )} - {views.length === 0 && !scanning && ( + {views.length === 0 && !scanning && !problem && ( { // (/bin/sh); the wrapper rides behind as an arg. PF_CLIENT_BIN only when the backend resolved // a NATIVE client — else the wrapper's flatpak default stands and this shortcut is exactly // what it always was. - const clientBin = info.client_bin ? `PF_CLIENT_BIN=${info.client_bin} ` : ""; + const clientBin = safeClientBin(info.client_bin) ? `PF_CLIENT_BIN=${info.client_bin} ` : ""; const launchOpts = `${clientBin}PF_BROWSE=1 %command% "${info.runner}"`; // Reuse the remembered entry only if it still exists; a stale appId (deleted shortcut whose @@ -346,6 +346,16 @@ export function isSafeLaunchId(id: string): boolean { ); } +/** + * Is a resolved native-client path safe to put in Steam's launch options? Same rule, separate + * name because the failure is different: an unsafe id is a bug in our own data, an unsafe path + * is just where the user installed the client — so the browse shortcut degrades to its flatpak + * default rather than refusing to exist. + */ +function safeClientBin(bin: string | undefined): bin is string { + return !!bin && isSafeLaunchId(bin); +} + /** * Stream `ref` fullscreen in Gaming Mode, optionally with a pinned card's profile. Encodes the * target into the STREAM shortcut's launch options — one hidden shortcut serves every host — @@ -369,6 +379,12 @@ export async function launchStream(ref: string, opts: LaunchOpts = {}): Promise< // Set only for a NATIVE client install; absent, the wrapper takes its flatpak default, so every // existing Deck install produces byte-identical launch options to before. if (clientBin) { + // The one launch-option value that comes from the backend rather than a store id, and so + // the one that could carry a space: a path like `/home/deck/my apps/punktfunk-client` would + // split Steam's tokenizer and land its tail in front of %command% as a bogus env token. + if (!isSafeLaunchId(clientBin)) { + throw new Error(`client path can't ride Steam's launch options: ${clientBin}`); + } env.push(`PF_CLIENT_BIN=${clientBin}`); } if (opts.profileId) { diff --git a/clients/decky/src/trust.tsx b/clients/decky/src/trust.tsx index d839c023..a18f78e4 100644 --- a/clients/decky/src/trust.tsx +++ b/clients/decky/src/trust.tsx @@ -55,9 +55,22 @@ export const TrustSheet: FC<{ // Request access pins what the host ADVERTISES. The record's own pin is a different thing: // a host that already has one streams without ever opening this sheet. - const canRequestAccess = host.advertisedFp !== ""; + const hasIdentity = host.advertisedFp !== ""; + // A host advertising `pair=optional` admits anyone who pins its identity — there is no + // operator decision to wait for, and asking for one would be a wait that never ends and a + // record claiming somebody approved this Deck when nobody did. `paired` means the PIN + // ceremony or a real approval; the desktop client records exactly this case as *trusted*. + const needsApproval = host.pairPolicy !== "optional"; + const canRequestAccess = hasIdentity && needsApproval; + const canTrustDirectly = hasIdentity && !needsApproval; - const requestAccess = async () => { + /** + * Pin the advertised identity, then stream. + * + * `approval` is what differs between the two doors, and it is not cosmetic: it decides whether + * the launch waits ~185 s for an operator AND whether the record ends up marked paired. + */ + const letIn = async (approval: boolean) => { setBusy(true); setError(null); const { host: h, onStream: stream, onChanged: changed } = props.current; @@ -71,15 +84,17 @@ export const TrustSheet: FC<{ return; } changed(); - // Step 2: the launch itself waits for the approval. The session's plain connecting screen + // Step 2: the launch. Under approval it PARKS — and the session's plain connecting screen // looks identical whether it is parked or hanging, so say what is about to happen BEFORE - // it starts — this toast is a patch over that, and the real fix belongs in the session. - toaster.toast({ - title: "Punktfunk", - body: `Approve this Deck in ${h.name}’s console — the stream starts by itself`, - duration: 10_000, - }); - stream({ requestAccess: true }); + // it starts. That toast is a patch over that, and the real fix belongs in the session. + if (approval) { + toaster.toast({ + title: "Punktfunk", + body: `Approve this Deck in ${h.name}’s console — the stream starts by itself`, + duration: 10_000, + }); + } + stream({ requestAccess: approval }); closeModal?.(); } catch (e) { setError(String(e)); @@ -109,9 +124,11 @@ export const TrustSheet: FC<{ Connect to {host.name}
- {canRequestAccess - ? `${host.name} needs to let this device in before it can stream.` - : "No advertised identity for this host — pair with a PIN instead."} + {!hasIdentity + ? "No advertised identity for this host — pair with a PIN instead." + : canTrustDirectly + ? `${host.name} accepts new devices. Connecting pins its identity so later streams are silent.` + : `${host.name} needs to let this device in before it can stream.`}
{error && (
{error}
@@ -119,10 +136,15 @@ export const TrustSheet: FC<{ {canRequestAccess && ( - + void letIn(true)}> {busy ? : "Request access"} )} + {canTrustDirectly && ( + void letIn(false)}> + {busy ? : "Connect"} + + )} Use a PIN instead… diff --git a/crates/pf-client-core/src/discovery.rs b/crates/pf-client-core/src/discovery.rs index d318b5c5..7df42411 100644 --- a/crates/pf-client-core/src/discovery.rs +++ b/crates/pf-client-core/src/discovery.rs @@ -54,8 +54,8 @@ pub enum DiscoveryEvent { Removed { fullname: String }, } -/// Browse continuously for the app's lifetime. The thread exits when the receiver is -/// dropped (the send fails) or the daemon dies. +/// Browse continuously. The worker exits when the returned receiver is dropped, or when the +/// daemon dies — checked on a tick, so it stops even on a LAN where no advert ever arrives. pub fn browse() -> async_channel::Receiver { let (tx, rx) = async_channel::unbounded(); std::thread::Builder::new() @@ -75,7 +75,24 @@ pub fn browse() -> async_channel::Receiver { return; } }; - while let Ok(event) = receiver.recv() { + // Polled rather than blocked on: the worker has to notice that its consumer went + // away even when NOTHING is arriving, which is the normal state of a LAN with no + // hosts on it. A plain `recv()` parks forever there, and the ignored-event arm below + // never touches `tx` — so a bounded consumer like `discover_for` would leak this + // thread and its daemon (another thread, and a socket bound to :5353) on every call. + loop { + // Checked at the TOP so it also covers the arms below that `continue` without + // ever touching `tx` — the ignored event kinds, and an advert with no IPv4 + // address. Those are the paths that would otherwise keep this thread alive with + // nobody to send to. + if tx.is_closed() { + break; + } + let event = match receiver.recv_timeout(Duration::from_millis(250)) { + Ok(event) => event, + Err(_) if receiver.is_disconnected() => break, + Err(_) => continue, + }; let update = match event { ServiceEvent::ServiceResolved(info) => { let props = info.get_properties(); @@ -171,8 +188,8 @@ pub fn discover_for(timeout: Duration) -> Vec { while let Ok(event) = rx.try_recv() { fold(&mut adverts, event); } - // Dropping the receiver is what stops the worker: its next send fails and the thread exits, - // shutting the daemon down. Without this a one-shot consumer would leak a browse per call. + // Dropping the receiver is what stops the worker — it polls for that, so this holds even + // when nothing is advertising. Without it a one-shot consumer would leak a browse per call. drop(rx); sorted(adverts) } From 8f35155c1473056736c6e1fa442a9800ab8d7a60 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 21:30:18 +0200 Subject: [PATCH 48/53] fix(apple/shots): the store screenshots show the app as it actually is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uploading the 0.24.0 set surfaced a pair screen that reads as broken, and a hero that was never the orientation it claimed. The capture harness: - Landscape scenes were captured in PORTRAIT. `IOSOrientationConfigurator` asked for the geometry update from `updateUIViewController`, where `view.window` is still nil — SwiftUI makes one update pass for a `.background` representable, before the hierarchy is in a window, so the guard fell through and nothing ever asked again. Both `.landscape` scenes (the stream hero, the trust card) shipped as portrait. Now a real UIViewController asks from `viewDidAppear` and pins `supportedInterfaceOrientations`. - The shot host applied `.ignoresSafeArea()` to the whole scene, so the hero's HUD — resolution, bitrate, the latency breakdown, the entire point of that screenshot — sat under the Dynamic Island. Only the black backing ignores it now; scenes that want full bleed already ignore it themselves. - `03-pair` was hand-composed into a ZStack rather than presented. PairSheet is a bottom sheet on iOS: its detents and the system's Liquid Glass only exist inside a real `.sheet`. Composed, the grouped Form stretched to full screen height and the capture was a strip of content over a black void, with a DISABLED "Pair & Connect" (empty PIN) and the capture simulator's own name — `pf-shot-iphone-6.9` — rendered in as the device name. - Sheets do not inherit `.environment(\.colorScheme, .dark)` across the presentation boundary; they follow the DEVICE. The pairing sheet came out light grey over the dark app. The simulator is now set to dark appearance. - Discovery browsed the live LAN mid-capture, so a bystanding machine's hostname went out on the listing and no two runs matched. `HostDiscovery` gains a `debugSet` seam (the counterpart to `HostWaker.debugSet`); the mock hosts advertise, so cards read ONLINE through the real `advertises` path and the reachability probe never touches the network. - Created simulators were named `pf-shot-`, which the reuse regex never matches: every run created another simulator and none was reused. They are named after the device now — reusable, and not user-visible junk. Two bugs found on the way, neither screenshot-only: - HostStore/ProfileStore PERSISTED the harness's mock data. On a dev Mac that is the same App-Group suite the real app reads, so running the script could replace the tester's saved hosts with "Battlestation" & co. - GamepadHomeView drew the controller chip as a trailing `.overlay`, which reserves no width — on a portrait phone it sat on top of the centred "Select a Host". Laid out as a row with a hidden leading mirror. - The pairing sheet's field prompt said "How the host lists this Mac" on iPhone and iPad. Coverage: the listing set is six scenes in listing order, and is now the stream, the machines it found, the couch/controller mode, waking a sleeping host, the quality controls and pairing — the console and wake screens already existed in `ShotScenes.all` and were simply never captured. Mock hosts carry OS marks, Wake-on-LAN MACs and profile chips so the grid is full rather than three offline rows over an empty half-screen. `SCENES=` overrides the set for the dev scenes. --- clients/apple/README.md | 6 +- .../Home/GamepadHomeView.swift | 39 +++-- .../Screenshots/ScreenshotHost.swift | 69 ++++++-- .../Screenshots/ScreenshotScenes.swift | 150 ++++++++++++++++-- .../PunktfunkClient/Stores/HostStore.swift | 6 + .../PunktfunkClient/Stores/ProfileStore.swift | 17 +- .../PunktfunkClient/Trust/PairSheet.swift | 33 +++- .../Connection/HostDiscovery.swift | 32 ++++ clients/apple/tools/screenshots.sh | 42 +++-- 9 files changed, 342 insertions(+), 52 deletions(-) diff --git a/clients/apple/README.md b/clients/apple/README.md index 7dabf655..56743c27 100644 --- a/clients/apple/README.md +++ b/clients/apple/README.md @@ -121,7 +121,11 @@ PUNKTFUNK_AUTOCONNECT= PUNKTFUNK_MODE=1280x720x60 swift run PunktfunkCli host's virtual pad. - **App Store screenshots** are automated — `tools/screenshots.sh all` renders the real UI at the required pixel sizes via a DEBUG-only shot mode; the `apple` CI workflow captures the iOS sizes on - every main push. See the script header for details. + every main push. See the script header for details. The script's `SCENES` array is the listing + set, in listing order; override it (`SCENES="06-gamepad-home 10-edithost" tools/screenshots.sh ios`) + to capture any of the other scenes in `ShotScenes.all`. Mock data — hosts, adverts, profiles — is + seeded in `ShotMock` so a capture is byte-for-byte deterministic and never browses the real LAN + (a stranger's hostname reached the live listing that way once). - Deeper design notes live in the internal planning repo (punktfunk-planning: `apple-stage2-presenter.md`). diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index 0bd48f7c..2db6afc4 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift @@ -176,18 +176,33 @@ struct GamepadHomeView: View { // MARK: - Chrome private var titleBar: some View { - Text("Select a Host") - .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) - .foregroundStyle(.white) - .frame(maxWidth: .infinity) - .overlay(alignment: .trailing) { - // Which pad is driving this UI (name + battery) — quiet, and only where there's - // room; a compact-height phone gives the pixels to the carousel instead. - if !compact, let active = gamepads.active { - ControllerStatusChip(controller: active) - .padding(.trailing, 20) - } - } + // The chip used to be a trailing `.overlay`, which reserves no width: on a portrait phone + // it sat directly on top of the centred title ("Select a Host" ran straight into the pad + // name). Laying it out as a row with a hidden mirror on the leading side keeps the title + // optically centred AND clear of the chip at every width; the title shrinks a little + // before it would ever truncate. + HStack(spacing: 12) { + statusChip(hidden: true) + Text("Select a Host") + .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.75) + .frame(maxWidth: .infinity) + statusChip(hidden: false) + } + .padding(.horizontal, 20) + } + + /// Which pad is driving this UI (name + battery) — quiet, and only where there's room; a + /// compact-height phone gives the pixels to the carousel instead. `hidden` renders the same + /// chip purely as a width reserve. + @ViewBuilder private func statusChip(hidden: Bool) -> some View { + if !compact, let active = gamepads.active { + ControllerStatusChip(controller: active) + .opacity(hidden ? 0 : 1) + .accessibilityHidden(hidden) + } } private var cardSpacing: CGFloat { diff --git a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotHost.swift b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotHost.swift index fd47f23c..2e40c40e 100644 --- a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotHost.swift +++ b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotHost.swift @@ -24,6 +24,13 @@ import ImageIO @MainActor enum ScreenshotMode { + /// This process was launched to capture a screenshot. Cheap enough to consult from the + /// stores' persistence paths (`HostStore` / `ProfileStore`), which must NOT write their + /// mock contents back into a real user's App Group when the harness runs on a dev Mac. + static var isActive: Bool { + !(ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SCENE"] ?? "").isEmpty + } + /// The scene requested via PUNKTFUNK_SHOT_SCENE, or nil for a normal launch. static var requestedScene: ShotScene? { let name = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_SCENE"] ?? "" @@ -41,8 +48,11 @@ struct ScreenshotHostView: View { scene.make() .environment(\.colorScheme, scene.colorScheme) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.black) - .ignoresSafeArea() + // Black fills the display, but the SCENE keeps its safe area. Ignoring it wholesale + // here pushed the stream hero's HUD under the Dynamic Island (the resolution/bitrate + // line was unreadable in every 6.9" capture); scenes that genuinely want full bleed — + // the streamed frame itself — ignore it themselves. + .background(Color.black.ignoresSafeArea()) #if os(macOS) .background(MacShotWindowConfigurator(scene: scene)) #elseif os(iOS) @@ -129,18 +139,59 @@ enum MacSelfCapture { #endif #if os(iOS) -/// Best-effort orientation lock for the requested scene (landscape for the stream hero, portrait -/// for chrome). Requires the app to allow those orientations in Info.plist. +/// Orientation lock for the requested scene (landscape for the stream hero, portrait for chrome). +/// Requires the app to allow those orientations in Info.plist — it does, for both. private struct IOSOrientationConfigurator: UIViewControllerRepresentable { let orientation: ShotOrientation - func makeUIViewController(context: Context) -> UIViewController { UIViewController() } + func makeUIViewController(context: Context) -> ShotOrientationController { + ShotOrientationController(mask: mask) + } - func updateUIViewController(_ vc: UIViewController, context: Context) { - guard let scene = vc.view.window?.windowScene else { return } - let mask: UIInterfaceOrientationMask = orientation == .landscape ? .landscapeRight : .portrait + func updateUIViewController(_ vc: ShotOrientationController, context: Context) { + vc.mask = mask + vc.applyGeometry() + } + + private var mask: UIInterfaceOrientationMask { + orientation == .landscape ? .landscapeRight : .portrait + } +} + +/// Asks the window scene to rotate, from a place where there IS a window. +/// +/// The previous version made the request inside `updateUIViewController`, where `view.window` is +/// still nil: SwiftUI makes exactly one update pass for a representable mounted as a `.background`, +/// before the hierarchy is in a window, so the `guard` fell through and nothing ever asked again. +/// Every scene declared `.landscape` — the stream hero and the trust card — was therefore captured +/// in PORTRAIT at the portrait App Store size. Overriding `supportedInterfaceOrientations` as well +/// keeps the scene from rotating back if the simulator reports a device orientation change. +final class ShotOrientationController: UIViewController { + var mask: UIInterfaceOrientationMask + + init(mask: UIInterfaceOrientationMask) { + self.mask = mask + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("not from a nib") } + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { mask } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + applyGeometry() + } + + func applyGeometry() { + // `view.window` once mounted; the connected-scene lookup covers the first update pass, + // which still runs before this controller is in a window. + let scene = view.window?.windowScene + ?? UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first + guard let scene else { return } scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) - vc.setNeedsUpdateOfSupportedInterfaceOrientations() + setNeedsUpdateOfSupportedInterfaceOrientations() } } #endif diff --git a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift index b5c27331..e68ddf08 100644 --- a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift +++ b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift @@ -81,24 +81,116 @@ enum ShotScenes { @MainActor enum ShotMock { - /// A populated saved-host grid: a pinned recent host, a couple more, mixed online state. + // Stable ids so the store, the adverts and the profile bindings all point at the same things + // across every scene and every run. + static let battlestationID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000001")! + static let livingRoomID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000002")! + static let workshopID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000003")! + static let officeID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000004")! + + static let hdrProfileID = "a71c4e0d9f22" + static let couchProfileID = "3e88b107c4da" + + /// The catalog the host cards read their chips and pinned cards from. Seeded once, on the + /// first store build — `ProfileStore` is a singleton, and in shot mode its write-back is + /// suppressed, so this never reaches a real user's catalog. + static func installProfiles() { + guard !profilesInstalled else { return } + profilesInstalled = true + ProfileStore.shared.debugSet([ + StreamProfile(name: "4K HDR", id: hdrProfileID, accent: "#8B7BF7"), + StreamProfile(name: "Couch 1080p", id: couchProfileID, accent: "#4FD1A5"), + ]) + } + + private static var profilesInstalled = false + + /// A populated saved-host grid: the most-recent host bound to a profile (its chip), a second + /// paired machine, and one asleep box we hold a MAC for (so its card offers Wake-on-LAN). OS + /// chains give every tile its real vendor mark instead of a letter monogram. + /// + /// No PINNED host+profile card: it renders a second tile for the SAME host, which is the + /// feature working as designed but reads as a duplicate to anyone meeting the app in a store + /// listing. The binding chip carries the profile story on its own. static func hostStore() -> HostStore { + installProfiles() let store = HostStore() store.hosts = [ - StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777, - pinnedSHA256: fingerprint, lastConnected: Date().addingTimeInterval(-420)), - StoredHost(name: "Living Room PC", address: "192.168.1.41", port: 9777, - pinnedSHA256: fingerprint), - StoredHost(name: "Workshop", address: "10.0.0.7", port: 9777), + StoredHost( + id: battlestationID, name: "Battlestation", address: "192.168.1.20", port: 9777, + pinnedSHA256: fingerprint, lastConnected: Date().addingTimeInterval(-420), + macAddresses: ["a4:b1:c2:d3:e4:f5"], profileID: hdrProfileID, + osChain: "windows/11"), + StoredHost( + id: livingRoomID, name: "Living Room PC", address: "192.168.1.41", port: 9777, + pinnedSHA256: hostFingerprint(1), lastConnected: Date().addingTimeInterval(-86_400), + macAddresses: ["b8:27:eb:11:22:33"], osChain: "linux/fedora/bazzite"), + StoredHost( + id: officeID, name: "Office NUC", address: "192.168.1.33", port: 9777, + pinnedSHA256: hostFingerprint(4), lastConnected: Date().addingTimeInterval(-259_200), + profileID: couchProfileID, osChain: "linux/ubuntu"), + StoredHost( + id: workshopID, name: "Workshop", address: "10.0.0.7", port: 9777, + pinnedSHA256: hostFingerprint(2), macAddresses: ["de:ad:be:ef:00:07"], + osChain: "linux/arch"), ] return store } - static let host = StoredHost(name: "Battlestation", address: "192.168.1.20", port: 9777, - pinnedSHA256: fingerprint) + /// Discovery, seeded rather than live. Two saved hosts advertise (so their cards read ONLINE + /// through the real `advertises` path, and the reachability probe skips them — no network from + /// a capture), "Workshop" stays quiet so the grid shows an asleep machine, and one genuinely + /// new host populates the "On this network" section. + /// + /// A live browse made the shot non-deterministic AND leaked whatever was on the capturing + /// machine's LAN into the App Store listing. + static func discovery() -> HostDiscovery { + let discovery = HostDiscovery() + discovery.debugSet([ + HostDiscovery.debugAdvert( + id: "battlestation", name: "Battlestation", host: "192.168.1.20", + fingerprintHex: fingerprint.hexLower, macAddresses: ["a4:b1:c2:d3:e4:f5"], + osChain: "windows/11"), + HostDiscovery.debugAdvert( + id: "living-room", name: "Living Room PC", host: "192.168.1.41", + fingerprintHex: hostFingerprint(1).hexLower, macAddresses: ["b8:27:eb:11:22:33"], + osChain: "linux/fedora/bazzite"), + HostDiscovery.debugAdvert( + id: "office-nuc", name: "Office NUC", host: "192.168.1.33", + fingerprintHex: hostFingerprint(4).hexLower, osChain: "linux/ubuntu"), + HostDiscovery.debugAdvert( + id: "studio", name: "Studio PC", host: "192.168.1.58", + fingerprintHex: hostFingerprint(3).hexLower, requiresPairing: true, allowsTofu: false, + osChain: "windows/11"), + ]) + return discovery + } + + static let host = StoredHost( + id: battlestationID, name: "Battlestation", address: "192.168.1.20", port: 9777, + pinnedSHA256: fingerprint, osChain: "windows/11") + + /// What the pairing sheet calls THIS device. Taken from the platform, not from + /// `UIDevice.current.name` — on a capture simulator that is the harness's own throwaway name + /// (`pf-shot-iphone-6.9` went out on the store listing that way). + static var clientDeviceName: String { + #if os(tvOS) + "Apple TV" + #elseif os(macOS) + "MacBook Pro" + #else + UIDevice.current.userInterfaceIdiom == .pad ? "iPad Pro" : "iPhone" + #endif + } /// A plausible-looking 32-byte SHA-256 for the trust card / pin lock glyphs. - static let fingerprint = Data((0..<32).map { UInt8(($0 &* 37 &+ 0x1d) & 0xff) }) + static let fingerprint = hostFingerprint(0) + + /// Distinct per host — `StoredHost.matches` prefers a fingerprint comparison, so sharing one + /// across the mock grid made a single advert light up every card. + static func hostFingerprint(_ seed: Int) -> Data { + Data((0..<32).map { UInt8((($0 &* 37) &+ 0x1d &+ (seed &* 91)) & 0xff) }) + } } // MARK: - Home @@ -106,7 +198,7 @@ enum ShotMock { private struct ShotHome: View { @StateObject private var store = ShotMock.hostStore() @StateObject private var model = SessionModel() - @StateObject private var discovery = HostDiscovery() + @StateObject private var discovery = ShotMock.discovery() var body: some View { #if os(macOS) @@ -134,7 +226,7 @@ private struct ShotHome: View { private struct ShotGamepadHome: View { @StateObject private var store = ShotMock.hostStore() @StateObject private var model = SessionModel() - @StateObject private var discovery = HostDiscovery() + @StateObject private var discovery = ShotMock.discovery() @StateObject private var waker = HostWaker() var body: some View { @@ -166,7 +258,7 @@ private struct ShotConnect: View { @StateObject private var store = ShotMock.hostStore() @StateObject private var model = SessionModel() - @StateObject private var discovery = HostDiscovery() + @StateObject private var discovery = ShotMock.discovery() @StateObject private var waker = HostWaker() var body: some View { @@ -255,16 +347,44 @@ private struct ShotSettings: View { // MARK: - Pair (PIN ceremony) private struct ShotPair: View { + /// The PIN as the host's web console shows it, and a device name that doesn't depend on what + /// the capture simulator happens to be called. + private var sheet: some View { + PairSheet( + host: ShotMock.host, shotPIN: "418 306", + shotClientName: ShotMock.clientDeviceName, onPaired: { _ in }) + } + var body: some View { + #if os(iOS) + // PRESENT it, don't rebuild it. `PairSheet` is a bottom sheet on iOS — it carries its own + // `.presentationDetents([.medium, .large])` and the system's Liquid Glass background, both + // of which only exist inside a real `.sheet`. Composed into a ZStack instead (what this + // scene used to do), the detents were inert, the grouped Form stretched to the full height + // of the screen, and the capture was a thin strip of content over a huge black void. + ShotHome() + .sheet(isPresented: .constant(true)) { + // Pinned to one detent. The sheet ships `[.medium, .large]` so it can grow over + // the keyboard, and the resting height leaves a wide empty band between the form + // and the button row; a capture wants the snug version. + sheet.presentationDetents([.fraction(0.52)]) + } + #elseif os(tvOS) + // tvOS pushes the ceremony as a full screen (HomeView's `navigationDestination`). + NavigationStack { sheet } + #else + // macOS: a fixed-width panel (`.frame(width: 400).fixedSize()`) that hugs its content, so + // floating it over the dimmed grid matches how the window-modal sheet reads. `screencapture + // -l` grabs one window, and an AppKit sheet is a child window — a real `.sheet` + // would fall outside the capture. ZStack { ShotHome().blur(radius: 28).overlay(Color.black.opacity(0.5)) - PairSheet(host: ShotMock.host, onPaired: { _ in }) - .frame(maxWidth: 460) + sheet .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18)) .clipShape(RoundedRectangle(cornerRadius: 18)) .shadow(radius: 40, y: 16) - .padding(40) } + #endif } } diff --git a/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift b/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift index 29ecebce..2c47eeaa 100644 --- a/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift +++ b/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift @@ -191,6 +191,12 @@ final class HostStore: ObservableObject { private func persist() { + #if DEBUG + // The screenshot harness fills a store with mock hosts (ShotMock) purely to render a + // scene. On a dev Mac that store is the SAME App-Group suite the real app reads, so + // persisting would replace the tester's saved hosts with "Battlestation" & co. + if ScreenshotMode.isActive { return } + #endif if let data = try? JSONEncoder().encode(hosts) { defaults.set(data, forKey: Self.key) } diff --git a/clients/apple/Sources/PunktfunkClient/Stores/ProfileStore.swift b/clients/apple/Sources/PunktfunkClient/Stores/ProfileStore.swift index faeaac0c..45b2c16d 100644 --- a/clients/apple/Sources/PunktfunkClient/Stores/ProfileStore.swift +++ b/clients/apple/Sources/PunktfunkClient/Stores/ProfileStore.swift @@ -20,7 +20,14 @@ final class ProfileStore: ObservableObject { static let shared = ProfileStore() @Published private(set) var catalog: ProfileCatalog { - didSet { catalog.save() } + didSet { + #if DEBUG + // Shot mode seeds this SINGLETON with mock profiles to populate the host cards. + // Saving would write them into the tester's real catalog — see HostStore.persist(). + if ScreenshotMode.isActive { return } + #endif + catalog.save() + } } var profiles: [StreamProfile] { catalog.profiles } @@ -33,6 +40,14 @@ final class ProfileStore: ObservableObject { id.flatMap { catalog.profile(id: $0) } } + #if DEBUG + /// Shot-mode seed: replace the catalog outright so a capture shows a known set of profiles + /// rather than the tester's. Safe because `didSet` suppresses the write-back in shot mode. + func debugSet(_ profiles: [StreamProfile]) { + catalog = ProfileCatalog(profiles: profiles) + } + #endif + /// This host's default profile, dangling ids dropped — a deleted profile resolves as "Default /// settings", never an error (§4.4). func binding(for host: StoredHost) -> StreamProfile? { catalog.binding(for: host) } diff --git a/clients/apple/Sources/PunktfunkClient/Trust/PairSheet.swift b/clients/apple/Sources/PunktfunkClient/Trust/PairSheet.swift index 5d1b812c..41932d8a 100644 --- a/clients/apple/Sources/PunktfunkClient/Trust/PairSheet.swift +++ b/clients/apple/Sources/PunktfunkClient/Trust/PairSheet.swift @@ -109,7 +109,7 @@ struct PairSheet: View { #endif TextField( "Client name", text: $clientName, - prompt: Text("How the host lists this Mac")) + prompt: Text(Self.clientNamePrompt)) #if os(tvOS) .labelsHidden() // prefilled → tvOS floats the label off-center #endif @@ -184,6 +184,16 @@ struct PairSheet: View { #endif } + /// The field prompt names the device you are actually on — it said "this Mac" on every + /// platform, which on an iPhone is simply wrong. + private static var clientNamePrompt: String { + #if os(macOS) + "How the host lists this Mac" + #else + "How the host lists this device" + #endif + } + private func runCeremony() { busy = true errorText = nil @@ -229,3 +239,24 @@ struct PairSheet: View { } } } + +#if DEBUG +extension PairSheet { + /// Screenshot-harness seed (`ShotScenes`). A capture of the untouched sheet shows an empty PIN + /// field, a DISABLED "Pair & Connect", and — because the client name defaults to the device's + /// own — whatever the capture simulator happens to be called (`pf-shot-iphone-6.9` reached App + /// Store Connect that way). Seeding both fields captures the ceremony as a user meets it, + /// mid-entry, with a live primary button. + /// + /// An extension so `PairSheet` keeps its memberwise initialiser, and THIS file so it can reach + /// the private state. + init( + host: StoredHost, shotPIN: String, shotClientName: String, + onPaired: @escaping (Data) -> Void + ) { + self.init(host: host, onPaired: onPaired) + _pin = State(initialValue: shotPIN) + _clientName = State(initialValue: shotClientName) + } +} +#endif diff --git a/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift b/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift index d7ccb78c..3bc17a8a 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift @@ -59,6 +59,9 @@ public final class HostDiscovery: ObservableObject { /// Start browsing `_punktfunk._udp`. Idempotent — a second call while live is a no-op. public func start() { + #if DEBUG + guard !debugPinned else { return } // a seeded advert set outranks the live LAN + #endif guard browser == nil else { return } let browser = NWBrowser( for: .bonjourWithTXTRecord(type: "_punktfunk._udp", domain: nil), @@ -92,6 +95,35 @@ public final class HostDiscovery: ObservableObject { for conn in connections.values { conn.cancel() } } + #if DEBUG + /// A seeded advert set is in force — `start()` must not replace it with the live browse. + private var debugPinned = false + + /// Screenshot/preview seam, the discovery counterpart to `HostWaker.debugSet`: publish a FIXED + /// set of adverts and keep browsing off. Without it a capture shows whatever happens to be on + /// the machine's LAN — the App Store screenshots shipped a stranger's hostname more than once — + /// and every mock host reads Offline because nothing advertises it. + public func debugSet(_ adverts: [DiscoveredHost]) { + stop() + debugPinned = true + hosts = adverts + } + + /// Builds one advert. `DiscoveredHost`'s memberwise init is internal (a public struct's is), and + /// making it public would expose a wire-shaped model's construction to every consumer just to + /// serve the harness. + public static func debugAdvert( + id: String, name: String, host: String, port: UInt16 = 9777, + fingerprintHex: String? = nil, requiresPairing: Bool = false, allowsTofu: Bool = true, + macAddresses: [String] = [], osChain: String = "" + ) -> DiscoveredHost { + DiscoveredHost( + id: id, name: name, host: host, port: port, fingerprintHex: fingerprintHex, + requiresPairing: requiresPairing, allowsTofu: allowsTofu, + macAddresses: macAddresses, osChain: osChain) + } + #endif + private func restart() { stop() start() diff --git a/clients/apple/tools/screenshots.sh b/clients/apple/tools/screenshots.sh index 1b55a2bf..694de44d 100755 --- a/clients/apple/tools/screenshots.sh +++ b/clients/apple/tools/screenshots.sh @@ -35,7 +35,11 @@ cd "$APPLE_DIR" OUT="${OUT:-$APPLE_DIR/screenshots}" BUNDLE_ID="io.unom.punktfunk" -SCENES=(01-stream 02-hosts 03-pair 04-trust 05-settings) + +# The App Store set, in listing order — the first three are what most people ever see, so they are +# the stream itself, the machines it found, and the couch/controller mode. Everything else in +# ShotScenes.all is a dev scene; capture those with `SCENES="06-gamepad-home 10-edithost" ...`. +SCENES=(${SCENES:-01-stream 02-hosts 06-gamepad-home 09e-waking-modal 05-settings 03-pair}) SETTLE="${SETTLE:-4}" # seconds to let a scene lay out before capturing mkdir -p "$OUT" @@ -89,13 +93,20 @@ shoot_macos() { # $1 device-type regex (matches both existing device names and the device-type catalog) # $2 scheme $3 sdk $4 file prefix $5 runtime platform (iOS|tvOS — for the create fallback) +# $6 name for a device we have to create — MUST satisfy $1 (see below) shoot_sim() { require_xcode - local match="$1" scheme="$2" sdk="$3" prefix="$4" platform="$5" + local match="$1" scheme="$2" sdk="$3" prefix="$4" platform="$5" createname="$6" - # Reuse an existing device of this type; else create a throwaway one against the newest - # available runtime for the platform. CI runners commonly ship a runtime but not every device - # (the iPhone 16 Pro Max is absent on ours), so create-on-demand is what makes it reproducible. + # Reuse an existing device of this type; else create one against the newest available runtime + # for the platform. CI runners commonly ship a runtime but not every device (the iPhone 16 Pro + # Max is absent on ours), so create-on-demand is what makes it reproducible. + # + # The created device is named after the DEVICE, not after this script, for two reasons. It used + # to be "pf-shot-", which `$match` never matches — so every run created another + # simulator and none was ever reused (they piled up on the runner). And the name is user-visible: + # `UIDevice.current.name` is what the pairing sheet prefills as this device's name, so + # "pf-shot-iphone-6.9" was rendered into an App Store screenshot. local udid udid="$(xcrun simctl list devices available | grep -E "$match" | grep -oE '[0-9A-F-]{36}' | head -1 || true)" if [ -z "$udid" ]; then @@ -105,8 +116,8 @@ shoot_sim() { rt="$(xcrun simctl list runtimes available | grep -E "^$platform " \ | grep -oE 'com\.apple\.CoreSimulator\.SimRuntime\.[A-Za-z0-9.-]+' | tail -1 || true)" if [ -n "$devtype" ] && [ -n "$rt" ]; then - udid="$(xcrun simctl create "pf-shot-$prefix" "$devtype" "$rt" 2>/dev/null || true)" - [ -n "$udid" ] && log "$prefix — created Simulator $udid ($devtype)" + udid="$(xcrun simctl create "$createname" "$devtype" "$rt" 2>/dev/null || true)" + [ -n "$udid" ] && log "$prefix — created Simulator \"$createname\" $udid ($devtype)" fi fi [ -n "$udid" ] || die "$prefix: no Simulator matching /$match/, and none could be created @@ -114,6 +125,11 @@ shoot_sim() { log "$prefix — Simulator $udid" xcrun simctl boot "$udid" 2>/dev/null || true xcrun simctl bootstatus "$udid" -b >/dev/null 2>&1 || true + # Every scene is a dark-mode scene. The in-app `.environment(\.colorScheme, .dark)` override + # does NOT cross a presentation boundary — a `.sheet` gets its own environment and follows the + # DEVICE appearance — so the pairing sheet came out light grey over the dark app. Set the + # simulator itself to dark and the whole hierarchy, presentations included, agrees. + xcrun simctl ui "$udid" appearance dark >/dev/null 2>&1 || true log "$prefix — building ($scheme)…" # PF_SHOT_DERIVED_DATA (optional): a STABLE DerivedData root, so repeat runs reuse the @@ -150,15 +166,15 @@ pixels() { sips -g pixelWidth -g pixelHeight "$1" 2>/dev/null | awk '/pixel/{pri for target in "$@"; do case "$target" in macos) shoot_macos ;; - ios) shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS ;; - ipad) shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS ;; - tvos) shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS ;; + ios) shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS 'iPhone 16 Pro Max' ;; + ipad) shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS 'iPad Pro 13-inch (M4)' ;; + tvos) shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS 'Apple TV 4K' ;; all) shoot_macos if xcrun --find simctl >/dev/null 2>&1; then - shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS - shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS - shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS + shoot_sim 'iPhone 16 Pro Max' Punktfunk-iOS iphonesimulator iphone-6.9 iOS 'iPhone 16 Pro Max' + shoot_sim 'iPad Pro 13|iPad Pro .*M4|iPad Pro \(13' Punktfunk-iOS iphonesimulator ipad-13 iOS 'iPad Pro 13-inch (M4)' + shoot_sim 'Apple TV' Punktfunk-tvOS appletvsimulator appletv tvOS 'Apple TV 4K' else warn "Skipping iOS/iPadOS/tvOS — full Xcode not found (Command Line Tools only)." fi From 7b1554af4b597a08f944a4a5bc4581ee84c0d4ef Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 21:34:18 +0200 Subject: [PATCH 49/53] fix(apple/shots): fill the grid, open Settings on Display, note the iPad orientation limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Six mock hosts rather than three. An iPad-13 portrait grid is three columns wide and 2752 px tall; three cards left ~60% of the capture as black. - Settings opens on Display, not General. Resolution, frame rate, bitrate, HDR and codec are what someone reads a streaming app's settings shot for. - The wake scene is the modal-over-grid variant. The gamepad-UI one is a full-screen takeover over a bare gradient — correct, but four lines of text on an empty aurora; the modal shows the same overlay over the host grid. - `requestGeometryUpdate` now reports a refusal instead of failing silently. It does not help on the simulator (an app's stdout doesn't reach the driver through `simctl launch`) but it will on macOS and on a device. - Documented that `.landscape` does not rotate on iPad: a multitasking-capable iPad app is resizable, so iPadOS ignores the request and simctl cannot rotate a simulated device. The iPad set is portrait throughout. --- .../Screenshots/ScreenshotHost.swift | 7 ++++++- .../Screenshots/ScreenshotScenes.swift | 16 +++++++++++++--- clients/apple/tools/screenshots.sh | 8 +++++++- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotHost.swift b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotHost.swift index 2e40c40e..94798950 100644 --- a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotHost.swift +++ b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotHost.swift @@ -190,7 +190,12 @@ final class ShotOrientationController: UIViewController { let scene = view.window?.windowScene ?? UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first guard let scene else { return } - scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) + // Report a refusal instead of silently shipping the wrong orientation — that is exactly + // how every landscape scene went out as a portrait PNG for as long as it did. + scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { error in + print("PF_SHOT_ORIENTATION_REFUSED \(error.localizedDescription)") + fflush(stdout) + } setNeedsUpdateOfSupportedInterfaceOrientations() } } diff --git a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift index e68ddf08..7c4a4bba 100644 --- a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift +++ b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift @@ -87,6 +87,8 @@ enum ShotMock { static let livingRoomID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000002")! static let workshopID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000003")! static let officeID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000004")! + static let editingID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000005")! + static let bedroomID = UUID(uuidString: "5B0D1E00-0000-4000-8000-000000000006")! static let hdrProfileID = "a71c4e0d9f22" static let couchProfileID = "3e88b107c4da" @@ -133,6 +135,14 @@ enum ShotMock { id: workshopID, name: "Workshop", address: "10.0.0.7", port: 9777, pinnedSHA256: hostFingerprint(2), macAddresses: ["de:ad:be:ef:00:07"], osChain: "linux/arch"), + StoredHost( + id: editingID, name: "Editing Rig", address: "192.168.1.62", port: 9777, + pinnedSHA256: hostFingerprint(5), lastConnected: Date().addingTimeInterval(-604_800), + osChain: "linux/nobara"), + StoredHost( + id: bedroomID, name: "Bedroom Mini", address: "192.168.1.77", port: 9777, + pinnedSHA256: hostFingerprint(6), macAddresses: ["00:1a:2b:3c:4d:5e"], + osChain: "windows/11"), ] return store } @@ -335,9 +345,9 @@ private struct ShotSettings: View { #elseif os(iOS) // SettingsView owns its NavigationSplitView (sidebar + detail) and Done button, so it is // rendered directly — a wrapping NavigationStack would nest a split view in a stack. Open - // on General so the shot lands on real controls (iPad: sidebar + General detail; iPhone: - // the General page) instead of the bare category list. - SettingsView(initialCategory: .general) + // on Display rather than the bare category list: resolution, frame rate, bitrate, HDR and + // codec are what someone reads a streaming app's settings shot to find out. + SettingsView(initialCategory: .display) #else NavigationStack { SettingsView() } #endif diff --git a/clients/apple/tools/screenshots.sh b/clients/apple/tools/screenshots.sh index 694de44d..3eb88a9c 100755 --- a/clients/apple/tools/screenshots.sh +++ b/clients/apple/tools/screenshots.sh @@ -11,9 +11,15 @@ # The captured pixels are exactly App Store Connect's required sizes: # mac 2880×1800 (a 1× display yields 1440×900 — also accepted) # iphone-6.9 1320×2868 (portrait) / 2868×1320 (the landscape hero) -# ipad-13 2064×2752 (portrait) / 2752×2064 (the landscape hero) +# ipad-13 2064×2752 (portrait) # appletv 1920×1080 # +# A `.landscape` scene rotates on iPhone but NOT on iPad: an iPad app that supports multitasking +# is resizable, and iPadOS ignores `requestGeometryUpdate` orientation requests for it — the app +# follows the device, and simctl cannot rotate a simulated device. The iPad set is therefore +# portrait throughout (a valid App Store size, and uniform, which the gallery prefers). To get a +# landscape iPad hero, rotate the Simulator by hand (⌘←) and re-run just that scene. +# # Requirements: # • macOS target: just the Swift toolchain (`swift build`) + a one-time Screen Recording grant # for your terminal (System Settings → Privacy & Security → Screen Recording). From 1db7058a5d0be76754b9525403c23d91ba7734d8 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 21:11:05 +0200 Subject: [PATCH 50/53] feat(clients/input): system buttons route around local overlays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing guide/Steam/QAM collided with the client device's own shell: iOS 26 opens its Game Overlay for the Home press (no app opt-out until iOS 27 makes it a user setting), and a Gaming-Mode client opened BOTH Steam overlays for one press — the local one covering the stream. Two cross-client tier-P settings, zero wire changes: - system_buttons (auto|forward|local): raw guide+misc1 passthrough. Auto forwards everywhere EXCEPT under gamescope, where SteamOS reacts to the same physical press no matter what. - guide_gesture (auto|on|off): hold Select ALONE ~350ms sends the HOST's guide, down until release — held on, that's the host's long-press, which opens a Gaming-Mode host's QAM for regular pads. A Select tap is delivered on release with its up TAP_PRESS (50ms) behind, because per-transition sends fold into seq'd GamepadState snapshots and a back-to-back pair can coalesce into no press at all. A Select inside a combo (the escape chord) passes through untouched. Auto arms it only where the raw press can't reach the host cleanly: gamescope, iOS/iPadOS, tvOS. The same SelectGesture rules live in pf-client-core (pure state machine + unit tests), the Apple client (mask-diff adaptation in GamepadCapture), and Android's GamepadRouter. Settings rows on every surface (GTK, WinUI, console UI, Decky, Apple x2, Android x2) with profile plumbing throughout. punktfunk-session grows a control socket ($XDG_RUNTIME_DIR[/app/$FLATPAK_ID]/punktfunk-session-ctl.sock — the one runtime path a flatpak and the host see identically): 'guide'/'qam' verbs inject synthetic taps. The Decky panel gains a Host menus section (visible while the client runs) whose buttons press the host's Steam/QAM and close the local menu so the host's shows through. iOS 27's GCControllerHomeButtonSettingsManager deep-link is a TODO (the class needs the Xcode 27 SDK to compile). Docs: input, client-settings, steam-deck. Design: punktfunk-planning design/system-buttons-routing.md. Gates: docker clippy --all-targets --locked -D warnings + tests (pf-client-core 88 incl. 6 new gesture tests, pf-console-ui 47), cargo fmt --all --check, swift build (macOS), gradle kit+app compile, decky tsc --noEmit + py_compile. clients/windows not compiled (no box). --- .../unom/punktfunk/GamepadSettingsScreen.kt | 12 + .../main/kotlin/io/unom/punktfunk/Profiles.kt | 15 + .../main/kotlin/io/unom/punktfunk/Settings.kt | 43 ++ .../io/unom/punktfunk/SettingsScreen.kt | 19 + .../kotlin/io/unom/punktfunk/StreamScreen.kt | 1 + .../io/unom/punktfunk/kit/GamepadRouter.kt | 125 +++++- .../Session/SessionModel.swift | 7 +- .../Settings/GamepadSettingsView.swift | 14 + .../Settings/SettingsOptions.swift | 16 + .../Settings/SettingsView+Scope.swift | 8 + .../Settings/SettingsView+Sections.swift | 23 ++ .../PunktfunkKit/Gamepad/GamepadCapture.swift | 160 +++++++- .../PunktfunkShared/DefaultsKeys.swift | 11 + .../PunktfunkShared/EffectiveSettings.swift | 38 ++ .../PunktfunkShared/StreamProfile.swift | 12 + clients/decky/main.py | 53 +++ clients/decky/src/backend.ts | 9 + clients/decky/src/index.tsx | 59 ++- clients/linux/src/ui_settings.rs | 76 +++- clients/session/src/main.rs | 98 ++++- clients/windows/src/app/settings.rs | 61 +++ crates/pf-client-core/src/gamepad.rs | 383 +++++++++++++++++- crates/pf-client-core/src/profiles.rs | 22 + crates/pf-client-core/src/trust.rs | 48 +++ crates/pf-console-ui/src/screens/settings.rs | 52 ++- docs-site/content/docs/client-settings.md | 15 + docs-site/content/docs/input.md | 33 ++ docs-site/content/docs/steam-deck.md | 9 + 28 files changed, 1405 insertions(+), 17 deletions(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt index 6a808516..c33e4f88 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt @@ -483,6 +483,18 @@ private fun buildSettingsRows( "The virtual pad the host creates — Automatic matches this controller.", GAMEPAD_OPTIONS, s.gamepad, ) { update(s.copy(gamepad = it)) }, + choice( + "systemButtons", null, "Guide button", + "Where the guide (Xbox/PS) and share presses go while streaming — Automatic " + + "sends them to the host whenever this device delivers them.", + SYSTEM_BUTTON_OPTIONS, s.systemButtons, + ) { update(s.copy(systemButtons = it)) }, + choice( + "guideGesture", null, "Hold Select for guide", + "Hold Select alone to press the host's guide button — keep holding for a " + + "Gaming-Mode host's quick-access menu. A Select tap still goes through.", + GUIDE_GESTURE_OPTIONS, s.guideGesture, + ) { update(s.copy(guideGesture = it)) }, ) + listOfNotNull( if (hasBodyVibrator) { toggle( diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Profiles.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Profiles.kt index dfd8ee63..ec23975f 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Profiles.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Profiles.kt @@ -44,6 +44,8 @@ data class SettingsOverlay( val invertScroll: Boolean? = null, val gamepad: Int? = null, val gamepadForwarding: Boolean? = null, + val systemButtons: String? = null, + val guideGesture: String? = null, val statsVerbosity: StatsVerbosity? = null, /** * Android-only tier-P addition (design §3): the decode pipeline is a device fact everywhere @@ -78,6 +80,8 @@ data class SettingsOverlay( invertScroll = invertScroll ?: base.invertScroll, gamepad = gamepad ?: base.gamepad, gamepadForwarding = gamepadForwarding ?: base.gamepadForwarding, + systemButtons = systemButtons ?: base.systemButtons, + guideGesture = guideGesture ?: base.guideGesture, statsVerbosity = statsVerbosity ?: base.statsVerbosity, lowLatencyMode = lowLatencyMode ?: base.lowLatencyMode, presentPriority = presentPriority ?: base.presentPriority, @@ -115,6 +119,8 @@ data class SettingsOverlay( gamepadForwarding = if (after.gamepadForwarding != before.gamepadForwarding) after.gamepadForwarding else gamepadForwarding, + systemButtons = if (after.systemButtons != before.systemButtons) after.systemButtons else systemButtons, + guideGesture = if (after.guideGesture != before.guideGesture) after.guideGesture else guideGesture, statsVerbosity = if (after.statsVerbosity != before.statsVerbosity) after.statsVerbosity else statsVerbosity, lowLatencyMode = if (after.lowLatencyMode != before.lowLatencyMode) after.lowLatencyMode else lowLatencyMode, presentPriority = if (after.presentPriority != before.presentPriority) after.presentPriority else presentPriority, @@ -142,6 +148,8 @@ data class SettingsOverlay( "invert_scroll" -> copy(invertScroll = null) "gamepad" -> copy(gamepad = null) "gamepad_forwarding" -> copy(gamepadForwarding = null) + "system_buttons" -> copy(systemButtons = null) + "guide_gesture" -> copy(guideGesture = null) "stats_verbosity" -> copy(statsVerbosity = null) "low_latency_mode" -> copy(lowLatencyMode = null) "present_priority" -> copy(presentPriority = null) @@ -166,6 +174,8 @@ data class SettingsOverlay( if (invertScroll != null) add("invert_scroll") if (gamepad != null) add("gamepad") if (gamepadForwarding != null) add("gamepad_forwarding") + if (systemButtons != null) add("system_buttons") + if (guideGesture != null) add("guide_gesture") if (statsVerbosity != null) add("stats_verbosity") if (lowLatencyMode != null) add("low_latency_mode") if (presentPriority != null) add("present_priority") @@ -198,6 +208,8 @@ data class SettingsOverlay( invertScroll?.let { j.put("invert_scroll", it) } gamepad?.let { j.put("gamepad", it) } gamepadForwarding?.let { j.put("gamepad_forwarding", it) } + systemButtons?.let { j.put("system_buttons", it) } + guideGesture?.let { j.put("guide_gesture", it) } statsVerbosity?.let { j.put("stats_verbosity", it.name) } lowLatencyMode?.let { j.put("low_latency_mode", it) } presentPriority?.let { j.put("present_priority", it) } @@ -214,6 +226,7 @@ data class SettingsOverlay( "width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec", "hdr_enabled", "compositor", "audio_channels", "mic_enabled", "echo_cancel", "touch_mode", "mouse_mode", "invert_scroll", "gamepad", "gamepad_forwarding", + "system_buttons", "guide_gesture", "stats_verbosity", "low_latency_mode", "present_priority", "smooth_buffer", ) @@ -237,6 +250,8 @@ data class SettingsOverlay( invertScroll = j.optBooleanOrNull("invert_scroll"), gamepad = j.optIntOrNull("gamepad"), gamepadForwarding = j.optBooleanOrNull("gamepad_forwarding"), + systemButtons = j.optStringOrNull("system_buttons"), + guideGesture = j.optStringOrNull("guide_gesture"), statsVerbosity = j.optStringOrNull("stats_verbosity") ?.let { n -> StatsVerbosity.entries.firstOrNull { it.name == n } }, lowLatencyMode = j.optBooleanOrNull("low_latency_mode"), diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt index cc6f5a71..917258f6 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt @@ -45,6 +45,20 @@ data class Settings( * bind — which is why it gates the USB capture paths, not just the wire sends. */ val gamepadForwarding: Boolean = true, + /** + * Where the guide (Xbox/PS) and misc/share presses land while streaming — the + * cross-client `system_buttons` key: `"auto"` (forward on Android — the press reaches + * the app on most devices) | `"forward"` | `"local"`. + */ + val systemButtons: String = "auto", + /** + * The hold-Select guide gesture — the cross-client `guide_gesture` key: `"auto"` (off + * on Android) | `"on"` | `"off"`. On: holding Select alone ≥350 ms sends the HOST's + * guide, down until release (long hold = the host's long-press → a Gaming-Mode host's + * QAM); a Select tap is delivered on release, slightly delayed. For devices whose + * shell intercepts the physical guide button. + */ + val guideGesture: String = "auto", /** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it * can capture; the resolved count drives the decoder + AAudio layout. */ val audioChannels: Int = 2, @@ -228,6 +242,8 @@ class SettingsStore(context: Context) { compositor = prefs.getInt(K_COMPOSITOR, 0), gamepad = prefs.getInt(K_GAMEPAD, 0), gamepadForwarding = prefs.getBoolean(K_GAMEPAD_FORWARDING, true), + systemButtons = prefs.getString(K_SYSTEM_BUTTONS, "auto") ?: "auto", + guideGesture = prefs.getString(K_GUIDE_GESTURE, "auto") ?: "auto", audioChannels = prefs.getInt(K_AUDIO_CH, 2), codec = prefs.getString(K_CODEC, "auto") ?: "auto", micEnabled = prefs.getBoolean(K_MIC, false), @@ -275,6 +291,8 @@ class SettingsStore(context: Context) { .putInt(K_COMPOSITOR, s.compositor) .putInt(K_GAMEPAD, s.gamepad) .putBoolean(K_GAMEPAD_FORWARDING, s.gamepadForwarding) + .putString(K_SYSTEM_BUTTONS, s.systemButtons) + .putString(K_GUIDE_GESTURE, s.guideGesture) .putInt(K_AUDIO_CH, s.audioChannels) .putString(K_CODEC, s.codec) .putBoolean(K_MIC, s.micEnabled) @@ -305,6 +323,8 @@ class SettingsStore(context: Context) { const val K_COMPOSITOR = "compositor" const val K_GAMEPAD = "gamepad" const val K_GAMEPAD_FORWARDING = "gamepad_forwarding" + const val K_SYSTEM_BUTTONS = "system_buttons" + const val K_GUIDE_GESTURE = "guide_gesture" const val K_AUDIO_CH = "audio_channels" const val K_CODEC = "codec" const val K_MIC = "mic_enabled" @@ -539,6 +559,15 @@ fun codecOptionsFor(stored: String, av1Capable: Boolean): List Unit, onOpenCo caption = "The virtual pad the host creates. Automatic matches your controller; " + "every connected one is forwarded as its own player.", ) { g -> update(s.copy(gamepad = g)) } + SettingDropdown( + label = "Guide button", + options = SYSTEM_BUTTON_OPTIONS, + selected = s.systemButtons, + field = "system_buttons", + enabled = s.gamepadForwarding, + caption = "Where the guide (Xbox/PS) and share presses go while streaming. " + + "Automatic sends them to the host whenever this device delivers them.", + ) { v -> update(s.copy(systemButtons = v)) } + SettingDropdown( + label = "Hold Select for guide", + options = GUIDE_GESTURE_OPTIONS, + selected = s.guideGesture, + field = "guide_gesture", + enabled = s.gamepadForwarding, + caption = "Hold Select alone to press the host's guide button — keep holding for a " + + "Gaming-Mode host's quick-access menu. A Select tap still goes through, " + + "slightly delayed. For devices that intercept the real guide button.", + ) { v -> update(s.copy(guideGesture = v)) } DeviceScopeOnly { ClickableRow( title = "Connected controllers", diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index 0978c77c..5c156887 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -323,6 +323,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { // controller (Automatic). Built here, released on dispose. val router = GamepadRouter( context, handle, initialSettings.gamepad, initialSettings.gamepadForwarding, + initialSettings.systemButtonsForward(), initialSettings.guideGestureEnabled(), ) activity?.gamepadRouter = router // Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt index 6b586d18..7fe1f02d 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt @@ -50,12 +50,35 @@ class GamepadRouter( * capture links, which `StreamScreen` does not start at all while this is off. */ private val forwarding: Boolean = true, + /** + * Forward raw guide/QAM presses (`Settings.systemButtons` resolved — auto = forward on + * Android, where the press reaches the app on most devices; `local` exists for + * cross-client profile parity with the Gaming-Mode clients). Off keeps them entirely + * with this device. + */ + private val systemForward: Boolean = true, + /** + * The hold-Select guide gesture (`Settings.guideGesture` resolved — auto = off on + * Android): holding Select ALONE ≥ [GUIDE_HOLD_MS] sends the HOST's guide button, down + * until release — so a long hold is the host's long-press, a Gaming-Mode host's QAM. A + * Select tap is delivered on release (delayed by up to the threshold); a Select pressed + * while other buttons are down passes through untouched, so the exit/mic chords keep + * working. pf-client-core's `SelectGesture`, on the main-thread handler. + */ + private val guideGesture: Boolean = false, ) { /** One forwarded controller: its stable wire pad index, per-device axis state, and held buttons. */ private class Slot(val index: Int, val mapper: Gamepad.AxisMapper) { /** Forwarded button bits currently held (Gamepad.BTN_*) — for release-on-close + chord detection. */ var held = 0 + + // Hold-Select→guide gesture state ([guideGesture]): the pending Select's hold + // timer / a delivered tap's owed release (both on the main handler), and whether + // the held Select was transformed into a synthetic guide. + var pendingGuide: Runnable? = null + var pendingTapUp: Runnable? = null + var selectAsGuide = false } /** deviceId → slot. Concurrent: the feedback poll threads read it via [deviceForPad]. */ @@ -139,7 +162,24 @@ class GamepadRouter( * the mic-mute chord ([MIC_CHORD]). */ private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) { + // Raw system buttons stay local under the "local" policy — no wire send and no held + // tracking, symmetric on both edges so nothing leaks into the chords either. + if (!systemForward && (bit == Gamepad.BTN_GUIDE || bit == Gamepad.BTN_MISC1)) return if (down) { + if (guideGesture && send) { + // A Select pressed ALONE is held back until it resolves: a tap (delivered + // on release), a combo member (the next button flushes it as a real + // press), or — past GUIDE_HOLD_MS — a synthetic guide. Held state records + // it either way, so the exit/mic chords read as if the gesture didn't + // exist (Select+Y still fires the mic toggle: the flush sends Select's + // down before Y's). + if (bit == Gamepad.BTN_BACK && slot.held == 0) { + slot.held = slot.held or bit + armGuide(slot) + return + } + flushPendingSelect(slot) + } if (send && forwarding) { NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index) } @@ -155,7 +195,8 @@ class GamepadRouter( onMicChord?.invoke() } } else { - if (send && forwarding) { + val owned = guideGesture && bit == Gamepad.BTN_BACK && consumeSelectRelease(slot) + if (!owned && send && forwarding) { NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index) } slot.held = slot.held and bit.inv() @@ -167,6 +208,61 @@ class GamepadRouter( } } + /** Start a pending Select's hold countdown ([GUIDE_HOLD_MS] → a synthetic guide, down until release). */ + private fun armGuide(slot: Slot) { + val r = Runnable { + slot.pendingGuide = null + slot.selectAsGuide = true + if (forwarding) { + NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, true, slot.index) + } + } + slot.pendingGuide = r + mainHandler.postDelayed(r, GUIDE_HOLD_MS) + } + + /** + * A second button joined while Select was pending — it was a real Select after all; its + * deferred down goes out before the caller sends the new button's, preserving chronology. + */ + private fun flushPendingSelect(slot: Slot) { + val r = slot.pendingGuide ?: return + mainHandler.removeCallbacks(r) + slot.pendingGuide = null + if (forwarding) { + NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, true, slot.index) + } + } + + /** + * Select released with gesture state outstanding — true when the gesture owned the + * release. A transformed hold lifts the synthetic guide; a pending tap delivers its + * held-back press now, with the release [TAP_PRESS_MS] behind it (a back-to-back pair + * can fold into nothing in the host's per-pad input fold). + */ + private fun consumeSelectRelease(slot: Slot): Boolean { + if (slot.selectAsGuide) { + slot.selectAsGuide = false + if (forwarding) { + NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, false, slot.index) + } + return true + } + val r = slot.pendingGuide ?: return false + mainHandler.removeCallbacks(r) + slot.pendingGuide = null + if (forwarding) { + NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, true, slot.index) + val up = Runnable { + slot.pendingTapUp = null + NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, false, slot.index) + } + slot.pendingTapUp = up + mainHandler.postDelayed(up, TAP_PRESS_MS) + } + return true + } + /** Arm the exit-chord hold timer (once); on expiry, if the chord is still held, flush + leave. */ private fun armExit() { if (pendingExit != null) return // already counting down @@ -362,6 +458,24 @@ class GamepadRouter( /** Lift every held button + zero the axes/HAT dpad for [slot] (wire events only, all on its index). */ private fun releaseHeld(slot: Slot) { + // Gesture first: a pending (never-sent) Select just drops its timer; an owed tap + // release goes out NOW (its down is already on the wire and the handle may not + // outlive this slot); a transformed guide — which is not in `held` — is lifted. + slot.pendingGuide?.let { mainHandler.removeCallbacks(it) } + slot.pendingGuide = null + slot.pendingTapUp?.let { + mainHandler.removeCallbacks(it) + slot.pendingTapUp = null + if (forwarding) { + NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_BACK, false, slot.index) + } + } + if (slot.selectAsGuide) { + slot.selectAsGuide = false + if (forwarding) { + NativeBridge.nativeSendGamepadButton(handle, Gamepad.BTN_GUIDE, false, slot.index) + } + } var bits = slot.held while (bits != 0) { val bit = bits and -bits // lowest set bit @@ -403,5 +517,14 @@ class GamepadRouter( /** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */ const val EXTERNAL_ID_BASE = -1000 + + /** pf-client-core's `GUIDE_HOLD`: hold Select alone this long → the host's guide goes down. */ + const val GUIDE_HOLD_MS = 350L + + /** + * pf-client-core's `TAP_PRESS`: a held-back Select tap's release trails its press by + * this much, so the pair can't coalesce into no press at all. + */ + const val TAP_PRESS_MS = 50L } } diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index c0714b34..8cdf017f 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -675,8 +675,13 @@ final class SessionModel: ObservableObject { // `gamepadForwarding` off means the host gets this device's pads from somewhere else // (USB passthrough, or a pad plugged into the host) — capture still runs, and still // watches for the escape chord, but puts nothing on the wire. + // System-button routing: whether raw guide/share presses ride the wire, and whether + // hold-Select arms as the alternate guide route (auto = on everywhere but macOS — + // iOS reserves the physical Home press, tvOS never delivers it). let capture = GamepadCapture( - connection: conn, manager: .shared, forwarding: settings.gamepadForwarding) + connection: conn, manager: .shared, forwarding: settings.gamepadForwarding, + systemForward: settings.systemButtonsForward, + guideGesture: settings.guideGestureEnabled) // The cross-client escape chord (hold L1+R1+Start+Select 1.5 s) — on tvOS the only // controller way out of a stream (B/Menu is swallowed during sessions; see ContentView). capture.onDisconnectRequest = { [weak self] in self?.disconnect() } diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index 8c218dd9..514e7bc8 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -39,6 +39,8 @@ struct GamepadSettingsView: View { @AppStorage(DefaultsKey.compositor) private var compositor = 0 @AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0 @AppStorage(DefaultsKey.gamepadForwarding) private var gamepadForwarding = true + @AppStorage(DefaultsKey.systemButtons) private var systemButtons = "auto" + @AppStorage(DefaultsKey.guideGesture) private var guideGesture = "auto" @AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0 @AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2 @AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true @@ -399,6 +401,18 @@ struct GamepadSettingsView: View { detail: "The virtual pad the host creates — Automatic matches this controller.", options: SettingsOptions.padTypes, current: gamepadType ) { gamepadType = $0 }, + choiceRow( + id: "systemButtons", icon: "house.circle", label: "Guide button", + detail: "Where the guide (Xbox/PS) and share presses go while streaming — " + + "Automatic sends them to the host whenever this device delivers them.", + options: SettingsOptions.systemButtons, current: systemButtons + ) { systemButtons = $0 }, + choiceRow( + id: "guideGesture", icon: "hand.point.up.left", label: "Hold Select for guide", + detail: "Hold Select alone to press the host's guide button — keep holding " + + "for a Gaming-Mode host's quick-access menu. A tap still goes through.", + options: SettingsOptions.guideGestures, current: guideGesture + ) { guideGesture = $0 }, choiceRow( id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay", diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift index 9323ce14..fa23a22c 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift @@ -34,6 +34,22 @@ enum SettingsOptions { ("DualShock 4", 4), ] + /// System-button routing (the cross-client `system_buttons` key): where the guide + /// (Xbox/PS) and share presses land while streaming. Auto = forward on Apple. + static let systemButtons: [(label: String, tag: String)] = [ + ("Automatic", "auto"), + ("Send to host", "forward"), + ("This device", "local"), + ] + + /// The hold-Select guide gesture (the cross-client `guide_gesture` key). Auto = on + /// everywhere but macOS. + static let guideGestures: [(label: String, tag: String)] = [ + ("Automatic", "auto"), + ("On", "on"), + ("Off", "off"), + ] + static let hudPlacements: [(label: String, tag: String)] = HUDPlacement.allCases.map { ($0.label, $0.rawValue) } diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift index 70821ff6..661cccbb 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift @@ -126,6 +126,14 @@ enum SettingsFields { .init(name: "gamepad_forwarding", key: DefaultsKey.gamepadForwarding, overlay: \.gamepadForwarding, effective: \.gamepadForwarding) } + static var systemButtons: SettingsField { + .init(name: "system_buttons", key: DefaultsKey.systemButtons, + overlay: \.systemButtons, effective: \.systemButtons) + } + static var guideGesture: SettingsField { + .init(name: "guide_gesture", key: DefaultsKey.guideGesture, + overlay: \.guideGesture, effective: \.guideGesture) + } static var statsVerbosity: SettingsField { .init(name: "stats_verbosity", key: DefaultsKey.statsVerbosity, overlay: \.statsVerbosity, effective: \.statsVerbosity) diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift index 544f9607..93dbdf71 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift @@ -681,6 +681,29 @@ extension SettingsView { } .disabled(!effective.gamepadForwarding) } + described("Where the guide (Xbox/PS) and share presses go while streaming. " + + "Automatic sends them to the host whenever this device delivers them " + + "— the hold-Select gesture below reaches the host regardless.", + field: "system_buttons") { + Picker("Guide button", selection: scoped(SettingsFields.systemButtons)) { + Text("Automatic").tag("auto") + Text("Send to host").tag("forward") + Text("This device").tag("local") + } + .disabled(!effective.gamepadForwarding) + } + described("Hold Select on its own to press the host's guide button — keep " + + "holding for a Gaming-Mode host's quick-access menu. A Select tap still " + + "goes through, slightly delayed. Automatic arms it wherever the real " + + "button can't reach the host (this device reserves it).", + field: "guide_gesture") { + Picker("Hold Select for guide", selection: scoped(SettingsFields.guideGesture)) { + Text("Automatic").tag("auto") + Text("On").tag("on") + Text("Off").tag("off") + } + .disabled(!effective.gamepadForwarding) + } #if os(iOS) // iPhone only in practice: hidden where the device itself can't play haptics (iPad). if !inProfileScope, CHHapticEngine.capabilitiesForHardware().supportsHaptics { diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift index 6c9d8dc5..b623f050 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift @@ -67,6 +67,17 @@ public final class GamepadCapture { var axes: [Int32] = [0, 0, 0, 0, 0, 0] var fingerActive: [Bool] = [false, false] var lastMotionNs: UInt64 = 0 + // Hold-Select→guide gesture state (pf-client-core's `SelectGesture`, adapted to + // this class's mask-diff model): a Select pressed ALONE is held out of the mask + // until it resolves into a tap (delivered on release) or — past `guideHold` — a + // synthetic guide, down until release. + var selectPending = false + var selectAsGuide = false + /// A delivered tap's release is owed (`tapTimer` scheduled) — its down went out + /// outside `buttons`, so `flush` must know to lift it. + var tapReleaseOwed = false + var gestureTimer: Timer? + var tapTimer: Timer? init(controller: GCController, pad: UInt32, pref: PunktfunkConnection.GamepadType) { self.controller = controller self.pad = pad @@ -91,6 +102,16 @@ public final class GamepadCapture { GamepadWire.leftShoulder | GamepadWire.rightShoulder | GamepadWire.start | GamepadWire.back /// pf-client-core's `DISCONNECT_HOLD` — the same 1.5 s on every client. private static let disconnectHold: TimeInterval = 1.5 + /// pf-client-core's `GUIDE_HOLD`: hold Select alone this long → the HOST's guide goes + /// down (until release, so a long hold is the host's long-press — a Gaming-Mode + /// host's QAM). The gesture exists because iOS reserves the physical Home press (the + /// Game Overlay; sanctioned opt-out only via the user's iOS 27+ Home-button setting) + /// and tvOS never delivers it at all. + private static let guideHold: TimeInterval = 0.35 + /// pf-client-core's `TAP_PRESS`: a held-back Select tap is delivered as a press with + /// its release this far behind — back-to-back transitions can fold into nothing in + /// the host's per-pad input fold. + private static let tapPress: TimeInterval = 0.05 private var chordTimer: Timer? /// Fired ON MAIN once the escape chord has been held `disconnectHold` — the session owner /// disconnects. On tvOS this (plus the Siri Remote's hold-Back) is the ONLY way out of a @@ -115,10 +136,23 @@ public final class GamepadCapture { /// "don't forward" is one fact in one place rather than a condition at twelve call sites. private var wire: PunktfunkConnection? { forwarding ? connection : nil } - public init(connection: PunktfunkConnection, manager: GamepadManager, forwarding: Bool = true) { + /// Forward the raw guide + share/QAM presses (`EffectiveSettings.systemButtonsForward`, + /// default true on Apple — where the OS shows its own overlay for them, that's the OS's + /// business; local mode exists for profile parity with the Gaming-Mode clients). + public let systemForward: Bool + /// The hold-Select guide gesture (`EffectiveSettings.guideGestureEnabled` — auto = on + /// everywhere but macOS). See `guideHold`. + public let guideGesture: Bool + + public init( + connection: PunktfunkConnection, manager: GamepadManager, forwarding: Bool = true, + systemForward: Bool = true, guideGesture: Bool = false + ) { self.connection = connection self.manager = manager self.forwarding = forwarding + self.systemForward = systemForward + self.guideGesture = guideGesture } public func start() { @@ -206,7 +240,14 @@ public final class GamepadCapture { element.preferredSystemGestureState = .disabled } // The Home/PS button (→ guide; the host maps it to the DualSense PS / Xbox guide bit, - // BTN_MODE on the virtual xpad — the Steam-overlay button). Driven DIRECTLY from this + // BTN_MODE on the virtual xpad — the Steam-overlay button). On iOS 26 the OS opens its + // Game Overlay for this press regardless of the gesture claim below (the app is + // LSApplicationCategoryType=games, which enrolls it); the sanctioned per-controller + // opt-out is the USER's iOS 27+ Home-button setting. TODO(iOS 27 SDK): read + // `GCControllerHomeButtonSettingsManager` and surface a one-time + // `openControllerHomeButtonSettings(for:)` deep-link so users can hand the button to + // the stream — the class is Swift-only and 27.0+, so it needs the Xcode 27 SDK to + // even compile. Until then hold-Select is the reliable route. Driven DIRECTLY from this // handler's pressed value (not via buttonMask), because the legacy // `extendedGamepad.buttonHome` is unreliable/often nil even when the physical element // exists. On tvOS the element is absent (reserved) → nil, the whole block no-ops. @@ -289,7 +330,14 @@ public final class GamepadCapture { // as "changed" — otherwise the first stick/button move after a guide press would emit a // spurious guide-UP while the button is still physically held (and drop the bit from // `slot.buttons`, swallowing the real release too). `flush`/`allButtons` still release it. - let newButtons = Self.buttonMask(g) | (slot.buttons & GamepadWire.guide) + var raw = Self.buttonMask(g) + // Raw system buttons stay local when passthrough is off: misc1 (share/QAM) is + // masked here, guide is gated at its own handler. + if !systemForward { raw &= ~GamepadWire.misc1 } + // The hold-Select gesture rewrites the mask: a Select pressed alone is held out + // until it resolves (tap on release / synthetic guide past the threshold). + if guideGesture { raw = gestureFiltered(slot, raw) } + let newButtons = raw | (slot.buttons & GamepadWire.guide) let changed = newButtons ^ slot.buttons if changed != 0 { for bit in GamepadWire.allButtons where changed & bit != 0 { @@ -312,10 +360,106 @@ public final class GamepadCapture { updateEscapeChord() } + /// The hold-Select→guide state machine over one sync's raw mask (pf-client-core's + /// `SelectGesture` rules): Select pressed ALONE is suppressed while pending; another + /// button joining makes it real (unsuppressed — the diff sends its down); released + /// inside `guideHold` it's a tap, delivered out-of-band on release with the release + /// `tapPress` behind; past the threshold `gestureHoldFired` turned it into a synthetic + /// guide, lifted here when Select physically releases. + /// + /// One deliberate divergence from the Rust worker: while transformed into a guide the + /// Select stays OUT of `slot.buttons`, so the escape chord doesn't complete on top of + /// an in-flight guide-hold — release Select and press the chord plainly instead (the + /// chord's four-at-once press never lingers in pending long enough to be affected). + private func gestureFiltered(_ slot: Slot, _ raw: UInt32) -> UInt32 { + let back = GamepadWire.back + let backDown = raw & back != 0 + let othersDown = raw & ~back != 0 + if slot.selectAsGuide { + if backDown { return raw & ~back } + slot.selectAsGuide = false + sendGuide(slot, down: false, raw: false) + return raw + } + if slot.selectPending { + if !backDown { + endPending(slot) + deliverTap(slot) + return raw + } + if othersDown { + // A combo after all — Select unsuppresses and the diff sends its down. + endPending(slot) + return raw + } + return raw & ~back + } + if backDown, !othersDown, slot.buttons & back == 0 { + // Newly pressed, alone: hold it back. An owed tap release goes out first so + // the host never sees two downs in a row. + if slot.tapReleaseOwed { finishTap(slot) } + slot.selectPending = true + let timer = Timer(timeInterval: Self.guideHold, repeats: false) { [weak self, weak slot] _ in + Task { @MainActor in + if let self, let slot { self.gestureHoldFired(slot) } + } + } + RunLoop.main.add(timer, forMode: .common) + slot.gestureTimer?.invalidate() + slot.gestureTimer = timer + return raw & ~back + } + return raw + } + + /// The hold threshold passed with Select still pending → it IS the guide now, down + /// until the physical release (`gestureFiltered`'s `selectAsGuide` branch lifts it). + private func gestureHoldFired(_ slot: Slot) { + guard slot.selectPending else { return } + slot.selectPending = false + slot.gestureTimer = nil + slot.selectAsGuide = true + sendGuide(slot, down: true, raw: false) + } + + private func endPending(_ slot: Slot) { + slot.selectPending = false + slot.gestureTimer?.invalidate() + slot.gestureTimer = nil + } + + /// Deliver a held-back Select tap: the press now, its release `tapPress` behind. Both + /// sends bypass `slot.buttons` (the raw mask no longer carries Select, so the diff + /// stays consistent); `tapReleaseOwed` is what `flush` checks so the press can't + /// outlive the slot. + private func deliverTap(_ slot: Slot) { + wire?.send(.gamepadButton(GamepadWire.back, down: true, pad: slot.pad)) + slot.tapReleaseOwed = true + let timer = Timer(timeInterval: Self.tapPress, repeats: false) { [weak self, weak slot] _ in + Task { @MainActor in + if let self, let slot { self.finishTap(slot) } + } + } + RunLoop.main.add(timer, forMode: .common) + slot.tapTimer?.invalidate() + slot.tapTimer = timer + } + + private func finishTap(_ slot: Slot) { + guard slot.tapReleaseOwed else { return } + slot.tapReleaseOwed = false + slot.tapTimer?.invalidate() + slot.tapTimer = nil + wire?.send(.gamepadButton(GamepadWire.back, down: false, pad: slot.pad)) + } + /// Forward the guide (Home/PS) transition directly — it's kept out of `buttonMask` (the legacy /// `buttonHome` element is unreliable). Folds into the slot's `buttons` so a held PS button is - /// released by `flush` on focus loss / close just like the others. - private func sendGuide(_ slot: Slot, down: Bool) { + /// released by `flush` on focus loss / close just like the others. `raw: true` marks the + /// physical Home handler's calls, which the system-buttons policy can keep local; the + /// gesture's synthetic transitions pass `raw: false` and always go out. + private func sendGuide(_ slot: Slot, down: Bool, raw: Bool = true) { + if raw, !systemForward { return } guard !suspended else { return } let bit = GamepadWire.guide let now = down ? (slot.buttons | bit) : (slot.buttons & ~bit) @@ -449,6 +593,12 @@ public final class GamepadCapture { /// (no GC calls) — safe against an already-removed device. Does NOT close the slot or send /// GamepadRemove (that's `closeSlot`). private func flush(_ slot: Slot) { + // Gesture first: a pending (never-sent) Select just drops, an owed tap release + // goes out, and a transformed guide's bit — folded into `buttons` by `sendGuide` + // — is lifted by the loop below like any held button. + endPending(slot) + slot.selectAsGuide = false + if slot.tapReleaseOwed { finishTap(slot) } for bit in GamepadWire.allButtons where slot.buttons & bit != 0 { wire?.send(.gamepadButton(bit, down: false, pad: slot.pad)) } diff --git a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift index 00e1053f..56f97896 100644 --- a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift @@ -38,6 +38,17 @@ public enum DefaultsKey { /// host two pads for one pair of hands. Read at connect: `SessionModel` then never starts /// `GamepadCapture`, so no slot opens, no arrival is sent and no virtual pad is built. public static let gamepadForwarding = "punktfunk.gamepadForwarding" + /// Where a controller's SYSTEM buttons (guide + the share/QAM misc) land while streaming: + /// `"auto"` | `"forward"` | `"local"` — the cross-client `system_buttons` key. Auto + /// forwards on every Apple platform: the local Game Overlay is the OS's business (and on + /// iOS 27+ the user can hand the Home button to the app in Settings), so suppressing our + /// send would gain nothing. + public static let systemButtons = "punktfunk.systemButtons" + /// The hold-Select guide gesture: `"auto"` | `"on"` | `"off"` — the cross-client + /// `guide_gesture` key. Auto arms it everywhere but macOS: iOS reserves the physical Home + /// press for the Game Overlay (uncapturable pre-27) and tvOS never delivers it at all, so + /// holding Select is the controller route to the host's guide there. + public static let guideGesture = "punktfunk.guideGesture" public static let bitrateKbps = "punktfunk.bitrateKbps" /// Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it /// can capture; the resolved count drives the in-core decode + AVAudioEngine layout. diff --git a/clients/apple/Sources/PunktfunkShared/EffectiveSettings.swift b/clients/apple/Sources/PunktfunkShared/EffectiveSettings.swift index 5dd8a3c9..5bf45f45 100644 --- a/clients/apple/Sources/PunktfunkShared/EffectiveSettings.swift +++ b/clients/apple/Sources/PunktfunkShared/EffectiveSettings.swift @@ -35,6 +35,10 @@ public struct EffectiveSettings: Equatable, Sendable { public var invertScroll = false public var gamepadType = 0 public var gamepadForwarding = true + /// Cross-client `system_buttons`: "auto" | "forward" | "local". + public var systemButtons = "auto" + /// Cross-client `guide_gesture`: "auto" | "on" | "off". + public var guideGesture = "auto" /// A `StatsVerbosity` raw value; the enum lives in PunktfunkKit, which this module can't see. public var statsVerbosity = "normal" public var fullscreenWhileStreaming = true @@ -95,6 +99,8 @@ public struct EffectiveSettings: Equatable, Sendable { invertScroll = bool(DefaultsKey.invertScroll, invertScroll) gamepadType = int(DefaultsKey.gamepadType, gamepadType) gamepadForwarding = bool(DefaultsKey.gamepadForwarding, gamepadForwarding) + systemButtons = str(DefaultsKey.systemButtons, systemButtons) + guideGesture = str(DefaultsKey.guideGesture, guideGesture) statsVerbosity = Self.storedStatsVerbosity(defaults) fullscreenWhileStreaming = bool( DefaultsKey.fullscreenWhileStreaming, fullscreenWhileStreaming) @@ -121,6 +127,36 @@ public struct EffectiveSettings: Equatable, Sendable { return "normal" } + /// The `system_buttons` policy resolved for this platform: forward the raw guide (and + /// share/QAM misc) presses? Auto = forward on every Apple platform — where the OS shows + /// its own overlay for the press that is the OS's business, and suppressing our send + /// would only break users who handed the button to the app (iOS 27's Home-button + /// setting; macOS with the gestures claimed). + public var systemButtonsForward: Bool { + switch systemButtons { + case "local": return false + default: return true + } + } + + /// The hold-Select guide gesture resolved for this platform ([`guideGesture`]). Auto = + /// on everywhere but macOS: iOS reserves the physical Home press (the Game Overlay, + /// uncapturable pre-27) and tvOS never delivers it, so holding Select is the controller + /// route to the host's guide — and, held on, to a Gaming-Mode host's QAM. On macOS the + /// raw press reaches the host, so auto stays off and Select keeps its exact timing. + public var guideGestureEnabled: Bool { + switch guideGesture { + case "on": return true + case "off": return false + default: + #if os(macOS) + return false + #else + return true + #endif + } + } + /// The one resolution seam: this overlay on top of these settings. Pure — no store reads, no /// clock — so it is testable field by field. A `.some` that happens to equal the base is a /// legitimate PIN: it keeps its value when the global later moves. @@ -143,6 +179,8 @@ public struct EffectiveSettings: Equatable, Sendable { if let v = overlay.invertScroll { s.invertScroll = v } if let v = overlay.gamepadType { s.gamepadType = v } if let v = overlay.gamepadForwarding { s.gamepadForwarding = v } + if let v = overlay.systemButtons { s.systemButtons = v } + if let v = overlay.guideGesture { s.guideGesture = v } if let v = overlay.statsVerbosity { s.statsVerbosity = v } if let v = overlay.fullscreenWhileStreaming { s.fullscreenWhileStreaming = v } if let v = overlay.enable444 { s.enable444 = v } diff --git a/clients/apple/Sources/PunktfunkShared/StreamProfile.swift b/clients/apple/Sources/PunktfunkShared/StreamProfile.swift index dbe08fc6..fa0252bc 100644 --- a/clients/apple/Sources/PunktfunkShared/StreamProfile.swift +++ b/clients/apple/Sources/PunktfunkShared/StreamProfile.swift @@ -111,6 +111,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable { public var invertScroll: Bool? public var gamepadType: Int? public var gamepadForwarding: Bool? + public var systemButtons: String? + public var guideGesture: String? /// A `StatsVerbosity` raw value ("off"/"compact"/"normal"/"detailed") — the enum lives in /// PunktfunkKit, which this module must not depend on. public var statsVerbosity: String? @@ -153,6 +155,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable { case invertScroll = "invert_scroll" case gamepadType = "gamepad" case gamepadForwarding = "gamepad_forwarding" + case systemButtons = "system_buttons" + case guideGesture = "guide_gesture" case statsVerbosity = "stats_verbosity" case fullscreenWhileStreaming = "fullscreen_on_stream" case enable444 = "enable_444" @@ -187,6 +191,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable { invertScroll = bool(.invertScroll) gamepadType = int(.gamepadType) gamepadForwarding = bool(.gamepadForwarding) + systemButtons = str(.systemButtons) + guideGesture = str(.guideGesture) statsVerbosity = str(.statsVerbosity) fullscreenWhileStreaming = bool(.fullscreenWhileStreaming) enable444 = bool(.enable444) @@ -224,6 +230,8 @@ public struct SettingsOverlay: Codable, Equatable, Sendable { try c.encodeIfPresent(gamepadType, forKey: AnyKey(Key.gamepadType.rawValue)) try c.encodeIfPresent( gamepadForwarding, forKey: AnyKey(Key.gamepadForwarding.rawValue)) + try c.encodeIfPresent(systemButtons, forKey: AnyKey(Key.systemButtons.rawValue)) + try c.encodeIfPresent(guideGesture, forKey: AnyKey(Key.guideGesture.rawValue)) try c.encodeIfPresent(statsVerbosity, forKey: AnyKey(Key.statsVerbosity.rawValue)) try c.encodeIfPresent( fullscreenWhileStreaming, forKey: AnyKey(Key.fullscreenWhileStreaming.rawValue)) @@ -277,6 +285,8 @@ public enum OverlayField { case "invert_scroll": overlay.invertScroll = nil case "gamepad": overlay.gamepadType = nil case "gamepad_forwarding": overlay.gamepadForwarding = nil + case "system_buttons": overlay.systemButtons = nil + case "guide_gesture": overlay.guideGesture = nil case "stats_verbosity": overlay.statsVerbosity = nil case "fullscreen_on_stream": overlay.fullscreenWhileStreaming = nil case "enable_444": overlay.enable444 = nil @@ -313,6 +323,8 @@ public enum OverlayField { case "invert_scroll": return o.invertScroll != nil case "gamepad": return o.gamepadType != nil case "gamepad_forwarding": return o.gamepadForwarding != nil + case "system_buttons": return o.systemButtons != nil + case "guide_gesture": return o.guideGesture != nil case "stats_verbosity": return o.statsVerbosity != nil case "fullscreen_on_stream": return o.fullscreenWhileStreaming != nil case "enable_444": return o.enable444 != nil diff --git a/clients/decky/main.py b/clients/decky/main.py index 64695fb8..53b1e772 100644 --- a/clients/decky/main.py +++ b/clients/decky/main.py @@ -647,6 +647,19 @@ async def _native_update_state() -> dict: return {"error": "client-outdated"} if outdated else {} +def _ctl_sockets() -> list[Path]: + """Candidate paths of the streaming client's control socket (guide/QAM injection): + the flatpak app runtime dir first (the one runtime path the sandbox and this backend + see identically), then the plain runtime dir (native installs). Mirrors the session + binary's ``ctl_socket::path``.""" + uid = os.environ.get("PF_UID") or "1000" + run = Path(f"/run/user/{uid}") + return [ + run / "app" / APP_ID / "punktfunk-session-ctl.sock", + run / "punktfunk-session-ctl.sock", + ] + + class Plugin: # ---- Thin shells over the headless CLI ------------------------------------------------- # @@ -842,6 +855,46 @@ class Plugin: return {"ok": False} return {"ok": True} + async def stream_running(self) -> dict: + """Whether the streaming client's control socket exists — i.e. the client is up. + + The socket appears at the client's first stream and lives for the process, so + between console-mode streams it lingers; that only leaves the panel's host + buttons harmlessly visible. + """ + return {"running": any(p.is_socket() for p in _ctl_sockets())} + + async def host_action(self, action: str) -> dict: + """Press a HOST system button on the running stream: ``guide`` (the Steam/Xbox/PS + menu button) or ``qam`` (the quick-access ``…``). + + Talks to the session binary's control socket (one text verb per connection, + ``ok``/``err`` back) — the flatpak app runtime dir first (the sandboxed client; + that dir is the one runtime path host and sandbox see identically), then the + plain runtime dir (native installs). No socket = no running stream. + """ + if action not in ("guide", "qam"): + return {"ok": False, "error": f"unknown action {action!r}"} + for sock in _ctl_sockets(): + if not sock.is_socket(): + continue + try: + reader, writer = await asyncio.wait_for( + asyncio.open_unix_connection(str(sock)), timeout=2.0 + ) + except Exception: # noqa: BLE001 — a stale socket file; try the next path + continue + try: + writer.write(f"{action}\n".encode()) + await writer.drain() + reply = await asyncio.wait_for(reader.readline(), timeout=2.0) + return {"ok": reply.strip() == b"ok"} + except Exception as e: # noqa: BLE001 + return {"ok": False, "error": str(e)} + finally: + writer.close() + return {"ok": False, "error": "no-stream"} + async def _update_native_client(self) -> dict: """The non-flatpak leg of :meth:`update_client` — drive the client's own ``--apply-update``, which starts the packaged root helper. diff --git a/clients/decky/src/backend.ts b/clients/decky/src/backend.ts index db65398b..00e3a10a 100644 --- a/clients/decky/src/backend.ts +++ b/clients/decky/src/backend.ts @@ -170,6 +170,15 @@ export const applyControllerConfig = callable< { ok: boolean; applied?: string[]; errors?: string[]; accounts?: number; error?: string; detail?: string } >("apply_controller_config"); export const killStream = callable<[], { ok: boolean }>("kill_stream"); +// Whether the streaming client's control socket exists (a stream/console client is up) — +// gates the QAM panel's host-button section. +export const streamRunning = callable<[], { running: boolean }>("stream_running"); +// Press a HOST system button on the running stream: "guide" | "qam". The raw Steam/QAM +// presses stay on the Deck by default (the client's Controllers settings), so this — and +// holding Select — is how the host's own menus are reached. +export const hostAction = callable<[action: string], { ok: boolean; error?: string }>( + "host_action", +); export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update"); // Update the client by whichever route its install supports: `flatpak update --user` for the // flatpak, `punktfunk-client --apply-update` (the packaged root helper) for a one-tap-capable diff --git a/clients/decky/src/index.tsx b/clients/decky/src/index.tsx index f37b7870..24c5e180 100644 --- a/clients/decky/src/index.tsx +++ b/clients/decky/src/index.tsx @@ -8,6 +8,7 @@ import { ButtonItem, Field, + Navigation, PanelSection, PanelSectionRow, Spinner, @@ -15,9 +16,10 @@ import { staticClasses, } from "@decky/ui"; import { definePlugin, toaster } from "@decky/api"; -import { FC } from "react"; +import { FC, useEffect, useState } from "react"; import { FaDownload, + FaGamepad, FaLock, FaPlay, FaPlus, @@ -25,7 +27,7 @@ import { FaSyncAlt, FaTv, } from "react-icons/fa"; -import { killStream } from "./backend"; +import { hostAction, killStream, streamRunning } from "./backend"; import { PluginErrorBoundary } from "./boundary"; import { applyUpdate, @@ -66,6 +68,22 @@ async function forceStop(): Promise { toaster.toast({ title: "Punktfunk", body: "Stopped the stream" }); } +// Press a host system button (guide/QAM) on the running stream, then hand the screen back +// to it — closing the local menus is what lets the HOST's overlay show through. The raw +// Steam/··· presses stay on the Deck by default (both overlays would open at once), so this +// is the panel route to the host's menus; holding Select is the controller route. +async function pressHost(action: "guide" | "qam"): Promise { + const r = await hostAction(action).catch(() => ({ ok: false as const, error: "backend" })); + if (r.ok) { + Navigation.CloseSideMenus(); + } else { + toaster.toast({ + title: "Punktfunk", + body: r.error === "no-stream" ? "No stream is running" : "Couldn't reach the stream", + }); + } +} + /** The line under a host's name: where it is, whether it's up, and how far trust has got. */ function hostDescription(v: HostView): string { const trust = { @@ -127,6 +145,18 @@ const HostRow: FC<{ host: HostView; refresh: () => void }> = ({ host, refresh }) const QamPanel: FC = () => { const { views, scanning, problem, refresh } = useHosts(); const { info: update, checking, check } = useUpdate(); + // The host-buttons section shows only while the streaming client is up (checked per + // panel open — the QAM panel mounts fresh each time). + const [streaming, setStreaming] = useState(false); + useEffect(() => { + let live = true; + void streamRunning() + .then((r) => live && setStreaming(r.running)) + .catch(() => {}); + return () => { + live = false; + }; + }, []); return ( <> @@ -230,6 +260,31 @@ const QamPanel: FC = () => {
+ {streaming && ( + + + void pressHost("guide")} + > + + Steam menu on host + + + + void pressHost("qam")} + > + + Quick access on host + + + + )} + u32 { + SYSTEM_BUTTONS + .iter() + .position(|&v| v == s.system_buttons) + .unwrap_or(0) as u32 + } + + pub fn guide_gesture(s: &Settings) -> u32 { + GUIDE_GESTURES + .iter() + .position(|&v| v == s.guide_gesture) + .unwrap_or(0) as u32 + } + pub fn present_priority(s: &Settings) -> u32 { // Unknown values (a newer client's intent) read as the default, exactly as // `PresentPriority::resolve` treats them. @@ -642,6 +656,12 @@ fn commit_profile(active: &StreamProfile, touched: &Touched, values: &Settings) if touched.has("gamepad_forwarding") { o.gamepad_forwarding = Some(values.gamepad_forwarding); } + if touched.has("system_buttons") { + o.system_buttons = Some(values.system_buttons.clone()); + } + if touched.has("guide_gesture") { + o.guide_gesture = Some(values.guide_gesture.clone()); + } if touched.has("stats_verbosity") { o.stats_verbosity = Some(values.stats_verbosity()); } @@ -687,6 +707,15 @@ const GAMEPADS: &[&str] = &[ "dualshock4", "steamdeck", ]; +/// System-button routing values (persisted under the cross-client `system_buttons` key): +/// where the guide (Xbox/PS/Steam) and quick-access presses land while streaming. Auto = +/// the host, except under Gaming Mode where the local Steam UI reacts to the same press. +const SYSTEM_BUTTONS: &[&str] = &["auto", "forward", "local"]; +const SYSTEM_BUTTON_LABELS: &[&str] = &["Automatic", "Send to host", "This device"]; +/// Hold-Select guide gesture values (the cross-client `guide_gesture` key). Auto arms it +/// only where the raw guide press can't reach the host (Gaming Mode here). +const GUIDE_GESTURES: &[&str] = &["auto", "on", "off"]; +const GUIDE_GESTURE_LABELS: &[&str] = &["Automatic", "On", "Off"]; const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"]; /// Codec setting values (persisted) paired with their display labels below. PyroWave is /// preference-only by design (`Settings::preferred_codec`) — the ladder falls back to @@ -1542,16 +1571,39 @@ pub fn show_scoped( "Steam Deck", ], ); - // Both pad rows only mean something while something is being forwarded (the same + // Where the guide (Xbox/PS/Steam) + quick-access presses land, and the hold-Select + // gesture that keeps the host's guide reachable when they stay local. Desktop rarely + // needs either off Automatic — they exist here because profiles are authored on the + // desktop and applied everywhere, Gaming Mode included. + let sysbtn_row = ChoiceRow::new( + &dialog, + inline, + "Steam / guide button", + "Automatic sends it to the host, except where this device reacts to it too", + SYSTEM_BUTTON_LABELS, + ); + let gesture_row = ChoiceRow::new( + &dialog, + inline, + "Hold Select for guide", + "Hold Select alone for the host's guide button — a tap still goes through", + GUIDE_GESTURE_LABELS, + ); + // The pad rows only mean something while something is being forwarded (the same // relationship mic → echo cancellation draws just above, initial state included: the // seed's `set_active` fires this only when it CHANGES the switch). { let (f, t) = (forward_row.widget().clone(), pad_row.widget().clone()); + let (sb, gg) = (sysbtn_row.widget().clone(), gesture_row.widget().clone()); f.set_sensitive(seed.gamepad_forwarding); t.set_sensitive(seed.gamepad_forwarding); + sb.set_sensitive(seed.gamepad_forwarding); + gg.set_sensitive(seed.gamepad_forwarding); pad_forward_row.connect_active_notify(move |r| { f.set_sensitive(r.is_active()); t.set_sensitive(r.is_active()); + sb.set_sensitive(r.is_active()); + gg.set_sensitive(r.is_active()); }); } @@ -1566,6 +1618,8 @@ pub fn show_scoped( bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0); pad_forward_row.set_active(s.gamepad_forwarding); pad_row.set_selected(index::gamepad(s)); + sysbtn_row.set_selected(index::system_buttons(s)); + gesture_row.set_selected(index::guide_gesture(s)); let touch_i = index::touch(s); touch_row.set_selected(touch_i); // set_selected never fires the changed hook, so seed the dynamic caption directly. @@ -1795,6 +1849,18 @@ pub fn show_scoped( index::surround ); choice!(pad_row, "gamepad", o.gamepad.is_some(), index::gamepad); + choice!( + sysbtn_row, + "system_buttons", + o.system_buttons.is_some(), + index::system_buttons + ); + choice!( + gesture_row, + "guide_gesture", + o.guide_gesture.is_some(), + index::guide_gesture + ); toggle!( pad_forward_row, "gamepad_forwarding", @@ -2001,6 +2067,8 @@ pub fn show_scoped( controllers_group.add(forward_row.widget()); } controllers_group.add(pad_row.widget()); + controllers_group.add(sysbtn_row.widget()); + controllers_group.add(gesture_row.widget()); controllers.add(&controllers_group); // Cap every caption in one pass, after the rows exist: a per-row call would be sixteen @@ -2040,6 +2108,12 @@ pub fn show_scoped( if pad_sel != 0 || GAMEPADS.contains(&s.gamepad.as_str()) { s.gamepad = GAMEPADS[pad_sel].to_string(); } + s.system_buttons = SYSTEM_BUTTONS + [(sysbtn_row.selected() as usize).min(SYSTEM_BUTTONS.len() - 1)] + .to_string(); + s.guide_gesture = GUIDE_GESTURES + [(gesture_row.selected() as usize).min(GUIDE_GESTURES.len() - 1)] + .to_string(); s.touch_mode = TOUCH_MODES[(touch_row.selected() as usize).min(TOUCH_MODES.len() - 1)].to_string(); s.mouse_mode = diff --git a/clients/session/src/main.rs b/clients/session/src/main.rs index be6478a6..16f390e5 100644 --- a/clients/session/src/main.rs +++ b/clients/session/src/main.rs @@ -18,6 +18,78 @@ #[cfg(all(any(target_os = "linux", windows), feature = "ui"))] mod console; +/// The session control socket: a line-per-connection unix socket other same-user +/// processes use to poke the RUNNING stream — today two verbs, `guide` and `qam`, which +/// press the HOST's system buttons (the Decky panel's "Steam menu / Quick access on the +/// host" buttons; see `GamepadService::tap_guide`). Plain text, no JSON: `\n` in, +/// `ok\n` / `err\n` back. +/// +/// The path is `$XDG_RUNTIME_DIR/punktfunk-session-ctl.sock` — inside the flatpak app +/// runtime dir (`…/app/$FLATPAK_ID/`) when sandboxed, the ONE runtime path a flatpak and +/// the host see identically, which is what lets the Decky backend (outside the sandbox) +/// reach a flatpak-run session. +#[cfg(all(unix, any(target_os = "linux", windows)))] +mod ctl_socket { + use pf_client_core::gamepad::GamepadService; + use std::io::{BufRead, BufReader, Write}; + use std::os::unix::net::UnixListener; + use std::path::PathBuf; + + fn path() -> Option { + let mut p = PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR")?); + if let Ok(id) = std::env::var("FLATPAK_ID") { + p.push("app"); + p.push(id); + } + Some(p.join("punktfunk-session-ctl.sock")) + } + + /// Bind + serve on a background thread, once per process (later calls no-op). Any + /// failure just logs at debug — the socket is a convenience surface, never worth + /// failing a stream over. + pub(crate) fn spawn(gamepad: GamepadService) { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(move || { + let Some(path) = path() else { return }; + // A previous session's socket file refuses the bind — it's ours to replace. + let _ = std::fs::remove_file(&path); + let listener = match UnixListener::bind(&path) { + Ok(l) => l, + Err(e) => { + tracing::debug!(error = %e, path = %path.display(), "session ctl socket unavailable"); + return; + } + }; + let spawned = std::thread::Builder::new() + .name("pf-session-ctl".into()) + .spawn(move || { + for stream in listener.incoming() { + let Ok(mut s) = stream else { continue }; + let mut line = String::new(); + if BufReader::new(&s).read_line(&mut line).is_err() { + continue; + } + let ok = match line.trim() { + "guide" => { + gamepad.tap_guide(); + true + } + "qam" => { + gamepad.tap_qam(); + true + } + _ => false, + }; + let _ = s.write_all(if ok { b"ok\n" } else { b"err\n" }); + } + }); + if let Err(e) = spawned { + tracing::debug!(error = %e, "session ctl thread failed to start"); + } + }); + } +} + #[cfg(any(target_os = "linux", windows))] mod session_main { use pf_client_core::gamepad::GamepadService; @@ -44,14 +116,20 @@ mod session_main { std::env::args().any(|a| a == flag) } + /// Running under Gaming Mode (a Deck, or any gamescope session): the environment + /// where the local Steam UI owns the physical Steam/QAM buttons — the system-button + /// "auto" policy keys off this. + pub(crate) fn gaming_mode() -> bool { + std::env::var_os("SteamDeck").is_some() + || std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some() + } + /// Run fullscreen: `--fullscreen`, or the Deck/gamescope env as a fallback so a /// manual launch under Gaming Mode does the right thing too. (Browse-mode only — /// gated with `mod browse`, its one caller.) #[cfg(feature = "ui")] pub(crate) fn fullscreen_mode() -> bool { - arg_flag("--fullscreen") - || std::env::var_os("SteamDeck").is_some() - || std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some() + arg_flag("--fullscreen") || gaming_mode() } /// `--window-pos X,Y` → the window's top-left in desktop coordinates (a spawning @@ -194,6 +272,20 @@ mod session_main { // it back. It goes on before the attach below, so a non-forwarding session never opens // — never grabs — the device. gamepad.set_forwarding(settings.gamepad_forwarding); + // System-button routing: whether raw guide/QAM presses ride the wire, and whether + // hold-Select arms as the alternate guide route. Auto keys off Gaming Mode — the + // local Steam UI reacts to the same physical buttons there no matter what, so + // forwarding raw opens BOTH overlays, the local one on top of the stream. Set + // unconditionally for the same browse-mode-reuse reason as the line above. + let game_mode = gaming_mode(); + gamepad.set_system_buttons( + settings.system_buttons_forward(game_mode), + settings.guide_gesture_enabled(game_mode), + ); + // The control socket (guide/QAM injection — the Decky panel's host buttons). + // Spawned at first params-build so it exists for --connect AND console launches. + #[cfg(unix)] + crate::ctl_socket::spawn(gamepad.clone()); let mode = Mode { width: if settings.width == 0 { native.width diff --git a/clients/windows/src/app/settings.rs b/clients/windows/src/app/settings.rs index 2d924a44..4bbdd924 100644 --- a/clients/windows/src/app/settings.rs +++ b/clients/windows/src/app/settings.rs @@ -79,6 +79,17 @@ const GAMEPADS: &[(&str, &str)] = &[ // user could not ask the host for the Deck-shaped pad (trackpads, back grips). ("steamdeck", "Steam Deck"), ]; +/// System-button routing: `(stored value, display label)` — where the guide (Xbox/PS) +/// and quick-access presses land while streaming. The cross-client `system_buttons` key; +/// Automatic forwards on desktop and stays local under Gaming Mode. +const SYSTEM_BUTTONS: &[(&str, &str)] = &[ + ("auto", "Automatic"), + ("forward", "Send to host"), + ("local", "This device"), +]; +/// The hold-Select guide gesture: `(stored value, display label)` — the cross-client +/// `guide_gesture` key. Automatic arms it only where the raw press can't reach the host. +const GUIDE_GESTURES: &[(&str, &str)] = &[("auto", "Automatic"), ("on", "On"), ("off", "Off")]; /// Stats-overlay tiers: `(stored value, display label)` — the cross-client verbosity ladder /// (Compact ⊂ Normal ⊂ Detailed); Ctrl+Alt+Shift+S cycles it live in the session window. const STATS_TIERS: &[(StatsVerbosity, &str)] = &[ @@ -479,6 +490,8 @@ struct OverrideFlags { inhibit_shortcuts: bool, gamepad: bool, gamepad_forwarding: bool, + system_buttons: bool, + guide_gesture: bool, stats_verbosity: bool, fullscreen_on_stream: bool, present_priority: bool, @@ -512,6 +525,8 @@ impl OverrideFlags { inhibit_shortcuts: o.inhibit_shortcuts.is_some(), gamepad: o.gamepad.is_some(), gamepad_forwarding: o.gamepad_forwarding.is_some(), + system_buttons: o.system_buttons.is_some(), + guide_gesture: o.guide_gesture.is_some(), stats_verbosity: o.stats_verbosity.is_some(), fullscreen_on_stream: o.fullscreen_on_stream.is_some(), present_priority: o.present_priority.is_some(), @@ -977,6 +992,28 @@ pub(crate) fn settings_page( let pad_combo = setting_combo(ctx, scope, (rev, set_rev), pad_names, pad_i, |s, i| { s.gamepad = GAMEPADS[i].0.to_string(); }); + let (sysbtn_names, sysbtn_i) = presets(SYSTEM_BUTTONS, |v| *v == s.system_buttons); + let sysbtn_combo = setting_combo( + ctx, + scope, + (rev, set_rev), + sysbtn_names, + sysbtn_i, + |s, i| { + s.system_buttons = SYSTEM_BUTTONS[i].0.to_string(); + }, + ); + let (gesture_names, gesture_i) = presets(GUIDE_GESTURES, |v| *v == s.guide_gesture); + let gesture_combo = setting_combo( + ctx, + scope, + (rev, set_rev), + gesture_names, + gesture_i, + |s, i| { + s.guide_gesture = GUIDE_GESTURES[i].0.to_string(); + }, + ); let (touch_names, touch_i) = presets(TOUCH_MODES, |v| *v == s.touch_mode); let touch_combo = setting_combo(ctx, scope, (rev, set_rev), touch_names, touch_i, |s, i| { s.touch_mode = TOUCH_MODES[i].0.to_string(); @@ -1407,6 +1444,30 @@ pub(crate) fn settings_page( \u{2014} a DualSense keeps adaptive triggers, lightbar, touchpad and \ motion.", )), + Some(described_overridable( + (rev, set_rev), + scope, + "system_buttons", + "Steam / guide button", + over.system_buttons, + sysbtn_combo, + "Where the guide (Xbox/PS) and quick-access presses go while \ + streaming. Automatic sends them to the host \u{2014} except on \ + devices whose own overlay reacts to the same press (Gaming Mode), \ + where they stay local and the gesture below reaches the host.", + )), + Some(described_overridable( + (rev, set_rev), + scope, + "guide_gesture", + "Hold Select for guide", + over.guide_gesture, + gesture_combo, + "Hold Select on its own to press the host's guide button \u{2014} keep \ + holding for a Gaming-Mode host's quick-access menu. A Select tap \ + still goes through, slightly delayed. Automatic arms it only where \ + the real button can't reach the host.", + )), ] .into_iter() .flatten() diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index 3eb51d7b..a543fc35 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -61,6 +61,23 @@ const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wir /// Hold the [`ESCAPE_CHORD`] at least this long to disconnect (escalates the leave-fullscreen press). const DISCONNECT_HOLD: Duration = Duration::from_millis(1500); +/// Hold Select/Back ALONE at least this long to send the HOST the guide button — the +/// [`SelectGesture`], armed by [`Settings::guide_gesture`]. The synthetic guide stays down +/// for as long as Select is held, so a long hold IS the host's long-press (the QAM on a +/// Gaming-Mode host). Exists because on some platforms the physical guide press can never +/// reach the host cleanly: the local shell reserves it (iOS's Game Overlay, tvOS) or +/// reacts to it in parallel (Gaming Mode's Steam UI — see [`Settings::system_buttons`]). +/// +/// [`Settings::guide_gesture`]: crate::trust::Settings::guide_gesture +/// [`Settings::system_buttons`]: crate::trust::Settings::system_buttons +const GUIDE_HOLD: Duration = Duration::from_millis(350); + +/// A held-back Select TAP is delivered as a press with its release scheduled this far +/// behind — never back-to-back: per-transition sends are folded into seq'd `GamepadState` +/// snapshots by the core input task, and a down+up inside one fold window can coalesce +/// into no press at all. +const TAP_PRESS: Duration = Duration::from_millis(50); + /// Steam Deck actuator-decay keepalive cadence, declared to the core's rumble policy engine as an /// [`ActuatorQuirks`] at slot open. The Deck's built-in actuator decays inside SDL's ~2 s internal /// rumble resend (`SDL_RUMBLE_RESEND_MS`) and SDL short-circuits an identical `set_rumble` value @@ -337,6 +354,8 @@ enum Ctl { Pin(Option), KindOverride(GamepadPref), Forwarding(bool), + SystemButtons { forward_raw: bool, gesture: bool }, + TapButton(u32), MenuMode(bool), MenuRumble(MenuPulse), } @@ -503,6 +522,39 @@ impl GamepadService { let _ = self.ctl.send(Ctl::Forwarding(on)); } + /// The session's system-button policy, resolved from + /// [`Settings::system_buttons_forward`] × [`Settings::guide_gesture_enabled`]: + /// `forward_raw` gates the physical guide/QAM presses onto the wire (off = they stay + /// with the local shell — the Gaming-Mode default, where Steam reacts to them no + /// matter what and forwarding opens BOTH overlays); `gesture` arms the hold-Select + /// guide gesture ([`GUIDE_HOLD`]), the alternate route that keeps the host's guide — + /// and, held longer, a Gaming-Mode host's QAM — reachable from a controller. + /// + /// [`Settings::system_buttons_forward`]: crate::trust::Settings::system_buttons_forward + /// [`Settings::guide_gesture_enabled`]: crate::trust::Settings::guide_gesture_enabled + pub fn set_system_buttons(&self, forward_raw: bool, gesture: bool) { + let _ = self.ctl.send(Ctl::SystemButtons { + forward_raw, + gesture, + }); + } + + /// One-shot synthetic tap of the HOST's guide button ([`Ctl::TapButton`]): down now, + /// up [`TAP_PRESS`] later, on the first forwarded slot's wire index (pad 0 when none + /// is open). The session control socket's "press the host's Steam/guide button" verb + /// — the Decky panel's UI route to the host overlay. No-op while no session is + /// attached. + pub fn tap_guide(&self) { + let _ = self.ctl.send(Ctl::TapButton(wire::BTN_GUIDE)); + } + + /// Like [`Self::tap_guide`] for the quick-access button (`MISC1` — the Deck `…`). + /// Opens the QAM on a Gaming-Mode host whose virtual pad is Deck-shaped; other + /// virtual pads map it to their own misc button (or drop it) — harmless. + pub fn tap_qam(&self) { + let _ = self.ctl.send(Ctl::TapButton(wire::BTN_MISC1)); + } + pub fn attach(&self, connector: Arc) { let _ = self.ctl.send(Ctl::Attach(connector)); } @@ -552,6 +604,7 @@ impl GamepadPump { /// chord-hold and haptics inside the threaded worker's tolerances). pub fn tick(&mut self) { let _ = self.worker.drain_ctl(&self.ctl_rx); + self.worker.gesture_poll(); self.worker.maybe_fire_disconnect(); self.worker.menu_poll(); self.worker.render_feedback(); @@ -698,6 +751,9 @@ struct Slot { /// close lift a click held across detach/unplug. held_clicks: [bool; 2], last_accel: [i16; 3], + /// Hold-Select→guide state ([`SelectGesture`]) — only fed while the worker's + /// `guide_gesture` policy is on. + gesture: SelectGesture, } impl Slot { @@ -713,6 +769,7 @@ impl Slot { surface_last: [(0, 0, false); 2], held_clicks: [false; 2], last_accel: [0; 3], + gesture: SelectGesture::default(), } } @@ -723,6 +780,98 @@ impl Slot { } } +/// Per-slot hold-Select→guide state machine (see [`GUIDE_HOLD`]). Pure — fed transitions +/// and polled with a clock, it emits the wire sends due as `(button bit, down)` pairs — +/// so the timing rules are testable without SDL or a live session. +/// +/// The rules: +/// - Select pressed ALONE is held back (pending). Any other button already down means +/// Select is part of a combo — the escape chord ends in it — and passes through. +/// - A button pressed WHILE Select is pending makes it a real Select after all; its +/// deferred down goes out first, preserving chronology. +/// - Pending past [`GUIDE_HOLD`] becomes a synthetic guide, down until Select releases. +/// - Released before the threshold, it's a TAP: press delivered on release, the release +/// itself [`TAP_PRESS`] behind it (back-to-back transitions can fold into nothing). +#[derive(Default)] +struct SelectGesture { + /// Select is down and held back — tap-or-guide undecided. + pending_since: Option, + /// The held-back Select became a synthetic guide; its release lifts the guide. + as_guide: bool, + /// A delivered tap's release is owed at this time. + release_due: Option, +} + +impl SelectGesture { + /// Select went down (`alone` = no other button held on this slot). Returns true when + /// the press is held back; false lets the caller forward it as a normal button. + fn on_select_down(&mut self, now: Instant, alone: bool, out: &mut Vec<(u32, bool)>) -> bool { + // A previous tap's scheduled release still owed: lift it before the new press. + if self.release_due.take().is_some() { + out.push((wire::BTN_BACK, false)); + } + if alone { + self.pending_since = Some(now); + return true; + } + false + } + + /// Another button went down on this slot: a pending Select is a real Select after + /// all — its deferred down goes out before the caller sends the new button's. + fn on_other_down(&mut self, out: &mut Vec<(u32, bool)>) { + if self.pending_since.take().is_some() { + out.push((wire::BTN_BACK, true)); + } + } + + /// Select released. Returns true when the gesture owned this release (the caller + /// skips the normal button-up send). + fn on_select_up(&mut self, now: Instant, out: &mut Vec<(u32, bool)>) -> bool { + if self.as_guide { + self.as_guide = false; + out.push((wire::BTN_GUIDE, false)); + return true; + } + if self.pending_since.take().is_some() { + // A tap: deliver the held-back press now, its release TAP_PRESS behind. + out.push((wire::BTN_BACK, true)); + self.release_due = Some(now + TAP_PRESS); + return true; + } + false + } + + /// Clock-driven work: the hold threshold and the owed tap release. + fn poll(&mut self, now: Instant, out: &mut Vec<(u32, bool)>) { + if let Some(since) = self.pending_since { + if now.duration_since(since) >= GUIDE_HOLD { + self.pending_since = None; + self.as_guide = true; + out.push((wire::BTN_GUIDE, true)); + } + } + if let Some(due) = self.release_due { + if now >= due { + self.release_due = None; + out.push((wire::BTN_BACK, false)); + } + } + } + + /// Slot close / gesture disarm: nothing may stay down (or owed) on the wire. + fn flush(&mut self, out: &mut Vec<(u32, bool)>) { + self.pending_since = None; + if self.as_guide { + self.as_guide = false; + out.push((wire::BTN_GUIDE, false)); + } + if self.release_due.take().is_some() { + out.push((wire::BTN_BACK, false)); + } + } +} + struct Worker { subsystem: sdl3::GamepadSubsystem, /// UI-facing state (the `GamepadService` accessors): pad list, active pad, pin. @@ -750,6 +899,14 @@ struct Worker { /// `Auto` = per-pad detection. Applied at slot open to the kind DECLARED to the host, never /// to [`Slot::pref`] — the local feedback paths must keep reading the physical pad. kind_override: GamepadPref, + /// Forward raw guide/QAM presses ([`GamepadService::set_system_buttons`]); off keeps + /// them with the local shell. + system_forward: bool, + /// The hold-Select guide gesture is armed ([`GamepadService::set_system_buttons`]). + guide_gesture: bool, + /// Releases owed for synthetic taps ([`Ctl::TapButton`]): `(pad, bit, due)` — the + /// down went out on receipt, the up goes out from the poll once `due` passes. + synthetic_ups: Vec<(u8, u32, Instant)>, attached: Option>, /// Raises the UI escape signal; the escape chord fires it once per press. escape_tx: async_channel::Sender<()>, @@ -1051,6 +1208,14 @@ impl Worker { /// Emits wire events only (no SDL device calls), so it is safe against an already-removed pad. fn flush_slot(c: &NativeClient, slot: &mut Slot) { let pad = slot.index; + // Gesture first: a synthetic guide is NOT in `held_buttons`, so the drain below + // would never lift it — and a still-pending Select was never sent, so dropping + // it beats delivering a ghost press into the close. + let mut due = Vec::new(); + slot.gesture.flush(&mut due); + for (b, down) in due { + send(c, InputKind::GamepadButton, b, down as i32, pad); + } for b in slot.held_buttons.drain(..) { send(c, InputKind::GamepadButton, b, 0, pad); } @@ -1128,6 +1293,36 @@ impl Worker { } } + /// Clock-driven [`SelectGesture`] work — the hold threshold and owed tap releases — + /// polled like the chord hold, so timings carry at most one wakeup (~10 ms attached) + /// of jitter. + fn gesture_poll(&mut self) { + let Some(c) = self.attached.clone() else { + self.synthetic_ups.clear(); + return; + }; + let now = Instant::now(); + // Owed releases of synthetic taps (the control socket's guide/QAM verbs). + self.synthetic_ups.retain(|&(pad, bit, due)| { + if now >= due { + send(&c, InputKind::GamepadButton, bit, 0, pad); + false + } else { + true + } + }); + if !self.guide_gesture { + return; + } + for slot in &mut self.slots { + let mut due = Vec::new(); + slot.gesture.poll(now, &mut due); + for (b, down) in due { + send(&c, InputKind::GamepadButton, b, down as i32, slot.index); + } + } + } + /// Fire the disconnect signal once the escape chord has been continuously held past /// [`DISCONNECT_HOLD`]. Polled from the main loop so the hold completes without new events. fn maybe_fire_disconnect(&mut self) { @@ -1305,6 +1500,41 @@ impl Worker { self.refresh_active(); } Ok(Ctl::KindOverride(pref)) => self.kind_override = pref, + Ok(Ctl::SystemButtons { + forward_raw, + gesture, + }) => { + self.system_forward = forward_raw; + if self.guide_gesture == gesture { + continue; + } + self.guide_gesture = gesture; + // A mid-session flip may strand gesture state — a synthetic guide + // still down, an owed tap release — lift it now (no-op on the way on: + // an unarmed gesture was never fed). + if let Some(c) = self.attached.clone() { + for slot in &mut self.slots { + let mut due = Vec::new(); + slot.gesture.flush(&mut due); + for (b, down) in due { + send(&c, InputKind::GamepadButton, b, down as i32, slot.index); + } + } + } + } + Ok(Ctl::TapButton(bit)) => { + // Synthetic system-button tap (the session control socket): down on + // the first forwarded slot's index — pad 0 when none is open (a + // forwarding-off session; best-effort there, the wire pad may not + // exist host-side). The up is owed via `synthetic_ups`, TAP_PRESS + // later, so the pair can't fold into nothing. + if let Some(c) = self.attached.clone() { + let pad = self.slots.first().map_or(0, |s| s.index); + send(&c, InputKind::GamepadButton, bit, 1, pad); + self.synthetic_ups + .push((pad, bit, Instant::now() + TAP_PRESS)); + } + } Ok(Ctl::Forwarding(on)) => { if self.forwarding == on { continue; @@ -1405,8 +1635,32 @@ impl Worker { return; } if let Some(bit) = button_bit(button) { + // Raw system buttons stay with the local shell when passthrough is + // off (the Gaming-Mode default): Steam already opened ITS overlay + // for this press; the host's is reached via the hold-Select gesture + // (and the Decky panel) instead. + if !self.system_forward && matches!(bit, wire::BTN_GUIDE | wire::BTN_MISC1) { + return; + } + let mut due = Vec::new(); + let held_back = if !self.guide_gesture { + false + } else if bit == wire::BTN_BACK { + let alone = slot.held_buttons.is_empty(); + slot.gesture.on_select_down(Instant::now(), alone, &mut due) + } else { + slot.gesture.on_other_down(&mut due); + false + }; + for (b, down) in due { + send(&c, InputKind::GamepadButton, b, down as i32, slot.index); + } + // Held-back or not, the chord bookkeeping sees the physical press — + // the escape chord must not care that the gesture exists. slot.held_buttons.push(bit); - send(&c, InputKind::GamepadButton, bit, 1, slot.index); + if !held_back { + send(&c, InputKind::GamepadButton, bit, 1, slot.index); + } self.maybe_fire_escape(); } } @@ -1422,8 +1676,20 @@ impl Worker { return; } if let Some(bit) = button_bit(button) { + if !self.system_forward && matches!(bit, wire::BTN_GUIDE | wire::BTN_MISC1) { + return; + } slot.held_buttons.retain(|&b| b != bit); - send(&c, InputKind::GamepadButton, bit, 0, slot.index); + let mut due = Vec::new(); + let owned = self.guide_gesture + && bit == wire::BTN_BACK + && slot.gesture.on_select_up(Instant::now(), &mut due); + for (b, down) in due { + send(&c, InputKind::GamepadButton, b, down as i32, slot.index); + } + if !owned { + send(&c, InputKind::GamepadButton, bit, 0, slot.index); + } self.rearm_escape(); } } @@ -1671,6 +1937,9 @@ impl Worker { pinned: None, forwarding: true, kind_override: GamepadPref::Auto, + system_forward: true, + guide_gesture: false, + synthetic_ups: Vec::new(), attached: None, escape_tx, disconnect_tx, @@ -1742,6 +2011,7 @@ fn run( // Escalate a held escape chord to a disconnect (polled — the hold completes with no // new button events; the chord itself is only detected while a session is attached). + w.gesture_poll(); w.maybe_fire_disconnect(); w.menu_poll(); @@ -1749,6 +2019,115 @@ fn run( } } +#[cfg(test)] +mod select_gesture_tests { + use super::*; + + #[test] + fn tap_delivers_press_then_scheduled_release() { + let mut g = SelectGesture::default(); + let t = Instant::now(); + let mut out = Vec::new(); + assert!(g.on_select_down(t, true, &mut out), "not held back"); + assert!(out.is_empty(), "a held-back press sends nothing yet"); + // Released inside the threshold: the press goes out on release… + let up = t + Duration::from_millis(120); + assert!(g.on_select_up(up, &mut out)); + assert_eq!(out, vec![(wire::BTN_BACK, true)]); + out.clear(); + // …and the release only TAP_PRESS behind it, so the pair can't fold away. + g.poll(up + TAP_PRESS - Duration::from_millis(1), &mut out); + assert!(out.is_empty(), "release went out early"); + g.poll(up + TAP_PRESS, &mut out); + assert_eq!(out, vec![(wire::BTN_BACK, false)]); + } + + #[test] + fn hold_becomes_guide_down_until_release() { + let mut g = SelectGesture::default(); + let t = Instant::now(); + let mut out = Vec::new(); + assert!(g.on_select_down(t, true, &mut out)); + g.poll(t + GUIDE_HOLD - Duration::from_millis(1), &mut out); + assert!(out.is_empty(), "guide fired inside the threshold"); + g.poll(t + GUIDE_HOLD, &mut out); + assert_eq!(out, vec![(wire::BTN_GUIDE, true)]); + out.clear(); + // Held on: nothing more (the host times its own long-press = QAM). + g.poll(t + GUIDE_HOLD * 4, &mut out); + assert!(out.is_empty()); + // Release lifts the guide, never a Select. + assert!(g.on_select_up(t + GUIDE_HOLD * 5, &mut out)); + assert_eq!(out, vec![(wire::BTN_GUIDE, false)]); + } + + #[test] + fn second_button_makes_pending_select_real() { + let mut g = SelectGesture::default(); + let t = Instant::now(); + let mut out = Vec::new(); + assert!(g.on_select_down(t, true, &mut out)); + // A joins inside the window: the deferred Select down goes out first (the + // caller then sends A's own down — chronology preserved). + g.on_other_down(&mut out); + assert_eq!(out, vec![(wire::BTN_BACK, true)]); + out.clear(); + // The release is a normal button-up now — the gesture doesn't own it. + assert!(!g.on_select_up(t + Duration::from_millis(200), &mut out)); + assert!(out.is_empty()); + // And no stale guide fires later. + g.poll(t + GUIDE_HOLD * 2, &mut out); + assert!(out.is_empty()); + } + + #[test] + fn select_inside_a_combo_passes_through() { + let mut g = SelectGesture::default(); + let mut out = Vec::new(); + // L1+R1+Start already down (the escape chord ends in Select): not held back. + assert!(!g.on_select_down(Instant::now(), false, &mut out)); + assert!(out.is_empty()); + } + + #[test] + fn quick_repress_lifts_owed_release_first() { + let mut g = SelectGesture::default(); + let t = Instant::now(); + let mut out = Vec::new(); + assert!(g.on_select_down(t, true, &mut out)); + assert!(g.on_select_up(t + Duration::from_millis(80), &mut out)); + out.clear(); + // Re-pressed before the owed release fired: the up goes out before the new + // press is held back — the host never sees two downs in a row. + assert!(g.on_select_down(t + Duration::from_millis(100), true, &mut out)); + assert_eq!(out, vec![(wire::BTN_BACK, false)]); + } + + #[test] + fn flush_lifts_synthetic_guide_and_owed_release() { + let mut g = SelectGesture::default(); + let t = Instant::now(); + let mut out = Vec::new(); + // Transformed hold: flush lifts the guide. + assert!(g.on_select_down(t, true, &mut out)); + g.poll(t + GUIDE_HOLD, &mut out); + out.clear(); + g.flush(&mut out); + assert_eq!(out, vec![(wire::BTN_GUIDE, false)]); + out.clear(); + // Owed tap release: flush emits it. A pending (never-sent) Select just drops. + assert!(g.on_select_down(t, true, &mut out)); + assert!(g.on_select_up(t + Duration::from_millis(80), &mut out)); + out.clear(); + g.flush(&mut out); + assert_eq!(out, vec![(wire::BTN_BACK, false)]); + out.clear(); + assert!(g.on_select_down(t, true, &mut out)); + g.flush(&mut out); + assert!(out.is_empty(), "a never-sent pending Select ghosted a send"); + } +} + #[cfg(test)] mod menu_nav_tests { use super::*; diff --git a/crates/pf-client-core/src/profiles.rs b/crates/pf-client-core/src/profiles.rs index 0d3cf6ae..bbe77592 100644 --- a/crates/pf-client-core/src/profiles.rs +++ b/crates/pf-client-core/src/profiles.rs @@ -76,6 +76,10 @@ pub struct SettingsOverlay { #[serde(skip_serializing_if = "Option::is_none")] pub gamepad_forwarding: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub system_buttons: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub guide_gesture: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub stats_verbosity: Option, #[serde(skip_serializing_if = "Option::is_none")] pub fullscreen_on_stream: Option, @@ -159,6 +163,12 @@ impl SettingsOverlay { if let Some(v) = self.gamepad_forwarding { s.gamepad_forwarding = v; } + if let Some(v) = &self.system_buttons { + s.system_buttons = v.clone(); + } + if let Some(v) = &self.guide_gesture { + s.guide_gesture = v.clone(); + } if let Some(v) = self.stats_verbosity { // Through the setter so the legacy `show_stats` bool stays coherent for // pre-tier binaries reading the same settings file. @@ -252,6 +262,12 @@ impl SettingsOverlay { if after.gamepad_forwarding != before.gamepad_forwarding { self.gamepad_forwarding = Some(after.gamepad_forwarding); } + if after.system_buttons != before.system_buttons { + self.system_buttons = Some(after.system_buttons.clone()); + } + if after.guide_gesture != before.guide_gesture { + self.guide_gesture = Some(after.guide_gesture.clone()); + } if after.stats_verbosity() != before.stats_verbosity() { self.stats_verbosity = Some(after.stats_verbosity()); } @@ -302,6 +318,8 @@ impl SettingsOverlay { "inhibit_shortcuts" => self.inhibit_shortcuts = None, "gamepad" => self.gamepad = None, "gamepad_forwarding" => self.gamepad_forwarding = None, + "system_buttons" => self.system_buttons = None, + "guide_gesture" => self.guide_gesture = None, "stats_verbosity" => self.stats_verbosity = None, "fullscreen_on_stream" => self.fullscreen_on_stream = None, "present_priority" => self.present_priority = None, @@ -506,6 +524,8 @@ mod tests { inhibit_shortcuts: Some(false), gamepad: Some("dualsense".into()), gamepad_forwarding: Some(false), + system_buttons: Some("local".into()), + guide_gesture: Some("on".into()), match_window: Some(true), fullscreen_on_stream: Some(false), stats_verbosity: Some(StatsVerbosity::Detailed), @@ -532,6 +552,8 @@ mod tests { assert!(!out.inhibit_shortcuts); assert_eq!(out.gamepad, "dualsense"); assert!(!out.gamepad_forwarding); + assert_eq!(out.system_buttons, "local"); + assert_eq!(out.guide_gesture, "on"); assert!(out.match_window); assert!(!out.fullscreen_on_stream); assert_eq!(out.stats_verbosity(), StatsVerbosity::Detailed); diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index 9ab18cf2..d70218e7 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -879,6 +879,25 @@ pub struct Settings { /// forwarded as pad 0; empty = automatic (most recently connected). Applied to the /// gamepad service at startup so the choice survives restarts. pub forward_pad: String, + /// What a controller's SYSTEM buttons — guide (Xbox/PS/Steam) and the Deck's QAM `…` — + /// do while streaming: `"auto"` (default), `"forward"` (raw presses go to the host, + /// the pre-setting behaviour), or `"local"` (they stay with this device; the host's + /// are reached via the hold-Select gesture instead). Auto resolves per platform in + /// [`Settings::system_buttons_forward`]: forward everywhere EXCEPT under Gaming Mode, + /// where the local Steam UI always reacts to the same physical press — forwarding + /// there opens BOTH overlays, the local one on top of the stream. + #[serde(default = "default_auto")] + pub system_buttons: String, + /// The hold-Select guide gesture: holding Select/Back alone ≥ ~350 ms sends the HOST + /// the guide button (down for as long as it's held, so a long hold is the host's + /// long-press — the QAM on a Gaming-Mode host). `"auto"` (default) / `"on"` / `"off"`, + /// resolved in [`Settings::guide_gesture_enabled`]: auto = on only where the raw + /// guide press can't reach the host cleanly (Gaming Mode; iOS/tvOS resolve their own + /// auto in the Apple client). While armed, a Select TAP is delivered on release — + /// costing it up to the hold threshold in latency — and a Select held as part of a + /// combo (any other button already down) passes through untouched. + #[serde(default = "default_auto")] + pub guide_gesture: String, /// Which host compositor backend to request (advisory; the host falls back to /// auto-detect when unavailable). pub compositor: String, @@ -1032,6 +1051,10 @@ fn default_codec() -> String { "auto".into() } +fn default_auto() -> String { + "auto".into() +} + fn default_touch_mode() -> String { "trackpad".into() } @@ -1081,6 +1104,29 @@ impl Settings { PresentPriority::resolve(&self.present_priority, self.smooth_buffer) } + /// Whether raw system-button presses (guide + QAM) are forwarded to the host. + /// `game_mode` = this client runs as the embedded Gaming-Mode stream (gamescope), + /// where the local Steam UI reacts to the same physical buttons no matter what we + /// do — auto keeps them local there and forwards everywhere else. + pub fn system_buttons_forward(&self, game_mode: bool) -> bool { + match self.system_buttons.as_str() { + "forward" => true, + "local" => false, + _ => !game_mode, + } + } + + /// Whether the hold-Select guide gesture is armed ([`Settings::guide_gesture`]). + /// Auto = on only under Gaming Mode, where it is the sole controller route to the + /// host's guide once raw presses stay local. + pub fn guide_gesture_enabled(&self, game_mode: bool) -> bool { + match self.guide_gesture.as_str() { + "on" => true, + "off" => false, + _ => game_mode, + } + } + /// The `codec` setting as a `quic::CODEC_*` preference bit (`0` = auto). pub fn preferred_codec(&self) -> u8 { match self.codec.as_str() { @@ -1107,6 +1153,8 @@ impl Default for Settings { gamepad: "auto".into(), gamepad_forwarding: true, forward_pad: String::new(), + system_buttons: "auto".into(), + guide_gesture: "auto".into(), compositor: "auto".into(), touch_mode: "trackpad".into(), mouse_mode: "capture".into(), diff --git a/crates/pf-console-ui/src/screens/settings.rs b/crates/pf-console-ui/src/screens/settings.rs index b7656278..52f53702 100644 --- a/crates/pf-console-ui/src/screens/settings.rs +++ b/crates/pf-console-ui/src/screens/settings.rs @@ -41,6 +41,8 @@ enum RowId { PadForward, Pad, PadType, + SystemButtons, + GuideGesture, Touch, Mouse, InvertScroll, @@ -57,7 +59,7 @@ enum RowId { // cancellation all were). Still deliberately smaller than the desktop dialogs — device // pickers (GPU/speaker/mic) stay desktop-only, and profiles are pinnable here (the // trailing Profiles section) but created and edited only in the desktop app (design §5.4). -const ROWS: [RowId; 27] = [ +const ROWS: [RowId; 29] = [ RowId::Resolution, RowId::Refresh, RowId::RenderScale, @@ -77,6 +79,8 @@ const ROWS: [RowId; 27] = [ RowId::PadForward, RowId::Pad, RowId::PadType, + RowId::SystemButtons, + RowId::GuideGesture, RowId::Touch, RowId::Mouse, RowId::InvertScroll, @@ -152,6 +156,16 @@ const PAD_TYPES: [(&str, &str); 6] = [ ("dualshock4", "DualShock 4"), ("steamdeck", "Steam Deck"), ]; +/// Where the guide (Xbox/PS/Steam) and quick-access presses land while streaming — the +/// shared `system_buttons` key. Auto = host everywhere except Gaming Mode, where the +/// local Steam UI reacts to the same press and both overlays would open at once. +const SYSTEM_BUTTONS: [(&str, &str); 3] = [ + ("auto", "Automatic"), + ("forward", "Send to host"), + ("local", "This device"), +]; +/// The hold-Select guide gesture — the shared `guide_gesture` key. +const GUIDE_GESTURE: [(&str, &str); 3] = [("auto", "Automatic"), ("on", "On"), ("off", "Off")]; pub(crate) struct SettingsScreen { list: MenuList, @@ -350,7 +364,9 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec { // move everything under the cursor). let enabled = match id { RowId::EchoCancel => s.mic_enabled, - RowId::Pad | RowId::PadType => s.gamepad_forwarding, + RowId::Pad | RowId::PadType | RowId::SystemButtons | RowId::GuideGesture => { + s.gamepad_forwarding + } RowId::SmoothBuffer => s.present_priority == "smooth", _ => true, }; @@ -457,6 +473,16 @@ fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec { "Controller type", label_for(&PAD_TYPES, &s.gamepad).into(), ), + RowId::SystemButtons => ( + None, + "Steam / guide button", + label_for(&SYSTEM_BUTTONS, &s.system_buttons).into(), + ), + RowId::GuideGesture => ( + None, + "Hold Select for guide", + label_for(&GUIDE_GESTURE, &s.guide_gesture).into(), + ), RowId::Touch => ( Some("Touchscreen"), "Touch mode", @@ -553,6 +579,16 @@ fn detail(id: RowId) -> &'static str { } RowId::Pad => "Which pad is forwarded to the host, as player 1.", RowId::PadType => "The virtual pad the host creates — Automatic matches this controller.", + RowId::SystemButtons => { + "Where the guide (Xbox/PS/Steam) and quick-access presses go. Automatic \ + sends them to the host except in Gaming Mode, where Steam on this device \ + reacts to the same press and both overlays would open at once." + } + RowId::GuideGesture => { + "Hold Select on its own to press the host's guide button — keep holding for \ + the host's quick-access menu. Automatic arms it only where the real button \ + can't reach the host. A Select tap still goes through, slightly delayed." + } RowId::Touch => { "How the touchscreen drives the host: Trackpad (relative cursor), \ Direct pointer (cursor jumps to your finger), or Touch passthrough (raw contacts)." @@ -699,6 +735,18 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool { } step_str(&PAD_TYPES, &mut s.gamepad, delta, wrap) } + RowId::SystemButtons => { + if !s.gamepad_forwarding { + return false; + } + step_str(&SYSTEM_BUTTONS, &mut s.system_buttons, delta, wrap) + } + RowId::GuideGesture => { + if !s.gamepad_forwarding { + return false; + } + step_str(&GUIDE_GESTURE, &mut s.guide_gesture, delta, wrap) + } RowId::Touch => { let cur = TouchMode::ALL.iter().position(|m| *m == s.touch_mode()); step_option(cur, TouchMode::ALL.len(), delta, wrap) diff --git a/docs-site/content/docs/client-settings.md b/docs-site/content/docs/client-settings.md index 0d46f6a0..d2686f3e 100644 --- a/docs-site/content/docs/client-settings.md +++ b/docs-site/content/docs/client-settings.md @@ -194,6 +194,21 @@ which forwards *every* connected controller, each as its own player, on Linux, W console home. Pinning one restricts the session to that controller alone — single-player. The Android app has no such picker. +**Steam / guide button** (*Guide button* on Apple and Android) — *default: Automatic*, on every +client. Where the guide (Xbox/PS/Steam) and quick-access presses go while streaming: **Send to +host** forwards them raw, **This device** keeps them local. Automatic forwards everywhere except +Gaming Mode, where SteamOS opens its own menus for those buttons no matter what — forwarding raw +there opens *both* menus at once, the local one covering the stream. The full story, including how +to reach the host's menus when the raw press stays local, is on the +[Input page](/docs/input#the-guide-button-xbox--ps--steam-and-quick-access). + +**Hold Select for guide** — *default: Automatic*, on every client. The gesture that presses the +host's guide button from any controller: hold Select (Back/View) on its own for about a third of a +second, and keep holding for the host's long-press (a Gaming-Mode host's Quick Access Menu, on a +regular pad). Automatic arms it only where the raw guide press can't reach the host cleanly — +Gaming Mode, iPhone/iPad, Apple TV — because the gesture has a cost: a Select *tap* arrives a beat +late, and a game that expects a *held* Select would trigger it. Set **On** or **Off** to overrule. + **Capture system shortcuts** — *default: on.* Offered by the Linux and Windows apps and the console home; Windows spells the row out as *Capture system shortcuts (Alt+Tab, Win, …)*. On a Deck it matters only for a keyboard you attached yourself, for the reason the paragraph below gives: Gaming diff --git a/docs-site/content/docs/input.md b/docs-site/content/docs/input.md index a78a772a..9b058572 100644 --- a/docs-site/content/docs/input.md +++ b/docs-site/content/docs/input.md @@ -99,6 +99,39 @@ there the client stops opening the controller at all, which is the point of the **Ctrl+Alt+Shift+D** or the client's own UI to leave instead. The Apple and Android apps keep watching for the chord either way. +### The guide button (Xbox / PS / Steam) and Quick Access + +A controller's **guide button** — the Xbox logo, the PS button, the Deck's **Steam** button — is +meant to open menus **on the host**: the Steam overlay, or a Gaming-Mode host's Steam menu. Some +devices want that button for themselves, so every client also carries a gesture that works +everywhere: + +**Hold Select (Back / View) on its own for about a third of a second.** The host sees its guide +button go down, and it stays down for as long as you hold — so keeping it held reads as a long +press on the host, which is how SteamOS opens the **Quick Access Menu** for a regular pad. A quick +tap of Select still reaches the game, delivered when you let go (a beat late). Select pressed as +part of a combo — including the leave chord above — passes through untouched. + +What the raw button does, per client: + +- **Linux & Windows desktop, macOS, Android** — the guide press is forwarded to the host. If + Steam Big Picture or the Xbox Game Bar is also watching for it *on the device in your hands*, + both may react — that's a local setting on that device, not something the stream can suppress. +- **Steam Deck / Gaming Mode** — the **Steam** and **`…`** buttons stay with the Deck by default: + SteamOS always opens its own menus for them, so forwarding the raw press as well opened BOTH + menus at once, the Deck's on top of the stream. Reach the host's menus with **hold-Select**, or + with the Punktfunk panel's **Host menus** buttons ([Steam Deck page](/docs/steam-deck)). The + old behavior is one setting away: **Steam / guide button → Send to host**. +- **iPhone / iPad** — iOS reserves the Home press for its own Game Overlay, so hold-Select is the + reliable route to the host's overlay. On iOS 27 or later you can also hand the button to the + app yourself, in the system's per-controller Home-button setting. +- **Apple TV** — tvOS never delivers the Home press to apps; hold-Select is the only route. + +Both halves are [settings](/docs/client-settings#input), per profile like everything else: +**Steam / guide button** (Automatic / Send to host / This device) and **Hold Select for guide** +(Automatic / On / Off). Automatic picks the behavior above for each platform — the gesture stays +off where the raw button already works, so games that use a *held* Select keep it. + ## Mouse modes There are two, and they are a per-client setting called **Mouse input**: diff --git a/docs-site/content/docs/steam-deck.md b/docs-site/content/docs/steam-deck.md index bf46700a..9663deaf 100644 --- a/docs-site/content/docs/steam-deck.md +++ b/docs-site/content/docs/steam-deck.md @@ -143,6 +143,15 @@ for about a second and a half, or close the "game" from the Steam overlay. Eithe and drops you straight back to Gaming Mode. A quick press of the same four only releases captured input, so it is safe to hit by accident. +**The Steam and `…` buttons stay with the Deck while streaming.** SteamOS opens its own menus for +them no matter what, so forwarding the raw press as well opened *both* menus at once — the Deck's +covering the stream. To reach the **host's** menus instead: **hold Select** for the host's Steam +menu ([how it works](/docs/input#the-guide-button-xbox--ps--steam-and-quick-access)), or open the +Punktfunk panel — while a stream runs it grows a **Host menus** section whose two buttons, +**Steam menu on host** and **Quick access on host**, press the button on the host and close the +Deck's own menu so the host's shows through. Want the raw forwarding back? **Open Punktfunk → +Settings → Steam / guide button** → *Send to host*. + ## Updating The plugin **checks for updates itself** — no Decky store needed. It covers **both** the plugin *and* From 9fb41affba5fc188e25de804832ad3d45396a32f Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 22:25:51 +0200 Subject: [PATCH 51/53] fix(clients/settings): a controller setting you can't use no longer looks like one you can MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn "Forward controllers" off and four rows below it stop meaning anything — nothing is forwarded, so there is no pad type to pick and no guide button to route. GTK desensitised them, the touch settings on both mobile clients dimmed them and the console UI refused the step; the Windows client and BOTH controller-navigable screens left them fully live, so you could sit there changing settings that did nothing. Windows: `.enabled(s.gamepad_forwarding)` on the forwarded-controller picker, pad type, guide button and hold-Select rows — the same builder the echo-cancellation row already used to follow the mic switch. Apple's gamepad settings had no way to say it: `Row` carried `adjustable` (which only hides the chevrons) and nothing else. Added `Row.enabled`, dimmed the row CONTENTS only so the glass still reads as a focusable row, and enforced the inertness centrally in `adjust(id:)` / `activate(id:)` rather than in each builder's closure. The hint bar drops "Adjust"/"Change" on a dimmed row, because advertising them was the same lie the live row told. Android's gamepad settings already had `GpRow.enabled` — documented as "dimmed + inert" — but it only faded the label: every dimmed row still stepped and still wrote its setting. The "No profiles yet" placeholder looked inert only because its own closures were empty. Made it real in one named place (`liveRow`), covering all three input paths (left/right, A, and a tap on the already-focused row), then gated the pad rows on it. Also on that screen: the DualSense / DualShock passthrough toggle, which the touch settings have carried beside its SC2 twin all along. It was missing exactly where it matters most — a TV box has no touch interface to fall back to, so there was no way to reach it at all. Apple capture, separately: with forwarding off, opening a slot still claimed EVERY element's system gesture and powered the controller's IMU. Neither reaches the host, so the first only took the user's screenshot/Home gestures away for nothing and the second drained the pad's battery streaming gyro over Bluetooth. Narrowed rather than skipped — the escape chord is read off the same slot and on tvOS is the ONLY controller way out of a stream, so the chord's own four buttons keep their claim. A test pins the alias list against the chord mask; if they drift the symptom is a session nobody can leave, with nothing logged. Closes R17, R18, R19 (design/haptics-sweep-2026-08-03.md M11). R17 as filed named Windows and "Apple"; Apple's TOUCH settings were already correct and Android's controller-navigable screen was not — both corrected here. Verified: Windows clippy -D warnings exit 0 on a real Windows box; Apple swift build clean + full suite 192 tests / 0 failures (3 new); Android :app: + :kit: green (5 new); cargo fmt --all --check clean. Each fix probed by reverting it — every probe failed the tests it should. --- .../unom/punktfunk/GamepadSettingsScreen.kt | 54 ++++++++--- .../unom/punktfunk/GamepadSettingsRowsTest.kt | 97 +++++++++++++++++++ .../Settings/GamepadSettingsView.swift | 50 ++++++++-- .../PunktfunkKit/Gamepad/GamepadCapture.swift | 29 +++++- .../GamepadEscapeChordTests.swift | 52 ++++++++++ clients/windows/src/app/settings.rs | 16 ++- 6 files changed, 270 insertions(+), 28 deletions(-) create mode 100644 clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadSettingsRowsTest.kt create mode 100644 clients/apple/Tests/PunktfunkKitTests/GamepadEscapeChordTests.swift diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt index c33e4f88..b085163c 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt @@ -65,7 +65,7 @@ import io.unom.punktfunk.kit.security.KnownHostStore // a controller: up/down moves the focus bar, left/right steps the focused value, A cycles/toggles it, // B closes. Both write the same SharedPreferences, so values round-trip with the touch settings. -private class GpRow( +internal class GpRow( val id: String, val header: String?, val label: String, @@ -78,6 +78,15 @@ private class GpRow( val enabled: Boolean = true, // dimmed + inert when false (still focusable, for its detail) ) +/** + * The row at [index], or null when it is dimmed. The single place the "disabled ⇒ inert" half of + * [GpRow.enabled] is enforced, so the three input paths (pad left/right, A, and a tap on the + * already-focused row) cannot drift apart — before this, `enabled` dimmed the label and nothing + * else, and every dimmed row still stepped its setting. + */ +internal fun liveRow(rows: List, index: Int): GpRow? = + rows.getOrNull(index)?.takeIf { it.enabled } + @Composable fun GamepadSettingsScreen( initial: Settings, @@ -144,11 +153,13 @@ fun GamepadSettingsScreen( when (dir) { NavDir.UP -> if (focus > 0) focus-- NavDir.DOWN -> if (focus < rows.lastIndex) focus++ - NavDir.LEFT -> { adjustDir = -1; rows.getOrNull(focus)?.adjust(-1) } - NavDir.RIGHT -> { adjustDir = 1; rows.getOrNull(focus)?.adjust(1) } + // A disabled row is INERT, not just dim — the step is refused instead of writing a + // setting that has nothing to act on (see `liveRow`). + NavDir.LEFT -> { adjustDir = -1; liveRow(rows, focus)?.adjust(-1) } + NavDir.RIGHT -> { adjustDir = 1; liveRow(rows, focus)?.adjust(1) } } }, - onActivate = { adjustDir = 1; rows.getOrNull(focus)?.activate() }, + onActivate = { adjustDir = 1; liveRow(rows, focus)?.activate() }, ) // Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the // screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it. @@ -186,7 +197,10 @@ fun GamepadSettingsScreen( } itemsIndexed(rows, key = { _, r -> r.id }) { index, row -> SettingRowView(row, focused = index == focus, adjustDir = adjustDir, onClick = { - if (focus == index) { adjustDir = 1; row.activate() } else focus = index + // Same inertness as the pad path above — tapping a dimmed row focuses it (so + // its detail explains itself) but never flips it. + if (focus != index) focus = index + else if (row.enabled) { adjustDir = 1; row.activate() } }) } } @@ -340,7 +354,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick /** Build the console settings rows from the current [Settings], writing through [update]. * [hasBodyVibrator] gates the "Rumble on this phone" row (absent on TVs); [av1Capable] gates the * AV1 codec entry (see `codecOptionsFor`). */ -private fun buildSettingsRows( +internal fun buildSettingsRows( s: Settings, hasBodyVibrator: Boolean, av1Capable: Boolean, @@ -348,13 +362,14 @@ private fun buildSettingsRows( ): List { fun choice( id: String, header: String?, label: String, detail: String, - options: List>, current: T, write: (T) -> Unit, + options: List>, current: T, enabled: Boolean = true, write: (T) -> Unit, ): GpRow { val idx = options.indexOfFirst { it.first == current } return GpRow( id, header, label, value = options.getOrNull(idx)?.second ?: "—", detail = detail, + enabled = enabled, adjust = { delta -> if (idx < 0) { options.firstOrNull()?.let { write(it.first) } != null @@ -371,11 +386,12 @@ private fun buildSettingsRows( } fun toggle( id: String, header: String?, label: String, detail: String, - value: Boolean, write: (Boolean) -> Unit, + value: Boolean, enabled: Boolean = true, write: (Boolean) -> Unit, ): GpRow = GpRow( id, header, label, value = if (value) "On" else "Off", detail = detail, + enabled = enabled, adjust = { delta -> val target = delta > 0; if (value != target) { write(target); true } else false }, activate = { write(!value) }, toggled = value, @@ -478,22 +494,26 @@ private fun buildSettingsRows( "so games don't see two of them.", s.gamepadForwarding, ) { update(s.copy(gamepadForwarding = it)) }, + // Everything below the master switch follows it — dim and inert while nothing is being + // forwarded, the same relationship the touch settings draw with `enabled =`. This screen + // had the capability (`GpRow.enabled`) and used it only for the profiles placeholder, so + // the pad rows kept stepping settings that had nothing to act on. choice( "padType", null, "Controller type", "The virtual pad the host creates — Automatic matches this controller.", - GAMEPAD_OPTIONS, s.gamepad, + GAMEPAD_OPTIONS, s.gamepad, enabled = s.gamepadForwarding, ) { update(s.copy(gamepad = it)) }, choice( "systemButtons", null, "Guide button", "Where the guide (Xbox/PS) and share presses go while streaming — Automatic " + "sends them to the host whenever this device delivers them.", - SYSTEM_BUTTON_OPTIONS, s.systemButtons, + SYSTEM_BUTTON_OPTIONS, s.systemButtons, enabled = s.gamepadForwarding, ) { update(s.copy(systemButtons = it)) }, choice( "guideGesture", null, "Hold Select for guide", "Hold Select alone to press the host's guide button — keep holding for a " + "Gaming-Mode host's quick-access menu. A Select tap still goes through.", - GUIDE_GESTURE_OPTIONS, s.guideGesture, + GUIDE_GESTURE_OPTIONS, s.guideGesture, enabled = s.gamepadForwarding, ) { update(s.copy(guideGesture = it)) }, ) + listOfNotNull( if (hasBodyVibrator) { @@ -513,8 +533,18 @@ private fun buildSettingsRows( "sc2", null, "Steam Controller 2 passthrough", "Capture a Steam Controller 2 (wired, Puck dongle, or paired Bluetooth) and stream " + "it as-is — Steam on the host drives it like the physical pad.", - s.sc2Capture, + s.sc2Capture, enabled = s.gamepadForwarding, ) { update(s.copy(sc2Capture = it)) }, + // The SC2 row's twin, and missing here until now: the touch settings have carried both + // side by side, so a couch user on a TV box — where there IS no touch interface to fall + // back to — could turn on SC2 passthrough but not the Sony one. Same no-vibrator-gate + // reasoning: this capture renders feedback on the CONTROLLER's motors, not this device's. + toggle( + "dsCapture", null, "DualSense / DualShock passthrough (USB)", + "Drive a USB-connected Sony pad directly — rumble on any phone, plus adaptive " + + "triggers, lightbar and gyro.", + s.dsCapture, enabled = s.gamepadForwarding, + ) { update(s.copy(dsCapture = it)) }, ) } diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadSettingsRowsTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadSettingsRowsTest.kt new file mode 100644 index 00000000..b9012c0f --- /dev/null +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadSettingsRowsTest.kt @@ -0,0 +1,97 @@ +package io.unom.punktfunk + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The controller-navigable settings rows: what the master forwarding switch governs, and that a + * governed row is inert rather than merely dim. + * + * The touch settings and the desktop console have carried this relationship for a while (`enabled = + * s.gamepadForwarding` / `RowSpec.enabled`); this screen dimmed nothing and stepped everything, so + * these tests pin both halves — the flag AND the refusal to write. + */ +class GamepadSettingsRowsTest { + + /** Rows for a given forwarding state, capturing whatever a row writes back. */ + private fun rows( + forwarding: Boolean, + sink: MutableList = mutableListOf(), + ): List = buildSettingsRows( + Settings(gamepadForwarding = forwarding), + hasBodyVibrator = true, + av1Capable = true, + ) { sink += it } + + private fun row(rows: List, id: String): GpRow = + rows.first { it.id == id } + + /** Every row that only means something while a controller is actually being forwarded. */ + private val governed = listOf("padType", "systemButtons", "guideGesture", "sc2", "dsCapture") + + @Test + fun `forwarding off dims every row that depends on it`() { + val off = rows(forwarding = false) + for (id in governed) { + assertFalse("$id should be dimmed with forwarding off", row(off, id).enabled) + } + // The master switch itself stays live — otherwise it could never be turned back on. + assertTrue(row(off, "padForward").enabled) + } + + @Test + fun `forwarding on leaves them all live`() { + val on = rows(forwarding = true) + for (id in governed) { + assertTrue("$id should be live with forwarding on", row(on, id).enabled) + } + } + + @Test + fun `a dimmed row is inert - liveRow withholds it and nothing is written`() { + val writes = mutableListOf() + val off = rows(forwarding = false, sink = writes) + for (id in governed) { + val i = off.indexOfFirst { it.id == id } + assertNull("$id must not be reachable while dimmed", liveRow(off, i)) + // What the screen actually does on left/right/A — the whole point is that it no-ops. + liveRow(off, i)?.adjust(1) + liveRow(off, i)?.adjust(-1) + liveRow(off, i)?.activate() + } + assertEquals("a dimmed row wrote a setting", emptyList(), writes) + } + + @Test + fun `the same rows do write once forwarding is on`() { + val writes = mutableListOf() + val on = rows(forwarding = true, sink = writes) + val i = on.indexOfFirst { it.id == "sc2" } + assertNotNull(liveRow(on, i)) + liveRow(on, i)?.activate() + assertEquals(1, writes.size) + assertFalse("activate flips the toggle", writes[0].sc2Capture) + } + + /** + * R18: the Sony passthrough toggle the touch settings have always had. It matters most exactly + * where this screen is the only one reachable — a TV box has no touch interface to fall back to. + */ + @Test + fun `the DualSense passthrough toggle is present, next to its SC2 twin`() { + val on = rows(forwarding = true) + val ids = on.map { it.id } + assertTrue("dsCapture row is missing", "dsCapture" in ids) + assertEquals( + "the two passthrough rows belong side by side", + ids.indexOf("sc2") + 1, + ids.indexOf("dsCapture"), + ) + // Drawn as a switch, and reading the persisted default. + assertEquals(true, row(on, "dsCapture").toggled) + } +} diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index 514e7bc8..09274f1f 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -166,6 +166,11 @@ struct GamepadSettingsView: View { /// layer" rule), and a hostless picker has nothing to pin, so only Back remains. private var hints: [GamepadHint] { guard pinTarget != nil else { + // A dimmed row takes neither, so offering them would be the same lie the row itself + // used to tell — only Done remains, and the detail line says what to turn on first. + guard rows.first(where: { $0.id == focusID })?.enabled ?? true else { + return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done")] + } return [ .init(glyph: "arrow.left.and.right", text: "Adjust"), .init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"), @@ -218,7 +223,8 @@ struct GamepadSettingsView: View { HStack(spacing: 9) { Image(systemName: "chevron.left") .font(.system(size: m.chevronFont, weight: .semibold)) - .foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0)) + .foregroundStyle( + .white.opacity(focused && row.adjustable && row.enabled ? 0.6 : 0)) // Keyed by the value so a change slides the new option in instead of // hard-swapping the string — a QUIET horizontal slip following the user's // motion (a right-step enters from the right), crossfading over ~14 pt. @@ -239,9 +245,13 @@ struct GamepadSettingsView: View { .animation(.smooth(duration: 0.22), value: row.value) Image(systemName: "chevron.right") .font(.system(size: m.chevronFont, weight: .semibold)) - .foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0)) + .foregroundStyle( + .white.opacity(focused && row.adjustable && row.enabled ? 0.6 : 0)) } } + // Contents only — the glass and border below stay at full strength, so a dimmed row + // still reads as a row you can sit on (which you can: its detail is the point). + .opacity(row.enabled ? 1 : 0.45) .padding(.horizontal, m.rowHPad) .padding(.vertical, m.rowVPad) // Every row is Liquid Glass; the focused one takes a brand wash and reacts to press. @@ -276,6 +286,13 @@ struct GamepadSettingsView: View { /// Whether left/right means anything here — false hides the value's chevrons (the /// Profiles rows navigate, and the placeholder rows do nothing at all). var adjustable = true + /// Dimmed and inert when false: a row whose meaning depends on another setting that is + /// currently off. It stays in the list and stays FOCUSABLE — its `detail` is how the + /// user learns which switch to flip first, and a row that vanished mid-list would + /// shift everything under the cursor. Enforced centrally in `adjust(id:by:)` / + /// `activate(id:)`, not per closure, so no row builder can forget it. + /// (Android's `GpRow.enabled` and `pf-console-ui`'s `RowSpec.enabled` are the twins.) + var enabled = true /// Left/right step; returns whether the value actually changed (false ⇒ boundary thud). let adjust: (Int) -> Bool /// A — cycle forward (wrapping) / flip. @@ -286,12 +303,14 @@ struct GamepadSettingsView: View { /// (never on state captured at wire time). private func adjust(id: String, by delta: Int) -> Bool { lastAdjustDelta = delta - return rows.first { $0.id == id }?.adjust(delta) ?? false + guard let row = rows.first(where: { $0.id == id }), row.enabled else { return false } + return row.adjust(delta) } private func activate(id: String) { lastAdjustDelta = 1 // A always cycles forward - rows.first { $0.id == id }?.activate() + guard let row = rows.first(where: { $0.id == id }), row.enabled else { return } + row.activate() } private var rows: [Row] { @@ -391,27 +410,35 @@ struct GamepadSettingsView: View { + "controller already reaches the host another way — USB passthrough such " + "as VirtualHere — so games don't see two of them.", value: $gamepadForwarding), + // The four rows below only mean something while something is being forwarded, so + // they follow the switch above — the same relationship the touch settings draw with + // `.disabled(!effective.gamepadForwarding)`. This screen could not express it until + // `Row.enabled` existed, so it alone left them live and steppable. choiceRow( id: "pad", icon: "gamecontroller", label: "Use controller", detail: "Which pad is forwarded to the host, as player 1.", - options: controllers, current: gamepads.preferredID + options: controllers, current: gamepads.preferredID, + enabled: gamepadForwarding ) { gamepads.preferredID = $0 }, choiceRow( id: "padType", icon: "dpad", label: "Controller type", detail: "The virtual pad the host creates — Automatic matches this controller.", - options: SettingsOptions.padTypes, current: gamepadType + options: SettingsOptions.padTypes, current: gamepadType, + enabled: gamepadForwarding ) { gamepadType = $0 }, choiceRow( id: "systemButtons", icon: "house.circle", label: "Guide button", detail: "Where the guide (Xbox/PS) and share presses go while streaming — " + "Automatic sends them to the host whenever this device delivers them.", - options: SettingsOptions.systemButtons, current: systemButtons + options: SettingsOptions.systemButtons, current: systemButtons, + enabled: gamepadForwarding ) { systemButtons = $0 }, choiceRow( id: "guideGesture", icon: "hand.point.up.left", label: "Hold Select for guide", detail: "Hold Select alone to press the host's guide button — keep holding " + "for a Gaming-Mode host's quick-access menu. A tap still goes through.", - options: SettingsOptions.guideGestures, current: guideGesture + options: SettingsOptions.guideGestures, current: guideGesture, + enabled: gamepadForwarding ) { guideGesture = $0 }, choiceRow( @@ -583,13 +610,15 @@ struct GamepadSettingsView: View { private func choiceRow( id: String, header: String? = nil, icon: String, label: String, detail: String, - options: [(label: String, tag: T)], current: T, write: @escaping (T) -> Void + options: [(label: String, tag: T)], current: T, enabled: Bool = true, + write: @escaping (T) -> Void ) -> Row { let index = options.firstIndex { $0.tag == current } return Row( id: id, header: header, icon: icon, label: label, value: index.map { options[$0].label } ?? "—", detail: detail, + enabled: enabled, adjust: { delta in // Unknown current value: snap to the first option on any step. guard let index else { @@ -610,12 +639,13 @@ struct GamepadSettingsView: View { private func toggleRow( id: String, header: String? = nil, icon: String, label: String, detail: String, - value: Binding + value: Binding, enabled: Bool = true ) -> Row { Row( id: id, header: header, icon: icon, label: label, value: value.wrappedValue ? "On" : "Off", detail: detail, + enabled: enabled, adjust: { delta in // Directional semantics: left = off, right = on; a no-op reads as a boundary. let target = delta > 0 diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift index b623f050..4ea7812b 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift @@ -98,8 +98,17 @@ public final class GamepadCapture { /// `onDisconnectRequest`; the chord keeps forwarding to the host meanwhile (the user is /// leaving anyway). The desktop clients' quick-press step (leave fullscreen / release /// capture) has no Apple equivalent worth wiring — macOS has ⌃⌥⇧Q/D, touch has the HUD. - private static let escapeChord: UInt32 = + /// Internal rather than private only so `GamepadEscapeChordTests` can pin it against + /// `escapeChordElements` below — the two must not drift. + static let escapeChord: UInt32 = GamepadWire.leftShoulder | GamepadWire.rightShoulder | GamepadWire.start | GamepadWire.back + /// `escapeChord`'s four elements by GameController alias — the ONLY system gestures claimed + /// while forwarding is off (see `openSlot`). Kept beside the mask it mirrors: change one and + /// change the other, or the chord silently stops reaching us on tvOS. A test asserts the two + /// agree, because the failure is invisible until someone is stuck in a stream on an Apple TV. + static let escapeChordElements = [ + GCInputLeftShoulder, GCInputRightShoulder, GCInputButtonMenu, GCInputButtonOptions, + ] /// pf-client-core's `DISCONNECT_HOLD` — the same 1.5 s on every client. private static let disconnectHold: TimeInterval = 1.5 /// pf-client-core's `GUIDE_HOLD`: hold Select alone this long → the HOST's guide goes @@ -236,7 +245,17 @@ public final class GamepadCapture { // gesture attached the press is the system's, not the game's. During capture the remote // session IS the game: the share button must reach the host (e.g. Steam screenshots), // the PS button must open the host's Steam overlay. Restored to .enabled on close. - for element in c.physicalInputProfile.elements.values { + // + // With forwarding OFF none of that applies — no press reaches the host, so taking the + // user's screenshot gesture away buys nothing. NARROWED, not skipped: the escape chord + // is still read off this slot, and on tvOS it is the only controller way out of a + // stream, so the chord's own four elements keep their claim. (Menu especially: leave + // its gesture attached on tvOS and the press is the system's — the chord would never + // complete and the session would have no controller exit at all.) + let claimed = forwarding + ? Array(c.physicalInputProfile.elements.values) + : Self.escapeChordElements.compactMap { c.physicalInputProfile.elements[$0] } + for element in claimed { element.preferredSystemGestureState = .disabled } // The Home/PS button (→ guide; the host maps it to the DualSense PS / Xbox guide bit, @@ -276,7 +295,11 @@ public final class GamepadCapture { MainActor.assumeIsolated { if let self, let slot { self.touch(slot, finger: 1, x: x, y: y) } } } } - if let motion = c.motion { + // Motion is wire-only — `forwardMotion` has nothing to do with forwarding off, and no + // local feature reads it. Powering the IMU anyway costs the pad real battery (it streams + // gyro + accel continuously over Bluetooth, which is why `closeSlot` is careful to power + // it back down), so with nothing to forward we simply never turn it on. + if forwarding, let motion = c.motion { if motion.sensorsRequireManualActivation { motion.sensorsActive = true } motion.valueChangedHandler = { [weak self, weak slot] m in MainActor.assumeIsolated { if let self, let slot { self.forwardMotion(slot, m) } } diff --git a/clients/apple/Tests/PunktfunkKitTests/GamepadEscapeChordTests.swift b/clients/apple/Tests/PunktfunkKitTests/GamepadEscapeChordTests.swift new file mode 100644 index 00000000..0531f7e7 --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/GamepadEscapeChordTests.swift @@ -0,0 +1,52 @@ +import GameController +import XCTest + +@testable import PunktfunkKit + +/// The escape chord's mask and its GameController alias list have to describe the same four +/// buttons. `GamepadCapture.openSlot` claims the system gesture of every element while forwarding +/// is on, but only of `escapeChordElements` while it is off — so if the alias list ever stops +/// covering the mask, the missing button's press stays the system's and the chord never completes. +/// +/// That matters most on tvOS, where this chord is the only controller way out of a stream: the +/// symptom is a session nobody can leave with the pad in their hands, and nothing logs or crashes. +/// Hence a test on the invariant rather than trusting the comment beside it. +@MainActor +final class GamepadEscapeChordTests: XCTestCase { + + /// The intended alias↔bit pairing, spelled out independently of the implementation. + private let pairing: [(alias: String, bit: UInt32)] = [ + (GCInputLeftShoulder, GamepadWire.leftShoulder), + (GCInputRightShoulder, GamepadWire.rightShoulder), + (GCInputButtonMenu, GamepadWire.start), + (GCInputButtonOptions, GamepadWire.back), + ] + + func testChordMaskIsExactlyTheFourPairedButtons() { + XCTAssertEqual( + pairing.reduce(UInt32(0)) { $0 | $1.bit }, + GamepadCapture.escapeChord, + "the chord mask and the alias pairing describe different buttons") + } + + func testEveryChordBitHasAnElementToClaim() { + // One alias per bit — a mask that grew a fifth button without a matching alias would + // leave that button's gesture with the OS while forwarding is off. + XCTAssertEqual( + GamepadCapture.escapeChordElements.count, + GamepadCapture.escapeChord.nonzeroBitCount, + "alias list and chord mask differ in size") + XCTAssertEqual(GamepadCapture.escapeChordElements, pairing.map(\.alias)) + } + + /// The claim list is a strict subset of what a forwarding slot takes — it is a NARROWING of + /// the full sweep, never an extra grab, and it must not be empty (that would be "skip", which + /// is the behaviour this deliberately avoids). + func testClaimListIsNonEmptyAndAllDistinct() { + XCTAssertFalse(GamepadCapture.escapeChordElements.isEmpty) + XCTAssertEqual( + Set(GamepadCapture.escapeChordElements).count, + GamepadCapture.escapeChordElements.count, + "a repeated alias would mean a chord bit has no element") + } +} diff --git a/clients/windows/src/app/settings.rs b/clients/windows/src/app/settings.rs index 4bbdd924..ecfa1792 100644 --- a/clients/windows/src/app/settings.rs +++ b/clients/windows/src/app/settings.rs @@ -981,6 +981,13 @@ pub(crate) fn settings_page( s.forward_pad = key.unwrap_or_default(); s.save(); }) + // Dimmed with the master switch above it, like echo cancellation under the mic + // (see that row) — this and the three below have nothing to act on while no + // controller is forwarded at all. Every commit bumps `rev` and re-renders this + // screen, so they follow the toggle live. Brings this client in line with how GTK + // (`set_sensitive`), the touch settings on both mobile clients (`enabled`) and the + // console UI (dim + refuse the step) have always drawn the same relationship. + .enabled(s.gamepad_forwarding) }; let pad_forward_toggle = setting_toggle(ctx, scope, (rev, set_rev), s.gamepad_forwarding, |s, on| { @@ -991,7 +998,8 @@ pub(crate) fn settings_page( }); let pad_combo = setting_combo(ctx, scope, (rev, set_rev), pad_names, pad_i, |s, i| { s.gamepad = GAMEPADS[i].0.to_string(); - }); + }) + .enabled(s.gamepad_forwarding); let (sysbtn_names, sysbtn_i) = presets(SYSTEM_BUTTONS, |v| *v == s.system_buttons); let sysbtn_combo = setting_combo( ctx, @@ -1002,7 +1010,8 @@ pub(crate) fn settings_page( |s, i| { s.system_buttons = SYSTEM_BUTTONS[i].0.to_string(); }, - ); + ) + .enabled(s.gamepad_forwarding); let (gesture_names, gesture_i) = presets(GUIDE_GESTURES, |v| *v == s.guide_gesture); let gesture_combo = setting_combo( ctx, @@ -1013,7 +1022,8 @@ pub(crate) fn settings_page( |s, i| { s.guide_gesture = GUIDE_GESTURES[i].0.to_string(); }, - ); + ) + .enabled(s.gamepad_forwarding); let (touch_names, touch_i) = presets(TOUCH_MODES, |v| *v == s.touch_mode); let touch_combo = setting_combo(ctx, scope, (rev, set_rev), touch_names, touch_i, |s, i| { s.touch_mode = TOUCH_MODES[i].0.to_string(); From b31495bea53c80aa4d1a01482937729c32553928 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 22:11:10 +0200 Subject: [PATCH 52/53] fix(host): a leftover Sunshine folder is not a conflict, and a crashed host gives the screen back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things a field report (Discord, upgrade from 0.1x) turned up, all on the Windows host. 1. "It thinks I have Sunshine/Apollo running." It didn't — they were uninstalled. Both uninstallers leave their config/log directory in Program Files behind, and `detect.rs` counted a bare directory, or a service registered at ANY start type (including `disabled`), as a live conflict. The installer's own probe was narrowed to "service start type <= 2" after exactly this cried wolf on a `winget install`, and the tray dropped its always-on warning for the same reason in 3e782852 — the runtime probe never got the same treatment, so the one surface the user actually looks at kept shouting. `Evidence::is_active` now draws the line (running, or set to start on its own) and only active detections reach the startup warning, the `detect-conflicts` exit code, and `/local/summary`. Dormant findings still print in the full report, under a heading that says they need no action — that report is where "why does it think I have Apollo?" gets answered. 2. The console's conflicts card hardcoded "Another game-streaming server is **running** on this machine" regardless of what was found, so a dormant leftover was announced as a running server. It now says "active", and each entry names the observation — `Sunshine (running)`, `Apollo (starts automatically)`. 3. "The exclusive screen never times out going back to re-enabling the display." `isolate_displays_ccd` deactivates the operator's panels and hands the pre-isolate topology to the caller, which restores it at teardown — but that snapshot is PROCESS MEMORY, and Windows deliberately never saves the isolated topology to the CCD database. So a host that crashed, was killed, or was stopped mid-session left the desk dark with nothing in the product to undo it. There was one startup recovery leg already, but only for the EXPERIMENTAL `pnp_disable_monitors` axis, which is off by default — the default Exclusive path had none. `isolate_journal` now marks what an isolate is about to switch off (before the apply, so dying mid-apply is covered), clears the mark on restore, and force-EXTENDs at host startup if a mark survived. EXTEND rather than replaying the saved blob: the blob pins the virtual display's target id, which dies with the crashed host, so a replay would mostly fail BAD_CONFIGURATION into the very same backstop `restore_displays_ccd` already keeps — and EXTEND stays correct across a reboot, where saved ids would be stale. --- .../src/vdisplay/windows/manager.rs | 5 + crates/pf-win-display/src/win_display.rs | 201 ++++++++++++++++ crates/punktfunk-host/src/detect.rs | 215 ++++++++++++++---- crates/punktfunk-host/src/detect/linux.rs | 49 +++- crates/punktfunk-host/src/detect/windows.rs | 42 +++- crates/punktfunk-host/src/gamestream/mod.rs | 25 +- crates/punktfunk-host/src/main.rs | 26 ++- web/messages/de.json | 4 +- web/messages/en.json | 4 +- web/src/sections/Host/ConflictsCard.tsx | 16 +- 10 files changed, 509 insertions(+), 78 deletions(-) diff --git a/crates/pf-vdisplay/src/vdisplay/windows/manager.rs b/crates/pf-vdisplay/src/vdisplay/windows/manager.rs index 9b703606..313a9bb6 100644 --- a/crates/pf-vdisplay/src/vdisplay/windows/manager.rs +++ b/crates/pf-vdisplay/src/vdisplay/windows/manager.rs @@ -1764,6 +1764,11 @@ impl VirtualDisplayManager { if let Some(saved) = inner.group.ccd_saved.take() { restore_displays_ccd(&saved); } + // Drop the isolate's crash-recovery marker even when there was no snapshot to restore + // (a failed `isolate_displays_ccd` leaves `ccd_saved` None, and `restore_displays_ccd` + // — which clears it itself — then never runs). The group is gone either way, so no + // future host start owes this desk a force-EXTEND. + pf_win_display::win_display::isolate_journal::clear(); // EXPERIMENTAL `ddc_power_off` wake. OUTSIDE the `ccd_saved` gate, for the same reason // `pnp_disabled` is above it: the panels were commanded dark BEFORE the isolate, and // the isolate can return `None` (its `query_active_config` failed). Nested inside that diff --git a/crates/pf-win-display/src/win_display.rs b/crates/pf-win-display/src/win_display.rs index d7b31d0a..e0b5b66d 100644 --- a/crates/pf-win-display/src/win_display.rs +++ b/crates/pf-win-display/src/win_display.rs @@ -1215,6 +1215,186 @@ pub fn target_inventory() -> Vec { out } +/// Crash-recovery journal for the EXCLUSIVE isolate — the marker that lets a *fresh* host undo what +/// a *dead* one did. +/// +/// [`isolate_displays_ccd`] deactivates the operator's physical displays and hands the pre-isolate +/// topology back to its caller, which restores it at teardown ([`restore_displays_ccd`]). That +/// snapshot lives in **process memory only**, so a host that crashes, is killed, or is stopped +/// mid-session never restores it. Windows does not restore it either — the isolated topology is +/// deliberately never saved to the CCD database, precisely so teardown can put the user's layout +/// back. The result was a field-reported dead end: the physical screen stays dark, no timeout ever +/// fires, and nothing in the product puts it back (the operator's only recourse was `DisplaySwitch` +/// or a reboot). +/// +/// Same shape as [`monitor_devnode`](crate::monitor_devnode)'s PnP journal: write a marker while the +/// isolate is live, clear it on a clean restore, and re-light the desk at host startup if a marker +/// survived. +/// +/// **Why the EXTEND preset rather than replaying the saved CCD blob.** That blob pins target ids +/// *including the virtual display's*, and the crashed host's monitors die with it (startup reaps the +/// orphans), so a replay would mostly fail `ERROR_BAD_CONFIGURATION` and land in the very +/// force-EXTEND backstop [`restore_displays_ccd`] already keeps for that case. EXTEND re-activates +/// every connected display from the OS's own database, needs no struct serialization, and stays +/// correct across a reboot — where saved target ids would be stale anyway. +pub mod isolate_journal { + use std::sync::Mutex; + + /// What we last wrote, so the exclusive re-assert watchdog's repeat isolates don't rewrite the + /// file every couple of seconds. `None` = "no marker known to be on disk". + static LAST: Mutex>> = Mutex::new(None); + + fn path() -> std::path::PathBuf { + pf_paths::config_dir().join("display-isolate-active.json") + } + + /// Record that `deactivated` physical target(s) are switched off for a live exclusive isolate. + /// Best-effort: a journal we cannot write costs crash recovery, not the session. + pub fn mark(deactivated: &[u32]) { + if deactivated.is_empty() { + return; // nothing was deactivated ⇒ nothing for a later host to put back + } + let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner()); + if last.as_deref() == Some(deactivated) { + return; + } + let p = path(); + if let Some(dir) = p.parent() { + let _ = pf_paths::create_private_dir(dir); + } + match std::fs::write( + &p, + serde_json::to_vec_pretty(deactivated).unwrap_or_default(), + ) { + Ok(()) => *last = Some(deactivated.to_vec()), + Err(e) => tracing::warn!( + error = %e, + "display isolate: could not write the crash-recovery journal — if this host dies \ + mid-session the deactivated panels will stay dark" + ), + } + } + + /// The isolate is over (restored, or there was nothing to restore) — drop the marker. + /// Idempotent; safe to call when no marker exists. + pub fn clear() { + let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner()); + let _ = std::fs::remove_file(path()); + *last = None; + } + + /// Host-startup crash recovery: if a previous host exited with an exclusive isolate live, its + /// physical displays are still deactivated. Re-light them with the EXTEND preset. + /// + /// Call once, early in `serve`, **before** any session touches the topology. Gated on the marker + /// rather than on "is anything active", so a legitimately headless host is never forced awake. + pub fn startup_recover() { + let Some(targets) = pending() else { + return; + }; + tracing::warn!( + deactivated = ?targets, + "display isolate: a previous host exited with the operator's display(s) deactivated for \ + an EXCLUSIVE session and never restored them — forcing the EXTEND preset so the desk is \ + not left dark" + ); + super::force_extend_topology(); + clear(); + } + + /// The marker a previous host left behind, if any (its deactivated target ids) — the *decision* + /// half of [`startup_recover`], split out so the recovery rule is testable without driving a + /// real `SetDisplayConfig` against the machine running the test. + pub fn pending() -> Option> { + let bytes = std::fs::read(path()).ok()?; + Some(serde_json::from_slice(&bytes).unwrap_or_default()) + } + + #[cfg(test)] + mod tests { + use super::*; + + /// `PUNKTFUNK_CONFIG_DIR` (which `path()` resolves through) and the `LAST` cache are both + /// process-global, so these cases must not interleave. + static ENV: Mutex<()> = Mutex::new(()); + + /// Point the journal at a scratch dir for the duration of one case. + fn with_temp_dir(name: &str, f: impl FnOnce(&std::path::Path)) { + let _g = ENV.lock().unwrap_or_else(|e| e.into_inner()); + let dir = std::env::temp_dir().join(format!("pf-isolate-journal-{name}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("scratch dir"); + std::env::set_var("PUNKTFUNK_CONFIG_DIR", &dir); + clear(); // reset the LAST cache + any leftover marker from a previous run + f(&dir); + clear(); + std::env::remove_var("PUNKTFUNK_CONFIG_DIR"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The crash path: a host marks what it switched off and dies. The next start must see the + /// marker (and which targets), which is what makes it force the desk back on. + #[test] + fn a_mark_survives_for_the_next_host_and_clear_retracts_it() { + with_temp_dir("roundtrip", |_| { + assert_eq!(pending(), None, "a clean box owes no recovery"); + mark(&[101, 202]); + assert_eq!( + pending(), + Some(vec![101, 202]), + "a crashed host's marker must be readable by the next start" + ); + clear(); + assert_eq!(pending(), None, "a clean teardown retracts the marker"); + }); + } + + /// An isolate that deactivated nothing (single-display box: the virtual output is already + /// the only head) owes the next start no force-EXTEND — marking there would re-arrange a + /// desk we never touched. + #[test] + fn deactivating_nothing_writes_no_marker() { + with_temp_dir("empty", |_| { + mark(&[]); + assert_eq!(pending(), None); + }); + } + + /// The re-assert watchdog re-isolates every couple of seconds while something fights it; + /// that must not mean a disk write per cycle. + #[test] + fn repeating_the_same_mark_does_not_rewrite_the_file() { + with_temp_dir("cached", |dir| { + let file = dir.join("display-isolate-active.json"); + mark(&[7]); + // Overwrite behind the journal's back rather than comparing mtimes — a filesystem + // whose timestamp resolution is coarser than two back-to-back writes would let an + // mtime assertion pass without proving anything. + std::fs::write(&file, b"SENTINEL").unwrap(); + mark(&[7]); + assert_eq!( + std::fs::read(&file).unwrap(), + b"SENTINEL", + "an unchanged mark must not rewrite the journal" + ); + // A CHANGED set still lands — the group grew/shrank and recovery must follow it. + mark(&[7, 8]); + assert_eq!(pending(), Some(vec![7, 8])); + }); + } + + /// A corrupt/truncated journal must still trigger recovery: the FILE's existence is the + /// signal ("a host left displays off"), its contents are only diagnostics. + #[test] + fn an_unparseable_marker_still_asks_for_recovery() { + with_temp_dir("corrupt", |dir| { + std::fs::write(dir.join("display-isolate-active.json"), b"{ not json").unwrap(); + assert_eq!(pending(), Some(Vec::new())); + }); + } + } +} + /// Robust display isolation via the CCD API. The naive GDI approach (EnumDisplayDevices + /// ChangeDisplaySettings) MISSES displays on a hybrid box — an iGPU-attached physical monitor isn't /// flagged `ATTACHED_TO_DESKTOP` in the GDI enum, so it's never detached and the secure desktop / @@ -1246,6 +1426,18 @@ pub fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option { return Some(saved); } + // Journal what we are about to switch off BEFORE the first apply, not after a verified one: the + // window this exists to cover includes dying mid-apply. `saved.0` is the ACTIVE path set + // (QDC_ONLY_ACTIVE_PATHS), so everything in it outside the keep set is exactly what teardown + // owes the operator back. See `isolate_journal`. + let doomed: Vec = saved + .0 + .iter() + .map(|p| p.targetInfo.id) + .filter(|id| !keep_target_ids.contains(id)) + .collect(); + isolate_journal::mark(&doomed); + // Deactivate every non-keep display, then VERIFY and RETRY. A field-reported bug had a physical // monitor STAY ACTIVE in exclusive mode, so we don't trust a single SetDisplayConfig: re-query the // live topology each attempt and re-apply until ONLY the keep set is active. Secure-desktop @@ -1769,6 +1961,15 @@ static DARK_SINKS_FUTILE: std::sync::Mutex> = std::sync::Mute /// removed), re-activating the displays we deactivated. // pub so vdisplay::pf_vdisplay can reuse this backend-neutral CCD restore helper. pub fn restore_displays_ccd(saved: &SavedConfig) { + restore_displays_ccd_inner(saved); + // Clear the crash-recovery marker only AFTER the restore (and its dark-desk backstop) has run, + // never before: a host that dies part-way through the restore must still leave the marker + // behind so the next start re-lights the desk. `_inner` has several early returns, which is + // why this wraps rather than trailing the body. + isolate_journal::clear(); +} + +fn restore_displays_ccd_inner(saved: &SavedConfig) { let (paths, modes) = saved; if paths.is_empty() { return; diff --git a/crates/punktfunk-host/src/detect.rs b/crates/punktfunk-host/src/detect.rs index eb5a5c09..ccba5400 100644 --- a/crates/punktfunk-host/src/detect.rs +++ b/crates/punktfunk-host/src/detect.rs @@ -16,6 +16,19 @@ //! [`KNOWN`] as new forks appear) matched against running processes, registered OS services/units, //! and on-disk install markers. The platform back-ends (`detect/windows.rs`, `detect/linux.rs`) //! provide the raw facts; the matching + rendering here is portable and unit-tested. +//! +//! **Not every fingerprint is a conflict.** Only a host that is running, or that will start on its +//! own, can take the ports or load a second virtual-display driver. A leftover `Program Files` +//! folder from an uninstall, a binary on `PATH`, or a service registered but *disabled* clashes +//! with nothing — Sunshine's and Apollo's uninstallers both leave their config/log directories +//! behind, so treating mere presence as a conflict cries wolf on a machine whose other host is long +//! gone. [`Evidence::is_active`] draws that line and [`Detection::is_active`] lifts it to the +//! product; the warning surfaces (startup log, `/local/summary` → the web console's conflicts card, +//! the `detect-conflicts` exit code) report **only** active detections, while the full report still +//! lists the dormant ones as context for support. This matches the installer's own probe +//! (`punktfunk-host.iss`'s `StreamHostEnabled`: service start type <= 2), which was narrowed to +//! exactly this rule after a dormant Sunshine aborted a `winget install` in the field, and the tray, +//! which dropped its always-on warning over a merely-installed Sunshine in `3e782852`. use std::sync::OnceLock; @@ -73,17 +86,38 @@ impl Product { pub enum Evidence { /// A matching process is running **right now** (process/executable basename). Running { process: String }, - /// An OS service / systemd unit for the product is registered (installed; may be stopped). - Service { name: String }, + /// An OS service / systemd unit for the product is registered. `autostart` is the load-bearing + /// bit: a service that comes up on its own (Windows start type boot/system/automatic; an enabled + /// systemd unit) *will* clash, whereas a disabled/manual one is inert until someone starts it by + /// hand — at which point the `Running` evidence catches it on the next scan. + Service { name: String, autostart: bool }, /// Installed on disk — a Program Files directory, a flatpak app id, or a binary on `PATH`. + /// Always dormant: files that nothing launches bind no ports. Installed { at: String }, } impl Evidence { + /// Does this observation mean a conflicting host will actually take the ports / load a second + /// virtual-display driver? See the module docs — this is the whole false-alarm fix. + pub fn is_active(&self) -> bool { + match self { + Evidence::Running { .. } => true, + Evidence::Service { autostart, .. } => *autostart, + Evidence::Installed { .. } => false, + } + } + fn render(&self) -> String { match self { Evidence::Running { process } => format!("running now ({process})"), - Evidence::Service { name } => format!("service {name}"), + Evidence::Service { + name, + autostart: true, + } => format!("service {name} (starts automatically)"), + Evidence::Service { + name, + autostart: false, + } => format!("service {name} (disabled/manual — dormant)"), Evidence::Installed { at } => format!("installed at {at}"), } } @@ -105,12 +139,24 @@ impl Detection { .any(|e| matches!(e, Evidence::Running { .. })) } - /// A compact one-line label for the tray/console summary, e.g. `Sunshine (running)`. + /// True when this host is running **or** will start on its own — i.e. the detection is worth + /// warning a user about. A product seen only as files on disk or a disabled service is dormant + /// and reports `false`; see the module docs. + pub fn is_active(&self) -> bool { + self.evidence.iter().any(Evidence::is_active) + } + + /// A compact one-line label for the console summary, e.g. `Sunshine (running)`. The qualifier + /// names what was actually observed, so a card built from these labels can never claim a + /// dormant install is running. pub fn label(&self) -> String { + let name = self.product.label(); if self.is_running() { - format!("{} (running)", self.product.label()) + format!("{name} (running)") + } else if self.is_active() { + format!("{name} (starts automatically)") } else { - self.product.label().to_string() + format!("{name} (installed, not running)") } } } @@ -225,28 +271,66 @@ pub fn snapshot() -> &'static [Detection] { SNAPSHOT.get().map(Vec::as_slice).unwrap_or(&[]) } -/// Compact labels for the tray / web-console summary (e.g. `["Sunshine (running)", "Apollo"]`). -pub fn summary_labels(detections: &[Detection]) -> Vec { - detections.iter().map(Detection::label).collect() +/// True if any detection is active — the one gate the warning surfaces share (startup log, the +/// `detect-conflicts` exit code, the console card). +pub fn any_active(detections: &[Detection]) -> bool { + detections.iter().any(Detection::is_active) } -/// A full human-readable report: the blurb + one bullet per detected host with its evidence. -/// Empty string when nothing was detected (callers gate on `is_empty()`). +/// Compact labels for the web-console summary (e.g. `["Sunshine (running)"]`). +/// +/// **Active detections only.** A dormant leftover (an uninstalled Sunshine's `Program Files` folder, +/// a disabled service) is deliberately absent: this feeds the console's conflicts card, which exists +/// to explain why clients cannot reach a working-looking host, and files that nothing launches never +/// cause that. The full [`render_report`] still lists them for support. +pub fn summary_labels(detections: &[Detection]) -> Vec { + detections + .iter() + .filter(|d| d.is_active()) + .map(Detection::label) + .collect() +} + +/// A full human-readable report, split by whether the finding can actually clash. Empty string when +/// nothing was detected at all (callers gate on `is_empty()`). +/// +/// The dormant section is why this stays verbose where [`summary_labels`] is quiet: when a user asks +/// "why does Punktfunk think I have Apollo?", the answer is the exact leftover path, and the report +/// says in the same breath that it needs no action. pub fn render_report(detections: &[Detection]) -> String { if detections.is_empty() { return String::new(); } - let mut s = String::from("Detected another game-streaming host on this machine.\n"); - s.push_str(UNSUPPORTED_BLURB); - s.push_str("\n\nDetected:\n"); - for d in detections { + let bullet = |d: &Detection| { let ev = d .evidence .iter() .map(Evidence::render) .collect::>() .join("; "); - s.push_str(&format!(" \u{2022} {} \u{2014} {ev}\n", d.product.label())); + format!(" \u{2022} {} \u{2014} {ev}\n", d.product.label()) + }; + let (active, dormant): (Vec<_>, Vec<_>) = detections.iter().partition(|d| d.is_active()); + let mut s = String::new(); + if !active.is_empty() { + s.push_str("Detected another game-streaming host on this machine.\n"); + s.push_str(UNSUPPORTED_BLURB); + s.push_str("\n\nDetected:\n"); + for d in &active { + s.push_str(&bullet(d)); + } + } + if !dormant.is_empty() { + if !active.is_empty() { + s.push('\n'); + } + s.push_str( + "Also present but DORMANT — not running and not set to start on its own, so it clashes \ +with nothing and needs no action (typically leftovers from an uninstall):\n", + ); + for d in &dormant { + s.push_str(&bullet(d)); + } } s } @@ -275,15 +359,19 @@ mod tests { }, Evidence::Service { name: "SunshineService".into(), + autostart: true, }, ], ); assert!(d.is_running()); + assert!(d.is_active()); assert_eq!(d.label(), "Sunshine (running)"); } + /// The field case this split exists for: Apollo uninstalled, its `Program Files` folder left + /// behind. Nothing launches it, so it is NOT a conflict and must never reach the console card. #[test] - fn installed_only_is_not_running() { + fn a_leftover_install_dir_is_dormant_and_never_surfaces() { let d = det( Product::Apollo, vec![Evidence::Installed { @@ -291,42 +379,77 @@ mod tests { }], ); assert!(!d.is_running()); - assert_eq!(d.label(), "Apollo"); + assert!(!d.is_active(), "files on disk cannot bind a port"); + assert_eq!(d.label(), "Apollo (installed, not running)"); + assert!(summary_labels(std::slice::from_ref(&d)).is_empty()); + assert!(!any_active(&[d])); + } + + /// A registered-but-DISABLED service is the other half of the same false alarm: `service_exists` + /// used to count it, which disagreed with the installer's `Start <= 2` probe. + #[test] + fn a_disabled_service_is_dormant_but_an_autostart_one_is_not() { + let disabled = det( + Product::Sunshine, + vec![Evidence::Service { + name: "SunshineService".into(), + autostart: false, + }], + ); + assert!(!disabled.is_active()); + assert!(summary_labels(&[disabled]).is_empty()); + + let auto = det( + Product::Sunshine, + vec![Evidence::Service { + name: "SunshineService".into(), + autostart: true, + }], + ); + assert!(auto.is_active()); + assert!(!auto.is_running(), "registered to start != started"); + assert_eq!(auto.label(), "Sunshine (starts automatically)"); + assert_eq!( + summary_labels(&[auto]), + vec!["Sunshine (starts automatically)".to_string()] + ); } #[test] - fn report_lists_every_product_and_the_blurb() { - let report = render_report(&[ - det( - Product::Sunshine, - vec![Evidence::Running { - process: "sunshine".into(), - }], - ), - det( - Product::Apollo, - vec![Evidence::Installed { - at: "/usr/bin/apollo".into(), - }], - ), - ]); + fn report_separates_active_from_dormant_and_keeps_the_blurb() { + let active = det( + Product::Sunshine, + vec![Evidence::Running { + process: "sunshine".into(), + }], + ); + let dormant = det( + Product::Apollo, + vec![Evidence::Installed { + at: "/usr/bin/apollo".into(), + }], + ); + let report = render_report(&[active.clone(), dormant.clone()]); assert!(report.contains("UNSUPPORTED")); + // The bullets name the PRODUCT and let the evidence speak — `Detection::label`'s qualifier + // would only restate what follows the dash ("Sunshine (running) — running now (sunshine)"). + // The qualifier is for `summary_labels`, which has no evidence text beside it. assert!(report.contains("Sunshine \u{2014} running now (sunshine)")); + assert!(report.contains("DORMANT")); assert!(report.contains("Apollo \u{2014} installed at /usr/bin/apollo")); + // Only the live one is offered to the console card. assert_eq!( - summary_labels(&[ - det( - Product::Sunshine, - vec![Evidence::Running { - process: "sunshine".into() - }] - ), - det( - Product::Apollo, - vec![Evidence::Installed { at: "x".into() }] - ), - ]), - vec!["Sunshine (running)".to_string(), "Apollo".to_string()] + summary_labels(&[active, dormant.clone()]), + vec!["Sunshine (running)".to_string()] + ); + + // A dormant-only machine gets the explanatory listing WITHOUT the "unsupported" alarm — the + // whole point is that this needs no action. + let dormant_only = render_report(&[dormant]); + assert!(dormant_only.contains("DORMANT")); + assert!( + !dormant_only.contains("UNSUPPORTED"), + "a leftover folder must not read as an unsupported dual-host setup:\n{dormant_only}" ); } diff --git a/crates/punktfunk-host/src/detect/linux.rs b/crates/punktfunk-host/src/detect/linux.rs index 7b13e6b9..5be7f8bf 100644 --- a/crates/punktfunk-host/src/detect/linux.rs +++ b/crates/punktfunk-host/src/detect/linux.rs @@ -50,7 +50,11 @@ pub fn static_evidence(known: &Known) -> Vec { for unit in known.linux_units { let file = format!("{unit}.service"); if unit_dirs.iter().any(|d| Path::new(d).join(&file).exists()) { - ev.push(Evidence::Service { name: file }); + let autostart = unit_enabled(&file, home.as_deref()); + ev.push(Evidence::Service { + name: file, + autostart, + }); } } @@ -78,6 +82,49 @@ pub fn static_evidence(known: &Known) -> Vec { ev } +/// Is `unit` (a `.service` filename) **enabled** — i.e. will systemd start it on its own? +/// +/// `systemctl enable` works by symlinking the unit into a target's `.wants`/`.requires` directory, +/// so the presence of that link is the enablement fact — readable without spawning `systemctl` +/// (this module is deliberately subprocess-free, and the host often runs where `systemctl` output +/// would need a bus connection anyway). A unit file that exists but is linked from no target is +/// installed-but-inert: nothing starts it at boot, so it clashes with nothing. +/// +/// Scans the `.wants`/`.requires` subdirectories of the drop-in roots systemd actually reads, rather +/// than hardcoding `multi-user.target` — a unit pulled in by `graphical.target`, a user +/// `default.target`, or any other target is just as enabled. +fn unit_enabled(unit: &str, home: Option<&std::ffi::OsStr>) -> bool { + let mut roots: Vec = vec![ + "/etc/systemd/system".into(), + "/run/systemd/system".into(), + "/usr/lib/systemd/system".into(), + "/lib/systemd/system".into(), + "/etc/systemd/user".into(), + "/usr/lib/systemd/user".into(), + ]; + if let Some(h) = home { + roots.push(format!("{}/.config/systemd/user", h.to_string_lossy())); + } + for root in roots { + let Ok(entries) = std::fs::read_dir(&root) else { + continue; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if !(name.ends_with(".wants") || name.ends_with(".requires")) { + continue; + } + // `symlink_metadata` so a DANGLING link still counts: a link into a target's .wants is + // what "enabled" means, and a broken one still says the operator enabled it. + if std::fs::symlink_metadata(entry.path().join(unit)).is_ok() { + return true; + } + } + } + false +} + fn find_on_path(bin: &str, path: Option<&std::ffi::OsStr>) -> Option { let dirs = path.map(std::env::split_paths).into_iter().flatten(); // Always also probe the common bindirs, even if PATH is unset/narrow (e.g. a service context). diff --git a/crates/punktfunk-host/src/detect/windows.rs b/crates/punktfunk-host/src/detect/windows.rs index 558474d8..bcf82c12 100644 --- a/crates/punktfunk-host/src/detect/windows.rs +++ b/crates/punktfunk-host/src/detect/windows.rs @@ -7,7 +7,7 @@ use windows::Win32::Foundation::CloseHandle; use windows::Win32::System::Diagnostics::ToolHelp::{ CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS, }; -use windows_service::service::ServiceAccess; +use windows_service::service::{ServiceAccess, ServiceStartType}; use windows_service::service_manager::{ServiceManager, ServiceManagerAccess}; /// Lowercased executable basenames (without `.exe`) of every running process, via a Toolhelp @@ -49,9 +49,10 @@ pub fn running_processes() -> Vec { pub fn static_evidence(known: &Known) -> Vec { let mut ev = Vec::new(); for svc in known.win_services { - if service_exists(svc) { + if let Some(autostart) = service_start_type(svc) { ev.push(Evidence::Service { name: (*svc).to_string(), + autostart, }); } } @@ -63,14 +64,35 @@ pub fn static_evidence(known: &Known) -> Vec { ev } -/// True if a service by this name is registered with the SCM (running or stopped). Opening it with -/// `QUERY_STATUS` fails cleanly when it doesn't exist. -fn service_exists(name: &str) -> bool { - let Ok(mgr) = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT) - else { - return false; - }; - mgr.open_service(name, ServiceAccess::QUERY_STATUS).is_ok() +/// `Some(autostart)` if a service by this name is registered with the SCM (running or stopped), +/// `None` if it does not exist. Opening it fails cleanly when it doesn't exist. +/// +/// `autostart` mirrors the installer's `StreamHostEnabled` (start type <= 2): only boot/system/auto +/// come up on their own, and only a host that comes up can take the GameStream ports. A disabled or +/// manual service is dormant — see the module docs on `super`. When the start type cannot be read +/// (no `QUERY_CONFIG` right) we report the service as dormant rather than guessing it autostarts: +/// the false-alarm this whole split exists to kill is worse than a missed warning, and a host that +/// is genuinely up is caught by the process scan regardless of what its service config says. +fn service_start_type(name: &str) -> Option { + let mgr = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT).ok()?; + let svc = mgr + .open_service( + name, + ServiceAccess::QUERY_CONFIG | ServiceAccess::QUERY_STATUS, + ) + // Fall back to a status-only handle so a service we may not configure still registers as + // present (dormant) instead of vanishing from the report entirely. + .or_else(|_| mgr.open_service(name, ServiceAccess::QUERY_STATUS)) + .ok()?; + let autostart = svc.query_config().is_ok_and(|c| { + matches!( + c.start_type, + ServiceStartType::AutoStart + | ServiceStartType::BootStart + | ServiceStartType::SystemStart + ) + }); + Some(autostart) } /// The install directory under any of the Program Files roots, if it exists. diff --git a/crates/punktfunk-host/src/gamestream/mod.rs b/crates/punktfunk-host/src/gamestream/mod.rs index 147e4cf0..a4f06e67 100644 --- a/crates/punktfunk-host/src/gamestream/mod.rs +++ b/crates/punktfunk-host/src/gamestream/mod.rs @@ -334,15 +334,26 @@ pub fn serve( "punktfunk host" ); // Surface a conflicting Moonlight-compatible host (Sunshine/Apollo/…) as early as possible: - // scan once (cached for `/local/summary` → tray + web console) and warn loudly if found. + // scan once (cached for `/local/summary` → the web console) and warn loudly if one can actually + // clash. A dormant leftover (an uninstalled Sunshine's Program Files folder, a disabled service) + // is logged at INFO instead — it belongs in a support log, not in a warning that reads like a + // fault on every boot. let conflicts = crate::detect::init(); if !conflicts.is_empty() { - tracing::warn!( - target: "punktfunk::detect", - count = conflicts.len(), - "{}", - crate::detect::render_report(conflicts) - ); + let report = crate::detect::render_report(conflicts); + if crate::detect::any_active(conflicts) { + tracing::warn!( + target: "punktfunk::detect", + count = conflicts.len(), + "{report}" + ); + } else { + tracing::info!( + target: "punktfunk::detect", + count = conflicts.len(), + "{report}" + ); + } } if gamestream { tracing::warn!( diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index 07e6fdc3..77a497b1 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -104,10 +104,14 @@ mod tray; mod store; mod stream_marker; mod update; -// `monitor_devnode::startup_recover()` (below) re-enables PnP monitor devnodes disabled by a prior -// run; it lives in the `pf-win-display` leaf crate (plan §W6). +// The two startup crash-recovery legs (below), both in the `pf-win-display` leaf crate (plan §W6): +// `monitor_devnode::startup_recover()` re-enables PnP monitor devnodes disabled by a prior run, and +// `isolate_journal::startup_recover()` re-lights displays a prior run deactivated for an EXCLUSIVE +// session and never restored. #[cfg(target_os = "windows")] use pf_win_display::monitor_devnode; +#[cfg(target_os = "windows")] +use pf_win_display::win_display::isolate_journal; // Virtual-display orchestration lives in the `pf-vdisplay` subsystem crate (plan §W6); this shim // keeps every existing `crate::vdisplay::*` path valid (serve/mgmt/native/capture consume the trait, // registry, and manager through it). The DDC panel control + the KWin zkde protocol moved with it. @@ -379,6 +383,12 @@ fn real_main() -> Result<()> { // restored (crash/kill/power loss) — before any new session touches the topology. #[cfg(target_os = "windows")] monitor_devnode::startup_recover(); + // The same recovery for the DEFAULT Exclusive path: a previous host that died holding a + // CCD isolate left the operator's panels deactivated with nothing to put them back (the + // restore snapshot was process memory). Runs AFTER the devnode leg so re-enabled + // monitors are present again and the EXTEND preset can actually light them. + #[cfg(target_os = "windows")] + isolate_journal::startup_recover(); gamestream::serve(mgmt_opts, native, gamestream) } // Report other Moonlight-compatible hosts (Sunshine/Apollo/…) installed or running on this @@ -388,11 +398,17 @@ fn real_main() -> Result<()> { let found = detect::scan(); if found.is_empty() { println!("No conflicting game-streaming host detected."); - Ok(()) - } else { - print!("{}", detect::render_report(&found)); + return Ok(()); + } + print!("{}", detect::render_report(&found)); + // Exit 1 ONLY for a host that runs or will start on its own. The installers and support + // scripts gate on this code, and a dormant leftover used to abort them — a `winget + // install` failed in the field on a box whose Sunshine was merely present (see the + // module docs + `punktfunk-host.iss`). Dormant findings print, then exit 0. + if detect::any_active(&found) { std::process::exit(1); } + Ok(()) } // Install and run host plugins: `plugins add playnite`, `plugins enable`, … Package ops are // forwarded to the bun runner; enable/disable/status drive the systemd unit (Linux) or the diff --git a/web/messages/de.json b/web/messages/de.json index 9112fab4..c1221d92 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -134,8 +134,8 @@ "gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} bindet die GPU im Automatikmodus.", "gpu_encoder_pin_note": "PUNKTFUNK_ENCODER={value} bindet das Encoder-Backend.", "gpu_encoder_pin_warning": "PUNKTFUNK_ENCODER={value} bindet einen {vendor}-Encoder, aber die GPU der nächsten Sitzung ist „{name}“ — die veraltete Bindung sollte aus host.env entfernt werden.", - "host_conflicts_title": "Auf diesem Rechner läuft ein weiterer Game-Streaming-Server", - "host_conflicts_help": "Er belegt dieselben Ports wie Punktfunk — es antwortet also der Server, der zuerst gestartet ist. Das ist meist der Grund, warum sich ein scheinbar funktionierender Host nicht verbinden lässt. Beende oder deinstalliere den anderen Server und starte Punktfunk neu.", + "host_conflicts_title": "Auf diesem Rechner ist ein weiterer Game-Streaming-Server aktiv", + "host_conflicts_help": "Er läuft oder startet automatisch mit und belegt dieselben Ports wie Punktfunk — es antwortet also der Server, der zuerst gestartet ist. Das ist meist der Grund, warum sich ein scheinbar funktionierender Host nicht verbinden lässt. Beende und deaktiviere den anderen Server und starte Punktfunk neu. Ein Server, der nur installiert ist, stört nicht und wird hier nicht aufgeführt.", "host_displays_help": "Wie virtuelle Displays erstellt, aktiv gehalten und angeordnet werden. Wähle eine Voreinstellung oder „Benutzerdefiniert“, um Optionen direkt zu setzen. Eine Änderung gilt ab der nächsten Sitzung.", "display_config_title": "Konfiguration", "display_preset": "Voreinstellung", diff --git a/web/messages/en.json b/web/messages/en.json index 224dae17..d4e46593 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -134,8 +134,8 @@ "gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} pins the GPU while in automatic mode.", "gpu_encoder_pin_note": "PUNKTFUNK_ENCODER={value} pins the encoder backend.", "gpu_encoder_pin_warning": "PUNKTFUNK_ENCODER={value} pins a {vendor} encoder, but the next session's GPU is “{name}” — remove the stale pin from host.env.", - "host_conflicts_title": "Another game-streaming server is running on this machine", - "host_conflicts_help": "It listens on the same ports as punktfunk, so whichever one started first answers your clients — which is usually why a working-looking host cannot be connected to. Stop or uninstall the other server, then restart punktfunk.", + "host_conflicts_title": "Another game-streaming server is active on this machine", + "host_conflicts_help": "It is running, or set to start on its own, and listens on the same ports as Punktfunk — so whichever one started first answers your clients, which is usually why a working-looking host cannot be connected to. Stop and disable the other server, then restart Punktfunk. A server that is only left installed does not clash and is not listed here.", "host_displays_help": "How virtual displays are created, kept alive, and arranged. Pick a preset, or choose Custom to set options directly. A change applies to the next session.", "display_config_title": "Configuration", "display_preset": "Preset", diff --git a/web/src/sections/Host/ConflictsCard.tsx b/web/src/sections/Host/ConflictsCard.tsx index 84242934..12c845eb 100644 --- a/web/src/sections/Host/ConflictsCard.tsx +++ b/web/src/sections/Host/ConflictsCard.tsx @@ -7,11 +7,17 @@ import { m } from "@/paraglide/messages"; /** * "Something else is already listening on these ports." * - * The host detects other Moonlight-compatible servers (Sunshine, Apollo, …) running on the same - * machine at startup and reports them in `GET /local/summary` as `conflicts`. Nothing surfaced it, - * even though it is the single most common reason a punktfunk host looks installed and working but - * no client can reach it — two servers fighting over the same ports, with whichever won the bind - * answering the client. + * The host detects other Moonlight-compatible servers (Sunshine, Apollo, …) on the same machine at + * startup and reports them in `GET /local/summary` as `conflicts`. Nothing surfaced it, even though + * it is the single most common reason a Punktfunk host looks installed and working but no client can + * reach it — two servers fighting over the same ports, with whichever won the bind answering the + * client. + * + * `conflicts` carries only servers that are running or set to start on their own; the host filters + * dormant leftovers out (see `detect.rs`), because an uninstalled Sunshine's `Program Files` folder + * clashes with nothing and this card used to shout about it on every load. Each entry names what was + * observed — `Sunshine (running)`, `Apollo (starts automatically)` — so the heading never has to + * guess, which it previously did by hardcoding "is running". * * Renders nothing at all when there is no conflict, so a healthy host sees no extra chrome. */ From 42a0dd52bebf5d41c2e74d16e8eb090d14edd037 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 22:52:38 +0200 Subject: [PATCH 53/53] refactor(haptics): one copy of each thing every rumble path was transcribing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twelve findings from the sweep's DRY/docs/dead-code tail. Most are small; three found real defects hiding behind the duplication. **The UHID event ABI existed five times.** Every UHID gamepad backend — DualSense, DualShock 4, Switch Pro, Steam Controller, Steam Controller 2 — carried its own verbatim copy of the kernel's constants plus its own `put_cstr`, and they had already drifted: `switch_pro` was missing the SET_REPORT pair entirely, and `steam_controller` read a FIXED 16-byte SET_REPORT window instead of the event's own `size`. That last one is a bug in both directions — a longer report was truncated, and a shorter one had the parser reading whatever the reused event buffer still held past the payload, i.e. acting on rumble values the game never wrote. Now one `uhid_abi` module owns the numbers plus the two accessors that are easy to get subtly wrong, with tests on exactly that. **A dead force-feedback id fallback.** ff-core's `input_ff_upload` picks a free effect slot and writes it into the effect BEFORE uinput forwards the request, so the `id == -1` branch could never run — and allocating from a local counter would have been the wrong answer anyway, since the kernel owns that id space. Removed, with a `debug_assert` where it stood. **Apple's HID path silently dropped weak rumble.** `hidByte` took the top byte with no non-zero floor, so every amplitude below 0x0100 rendered as exactly nothing. Android has always floored it at 1; this was the odd one out. That converter also existed twice byte-identically inside one Gradle module — now one `wireAmplitudeToByte`. Also: the DS5 output-report layout gets named offsets (`dualsense_proto::out_report`) documenting all three transport bases — USB 0, SDL payload −1, Bluetooth +2 — since the differing bases are transport-forced, not drift. `pf-client-core` cannot import them (it and `pf-inject` do not depend on each other, and a DualSense layout has no business in `punktfunk-core`, their only shared crate), so its copy now DERIVES its offsets by explicit subtraction and a test pins the relationship. `PUNKTFUNK_HID_EFFECT_MAX` sizes the struct it describes instead of a second literal 11 — the header now emits `uint8_t effect[PUNKTFUNK_HID_EFFECT_MAX]`. The rumble policy engine's `min_pulse_ms` and `keepalive_ms` docs stop naming cases nothing implements: no in-tree caller sets `min_pulse_ms`, and the macOS DualSense-over-BT keepalive the doc cited CANNOT be served by the quirk, because that renderer skips writes whose levels are unchanged and would swallow the engine's re-emit — it keeps its own keepalive instead. `TrackpadHaptic` is marked as staged scaffolding (the tag is on a shipped wire; removing the variant would not reclaim it). Three ×257-vs-`<<8` doc comments corrected — the scaling itself is fine, both round-trip to 255. `backstop_ms.max(160)` deleted as unreachable (the engine floors at 500). New tests for `Ds5Feedback` and for the Android rumble JNI packing on BOTH sides, with `MAX_PADS <= 16` now a compile-time assertion rather than a comment. Closes S1-S9, S11, T2, T3 (design/haptics-sweep-2026-08-03.md M12). S11's second half is NOT a defect and was left alone: `clients/session/src/main.rs` calls `set_forwarding` unconditionally on every params-build (its own comment explains why — browse mode reuses one service across launches), so `Ctl::Forwarding` routinely arrives unchanged and that early-out is what stops a redundant `sync_open` + Valve-HIDAPI cycle each launch. Verified: pf-inject clippy -D warnings 0 / 91 tests; pf-client-core + punktfunk-core clippy 0 / 437 tests (amd64 container); punktfunk-client-android 7 tests; Android :kit: 6 tests; Apple swift build + 189 tests / 0 failures; cargo fmt --all --check clean. Each new test probed by reverting its fix — the fixed SET_REPORT window fails 3, a broken pack shift fails 3, dropping the amplitude floor fails 1, and a wrong DS5 offset either fails the pin or refuses to compile. --- .../kotlin/io/unom/punktfunk/kit/DsDevice.kt | 14 +- .../io/unom/punktfunk/kit/GamepadFeedback.kt | 33 +--- .../io/unom/punktfunk/kit/RumbleWire.kt | 47 ++++++ .../io/unom/punktfunk/kit/RumbleWireTest.kt | 79 +++++++++ clients/android/native/src/feedback.rs | 92 +++++++++- .../Gamepad/ControllerTester.swift | 2 +- .../Gamepad/GamepadFeedback.swift | 4 +- .../PunktfunkKit/Gamepad/RumbleRenderer.swift | 34 ++-- .../PunktfunkKitTests/RumbleTuningTests.swift | 4 +- crates/pf-client-core/src/gamepad.rs | 159 +++++++++++++++++- .../pf-inject/src/inject/linux/dualsense.rs | 26 +-- .../pf-inject/src/inject/linux/dualshock4.rs | 25 +-- crates/pf-inject/src/inject/linux/gamepad.rs | 14 +- .../src/inject/linux/steam_controller.rs | 35 ++-- .../src/inject/linux/steam_controller2.rs | 24 +-- .../pf-inject/src/inject/linux/switch_pro.rs | 22 +-- crates/pf-inject/src/inject/linux/uhid_abi.rs | 143 ++++++++++++++++ .../src/inject/proto/dualsense_proto.rs | 102 +++++++---- crates/pf-inject/src/inject/uhid_manager.rs | 7 +- crates/pf-inject/src/lib.rs | 5 + crates/punktfunk-core/src/abi.rs | 13 +- crates/punktfunk-core/src/client/rumble.rs | 21 ++- crates/punktfunk-core/src/quic/datagram.rs | 8 + include/punktfunk_core.h | 13 +- 24 files changed, 705 insertions(+), 221 deletions(-) create mode 100644 clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/RumbleWire.kt create mode 100644 clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/RumbleWireTest.kt create mode 100644 crates/pf-inject/src/inject/linux/uhid_abi.rs diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsDevice.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsDevice.kt index f0f4f5ea..71ae86af 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsDevice.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/DsDevice.kt @@ -279,8 +279,8 @@ object DsDevice { fun ds5RumbleReport(model: Model, low: Int, high: Int): ByteArray = newDs5(model).also { it[1] = (DS5_FLAG0_COMPAT_VIBRATION or DS5_FLAG0_HAPTICS_SELECT).toByte() it[39] = DS5_FLAG2_VIBRATION2.toByte() - it[3] = amp8(high).toByte() - it[4] = amp8(low).toByte() + it[3] = wireAmplitudeToByte(high).toByte() + it[4] = wireAmplitudeToByte(low).toByte() } /** @@ -324,17 +324,11 @@ object DsDevice { ByteArray(Model.DUALSHOCK4.outputSize).also { it[0] = 0x05 it[1] = (DS4_FLAG0_MOTORS or DS4_FLAG0_LED).toByte() - it[4] = amp8(high).toByte() - it[5] = amp8(low).toByte() + it[4] = wireAmplitudeToByte(high).toByte() + it[5] = wireAmplitudeToByte(low).toByte() it[6] = r.toByte() it[7] = g.toByte() it[8] = b.toByte() } - // Wire u16 amplitude → motor byte; a nonzero command never collapses to 0 (parity with the - // vibrator path's toAmplitude). - private fun amp8(v16: Int): Int { - val a = (v16 ushr 8) and 0xFF - return if (v16 != 0 && a == 0) 1 else a - } } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt index 63d22d57..5746b861 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt @@ -127,21 +127,10 @@ class GamepadFeedback( rumbleThread = Thread({ while (running) { val ev = NativeBridge.nativeNextRumble(handle) - if (ev < 0L) continue // timeout / closed - // ev bits 49..52 = wire pad index; bits 32..47 = backstop duration (ms); - // 16..31 = low; 0..15 = high. These are EFFECTIVE commands from the core's shared - // rumble policy engine — it owns every lease/staleness/close decision (uniform - // across all clients; the old 60 s legacy-host exposure is gone) and emits - // explicit zeros, so apply verbatim: (0, 0) = cancel, non-zero = one-shot for - // the backstop (the hardware net under a stalled poll thread). - val pad = ((ev ushr 49) and 0xFL).toInt() - val backstopMs = ((ev ushr 32) and 0xFFFF) - renderRumble( - pad, - ((ev ushr 16) and 0xFFFF).toInt(), - (ev and 0xFFFF).toInt(), - backstopMs, - ) + // Layout + semantics live in `unpackRumbleEvent` (RumbleWire.kt), tested there + // against the Rust packer. + val cmd = unpackRumbleEvent(ev) ?: continue // timeout / closed + renderRumble(cmd.pad, cmd.low, cmd.high, cmd.backstopMs) } }, "pf-rumble").apply { isDaemon = true; start() } @@ -264,8 +253,8 @@ class GamepadFeedback( return } val bind = rumbleBindFor(pad) ?: return - val lo = toAmplitude(low) - val hi = toAmplitude(high) + val lo = wireAmplitudeToByte(low) + val hi = wireAmplitudeToByte(high) val m = bind.vm if (m != null) { if (lo == 0 && hi == 0) { @@ -314,8 +303,8 @@ class GamepadFeedback( */ private fun renderDeviceRumble(low: Int, high: Int, durationMs: Long) { val v = deviceVibrator ?: return - val lo = toAmplitude(low) - val hi = toAmplitude(high) + val lo = wireAmplitudeToByte(low) + val hi = wireAmplitudeToByte(high) if (lo == 0 && hi == 0) { runCatching { v.cancel() } // (0,0) = stop return @@ -329,12 +318,6 @@ class GamepadFeedback( } } - // 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0. - private fun toAmplitude(v16: Int): Int { - val a = (v16 ushr 8) and 0xFF - return if (v16 != 0 && a == 0) 1 else a - } - // One-shot held for `durationMs` — the host's v2 TTL (renewed while the level holds), so it // self-terminates on a lost stop; cancel on zero. Floor the duration at 1 ms: `createOneShot` // throws IllegalArgumentException on a non-positive duration, and a lease can carry ttl_ms==0 diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/RumbleWire.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/RumbleWire.kt new file mode 100644 index 00000000..9516d8b2 --- /dev/null +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/RumbleWire.kt @@ -0,0 +1,47 @@ +package io.unom.punktfunk.kit + +/** + * The two conversions every rumble path in this module needs, in one place. + * + * Both used to be transcribed per call site: [wireAmplitudeToByte] existed twice, byte-identical, + * in `GamepadFeedback` and `DsDevice`; [unpackRumbleEvent] was inline bit-shifting in the poll loop + * with no test on either side of the JNI boundary. Neither is complicated — which is exactly why a + * silent divergence between copies would have been hard to notice. + */ + +/** + * Wire amplitude (`0..0xFFFF`) → an 8-bit motor/vibrator level. + * + * The high byte, except that a **nonzero command never collapses to zero**: anything below 0x0100 + * would otherwise round to silence, turning a weak-but-real rumble into no rumble at all. 1 is + * imperceptibly light, but it moves. + */ +internal fun wireAmplitudeToByte(v16: Int): Int { + val a = (v16 ushr 8) and 0xFF + return if (v16 != 0 && a == 0) 1 else a +} + +/** One effective rumble command, as packed by the native side's `nativeNextRumble`. */ +internal data class RumbleCmd(val pad: Int, val low: Int, val high: Int, val backstopMs: Long) + +/** + * Unpack `NativeBridge.nativeNextRumble`'s `jlong`, or null for the timeout/closed sentinel. + * + * Layout, mirroring `clients/android/native/src/feedback.rs::pack_rumble`: + * bits 49..52 = wire pad index, 32..47 = backstop duration (ms), 16..31 = low, 0..15 = high. + * The pad field is 4 bits because `punktfunk_core::input::MAX_PADS` is 16 — the Rust side has a + * compile-time assertion tying the two together, so this can't silently start truncating. + * + * These are EFFECTIVE commands from the core's shared rumble policy engine: it owns every + * lease/staleness/close decision and emits explicit zeros, so apply them verbatim — + * `(0, 0)` = cancel, non-zero = one-shot for the backstop. + */ +internal fun unpackRumbleEvent(ev: Long): RumbleCmd? { + if (ev < 0L) return null // timeout / closed + return RumbleCmd( + pad = ((ev ushr 49) and 0xFL).toInt(), + low = ((ev ushr 16) and 0xFFFF).toInt(), + high = (ev and 0xFFFF).toInt(), + backstopMs = (ev ushr 32) and 0xFFFF, + ) +} diff --git a/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/RumbleWireTest.kt b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/RumbleWireTest.kt new file mode 100644 index 00000000..30226dde --- /dev/null +++ b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/RumbleWireTest.kt @@ -0,0 +1,79 @@ +package io.unom.punktfunk.kit + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * The Kotlin half of the rumble JNI boundary. The Rust half is pinned by `pack_rumble_tests` in + * `clients/android/native/src/feedback.rs`; the two suites describe the same layout from opposite + * sides, which is the only thing that catches one of them drifting. + */ +class RumbleWireTest { + + /** `pack_rumble` from the native side, transcribed — the packer these tests unpack. */ + private fun pack(pad: Int, low: Int, high: Int, backstopMs: Int): Long = + ((pad and 0xF).toLong() shl 49) or + ((backstopMs.coerceAtMost(0xFFFF)).toLong() shl 32) or + (low.toLong() shl 16) or + high.toLong() + + @Test + fun `every field round-trips at its extremes`() { + val cases = listOf( + listOf(0, 0, 0, 0), + listOf(15, 0xFFFF, 0xFFFF, 0xFFFF), + listOf(1, 0x1234, 0x5678, 500), + listOf(7, 0, 0xFFFF, 2000), + ) + for ((pad, low, high, backstop) in cases) { + val cmd = unpackRumbleEvent(pack(pad, low, high, backstop))!! + assertEquals("pad", pad, cmd.pad) + assertEquals("low", low, cmd.low) + assertEquals("high", high, cmd.high) + assertEquals("backstop", backstop.toLong(), cmd.backstopMs) + } + } + + /** MAX_PADS is 16, so all 16 indices must survive the 4-bit field without aliasing. */ + @Test + fun `all sixteen pad indices are distinct`() { + val seen = (0 until 16).map { unpackRumbleEvent(pack(it, 1, 2, 3))!!.pad } + assertEquals((0 until 16).toList(), seen) + } + + @Test + fun `the negative sentinel is not a command`() { + assertNull(unpackRumbleEvent(-1L)) + assertNull(unpackRumbleEvent(Long.MIN_VALUE)) + } + + @Test + fun `a stop is distinguishable from a hold`() { + val stop = unpackRumbleEvent(pack(2, 0, 0, 0))!! + val hold = unpackRumbleEvent(pack(2, 0x8000, 0x8000, 500))!! + assertEquals(0, stop.low) + assertEquals(0, stop.high) + assertNotEquals(stop, hold) + } + + // --- wireAmplitudeToByte (was two byte-identical private copies) --- + + @Test + fun `amplitude takes the high byte`() { + assertEquals(0xFF, wireAmplitudeToByte(0xFFFF)) + assertEquals(0x80, wireAmplitudeToByte(0x8000)) + assertEquals(0x12, wireAmplitudeToByte(0x1234)) + } + + @Test + fun `zero stays silent but a weak nonzero never does`() { + assertEquals("only a real zero may render as silence", 0, wireAmplitudeToByte(0)) + // Everything below 0x0100 has a zero high byte — without the floor these all vanish. + for (v in listOf(1, 0x0042, 0x00FF)) { + assertEquals("wire $v collapsed to silence", 1, wireAmplitudeToByte(v)) + } + assertEquals(1, wireAmplitudeToByte(0x0100)) // first value that reaches 1 on its own + } +} diff --git a/clients/android/native/src/feedback.rs b/clients/android/native/src/feedback.rs index 6833666e..432b050d 100644 --- a/clients/android/native/src/feedback.rs +++ b/clients/android/native/src/feedback.rs @@ -18,6 +18,29 @@ use std::time::Duration; /// observes its `running=false` flag promptly on teardown. const PULL_TIMEOUT: Duration = Duration::from_millis(100); +/// Width of the packed `pad` field in [`pack_rumble`] — 4 bits, i.e. indices 0..15. +const PAD_BITS: u32 = 4; +/// The packing is only lossless while every representable pad index fits in [`PAD_BITS`]. This was +/// a comment before; growing `MAX_PADS` past 16 would have silently aliased pad 16 onto pad 0 +/// rather than failing the build. +const _: () = assert!( + punktfunk_core::input::MAX_PADS <= 1usize << PAD_BITS, + "MAX_PADS no longer fits the 4-bit pad field in the packed rumble long" +); + +/// Pack one effective rumble command into the `jlong` `nativeNextRumble` returns. +/// +/// Layout — mirrored by `unpackRumbleEvent` in `RumbleWire.kt`: bits 49..52 `pad`, 32..47 +/// `backstop_ms`, 16..31 `low`, 0..15 `high`. Always non-negative, so the `-1` timeout/closed +/// sentinel stays unambiguous. Split out from the JNI entry point purely so it can be tested +/// without a live session handle — the shift arithmetic is the part worth pinning. +fn pack_rumble(pad: u16, low: u16, high: u16, backstop_ms: u32) -> jlong { + (jlong::from(pad & ((1 << PAD_BITS) - 1)) << 49) + | (jlong::from(backstop_ms.min(0xFFFF) as u16) << 32) + | (jlong::from(low) << 16) + | jlong::from(high) +} + // HID-output kind tags written into the returned ByteBuffer (Kotlin reads them back). const TAG_LED: u8 = 0x01; const TAG_PLAYER_LEDS: u8 = 0x02; @@ -54,12 +77,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble( // handle. let h = unsafe { &*(handle as *const SessionHandle) }; match h.client.next_rumble_command(PULL_TIMEOUT) { - Ok(cmd) => { - (jlong::from(cmd.pad & 0xF) << 49) - | (jlong::from(cmd.backstop_ms.min(0xFFFF) as u16) << 32) - | (jlong::from(cmd.low) << 16) - | jlong::from(cmd.high) - } + Ok(cmd) => pack_rumble(cmd.pad, cmd.low, cmd.high, cmd.backstop_ms), Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag } }) @@ -160,3 +178,65 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout( n as jint }) } + +#[cfg(test)] +mod pack_rumble_tests { + use super::*; + use punktfunk_core::input::MAX_PADS; + + /// Kotlin's `unpackRumbleEvent`, transcribed — if these two ever disagree the boundary is + /// broken, and nothing else in the build would say so. + fn unpack(ev: jlong) -> (u16, u16, u16, u32) { + let pad = ((ev >> 49) & 0xF) as u16; + let backstop = ((ev >> 32) & 0xFFFF) as u32; + let low = ((ev >> 16) & 0xFFFF) as u16; + let high = (ev & 0xFFFF) as u16; + (pad, low, high, backstop) + } + + #[test] + fn round_trips_every_field_at_its_extremes() { + for &(pad, low, high, backstop) in &[ + (0u16, 0u16, 0u16, 0u32), + (15, 0xFFFF, 0xFFFF, 0xFFFF), + (1, 0x1234, 0x5678, 500), + (7, 0, 0xFFFF, 2000), + ] { + let ev = pack_rumble(pad, low, high, backstop); + assert_eq!(unpack(ev), (pad, low, high, backstop), "pad {pad}"); + } + } + + #[test] + fn every_representable_pad_survives_the_four_bit_field() { + for pad in 0..MAX_PADS as u16 { + let (got, ..) = unpack(pack_rumble(pad, 1, 2, 3)); + assert_eq!(got, pad, "pad {pad} aliased in the packed long"); + } + } + + #[test] + fn a_packed_command_is_never_negative() { + // `-1` is the timeout/closed sentinel; any packed value colliding with it would read as + // "no command" and the rumble would simply vanish. + assert!(pack_rumble(15, 0xFFFF, 0xFFFF, 0xFFFF) >= 0); + assert!(pack_rumble(0, 0, 0, 0) >= 0); + } + + #[test] + fn an_oversized_backstop_saturates_instead_of_corrupting_the_pad_field() { + let ev = pack_rumble(3, 0, 0, u32::MAX); + let (pad, _, _, backstop) = unpack(ev); + assert_eq!(pad, 3, "a huge backstop must not bleed into the pad bits"); + assert_eq!(backstop, 0xFFFF); + } + + #[test] + fn a_stop_is_distinguishable_from_a_hold() { + let stop = pack_rumble(2, 0, 0, 0); + let hold = pack_rumble(2, 0x8000, 0x8000, 500); + assert_ne!(stop, hold); + assert_eq!(unpack(stop).1, 0); + assert_eq!(unpack(stop).2, 0); + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/ControllerTester.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/ControllerTester.swift index 422182c8..0b481c4c 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/ControllerTester.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/ControllerTester.swift @@ -12,7 +12,7 @@ import GameController public final class ControllerTester: ObservableObject { // `.manual`: the panel's toggles hold a level until changed — no session wire refreshes // exist here to keep the renderer's staleness watchdog fed. - private let renderer = RumbleRenderer(policy: .manual) + private let renderer = RumbleRenderer() private weak var controller: GCController? /// The rumble backend now in use — "DualSense HID · USB/Bluetooth", "CoreHaptics", or "—" — diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift index 9f32ceee..3c63b016 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift @@ -65,7 +65,7 @@ public final class GamepadFeedback { #if os(iOS) if UserDefaults.standard.bool(forKey: DefaultsKey.rumbleOnDevice), CHHapticEngine.capabilitiesForHardware().supportsHaptics { - deviceRumble = RumbleRenderer(policy: .session, actuator: .device) + deviceRumble = RumbleRenderer(actuator: .device) } else { deviceRumble = nil } @@ -128,7 +128,7 @@ public final class GamepadFeedback { replay(slot) } else { slots[pad] = Slot(controller: controller) - let renderer = RumbleRenderer(policy: .session) + let renderer = RumbleRenderer() renderer.retarget(controller) withRouting { rumbleByPad[pad] = renderer } } diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/RumbleRenderer.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/RumbleRenderer.swift index 563a905f..7b76f246 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/RumbleRenderer.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/RumbleRenderer.swift @@ -43,8 +43,14 @@ enum RumbleTuning { /// Wire amplitude (0...0xFFFF) → CoreHaptics intensity (0...1). static func amplitude(_ wire: UInt16) -> Float { Float(wire) / 65535 } - /// Wire amplitude → DualSense HID motor byte. - static func hidByte(_ wire: UInt16) -> UInt8 { UInt8(wire >> 8) } + /// Wire amplitude → DualSense HID motor byte. A nonzero command never collapses to silence: + /// the top byte of anything below 0x0100 is 0, so a weak-but-real rumble used to render as + /// nothing at all on this path. Floored at 1 — imperceptibly light, but moving. (Android's + /// `toAmplitude` has always done this; this was the odd one out.) + static func hidByte(_ wire: UInt16) -> UInt8 { + let b = UInt8(wire >> 8) + return wire != 0 && b == 0 ? 1 : b + } /// Single-actuator pads render whichever motor is stronger. static func combined(low: UInt16, high: UInt16) -> UInt16 { max(low, high) } /// Are two baked levels the same (skip the rebuild)? @@ -81,10 +87,11 @@ enum RumbleTuning { /// 4. **Escalating stop.** A throwing `player.stop` means the engine's state is unknown — the /// whole engine is stopped (silencing every player it hosts) and lazily rebuilt behind the /// exponential backoff. -/// 5. **Staleness watchdog** (`Policy.session`): audible with no wire command for -/// `sessionStaleSeconds` → force silence. A lost stop can outlive the host's 500 ms heal -/// only if the channel itself died, and then the pad must not buzz forever. `Policy.manual` -/// (the settings test panel) instead holds a level until it is changed. +/// 5. **No staleness watchdog here.** There was one, keyed off a `Policy` type and a +/// `sessionStaleSeconds`; both are gone. Every liveness decision — lease expiry, legacy-host +/// staleness, session close — now belongs to punktfunk-core's shared policy engine +/// (`client/rumble.rs`), which emits explicit zero commands, so this renderer applies what it +/// is told and never decides on its own when a level should end. /// /// Engines are created lazily on the first nonzero amplitude and torn down on retarget; /// failures (pads without haptics, engine resets) downgrade to silence — rumble is best-effort @@ -93,17 +100,6 @@ enum RumbleTuning { /// `@unchecked Sendable` is sound because every property is read and written only inside /// `queue` closures — the serial queue is the synchronization. final class RumbleRenderer: @unchecked Sendable { - /// Who ends an un-refreshed nonzero target. Session mode applies the core policy engine's - /// commands verbatim — the engine (punktfunk-core `client/rumble.rs`) owns every lease, - /// staleness, and close decision and emits explicit zeros, so the renderer keeps NO - /// staleness policy of its own anymore. The controller test panel (`manual`) holds a slider - /// level indefinitely; both are identical renderer-side today, the distinction is kept for - /// the call sites' intent. - struct Policy { - static let session = Policy() - static let manual = Policy() - } - /// Which physical actuator this renderer drives: the forwarded controller's haptics engine /// (the default), or THIS device's own Taptic Engine (`CHHapticEngine()`) — the opt-in /// "rumble on this device" mirror for phone-clip pads that ship without rumble motors. @@ -115,7 +111,6 @@ final class RumbleRenderer: @unchecked Sendable { } private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive) - private let policy: Policy private let actuator: Actuator /// One finite haptic play on a motor: the player plus when (engine timeline) it expires. @@ -190,8 +185,7 @@ final class RumbleRenderer: @unchecked Sendable { ((0, 0), DispatchTime(uptimeNanoseconds: 0)) #endif - init(policy: Policy = .session, actuator: Actuator = .controller) { - self.policy = policy + init(actuator: Actuator = .controller) { self.actuator = actuator } diff --git a/clients/apple/Tests/PunktfunkKitTests/RumbleTuningTests.swift b/clients/apple/Tests/PunktfunkKitTests/RumbleTuningTests.swift index 8bbf1a4e..073433c7 100644 --- a/clients/apple/Tests/PunktfunkKitTests/RumbleTuningTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/RumbleTuningTests.swift @@ -56,7 +56,7 @@ final class RumbleTuningTests: XCTestCase { /// storm, an audible target left to the ticker (watchdog path), then `stop()` — which runs /// `queue.sync` against the same serial queue the ticker fires on and must not deadlock. func testRendererSurvivesCallStormAndTeardownWithoutController() { - let renderer = RumbleRenderer(policy: .session) + let renderer = RumbleRenderer() renderer.retarget(nil) for i in 0..<500 { renderer.apply( @@ -72,7 +72,7 @@ final class RumbleTuningTests: XCTestCase { /// every policy stop (lease expiry, legacy staleness, session close), and the renderer's only /// job is to apply them. Drive the real queue/ticker (no physical pad) and confirm no wedge. func testZeroCommandSilencesAndTeardownDoesNotDeadlock() { - let renderer = RumbleRenderer(policy: .session) + let renderer = RumbleRenderer() renderer.retarget(nil) renderer.apply(low: 0x8000, high: 0x8000) Thread.sleep(forTimeInterval: 0.1) diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index a543fc35..f42b4db9 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -682,13 +682,27 @@ fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) { /// host parses off its virtual pad; the wire's 11-byte trigger blocks drop in verbatim. /// Enable bits select only the fields each update touches, so rumble (driven separately /// through SDL) and untouched fields keep their state. +/// +/// The offsets below are the USB output report's, **minus one**: SDL's payload carries no leading +/// report id. `pf-inject`'s `dualsense_proto::out_report` is where that layout is written down and +/// explained (including the Bluetooth `+2` base), but this crate cannot import it — `pf-inject` is +/// host-side and neither crate depends on the other, and a DualSense report layout has no business +/// in `punktfunk-core`, the only crate they share. So this is a deliberate second copy, and +/// [`ds5_offsets_track_the_usb_report`](ds5_feedback_tests) pins the `−1` relationship rather than +/// leaving it to a comment. struct Ds5Feedback; impl Ds5Feedback { - const RIGHT_TRIGGER: usize = 10; - const LEFT_TRIGGER: usize = 21; - const PAD_LIGHTS: usize = 43; - const LED_RGB: usize = 44; + /// The USB report offsets these are derived from — see the type doc. Kept beside the derived + /// values so the subtraction is visible at the point of definition. + const REPORT_ID_LEN: usize = 1; + const RIGHT_TRIGGER: usize = 11 - Self::REPORT_ID_LEN; + const LEFT_TRIGGER: usize = 22 - Self::REPORT_ID_LEN; + const PAD_LIGHTS: usize = 44 - Self::REPORT_ID_LEN; + const LED_RGB: usize = 45 - Self::REPORT_ID_LEN; + /// One adaptive-trigger parameter block: a mode byte plus 10 parameters. Mirrors + /// `PUNKTFUNK_HID_EFFECT_MAX`, which is the same number at the C-ABI boundary. + const TRIGGER_LEN: usize = punktfunk_core::abi::PUNKTFUNK_HID_EFFECT_MAX as usize; fn trigger_packet(which: u8, effect: &[u8]) -> [u8; 47] { let mut p = [0u8; 47]; @@ -698,7 +712,7 @@ impl Ds5Feedback { (0x08, Self::LEFT_TRIGGER) }; p[0] = flag; - let n = effect.len().min(11); + let n = effect.len().min(Self::TRIGGER_LEN); p[off..off + n].copy_from_slice(&effect[..n]); p } @@ -1837,7 +1851,12 @@ impl Worker { let dur_ms: u32 = if (low, high) == (0, 0) { 100 // a stop takes effect immediately; the duration is irrelevant } else { - backstop_ms.max(160) // floor: a jittered renewal can never gap the actuator + // No local floor. There was a `.max(160)` here, and it could never do anything: the + // engine's own `backstop()` returns `(2 * ttl).clamp(500, 5000)` or the 2000 ms legacy + // value, so a non-zero command's backstop is never below 500. A floor that belongs to a + // particular actuator belongs in its `ActuatorQuirks::min_pulse_ms`, which the engine + // already applies — not re-invented per renderer where it can silently disagree. + backstop_ms }; // Surface a failed SDL rumble write: a swallowed error here (DualSense not in the right // HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The host logs the send side @@ -2387,3 +2406,131 @@ mod slot_tests { ); } } + +/// [`Ds5Feedback`]'s three packet builders. The host-side parser, the Android writer and the Apple +/// writer are all pinned by their own suites; this writer had nothing, despite being the one that +/// hand-shifts every offset by the report-id length. +#[cfg(test)] +mod ds5_feedback_tests { + use super::*; + + /// The USB output report offsets, written out independently of the implementation. A DS5 + /// effects payload is the same block with the leading report id removed, so every offset is + /// exactly one lower — this is the relationship the derived constants encode. + #[test] + fn ds5_offsets_track_the_usb_report() { + for (usb, payload) in [ + (11usize, Ds5Feedback::RIGHT_TRIGGER), + (22, Ds5Feedback::LEFT_TRIGGER), + (44, Ds5Feedback::PAD_LIGHTS), + (45, Ds5Feedback::LED_RGB), + ] { + assert_eq!(payload, usb - 1, "payload offset for USB byte {usb}"); + } + assert_eq!(Ds5Feedback::TRIGGER_LEN, 11); + } + + #[test] + fn lightbar_sets_only_its_enable_bit_and_its_three_bytes() { + let p = Ds5Feedback::lightbar_packet(0x11, 0x22, 0x33); + assert_eq!(p.len(), 47); + assert_eq!(p[1], 0x04, "valid_flag1 lightbar bit"); + assert_eq!(p[0], 0, "must not claim any valid_flag0 field"); + assert_eq!( + ( + p[Ds5Feedback::LED_RGB], + p[Ds5Feedback::LED_RGB + 1], + p[Ds5Feedback::LED_RGB + 2] + ), + (0x11, 0x22, 0x33) + ); + // Everything else stays zero — an over-broad packet would blank the triggers/player LEDs + // it never meant to touch. + let touched = [ + 1, + Ds5Feedback::LED_RGB, + Ds5Feedback::LED_RGB + 1, + Ds5Feedback::LED_RGB + 2, + ]; + assert!(p + .iter() + .enumerate() + .all(|(i, &b)| touched.contains(&i) || b == 0)); + } + + #[test] + fn player_leds_are_masked_to_five_bits() { + let p = Ds5Feedback::player_packet(0xFF); + assert_eq!(p[1], 0x10, "valid_flag1 player-indicator bit"); + assert_eq!( + p[Ds5Feedback::PAD_LIGHTS], + 0x1F, + "high bits are not ours to set" + ); + let p = Ds5Feedback::player_packet(0b0000_0101); + assert_eq!(p[Ds5Feedback::PAD_LIGHTS], 0b0000_0101); + } + + /// which 1 = R2 and which 0 = L2 — and the RIGHT block sits FIRST in the report, which is the + /// pairing most likely to be transcribed backwards. + #[test] + fn trigger_which_selects_the_right_flag_and_offset() { + let eff: Vec = (1..=11).collect(); + + let r = Ds5Feedback::trigger_packet(1, &eff); + assert_eq!(r[0], 0x04, "valid_flag0 R2 bit"); + assert_eq!( + &r[Ds5Feedback::RIGHT_TRIGGER..Ds5Feedback::RIGHT_TRIGGER + 11], + &eff[..] + ); + assert_eq!( + r[Ds5Feedback::LEFT_TRIGGER], + 0, + "the other trigger is untouched" + ); + + let l = Ds5Feedback::trigger_packet(0, &eff); + assert_eq!(l[0], 0x08, "valid_flag0 L2 bit"); + assert_eq!( + &l[Ds5Feedback::LEFT_TRIGGER..Ds5Feedback::LEFT_TRIGGER + 11], + &eff[..] + ); + assert_eq!(l[Ds5Feedback::RIGHT_TRIGGER], 0); + } + + #[test] + fn an_oversized_effect_is_clamped_rather_than_overflowing_into_the_next_field() { + let long = vec![0xAAu8; 40]; + let p = Ds5Feedback::trigger_packet(1, &long); + assert_eq!(p.len(), 47); + // Exactly TRIGGER_LEN bytes written; the left block must not be scribbled on. + assert_eq!(p[Ds5Feedback::RIGHT_TRIGGER + 10], 0xAA); + assert_eq!(p[Ds5Feedback::RIGHT_TRIGGER + 11], 0); + assert_eq!(p[Ds5Feedback::LEFT_TRIGGER], 0); + } + + #[test] + fn a_short_effect_leaves_the_rest_of_the_block_zeroed() { + let p = Ds5Feedback::trigger_packet(0, &[0x02, 0x99]); + assert_eq!(p[Ds5Feedback::LEFT_TRIGGER], 0x02); + assert_eq!(p[Ds5Feedback::LEFT_TRIGGER + 1], 0x99); + assert!( + p[Ds5Feedback::LEFT_TRIGGER + 2..Ds5Feedback::LEFT_TRIGGER + 11] + .iter() + .all(|&b| b == 0) + ); + } + + /// An empty effect is a well-formed all-zero block: mode 0x00 = release. It must still assert + /// its enable bit, or the pad keeps whatever effect it was holding. + #[test] + fn an_empty_effect_is_a_release_not_a_no_op() { + let p = Ds5Feedback::trigger_packet(1, &[]); + assert_eq!(p[0], 0x04); + assert!( + p[Ds5Feedback::RIGHT_TRIGGER..Ds5Feedback::RIGHT_TRIGGER + 11] + .iter() + .all(|&b| b == 0) + ); + } +} diff --git a/crates/pf-inject/src/inject/linux/dualsense.rs b/crates/pf-inject/src/inject/linux/dualsense.rs index 121388e4..2e90f5f0 100644 --- a/crates/pf-inject/src/inject/linux/dualsense.rs +++ b/crates/pf-inject/src/inject/linux/dualsense.rs @@ -17,6 +17,11 @@ use super::dualsense_proto::{ DS_EDGE_PRODUCT, DS_FEATURE_CALIBRATION, DS_FEATURE_FIRMWARE, DS_INPUT_REPORT_LEN, DS_PRODUCT, DS_TOUCH_H, DS_TOUCH_W, DS_VENDOR, DUALSENSE_EDGE_RDESC, DUALSENSE_RDESC, }; +use crate::uhid_abi::{ + put_cstr, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2, UHID_DESTROY, UHID_EVENT_SIZE, + UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2, UHID_OUTPUT, UHID_PATH, UHID_SET_REPORT, + UHID_SET_REPORT_REPLY, +}; use crate::uhid_manager::{PadFeedback, PadProto, UhidManager}; use anyhow::{Context, Result}; use punktfunk_core::quic::RichInput; @@ -24,27 +29,6 @@ use std::fs::{File, OpenOptions}; use std::io::{Read, Write}; use std::os::unix::fs::OpenOptionsExt; -// /dev/uhid event ABI (linux/uhid.h). `struct uhid_event` is __packed__: a u32 `type` then a -// union whose largest member is uhid_create2_req (128+64+64 + 2+2 + 4*4 + rd_data[4096] = 4372). -const UHID_PATH: &str = "/dev/uhid"; -const UHID_DESTROY: u32 = 1; -const UHID_OUTPUT: u32 = 6; -const UHID_GET_REPORT: u32 = 9; -const UHID_GET_REPORT_REPLY: u32 = 10; -const UHID_CREATE2: u32 = 11; -const UHID_INPUT2: u32 = 12; -const UHID_SET_REPORT: u32 = 13; -const UHID_SET_REPORT_REPLY: u32 = 14; -const HID_MAX_DESCRIPTOR_SIZE: usize = 4096; -const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2) -const BUS_USB: u16 = 0x03; - -/// Copy a NUL-padded C string field into the event buffer. -fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) { - let n = s.len().min(cap - 1); - ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated) -} - /// The UHID identity a [`DualSensePad`] is created with — the plain DualSense or the Edge (same /// driver, same report codec; the Edge differs by PID + descriptor and carries the four extra /// `buttons[2]` bits). Mirrors the uinput pad's `PadIdentity` shape. diff --git a/crates/pf-inject/src/inject/linux/dualshock4.rs b/crates/pf-inject/src/inject/linux/dualshock4.rs index 1f9555d2..fd37d9ee 100644 --- a/crates/pf-inject/src/inject/linux/dualshock4.rs +++ b/crates/pf-inject/src/inject/linux/dualshock4.rs @@ -18,6 +18,11 @@ use super::dualshock4_proto::{ parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_PRODUCT, DS4_TOUCH_H, DS4_TOUCH_W, DS4_VENDOR, }; +use crate::uhid_abi::{ + put_cstr, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2, UHID_DESTROY, UHID_EVENT_SIZE, + UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2, UHID_OUTPUT, UHID_PATH, UHID_SET_REPORT, + UHID_SET_REPORT_REPLY, +}; use crate::uhid_manager::{PadFeedback, PadProto, UhidManager}; use anyhow::{Context, Result}; use punktfunk_core::quic::{HidOutput, RichInput}; @@ -25,20 +30,6 @@ use std::fs::{File, OpenOptions}; use std::io::{Read, Write}; use std::os::unix::fs::OpenOptionsExt; -// /dev/uhid event ABI (linux/uhid.h) — identical to the DualSense backend's; see `super::dualsense`. -const UHID_PATH: &str = "/dev/uhid"; -const UHID_DESTROY: u32 = 1; -const UHID_OUTPUT: u32 = 6; -const UHID_GET_REPORT: u32 = 9; -const UHID_GET_REPORT_REPLY: u32 = 10; -const UHID_CREATE2: u32 = 11; -const UHID_INPUT2: u32 = 12; -const UHID_SET_REPORT: u32 = 13; -const UHID_SET_REPORT_REPLY: u32 = 14; -const HID_MAX_DESCRIPTOR_SIZE: usize = 4096; -const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2) -const BUS_USB: u16 = 0x03; - // Feature reports `hid-playstation` GET_REPORTs during DS4 init. The PAIRING report (0x12) is // MANDATORY — without a valid reply `dualshock4_create()` aborts and creates NO input devices; the // kernel reads the 6-byte device MAC from bytes 1..7. CALIBRATION (0x02) and FIRMWARE (0xa3) are @@ -144,12 +135,6 @@ const DS4_RDESC: &[u8] = &[ 0xB1, 0x02, 0xC0, ]; -/// Copy a NUL-padded C string field into the event buffer. -fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) { - let n = s.len().min(cap - 1); - ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated) -} - /// A virtual DualShock 4 backed by `/dev/uhid` (hand-rolled codec mirroring the DualSense pad's). /// Dropping it destroys the device (the kernel tears down the bound `hid-playstation` interface). pub struct DualShock4Pad { diff --git a/crates/pf-inject/src/inject/linux/gamepad.rs b/crates/pf-inject/src/inject/linux/gamepad.rs index 435cf87d..6e7eef98 100644 --- a/crates/pf-inject/src/inject/linux/gamepad.rs +++ b/crates/pf-inject/src/inject/linux/gamepad.rs @@ -268,7 +268,6 @@ struct Effect { /// the policy is pure and unit-testable without a live uinput fd. struct FfState { effects: HashMap, - next_effect_id: i16, gain: u32, /// Last `(low, high)` reported, to dedup. last_mix: (u16, u16), @@ -284,7 +283,6 @@ impl FfState { fn new() -> FfState { FfState { effects: HashMap::new(), - next_effect_id: 0, gain: 0xFFFF, last_mix: (0, 0), last_activity: Instant::now(), @@ -531,11 +529,13 @@ impl VirtualPad { let mut up: UinputFfUpload = unsafe { std::mem::zeroed() }; up.request_id = ev.value as u32; if ioctl_ptr(raw, UI_BEGIN_FF_UPLOAD, &mut up, "UI_BEGIN_FF_UPLOAD").is_ok() { - let mut e = up.effect; - if e.id == -1 { - e.id = self.ff.next_effect_id; - self.ff.next_effect_id = self.ff.next_effect_id.wrapping_add(1); - } + let e = up.effect; + // No `id == -1` fallback: ff-core's `input_ff_upload` picks a free slot and + // writes it into the effect BEFORE handing the request to uinput, so what + // arrives here is always an assigned id. The fallback that used to allocate + // one from a local counter could therefore never run, and a local counter is + // the wrong answer anyway — the kernel owns that id space. + debug_assert!(e.id >= 0, "uinput handed us an unassigned FF effect id"); if e.type_ == FF_RUMBLE { let strong = u16::from_ne_bytes([e.u[0], e.u[1]]); let weak = u16::from_ne_bytes([e.u[2], e.u[3]]); diff --git a/crates/pf-inject/src/inject/linux/steam_controller.rs b/crates/pf-inject/src/inject/linux/steam_controller.rs index 73d6f1ec..12e716bb 100644 --- a/crates/pf-inject/src/inject/linux/steam_controller.rs +++ b/crates/pf-inject/src/inject/linux/steam_controller.rs @@ -23,6 +23,11 @@ use super::steam_proto::{ btn, parse_steam_output, sc_from_gamepad, serial_reply, serialize_deck_state, serialize_sc_state, SteamModel, SteamState, STEAMDECK_RDESC, STEAM_REPORT_LEN, STEAM_VENDOR, }; +use crate::uhid_abi::{ + put_cstr, request_id, set_report_data, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2, + UHID_DESTROY, UHID_EVENT_SIZE, UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2, + UHID_OUTPUT, UHID_PATH, UHID_SET_REPORT, UHID_SET_REPORT_REPLY, +}; use crate::uhid_manager::{PadFeedback, PadProto, UhidManager}; use anyhow::{Context, Result}; use punktfunk_core::quic::RichInput; @@ -32,20 +37,6 @@ use std::os::unix::fs::OpenOptionsExt; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; -// /dev/uhid event ABI — same layout as the DualSense backend. -const UHID_PATH: &str = "/dev/uhid"; -const UHID_DESTROY: u32 = 1; -const UHID_OUTPUT: u32 = 6; -const UHID_GET_REPORT: u32 = 9; -const UHID_GET_REPORT_REPLY: u32 = 10; -const UHID_CREATE2: u32 = 11; -const UHID_INPUT2: u32 = 12; -const UHID_SET_REPORT: u32 = 13; -const UHID_SET_REPORT_REPLY: u32 = 14; -const HID_MAX_DESCRIPTOR_SIZE: usize = 4096; -const UHID_EVENT_SIZE: usize = 4 + 4372; -const BUS_USB: u16 = 0x03; - /// Hold the `b9.6` mode-switch this long at creation to toggle `gamepad_mode` on (the kernel needs /// ~450 ms continuous; give margin). const MODE_ENTER: Duration = Duration::from_millis(650); @@ -53,11 +44,6 @@ const MODE_ENTER: Duration = Duration::from_millis(650); /// we insert a one-frame release so an in-game long-Start-hold can't toggle `gamepad_mode` off. const MENU_HOLD_CAP: Duration = Duration::from_millis(350); -fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) { - let n = s.len().min(cap - 1); - ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); -} - /// Best-effort, once per process: clear `hid_steam`'s `lizard_mode` so `steam_do_deck_input_event` /// stops gating on `gamepad_mode` (gamepad events then always flow). Needs root; on failure the /// per-pad `b9.6` pulse + guard handle it instead. @@ -214,10 +200,13 @@ impl SteamDeckPad { let _ = self.reply_get_report(id, &serial_reply("PUNKTFUNK01")); } UHID_SET_REPORT => { - let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]); - // SET_REPORT data: [report-id 0, cmd, …] at ev[12..]. Surface rumble, then ack. - let end = (12 + 16).min(UHID_EVENT_SIZE); - if let Some(r) = parse_steam_output(&ev[12..end]).rumble { + let id = request_id(&ev); + // SET_REPORT data: [report-id 0, cmd, …]. Take exactly the bytes the kernel + // declared — this used to read a fixed 16-byte window, which truncated any + // longer report and, for a shorter one, fed the parser whatever the reused + // event buffer still held past the payload. Every sibling backend that parses + // SET_REPORT already read the size field; this one didn't. + if let Some(r) = parse_steam_output(set_report_data(&ev)).rumble { rumble = Some(r); } let _ = self.reply_set_report(id); diff --git a/crates/pf-inject/src/inject/linux/steam_controller2.rs b/crates/pf-inject/src/inject/linux/steam_controller2.rs index 4dc9a18a..ad9c0f9a 100644 --- a/crates/pf-inject/src/inject/linux/steam_controller2.rs +++ b/crates/pf-inject/src/inject/linux/steam_controller2.rs @@ -23,6 +23,11 @@ use super::triton_proto::{ triton_serial, triton_unit_id, TritonState, TRITON_RDESC, TRITON_STATE_LEN, TRITON_VENDOR, TRITON_WIRED_PRODUCT, }; +use crate::uhid_abi::{ + put_cstr, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2, UHID_DESTROY, UHID_EVENT_SIZE, + UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2, UHID_OUTPUT, UHID_PATH, UHID_SET_REPORT, + UHID_SET_REPORT_REPLY, +}; use crate::uhid_manager::{PadFeedback, PadProto, UhidManager}; use anyhow::{Context, Result}; use punktfunk_core::quic::{HidOutput, RichInput, HID_RAW_FEATURE, HID_RAW_OUTPUT}; @@ -30,25 +35,6 @@ use std::fs::{File, OpenOptions}; use std::io::{Read, Write}; use std::os::unix::fs::OpenOptionsExt; -// /dev/uhid event ABI — same layout as the Deck/DualSense backends. -const UHID_PATH: &str = "/dev/uhid"; -const UHID_DESTROY: u32 = 1; -const UHID_OUTPUT: u32 = 6; -const UHID_GET_REPORT: u32 = 9; -const UHID_GET_REPORT_REPLY: u32 = 10; -const UHID_CREATE2: u32 = 11; -const UHID_INPUT2: u32 = 12; -const UHID_SET_REPORT: u32 = 13; -const UHID_SET_REPORT_REPLY: u32 = 14; -const HID_MAX_DESCRIPTOR_SIZE: usize = 4096; -const UHID_EVENT_SIZE: usize = 4 + 4372; -const BUS_USB: u16 = 0x03; - -fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) { - let n = s.len().min(cap - 1); - ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); -} - /// A virtual Steam Controller 2 backed by `/dev/uhid`. Dropping it destroys the device. pub struct TritonPad { fd: File, diff --git a/crates/pf-inject/src/inject/linux/switch_pro.rs b/crates/pf-inject/src/inject/linux/switch_pro.rs index c4f31f94..c6e5e104 100644 --- a/crates/pf-inject/src/inject/linux/switch_pro.rs +++ b/crates/pf-inject/src/inject/linux/switch_pro.rs @@ -22,6 +22,10 @@ use super::switch_proto::{ serialize_report_0x30, spi_flash_read, switch_mac, SwitchOutput, SwitchState, PROCON_RDESC, SWITCH_PRODUCT, SWITCH_REPORT_LEN, SWITCH_VENDOR, }; +use crate::uhid_abi::{ + put_cstr, BUS_USB, HID_MAX_DESCRIPTOR_SIZE, UHID_CREATE2, UHID_DESTROY, UHID_EVENT_SIZE, + UHID_GET_REPORT, UHID_GET_REPORT_REPLY, UHID_INPUT2, UHID_OUTPUT, UHID_PATH, +}; use crate::uhid_manager::{PadFeedback, PadProto, UhidManager}; use anyhow::{Context, Result}; use punktfunk_core::quic::{HidOutput, RichInput}; @@ -29,24 +33,6 @@ use std::fs::{File, OpenOptions}; use std::io::{Read, Write}; use std::os::unix::fs::OpenOptionsExt; -// /dev/uhid event ABI (linux/uhid.h) — identical to the DualSense backend's; see `super::dualsense`. -const UHID_PATH: &str = "/dev/uhid"; -const UHID_DESTROY: u32 = 1; -const UHID_OUTPUT: u32 = 6; -const UHID_GET_REPORT: u32 = 9; -const UHID_GET_REPORT_REPLY: u32 = 10; -const UHID_CREATE2: u32 = 11; -const UHID_INPUT2: u32 = 12; -const HID_MAX_DESCRIPTOR_SIZE: usize = 4096; -const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2) -const BUS_USB: u16 = 0x03; - -/// Copy a NUL-padded C string field into the event buffer. -fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) { - let n = s.len().min(cap - 1); - ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated) -} - /// A virtual Pro Controller backed by `/dev/uhid`. Dropping it destroys the device (the kernel /// tears down the bound `hid-nintendo` interface). pub struct SwitchProPad { diff --git a/crates/pf-inject/src/inject/linux/uhid_abi.rs b/crates/pf-inject/src/inject/linux/uhid_abi.rs new file mode 100644 index 00000000..2dbd2c12 --- /dev/null +++ b/crates/pf-inject/src/inject/linux/uhid_abi.rs @@ -0,0 +1,143 @@ +//! The `/dev/uhid` event ABI (`linux/uhid.h`), in one place. +//! +//! Every UHID gamepad backend — DualSense, DualShock 4, Switch Pro, Steam Controller and Steam +//! Controller 2 — speaks the same kernel protocol, and each carried its own verbatim copy of these +//! constants plus its own `put_cstr`. Five copies of one kernel ABI is five chances to drift from +//! it, and they already had: `switch_pro` was missing the SET_REPORT pair entirely, and one backend +//! read a fixed-size SET_REPORT payload instead of the length the kernel gave it (see +//! [`set_report_data`]). +//! +//! `struct uhid_event` is `__packed__`: a `u32` `type` followed by a union whose largest member is +//! `uhid_create2_req` (name 128 + phys 64 + uniq 64 + rd_size 2 + bus 2 + 4×u32 + rd_data 4096 = +//! 4372 bytes). Nothing here allocates or parses a whole event — the backends still drive their own +//! read/write loops; this module owns the numbers and the two field accessors that are easy to get +//! subtly wrong. + +/// The character device every backend opens. +pub const UHID_PATH: &str = "/dev/uhid"; + +// Event types (`enum uhid_event_type`). Only the ones the backends actually use. +pub const UHID_DESTROY: u32 = 1; +pub const UHID_OUTPUT: u32 = 6; +pub const UHID_GET_REPORT: u32 = 9; +pub const UHID_GET_REPORT_REPLY: u32 = 10; +pub const UHID_CREATE2: u32 = 11; +pub const UHID_INPUT2: u32 = 12; +pub const UHID_SET_REPORT: u32 = 13; +pub const UHID_SET_REPORT_REPLY: u32 = 14; + +/// `HID_MAX_DESCRIPTOR_SIZE` — also the cap on a report payload we will copy out of an event. +pub const HID_MAX_DESCRIPTOR_SIZE: usize = 4096; +/// `size_of::()`: the `u32` type tag plus the create2 union. +pub const UHID_EVENT_SIZE: usize = 4 + 4372; +/// `BUS_USB` from `linux/input.h`. +pub const BUS_USB: u16 = 0x03; + +/// Offset of the `id` field shared by the GET_REPORT / SET_REPORT request and reply structs. +const OFF_ID: usize = 4; +/// Offset of `uhid_set_report_req::size` (after `id: u32`, `rnum: u8`, `rtype: u8`). +const OFF_SET_REPORT_SIZE: usize = 10; +/// Offset of the payload in a SET_REPORT request — and of `data` in the reply structs. +const OFF_DATA: usize = 12; +/// Offset of `uhid_output_req::size` (the payload follows `data[4096]`). +const OFF_OUTPUT_SIZE: usize = 4 + HID_MAX_DESCRIPTOR_SIZE; + +/// Copy a NUL-padded C string field into the event buffer. The buffer is zeroed by the caller, so +/// truncation still leaves a NUL terminator. +pub fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) { + let n = s.len().min(cap - 1); + ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]); // rest already zero (NUL-terminated) +} + +/// The request id of a GET_REPORT / SET_REPORT event — what the matching reply must echo. +pub fn request_id(ev: &[u8]) -> u32 { + u32::from_ne_bytes([ev[OFF_ID], ev[OFF_ID + 1], ev[OFF_ID + 2], ev[OFF_ID + 3]]) +} + +/// The payload of a `UHID_SET_REPORT` event: exactly the bytes the kernel says are there. +/// +/// Read the length from the event's own `size` field. Assuming a fixed window instead is wrong in +/// both directions — a longer report is silently truncated, and a shorter one is parsed together +/// with whatever stale bytes the reused event buffer still holds past its end, which for a rumble +/// report means acting on numbers the game never wrote. +pub fn set_report_data(ev: &[u8]) -> &[u8] { + let size = u16::from_ne_bytes([ev[OFF_SET_REPORT_SIZE], ev[OFF_SET_REPORT_SIZE + 1]]) as usize; + let end = (OFF_DATA + size.min(HID_MAX_DESCRIPTOR_SIZE)).min(ev.len()); + &ev[OFF_DATA.min(end)..end] +} + +/// The payload of a `UHID_OUTPUT` event (`uhid_output_req`: `data[4096]` then `size`). +pub fn output_data(ev: &[u8]) -> &[u8] { + let size = u16::from_ne_bytes([ev[OFF_OUTPUT_SIZE], ev[OFF_OUTPUT_SIZE + 1]]) as usize; + let end = (4 + size.min(HID_MAX_DESCRIPTOR_SIZE)).min(ev.len()); + &ev[4.min(end)..end] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn blank() -> Vec { + vec![0u8; UHID_EVENT_SIZE] + } + + #[test] + fn set_report_data_honours_the_events_own_size() { + let mut ev = blank(); + ev[OFF_SET_REPORT_SIZE..OFF_SET_REPORT_SIZE + 2].copy_from_slice(&5u16.to_ne_bytes()); + for (i, b) in [1u8, 2, 3, 4, 5].iter().enumerate() { + ev[OFF_DATA + i] = *b; + } + // Stale bytes past the payload — a fixed-window read would hand these to the parser. + ev[OFF_DATA + 5] = 0xAA; + ev[OFF_DATA + 15] = 0xBB; + assert_eq!(set_report_data(&ev), &[1, 2, 3, 4, 5]); + } + + #[test] + fn set_report_data_is_not_truncated_at_sixteen() { + let mut ev = blank(); + let n = 40usize; + ev[OFF_SET_REPORT_SIZE..OFF_SET_REPORT_SIZE + 2].copy_from_slice(&(n as u16).to_ne_bytes()); + for i in 0..n { + ev[OFF_DATA + i] = i as u8; + } + let d = set_report_data(&ev); + assert_eq!( + d.len(), + n, + "a report longer than 16 bytes must survive whole" + ); + assert_eq!(d[39], 39); + } + + #[test] + fn oversized_and_empty_sizes_stay_in_bounds() { + let mut ev = blank(); + ev[OFF_SET_REPORT_SIZE..OFF_SET_REPORT_SIZE + 2].copy_from_slice(&u16::MAX.to_ne_bytes()); + assert!(set_report_data(&ev).len() <= HID_MAX_DESCRIPTOR_SIZE); + assert!(OFF_DATA + set_report_data(&ev).len() <= UHID_EVENT_SIZE); + + let ev0 = blank(); // size = 0 + assert!(set_report_data(&ev0).is_empty()); + assert!(output_data(&ev0).is_empty()); + } + + #[test] + fn output_data_reads_its_trailing_size_field() { + let mut ev = blank(); + ev[OFF_OUTPUT_SIZE..OFF_OUTPUT_SIZE + 2].copy_from_slice(&3u16.to_ne_bytes()); + ev[4] = 0x02; + ev[5] = 0x11; + ev[6] = 0x22; + ev[7] = 0x33; // past the declared size + assert_eq!(output_data(&ev), &[0x02, 0x11, 0x22]); + } + + #[test] + fn request_id_round_trips() { + let mut ev = blank(); + ev[OFF_ID..OFF_ID + 4].copy_from_slice(&0xDEAD_BEEFu32.to_ne_bytes()); + assert_eq!(request_id(&ev), 0xDEAD_BEEF); + } +} diff --git a/crates/pf-inject/src/inject/proto/dualsense_proto.rs b/crates/pf-inject/src/inject/proto/dualsense_proto.rs index 32852914..46582338 100644 --- a/crates/pf-inject/src/inject/proto/dualsense_proto.rs +++ b/crates/pf-inject/src/inject/proto/dualsense_proto.rs @@ -471,7 +471,14 @@ fn pack_touch(dst: &mut [u8], t: &Touch) { #[derive(Default)] pub struct DsFeedback { pub hidout: Vec, - /// `(low, high)` motor levels (0..=0xFFFF), if a report carried them. + /// `(low, high)` motor levels, if a report carried them. + /// + /// This parser widens the device's 8-bit motor bytes by `<< 8`, so the values it produces are + /// `0..=0xFF00` in steps of 0x100 — NOT `0..=0xFFFF`, which is what this said before. The + /// Windows backend widens the same bytes by `× 257` and does reach 0xFFFF. Both are correct: + /// every consumer narrows with `>> 8`, and 0xFF00 and 0xFFFF both narrow back to 255. Do not + /// "fix" one to match the other — see [`crate::uhid_manager::PadFeedback::rumble`], which is + /// the type that sees both. pub rumble: Option<(u16, u16)>, /// The driver's output-report ring overflowed this poll — pending reports were DISCARDED and /// feedback state is unknown; the [`UhidManager`](crate::uhid_manager) must resync (silence + @@ -479,64 +486,101 @@ pub struct DsFeedback { pub resync: bool, } -/// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`]. The byte layout below is -/// the USB DualSense common report; only the well-understood fields (motor rumble, lightbar RGB, -/// player LEDs) are surfaced — adaptive-trigger blocks are forwarded raw for the client. +/// Field offsets in the DualSense **output** report, as indices into a whole USB report — i.e. +/// including the leading report id at `[0]`. This is the one place in Rust the layout is written +/// down; index off these rather than repeating the numbers. +/// +/// **The same fields sit at different offsets per transport, and that is not drift.** Every writer +/// lays out one common block; what changes is how much header precedes it: +/// +/// | base | where | first payload byte | +/// |---|---|---| +/// | `0` | USB report, id included — what these constants describe, and what this parser reads | `[1]` | +/// | `−1` | SDL `DS5EffectsState_t` — a 47-byte payload with NO report id (`pf-client-core`'s `Ds5Feedback`) | `[0]` | +/// | `+2` | Bluetooth report `0x31` — id, sequence, magic, then the block; CRC32 in the last 4 bytes | `[3]` | +/// +/// Subtract or add the base to translate. Mirrors that cannot import this module — Kotlin +/// (`DsDevice.kt`, USB base 0) and Swift (`DualSenseHID.swift`, which handles both the USB and +/// Bluetooth bases) — carry a pointer back here; keep them in step by hand. +pub mod out_report { + /// `valid_flag0`: BIT0 compat vibration, BIT1 haptics select, BIT2 R2, BIT3 L2. + pub const VALID_FLAG0: usize = 1; + /// `valid_flag1`: BIT2 lightbar, BIT4 player indicators. + pub const VALID_FLAG1: usize = 2; + /// High-frequency (small / right) motor. + pub const MOTOR_RIGHT: usize = 3; + /// Low-frequency (big / left) motor. + pub const MOTOR_LEFT: usize = 4; + /// First byte of the RIGHT trigger's parameter block — it precedes the left one in the report. + pub const RIGHT_TRIGGER: usize = 11; + /// First byte of the LEFT trigger's parameter block. + pub const LEFT_TRIGGER: usize = 22; + /// One adaptive-trigger parameter block: a mode byte plus 10 parameters. + pub const TRIGGER_LEN: usize = 11; + /// `valid_flag2`: BIT2 = `COMPATIBLE_VIBRATION2` (the firmware ≥ 2.24 rumble signal). + pub const VALID_FLAG2: usize = 39; + /// Lit player-indicator bits (low 5). + pub const PLAYER_LEDS: usize = 44; + /// Lightbar red; green and blue follow. + pub const LED_RGB: usize = 45; +} + +/// Parse a DualSense USB output report (`0x02`) into a [`DsFeedback`], indexed off +/// [`out_report`]. Only the well-understood fields (motor rumble, lightbar RGB, player LEDs) are +/// surfaced — adaptive-trigger blocks are forwarded raw for the client. /// /// Every field is gated on the report's valid-flags (`valid_flag0` at data[1], `valid_flag1` /// at data[2]) — writers only set the bits for fields they mean to change (the rest is zeroed), /// so an ungated parse would turn every plain rumble write into a lightbar-off + triggers-off /// broadcast. pub fn parse_ds_output(pad: u8, data: &[u8], fb: &mut DsFeedback) { + use out_report as o; // data[0] is the report id (0x02). Be defensive about short reports. if data.first() != Some(&0x02) || data.len() < 48 { return; } - let flag0 = data[1]; // BIT0 compat vibration, BIT1 haptics select, BIT2 R2, BIT3 L2 - let flag1 = data[2]; // BIT2 lightbar, BIT4 player indicators - // Motor rumble: high-frequency (small/right) motor at data[3], low-frequency (big/left) at - // data[4]. Scale 0..255 → 0..0xFFFF, same (low, high) convention as the uinput pad's mixer, - // and route to the universal rumble plane (0xCA). - // Writers on firmware ≥ 2.24 signal rumble via COMPATIBLE_VIBRATION2 in valid_flag2 - // (data[39] BIT2) instead of flag0 BIT0. Our feature report advertises a version - // above 2.24 (DS_FEATURE_FIRMWARE bytes 44..46, chosen to keep Sony's updater - // quiet), so the kernel and SDL write the v2 flag — while older writers, and any - // that never read the version, stay on flag0. Both conventions must land here: a - // rumble dropped on either — including stops — is silently ignored, and a missed - // stop buzzes for the rest of the session (the 500 ms refresh re-sends stale state - // forever). - if flag0 & 0x03 != 0 || data[39] & 0x04 != 0 { - let high = (data[3] as u16) << 8; - let low = (data[4] as u16) << 8; + let flag0 = data[o::VALID_FLAG0]; // BIT0 compat vibration, BIT1 haptics select, BIT2 R2, BIT3 L2 + let flag1 = data[o::VALID_FLAG1]; // BIT2 lightbar, BIT4 player indicators + // Motor rumble: high-frequency (small/right) motor first, low-frequency (big/left) second. + // Widened 0..255 → 0..0xFF00 by `<< 8` (NOT 0xFFFF — see `DsFeedback::rumble`), same + // (low, high) convention as the uinput pad's mixer, and routed to the 0xCA plane. + // Writers on firmware ≥ 2.24 signal rumble via COMPATIBLE_VIBRATION2 in valid_flag2 + // instead of flag0 BIT0. Our feature report advertises a version above 2.24 + // (DS_FEATURE_FIRMWARE bytes 44..46, chosen to keep Sony's updater quiet), so the + // kernel and SDL write the v2 flag — while older writers, and any that never read the + // version, stay on flag0. Both conventions must land here: a rumble dropped on either + // — including stops — is silently ignored, and a missed stop buzzes for the rest of + // the session (the 500 ms refresh re-sends stale state forever). + if flag0 & 0x03 != 0 || data[o::VALID_FLAG2] & 0x04 != 0 { + let high = (data[o::MOTOR_RIGHT] as u16) << 8; + let low = (data[o::MOTOR_LEFT] as u16) << 8; fb.rumble = Some((low, high)); } - // Lightbar RGB (USB common report: bytes 45..48). Player LEDs at byte 44. if flag1 & 0x04 != 0 { - let (r, g, b) = (data[45], data[46], data[47]); + let (r, g, b) = (data[o::LED_RGB], data[o::LED_RGB + 1], data[o::LED_RGB + 2]); fb.hidout.push(HidOutput::Led { pad, r, g, b }); } if flag1 & 0x10 != 0 { fb.hidout.push(HidOutput::PlayerLeds { pad, - bits: data[44] & 0x1F, + bits: data[o::PLAYER_LEDS] & 0x1F, }); } - // Adaptive-trigger parameter blocks, 11 bytes each: the RIGHT trigger comes FIRST in the - // report (bytes 11..22), the left at 22..33 — per SDL's DS5EffectsState_t / inputtino's - // ps5.hpp. Wire convention: which 0 = L2, 1 = R2. - if data.len() >= 33 { + // The RIGHT trigger block comes FIRST in the report — per SDL's DS5EffectsState_t / + // inputtino's ps5.hpp. Wire convention: which 0 = L2, 1 = R2. + if data.len() >= o::LEFT_TRIGGER + o::TRIGGER_LEN { if flag0 & 0x04 != 0 { fb.hidout.push(HidOutput::Trigger { pad, which: 1, - effect: data[11..22].to_vec(), + effect: data[o::RIGHT_TRIGGER..o::RIGHT_TRIGGER + o::TRIGGER_LEN].to_vec(), }); } if flag0 & 0x08 != 0 { fb.hidout.push(HidOutput::Trigger { pad, which: 0, - effect: data[22..33].to_vec(), + effect: data[o::LEFT_TRIGGER..o::LEFT_TRIGGER + o::TRIGGER_LEN].to_vec(), }); } } diff --git a/crates/pf-inject/src/inject/uhid_manager.rs b/crates/pf-inject/src/inject/uhid_manager.rs index 8f91f0a9..c537fdd8 100644 --- a/crates/pf-inject/src/inject/uhid_manager.rs +++ b/crates/pf-inject/src/inject/uhid_manager.rs @@ -18,7 +18,12 @@ use std::time::{Duration, Instant}; /// 0xCD feedback events (lightbar / player LEDs / adaptive triggers), deduped via [`HidoutDedup`]. #[derive(Default)] pub struct PadFeedback { - /// `(low, high)` motor levels (0..=0xFF00), if the pass saw a rumble report. + /// `(low, high)` motor levels, if the pass saw a rumble report. + /// + /// Range is `0..=0xFFFF` — this said `0..=0xFF00`, which is only true of the backends that + /// widen the device's 8-bit motor byte by `<< 8` (the UHID/DualSense path). The Windows + /// backend widens by `× 257` and does reach 0xFFFF, and this type carries both. Neither is a + /// defect: consumers narrow with `>> 8`, and 0xFF00 and 0xFFFF both narrow back to 255. pub rumble: Option<(u16, u16)>, pub hidout: Vec, /// Whether the game drove this pad's RUMBLE plane this poll — at least one output report diff --git a/crates/pf-inject/src/lib.rs b/crates/pf-inject/src/lib.rs index 3dde4586..ed946eb1 100644 --- a/crates/pf-inject/src/lib.rs +++ b/crates/pf-inject/src/lib.rs @@ -457,6 +457,11 @@ pub mod triton_proto; #[cfg(target_os = "linux")] #[path = "inject/linux/triton_usbip.rs"] pub mod triton_usbip; +/// Linux: the `/dev/uhid` event ABI shared by every UHID gamepad backend — the constants each +/// used to transcribe for itself, plus the field accessors that read a payload's real length. +#[cfg(target_os = "linux")] +#[path = "inject/linux/uhid_abi.rs"] +pub mod uhid_abi; /// The generic stateful virtual-pad manager ([`uhid_manager::UhidManager`]) — event routing, frame /// merge, heartbeat, and feedback pump shared by the five UHID/UMDF backends; each supplies only /// its per-controller protocol via [`uhid_manager::PadProto`] (G12). diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index b2db9524..5a6653b7 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -698,7 +698,10 @@ pub struct PunktfunkHidOutput { /// Trigger: number of valid bytes in `effect` (≤ `PUNKTFUNK_HID_EFFECT_MAX`). pub effect_len: u8, /// Trigger: the raw DualSense trigger parameter block (mode + params). - pub effect: [u8; 11], + /// Sized off [`PUNKTFUNK_HID_EFFECT_MAX`] rather than a second literal `11` — the constant is + /// exported precisely so embedders can size their own buffers against it, and it declaring one + /// number while the struct it describes hardcoded another was the whole hazard. + pub effect: [u8; PUNKTFUNK_HID_EFFECT_MAX as usize], } #[cfg(feature = "quic")] @@ -2497,10 +2500,12 @@ pub unsafe extern "C" fn punktfunk_connection_next_rumble_cmd( /// Declare a physical actuator's quirks for wire pad `pad` — how a platform parameterizes the /// shared rumble policy engine instead of forking it (typically called at controller attach). /// `keepalive_ms`: re-emit an unchanged non-zero level at this cadence for actuators whose -/// hardware output decays between wire renewals (Steam Deck ≈ 40, DualSense-over-BT raw HID -/// ≈ 900); `0` = none. `min_pulse_ms`: floor for `backstop_ms` on non-zero commands. `flags`: +/// hardware output decays between wire renewals (the Steam Deck's ≈ 40 is the one in-tree user); +/// `0` = none. `min_pulse_ms`: floor for `backstop_ms` on non-zero commands — no in-tree caller +/// sets it, it exists for embedders whose duration-taking API rejects short values. `flags`: /// [`PUNKTFUNK_RUMBLE_QUIRK_DEDUP_JITTER`]. All-zero (the initial state) describes a well-behaved -/// actuator. +/// actuator. See [`ActuatorQuirks`](crate::client::rumble::ActuatorQuirks) for why a renderer that +/// dedupes its own writes (the Apple HID path) cannot use `keepalive_ms` and keeps its own. /// /// # Safety /// `c` is a valid connection handle. Callable from any thread. diff --git a/crates/punktfunk-core/src/client/rumble.rs b/crates/punktfunk-core/src/client/rumble.rs index e3f024d9..452d40a8 100644 --- a/crates/punktfunk-core/src/client/rumble.rs +++ b/crates/punktfunk-core/src/client/rumble.rs @@ -53,10 +53,25 @@ pub struct RumbleCommand { #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct ActuatorQuirks { /// Re-emit an unchanged non-zero level every this many ms — for actuators whose hardware - /// output decays between wire renewals (Steam Deck ≈ 40, macOS DualSense-over-HID BT ≈ 900). - /// `0` = no keepalive (the common case). + /// output decays between wire renewals. `0` = no keepalive (the common case). + /// + /// The one in-tree producer is the Steam Deck's ≈ 40 ms (`pf-client-core`'s slot open, paired + /// with `dedup_jitter`). The macOS DualSense-over-HID Bluetooth decay is NOT served by this + /// quirk, though it reads like the obvious second example: the Apple client keeps its own + /// ≈ 900 ms keepalive down in `RumbleRenderer` (`RumbleTuning.hidKeepaliveSeconds`) because + /// the re-emit has to happen BELOW the command layer. An engine keepalive arrives as a + /// command carrying the same levels, and that renderer skips a HID write whose levels are + /// unchanged — so the re-emit would be swallowed by the very dedupe it exists to defeat + /// (`dedup_jitter` is the Deck's answer to the same problem one layer up). pub keepalive_ms: u16, - /// Floor for `backstop_ms` on non-zero commands (Android's `createOneShot` throws on 0). + /// Floor for `backstop_ms` on non-zero commands. + /// + /// **No in-tree producer sets this non-zero** — it is reachable only through the C ABI + /// (`punktfunk_connection_set_rumble_quirks`), for embedders whose duration-taking API + /// rejects short values. The case it was written for is handled elsewhere: Android's + /// `createOneShot` does throw on a non-positive duration, but the Kotlin renderer floors the + /// duration itself at the call, and that path never declares quirks at all. Kept because it + /// is exported ABI, and because a floor belongs here rather than re-invented per embedder. pub min_pulse_ms: u16, /// Alternate the low motor's LSB on keepalive re-emits (imperceptible) so an SDL-class layer /// that no-ops identical values still writes the device — the Deck's dedupe-defeat. diff --git a/crates/punktfunk-core/src/quic/datagram.rs b/crates/punktfunk-core/src/quic/datagram.rs index 0567f241..42368596 100644 --- a/crates/punktfunk-core/src/quic/datagram.rs +++ b/crates/punktfunk-core/src/quic/datagram.rs @@ -431,6 +431,14 @@ pub enum HidOutput { /// A trackpad haptic pulse for a Steam Controller's voice-coil actuators (its only "rumble"). /// `side` 0 = right pad, 1 = left pad; `amplitude` + `period` (µs off-time) + `count` (pulses) /// synthesize a buzz. A client without trackpad coils drops it (or maps it to ordinary rumble). + /// + /// **STAGED SCAFFOLDING — deliberately unreachable today, do not delete.** Nothing on the host + /// produces this variant and no client renders it; it codes/decodes and round-trips in tests + /// and nothing else. It stays because `HIDOUT_TRACKPAD_HAPTIC` is an allocated tag on a + /// SHIPPED wire: removing the variant would not reclaim the tag (a future peer could still + /// send it), it would only lose the decoder that keeps such a datagram from being mistaken + /// for something else. The producer is the Steam Controller coil path; the renderer is the + /// client-side coil write. Wire up either half and this becomes live with no format change. TrackpadHaptic { pad: u8, side: u8, diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 62d53b32..59134bce 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -1649,7 +1649,10 @@ typedef struct { // Trigger: number of valid bytes in `effect` (≤ `PUNKTFUNK_HID_EFFECT_MAX`). uint8_t effect_len; // Trigger: the raw DualSense trigger parameter block (mode + params). - uint8_t effect[11]; + // Sized off [`PUNKTFUNK_HID_EFFECT_MAX`] rather than a second literal `11` — the constant is + // exported precisely so embedders can size their own buffers against it, and it declaring one + // number while the struct it describes hardcoded another was the whole hazard. + uint8_t effect[PUNKTFUNK_HID_EFFECT_MAX]; } PunktfunkHidOutput; #endif @@ -2445,10 +2448,12 @@ PunktfunkStatus punktfunk_connection_next_rumble_cmd(PunktfunkConnection *c, // Declare a physical actuator's quirks for wire pad `pad` — how a platform parameterizes the // shared rumble policy engine instead of forking it (typically called at controller attach). // `keepalive_ms`: re-emit an unchanged non-zero level at this cadence for actuators whose -// hardware output decays between wire renewals (Steam Deck ≈ 40, DualSense-over-BT raw HID -// ≈ 900); `0` = none. `min_pulse_ms`: floor for `backstop_ms` on non-zero commands. `flags`: +// hardware output decays between wire renewals (the Steam Deck's ≈ 40 is the one in-tree user); +// `0` = none. `min_pulse_ms`: floor for `backstop_ms` on non-zero commands — no in-tree caller +// sets it, it exists for embedders whose duration-taking API rejects short values. `flags`: // [`PUNKTFUNK_RUMBLE_QUIRK_DEDUP_JITTER`]. All-zero (the initial state) describes a well-behaved -// actuator. +// actuator. See [`ActuatorQuirks`](crate::client::rumble::ActuatorQuirks) for why a renderer that +// dedupes its own writes (the Apple HID path) cannot use `keepalive_ms` and keeps its own. // // # Safety // `c` is a valid connection handle. Callable from any thread.