The host cursor stops riding the video and becomes a real OS cursor on the client (the Parsec/RDP model): pointer feel no longer pays the capture→encode→network→decode→present round trip. Wire (M2a): - Hello grows a client_caps trailing byte (CLIENT_CAP_CURSOR) after the fixed display_hdr block — presence disambiguated by remaining length, which caps the post-HDR tail at 27 bytes (documented); Welcome answers HOST_CAP_CURSOR (capable-and-asked, the 444/clipboard precedent). - CursorShape (0x50, control stream): serial + dims + hotspot + straight RGBA, ≤120px/side so the u16 frame always fits (128² would overshoot); client caches by serial — re-showing a known shape costs 14 bytes, not a bitmap (RDP pointer-cache for free). - CursorState (0xD0 datagram): serial + visible/relative_hint flags + position, sent once per encode-loop tick — latest-wins, self-healing under loss, no refresh timer. relative_hint is reserved for M3. - Client core: two new planes (control-task + datagram-task arms) → next_cursor_shape/next_cursor_state; connect() grows client_caps (C ABI passes 0 until the v11 cursor poll fns exist). Host (M2b, Linux portal only): - handshake::cursor_forward is THE predicate (client asked ∧ Linux ∧ compositor ≠ gamescope) — Welcome bit and session wiring both read it. - SessionPlan.cursor_blend goes false for a forwarding session; the encode loop ticks a CursorForwarder every iteration: shape-serial diff → control-task bridge (mirrors probe_result), state datagram → conn. - CursorOverlay/capture CursorState carry the hotspot through (nearest-neighbor downscale backstop for XL cursors, unit-tested). Presenter: - CursorChannel drains both planes per loop iteration; shapes become SDL color cursors (from_surface + hotspot), applied while the desktop mouse model is engaged; visibility follows the host; capture/released hands back the system cursor. Sessions advertise the cap when they START in desktop mode. Verified on .21: fmt + clippy -D warnings (7 crates) + tests green (core 218 incl. new wire roundtrips, host 245 incl. e2e + forwarder downscale tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
81 lines
3.2 KiB
Rust
81 lines
3.2 KiB
Rust
//! Network speed-test probe — the GUI's per-host "Test Network Speed…" ([`crate::app`]'s
|
|
//! speed page) and the `--headless --speed-test` CLI.
|
|
//!
|
|
//! Split out of the former in-process session module: the shared spawned-`punktfunk-session`
|
|
//! binary owns real streaming now, but the speed test is a shell-side, decode-less measurement
|
|
//! over the real data plane, so it stays here. [`decodable_codecs`] rode along for the same
|
|
//! reason — the probe connect still advertises which codecs this client can decode.
|
|
|
|
use ffmpeg_next as ffmpeg;
|
|
use punktfunk_core::client::NativeClient;
|
|
use punktfunk_core::config::{CompositorPref, GamepadPref, Mode};
|
|
use std::time::{Duration, Instant};
|
|
|
|
/// The `quic` codec bitfield this client can decode — whatever FFmpeg has a decoder for (HEVC/H.264
|
|
/// always; AV1 when built in). Advertised to the host so it never emits a codec we can't decode.
|
|
pub fn decodable_codecs() -> u8 {
|
|
let _ = ffmpeg::init();
|
|
let mut bits = 0u8;
|
|
for (id, bit) in [
|
|
(ffmpeg::codec::Id::HEVC, punktfunk_core::quic::CODEC_HEVC),
|
|
(ffmpeg::codec::Id::H264, punktfunk_core::quic::CODEC_H264),
|
|
(ffmpeg::codec::Id::AV1, punktfunk_core::quic::CODEC_AV1),
|
|
] {
|
|
if ffmpeg::decoder::find(id).is_some() {
|
|
bits |= bit;
|
|
}
|
|
}
|
|
bits
|
|
}
|
|
|
|
/// Blocking speed-test probe (the GUI's per-host "Test" and the `--headless --speed-test` CLI):
|
|
/// a minimal identified connect (720p60 — the host builds a virtual output, but nothing is
|
|
/// decoded), then `request_probe` (a 2 s burst up to the host's 3 Gbps ceiling) polled to
|
|
/// completion. Run on a worker thread.
|
|
pub fn run_speed_probe(
|
|
addr: &str,
|
|
port: u16,
|
|
fp_hex: Option<&str>,
|
|
identity: (String, String),
|
|
) -> Result<punktfunk_core::client::ProbeOutcome, String> {
|
|
// Pin the saved/advertised fingerprint when we have one; a manual host measures over TOFU.
|
|
let pin = fp_hex.and_then(crate::trust::parse_hex32);
|
|
let c = NativeClient::connect(
|
|
addr,
|
|
port,
|
|
Mode {
|
|
width: 1280,
|
|
height: 720,
|
|
refresh_hz: 60,
|
|
},
|
|
CompositorPref::Auto,
|
|
GamepadPref::Auto,
|
|
0, // bitrate_kbps: host default
|
|
0, // video_caps: probe connect, nothing is decoded
|
|
2, // audio_channels: stereo baseline
|
|
decodable_codecs(),
|
|
0, // preferred_codec: no preference
|
|
None, // display_hdr: probe connect, nothing presents
|
|
0, // client_caps: probe connect, nothing renders a cursor
|
|
None, // launch: no game
|
|
pin,
|
|
Some(identity),
|
|
Duration::from_secs(15),
|
|
)
|
|
.map_err(|e| format!("connect: {e:?}"))?;
|
|
c.request_probe(3_000_000, 2_000)
|
|
.map_err(|e| format!("probe: {e:?}"))?;
|
|
let deadline = Instant::now() + Duration::from_secs(10);
|
|
loop {
|
|
std::thread::sleep(Duration::from_millis(250));
|
|
if c.probe_result().done {
|
|
// Let the last UDP shards land before tearing down.
|
|
std::thread::sleep(Duration::from_millis(400));
|
|
return Ok(c.probe_result());
|
|
}
|
|
if Instant::now() > deadline {
|
|
return Err("probe timed out".to_string());
|
|
}
|
|
}
|
|
}
|